diff --git a/.claude/settings.json b/.claude/settings.json index e7e94052..fb3b322e 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -21,7 +21,9 @@ "Bash(tail:*)", "Bash(tree:*)", "Bash(wc:*)", - "Bash(which:*)" + "Bash(which:*)", + "WebFetch(domain:www.fastly.com)", + "WebFetch(domain:raw.githubusercontent.com)" ] }, "enabledPlugins": { diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8204ec07..b6a38665 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,6 +58,42 @@ jobs: - name: Nested AppConfig audit run: cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates + # Enforce spec §"Adapter manifests are gitignored" + §"Migration for + # downstream projects". All five generated adapter manifests + # (`axum.toml`, `wrangler.toml`, `fastly.toml`, `spin.toml`, + # `runtime-config.toml`), Cloudflare's `.dev.vars`, AND the + # provision-written secret-bearing env-line files (Spin's + # `/.env`, Axum's `.edgezero/.env`) MUST NOT be tracked -- + # teammates regenerate them locally via `provision --local`. + - name: Enforce adapter manifests and secret env files are not tracked + run: | + if git ls-files | grep -E '(^|/)(axum|fastly|spin|wrangler|runtime-config)\.toml$|(^|/)\.dev\.vars$|(^|/)\.env$'; then + echo "::error::These adapter manifests AND secret-bearing env files (.dev.vars / .env) must be gitignored (spec §'Adapter manifests are gitignored' + §'Migration for downstream projects'). They carry operator secret values and must NEVER be committed." + exit 1 + fi + + # The step above only proves these files are not CURRENTLY tracked. + # Also prove they are actually IGNORED, so a `git add` can't slip a + # regenerated manifest (or a secret-bearing env file) into a commit. + # `git check-ignore` evaluates the ignore rules against the pathname + # whether or not the file exists. + - name: Enforce adapter manifests and secret env files are gitignored + run: | + for probe in \ + crates/demo/axum.toml \ + crates/demo/wrangler.toml \ + crates/demo/fastly.toml \ + crates/demo/spin.toml \ + crates/demo/runtime-config.toml \ + crates/demo/.dev.vars \ + crates/demo/.env \ + .edgezero/.env; do + if ! git check-ignore -q "$probe"; then + echo "::error::'$probe' is NOT gitignored. Add the pattern to .gitignore so provision-generated adapter state and secret-bearing env files can never be committed by accident." + exit 1 + fi + done + # The checker's own unit tests live behind `required-features = # ["nested-app-config-check"]`, so the unfeatured # `cargo test --workspace` step below does not compile or run them. @@ -65,6 +101,17 @@ jobs: - name: Nested AppConfig checker tests run: cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config + # Lint the smoke/helper shell scripts. `-x` follows `source`d libs; + # `--severity=warning` gates on warnings and errors (SC1091/SC2086-style + # info notes stay advisory). ubuntu-latest ships shellcheck. + - name: ShellCheck smoke scripts + run: shellcheck -x --severity=warning scripts/*.sh scripts/lib/*.sh + + # Run the backup/restore helper's own unit tests -- fail-closed backup, + # atomic restore, symlink refusal, ownership-guarded server teardown. + - name: Smoke backup helper unit tests + run: bash scripts/lib/smoke_backup_test.sh + - name: Run workspace tests run: cargo test --workspace --all-targets diff --git a/.gitignore b/.gitignore index beba4261..0fe0d3f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ -# node +# --- Node --- node_modules/ -# compiled output +# --- Cargo / Rust --- bin/ # `bin/` above is overly broad — it also blocks Cargo's `src/bin/` # directories, which are LEGITIMATE source (one file per binary @@ -10,10 +10,32 @@ bin/ !**/src/bin/** pkg/ target/ -.wrangler/ -.spin/ +*.rlib + +# --- EdgeZero core (shared across adapters) --- +# Runtime dir the CLI creates for adapter-local state: +# - `.edgezero/.env` — Axum's env-line file +# - `.edgezero/local-config-.json` — Axum's local config store +# - `.edgezero/provision.lock` — cross-process advisory lock +# (`provision` + `config push`) +# The whole `.edgezero/` directory is per-machine and never committed. .edgezero/ +# --- Cloudflare adapter --- +# `wrangler.toml` is regenerated by `provision --local`; teammates +# must not commit each other's namespace ids / platform binding +# names. `.dev.vars` holds operator-filled runtime secrets and +# `.wrangler/` is `wrangler dev`'s local state directory. +wrangler.toml +.dev.vars +.wrangler/ + +# --- Fastly adapter --- +# `fastly.toml` is regenerated by `provision --local`. It carries +# the compute manifest + `[local_server]` Viceroy state; per-machine +# secret entries and setup blocks must not be shared via git. +fastly.toml + # Fastly local config-push lock and staging temp files (sidecars next to the # manifest). `examples/app-demo` has a `fastly.toml`, so `config push --local` # there leaves these behind; a SIGKILL/panic mid-push can also strand a temp. @@ -21,16 +43,35 @@ target/ .*.edgezero-lock .*.edgezero-*.tmp -# env +# --- Spin adapter --- +# `spin.toml` (component / variables) + `runtime-config.toml` (KV +# backend paths) are regenerated by `provision --local`. +# `.spin/` is Spin's local state directory (SQLite KV, log tail). +spin.toml +runtime-config.toml +.spin/ + +# --- Axum adapter --- +# `axum.toml` is regenerated by `provision --local` (host / port / +# crate name defaults; operator edits are preserved because +# provision's merge path never re-writes the file after the first +# synthesis). +axum.toml + +# --- Shared per-crate `.env` --- +# Spin's `/.env` and any future adapter's crate-local +# `.env` land here. `.edgezero/.env` also matches via the top-level +# `.env` pattern; both are provision-owned + operator-filled and +# must never be committed. .env -# OS +# --- OS --- .DS_Store -# Worktrees +# --- Worktrees --- .worktrees/ -# Editors +# --- Editors --- .claude/* !.claude/settings.json !.claude/commands/ @@ -42,4 +83,3 @@ target/ !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json -*.rlib diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..acdf26c4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,109 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Breaking changes + +- **`edgezero-adapter::Adapter::provision` trait method changed shape.** Was + `fn provision(&self, root, adapter_manifest, component, stores, dry_run) -> Result, String>` + with an `Ok(Vec::new())` default. Is now + `fn provision(&self, root, adapter_manifest, component, stores, deployed: Option<&AdapterDeployedState>, mode: ProvisionMode, dry_run) -> Result` + with **no default** — every `impl Adapter` must supply it. Any + out-of-tree adapter written against the previous shape will fail to + compile with two errors: the method's arity and its return type. To + migrate: + 1. Add a `mode: ProvisionMode` match arm and a `deployed: Option<&AdapterDeployedState>` parameter. + 2. Return `ProvisionOutcome::from_status_lines(lines)` (or + `::with_deployed(lines, deployed_state)` when the cloud arm has + an id to write back) instead of `Ok(vec![...])`. + 3. Add a fall-through arm `other => Err(...)` on the `match mode` — + `ProvisionMode` is `#[non_exhaustive]` and may gain variants. + +- **`edgezero-adapter::AdapterDeployedState`, `ProvisionOutcome`, + and `ProvisionMode` are now `#[non_exhaustive]`.** Struct-literal + construction from a downstream crate (e.g. + `ProvisionOutcome { status_lines, deployed }`) no longer compiles. + Use the new constructors: + - `ProvisionOutcome::from_status_lines(status_lines)` for local mode + (which returns `deployed: None`). + - `ProvisionOutcome::with_deployed(status_lines, deployed_state)` + for cloud mode that populates the writeback. + - `AdapterDeployedState::default()` + `.fields.insert(...)` / + `.sub_tables.insert(...)` for the deployed state. + +### Added + +- **`edgezero provision` / `edgezero config push` cross-process advisory + lock** (`.edgezero/provision.lock` under the manifest's project root). + Serialises concurrent invocations against the same tree so + read-modify-write on `.env` / `.dev.vars` / `edgezero.toml` no + longer silently drops a competing writer's edits. Dry-run skips + the lock. Auto-released on process exit; the sentinel file itself + is git-ignored per-machine and safe to delete when no invocation + is running. + +### Fixed + +- Fastly `service_id` no longer lands under `[local_server]` on + re-provision; the merged `fastly.toml` correctly carries it at the + TOML root so `fastly compute deploy` picks it up. +- Fastly cloud `provision` no longer auto-captures `service_id` from + the gitignored, per-machine `fastly.toml` into tracked + `[adapters.fastly.deployed]`. Per the v1 contract that writeback + stays a documented one-time manual copy after `fastly compute deploy`; + auto-capturing it let a stale local file silently overwrite the + team's committed service id. +- Cloudflare `.dev.vars` commented `__KEY` placeholder now uses + `_staging` (was `-key>`). +- Cross-adapter `path_mutation_guard` unification (`edgezero-cli` + test binary): scaffold + push-shim tests share the same mutex, no + more intermittent CI flakes from PATH-restore races. +- Cloud `config push` now honours the spec's path-containment MUST + (absolute path + `..` traversal rejection). The strict-local + "manifest inside adapter crate" check stays `--local`-gated so + existing cloud fixtures with root-level manifest paths keep + working. +- Provision `.dev.vars` / `.env` written 0600 on Unix so operator- + filled secret values are not world-readable. +- Provision line-oriented files reject values containing `\n` or + `\r` — a malicious env override can no longer split into a second + `KEY=VALUE` line and inject an unintended env-var. +- Dry-run report emits a **unified diff with 2-line context radius** + instead of the full pre-image; `.dev.vars` / `.env` operator + values no longer stream into CI logs. +- Adapter error paths inside `run_local_dry_run` sanitise raw + `/var/folders/.../edgezero-staging-*` tempdir paths back to the + project-relative form before surfacing. + +### Test coverage + +- End-to-end env-overlay: `EDGEZERO__STORES______NAME` + now has an integration test that drives it from process env + through `EnvConfig::store_name()` into the emitted `.edgezero/.env`. +- Case-insensitive adapter arg: `--adapter AXUM` against + `[adapters.axum]` lowercase now covered (previously only the + reverse direction was locked). +- Cloudflare `wrangler.toml` schema header preserved at line 1 after + provision merge into an operator-authored doc. +- `*.toml.hbs` scaffold templates walker asserts no `KEY = ""` + placeholder leaks past `write_baseline_to_disk`. +- Fastly root-scalar assertions (`service_id`, `[[local_server.kv_stores.sessions]]` + stub row) now reparse-then-index instead of substring-match, so + the shipped `service_id`-under-`[local_server]` bug's regression + class is locked. + +### Deprecated / renamed + +- Three tests named `provision_local_push_after_provision_preserves_*` + were renamed to `provision_typed_local_re_run_preserves_*` — + their bodies never invoked `push_config_entries` and the prior + name misled readers looking for real push→provision coverage. + Real push→provision coverage now exists as + `push_after_provision_preserves_*` in each adapter (Axum/Cloudflare/ + Fastly/Spin): each seeds an operator secret into the provision-written + file, runs `config push --local`, and asserts that file is untouched. diff --git a/Cargo.lock b/Cargo.lock index 5d2c6c15..e2c04baa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -672,6 +672,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "toml", + "toml_edit", "tower", "tracing", "walkdir", @@ -772,6 +773,7 @@ dependencies = [ "edgezero-adapter-fastly", "edgezero-adapter-spin", "edgezero-core", + "fs4", "futures", "handlebars", "log", @@ -784,6 +786,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "toml", + "toml_edit", "validator", "walkdir", ] @@ -1004,6 +1007,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -3373,6 +3386,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 48519d07..b1cf5797 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,12 @@ edgezero-cli = { path = "crates/edgezero-cli", default-features = false } fastly = "0.12" fern = "0.7" flate2 = { version = "1", features = ["rust_backend"] } +# Cross-platform advisory file locks (flock on Unix, LockFileEx on +# Windows). Used by edgezero-cli to serialise concurrent `provision` +# / `config push` invocations against the same project tree so +# read-modify-write on `.env` / `.dev.vars` / `edgezero.toml` +# doesn't silently drop a competing writer's changes. +fs4 = { version = "0.13", default-features = false, features = ["sync"] } futures = { version = "0.3", features = ["std", "executor"] } futures-util = { version = "0.3", features = ["alloc", "io"] } handlebars = "6" @@ -174,5 +180,25 @@ exhaustive_enums = "allow" std_instead_of_alloc = "allow" std_instead_of_core = "allow" +# --- Layout / ordering restrictions that fight standard Rust conventions --- +# +# `mod_module_files` (deny) requires `cli.rs` sibling to `cli/` (2018 +# layout). We use the `cli/mod.rs` form because it groups the module + its +# submodules under one directory — more grep-friendly and matches the +# codebase's four adapter crates. `self_named_module_files` (the opposite +# lint) would fire the other way. This workspace picks the `mod.rs` form +# and allows the lint globally with one line here rather than sprinkling +# a `#![expect]` in every adapter's `cli/mod.rs`. +mod_module_files = "allow" +# +# `arbitrary_source_item_ordering` (deny) enforces a strict order among 5 +# item kinds (module/struct/enum/trait/impl) and its config API can't be +# tuned for the common patterns this workspace uses: cfg-gated `use` in +# the middle of a file for test-only helpers, the "declare struct then +# immediately impl it" convention, and interleaved trait impls between +# module declarations and statics. Reordering the ~15k lines across four +# adapter crates to satisfy the lint would be pure churn. +arbitrary_source_item_ordering = "allow" + [workspace.lints.rust] unsafe_code = "deny" \ No newline at end of file diff --git a/crates/edgezero-adapter-axum/Cargo.toml b/crates/edgezero-adapter-axum/Cargo.toml index 9979e39a..a16af589 100644 --- a/crates/edgezero-adapter-axum/Cargo.toml +++ b/crates/edgezero-adapter-axum/Cargo.toml @@ -25,6 +25,7 @@ cli = [ "edgezero-adapter/cli", "dep:ctor", "dep:toml", + "dep:toml_edit", "dep:walkdir", ] @@ -47,6 +48,7 @@ reqwest = { workspace = true, optional = true } serde_json = { workspace = true } simple_logger = { workspace = true } thiserror = { workspace = true } +toml_edit = { workspace = true, optional = true } tokio = { workspace = true, optional = true } toml = { workspace = true, optional = true } tower = { workspace = true, optional = true } diff --git a/crates/edgezero-adapter-axum/src/cli/mod.rs b/crates/edgezero-adapter-axum/src/cli/mod.rs new file mode 100644 index 00000000..cd9f3450 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/cli/mod.rs @@ -0,0 +1,1449 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use ctor::ctor; +use edgezero_adapter::cli_support; +use edgezero_adapter::env_file::{ + EDGEZERO_PROVISION_HEADER, append_lines_dedup_with_header, reject_symlink_components, + reject_symlinked_target, +}; +use edgezero_adapter::registry::{ + Adapter, AdapterAction, AdapterDeployedState, AdapterExecContext, AdapterPushContext, + ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, + TypedSecretEntry, register_adapter, +}; +use edgezero_adapter::scaffold::{ + AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, + ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, +}; + +mod provision_local; +mod run; + +// `axum.toml` is intentionally absent from the scaffold registration. +// It is written by the scaffold-time provision loop that runs +// immediately after file emission (see `generator.rs` +// `provision_all_selected_adapters` -> `Adapter::synthesise_baseline_manifest` +// -> `run::synthesise_axum_toml`). Registering a scaffold template +// here would cause `axum.toml` to be created before provision runs; +// provision's `write_baseline_to_disk` skips files that already +// exist (spec § "Adapter manifests are gitignored"), so the two +// baselines would diverge — the scaffold template would win at +// `edgezero new`, but the synthesiser would win on a clean clone. +// Keep this single-source: only the synthesiser writes `axum.toml`. +static AXUM_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ + TemplateRegistration { + name: "axum_Cargo_toml", + contents: include_str!("../templates/Cargo.toml.hbs"), + }, + TemplateRegistration { + name: "axum_src_main_rs", + contents: include_str!("../templates/src/main.rs.hbs"), + }, +]; + +static AXUM_FILE_SPECS: &[AdapterFileSpec] = &[ + AdapterFileSpec { + template: "axum_Cargo_toml", + output: "Cargo.toml", + }, + AdapterFileSpec { + template: "axum_src_main_rs", + output: "src/main.rs", + }, +]; + +static AXUM_DEPENDENCIES: &[DependencySpec] = &[ + DependencySpec { + key: "dep_edgezero_core_axum", + repo_crate: "crates/edgezero-core", + fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\" }", + features: &[], + }, + DependencySpec { + key: "dep_edgezero_adapter_axum", + repo_crate: "crates/edgezero-adapter-axum", + fallback: "edgezero-adapter-axum = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-axum\", default-features = false }", + features: &["axum"], + }, +]; + +static AXUM_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { + id: "axum", + display_name: "Axum", + crate_suffix: "adapter-axum", + dependency_crate: "edgezero-adapter-axum", + dependency_repo_path: "crates/edgezero-adapter-axum", + template_registrations: AXUM_TEMPLATE_REGISTRATIONS, + files: AXUM_FILE_SPECS, + extra_dirs: &["src"], + dependencies: AXUM_DEPENDENCIES, + manifest: ManifestSpec { + manifest_filename: "axum.toml", + build_target: "native", + build_profile: "dev", + build_features: &[], + }, + commands: CommandTemplates { + // Omit the `[adapters.axum.commands]` shell block: a shell override + // would take precedence over the registry dispatch and run cargo by + // package name, bypassing `axum.toml` (its `crate_dir` in + // particular). With no block, `build`/`serve` route through the + // axum.toml-aware registry adapter. These strings are unused while + // `emit_commands` is false; kept for parity with the other + // blueprints. + build: "cargo build -p {crate}", + serve: "cargo run -p {crate}", + deploy: "# configure deployment for Axum", + emit_commands: false, + }, + logging: LoggingDefaults { + endpoint: None, + level: "info", + echo_stdout: Some(true), + }, + readme: ReadmeInfo { + description: "{display} adapter entrypoint.", + dev_heading: "{display} (local)", + dev_steps: &[ + "`cd {crate_dir}`", + "`cargo run` or `edgezero serve --adapter axum`", + ], + }, + run_module: "edgezero_adapter_axum", +}; + +static AXUM_ADAPTER: AxumCliAdapter = AxumCliAdapter; + +struct AxumCliAdapter; + +impl Adapter for AxumCliAdapter { + fn execute( + &self, + action: AdapterAction, + args: &[String], + ctx: &AdapterExecContext<'_>, + ) -> Result<(), String> { + match action { + // The axum adapter is the in-process native dev server — + // there is no remote auth provider to sign in/out of. + // Per spec this is an explicit no-op. + AdapterAction::AuthLogin | AdapterAction::AuthLogout | AdapterAction::AuthStatus => { + log::info!( + "[edgezero] axum has no remote auth surface; `auth` is a no-op for this adapter" + ); + Ok(()) + } + AdapterAction::Build => run::build(args, ctx), + AdapterAction::Deploy => run::deploy(args), + AdapterAction::Serve => run::serve(args, ctx), + other => Err(format!("axum adapter does not support {other:?}")), + } + } + + fn name(&self) -> &'static str { + "axum" + } + + // Axum has no cloud identifiers to persist across provisions. + #[inline] + fn deployed_fields(&self) -> &'static [&'static str] { + &[] + } + + // Axum's KV / config / secrets each live in their own file — no + // logical-id merging across store kinds. + #[inline] + fn merged_id_kinds(&self) -> &'static [&'static str] { + &[] + } + + // Axum has no per-platform adapter manifest to validate — axum.toml + // is the runtime's own file, checked at load time by the axum + // adapter, not by the CLI. No-op mirrors the trait default. + #[inline] + fn validate_adapter_manifest( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _allow_component_refresh: bool, + ) -> Result<(), String> { + Ok(()) + } + + // Axum has no adapter-specific key naming constraint on + // app-config keys. Trait default no-op. + #[inline] + fn validate_app_config_keys(&self, _keys: &[&str]) -> Result<(), String> { + Ok(()) + } + + /// Axum writes each typed secret as a bare `=` line into + /// `.edgezero/.env`. A key that cannot round-trip through that format + /// would emit a line meaning something else: `partner=token` becomes + /// `partner=token=`, which every `.env` reader parses back as the key + /// `partner` with the value `token=`. Reject those keys here rather + /// than writing an unrepresentable file. + #[inline] + fn validate_typed_secrets(&self, entries: &[TypedSecretEntry<'_>]) -> Result<(), String> { + // The ENTIRE `EDGEZERO__` namespace is reserved for runtime config: + // provision writes generated `EDGEZERO__STORES__...` store overlays + // into the same `.edgezero/.env` typed secrets are appended to, and + // the runtime also reads `EDGEZERO__ADAPTER__*` / `EDGEZERO__LOGGING__*` + // / etc. from that env. A secret key anywhere under `EDGEZERO__` would + // collide with (or be shadowed by) runtime configuration and resolve + // to config data instead of the credential. Reject in preflight. + const RESERVED_PREFIX: &str = "EDGEZERO__"; + for entry in entries { + let key = entry.key_value; + let reason = if key.is_empty() { + Some("is empty") + } else if key.contains('=') { + Some("contains `=`") + } else if key.contains('\n') || key.contains('\r') { + Some("contains a newline") + } else if key.trim() != key { + Some("has leading or trailing whitespace") + } else if key.starts_with('#') { + Some("starts with `#`, which `.env` readers treat as a comment") + } else if key.to_ascii_uppercase().starts_with(RESERVED_PREFIX) { + Some( + "is in the reserved `EDGEZERO__` namespace used for runtime configuration \ + (store overlays, `EDGEZERO__ADAPTER__*`, `EDGEZERO__LOGGING__*`, ...) in \ + `.edgezero/.env`; a secret there would collide with config and resolve to \ + configuration data instead of the credential", + ) + } else { + None + }; + if let Some(rejection) = reason { + return Err(format!( + "secret key `{key}` (field `{field}`) {rejection}; axum writes typed secrets as \ + `=` lines in `.edgezero/.env`, and this key cannot round-trip through \ + that format. Rename the key on the `#[secret]` field.", + field = entry.field_name, + )); + } + } + Ok(()) + } + + 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 { + match mode { + ProvisionMode::Cloud => {} + ProvisionMode::Local => { + return provision_local::provision(manifest_root, stores, dry_run); + } + // ProvisionMode is #[non_exhaustive]; explicit error so a + // future mode variant doesn't quietly fall through. + other => { + return Err(format!( + "axum adapter does not implement provision mode {other:?}" + )); + } + } + //: 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 + // nothing to actually perform. + let mut out = Vec::with_capacity( + stores + .kv + .len() + .saturating_add(stores.config.len()) + .saturating_add(stores.secrets.len()), + ); + for store in stores.kv { + let logical = store.logical.as_str(); + out.push(format!( + "axum KV store `{logical}` is in-memory; nothing to provision" + )); + } + for store in stores.config { + // Axum reads `.edgezero/local-config-.json`. + // The platform name is informational here -- the env + // overlay isn't used for local file paths because the + // path encoding is the spec's canonical form. + let logical = store.logical.as_str(); + out.push(format!( + "axum config store `{logical}` reads `.edgezero/local-config-{logical}.json`; nothing to provision" + )); + } + for store in stores.secrets { + let logical = store.logical.as_str(); + out.push(format!( + "axum secret store `{logical}` reads env vars; nothing to provision" + )); + } + if out.is_empty() { + out.push("axum has no declared stores to provision".to_owned()); + } + Ok(ProvisionOutcome::from_status_lines(out)) + } + + fn provision_typed( + &self, + manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + typed_secrets: &[TypedSecretEntry<'_>], + mode: ProvisionMode, + dry_run: bool, + ) -> Result { + // Axum has no cloud secret store: cloud is a documented no-op. + // Local mode appends `=` lines to `.edgezero/.env` + // (unquoted empty value — the loosest `.env` form). The + // operator fills in the actual secret by editing the file. + // `append_lines_dedup` handles parent-dir creation so + // `.edgezero/` gets auto-created on the first-run case. + if !matches!(mode, ProvisionMode::Local) { + return Ok(ProvisionOutcome::default()); + } + let env_path = manifest_root.join(".edgezero").join(".env"); + let lines: Vec = typed_secrets + .iter() + .map(|entry| format!("{}=", entry.key_value)) + .collect(); + let wrote = append_lines_dedup_with_header( + &env_path, + Some(EDGEZERO_PROVISION_HEADER), + &lines, + dry_run, + ) + .map_err(|err| format!("write {}: {err}", env_path.display()))?; + // Report what actually landed, not the candidate count: on a + // re-provision where every placeholder already exists, the write + // is a dedup no-op and claiming "wrote N" would be misleading. + let status_lines = if wrote { + vec![format!( + "axum: wrote {} secret placeholder(s) to {}", + typed_secrets.len(), + env_path.display() + )] + } else { + vec![format!( + "axum: {} secret placeholder(s) already present in {}; no change", + typed_secrets.len(), + env_path.display() + )] + }; + Ok(ProvisionOutcome::from_status_lines(status_lines)) + } + + fn push_config_entries( + &self, + manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + //: axum is local-only. Push writes the same flat + // `string -> string` JSON object `AxumConfigStore` reads + // back from `.edgezero/local-config-.json`. The path + // is keyed on the LOGICAL id, not the env-resolved + // platform name -- the local file flow is the spec's + // canonical form and isn't subject to the per-store env + // overlay (which targets platform store names, not local + // file paths). + let logical = store.logical.as_str(); + let local_dir = manifest_root.join(".edgezero"); + let target = local_dir.join(format!("local-config-{logical}.json")); + if dry_run { + return Ok(vec![format!( + "would write {} entries to {}", + entries.len(), + target.display() + )]); + } + // A symlinked local-config file would have the read below + // follow it and the write clobber (or, if dangling, create) + // its target outside the project tree. + reject_symlinked_target(&target)?; + fs::create_dir_all(&local_dir) + .map_err(|err| format!("failed to create {}: {err}", local_dir.display()))?; + // Upsert into any existing map so a `config push --key + // app_config_staging` doesn't wipe a previously-pushed + // `app_config` blob (spec 12.7 requires default + staging + // to coexist for the `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` + // override to switch between them). The map is owned (rather + // than borrowed) so we can merge old + new without lifetime + // surgery on the slice. + // Only two cases legitimately yield an empty starting map: the + // file does not exist yet, or it exists but is blank. EVERY + // other read failure (invalid UTF-8, permission denied, a + // transient I/O error) MUST propagate -- a `_ => BTreeMap::new()` + // catch-all silently discards the operator's existing keys and + // the `fs::write` below then replaces the file with just this + // push's entries, losing every sibling blob (e.g. a previously + // pushed `app_config` when pushing `app_config_staging`). + let mut map: BTreeMap = match fs::read_to_string(&target) { + Ok(text) if text.trim().is_empty() => BTreeMap::new(), + Ok(text) => serde_json::from_str(&text).map_err(|err| { + format!( + "failed to parse existing {}: {err} (expected a JSON object of key->envelope)", + target.display() + ) + })?, + Err(err) if err.kind() == io::ErrorKind::NotFound => BTreeMap::new(), + Err(err) => { + return Err(format!( + "failed to read existing {}: {err} -- refusing to overwrite it, which would \ + drop any config keys it already holds", + target.display() + )); + } + }; + for (key, value) in entries { + map.insert(key.clone(), value.clone()); + } + let json = serde_json::to_string_pretty(&map) + .map_err(|err| format!("failed to serialize config to JSON: {err}"))?; + fs::write(&target, json) + .map_err(|err| format!("failed to write {}: {err}", target.display()))?; + Ok(vec![format!( + "wrote {} entries to {} ({} total keys after upsert)", + entries.len(), + target.display(), + map.len(), + )]) + } + + fn push_config_entries_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + // Axum is local-only: the default push already writes + // `.edgezero/local-config-.json`, which is what the + // running dev server reads. `--local` is therefore the + // same as the default; we delegate and prepend a notice + // so the operator who typed `--local` for parity with + // fastly/cloudflare knows there was nothing extra to do. + let mut lines = self.push_config_entries( + manifest_root, + adapter_manifest_path, + component_selector, + store, + entries, + push_ctx, + dry_run, + )?; + let notice = + "axum push is always local: `--local` has no separate effect (writes the same `.edgezero/local-config-.json` either way)".to_owned(); + lines.insert(0, notice); + Ok(lines) + } + + fn read_config_entry( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + store: &ResolvedStoreId, + key: &str, + push_ctx: &AdapterPushContext<'_>, + ) -> Result { + // Axum has no "remote" — delegate to the local impl. + // The local JSON file IS the live state for the running dev server. + self.read_config_entry_local( + manifest_root, + adapter_manifest_path, + component_selector, + store, + key, + push_ctx, + ) + } + + fn read_config_entry_local( + &self, + manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + key: &str, + _push_ctx: &AdapterPushContext<'_>, + ) -> Result { + // Axum reads `.edgezero/local-config-.json`. + // The path is keyed on the LOGICAL id (matching + // `push_config_entries`), not the env-resolved platform name. + let path = manifest_root + .join(".edgezero") + .join(format!("local-config-{}.json", store.logical)); + // Reject a symlink at ANY component from `manifest_root` down to the + // JSON file -- not just the final one -- so a symlinked `.edgezero` + // directory can't redirect the diff read off the tree either. + // Matches the write side's containment policy. + reject_symlink_components(manifest_root, &path)?; + match fs::read_to_string(&path) { + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(ReadConfigEntry::MissingStore), + Err(err) => Err(format!("failed to read {}: {err}", path.display())), + Ok(raw) => { + let map: BTreeMap = serde_json::from_str(&raw) + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + match map.get(key) { + Some(value) => Ok(ReadConfigEntry::Present(value.clone())), + None => Ok(ReadConfigEntry::MissingKey), + } + } + } + } + + // Axum config is a local JSON map (one value per key, no chunk fan-out), + // so there are no orphaned chunk entries to reclaim -- inherit the trait's + // "not implemented" default (spelled out for the `missing_trait_methods` + // lint). + fn gc_config_entries( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _store: &ResolvedStoreId, + _push_ctx: &AdapterPushContext<'_>, + _older_than_secs: u64, + _dry_run: bool, + ) -> Result, String> { + Err(format!( + "adapter `{}` does not implement `config gc`", + self.name() + )) + } + + fn preflight_config_write(&self, _key: &str, _body: &str) -> Result<(), String> { + Ok(()) + } + + fn single_store_kinds(&self) -> &'static [&'static str] { + //: axum is Multi for KV (local file dirs) and Config + // (local JSON files), Single for Secrets (env vars). + &["secrets"] + } + + fn synthesise_baseline_manifest( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + adapter_crate_path: Option<&str>, + _component_selector: Option<&str>, + app_name: &str, + _deployed: Option<&AdapterDeployedState>, + _allowed_outbound_hosts: &[String], + ) -> Result, String> { + // Axum's manifest is pure operator-facing dev-server config + // (host, port, crate name, crate dir). There are no cloud + // identifiers to weave in, and provision's merge path is a + // no-op on the file. The baseline is emitted so a fresh + // clone's `provision --local` gets a runnable `axum.toml` + // without needing the operator to hand-author one -- same + // model as Cloudflare / Fastly / Spin. + let rel = adapter_manifest_path.map_or_else(|| PathBuf::from("axum.toml"), PathBuf::from); + // Prefer the authoritative declared `[adapters.axum.adapter].crate` + // for the crate name; fall back to the ancestor `Cargo.toml` + // search, then the scaffold convention. (An ancestor search alone + // could pick a nested package between the manifest and the crate.) + let crate_name = match cli_support::read_crate_name_at(manifest_root, adapter_crate_path)? { + Some(name) => name, + None => cli_support::read_adapter_crate_name(manifest_root, adapter_manifest_path) + .unwrap_or_else(|| { + if app_name.is_empty() { + "app-adapter-axum".to_owned() + } else { + format!("{app_name}-adapter-axum") + } + }), + }; + // Compute `crate_dir` (relative path from the manifest's + // parent to the crate root). The scaffold convention + // `crates//axum.toml` puts the manifest INSIDE the + // crate root so `crate_dir = "."`. A nested manifest like + // `crates/server/config/axum.toml` needs `".."` because + // the crate root sits one level above the manifest's + // parent — the axum-adapter loader consumes `crate_dir` + // to locate `Cargo.toml`, and without the right count of + // `..` it looks in `config/Cargo.toml` and fails + // discovery. + let crate_dir = + derive_axum_crate_dir(manifest_root, adapter_manifest_path, adapter_crate_path); + Ok(vec![( + rel, + run::synthesise_axum_toml(&crate_name, &crate_dir), + )]) + } +} + +/// Return the `crate_dir` string the synthesiser should emit for +/// this manifest layout: the relative path from the manifest's +/// parent to the crate root (the dir carrying `Cargo.toml`). +/// +/// The crate root is the AUTHORITATIVE declared +/// `[adapters.axum.adapter].crate` when present (so `crate_dir` agrees +/// with the crate NAME, which also honours `.crate`); it falls back to +/// the nearest-ancestor `Cargo.toml` search only when `.crate` is +/// undeclared. Without this, a nested package between the manifest and +/// the intended crate could make the crate NAME and `crate_dir` name two +/// DIFFERENT crates in the same `axum.toml`. +/// +/// Falls back to `"."` when `.manifest` is unset (first-run scaffold), +/// when no crate root can be resolved, or when the resolved crate root +/// isn't a lexical ancestor of the manifest's parent (a `.crate` / +/// `.manifest` misconfiguration) -- `"."` is the scaffold-convention +/// default (`crates//axum.toml`). +fn derive_axum_crate_dir( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + adapter_crate_path: Option<&str>, +) -> String { + use std::iter; + use std::path::Component; + let Some(rel_str) = adapter_manifest_path else { + return ".".to_owned(); + }; + let crate_root = match adapter_crate_path { + Some(cp) => manifest_root.join(cp), + None => match cli_support::read_adapter_crate_root(manifest_root, adapter_manifest_path) { + Some(root) => root, + None => return ".".to_owned(), + }, + }; + let manifest_abs = manifest_root.join(rel_str); + let Some(manifest_parent) = manifest_abs.parent() else { + return ".".to_owned(); + }; + // Count directory hops from `manifest_parent` back up to + // `crate_root`. Both are built from the SAME `manifest_root.join(...)` + // base, and `read_adapter_crate_root` returns a lexical ancestor of + // `manifest_parent` (it walks up via `.parent()`), so `strip_prefix` + // succeeds LEXICALLY. We deliberately do NOT `canonicalize()` here: + // canonicalisation requires the path to exist on disk, and on a fresh + // clone the nested `/config/` dir may not be created yet -- it + // would leave `manifest_parent` relative while an existing + // `crate_root` resolved to absolute, so `strip_prefix` would fail and + // emit `.` (wrong: the nested manifest needs `..`). Falls back to `.` + // only if the lexical prefix genuinely doesn't hold. + let Ok(down_from_crate) = manifest_parent.strip_prefix(&crate_root) else { + return ".".to_owned(); + }; + let hops = down_from_crate + .components() + .filter(|comp| matches!(comp, Component::Normal(_))) + .count(); + if hops == 0 { + ".".to_owned() + } else { + iter::repeat_n("..", hops).collect::>().join("/") + } +} + +#[inline] +pub fn register() { + register_adapter(&AXUM_ADAPTER); + register_adapter_blueprint(&AXUM_BLUEPRINT); +} + +#[ctor(unsafe)] +fn register_ctor() { + register(); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + fn write_crate(root: &Path, rel: &str, name: &str) { + let dir = root.join(rel); + fs::create_dir_all(&dir).expect("mkdir crate"); + fs::write( + dir.join("Cargo.toml"), + format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"), + ) + .expect("write Cargo.toml"); + } + + #[test] + fn derive_axum_crate_dir_nested_manifest_without_existing_config_dir() { + // Regression: the nested `config/` dir does NOT exist yet (fresh + // clone / synthesis), but the crate root does. The derivation must + // still emit `..` -- it did not when it canonicalised the two + // sides independently (missing path stayed relative, existing path + // went absolute, `strip_prefix` failed and emitted `.`). + let dir = tempdir().expect("tempdir"); + let root = dir.path(); + write_crate(root, "crates/server", "server"); + // `crates/server/config/` is deliberately NOT created. `None` + // crate path exercises the ancestor-search fallback. + assert_eq!( + derive_axum_crate_dir(root, Some("crates/server/config/axum.toml"), None), + "..", + "a manifest one dir below the crate root needs `..`" + ); + assert_eq!( + derive_axum_crate_dir(root, Some("crates/server/config/deep/axum.toml"), None), + "../..", + "two dirs below the crate root needs `../..`" + ); + } + + #[test] + fn derive_axum_crate_dir_scaffold_convention_is_dot() { + let dir = tempdir().expect("tempdir"); + let root = dir.path(); + write_crate(root, "crates/app", "app"); + assert_eq!( + derive_axum_crate_dir(root, Some("crates/app/axum.toml"), None), + ".", + "a manifest AT the crate root needs `.`" + ); + } + + #[test] + fn derive_axum_crate_dir_prefers_declared_crate_over_nested_package() { + // A nested package sits BETWEEN the manifest and the intended + // crate. Ancestor search would stop at `crates/server/config` + // (crate_dir `.`), disagreeing with the crate NAME (which honours + // `.crate`). The declared `.crate` must win: `crate_dir = ".."`. + let dir = tempdir().expect("tempdir"); + let root = dir.path(); + write_crate(root, "crates/server", "server"); + // Intervening nested package that ancestor search would wrongly pick. + write_crate(root, "crates/server/config", "server-config"); + assert_eq!( + derive_axum_crate_dir( + root, + Some("crates/server/config/axum.toml"), + Some("crates/server"), + ), + "..", + "declared `.crate` must anchor crate_dir, not the nested package" + ); + } + + #[test] + fn derive_axum_crate_dir_no_manifest_is_dot() { + let dir = tempdir().expect("tempdir"); + assert_eq!(derive_axum_crate_dir(dir.path(), None, None), "."); + } + + #[test] + fn adapter_name_is_axum() { + assert_eq!(AXUM_ADAPTER.name(), "axum"); + } + + #[test] + fn blueprint_has_correct_id() { + assert_eq!(AXUM_BLUEPRINT.id, "axum"); + assert_eq!(AXUM_BLUEPRINT.display_name, "Axum"); + } + + // ---------- validate_typed_secrets ---------- + + #[test] + fn validate_typed_secrets_rejects_key_containing_equals() { + // `partner=token` would be written as `partner=token=`, which + // reads back as key `partner` with value `token=`. + let entries = vec![TypedSecretEntry::new("default", "field", "partner=token")]; + let err = AXUM_ADAPTER + .validate_typed_secrets(&entries) + .expect_err("a key containing `=` cannot round-trip through a .env line"); + assert!( + err.contains("partner=token") && err.contains("contains `=`"), + "error names the key and the reason: {err}" + ); + } + + #[test] + fn validate_typed_secrets_rejects_newline_whitespace_and_comment_keys() { + for bad in ["with\nnewline", " padded", "padded ", "#commented", ""] { + let entries = vec![TypedSecretEntry::new("default", "field", bad)]; + AXUM_ADAPTER + .validate_typed_secrets(&entries) + .expect_err("a key that cannot round-trip through a .env line must be rejected"); + } + } + + #[test] + fn validate_typed_secrets_rejects_reserved_store_overlay_namespace() { + // A secret key in the `EDGEZERO__STORES__` namespace would collide + // (and be silently deduped away) against the generated store-overlay + // lines base provision writes into the SAME `.edgezero/.env`. + for reserved in [ + "EDGEZERO__STORES__CONFIG__APP__KEY", + "EDGEZERO__ADAPTER__HOST", + "EDGEZERO__LOGGING__LEVEL", + "edgezero__adapter__port", // case-insensitive + ] { + let entries = vec![TypedSecretEntry::new("default", "field", reserved)]; + let Err(err) = AXUM_ADAPTER.validate_typed_secrets(&entries) else { + panic!("a reserved-namespace secret key must be rejected: {reserved}"); + }; + assert!( + err.contains("reserved") && err.contains("EDGEZERO__"), + "error explains the reserved-namespace collision for {reserved}: {err}" + ); + } + } + + #[test] + fn validate_typed_secrets_accepts_ordinary_keys() { + let entries = vec![ + TypedSecretEntry::new("default", "field_a", "demo_api_token"), + TypedSecretEntry::new("default", "field_b", "PARTNER_TOKEN"), + ]; + AXUM_ADAPTER + .validate_typed_secrets(&entries) + .expect("ordinary keys round-trip fine"); + } + + // ---------- push_config_entries ---------- + + #[test] + fn push_writes_flat_json_to_local_config_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("service.timeout_ms".to_owned(), "1500".to_owned()), + ]; + let lines = AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect("push succeeds"); + assert_eq!(lines.len(), 1); + assert!( + lines[0].contains("wrote 2 entries"), + "status line names count: {lines:?}" + ); + let json_path = dir.path().join(".edgezero/local-config-app_config.json"); + let raw = fs::read_to_string(&json_path).expect("read written file"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + assert_eq!(parsed["greeting"], "hello"); + assert_eq!(parsed["service.timeout_ms"], "1500"); + } + + #[test] + fn push_dry_run_does_not_create_local_dir_or_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let entries = vec![("greeting".to_owned(), "hello".to_owned())]; + let lines = AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run succeeds"); + assert!( + lines[0].contains("would write 1 entries"), + "dry-run line: {lines:?}" + ); + assert!( + !dir.path().join(".edgezero").exists(), + ".edgezero must not exist after dry-run" + ); + } + + #[test] + fn push_creates_dot_edgezero_directory_when_missing() { + let dir = tempfile::tempdir().expect("tempdir"); + let entries = vec![("key".to_owned(), "value".to_owned())]; + AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("x"), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect("push succeeds"); + assert!(dir.path().join(".edgezero").is_dir(), ".edgezero created"); + } + + #[test] + fn push_with_empty_entries_writes_empty_json_object() { + let dir = tempfile::tempdir().expect("tempdir"); + AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("empty"), + &[], + &AdapterPushContext::new(), + false, + ) + .expect("push succeeds even with no entries"); + let raw = fs::read_to_string(dir.path().join(".edgezero/local-config-empty.json")) + .expect("read written file"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + assert_eq!(parsed, serde_json::json!({})); + } + + #[test] + fn push_propagates_read_error_instead_of_clobbering_existing_keys() { + // Regression: the read + // arm used to be `_ => BTreeMap::new()`, which swallowed EVERY + // read failure -- not just NotFound. A local-config file that + // is unreadable (invalid UTF-8 here; permission-denied and + // transient I/O errors take the same arm) would silently reset + // the map, and the `fs::write` that follows would replace the + // file with only this push's entries -- destroying every + // sibling blob the operator had already pushed. + // + // Invalid UTF-8 is the portable way to force `read_to_string` + // to fail: a permission-based fixture would need root-less + // chmod semantics that differ across CI platforms. + let dir = tempfile::tempdir().expect("tempdir"); + let local_dir = dir.path().join(".edgezero"); + fs::create_dir_all(&local_dir).expect("mkdir .edgezero"); + let target = local_dir.join("local-config-app_config.json"); + fs::write(&target, [0x66, 0x6f, 0x6f, 0xff, 0xfe]).expect("seed invalid UTF-8"); + + let err = AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + &[("app_config".to_owned(), "{}".to_owned())], + &AdapterPushContext::new(), + false, + ) + .expect_err("an unreadable local-config file must abort the push"); + assert!( + err.contains("failed to read existing"), + "error must name the read failure rather than silently resetting the store: {err}" + ); + + // The load-bearing assertion: the unreadable file is untouched, + // so no operator data was destroyed by the failed push. + let after = fs::read(&target).expect("file still present"); + assert_eq!( + after, + vec![0x66, 0x6f, 0x6f, 0xff, 0xfe], + "push must not overwrite a local-config file it could not read" + ); + } + + /// Push-after-provision COMPOSITION: run real `provision_typed` + /// (which writes the secret file), let the operator fill in the + /// value, then `config push --local` (which writes + /// `.edgezero/local-config-.json`) and assert the operator's + /// secret survives. Running the actual provision means a + /// provision-output-shape regression can't slip past this test. + #[test] + fn push_after_provision_preserves_dotenv_secret() { + let dir = tempfile::tempdir().expect("tempdir"); + // 1. Provision writes the secret placeholder into .edgezero/.env. + AxumCliAdapter + .provision_typed( + dir.path(), + None, + None, + &[TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )], + ProvisionMode::Local, + false, + ) + .expect("provision_typed writes the placeholder"); + let env_path = dir.path().join(".edgezero").join(".env"); + assert!( + fs::read_to_string(&env_path) + .expect("provision wrote .env") + .contains("demo_api_token="), + "provision must write the secret placeholder line" + ); + // 2. Operator fills the placeholder with a real value. + let filled = fs::read_to_string(&env_path) + .unwrap() + .replace("demo_api_token=", "demo_api_token=real-secret-value"); + fs::write(&env_path, &filled).expect("operator edit"); + + // 3. Push config; the secret file must be left intact. + AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + &[("app_config".to_owned(), "{}".to_owned())], + &AdapterPushContext::new(), + false, + ) + .expect("push succeeds"); + + assert!( + fs::read_to_string(&env_path) + .expect("read .env") + .contains("demo_api_token=real-secret-value"), + "config push must not touch the provision-written .env secret" + ); + } + + /// A symlinked local-config file must be refused before push + /// reads through it and the write clobbers the link's target. + #[cfg(unix)] + #[test] + fn push_refuses_a_symlinked_local_config_file() { + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().expect("tempdir"); + let victim = dir.path().join("victim-outside"); + fs::write(&victim, "OPERATOR DATA\n").expect("seed victim"); + let local_dir = dir.path().join(".edgezero"); + fs::create_dir_all(&local_dir).expect("mkdir .edgezero"); + symlink(&victim, local_dir.join("local-config-app_config.json")).expect("symlink"); + + let err = AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + &[("app_config".to_owned(), "{}".to_owned())], + &AdapterPushContext::new(), + false, + ) + .expect_err("a symlinked local-config file must be refused"); + assert!(err.contains("is a symlink"), "{err}"); + assert_eq!( + fs::read_to_string(&victim).expect("victim intact"), + "OPERATOR DATA\n", + "push must not write through the symlink" + ); + } + + // ---------- read_config_entry / read_config_entry_local ---------- + + #[cfg(unix)] + #[test] + fn read_config_entry_local_rejects_a_symlinked_edgezero_dir() { + // The diff/read path must reject a symlinked INTERMEDIATE `.edgezero` + // dir, not just the final JSON, so a planted directory symlink can't + // redirect the read off the tree. + use std::fs::create_dir_all; + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path().join("proj"); + create_dir_all(&root).expect("mkdir proj"); + let outside = dir.path().join("outside"); + create_dir_all(&outside).expect("mkdir outside"); + symlink(&outside, root.join(".edgezero")).expect("symlink .edgezero"); + + let Err(err) = AxumCliAdapter.read_config_entry_local( + &root, + None, + None, + &ResolvedStoreId::from_logical("app_config"), + "greeting", + &AdapterPushContext::new(), + ) else { + panic!("a symlinked .edgezero must be refused, not read through"); + }; + assert!(err.contains("symlink"), "error names the symlink: {err}"); + } + + #[test] + fn read_config_entry_local_returns_missing_store_when_file_absent() { + let dir = tempfile::tempdir().expect("tempdir"); + let result = AxumCliAdapter + .read_config_entry_local( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + "greeting", + &AdapterPushContext::new(), + ) + .expect("infallible on missing file"); + assert!( + matches!(result, ReadConfigEntry::MissingStore), + "missing file => MissingStore" + ); + } + + #[test] + fn read_config_entry_local_returns_missing_key_when_key_absent() { + let dir = tempfile::tempdir().expect("tempdir"); + // Write a JSON file with one key so the store exists, but the + // requested key is not in it. + let local_dir = dir.path().join(".edgezero"); + fs::create_dir_all(&local_dir).expect("create dir"); + fs::write( + local_dir.join("local-config-app_config.json"), + r#"{"other_key": "value"}"#, + ) + .expect("write"); + let result = AxumCliAdapter + .read_config_entry_local( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + "greeting", + &AdapterPushContext::new(), + ) + .expect("infallible on missing key"); + assert!( + matches!(result, ReadConfigEntry::MissingKey), + "key absent => MissingKey" + ); + } + + #[test] + fn read_config_entry_local_returns_present_when_key_exists() { + let dir = tempfile::tempdir().expect("tempdir"); + let local_dir = dir.path().join(".edgezero"); + fs::create_dir_all(&local_dir).expect("create dir"); + fs::write( + local_dir.join("local-config-app_config.json"), + r#"{"greeting": "hello-axum"}"#, + ) + .expect("write"); + let result = AxumCliAdapter + .read_config_entry_local( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + "greeting", + &AdapterPushContext::new(), + ) + .expect("key present"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present variant"); + }; + assert_eq!(value, "hello-axum", "value matches"); + } + + #[test] + fn read_config_entry_delegates_to_local() { + // Axum has no remote: read_config_entry and read_config_entry_local + // must return the same result for the same inputs. + let dir = tempfile::tempdir().expect("tempdir"); + let local_dir = dir.path().join(".edgezero"); + fs::create_dir_all(&local_dir).expect("create dir"); + fs::write( + local_dir.join("local-config-app_config.json"), + r#"{"greeting": "hello-axum"}"#, + ) + .expect("write"); + let store = ResolvedStoreId::from_logical("app_config"); + let ctx = AdapterPushContext::new(); + let via_local = AxumCliAdapter + .read_config_entry_local(dir.path(), None, None, &store, "greeting", &ctx) + .expect("local ok"); + let via_remote = AxumCliAdapter + .read_config_entry(dir.path(), None, None, &store, "greeting", &ctx) + .expect("remote ok"); + let ReadConfigEntry::Present(local_val) = via_local else { + panic!("expected Present from local"); + }; + let ReadConfigEntry::Present(remote_val) = via_remote else { + panic!("expected Present from remote"); + }; + assert_eq!(local_val, remote_val, "local and remote agree"); + } + + #[test] + fn read_config_entry_local_errors_on_malformed_json() { + let dir = tempfile::tempdir().expect("tempdir"); + let local_dir = dir.path().join(".edgezero"); + fs::create_dir_all(&local_dir).expect("create dir"); + fs::write( + local_dir.join("local-config-app_config.json"), + "not valid json {{{", + ) + .expect("write"); + let result = AxumCliAdapter.read_config_entry_local( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + "greeting", + &AdapterPushContext::new(), + ); + match result { + Err(err) => assert!( + err.contains("failed to parse"), + "error names the failure: {err}" + ), + Ok(_) => panic!("expected Err for malformed JSON"), + } + } + + /// Spec 12.7: pushing two blobs under different keys (e.g. + /// `app_config` + `app_config_staging`) must leave both keys + /// readable so the runtime + /// `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` override can + /// switch between them. Prior to the upsert fix the second push + /// wiped the first by wholesale-rewriting the JSON map. + #[test] + fn push_config_entries_preserves_sibling_keys() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = ResolvedStoreId::from_logical("app_config"); + let ctx = AdapterPushContext::new(); + + AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &store, + &[("app_config".to_owned(), "{\"envelope\":\"A\"}".to_owned())], + &ctx, + false, + ) + .expect("first push"); + AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &store, + &[( + "app_config_staging".to_owned(), + "{\"envelope\":\"B\"}".to_owned(), + )], + &ctx, + false, + ) + .expect("second push (sibling key)"); + + let raw = fs::read_to_string(dir.path().join(".edgezero/local-config-app_config.json")) + .expect("read"); + let map: BTreeMap = serde_json::from_str(&raw).expect("parse map"); + assert_eq!( + map.get("app_config").map(String::as_str), + Some("{\"envelope\":\"A\"}"), + "default key must survive sibling push: {raw}" + ); + assert_eq!( + map.get("app_config_staging").map(String::as_str), + Some("{\"envelope\":\"B\"}"), + "staging key must be present: {raw}" + ); + } + + // ---------- provision_typed (Local mode) — secret placeholders ---------- + + #[test] + fn axum_provision_typed_appends_secret_placeholders_to_edgezero_env() { + // Fixture: no `.edgezero/` pre-existing (append_lines_dedup + // creates it via parent-dir handling). provision_typed writes + // `=` per entry — unquoted empty value. + let dir = tempdir().unwrap(); + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + let outcome = AxumCliAdapter + .provision_typed( + dir.path(), + None, + None, + &entries, + ProvisionMode::Local, + false, + ) + .unwrap(); + let env_path = dir.path().join(".edgezero/.env"); + assert!(env_path.exists(), ".env exists: {}", env_path.display()); + let env = fs::read_to_string(&env_path).unwrap(); + assert!( + env.lines().any(|line| line == "demo_api_token="), + "unquoted empty-value placeholder present: {env}" + ); + assert!( + outcome + .status_lines + .iter() + .any(|line| line.contains(&env_path.display().to_string())), + "status line names the .env path: {:?}", + outcome.status_lines + ); + assert!( + outcome.deployed.is_none(), + "local provision_typed returns no deployed state" + ); + } + + #[test] + fn axum_provision_typed_creates_dot_edgezero_if_missing() { + // No `.edgezero/` pre-existing. append_lines_dedup + // creates parent dirs, so the first-run case works without an + // explicit `create_dir_all` in provision_typed. + let dir = tempdir().unwrap(); + assert!( + !dir.path().join(".edgezero").exists(), + "sanity: .edgezero/ must NOT pre-exist" + ); + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + AxumCliAdapter + .provision_typed( + dir.path(), + None, + None, + &entries, + ProvisionMode::Local, + false, + ) + .unwrap(); + assert!( + dir.path().join(".edgezero").is_dir(), + ".edgezero/ auto-created via append_lines_dedup parent-dir handling" + ); + assert!( + dir.path().join(".edgezero/.env").exists(), + ".env landed inside auto-created .edgezero/" + ); + } + + #[test] + fn axum_provision_typed_cloud_mode_is_a_no_op() { + // Cloud is a no-op: axum has no cloud secret store. The load- + // bearing negative assertion is that Cloud mode must NOT + // create `.edgezero/` or `.env`. + let dir = tempdir().unwrap(); + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + let outcome = AxumCliAdapter + .provision_typed( + dir.path(), + None, + None, + &entries, + ProvisionMode::Cloud, + false, + ) + .unwrap(); + assert!( + outcome.status_lines.is_empty(), + "cloud mode emits no status lines: {:?}", + outcome.status_lines + ); + assert!( + outcome.deployed.is_none(), + "cloud mode returns no deployed state" + ); + assert!( + !dir.path().join(".edgezero").exists(), + "cloud mode must NOT auto-create .edgezero/" + ); + } + + #[test] + fn axum_provision_typed_deduplicates_matching_key() { + // Operator has already filled in the real value. Re-running + // provision_typed must NOT clobber it with the empty + // placeholder — append_lines_dedup collapses keys. + let dir = tempdir().unwrap(); + let dot_edgezero = dir.path().join(".edgezero"); + fs::create_dir_all(&dot_edgezero).unwrap(); + let env_path = dot_edgezero.join(".env"); + fs::write(&env_path, "demo_api_token=operator_value\n").unwrap(); + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + AxumCliAdapter + .provision_typed( + dir.path(), + None, + None, + &entries, + ProvisionMode::Local, + false, + ) + .unwrap(); + let env = fs::read_to_string(&env_path).unwrap(); + assert!( + env.contains("demo_api_token=operator_value"), + "operator's real value survives: {env}" + ); + let token_lines = env + .lines() + .filter(|line| { + let after_hash = line.trim_start().strip_prefix('#').unwrap_or(line); + after_hash.trim_start().starts_with("demo_api_token=") + }) + .count(); + assert_eq!( + token_lines, 1, + "exactly one demo_api_token line remains: {env}" + ); + } + + #[test] + fn axum_provision_typed_handles_multiple_entries() { + // Multiple TypedSecretEntry values across different store_ids. + // Every key_value must land as a `=` line, exactly + // once each. + let dir = tempdir().unwrap(); + let entries = [ + TypedSecretEntry::new("default", "api_token", "demo_api_token"), + TypedSecretEntry::new("default", "hmac_key", "demo_hmac_key"), + TypedSecretEntry::new("audit", "audit_token", "audit_secret"), + ]; + AxumCliAdapter + .provision_typed( + dir.path(), + None, + None, + &entries, + ProvisionMode::Local, + false, + ) + .unwrap(); + let env = fs::read_to_string(dir.path().join(".edgezero/.env")).unwrap(); + for expected in ["demo_api_token=", "demo_hmac_key=", "audit_secret="] { + let count = env.lines().filter(|line| *line == expected).count(); + assert_eq!( + count, 1, + "expected exactly one line `{expected}` in .env: {env}" + ); + } + } +} diff --git a/crates/edgezero-adapter-axum/src/cli/provision_local.rs b/crates/edgezero-adapter-axum/src/cli/provision_local.rs new file mode 100644 index 00000000..07707180 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/cli/provision_local.rs @@ -0,0 +1,664 @@ +use std::fs; +use std::path::Path; + +use edgezero_adapter::env_file::{EDGEZERO_PROVISION_HEADER, append_lines_dedup_with_header}; +use edgezero_adapter::registry::{ProvisionOutcome, ProvisionStores}; + +/// Local-mode `provision` arm. +/// +/// Axum's baseline `axum.toml` is written by +/// `Adapter::synthesise_baseline_manifest` (see `cli/mod.rs`); the +/// merge path here doesn't touch the manifest because Axum has no +/// per-machine identifiers to weave in on re-provision. Once +/// synthesised, operator edits (custom host / port / `crate_dir`) +/// survive re-runs byte-identical. +/// +/// The only thing this fn writes is the `.edgezero/.env` file the +/// runtime reads at boot: `__NAME` lines seed the +/// store->platform-name map for every declared kind (KV / CONFIG / +/// SECRETS), and commented `__KEY` placeholders for CONFIG stores +/// let the operator uncomment them to switch to a staging blob +/// without hand-remembering the full env-var name. +/// +/// The `.edgezero/` directory anchors at `manifest_root`. +/// +/// Dedup — including commented/uncommented cross-form dedup — is +/// delegated to [`append_lines_dedup`] so operator overrides survive +/// re-runs. +pub(super) fn provision( + manifest_root: &Path, + stores: &ProvisionStores<'_>, + dry_run: bool, +) -> Result { + let dot_edgezero = manifest_root.join(".edgezero"); + if !dry_run { + fs::create_dir_all(&dot_edgezero) + .map_err(|err| format!("create {}: {err}", dot_edgezero.display()))?; + } + let env_path = dot_edgezero.join(".env"); + let env_lines = build_axum_env_lines(stores); + append_lines_dedup_with_header( + &env_path, + Some(EDGEZERO_PROVISION_HEADER), + &env_lines, + dry_run, + ) + .map_err(|err| format!("write {}: {err}", env_path.display()))?; + let status_lines = vec![format!( + "axum: ensured {} + appended {} lines to {}", + dot_edgezero.display(), + env_lines.len(), + env_path.display() + )]; + Ok(ProvisionOutcome::from_status_lines(status_lines)) +} + +/// Build the `.env` line set emitted by [`provision_local`]. +/// +/// - One `EDGEZERO__STORES______NAME=` +/// line per store, for every kind (KV, CONFIG, SECRETS). +/// - One commented `# EDGEZERO__STORES__CONFIG____KEY=_staging` +/// placeholder per CONFIG store, so the operator can uncomment to +/// switch blobs without remembering the exact env-var name. +/// +/// Env-var KEY uses the LOGICAL id upper-cased so the runtime env +/// overlay finds it regardless of a teammate's per-store platform +/// override. Env-var VALUE uses the PLATFORM name so the runtime +/// resolves the same backend the rest of the toolchain (Cloudflare, +/// Fastly, Spin, and here the Axum local file store) points at. +fn build_axum_env_lines(stores: &ProvisionStores<'_>) -> Vec { + let mut lines: Vec = Vec::new(); + for (kind, kind_stores) in [ + ("KV", stores.kv), + ("CONFIG", stores.config), + ("SECRETS", stores.secrets), + ] { + for store in kind_stores { + let logical_upper = store.logical.to_ascii_uppercase(); + let platform = &store.platform; + lines.push(format!( + "EDGEZERO__STORES__{kind}__{logical_upper}__NAME={platform}" + )); + } + } + for store in stores.config { + let logical_upper = store.logical.to_ascii_uppercase(); + let logical = &store.logical; + lines.push(format!( + "# EDGEZERO__STORES__CONFIG__{logical_upper}__KEY={logical}_staging" + )); + } + lines +} + +#[cfg(test)] +mod tests { + use super::super::AxumCliAdapter; + use edgezero_adapter::registry::{ + Adapter as _, ProvisionMode, ProvisionStores, ResolvedStoreId, + }; + use std::fs; + use std::path::PathBuf; + use tempfile::tempdir; + + #[test] + fn axum_local_provision_creates_dot_edgezero_dir() { + // Empty fixture — no `.edgezero/` yet, no stores declared. + // Local provision must still create the directory so the + // runtime always sees a well-known location for the `.env` + // file it reads at boot. + let dir = tempdir().unwrap(); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + AxumCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + assert!( + dir.path().join(".edgezero").is_dir(), + ".edgezero/ must exist after local provision" + ); + } + + #[test] + fn axum_local_provision_preserves_existing_axum_toml_content() { + // Contract: when axum.toml already exists (operator has + // edited host/port or other fields), provision's MERGE path + // must NOT rewrite it. The synthesise_baseline_manifest hook + // only writes when the file is missing (write_baseline_to_disk + // skips existing files); the provision merge itself is a + // no-op on axum.toml because Axum has no cloud identifiers + // to weave in. Operator edits therefore survive re-runs + // byte-identical. + let dir = tempdir().unwrap(); + let axum_toml = dir.path().join("axum.toml"); + let operator_content = "# operator-edited\n[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nhost = \"0.0.0.0\"\nport = 3000\n"; + fs::write(&axum_toml, operator_content).unwrap(); + let config_ids = ResolvedStoreId::from_logicals(&["app_config"]); + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + AxumCliAdapter + .provision( + dir.path(), + Some("axum.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + let after = fs::read_to_string(&axum_toml).unwrap(); + assert_eq!( + after, operator_content, + "existing axum.toml must be byte-for-byte unchanged after re-provision" + ); + } + + #[test] + fn axum_local_provision_writes_env_name_lines() { + // For every declared store id (all kinds), a `__NAME` line + // seeds the runtime store->platform-name map. CONFIG stores + // also get a commented `__KEY` placeholder the operator can + // uncomment to switch to a staging blob. + let dir = tempdir().unwrap(); + let config_ids = ResolvedStoreId::from_logicals(&["app_config"]); + let kv_ids = ResolvedStoreId::from_logicals(&["sessions"]); + let secret_ids = ResolvedStoreId::from_logicals(&["default"]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, + }; + AxumCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + let env = fs::read_to_string(dir.path().join(".edgezero/.env")).unwrap(); + assert!( + env.contains("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=app_config"), + "config __NAME line present: {env}" + ); + assert!( + env.contains("EDGEZERO__STORES__KV__SESSIONS__NAME=sessions"), + "kv __NAME line present: {env}" + ); + assert!( + env.contains("EDGEZERO__STORES__SECRETS__DEFAULT__NAME=default"), + "secrets __NAME line present: {env}" + ); + assert!( + env.contains("# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_staging"), + "commented __KEY placeholder present for CONFIG only: {env}" + ); + } + + #[test] + fn axum_local_provision_dedup_preserves_operator_env_overrides() { + // Operator already uncommented + edited the __KEY override. + // A re-provision must NOT re-add the commented placeholder, + // and must NOT clobber the operator's live value. + let dir = tempdir().unwrap(); + let dot_edgezero = dir.path().join(".edgezero"); + fs::create_dir_all(&dot_edgezero).unwrap(); + let env_path = dot_edgezero.join(".env"); + fs::write( + &env_path, + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=operator_override\n", + ) + .unwrap(); + let config_ids = ResolvedStoreId::from_logicals(&["app_config"]); + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + AxumCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + let env = fs::read_to_string(&env_path).unwrap(); + assert!( + env.contains("EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=operator_override"), + "operator override preserved: {env}" + ); + assert!( + !env.contains("# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY="), + "commented placeholder must NOT be re-added: {env}" + ); + } + + #[test] + fn axum_local_provision_uses_platform_name_when_env_overlay_active() { + // Simulates + // EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config + // in effect at CLI time via ResolvedStoreId::new(logical, + // platform). The emitted __NAME line's VALUE must be the + // env-resolved platform (`prod_config`); the ENV-VAR KEY + // must still use the LOGICAL id upper-cased (`APP_CONFIG`) + // so the runtime env overlay finds it. Same discipline as + // Cloudflare handles this separately. + let dir = tempdir().unwrap(); + let config_ids = vec![ResolvedStoreId::new("app_config", "prod_config")]; + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + AxumCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + let env = fs::read_to_string(dir.path().join(".edgezero/.env")).unwrap(); + assert!( + env.contains("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config"), + "value uses PLATFORM, env-var key uses LOGICAL: {env}" + ); + assert!( + !env.contains("EDGEZERO__STORES__CONFIG__PROD_CONFIG__NAME="), + "platform name must NOT leak into the env-var key: {env}" + ); + } + + #[test] + fn axum_local_provision_cloud_mode_is_a_no_op() { + // Cloud mode: the pre-existing status-line-only arm stays in + // charge; nothing gets written to disk, and `.edgezero/` must + // NOT be auto-created. The load-bearing assertion here is + // the negative one — the Local arm's file work must not leak + // into Cloud mode. + let dir = tempdir().unwrap(); + let config_ids = ResolvedStoreId::from_logicals(&["app_config"]); + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + let outcome = AxumCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) + .unwrap(); + assert!( + !dir.path().join(".edgezero").exists(), + "cloud mode must NOT auto-create .edgezero/" + ); + assert!( + !outcome.status_lines.is_empty(), + "cloud arm still emits informational status lines" + ); + } + + #[test] + fn provision_local_creates_dot_edgezero_dir() { + // Empty fixture: `.edgezero/` does not pre-exist and no stores + // are declared. Local provision must still create the directory + // so the runtime has a well-known location to read the `.env` + // file from at boot. + let dir = tempdir().unwrap(); + assert!( + !dir.path().join(".edgezero").exists(), + "sanity: .edgezero/ must NOT pre-exist" + ); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + AxumCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + assert!( + dir.path().join(".edgezero").is_dir(), + ".edgezero/ must exist as a directory after local provision" + ); + } + + #[test] + fn provision_local_preserves_existing_axum_toml() { + // Renamed from `provision_local_does_not_touch_axum_toml` + // (2026-07 refactor). Axum's manifest joined the provision- + // generated set when `synthesise_baseline_manifest` was wired + // up. Provision synthesises a baseline `axum.toml` only when + // the file is missing (via `write_baseline_to_disk`); the + // adapter's merge path is a no-op because Axum has no cloud + // identifiers. Operator edits therefore survive re-runs + // byte-identical -- lock this with a distinctive sentinel. + let dir = tempdir().unwrap(); + let axum_toml = dir.path().join("axum.toml"); + let sentinel = + b"# operator-edited\n[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nhost = \"0.0.0.0\"\nport = 9090\n"; + fs::write(&axum_toml, sentinel).unwrap(); + let config_ids = ResolvedStoreId::from_logicals(&["app_config"]); + let kv_ids = ResolvedStoreId::from_logicals(&["sessions"]); + let secret_ids = ResolvedStoreId::from_logicals(&["default"]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, + }; + AxumCliAdapter + .provision( + dir.path(), + Some("axum.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + let after = fs::read(&axum_toml).unwrap(); + assert_eq!( + after, + sentinel.to_vec(), + "existing axum.toml must be byte-for-byte unchanged after re-provision" + ); + } + + #[test] + fn synthesised_axum_toml_honors_renamed_adapter_crate() { + // Regression: reviewer verified that a project with + // `[adapters.axum.adapter].manifest = "crates/server/axum.toml"` + // + `[package].name = "server"` in `crates/server/Cargo.toml` + // ended up with `crate = "demo-app-adapter-axum"` on + // clean-clone provision because the synth ignored the + // adjacent Cargo.toml. This test pins the fix: the + // synthesiser must read `crates//Cargo.toml` + // `[package].name` and thread THAT into `[adapter].crate`. + let dir = tempdir().unwrap(); + let root = dir.path(); + let crate_dir = root.join("crates/server"); + fs::create_dir_all(&crate_dir).unwrap(); + fs::write( + crate_dir.join("Cargo.toml"), + "[package]\nname = \"server\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let outcome = AxumCliAdapter + .synthesise_baseline_manifest( + root, + Some("crates/server/axum.toml"), + Some("crates/server"), + None, + "demo-app", + None, + &[], + ) + .expect("baseline synthesis succeeds for renamed crate"); + assert_eq!(outcome.len(), 1); + let (rel, body) = outcome.into_iter().next().unwrap(); + assert_eq!(rel, PathBuf::from("crates/server/axum.toml")); + assert!( + body.contains(r#"crate = "server""#), + "synthesised axum.toml must honour the renamed adapter crate \ + `[package].name = \"server\"` from crates/server/Cargo.toml \ + — got: {body}" + ); + assert!( + !body.contains(r#"crate = "demo-app-adapter-axum""#), + "synthesised axum.toml MUST NOT fall back to the scaffold \ + convention when a real Cargo.toml is present: {body}" + ); + } + + #[test] + fn synthesised_axum_toml_honors_renamed_adapter_crate_with_nested_manifest() { + // Reviewer regression: the manifest may live at a nested + // path like `crates/server/config/axum.toml`, and the + // package `[package].name` sits one level up at + // `crates/server/Cargo.toml`. The synthesiser must walk up + // from the manifest parent to find the crate root before + // reading the package name. + let dir = tempdir().unwrap(); + let root = dir.path(); + let crate_dir = root.join("crates/server"); + fs::create_dir_all(crate_dir.join("config")).unwrap(); + fs::write( + crate_dir.join("Cargo.toml"), + "[package]\nname = \"server\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let outcome = AxumCliAdapter + .synthesise_baseline_manifest( + root, + Some("crates/server/config/axum.toml"), + Some("crates/server"), + None, + "demo-app", + None, + &[], + ) + .expect("baseline synthesis succeeds for nested manifest"); + let (rel, body) = outcome.into_iter().next().unwrap(); + assert_eq!(rel, PathBuf::from("crates/server/config/axum.toml")); + assert!( + body.contains(r#"crate = "server""#), + "nested manifest must walk up to `crates/server/Cargo.toml` and read `[package].name = \"server\"` — got: {body}" + ); + assert!( + !body.contains(r#"crate = "demo-app-adapter-axum""#), + "MUST NOT fall back to scaffold convention when the crate Cargo.toml exists further up: {body}" + ); + // Regression: the pre-fix + // synthesiser hard-coded `crate_dir = "."`, which for a + // nested manifest points the loader at `config/Cargo.toml` + // (manifest parent) — where no Cargo.toml exists. + // Discovery then fails and `edgezero serve --adapter axum` + // errors out with the "expected `Cargo.toml` next to the + // manifest" message. + // + // With the manifest at `crates/server/config/axum.toml` + // and Cargo.toml at `crates/server/Cargo.toml`, the + // correct relative crate_dir is `..` (one hop up from + // the manifest's parent to the crate root). + assert!( + body.contains(r#"crate_dir = "..""#), + "nested manifest must emit `crate_dir = \"..\"` so the axum loader finds `crates/server/Cargo.toml`, not `crates/server/config/Cargo.toml` — got: {body}" + ); + } + + #[test] + fn synthesised_axum_toml_scaffold_convention_uses_dot_crate_dir() { + // The scaffold-convention layout `crates//axum.toml` + // (2-deep) places the manifest AT the crate root. The + // synthesiser must emit `crate_dir = "."` — regression + // guard for the fix above (avoid over-correcting nested + // layouts and breaking the common case). + let dir = tempdir().unwrap(); + let root = dir.path(); + let crate_dir = root.join("crates/server"); + fs::create_dir_all(&crate_dir).unwrap(); + fs::write( + crate_dir.join("Cargo.toml"), + "[package]\nname = \"server\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let outcome = AxumCliAdapter + .synthesise_baseline_manifest( + root, + Some("crates/server/axum.toml"), + Some("crates/server"), + None, + "demo-app", + None, + &[], + ) + .expect("baseline synthesis succeeds for scaffold-convention manifest"); + let (_, body) = outcome.into_iter().next().unwrap(); + assert!( + body.contains(r#"crate_dir = ".""#), + "scaffold-convention manifest must emit `crate_dir = \".\"` — got: {body}" + ); + } + + #[test] + fn provision_local_writes_env_name_lines() { + // Fixture: one store per kind. Local provision must: + // - write `.edgezero/.env` starting with the provenance + // header (Section 5 review fix — `# edgezero-provision: v1`); + // - emit one `__NAME` line per kind (KV / CONFIG / SECRETS); + // - emit a commented `__KEY` placeholder for CONFIG only. + let dir = tempdir().unwrap(); + let config_ids = ResolvedStoreId::from_logicals(&["app_config"]); + let kv_ids = ResolvedStoreId::from_logicals(&["sessions"]); + let secret_ids = ResolvedStoreId::from_logicals(&["default"]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, + }; + AxumCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + let env = fs::read_to_string(dir.path().join(".edgezero/.env")).unwrap(); + assert!( + env.starts_with("# edgezero-provision: v1"), + ".env must start with the provenance header: {env}" + ); + assert!( + env.contains("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=app_config"), + "config __NAME line present: {env}" + ); + assert!( + env.contains("EDGEZERO__STORES__KV__SESSIONS__NAME=sessions"), + "kv __NAME line present: {env}" + ); + assert!( + env.contains("EDGEZERO__STORES__SECRETS__DEFAULT__NAME=default"), + "secrets __NAME line present: {env}" + ); + assert!( + env.contains("# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_staging"), + "commented __KEY placeholder present for CONFIG only: {env}" + ); + } + + #[test] + fn re_provision_preserves_operator_env_edits() { + // First provision writes the base `.edgezero/.env` (including + // the commented `__KEY` placeholder). The operator uncomments + // AND edits the line to point at their own override value. + // Re-running provision must NOT re-add the commented form and + // MUST leave the operator's uncommented line byte-identical + // (dedup semantics — key-normalised uncommented + // form wins over any commented sibling). + let dir = tempdir().unwrap(); + let config_ids = ResolvedStoreId::from_logicals(&["app_config"]); + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + AxumCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + let env_path = dir.path().join(".edgezero/.env"); + let first = fs::read_to_string(&env_path).unwrap(); + assert!( + first.contains("# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_staging"), + "first-run must seed the commented placeholder: {first}" + ); + + // Operator uncomments AND edits the value. + let operator_line = "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=my_local_override"; + let edited = first.replace( + "# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_staging", + operator_line, + ); + fs::write(&env_path, &edited).unwrap(); + + AxumCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + let after = fs::read_to_string(&env_path).unwrap(); + let matching: Vec<&str> = after + .lines() + .filter(|line| *line == operator_line) + .collect(); + assert_eq!( + matching.len(), + 1, + "operator's uncommented override line must survive byte-identical: {after}" + ); + assert!( + !after.contains("# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY="), + "commented placeholder must NOT be re-added when uncommented form exists: {after}" + ); + } +} diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli/run.rs similarity index 53% rename from crates/edgezero-adapter-axum/src/cli.rs rename to crates/edgezero-adapter-axum/src/cli/run.rs index a609106f..1c90018c 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli/run.rs @@ -1,114 +1,18 @@ -use std::collections::BTreeMap; use std::env; use std::fs; -use std::io; use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; use std::process::Command; -use ctor::ctor; use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, -}; -use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, ResolvedStoreId, - register_adapter, -}; -use edgezero_adapter::scaffold::{ - AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, - ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, + self, find_manifest_upwards, find_workspace_root, path_distance, read_package_name, }; +use edgezero_adapter::registry::AdapterExecContext; use edgezero_core::addr; use edgezero_core::manifest::ManifestLoader; use toml::Value; use walkdir::WalkDir; -static AXUM_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ - TemplateRegistration { - name: "axum_Cargo_toml", - contents: include_str!("templates/Cargo.toml.hbs"), - }, - TemplateRegistration { - name: "axum_src_main_rs", - contents: include_str!("templates/src/main.rs.hbs"), - }, - TemplateRegistration { - name: "axum_axum_toml", - contents: include_str!("templates/axum.toml.hbs"), - }, -]; - -static AXUM_FILE_SPECS: &[AdapterFileSpec] = &[ - AdapterFileSpec { - template: "axum_Cargo_toml", - output: "Cargo.toml", - }, - AdapterFileSpec { - template: "axum_src_main_rs", - output: "src/main.rs", - }, - AdapterFileSpec { - template: "axum_axum_toml", - output: "axum.toml", - }, -]; - -static AXUM_DEPENDENCIES: &[DependencySpec] = &[ - DependencySpec { - key: "dep_edgezero_core_axum", - repo_crate: "crates/edgezero-core", - fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\" }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_axum", - repo_crate: "crates/edgezero-adapter-axum", - fallback: "edgezero-adapter-axum = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-axum\", default-features = false }", - features: &["axum"], - }, -]; - -static AXUM_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { - id: "axum", - display_name: "Axum", - crate_suffix: "adapter-axum", - dependency_crate: "edgezero-adapter-axum", - dependency_repo_path: "crates/edgezero-adapter-axum", - template_registrations: AXUM_TEMPLATE_REGISTRATIONS, - files: AXUM_FILE_SPECS, - extra_dirs: &["src"], - dependencies: AXUM_DEPENDENCIES, - manifest: ManifestSpec { - manifest_filename: "axum.toml", - build_target: "native", - build_profile: "dev", - build_features: &[], - }, - commands: CommandTemplates { - build: "cargo build -p {crate}", - serve: "cargo run -p {crate}", - deploy: "# configure deployment for Axum", - }, - logging: LoggingDefaults { - endpoint: None, - level: "info", - echo_stdout: Some(true), - }, - readme: ReadmeInfo { - description: "{display} adapter entrypoint.", - dev_heading: "{display} (local)", - dev_steps: &[ - "`cd {crate_dir}`", - "`cargo run` or `edgezero serve --adapter axum`", - ], - }, - run_module: "edgezero_adapter_axum", -}; - -static AXUM_ADAPTER: AxumCliAdapter = AxumCliAdapter; - -struct AxumCliAdapter; - #[derive(Debug)] struct AxumProject { addr: SocketAddr, @@ -128,261 +32,132 @@ struct EdgezeroAxumConfig { port: Option, } -#[expect( - clippy::missing_trait_methods, - reason = "axum has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `read_config_entry` delegates to `read_config_entry_local` (axum is local-only). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." -)] -impl Adapter for AxumCliAdapter { - fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { - match action { - // The axum adapter is the in-process native dev server — - // there is no remote auth provider to sign in/out of. - // Per spec this is an explicit no-op. - AdapterAction::AuthLogin | AdapterAction::AuthLogout | AdapterAction::AuthStatus => { - log::info!( - "[edgezero] axum has no remote auth surface; `auth` is a no-op for this adapter" - ); - Ok(()) - } - AdapterAction::Build => build(args), - AdapterAction::Deploy => deploy(args), - AdapterAction::Serve => serve(args), - other => Err(format!("axum adapter does not support {other:?}")), - } - } - - fn name(&self) -> &'static str { - "axum" - } - - fn provision( - &self, - _manifest_root: &Path, - _adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - stores: &ProvisionStores<'_>, - _dry_run: bool, - ) -> Result, String> { - //: 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 - // nothing to actually perform. - let mut out = Vec::with_capacity( - stores - .kv - .len() - .saturating_add(stores.config.len()) - .saturating_add(stores.secrets.len()), - ); - for store in stores.kv { - let logical = store.logical.as_str(); - out.push(format!( - "axum KV store `{logical}` is in-memory; nothing to provision" - )); - } - for store in stores.config { - // Axum reads `.edgezero/local-config-.json`. - // The platform name is informational here -- the env - // overlay isn't used for local file paths because the - // path encoding is the spec's canonical form. - let logical = store.logical.as_str(); - out.push(format!( - "axum config store `{logical}` reads `.edgezero/local-config-{logical}.json`; nothing to provision" - )); - } - for store in stores.secrets { - let logical = store.logical.as_str(); - out.push(format!( - "axum secret store `{logical}` reads env vars; nothing to provision" - )); - } - if out.is_empty() { - out.push("axum has no declared stores to provision".to_owned()); - } - Ok(out) - } - - fn push_config_entries( - &self, - manifest_root: &Path, - _adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - _push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - //: axum is local-only. Push writes the same flat - // `string -> string` JSON object `AxumConfigStore` reads - // back from `.edgezero/local-config-.json`. The path - // is keyed on the LOGICAL id, not the env-resolved - // platform name -- the local file flow is the spec's - // canonical form and isn't subject to the per-store env - // overlay (which targets platform store names, not local - // file paths). - let logical = store.logical.as_str(); - let local_dir = manifest_root.join(".edgezero"); - let target = local_dir.join(format!("local-config-{logical}.json")); - if dry_run { - return Ok(vec![format!( - "would write {} entries to {}", - entries.len(), - target.display() - )]); - } - fs::create_dir_all(&local_dir) - .map_err(|err| format!("failed to create {}: {err}", local_dir.display()))?; - // Upsert into any existing map so a `config push --key - // app_config_staging` doesn't wipe a previously-pushed - // `app_config` blob (spec 12.7 requires default + staging - // to coexist for the `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` - // override to switch between them). The map is owned (rather - // than borrowed) so we can merge old + new without lifetime - // surgery on the slice. - let mut map: BTreeMap = match fs::read_to_string(&target) { - Ok(text) if !text.trim().is_empty() => serde_json::from_str(&text).map_err(|err| { - format!( - "failed to parse existing {}: {err} (expected a JSON object of key->envelope)", - target.display() - ) - })?, - _ => BTreeMap::new(), - }; - for (key, value) in entries { - map.insert(key.clone(), value.clone()); - } - let json = serde_json::to_string_pretty(&map) - .map_err(|err| format!("failed to serialize config to JSON: {err}"))?; - fs::write(&target, json) - .map_err(|err| format!("failed to write {}: {err}", target.display()))?; - Ok(vec![format!( - "wrote {} entries to {} ({} total keys after upsert)", - entries.len(), - target.display(), - map.len(), - )]) - } - - fn push_config_entries_local( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - // Axum is local-only: the default push already writes - // `.edgezero/local-config-.json`, which is what the - // running dev server reads. `--local` is therefore the - // same as the default; we delegate and prepend a notice - // so the operator who typed `--local` for parity with - // fastly/cloudflare knows there was nothing extra to do. - let mut lines = self.push_config_entries( - manifest_root, - adapter_manifest_path, - component_selector, - store, - entries, - push_ctx, - dry_run, - )?; - let notice = - "axum push is always local: `--local` has no separate effect (writes the same `.edgezero/local-config-.json` either way)".to_owned(); - lines.insert(0, notice); - Ok(lines) - } - - fn read_config_entry( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - push_ctx: &AdapterPushContext<'_>, - ) -> Result { - // Axum has no "remote" — delegate to the local impl. - // The local JSON file IS the live state for the running dev server. - self.read_config_entry_local( - manifest_root, - adapter_manifest_path, - component_selector, - store, - key, - push_ctx, - ) - } - - fn read_config_entry_local( - &self, - manifest_root: &Path, - _adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - _push_ctx: &AdapterPushContext<'_>, - ) -> Result { - // Axum reads `.edgezero/local-config-.json`. - // The path is keyed on the LOGICAL id (matching - // `push_config_entries`), not the env-resolved platform name. - let path = manifest_root - .join(".edgezero") - .join(format!("local-config-{}.json", store.logical)); - match fs::read_to_string(&path) { - Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(ReadConfigEntry::MissingStore), - Err(err) => Err(format!("failed to read {}: {err}", path.display())), - Ok(raw) => { - let map: BTreeMap = serde_json::from_str(&raw) - .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; - match map.get(key) { - Some(value) => Ok(ReadConfigEntry::Present(value.clone())), - None => Ok(ReadConfigEntry::MissingKey), - } - } - } - } - - fn single_store_kinds(&self) -> &'static [&'static str] { - //: axum is Multi for KV (local file dirs) and Config - // (local JSON files), Single for Secrets (env vars). - &["secrets"] - } -} - -#[inline] -pub fn register() { - register_adapter(&AXUM_ADAPTER); - register_adapter_blueprint(&AXUM_BLUEPRINT); +/// Emit the baseline `axum.toml` contents for a fresh scaffold / +/// clean-clone bootstrap. +/// +/// `crate_name` is the adapter crate's package name (typically +/// `-adapter-axum`). +/// +/// `crate_dir` is the RELATIVE path from the manifest's parent +/// directory to the crate root (the dir carrying `Cargo.toml`). +/// The scaffold convention `crates//axum.toml` uses `"."` +/// (manifest lives at the crate root). A nested manifest like +/// `crates/server/config/axum.toml` needs `".."` — otherwise the +/// axum-adapter loader looks for `config/Cargo.toml` and fails +/// discovery. +/// +/// All other fields use dev-server defaults the operator can +/// edit; provision's merge path never touches this file after +/// the first write, so operator edits (custom host, non-default +/// port) survive. +pub(super) fn synthesise_axum_toml(crate_name: &str, crate_dir: &str) -> String { + use toml_edit::{DocumentMut, Item, Table, value}; + + // Built through `toml_edit`, never raw string interpolation: a crate + // name or directory carrying a quote, backslash, or newline would + // otherwise emit a file that isn't valid TOML. `toml_edit` escapes + // the values for us. + let mut doc = DocumentMut::new(); + doc.decor_mut().set_prefix("# edgezero-provision: v1\n"); + let mut adapter = Table::new(); + adapter.insert("crate", value(crate_name)); + adapter.insert("crate_dir", value(crate_dir)); + adapter.insert("host", value("127.0.0.1")); + adapter.insert("port", value(8787_i64)); + doc.insert("adapter", Item::Table(adapter)); + doc.to_string() } -#[ctor(unsafe)] -fn register_ctor() { - register(); +pub(super) fn build(extra_args: &[String], ctx: &AdapterExecContext<'_>) -> Result<(), String> { + let project = locate_project(ctx)?; + run_cargo(&project, "build", extra_args, ctx) } -fn build(extra_args: &[String]) -> Result<(), String> { - let project = locate_project()?; - run_cargo(&project, "build", extra_args) +pub(super) fn serve(extra_args: &[String], ctx: &AdapterExecContext<'_>) -> Result<(), String> { + let project = locate_project(ctx)?; + run_cargo(&project, "run", extra_args, ctx) } -fn serve(extra_args: &[String]) -> Result<(), String> { - let project = locate_project()?; - run_cargo(&project, "run", extra_args) +pub(super) fn deploy(_extra_args: &[String]) -> Result<(), String> { + Err("Axum adapter does not define a deploy command. Extend your workspace manifest with one if needed.".into()) } -fn deploy(_extra_args: &[String]) -> Result<(), String> { - Err("Axum adapter does not define a deploy command. Extend your workspace manifest with one if needed.".into()) +fn locate_project(ctx: &AdapterExecContext<'_>) -> Result { + let manifest = cli_support::declared_or_discovered_manifest(ctx, || { + find_axum_manifest(&cli_support::discovery_base(ctx)?) + })?; + let project = read_axum_project(&manifest)?; + // `axum.toml`'s `crate_dir` is operator-editable. Refuse to spawn + // cargo against it until we've confirmed it neither escapes the + // project nor contradicts the authoritative declared `.crate`. + validate_axum_crate_dir(ctx, &manifest, &project)?; + Ok(project) } -fn locate_project() -> Result { - let cwd = env::current_dir().map_err(|err| err.to_string())?; - let manifest = find_axum_manifest(&cwd)?; - read_axum_project(&manifest) +/// Guard the operator-editable `axum.toml` `crate_dir` BEFORE `run_cargo` +/// spawns cargo with `--manifest-path /Cargo.toml` and +/// `current_dir()`. `crate_dir` is CANONICALISED (it must +/// already exist -- `read_axum_project` verified its `Cargo.toml`), which +/// resolves BOTH `..` traversal AND symlinks to a real path, so this one +/// check covers conflicts, `..` escapes, and symlink escapes: +/// - when the CLI supplied the authoritative declared `.crate` +/// (`ctx.adapter_crate()`), the resolved `crate_dir` MUST canonicalise +/// to the same directory -- an operator who hand-edits `crate_dir` to +/// a different crate (or symlinks it elsewhere) is refused rather than +/// silently building that crate; +/// - standalone (no declared `.crate`), `crate_dir` must stay under the +/// workspace root, so a traversing/symlinked value can't point cargo +/// at a crate outside the project. +fn validate_axum_crate_dir( + ctx: &AdapterExecContext<'_>, + manifest: &Path, + project: &AxumProject, +) -> Result<(), String> { + let crate_canon = project.crate_dir.canonicalize().map_err(|err| { + format!( + "cannot resolve axum.toml `crate_dir` `{}`: {err}", + project.crate_dir.display() + ) + })?; + if let Some(declared) = ctx.adapter_crate() { + let declared_canon = declared.canonicalize().map_err(|err| { + format!( + "cannot resolve tracked `[adapters.axum.adapter].crate` `{}`: {err}", + declared.display() + ) + })?; + if crate_canon != declared_canon { + return Err(format!( + "axum.toml `crate_dir` resolves to `{}`, but the tracked \ + `[adapters.axum.adapter].crate` points at `{}`. The declared crate is \ + authoritative; refusing to build a different crate. Fix `crate_dir` in axum.toml \ + (or `.crate` in edgezero.toml) so they agree.", + crate_canon.display(), + declared_canon.display() + )); + } + } else { + let manifest_dir = manifest.parent().unwrap_or_else(|| Path::new(".")); + let root = find_workspace_root(manifest_dir); + let root_canon = root.canonicalize().unwrap_or(root); + if !crate_canon.starts_with(&root_canon) { + return Err(format!( + "axum.toml `crate_dir` resolves to `{}`, which is OUTSIDE the project (`{}`). \ + Refusing to run cargo against a crate outside the workspace.", + crate_canon.display(), + root_canon.display() + )); + } + } + Ok(()) } -fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> Result<(), String> { +fn run_cargo( + project: &AxumProject, + subcommand: &str, + extra_args: &[String], + ctx: &AdapterExecContext<'_>, +) -> Result<(), String> { let resolution = resolve_subprocess_addr(project)?; for warning in &resolution.warnings { log::warn!("[edgezero] {warning}"); @@ -405,12 +180,31 @@ fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> ); command.args(extra_args); command.current_dir(&project.crate_dir); - // Canonical env vars. The runtime's `EnvConfig` reads only the - // `EDGEZERO__*` form (see `crates/edgezero-core/src/env_config.rs`); - // setting the legacy `EDGEZERO_HOST` / `EDGEZERO_PORT` here would be a - // no-op for the child process. - command.env("EDGEZERO__ADAPTER__HOST", bind_addr.ip().to_string()); - command.env("EDGEZERO__ADAPTER__PORT", bind_addr.port().to_string()); + // Child env from the registry-fallback context (the provision- + // written `.env` overlay + manifest `[environment.variables]`). + // Applied BEFORE the canonical HOST/PORT below so this adapter's + // richer address resolution (which reads axum.toml + edgezero.toml) + // wins on those two keys. Empty when dispatched via the manifest + // `commands.serve` shell path, so behaviour there is unchanged. + for (key, value) in ctx.env() { + command.env(key, value); + } + // Canonical HOST/PORT fall back to this adapter's address resolution + // (axum.toml + edgezero.toml). But the resolved CONTEXT environment + // (`.edgezero/.env` overlay + manifest `[environment.variables]`, + // applied above) has HIGHER precedence and must reach the child + // UNCHANGED -- `resolve_subprocess_addr` only reads the process env, so + // it can't see a ctx-provided value. Set the derived address ONLY when + // ctx did not already provide it, or the ctx value would silently lose + // to axum.toml. The runtime's `EnvConfig` reads only the `EDGEZERO__*` + // form (see `crates/edgezero-core/src/env_config.rs`). + let ctx_sets = |name: &str| ctx.env().iter().any(|(key, _)| key == name); + if !ctx_sets("EDGEZERO__ADAPTER__HOST") { + command.env("EDGEZERO__ADAPTER__HOST", bind_addr.ip().to_string()); + } + if !ctx_sets("EDGEZERO__ADAPTER__PORT") { + command.env("EDGEZERO__ADAPTER__PORT", bind_addr.port().to_string()); + } let status = command .status() .map_err(|err| format!("failed to run cargo {subcommand}: {err}"))?; @@ -688,6 +482,173 @@ mod tests { use std::net::Ipv6Addr; use tempfile::tempdir; + #[test] + fn synthesise_axum_toml_emits_parseable_baseline() { + let rendered = synthesise_axum_toml("demo-adapter-axum", "."); + assert!( + rendered.starts_with("# edgezero-provision: v1\n"), + "provision marker retained: {rendered}" + ); + let doc: toml_edit::DocumentMut = rendered.parse().expect("baseline must be valid TOML"); + assert_eq!(doc["adapter"]["crate"].as_str(), Some("demo-adapter-axum")); + assert_eq!(doc["adapter"]["crate_dir"].as_str(), Some(".")); + assert_eq!(doc["adapter"]["host"].as_str(), Some("127.0.0.1")); + assert_eq!(doc["adapter"]["port"].as_integer(), Some(8787)); + } + + #[test] + fn synthesise_axum_toml_escapes_toml_significant_names() { + // A quote/backslash in the app (hence crate) name must not break + // out of the string and emit an unparseable manifest. + let rendered = synthesise_axum_toml("we\"ird\\name", "../up"); + let doc: toml_edit::DocumentMut = rendered + .parse() + .expect("a pathological crate name must still produce valid TOML"); + assert_eq!( + doc["adapter"]["crate"].as_str(), + Some("we\"ird\\name"), + "value round-trips through escaping: {rendered}" + ); + assert_eq!(doc["adapter"]["crate_dir"].as_str(), Some("../up")); + } + + fn project_with_crate_dir(crate_dir: PathBuf) -> AxumProject { + AxumProject { + addr: SocketAddr::new(addr::DEFAULT_HOST, addr::DEFAULT_PORT), + axum_host: None, + axum_manifest: PathBuf::new(), + axum_port: None, + cargo_manifest: crate_dir.join("Cargo.toml"), + crate_dir, + crate_name: "demo".to_owned(), + env_host: None, + env_port: None, + } + } + + #[cfg(unix)] + #[test] + fn run_cargo_lets_ctx_env_host_port_win_over_axum_resolution() { + use edgezero_core::test_env::PathPrepend; + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().unwrap(); + let crate_dir = dir.path().join("crate"); + fs::create_dir_all(&crate_dir).unwrap(); + fs::write(crate_dir.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap(); + + // A fake `cargo` that records the HOST/PORT env it was handed. + let bin = dir.path().join("bin"); + fs::create_dir_all(&bin).unwrap(); + let env_log = dir.path().join("child_env.txt"); + let script = format!( + "#!/bin/sh\nprintf 'HOST=%s\\nPORT=%s\\n' \"$EDGEZERO__ADAPTER__HOST\" \"$EDGEZERO__ADAPTER__PORT\" > '{}'\nexit 0\n", + env_log.display() + ); + let cargo = bin.join("cargo"); + fs::write(&cargo, script).unwrap(); + fs::set_permissions(&cargo, fs::Permissions::from_mode(0o755)).unwrap(); + let _path = PathPrepend::new(&bin); + + // The project's own resolution derives 9.9.9.9:1234 from axum.toml. + let mut project = project_with_crate_dir(crate_dir.clone()); + project.addr = SocketAddr::new("9.9.9.9".parse().unwrap(), 1234); + project.axum_host = Some("9.9.9.9".to_owned()); + project.axum_port = Some(1234); + + // The resolved context env sets DIFFERENT values -- they must reach + // the child UNCHANGED (they have higher precedence than axum.toml). + let env = [ + ("EDGEZERO__ADAPTER__HOST".to_owned(), "1.2.3.4".to_owned()), + ("EDGEZERO__ADAPTER__PORT".to_owned(), "5678".to_owned()), + ]; + let ctx = AdapterExecContext::new().with_env(&env); + run_cargo(&project, "build", &[], &ctx).expect("fake cargo runs"); + + let logged = fs::read_to_string(&env_log).unwrap(); + assert!( + logged.contains("HOST=1.2.3.4"), + "ctx HOST must reach the child unchanged, not lose to axum.toml: {logged}" + ); + assert!( + logged.contains("PORT=5678"), + "ctx PORT must reach the child unchanged, not lose to axum.toml: {logged}" + ); + } + + #[test] + fn validate_axum_crate_dir_rejects_conflict_with_declared_crate() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::create_dir_all(root.join("crates/server")).unwrap(); + fs::create_dir_all(root.join("crates/other")).unwrap(); + // axum.toml points crate_dir at `crates/other`, but the tracked + // `.crate` is `crates/server` -- the declared crate wins. + let project = project_with_crate_dir(root.join("crates/other")); + let declared = root.join("crates/server"); + let ctx = AdapterExecContext::new().with_adapter_crate(&declared); + let err = validate_axum_crate_dir(&ctx, &root.join("axum.toml"), &project) + .expect_err("crate_dir conflicting with declared .crate must be refused"); + assert!( + err.contains("authoritative") && err.contains("crate_dir"), + "error explains the conflict: {err}" + ); + } + + #[test] + fn validate_axum_crate_dir_accepts_matching_declared_crate() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::create_dir_all(root.join("crates/server")).unwrap(); + let crate_dir = root.join("crates/server"); + let ctx = AdapterExecContext::new().with_adapter_crate(&crate_dir); + validate_axum_crate_dir( + &ctx, + &root.join("crates/server/config/axum.toml"), + &project_with_crate_dir(crate_dir.clone()), + ) + .expect("crate_dir matching the declared .crate is fine"); + } + + #[test] + fn validate_axum_crate_dir_rejects_escape_outside_workspace() { + let dir = tempdir().unwrap(); + let base = dir.path(); + // Workspace root under `base`; the escape target is a sibling of + // the workspace (so it exists for canonicalize but is off-tree). + let ws = base.join("ws"); + fs::create_dir_all(ws.join("proj")).unwrap(); + fs::write(ws.join("Cargo.toml"), "[workspace]\n").unwrap(); + fs::create_dir_all(base.join("outside")).unwrap(); + // No declared `.crate` (standalone). `..` climbs out of the ws. + let escaping = ws.join("proj/../../outside"); + let project = project_with_crate_dir(escaping); + let ctx = AdapterExecContext::new(); + let err = validate_axum_crate_dir(&ctx, &ws.join("proj/axum.toml"), &project) + .expect_err("a crate_dir escaping the workspace must be refused"); + assert!(err.contains("OUTSIDE"), "error explains the escape: {err}"); + } + + #[cfg(unix)] + #[test] + fn validate_axum_crate_dir_rejects_symlink_escape() { + use std::os::unix::fs::symlink; + let dir = tempdir().unwrap(); + let base = dir.path(); + let ws = base.join("ws"); + fs::create_dir_all(ws.join("proj")).unwrap(); + fs::write(ws.join("Cargo.toml"), "[workspace]\n").unwrap(); + // Symlink target lives OUTSIDE the workspace; canonicalize + // resolves the link to it, so containment must reject. + let outside = base.join("outside-crate"); + fs::create_dir_all(&outside).unwrap(); + symlink(&outside, ws.join("proj/link")).unwrap(); + let project = project_with_crate_dir(ws.join("proj/link")); + let ctx = AdapterExecContext::new(); + let err = validate_axum_crate_dir(&ctx, &ws.join("proj/axum.toml"), &project) + .expect_err("a symlinked crate_dir escaping the workspace must be refused"); + assert!(err.contains("OUTSIDE"), "error explains the escape: {err}"); + } + #[test] fn read_axum_project_loads_defaults() { let dir = tempdir().unwrap(); @@ -1076,11 +1037,6 @@ mod tests { ); } - #[test] - fn adapter_name_is_axum() { - assert_eq!(AXUM_ADAPTER.name(), "axum"); - } - #[test] fn read_axum_project_env_overrides_config() { let dir = tempdir().unwrap(); @@ -1175,289 +1131,4 @@ mod tests { assert_eq!(resolution.addr, SocketAddr::from(([127, 0, 0, 1], 3000))); assert_eq!(resolution.warnings.len(), 1); } - - #[test] - fn blueprint_has_correct_id() { - assert_eq!(AXUM_BLUEPRINT.id, "axum"); - assert_eq!(AXUM_BLUEPRINT.display_name, "Axum"); - } - - // ---------- push_config_entries ---------- - - #[test] - fn push_writes_flat_json_to_local_config_file() { - let dir = tempfile::tempdir().expect("tempdir"); - let entries = vec![ - ("greeting".to_owned(), "hello".to_owned()), - ("service.timeout_ms".to_owned(), "1500".to_owned()), - ]; - let lines = AxumCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical("app_config"), - &entries, - &AdapterPushContext::new(), - false, - ) - .expect("push succeeds"); - assert_eq!(lines.len(), 1); - assert!( - lines[0].contains("wrote 2 entries"), - "status line names count: {lines:?}" - ); - let json_path = dir.path().join(".edgezero/local-config-app_config.json"); - let raw = fs::read_to_string(&json_path).expect("read written file"); - let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); - assert_eq!(parsed["greeting"], "hello"); - assert_eq!(parsed["service.timeout_ms"], "1500"); - } - - #[test] - fn push_dry_run_does_not_create_local_dir_or_file() { - let dir = tempfile::tempdir().expect("tempdir"); - let entries = vec![("greeting".to_owned(), "hello".to_owned())]; - let lines = AxumCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical("app_config"), - &entries, - &AdapterPushContext::new(), - true, - ) - .expect("dry-run succeeds"); - assert!( - lines[0].contains("would write 1 entries"), - "dry-run line: {lines:?}" - ); - assert!( - !dir.path().join(".edgezero").exists(), - ".edgezero must not exist after dry-run" - ); - } - - #[test] - fn push_creates_dot_edgezero_directory_when_missing() { - let dir = tempfile::tempdir().expect("tempdir"); - let entries = vec![("key".to_owned(), "value".to_owned())]; - AxumCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical("x"), - &entries, - &AdapterPushContext::new(), - false, - ) - .expect("push succeeds"); - assert!(dir.path().join(".edgezero").is_dir(), ".edgezero created"); - } - - #[test] - fn push_with_empty_entries_writes_empty_json_object() { - let dir = tempfile::tempdir().expect("tempdir"); - AxumCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical("empty"), - &[], - &AdapterPushContext::new(), - false, - ) - .expect("push succeeds even with no entries"); - let raw = fs::read_to_string(dir.path().join(".edgezero/local-config-empty.json")) - .expect("read written file"); - let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); - assert_eq!(parsed, serde_json::json!({})); - } - - // ---------- read_config_entry / read_config_entry_local ---------- - - #[test] - fn read_config_entry_local_returns_missing_store_when_file_absent() { - let dir = tempfile::tempdir().expect("tempdir"); - let result = AxumCliAdapter - .read_config_entry_local( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical("app_config"), - "greeting", - &AdapterPushContext::new(), - ) - .expect("infallible on missing file"); - assert!( - matches!(result, ReadConfigEntry::MissingStore), - "missing file => MissingStore" - ); - } - - #[test] - fn read_config_entry_local_returns_missing_key_when_key_absent() { - let dir = tempfile::tempdir().expect("tempdir"); - // Write a JSON file with one key so the store exists, but the - // requested key is not in it. - let local_dir = dir.path().join(".edgezero"); - fs::create_dir_all(&local_dir).expect("create dir"); - fs::write( - local_dir.join("local-config-app_config.json"), - r#"{"other_key": "value"}"#, - ) - .expect("write"); - let result = AxumCliAdapter - .read_config_entry_local( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical("app_config"), - "greeting", - &AdapterPushContext::new(), - ) - .expect("infallible on missing key"); - assert!( - matches!(result, ReadConfigEntry::MissingKey), - "key absent => MissingKey" - ); - } - - #[test] - fn read_config_entry_local_returns_present_when_key_exists() { - let dir = tempfile::tempdir().expect("tempdir"); - let local_dir = dir.path().join(".edgezero"); - fs::create_dir_all(&local_dir).expect("create dir"); - fs::write( - local_dir.join("local-config-app_config.json"), - r#"{"greeting": "hello-axum"}"#, - ) - .expect("write"); - let result = AxumCliAdapter - .read_config_entry_local( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical("app_config"), - "greeting", - &AdapterPushContext::new(), - ) - .expect("key present"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present variant"); - }; - assert_eq!(value, "hello-axum", "value matches"); - } - - #[test] - fn read_config_entry_delegates_to_local() { - // Axum has no remote: read_config_entry and read_config_entry_local - // must return the same result for the same inputs. - let dir = tempfile::tempdir().expect("tempdir"); - let local_dir = dir.path().join(".edgezero"); - fs::create_dir_all(&local_dir).expect("create dir"); - fs::write( - local_dir.join("local-config-app_config.json"), - r#"{"greeting": "hello-axum"}"#, - ) - .expect("write"); - let store = ResolvedStoreId::from_logical("app_config"); - let ctx = AdapterPushContext::new(); - let via_local = AxumCliAdapter - .read_config_entry_local(dir.path(), None, None, &store, "greeting", &ctx) - .expect("local ok"); - let via_remote = AxumCliAdapter - .read_config_entry(dir.path(), None, None, &store, "greeting", &ctx) - .expect("remote ok"); - let ReadConfigEntry::Present(local_val) = via_local else { - panic!("expected Present from local"); - }; - let ReadConfigEntry::Present(remote_val) = via_remote else { - panic!("expected Present from remote"); - }; - assert_eq!(local_val, remote_val, "local and remote agree"); - } - - #[test] - fn read_config_entry_local_errors_on_malformed_json() { - let dir = tempfile::tempdir().expect("tempdir"); - let local_dir = dir.path().join(".edgezero"); - fs::create_dir_all(&local_dir).expect("create dir"); - fs::write( - local_dir.join("local-config-app_config.json"), - "not valid json {{{", - ) - .expect("write"); - let result = AxumCliAdapter.read_config_entry_local( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical("app_config"), - "greeting", - &AdapterPushContext::new(), - ); - match result { - Err(err) => assert!( - err.contains("failed to parse"), - "error names the failure: {err}" - ), - Ok(_) => panic!("expected Err for malformed JSON"), - } - } - - /// Spec 12.7: pushing two blobs under different keys (e.g. - /// `app_config` + `app_config_staging`) must leave both keys - /// readable so the runtime - /// `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` override can - /// switch between them. Prior to the upsert fix the second push - /// wiped the first by wholesale-rewriting the JSON map. - #[test] - fn push_config_entries_preserves_sibling_keys() { - let dir = tempfile::tempdir().expect("tempdir"); - let store = ResolvedStoreId::from_logical("app_config"); - let ctx = AdapterPushContext::new(); - - AxumCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &store, - &[("app_config".to_owned(), "{\"envelope\":\"A\"}".to_owned())], - &ctx, - false, - ) - .expect("first push"); - AxumCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &store, - &[( - "app_config_staging".to_owned(), - "{\"envelope\":\"B\"}".to_owned(), - )], - &ctx, - false, - ) - .expect("second push (sibling key)"); - - let raw = fs::read_to_string(dir.path().join(".edgezero/local-config-app_config.json")) - .expect("read"); - let map: BTreeMap = serde_json::from_str(&raw).expect("parse map"); - assert_eq!( - map.get("app_config").map(String::as_str), - Some("{\"envelope\":\"A\"}"), - "default key must survive sibling push: {raw}" - ); - assert_eq!( - map.get("app_config_staging").map(String::as_str), - Some("{\"envelope\":\"B\"}"), - "staging key must be present: {raw}" - ); - } } diff --git a/crates/edgezero-adapter-axum/src/config_store.rs b/crates/edgezero-adapter-axum/src/config_store.rs index 19b7ddfd..6db7295d 100644 --- a/crates/edgezero-adapter-axum/src/config_store.rs +++ b/crates/edgezero-adapter-axum/src/config_store.rs @@ -97,6 +97,18 @@ impl AxumConfigStore { /// exists but cannot be read or parsed. #[inline] pub fn from_path(path: &Path) -> Result { + // Reject a symlinked final component before reading. `config push + // --adapter axum` / provision refuse to WRITE these files through a + // symlink; a legitimately-provisioned local config is therefore + // never a symlink, so one appearing here is anomalous. Rejecting it + // on the read path too keeps a single consistent final-path policy + // and stops a planted symlink from redirecting the runtime read. + if fs::symlink_metadata(path).is_ok_and(|md| md.file_type().is_symlink()) { + return Err(ConfigStoreError::unavailable(format!( + "refusing to read `{}`: it is a symlink; EdgeZero-owned local config is never a symlink", + path.display() + ))); + } let raw = match fs::read_to_string(path) { Ok(raw) => raw, Err(err) if err.kind() == ErrorKind::NotFound => { @@ -169,10 +181,10 @@ impl ConfigStore for AxumConfigStore { /// root without finding one. /// /// Used by [`AxumConfigStore::local_path`] to keep push and runtime -/// on the same path regardless of launch cwd. Pulled out as a free -/// function so the same discovery rule can be reused by other -/// runtime helpers in the future. -fn find_project_root_dir() -> Option { +/// on the same path regardless of launch cwd. Also reused by the dev +/// server's KV path anchoring so config and KV state land in the SAME +/// `.edgezero` directory. +pub(crate) fn find_project_root_dir() -> Option { find_project_root_dir_from(&env::current_dir().ok()?) } @@ -202,6 +214,26 @@ mod tests { use futures::executor::block_on; use tempfile::tempdir; + #[cfg(unix)] + #[test] + fn from_path_rejects_symlinked_config_file() { + use std::os::unix::fs::symlink; + // A planted symlink where the local config is expected must be + // refused on the READ path, matching the write side. + let temp = tempdir().expect("tempdir"); + let real = temp.path().join("real.json"); + fs::write(&real, "{\"k\":\"v\"}").expect("write real"); + let link = temp.path().join("local-config-app.json"); + symlink(&real, &link).expect("symlink"); + let Err(err) = AxumConfigStore::from_path(&link) else { + panic!("symlinked config must be refused, not silently followed"); + }; + assert!( + matches!(err, ConfigStoreError::Unavailable { .. }), + "symlinked config is Unavailable" + ); + } + #[test] fn axum_config_store_from_map_returns_values() { let cs = AxumConfigStore::from_map([("greeting".to_owned(), "hello".to_owned())]); diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 1a5405c0..2233413d 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -262,8 +262,36 @@ fn stable_store_name_hash(store_name: &str) -> u64 { hash } +/// Anchor a `.edgezero`-relative store path at the project root (the +/// ancestor holding `edgezero.toml`), the SAME directory `config push` / +/// provision write `.edgezero` into, so the runtime and the CLI agree +/// regardless of launch cwd -- a registry `serve` launches this binary with +/// cwd = the adapter crate, NOT the project root, so a bare relative +/// `.edgezero` would land KV state in the crate dir. Falls back to the +/// cwd-relative path in a deployed binary with no `edgezero.toml` alongside, +/// matching [`AxumConfigStore::local_path`]. +fn anchor_at_project_root(relative: PathBuf) -> PathBuf { + use crate::config_store::find_project_root_dir; + match find_project_root_dir() { + Some(root) => root.join(relative), + None => relative, + } +} + fn kv_handle_from_path(kv_path: &Path) -> anyhow::Result { if let Some(parent) = kv_path.parent() { + // Refuse to write THROUGH a symlinked `.edgezero`: EdgeZero-owned + // local state is never a symlink, and following one would drop the + // redb outside the project tree. Mirrors the provision-side + // symlink rejection. + if let Ok(meta) = fs::symlink_metadata(parent) + && meta.file_type().is_symlink() + { + anyhow::bail!( + "refusing to open KV store: `{}` is a symlink; EdgeZero-owned local state (`.edgezero/`) is never a symlink -- replace it with a regular directory", + parent.display() + ); + } fs::create_dir_all(parent).context("failed to create KV store directory")?; } let kv_store = Arc::new(PersistentKvStore::new(kv_path).context("failed to create KV store")?); @@ -399,7 +427,7 @@ fn build_kv_registry( let mut by_id: BTreeMap = BTreeMap::new(); for id in meta.ids { let store_name = env.store_name("kv", id); - let kv_path = kv_store_path(&store_name); + let kv_path = anchor_at_project_root(kv_store_path(&store_name)); let handle = match kv_handle_from_path(&kv_path) { Ok(handle) => handle, Err(err) => match init { @@ -659,6 +687,30 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn kv_handle_refuses_a_symlinked_dot_edgezero_dir() { + use std::os::unix::fs::symlink; + use tempfile::tempdir; + // A planted symlink where `.edgezero/` is expected must be refused, + // so the redb is never written THROUGH a link outside the tree. + let temp = tempdir().expect("tempdir"); + let outside = temp.path().join("outside"); + fs::create_dir_all(&outside).expect("mkdir outside"); + let dot = temp.path().join(".edgezero"); + symlink(&outside, &dot).expect("symlink .edgezero"); + let kv_path = dot.join("kv-sessions-0000000000000000.redb"); + let err = kv_handle_from_path(&kv_path).expect_err("a symlinked .edgezero must be refused"); + assert!( + err.to_string().contains("symlink"), + "error names the symlink: {err}" + ); + assert!( + !outside.join("kv-sessions-0000000000000000.redb").exists(), + "the refused open must not have created the db through the link" + ); + } + #[test] fn resolve_addr_defaults_without_env_config() { let empty: [(&str, &str); 0] = []; diff --git a/crates/edgezero-adapter-axum/src/templates/axum.toml.hbs b/crates/edgezero-adapter-axum/src/templates/axum.toml.hbs deleted file mode 100644 index 30fc6e19..00000000 --- a/crates/edgezero-adapter-axum/src/templates/axum.toml.hbs +++ /dev/null @@ -1,5 +0,0 @@ -[adapter] -crate = "{{proj_axum}}" -crate_dir = "." -host = "127.0.0.1" -port = 8787 diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs deleted file mode 100644 index 14a92df0..00000000 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ /dev/null @@ -1,2139 +0,0 @@ -use std::collections::BTreeSet; -use std::env; -use std::fs; -use std::io::ErrorKind; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use ctor::ctor; -use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, -}; -use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, ResolvedStoreId, - register_adapter, -}; -use edgezero_adapter::scaffold::{ - AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, - ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, -}; -use walkdir::WalkDir; - -static CLOUDFLARE_ADAPTER: CloudflareCliAdapter = CloudflareCliAdapter; - -static CLOUDFLARE_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { - id: "cloudflare", - display_name: "Cloudflare Workers", - crate_suffix: "adapter-cloudflare", - dependency_crate: "edgezero-adapter-cloudflare", - dependency_repo_path: "crates/edgezero-adapter-cloudflare", - template_registrations: CLOUDFLARE_TEMPLATE_REGISTRATIONS, - files: CLOUDFLARE_FILE_SPECS, - extra_dirs: &["src", ".cargo"], - dependencies: CLOUDFLARE_DEPENDENCIES, - manifest: ManifestSpec { - manifest_filename: "wrangler.toml", - build_target: "wasm32-unknown-unknown", - build_profile: "release", - build_features: &["cloudflare"], - }, - commands: CommandTemplates { - build: "wrangler build --cwd {crate_dir}", - deploy: "wrangler deploy --cwd {crate_dir}", - serve: "wrangler dev --cwd {crate_dir}", - }, - logging: LoggingDefaults { - endpoint: None, - level: "info", - echo_stdout: None, - }, - readme: ReadmeInfo { - description: "{display} entrypoint.", - dev_heading: "{display} (local)", - dev_steps: &["`edgezero serve --adapter cloudflare`"], - }, - run_module: "edgezero_adapter_cloudflare", -}; - -static CLOUDFLARE_DEPENDENCIES: &[DependencySpec] = &[ - DependencySpec { - key: "dep_edgezero_core_cloudflare", - repo_crate: "crates/edgezero-core", - fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\", default-features = false }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_cloudflare", - repo_crate: "crates/edgezero-adapter-cloudflare", - fallback: "edgezero-adapter-cloudflare = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-cloudflare\", default-features = false }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_cloudflare_wasm", - repo_crate: "crates/edgezero-adapter-cloudflare", - fallback: "edgezero-adapter-cloudflare = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-cloudflare\", default-features = false, features = [\"cloudflare\"] }", - features: &["cloudflare"], - }, -]; - -static CLOUDFLARE_FILE_SPECS: &[AdapterFileSpec] = &[ - AdapterFileSpec { - template: "cf_Cargo_toml", - output: "Cargo.toml", - }, - AdapterFileSpec { - template: "cf_src_lib_rs", - output: "src/lib.rs", - }, - AdapterFileSpec { - template: "cf_src_main_rs", - output: "src/main.rs", - }, - AdapterFileSpec { - template: "cf_cargo_config_toml", - output: ".cargo/config.toml", - }, - AdapterFileSpec { - template: "cf_wrangler_toml", - output: "wrangler.toml", - }, -]; - -static CLOUDFLARE_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ - TemplateRegistration { - name: "cf_Cargo_toml", - contents: include_str!("templates/Cargo.toml.hbs"), - }, - TemplateRegistration { - name: "cf_src_lib_rs", - contents: include_str!("templates/src/lib.rs.hbs"), - }, - TemplateRegistration { - name: "cf_src_main_rs", - contents: include_str!("templates/src/main.rs.hbs"), - }, - TemplateRegistration { - name: "cf_cargo_config_toml", - contents: include_str!("templates/.cargo/config.toml.hbs"), - }, - TemplateRegistration { - name: "cf_wrangler_toml", - contents: include_str!("templates/wrangler.toml.hbs"), - }, -]; - -const TARGET_TRIPLE: &str = "wasm32-unknown-unknown"; - -const WRANGLER_INSTALL_HINT: &str = - "install the Cloudflare CLI (`npm install -g wrangler`) and try again"; - -struct CloudflareCliAdapter; - -#[expect( - clippy::missing_trait_methods, - 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 execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { - match action { - // `wrangler` is the native sign-in surface for Cloudflare - // Workers. EdgeZero stores no credentials — this is a thin - // shell-out. - AdapterAction::AuthLogin => { - run_native_cli("wrangler", &["login"], WRANGLER_INSTALL_HINT) - } - AdapterAction::AuthLogout => { - run_native_cli("wrangler", &["logout"], WRANGLER_INSTALL_HINT) - } - AdapterAction::AuthStatus => { - run_native_cli("wrangler", &["whoami"], WRANGLER_INSTALL_HINT) - } - AdapterAction::Build => build(args).map(|artifact| { - log::info!( - "[edgezero] Cloudflare build artifact -> {}", - artifact.display() - ); - }), - AdapterAction::Deploy => deploy(args), - AdapterAction::Serve => serve(args), - other => Err(format!("cloudflare adapter does not support {other:?}")), - } - } - - fn merged_id_kinds(&self) -> &'static [&'static str] { - // Both KV and Config back to Worker KV namespaces via the - // same `[[kv_namespaces]] binding = ` - // wrangler.toml entry. Declaring the same logical id under - // both kinds (e.g. `[stores.kv].ids = ["x"]` AND - // `[stores.config].ids = ["x"]`) resolves to a SINGLE - // underlying KV namespace at runtime — KV writes from the - // app silently clobber config-shaped entries (and vice - // versa). Provision compounds the hazard: the second - // binding would already be present from the first kind's - // `upsert_kv_namespace` and get reported as "already - // provisioned" instead of failing the collision. - // - // CLI `config validate` rejects this collision before any - // wrangler shell-out happens. - &["kv", "config"] - } - - fn name(&self) -> &'static str { - "cloudflare" - } - - fn provision( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - stores: &ProvisionStores<'_>, - dry_run: bool, - ) -> Result, String> { - //: 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. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for provision" - .to_owned(), - ); - }; - let wrangler_path = manifest_root.join(rel); - - let mut out = Vec::new(); - for store in stores.kv.iter().chain(stores.config.iter()) { - let logical = &store.logical; - // The Cloudflare KV binding name is what the runtime - // calls `env.kv(...)` with -- it's resolved at request - // time from `EDGEZERO__STORES______NAME` - // (default = logical id). Provision must write the - // resolved PLATFORM name into wrangler.toml, otherwise - // the runtime will look up a binding the CLI never - // created. - let binding = &store.platform; - // Idempotency check BEFORE shelling out: if a - // [[kv_namespaces]] entry with `binding = ` - // is already present and has a real namespace id, skip. - // Without this guard a re-run of provision would invoke - // `wrangler kv namespace create` again and orphan the - // previously-created namespace -- wasting account quota. - // A placeholder id (anything that isn't a 32-char - // lowercase hex string, like the - // `local-dev-placeholder` the scaffold wrangler.toml - // writes) is treated as "not yet provisioned" so the - // entry gets rewritten with the real id. - // - // We deliberately do NOT cross-check the stored id - // against Cloudflare's API (e.g. by calling `wrangler - // kv namespace list` to confirm the id still exists). - // Verifying every entry on every provision run would - // add a network round-trip per id and require parsing - // yet another wrangler subcommand output. The skip - // line names the existing id explicitly so the operator - // can verify it themselves and, if the Cloudflare-side - // namespace was deleted out-of-band, remove the stale - // entry by hand before re-running provision. - let existing = existing_real_namespace_id(&wrangler_path, binding)?; - if let Some(existing_id) = existing { - out.push(format!( - "binding `{binding}` (logical id `{logical}`) already provisioned (id={existing_id} in {}); skipping. To force a fresh namespace: delete the [[kv_namespaces]] entry for binding `{binding}` AND run `wrangler kv namespace delete --namespace-id={existing_id}` (the old remote namespace lingers otherwise), then re-run provision.", - wrangler_path.display() - )); - continue; - } - // Pre-flight the writeback shape BEFORE shelling - // `wrangler kv namespace create`. `read_namespace_id` - // tolerates both `[[kv_namespaces]]` (array-of-tables) - // and `kv_namespaces = [{ binding = "...", id = "..." }]` - // (inline-array) forms, but `upsert_kv_namespace` only - // writes back through the array-of-tables shape. Without - // this guard, an inline-array manifest passes the - // "already provisioned?" probe (because no id is - // present), the remote `create` succeeds, and then the - // upsert errors out — leaving the freshly-created - // namespace orphaned on Cloudflare with no local - // writeback to track it. - // - // Refuse early so the operator fixes the manifest shape - // BEFORE any account-side mutation. - check_kv_namespaces_writeback_shape(&wrangler_path)?; - if dry_run { - out.push(format!( - "would run `wrangler kv namespace create {binding}` and append [[kv_namespaces]] binding = \"{binding}\" to {} (logical id `{logical}`)", - wrangler_path.display() - )); - continue; - } - let namespace_id = create_kv_namespace(binding)?; - upsert_kv_namespace(&wrangler_path, binding, &namespace_id)?; - out.push(format!( - "created KV namespace `{binding}` (logical id `{logical}`, namespace id={namespace_id}); written to {}", - wrangler_path.display() - )); - } - for store in stores.secrets { - let logical = &store.logical; - let platform = &store.platform; - out.push(format!( - "cloudflare secret `{platform}` (logical id `{logical}`) is runtime-managed via `wrangler secret put`; nothing to provision" - )); - } - if out.is_empty() { - out.push("cloudflare has no declared stores to provision".to_owned()); - } - Ok(out) - } - - fn push_config_entries( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - _push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - // Read namespace id from wrangler.toml (matched by - // `binding = `), then `wrangler kv bulk put - // --namespace-id= --remote`. The - // CLI hands this writer one logical (root_key, envelope_json) - // entry; the bulk-put still works because it's one upsert - // per entry, and the one-entry case is degenerate. - // - // **--remote** is mandatory for the prod-push path: - // wrangler v4 defaults KV bulk-put to LOCAL storage when - // the command supports both — meaning a v4 user running - // `wrangler kv bulk put` without `--remote` would silently - // populate Miniflare state under `.wrangler/state` and - // report success while leaving the live Cloudflare - // namespace empty. Explicit `--remote` removes the - // ambiguity. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for config push" - .to_owned(), - ); - }; - let wrangler_path = manifest_root.join(rel); - let binding = store.platform.as_str(); - let logical = store.logical.as_str(); - // Dry-run is lenient about a missing/unresolved binding so - // operators can preview the keyset BEFORE running provision. - // Real runs still err loudly so we don't silently push to - // a non-existent namespace. - if dry_run { - let header = find_namespace_id(&wrangler_path, binding).map_or_else( - |_| format!( - "would run `wrangler kv bulk put --namespace-id= --remote` with {} entries for binding `{binding}` (logical id `{logical}`, binding not yet provisioned -- run `edgezero provision --adapter cloudflare` to resolve the namespace id)", - entries.len() - ), - |ns_id| format!( - "would run `wrangler kv bulk put --namespace-id={ns_id} --remote` with {} entries for binding `{binding}` (logical id `{logical}`)", - entries.len() - ), - ); - let mut out = vec![header]; - for (key, _) in entries { - out.push(format!(" would create entry `{key}`")); - } - return Ok(out); - } - let namespace_id = find_namespace_id(&wrangler_path, binding)?; - if entries.is_empty() { - return Ok(vec![format!( - "no config entries to push to KV namespace `{binding}` (logical id `{logical}`, id={namespace_id})" - )]); - } - let payload = bulk_payload(entries)?; - let temp = tempfile::Builder::new() - .prefix("edgezero-cf-push-") - .suffix(".json") - .tempfile() - .map_err(|err| { - format!("failed to create temp file for wrangler bulk payload: {err}") - })?; - fs::write(temp.path(), payload.as_bytes()) - .map_err(|err| format!("failed to write {}: {err}", temp.path().display()))?; - let temp_arg = temp - .path() - .to_str() - .ok_or_else(|| format!("temp file path {} is not UTF-8", temp.path().display()))?; - let namespace_arg = format!("--namespace-id={namespace_id}"); - // Run from the wrangler.toml's directory so wrangler picks - // up its `account_id` / `--env` resolution + persistence - // settings the same way `wrangler dev` / `wrangler deploy` - // do for this project. - let project_dir = wrangler_path.parent().unwrap_or(manifest_root); - let output = Command::new("wrangler") - .current_dir(project_dir) - .args([ - "kv", - "bulk", - "put", - temp_arg, - namespace_arg.as_str(), - "--remote", - ]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") - } else { - format!("failed to spawn `wrangler`: {err}") - } - })?; - if !output.status.success() { - return Err(format!( - "`wrangler kv bulk put --remote` exited with status {}\nstderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )); - } - Ok(vec![format!( - "pushed {} entries to KV namespace `{binding}` (logical id `{logical}`, id={namespace_id})", - entries.len() - )]) - } - - fn push_config_entries_local( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - _push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - // Local push: address the binding directly via - // `wrangler kv bulk put --binding --local`. - // Crucially we do NOT resolve a namespace id here — the - // scaffold ships with `local-dev-placeholder` ids, so an - // operator that hasn't run `edgezero provision` yet should - // still be able to seed `.wrangler/state` from the manifest - // (matching wrangler's own local KV docs). Wrangler stores - // local entries keyed by binding, not namespace id, so the - // follow-up `wrangler dev --local` / `edgezero serve - // --adapter cloudflare` reads them back through the same - // binding name. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for config push --local" - .to_owned(), - ); - }; - let wrangler_path = manifest_root.join(rel); - let project_dir = wrangler_path.parent().unwrap_or(manifest_root); - let binding = store.platform.as_str(); - let logical = store.logical.as_str(); - if dry_run { - let mut out = vec![format!( - "would run `wrangler kv bulk put --binding {binding} --local` with {} entries for binding `{binding}` (logical id `{logical}`)", - entries.len() - )]; - for (key, _) in entries { - out.push(format!(" would create local entry `{key}`")); - } - return Ok(out); - } - if entries.is_empty() { - return Ok(vec![format!( - "no config entries to push to local KV namespace `{binding}` (logical id `{logical}`)" - )]); - } - let payload = bulk_payload(entries)?; - let temp = tempfile::Builder::new() - .prefix("edgezero-cf-push-local-") - .suffix(".json") - .tempfile() - .map_err(|err| { - format!("failed to create temp file for wrangler bulk payload: {err}") - })?; - fs::write(temp.path(), payload.as_bytes()) - .map_err(|err| format!("failed to write {}: {err}", temp.path().display()))?; - let temp_arg = temp - .path() - .to_str() - .ok_or_else(|| format!("temp file path {} is not UTF-8", temp.path().display()))?; - let output = Command::new("wrangler") - .current_dir(project_dir) - .args([ - "kv", - "bulk", - "put", - temp_arg, - "--binding", - binding, - "--local", - ]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") - } else { - format!("failed to spawn `wrangler`: {err}") - } - })?; - if !output.status.success() { - return Err(format!( - "`wrangler kv bulk put --binding {binding} --local` exited with status {}\nstderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )); - } - Ok(vec![format!( - "pushed {} entries to local KV namespace bound as `{binding}` (logical id `{logical}`); `.wrangler/state` updated", - entries.len() - )]) - } - - fn read_config_entry( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - _push_ctx: &AdapterPushContext<'_>, - ) -> Result { - read_wrangler_kv_key(manifest_root, adapter_manifest_path, store, key, "--remote") - } - - fn read_config_entry_local( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - _push_ctx: &AdapterPushContext<'_>, - ) -> Result { - read_wrangler_kv_key(manifest_root, adapter_manifest_path, store, key, "--local") - } - - fn single_store_kinds(&self) -> &'static [&'static str] { - //: cloudflare is Multi for KV (KV namespaces) and - // Config (KV namespaces), Single for Secrets (Worker - // Secrets is a single flat bag). - &["secrets"] - } -} - -/// Shell out to `wrangler kv namespace create `, capture -/// stdout, and parse the resulting namespace id. The CLI's -/// `provision` command resolves this against the user's -/// `wrangler.toml` and writes the `[[kv_namespaces]]` entry. -/// -/// # Errors -/// Returns an error if `wrangler` isn't on `PATH`, the child fails -/// to spawn, the exit status is non-zero, or stdout doesn't -/// include a parseable `id = "..."` line. -fn create_kv_namespace(binding: &str) -> Result { - let output = Command::new("wrangler") - .args(["kv", "namespace", "create", binding]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") - } else { - format!("failed to spawn `wrangler`: {err}") - } - })?; - if !output.status.success() { - return Err(format!( - "`wrangler kv namespace create {binding}` exited with status {}\nstderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )); - } - let stdout = String::from_utf8_lossy(&output.stdout); - extract_namespace_id(&stdout).ok_or_else(|| { - format!( - "wrangler created `{binding}` but stdout did not include a parseable `id = \"...\"` line -- wrangler may have changed its output format; pin a known-compatible wrangler version or file an issue. Raw stdout:\n{stdout}" - ) - }) -} - -/// Pull the namespace id out of `wrangler kv namespace create` -/// stdout. Wrangler 3+ prints (something like): -/// -/// ```text -/// 🌀 Creating namespace with title "..." -/// ✨ Success! -/// Add the following to your configuration file in your kv_namespaces array: -/// [[kv_namespaces]] -/// binding = "my-kv" -/// id = "abc123..." -/// ``` -/// -/// We tolerate leading whitespace + surrounding decoration. To -/// avoid grabbing a stray informational line like -/// `id = ""` printed somewhere else in wrangler -/// output (or a hypothetical future `id = ...` line that names a -/// non-KV resource), we anchor to the `[[kv_namespaces]]` table -/// header AND require the value to be 32-char lowercase hex -/// (Cloudflare's actual namespace-id shape). The scan walks -/// lines top-down: when we see `[[kv_namespaces]]` we set a -/// scope flag; the next `id = "<32-char-hex>"` line within that -/// scope is the result. A new top-level header resets the scope. -fn extract_namespace_id(stdout: &str) -> Option { - let mut in_kv_namespaces = false; - for line in stdout.lines() { - let trimmed = line.trim(); - if trimmed == "[[kv_namespaces]]" { - in_kv_namespaces = true; - continue; - } - // Any other table header ends the scope so we don't reach - // forward into a sibling block. - if trimmed.starts_with('[') && trimmed.ends_with(']') { - in_kv_namespaces = false; - continue; - } - if !in_kv_namespaces { - continue; - } - let Some(after_id_kw) = trimmed.strip_prefix("id") else { - continue; - }; - let Some(after_eq) = after_id_kw.trim_start().strip_prefix('=') else { - continue; - }; - let Some(quoted) = after_eq.trim_start().strip_prefix('"') else { - continue; - }; - let Some((id, _)) = quoted.split_once('"') else { - continue; - }; - if is_real_namespace_id(id) { - return Some(id.to_owned()); - } - } - None -} - -/// Heuristic: is `id` a real Cloudflare KV namespace id (32-char -/// lowercase hex), as opposed to a scaffold placeholder like -/// `local-dev-placeholder`? Cloudflare's API consistently returns -/// 32-char lowercase hex, so we use that as a tight cheap signal. -/// -/// Additionally rejects hex-shape sentinels that LOOK like real -/// ids but are obviously hand-typed placeholders: anything with -/// fewer than 6 distinct hex characters (catches all-zeros, -/// all-`a`, `deadbeefdeadbeefdeadbeefdeadbeef`, etc.). A real id -/// generated by Cloudflare's API has effectively uniform random -/// hex distribution: expected distinct chars over 32 draws from -/// 16 symbols is ~14, and the dominant term P(=5 distinct) is on -/// the order of 10^-13 -- so false rejections of real ids are -/// astronomically unlikely. -fn is_real_namespace_id(id: &str) -> bool { - if id.len() != 32 { - return false; - } - if !id - .bytes() - .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) - { - return false; - } - // Distinct-byte count via a BTreeSet: 32 inserts is trivial, - // and the set form avoids the arithmetic-side-effect / - // silent-as / indexing-panic shapes the project's clippy - // profile rejects. - let distinct: BTreeSet = id.bytes().collect(); - distinct.len() >= 6 -} - -/// If `path` already declares a `[[kv_namespaces]]` entry with -/// `binding = binding` AND its `id` looks like a real Cloudflare -/// namespace id, return that id. Returns `Ok(None)` if the binding -/// is absent OR present with a placeholder id (so provision can -/// treat both cases as "needs (re-)create"). A failure to read / -/// parse the file is a hard error -- provision needs an authoritative -/// answer. -fn existing_real_namespace_id(path: &Path, binding: &str) -> Result, String> { - let Some(existing) = read_namespace_id(path, binding)? else { - return Ok(None); - }; - if is_real_namespace_id(&existing) { - Ok(Some(existing)) - } else { - Ok(None) - } -} - -/// Internal: look up `binding`'s `id` in `wrangler.toml` without -/// the "did you run provision?" error path that `find_namespace_id` -/// adds. Missing file -> `Ok(None)`. Returns the raw id whether or -/// not it looks like a real Cloudflare id. -/// -/// Errors loudly if `kv_namespaces` exists but is neither an -/// array-of-tables nor an inline-array (e.g. the operator typed -/// `kv_namespaces = "oops"`). Silently returning `None` there -/// surfaces downstream as "did you run provision?" -- misleading, -/// because the actual problem is a malformed manifest. -fn read_namespace_id(path: &Path, binding: &str) -> Result, String> { - use toml_edit::{DocumentMut, Item, Value}; - - let raw = match fs::read_to_string(path) { - Ok(raw) => raw, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(format!("failed to read {}: {err}", path.display())), - }; - let doc: DocumentMut = raw - .parse() - .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; - let id = match doc.get("kv_namespaces") { - Some(Item::ArrayOfTables(arr)) => arr.iter().find_map(|table| { - if table.get("binding").and_then(Item::as_str) == Some(binding) { - table.get("id").and_then(Item::as_str).map(str::to_owned) - } else { - None - } - }), - Some(Item::Value(Value::Array(arr))) => arr.iter().find_map(|item| { - let table = item.as_inline_table()?; - if table.get("binding").and_then(Value::as_str) == Some(binding) { - table.get("id").and_then(Value::as_str).map(str::to_owned) - } else { - None - } - }), - Some(other) => { - return Err(format!( - "{}: `kv_namespaces` exists but is neither `[[kv_namespaces]]` (array-of-tables) nor an inline array of `{{ binding, id }}` records; got TOML item of type `{}`", - path.display(), - item_kind(other) - )); - } - None => None, - }; - Ok(id) -} - -/// Refuse to provision a new namespace when `wrangler.toml`'s -/// `kv_namespaces` exists in a form that `upsert_kv_namespace` -/// can't write back to. Today that means the inline-array form -/// (`kv_namespaces = [{ binding = "...", id = "..." }]`), which -/// `read_namespace_id` tolerates but `upsert_kv_namespace`'s -/// `as_array_of_tables_mut()` rejects. Without this guard, the -/// orphan-namespace hazard documented in `upsert_kv_namespace` -/// reappears: `wrangler kv namespace create` succeeds, then -/// upsert errors out and the new namespace lingers on -/// Cloudflare with no local writeback to track it. Missing or -/// array-of-tables forms are OK. -fn check_kv_namespaces_writeback_shape(path: &Path) -> Result<(), String> { - use toml_edit::{DocumentMut, Item, Value}; - - let raw = match fs::read_to_string(path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), - Err(err) => return Err(format!("failed to read {}: {err}", path.display())), - }; - let doc: DocumentMut = raw - .parse() - .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; - match doc.get("kv_namespaces") { - None | Some(Item::ArrayOfTables(_)) => Ok(()), - Some(Item::Value(Value::Array(_))) => Err(format!( - "{}: `kv_namespaces` is declared as an inline array (`kv_namespaces = [{{ binding = \"...\", id = \"...\" }}]`); provision can only write back through the `[[kv_namespaces]]` array-of-tables form. Convert each entry to a `[[kv_namespaces]]` block BEFORE re-running provision; otherwise a successful `wrangler kv namespace create` would leave the new namespace orphaned on Cloudflare with no local entry to track it.", - path.display() - )), - Some(other) => Err(format!( - "{}: `kv_namespaces` exists but is neither `[[kv_namespaces]]` (array-of-tables) nor an inline array of `{{ binding, id }}` records; got TOML item of type `{}`. Convert it manually before re-running provision.", - path.display(), - item_kind(other) - )), - } -} - -/// One-line label for a `toml_edit::Item` (for diagnostic -/// messages -- not a canonical TOML type description). -fn item_kind(item: &toml_edit::Item) -> &'static str { - use toml_edit::{Item, Value}; - match item { - Item::None => "none", - Item::Value(Value::String(_)) => "string", - Item::Value(Value::Integer(_)) => "integer", - Item::Value(Value::Float(_)) => "float", - Item::Value(Value::Boolean(_)) => "boolean", - Item::Value(Value::Datetime(_)) => "datetime", - Item::Value(Value::Array(_)) => "array", - Item::Value(Value::InlineTable(_)) => "inline-table", - Item::Table(_) => "table", - Item::ArrayOfTables(_) => "array-of-tables", - } -} - -/// Insert OR update the `[[kv_namespaces]]` entry for `binding`, -/// rewriting `id` if the binding already exists (e.g. provision -/// is replacing a `local-dev-placeholder`). Used by provision so -/// re-running on a scaffolded wrangler.toml replaces the placeholder -/// with the real id instead of silently skipping. -/// -/// Caveat: `toml_edit::Table::insert` replaces the value's `Item`, -/// which drops any trailing inline comment that was attached to -/// the prior `id = "..."` line (e.g. `id = "old" # delete me`). -/// Sibling fields under the same `[[kv_namespaces]]` table are -/// preserved verbatim -- only the `id` line's decor is lost. -/// -/// Concurrency: provision is NOT safe to run concurrently against -/// the same `wrangler.toml`. Two concurrent runs may both miss the -/// idempotency check, both call `wrangler kv namespace create` -/// remotely, then race the file write -- the loser's namespace -/// becomes an orphan in the Cloudflare account. `EdgeZero` does not -/// take a lockfile; operators must serialise provision themselves. -fn upsert_kv_namespace(path: &Path, binding: &str, id: &str) -> Result<(), String> { - use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, value}; - - // Treat NotFound as "start with empty document" symmetrically with - // `read_namespace_id` so the orphan-namespace hazard goes away: if - // wrangler.toml is missing entirely (e.g. operator deleted it - // between scaffold and provision), the upsert that follows a - // successful `wrangler kv namespace create` would otherwise error - // out, leaving the remote namespace orphaned. - let raw = match fs::read_to_string(path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => String::new(), - Err(err) => return Err(format!("failed to read {}: {err}", path.display())), - }; - let mut doc: DocumentMut = raw - .parse() - .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; - - let entry = doc - .entry("kv_namespaces") - .or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())); - let arr_of_tables = entry.as_array_of_tables_mut().ok_or_else(|| { - format!( - "{}: `kv_namespaces` exists but is not an array-of-tables (`[[kv_namespaces]]`); convert it manually before re-running provision", - path.display() - ) - })?; - - let existing_idx = arr_of_tables - .iter() - .position(|table| table.get("binding").and_then(Item::as_str) == Some(binding)); - if let Some(idx) = existing_idx { - if let Some(existing) = arr_of_tables.get_mut(idx) { - existing.insert("id", value(id)); - } - } else { - let mut new_table = Table::new(); - new_table.insert("binding", value(binding)); - new_table.insert("id", value(id)); - arr_of_tables.push(new_table); - } - - fs::write(path, doc.to_string()) - .map_err(|err| format!("failed to write {}: {err}", path.display()))?; - Ok(()) -} - -/// Render the entries as the `[{"key": "...", "value": "..."}, …]` -/// JSON wrangler expects for `kv bulk put`. Under the blob model the -/// CLI hands this writer one logical `(root_key, envelope_json)` entry; -/// Cloudflare passes the value through unchanged (the envelope is an -/// opaque string from the platform's perspective). -fn bulk_payload(entries: &[(String, String)]) -> Result { - let payload: Vec = entries - .iter() - .map(|(key, value)| serde_json::json!({ "key": key, "value": value })) - .collect(); - serde_json::to_string(&payload) - .map_err(|err| format!("failed to serialize wrangler bulk payload: {err}")) -} - -/// Read a single key from a Cloudflare KV namespace by shelling out to -/// `wrangler kv key get --binding `. -/// -/// `locality` is either `"--remote"` (live Cloudflare KV) or `"--local"` -/// (Miniflare `.wrangler/state`). The two read methods on the adapter call -/// this shared helper with the appropriate flag. -/// -/// # Mapping to `ReadConfigEntry` -/// - Success (exit 0) → `Present(stdout)`. -/// - Exit non-zero, stderr contains "not found" / "does not exist" → `MissingKey`. -/// - Exit non-zero, stderr mentions "binding" → `MissingStore` (the KV -/// namespace binding itself doesn't exist in `wrangler.toml`). -/// - Any other non-zero exit → `Err`. -fn read_wrangler_kv_key( - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - store: &ResolvedStoreId, - key: &str, - locality: &str, -) -> Result { - let rel = adapter_manifest_path.ok_or_else(|| { - "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for config diff" - .to_owned() - })?; - let wrangler_path = manifest_root.join(rel); - let binding = store.platform.as_str(); - let project_dir = wrangler_path.parent().unwrap_or(manifest_root); - let output = Command::new("wrangler") - .args(["kv", "key", "get", "--binding", binding, key, locality]) - .current_dir(project_dir) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") - } else { - format!("failed to spawn `wrangler`: {err}") - } - })?; - if output.status.success() { - let body = String::from_utf8(output.stdout) - .map_err(|err| format!("`wrangler kv key get` stdout is not UTF-8: {err}"))?; - // Wrangler 4.x (verified 4.64.0) returns exit 0 + stdout - // "Value not found" for a missing key instead of exit 1 + - // stderr. Detect that shape and map to MissingKey -- a - // missing key in the blob model is valid initial state - // (first push hasn't run yet), not corrupt remote state. - // Match the trimmed first line so trailing newlines or - // future variants like "Value not found.\n" still match. - let trimmed = body.trim(); - if trimmed.eq_ignore_ascii_case("value not found") - || trimmed.eq_ignore_ascii_case("value not found.") - { - return Ok(ReadConfigEntry::MissingKey); - } - return Ok(ReadConfigEntry::Present(body)); - } - let stderr = String::from_utf8_lossy(&output.stderr); - if stderr.contains("not found") || stderr.contains("does not exist") { - return Ok(ReadConfigEntry::MissingKey); - } - if stderr.contains("binding") || stderr.contains("Binding") { - return Ok(ReadConfigEntry::MissingStore); - } - Err(format!( - "`wrangler kv key get --binding {binding} {key} {locality}` exited with status {}\nstderr: {}", - output.status, - stderr.trim() - )) -} - -/// # Errors -/// Returns an error if the Cloudflare wrangler build command fails. -#[inline] -pub fn build(extra_args: &[String]) -> Result { - let manifest = - find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; - let cargo_manifest = manifest_dir.join("Cargo.toml"); - let crate_name = read_package_name(&cargo_manifest)?; - - let status = Command::new("cargo") - .args([ - "build", - "--release", - "--target", - TARGET_TRIPLE, - "--manifest-path", - cargo_manifest - .to_str() - .ok_or("invalid Cargo manifest path")?, - ]) - .args(extra_args) - .status() - .map_err(|err| format!("failed to run cargo build: {err}"))?; - if !status.success() { - return Err(format!("cargo build failed with status {status}")); - } - - let workspace_root = find_workspace_root(manifest_dir); - let artifact = locate_artifact(&workspace_root, manifest_dir, &crate_name)?; - let pkg_dir = workspace_root.join("pkg"); - fs::create_dir_all(&pkg_dir) - .map_err(|err| format!("failed to create {}: {err}", pkg_dir.display()))?; - let dest = pkg_dir.join(format!("{}.wasm", crate_name.replace('-', "_"))); - fs::copy(&artifact, &dest) - .map_err(|err| format!("failed to copy artifact to {}: {err}", dest.display()))?; - - Ok(dest) -} - -/// # Errors -/// Returns an error if the Cloudflare wrangler deploy command fails. -#[inline] -pub fn deploy(extra_args: &[String]) -> Result<(), String> { - let manifest = - find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; - let config = manifest - .to_str() - .ok_or_else(|| "invalid wrangler config path".to_owned())?; - - let status = Command::new("wrangler") - .args(["deploy", "--config", config]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|err| format!("failed to run wrangler CLI: {err}"))?; - if !status.success() { - return Err(format!("wrangler deploy failed with status {status}")); - } - - Ok(()) -} - -/// Look up the namespace id wrangler.toml has bound to `binding`, -/// rejecting placeholder ids (anything that isn't a 32-char -/// lowercase hex Cloudflare API id). -/// -/// Accepts both `[[kv_namespaces]]` (array-of-tables, what -/// `provision` writes and wrangler's own post-create hint prints) -/// and the inline-array form. Returns Err with a "did you run -/// provision?" hint if the binding is absent OR holds a placeholder -/// like `local-dev-placeholder` — without this check `push` would -/// shell out to `wrangler kv bulk put --namespace-id=`, -/// which fails at wrangler with a less actionable error. -fn find_namespace_id(wrangler_path: &Path, binding: &str) -> Result { - // read_namespace_id returns Ok(None) for both - // missing-file AND binding-not-present; for `find_namespace_id` - // the user wants a "did you run provision?" hint in both cases, - // so collapse them into the same error message. - let raw = read_namespace_id(wrangler_path, binding)?.ok_or_else(|| { - format!( - "{}: no [[kv_namespaces]] entry with binding = {binding:?} (did you run `edgezero provision --adapter cloudflare`?)", - wrangler_path.display() - ) - })?; - if is_real_namespace_id(&raw) { - Ok(raw) - } else { - Err(format!( - "{}: binding {binding:?} has id {raw:?}, which doesn't look like a real Cloudflare KV namespace id (expected 32-char lowercase hex). This is usually a scaffold placeholder -- run `edgezero provision --adapter cloudflare` to create a real namespace and overwrite the entry.", - wrangler_path.display() - )) - } -} - -fn find_wrangler_manifest(start: &Path) -> Result { - if let Some(found) = find_manifest_upwards(start, "wrangler.toml") { - return Ok(found); - } - - let root = find_workspace_root(start); - let mut candidates: Vec = WalkDir::new(&root) - .follow_links(true) - .max_depth(8) - .into_iter() - .filter_map(Result::ok) - .map(|entry| entry.path().to_path_buf()) - .filter(|path| { - path.file_name().is_some_and(|n| n == "wrangler.toml") - && path - .parent() - .is_some_and(|dir| dir.join("Cargo.toml").exists()) - }) - .collect(); - - if candidates.is_empty() { - return Err("could not locate wrangler.toml".to_owned()); - } - - candidates.sort_by_key(|path| { - let parent = path.parent().unwrap_or(Path::new("")); - path_distance(start, parent) - }); - - Ok(candidates.remove(0)) -} - -fn locate_artifact( - workspace_root: &Path, - manifest_dir: &Path, - crate_name: &str, -) -> Result { - let release_name = format!("{}.wasm", crate_name.replace('-', "_")); - - if let Some(custom) = env::var_os("CARGO_TARGET_DIR") { - let candidate = PathBuf::from(custom) - .join(TARGET_TRIPLE) - .join("release") - .join(&release_name); - if candidate.exists() { - return Ok(candidate); - } - } - - let manifest_target = manifest_dir - .join("target") - .join(TARGET_TRIPLE) - .join("release") - .join(&release_name); - if manifest_target.exists() { - return Ok(manifest_target); - } - - let workspace_target = workspace_root - .join("target") - .join(TARGET_TRIPLE) - .join("release") - .join(&release_name); - if workspace_target.exists() { - return Ok(workspace_target); - } - - Err(format!( - "compiled artifact not found for {crate_name} (looked in manifest and workspace target directories)" - )) -} - -#[inline] -pub fn register() { - register_adapter(&CLOUDFLARE_ADAPTER); - register_adapter_blueprint(&CLOUDFLARE_BLUEPRINT); -} - -#[ctor(unsafe)] -fn register_ctor() { - register(); -} - -/// # Errors -/// Returns an error if the Cloudflare wrangler dev command fails. -#[inline] -pub fn serve(extra_args: &[String]) -> Result<(), String> { - let manifest = - find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; - let config = manifest - .to_str() - .ok_or_else(|| "invalid wrangler config path".to_owned())?; - - let status = Command::new("wrangler") - .args(["dev", "--config", config]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|err| format!("failed to run wrangler CLI: {err}"))?; - if !status.success() { - return Err(format!("wrangler dev failed with status {status}")); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - #[cfg(unix)] - use edgezero_core::test_env::PathPrepend; - - #[cfg(unix)] - use std::sync::Mutex; - use tempfile::tempdir; - - // Shared fixture names. Pinning these as consts (instead of - // inline `"sessions"` / `"app_config"` per call site) keeps the - // setup-vs-assertion pair in sync -- a typo in one place no - // longer silently divorces from the other, because both reference - // the same const. Also names the intent: these are the LOGICAL - // store ids the cloudflare adapter operates on, not arbitrary - // strings. - const TEST_KV_ID: &str = "sessions"; - const TEST_KV_ID_ALT: &str = "cache"; - const TEST_CONFIG_ID: &str = "app_config"; - const TEST_SECRET_ID: &str = "default"; - - // ---------- extract_namespace_id ---------- - - #[test] - fn extract_namespace_id_parses_wrangler_3_output() { - // wrangler decorates these lines with unicode glyphs in real - // output; we drop them from the fixture to keep the source - // file ASCII-only (clippy::non_ascii_literal). The parser - // requires both the `[[kv_namespaces]]` anchor and a - // 32-char-lowercase-hex id. - let stdout = r#"Creating namespace with title "my-kv" -Success! -Add the following to your configuration file in your kv_namespaces array: -[[kv_namespaces]] -binding = "my-kv" -id = "00112233445566778899aabbccddeeff" -"#; - assert_eq!( - extract_namespace_id(stdout).as_deref(), - Some("00112233445566778899aabbccddeeff") - ); - } - - #[test] - fn extract_namespace_id_tolerates_extra_whitespace() { - let stdout = "[[kv_namespaces]]\n id = \"00112233445566778899aabbccddeeff\" \n"; - assert_eq!( - extract_namespace_id(stdout).as_deref(), - Some("00112233445566778899aabbccddeeff") - ); - } - - #[test] - fn extract_namespace_id_returns_none_on_missing_id_line() { - assert!(extract_namespace_id("nothing to see here").is_none()); - assert!(extract_namespace_id("").is_none()); - assert!( - extract_namespace_id("[[kv_namespaces]]\nid = \"\"").is_none(), - "empty value not a real id" - ); - } - - #[test] - fn extract_namespace_id_ignores_unrelated_lines_starting_with_id() { - // `identifier = "..."` doesn't match -- we strip exactly the - // prefix `id` then require `=`. Also doesn't match because - // there's no `[[kv_namespaces]]` anchor. - assert!(extract_namespace_id("[[kv_namespaces]]\nidentifier = \"x\"").is_none()); - } - - #[test] - fn extract_namespace_id_requires_kv_namespaces_anchor() { - // A bare `id = "<32-char-hex>"` line that isn't preceded by - // `[[kv_namespaces]]` must not match -- otherwise a future - // wrangler info line like `id = ""` printed - // somewhere else in stdout would be picked up as the - // namespace id and silently corrupt wrangler.toml on writeback. - let unanchored = "id = \"00112233445566778899aabbccddeeff\"\n"; - assert!(extract_namespace_id(unanchored).is_none()); - - // A different table header BEFORE the `id` line scopes us - // out of the kv-namespaces context. - let other_block = "[[d1_databases]]\nid = \"00112233445566778899aabbccddeeff\"\n"; - assert!(extract_namespace_id(other_block).is_none()); - } - - #[test] - fn extract_namespace_id_rejects_non_real_id_inside_kv_namespaces_anchor() { - // Even with the anchor, the value must look like a real - // Cloudflare id (32-char lowercase hex with the diversity - // floor). Shorter or non-hex values are skipped, not - // returned -- forces the operator to investigate stdout - // drift rather than silently writing a bogus id. - let stdout = "[[kv_namespaces]]\nbinding = \"my-kv\"\nid = \"abc123\"\n"; - assert!(extract_namespace_id(stdout).is_none()); - } - - fn write_wrangler(dir: &Path, contents: &str) -> PathBuf { - let path = dir.join("wrangler.toml"); - fs::write(&path, contents).expect("write wrangler.toml"); - path - } - - // ---------- is_real_namespace_id ---------- - - #[test] - fn is_real_namespace_id_accepts_32_char_lowercase_hex_with_sufficient_diversity() { - // 16-distinct-char fixture: maximum diversity. - assert!(is_real_namespace_id("00112233445566778899aabbccddeeff")); - // Realistic randomish fixture: 14 distinct chars. - assert!(is_real_namespace_id("4a8f3c2b9e1d5670adef2839c4b6e1f0")); - } - - #[test] - fn is_real_namespace_id_rejects_placeholder_or_short_id() { - assert!(!is_real_namespace_id("local-dev-placeholder")); - assert!(!is_real_namespace_id("abc123")); - assert!(!is_real_namespace_id("")); - } - - #[test] - fn is_real_namespace_id_rejects_uppercase_or_non_hex() { - // Uppercase rejected: Cloudflare's API returns lowercase. - assert!(!is_real_namespace_id("00112233445566778899AABBCCDDEEFF")); - // Non-hex digits rejected. - assert!(!is_real_namespace_id("z0112233445566778899aabbccddeeff")); - } - - #[test] - fn is_real_namespace_id_rejects_hex_shape_sentinels() { - // 32-char lowercase hex but obvious hand-typed placeholder: - // distinct-hex-digit count is below the diversity floor. - // Real Cloudflare ids have effectively uniform random hex, - // so collisions with this guard are astronomical. - assert!( - !is_real_namespace_id("00000000000000000000000000000000"), - "all-zeros rejected" - ); - assert!( - !is_real_namespace_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), - "all-a rejected" - ); - assert!( - !is_real_namespace_id("deadbeefdeadbeefdeadbeefdeadbeef"), - "deadbeef rejected (only 5 distinct chars: d,e,a,b,f)" - ); - // Boundary: a real-looking id with the diversity floor or - // more must still pass. - assert!( - is_real_namespace_id("00112233445566778899aabbccddeeff"), - "16-distinct-char fixture must still pass" - ); - // Exactly 6 distinct chars (a,b,c,d,e,f): on the boundary, - // must pass. - assert!( - is_real_namespace_id("aabbccddeeffaabbccddeeffaabbccdd"), - "6-distinct-char fixture (boundary) passes" - ); - } - - // ---------- read_namespace_id ---------- - - #[test] - fn read_namespace_id_errors_when_kv_namespaces_is_non_array_value() { - // `kv_namespaces = "oops"` is a malformed manifest. Silently - // returning None there bubbles up as "did you run provision?" - // -- a misleading error. The right surface is "manifest - // doesn't match the expected shape". - let dir = tempdir().expect("tempdir"); - let path = write_wrangler(dir.path(), "name = \"demo\"\nkv_namespaces = \"oops\"\n"); - let err = read_namespace_id(&path, TEST_CONFIG_ID) - .expect_err("non-array kv_namespaces must error"); - assert!( - err.contains("array-of-tables") || err.contains("inline array"), - "error names the expected shapes: {err}" - ); - assert!( - err.contains("string"), - "error names the offending kind: {err}" - ); - } - - // ---------- extract_namespace_id (pinning behaviour) ---------- - - #[test] - fn extract_namespace_id_returns_first_real_match_inside_kv_namespaces_anchor() { - // Pin: top-down scan, first qualifying line inside the - // `[[kv_namespaces]]` anchor wins. Real wrangler output has - // exactly one. A hypothetical future format with multiple - // qualifying lines would surface the earliest, but only - // values that look like real Cloudflare ids count. - let stdout = "[[kv_namespaces]]\n\ - id = \"00112233445566778899aabbccddeeff\"\n\ - id = \"ffeeddccbbaa99887766554433221100\"\n"; - assert_eq!( - extract_namespace_id(stdout).as_deref(), - Some("00112233445566778899aabbccddeeff") - ); - } - - // ---------- upsert_kv_namespace ---------- - - #[test] - fn upsert_kv_namespace_replaces_placeholder_id_for_existing_binding() { - let dir = tempdir().expect("tempdir"); - let path = write_wrangler( - dir.path(), - "[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", - ); - upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); - let after = fs::read_to_string(&path).expect("read"); - assert!( - after.contains("id = \"00112233445566778899aabbccddeeff\""), - "placeholder replaced: {after}" - ); - assert!( - !after.contains("local-dev-placeholder"), - "placeholder removed: {after}" - ); - assert_eq!( - after.matches("binding = \"sessions\"").count(), - 1, - "no duplicate binding: {after}" - ); - } - - #[test] - fn upsert_kv_namespace_appends_when_binding_absent() { - let dir = tempdir().expect("tempdir"); - let path = write_wrangler(dir.path(), "name = \"demo\"\n"); - upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); - let after = fs::read_to_string(&path).expect("read"); - assert!( - after.contains("binding = \"sessions\"") - && after.contains("id = \"00112233445566778899aabbccddeeff\""), - "appended new entry: {after}" - ); - assert!( - after.contains("name = \"demo\""), - "preserved original keys: {after}" - ); - } - - #[test] - fn upsert_kv_namespace_appends_next_to_existing_entries() { - let dir = tempdir().expect("tempdir"); - let path = write_wrangler( - dir.path(), - "[[kv_namespaces]]\nbinding = \"cache\"\nid = \"old\"\n", - ); - upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); - let after = fs::read_to_string(&path).expect("read"); - assert!( - after.contains("binding = \"cache\"") && after.contains("id = \"old\""), - "existing entry kept: {after}" - ); - assert!( - after.contains("binding = \"sessions\""), - "new entry added: {after}" - ); - assert_eq!( - after.matches("[[kv_namespaces]]").count(), - 2, - "two entries: {after}" - ); - } - - #[test] - fn upsert_kv_namespace_preserves_top_comments() { - let dir = tempdir().expect("tempdir"); - let path = write_wrangler( - dir.path(), - "# managed by hand -- please keep this line\nname = \"my-worker\"\n", - ); - upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); - let after = fs::read_to_string(&path).expect("read"); - assert!( - after.contains("# managed by hand"), - "preserved comment: {after}" - ); - } - - #[test] - fn upsert_kv_namespace_preserves_sibling_fields_on_existing_entry() { - // toml_edit replaces only the `id` Item when we update it; - // sibling fields on the same `[[kv_namespaces]]` table - // (e.g. `preview_id`, custom annotations the user added) - // must survive the rewrite. Pinning this so a future - // toml_edit upgrade or a refactor can't silently drop - // operator data. - let dir = tempdir().expect("tempdir"); - let path = write_wrangler( - dir.path(), - "[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\npreview_id = \"local-preview\"\ndescription = \"hand-added by ops\"\n", - ); - upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); - let after = fs::read_to_string(&path).expect("read"); - assert!( - after.contains("id = \"00112233445566778899aabbccddeeff\""), - "id rewritten: {after}" - ); - assert!( - after.contains("preview_id = \"local-preview\""), - "preserved preview_id: {after}" - ); - assert!( - after.contains("description = \"hand-added by ops\""), - "preserved description: {after}" - ); - } - - #[test] - fn upsert_kv_namespace_creates_file_when_wrangler_toml_missing() { - // Orphan-namespace hazard: if `wrangler kv namespace create` - // succeeds but wrangler.toml is missing at writeback time, - // erroring here would leave the remote namespace orphaned - // with no local reference. Symmetric with read_namespace_id's - // NotFound -> Ok(None) behaviour: upsert treats NotFound as - // "start with empty document" and writes the entry. - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("missing.toml"); - assert!(!path.exists(), "precondition: file must not exist"); - upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff") - .expect("missing file is permissive"); - let after = fs::read_to_string(&path).expect("file now exists"); - assert!( - after.contains("binding = \"sessions\""), - "created file with new entry: {after}" - ); - assert!( - after.contains("id = \"00112233445566778899aabbccddeeff\""), - "id written: {after}" - ); - } - - // ---------- writeback shape pre-check ---------- - - #[test] - fn check_kv_namespaces_writeback_shape_ok_when_file_missing() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("missing.toml"); - check_kv_namespaces_writeback_shape(&path) - .expect("missing file is permissive (upsert creates it)"); - } - - #[test] - fn check_kv_namespaces_writeback_shape_ok_when_kv_namespaces_absent() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("wrangler.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write wrangler.toml"); - check_kv_namespaces_writeback_shape(&path).expect("no kv_namespaces => OK"); - } - - #[test] - fn check_kv_namespaces_writeback_shape_ok_when_array_of_tables() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("wrangler.toml"); - fs::write( - &path, - "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", - ) - .expect("write wrangler.toml"); - check_kv_namespaces_writeback_shape(&path) - .expect("[[kv_namespaces]] is the writeback-supported shape"); - } - - #[test] - fn check_kv_namespaces_writeback_shape_rejects_inline_array_with_actionable_message() { - // Regression for the orphan-namespace hazard: pre-fix, a - // `kv_namespaces = [{ binding = "sessions" }]` manifest (no - // id present) made `read_namespace_id` return None ("not yet - // provisioned") so provision shelled `wrangler kv namespace - // create` successfully, then `upsert_kv_namespace`'s - // `as_array_of_tables_mut()` returned None and the upsert - // errored — leaving the freshly-created namespace orphaned - // on Cloudflare. The pre-flight rejects the inline-array - // shape BEFORE any account-side call. - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("wrangler.toml"); - fs::write( - &path, - "name = \"demo\"\nkv_namespaces = [{ binding = \"sessions\" }]\n", - ) - .expect("write wrangler.toml"); - let err = check_kv_namespaces_writeback_shape(&path) - .expect_err("inline-array form must be rejected before provision shells out"); - assert!( - err.contains("inline array") - && err.contains("[[kv_namespaces]]") - && err.contains("orphaned"), - "error must name the inline-array form, the supported [[kv_namespaces]] form, AND the orphan hazard so the operator knows what's at stake: {err}" - ); - } - - // ---------- provision (dry-run + error path) ---------- - - #[test] - fn provision_dry_run_does_not_invoke_wrangler() { - let dir = tempdir().expect("tempdir"); - write_wrangler(dir.path(), "name = \"demo\"\n"); - let kv_ids: Vec = - ResolvedStoreId::from_logicals(&[TEST_KV_ID, TEST_KV_ID_ALT]); - let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); - let secret_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_SECRET_ID]); - let stores = ProvisionStores { - config: &config_ids, - kv: &kv_ids, - secrets: &secret_ids, - }; - let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, 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`")); - // 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"); - } - - #[test] - fn provision_dry_run_writes_resolved_platform_name_into_binding() { - // Regression: provision used to receive only logical ids - // and write them verbatim into wrangler.toml. With the - // platform-name flow, an operator who sets - // `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config` - // sees `prod_config` land as the binding name (matching what - // the runtime resolves via `env.kv(...)`), with the logical - // id still mentioned for human-facing wording. - let dir = tempdir().expect("tempdir"); - write_wrangler(dir.path(), "name = \"demo\"\n"); - let config_ids = vec![ResolvedStoreId::new(TEST_CONFIG_ID, "prod_config")]; - let stores = ProvisionStores { - config: &config_ids, - kv: &[], - secrets: &[], - }; - let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) - .expect("dry-run succeeds"); - assert_eq!(out.len(), 1); - assert!( - out[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\""), - "dry-run writes platform name as the binding: {out:?}" - ); - assert!( - out[0].contains("logical id `app_config`"), - "logical id is preserved for operator wording: {out:?}" - ); - } - - #[test] - fn provision_errors_when_adapter_manifest_path_missing() { - let dir = tempdir().expect("tempdir"); - let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); - let stores = ProvisionStores { - config: &[], - kv: &kv_ids, - secrets: &[], - }; - let err = CloudflareCliAdapter - .provision(dir.path(), None, None, &stores, true) - .expect_err("missing adapter manifest path must error"); - assert!( - err.contains("wrangler.toml"), - "error names what's missing: {err}" - ); - } - - #[test] - fn provision_dry_run_skips_bindings_already_provisioned_with_real_id() { - let dir = tempdir().expect("tempdir"); - // 32-char lowercase hex id == real Cloudflare namespace id. - let path = write_wrangler( - dir.path(), - "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"00112233445566778899aabbccddeeff\"\n", - ); - let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); - let stores = ProvisionStores { - config: &[], - kv: &kv_ids, - secrets: &[], - }; - let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) - .expect("dry-run succeeds"); - assert_eq!(out.len(), 1); - assert!( - out[0].contains("already provisioned") - && out[0].contains("00112233445566778899aabbccddeeff"), - "skip line names the existing id: {out:?}" - ); - let after = fs::read_to_string(&path).expect("read"); - assert!( - after.contains("00112233445566778899aabbccddeeff"), - "did not touch existing id: {after}" - ); - } - - #[test] - fn provision_dry_run_treats_placeholder_id_as_unprovisioned() { - // A scaffolded wrangler.toml ships with placeholder ids the - // user is expected to overwrite by running provision. - // Dry-run should report the would-be create call, NOT the - // already-provisioned skip. - let dir = tempdir().expect("tempdir"); - write_wrangler( - dir.path(), - "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", - ); - let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); - let stores = ProvisionStores { - config: &[], - kv: &kv_ids, - secrets: &[], - }; - let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) - .expect("dry-run succeeds"); - assert_eq!(out.len(), 1); - assert!( - out[0].contains("would run `wrangler kv namespace create sessions`"), - "placeholder id is treated as unprovisioned: {out:?}" - ); - } - - #[test] - fn provision_with_no_declared_stores_says_so() { - let dir = tempdir().expect("tempdir"); - write_wrangler(dir.path(), "name = \"demo\"\n"); - let stores = ProvisionStores { - config: &[], - kv: &[], - secrets: &[], - }; - let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, false) - .expect("no-store provision is fine"); - assert_eq!(out, vec!["cloudflare has no declared stores to provision"]); - } - - // ---------- find_namespace_id ---------- - - #[test] - fn find_namespace_id_reads_array_of_tables() { - let dir = tempdir().expect("tempdir"); - let path = write_wrangler( - dir.path(), - "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n", - ); - let id = find_namespace_id(&path, TEST_CONFIG_ID).expect("found"); - assert_eq!(id, "00112233445566778899aabbccddeeff"); - } - - #[test] - fn find_namespace_id_reads_inline_array() { - let dir = tempdir().expect("tempdir"); - let path = write_wrangler( - dir.path(), - "name = \"demo\"\nkv_namespaces = [{ binding = \"app_config\", id = \"ffeeddccbbaa99887766554433221100\" }]\n", - ); - let id = find_namespace_id(&path, TEST_CONFIG_ID).expect("found"); - assert_eq!(id, "ffeeddccbbaa99887766554433221100"); - } - - #[test] - fn find_namespace_id_errors_with_provision_hint_when_binding_absent() { - let dir = tempdir().expect("tempdir"); - let path = write_wrangler( - dir.path(), - "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"other\"\nid = \"00112233445566778899aabbccddeeff\"\n", - ); - let err = find_namespace_id(&path, TEST_CONFIG_ID).expect_err("missing must error"); - assert!( - err.contains(TEST_CONFIG_ID) && err.contains("provision"), - "error names the binding and points at provision: {err}" - ); - } - - #[test] - fn find_namespace_id_rejects_placeholder_id_with_provision_hint() { - // A binding with `id = "local-dev-placeholder"` (or any - // other non-32-char-hex value) is treated the same as - // a missing binding: the operator needs to run provision - // before the id is usable for `wrangler kv bulk put`. - // Without this guard, push would shell out with the - // placeholder as `--namespace-id=...` and fail at wrangler - // with a less actionable error. - let dir = tempdir().expect("tempdir"); - let path = write_wrangler( - dir.path(), - "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"local-dev-placeholder\"\n", - ); - let err = - find_namespace_id(&path, TEST_CONFIG_ID).expect_err("placeholder id must be rejected"); - assert!( - err.contains("local-dev-placeholder") && err.contains("provision"), - "error names the placeholder and points at provision: {err}" - ); - } - - #[test] - fn find_namespace_id_errors_with_provision_hint_when_file_missing() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("does-not-exist.toml"); - let err = - find_namespace_id(&path, TEST_CONFIG_ID).expect_err("missing wrangler.toml must error"); - assert!( - err.contains("provision"), - "error points at provision: {err}" - ); - } - - // ---------- bulk_payload ---------- - - #[test] - fn bulk_payload_emits_wrangler_array_of_key_value_objects() { - let entries = vec![ - ("greeting".to_owned(), "hello".to_owned()), - ("service.timeout_ms".to_owned(), "1500".to_owned()), - ]; - let raw = bulk_payload(&entries).expect("payload"); - let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); - let array = parsed.as_array().expect("array"); - assert_eq!(array.len(), 2); - assert_eq!(array[0]["key"], "greeting"); - assert_eq!(array[0]["value"], "hello"); - assert_eq!(array[1]["key"], "service.timeout_ms"); - assert_eq!(array[1]["value"], "1500"); - } - - #[test] - fn bulk_payload_with_no_entries_is_empty_array() { - let raw = bulk_payload(&[]).expect("empty payload"); - let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); - assert_eq!(parsed, serde_json::json!([])); - } - - // ---------- push_config_entries (dry-run + error paths) ---------- - - #[test] - fn push_dry_run_resolves_namespace_id_and_does_not_invoke_wrangler() { - let dir = tempdir().expect("tempdir"); - let original = "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n"; - let path = write_wrangler(dir.path(), original); - let entries = vec![ - ("greeting".to_owned(), "hello".to_owned()), - ("feature.new_checkout".to_owned(), "false".to_owned()), - ]; - let out = CloudflareCliAdapter - .push_config_entries( - dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - true, - ) - .expect("dry-run succeeds"); - // Header + per-entry preview, matching the fastly dry-run shape. - assert_eq!(out.len(), 1 + entries.len(), "header + per-entry preview"); - assert!( - out[0].contains("would run `wrangler kv bulk put") - && out[0].contains("--namespace-id=00112233445566778899aabbccddeeff"), - "dry-run header names namespace id: {out:?}" - ); - assert!( - out.iter().any(|line| line.contains("`greeting`")), - "dry-run lists `greeting`: {out:?}" - ); - assert!( - out.iter() - .any(|line| line.contains("`feature.new_checkout`")), - "dry-run lists `feature.new_checkout`: {out:?}" - ); - let after = fs::read_to_string(&path).expect("read"); - assert_eq!(after, original, "dry-run must not mutate wrangler.toml"); - } - - #[test] - fn push_dry_run_is_lenient_when_binding_not_yet_provisioned() { - let dir = tempdir().expect("tempdir"); - write_wrangler(dir.path(), "name = \"demo\"\n"); - let entries = vec![("greeting".to_owned(), "hello".to_owned())]; - let out = CloudflareCliAdapter - .push_config_entries( - dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - true, - ) - .expect("dry-run is lenient: pre-provision preview is allowed"); - assert!( - out[0].contains("") && out[0].contains("provision"), - "dry-run header explains the namespace is unresolved and points at provision: {out:?}" - ); - assert!( - out.iter().any(|line| line.contains("`greeting`")), - "dry-run still lists the entries it would push: {out:?}" - ); - } - - #[test] - fn push_errors_when_adapter_manifest_path_missing() { - let dir = tempdir().expect("tempdir"); - let entries = vec![("k".to_owned(), "v".to_owned())]; - let err = CloudflareCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - true, - ) - .expect_err("missing adapter manifest path must error"); - assert!( - err.contains("wrangler.toml") && err.contains("config push"), - "error explains the missing manifest pointer: {err}" - ); - } - - #[test] - fn push_real_run_errors_with_provision_hint_when_binding_absent() { - // dry-run is now lenient (see - // `push_dry_run_is_lenient_when_binding_not_yet_provisioned`), - // but a real run still must err so we don't silently push - // to a non-existent namespace. - let dir = tempdir().expect("tempdir"); - write_wrangler(dir.path(), "name = \"demo\"\n"); - let entries = vec![("greeting".to_owned(), "hello".to_owned())]; - let err = CloudflareCliAdapter - .push_config_entries( - dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - false, - ) - .expect_err("missing binding must error on real run"); - assert!( - err.contains("provision") && err.contains(TEST_CONFIG_ID), - "error points at provision: {err}" - ); - } - - #[test] - fn push_with_no_entries_reports_no_op_after_resolving_namespace() { - let dir = tempdir().expect("tempdir"); - write_wrangler( - dir.path(), - "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n", - ); - let out = CloudflareCliAdapter - .push_config_entries( - dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[], - &AdapterPushContext::new(), - false, - ) - .expect("zero-entry push is fine"); - assert_eq!(out.len(), 1); - assert!( - out[0].contains("no config entries") - && out[0].contains("00112233445566778899aabbccddeeff"), - "status line names empty + namespace id: {out:?}" - ); - } - - // ---------- read_config_entry / read_config_entry_local (fake wrangler) ---------- - - /// Build a tempdir containing a `wrangler` script that emits fixed stdout / - /// stderr and exits with the given code. The files are written to siblings - /// of the script so shell-active chars in the payloads don't get - /// re-interpreted. - #[cfg(unix)] - fn fake_wrangler_returning( - stdout_body: &str, - stderr_body: &str, - exit_code: i32, - ) -> tempfile::TempDir { - use std::os::unix::fs::PermissionsExt as _; - let dir = tempdir().expect("tempdir"); - let script_path = dir.path().join("wrangler"); - let stdout_file = dir.path().join("stdout_payload.txt"); - let stderr_file = dir.path().join("stderr_payload.txt"); - fs::write(&stdout_file, stdout_body).expect("write stdout payload"); - fs::write(&stderr_file, stderr_body).expect("write stderr payload"); - let script = format!( - "#!/bin/sh\ncat '{stdout}'\ncat '{stderr}' >&2\nexit {code}\n", - stdout = stdout_file.display(), - stderr = stderr_file.display(), - code = exit_code, - ); - fs::write(&script_path, script).expect("write wrangler script"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod +x"); - dir - } - - /// Build a fake `wrangler` that logs each argv token (one per line) to - /// `out_path`, prints a single line of stdout, and exits 0. - #[cfg(unix)] - fn fake_wrangler_argv_log(out_path: &Path) -> tempfile::TempDir { - use std::os::unix::fs::PermissionsExt as _; - let dir = tempdir().expect("tempdir"); - let script_path = dir.path().join("wrangler"); - let script = format!( - "#!/bin/sh\nfor arg in \"$@\"; do printf '%s\\n' \"$arg\" >> '{out}'; done\nprintf 'val'\n", - out = out_path.display(), - ); - fs::write(&script_path, script).expect("write script"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod +x"); - dir - } - - /// Process-wide mutex serialising PATH-mutating tests so parallel - /// test threads don't race on the environment variable. - #[cfg(unix)] - fn path_mutation_guard() -> &'static Mutex<()> { - use std::sync::{Mutex, OnceLock}; - static GUARD: OnceLock> = OnceLock::new(); - GUARD.get_or_init(|| Mutex::new(())) - } - - #[cfg(unix)] - #[test] - fn read_remote_returns_present_on_success() { - let _lock = path_mutation_guard().lock().expect("guard"); - let project_dir = tempdir().expect("tempdir"); - write_wrangler(project_dir.path(), "name = \"demo\"\n"); - let fake = fake_wrangler_returning("hello-cloudflare", "", 0); - let _path = PathPrepend::new(fake.path()); - let result = CloudflareCliAdapter - .read_config_entry( - project_dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("wrangler exit-0 must succeed"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present"); - }; - assert_eq!(value, "hello-cloudflare"); - } - - #[cfg(unix)] - #[test] - fn read_remote_returns_missing_key_on_not_found_stderr() { - let _lock = path_mutation_guard().lock().expect("guard"); - let project_dir = tempdir().expect("tempdir"); - write_wrangler(project_dir.path(), "name = \"demo\"\n"); - let fake = fake_wrangler_returning("", "Error: key not found", 1); - let _path = PathPrepend::new(fake.path()); - let result = CloudflareCliAdapter - .read_config_entry( - project_dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("not-found maps to MissingKey (not Err)"); - assert!( - matches!(result, ReadConfigEntry::MissingKey), - "not-found stderr => MissingKey" - ); - } - - /// Wrangler 4.x (verified 4.64.0) returns exit 0 + stdout - /// `"Value not found"` for a missing key instead of exit 1 + - /// stderr. The previous read path treated every exit-0 stdout - /// as a `Present` envelope, which made the next CLI step try - /// to parse `"Value not found"` as a `BlobEnvelope` and abort. - /// A missing key in the blob model is valid initial state -- - /// the first push hasn't run yet -- not corrupt remote state, - /// so it must map to `MissingKey`. - #[cfg(unix)] - #[test] - fn read_remote_returns_missing_key_on_wrangler_4_value_not_found_stdout() { - let _lock = path_mutation_guard().lock().expect("guard"); - let project_dir = tempdir().expect("tempdir"); - write_wrangler(project_dir.path(), "name = \"demo\"\n"); - let fake = fake_wrangler_returning("Value not found\n", "", 0); - let _path = PathPrepend::new(fake.path()); - let result = CloudflareCliAdapter - .read_config_entry( - project_dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("Wrangler 4.x exit-0 'Value not found' must map to MissingKey"); - if let ReadConfigEntry::Present(body) = &result { - panic!( - "expected MissingKey on Wrangler 4.x 'Value not found' stdout; \ - got Present({body:?})", - ); - } - assert!( - matches!(result, ReadConfigEntry::MissingKey), - "Wrangler 4.x stdout='Value not found' (exit 0) must classify as MissingKey", - ); - } - - #[cfg(unix)] - #[test] - fn read_remote_returns_missing_store_on_binding_stderr() { - let _lock = path_mutation_guard().lock().expect("guard"); - let project_dir = tempdir().expect("tempdir"); - write_wrangler(project_dir.path(), "name = \"demo\"\n"); - let fake = fake_wrangler_returning("", "Error: binding APP_CONFIG is not defined", 1); - let _path = PathPrepend::new(fake.path()); - let result = CloudflareCliAdapter - .read_config_entry( - project_dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("binding-error maps to MissingStore (not Err)"); - assert!( - matches!(result, ReadConfigEntry::MissingStore), - "binding stderr => MissingStore" - ); - } - - #[cfg(unix)] - #[test] - fn read_local_uses_local_flag() { - // Verify that read_config_entry_local passes `--local` (not `--remote`) - // to wrangler. We capture argv via a fake wrangler and check the args. - let _lock = path_mutation_guard().lock().expect("guard"); - let project_dir = tempdir().expect("tempdir"); - write_wrangler(project_dir.path(), "name = \"demo\"\n"); - let argv_log = project_dir.path().join("argv.txt"); - let fake = fake_wrangler_argv_log(&argv_log); - let _path = PathPrepend::new(fake.path()); - let result = CloudflareCliAdapter - .read_config_entry_local( - project_dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("local read succeeds"); - assert!( - matches!(result, ReadConfigEntry::Present(_)), - "expected Present from local read" - ); - let captured = fs::read_to_string(&argv_log).expect("argv log"); - assert!( - captured.contains("--local"), - "read_local must pass --local to wrangler; got argv:\n{captured}" - ); - assert!( - !captured.contains("--remote"), - "read_local must NOT pass --remote; got argv:\n{captured}" - ); - } - - #[test] - fn read_config_entry_requires_adapter_manifest_path() { - let dir = tempdir().expect("tempdir"); - let result = CloudflareCliAdapter.read_config_entry( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ); - match result { - Err(err) => assert!( - err.contains("[adapters.cloudflare.adapter].manifest"), - "error names the missing field: {err}" - ), - Ok(_) => panic!("expected Err when adapter_manifest_path is None"), - } - } -} diff --git a/crates/edgezero-adapter-cloudflare/src/cli/mod.rs b/crates/edgezero-adapter-cloudflare/src/cli/mod.rs new file mode 100644 index 00000000..5b51b200 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/cli/mod.rs @@ -0,0 +1,785 @@ +use std::path::{Path, PathBuf}; + +use ctor::ctor; +use edgezero_adapter::cli_support; +use edgezero_adapter::cli_support::run_native_cli; +use edgezero_adapter::env_file::{EDGEZERO_PROVISION_HEADER, append_lines_dedup_with_header}; +use edgezero_adapter::registry::{ + Adapter, AdapterAction, AdapterDeployedState, AdapterExecContext, AdapterPushContext, + ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, + TypedSecretEntry, register_adapter, +}; +use edgezero_adapter::scaffold::{ + AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, + ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, +}; + +mod provision_cloud; +mod provision_local; +mod push_cloud; +mod run; + +static CLOUDFLARE_ADAPTER: CloudflareCliAdapter = CloudflareCliAdapter; + +static CLOUDFLARE_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { + id: "cloudflare", + display_name: "Cloudflare Workers", + crate_suffix: "adapter-cloudflare", + dependency_crate: "edgezero-adapter-cloudflare", + dependency_repo_path: "crates/edgezero-adapter-cloudflare", + template_registrations: CLOUDFLARE_TEMPLATE_REGISTRATIONS, + files: CLOUDFLARE_FILE_SPECS, + extra_dirs: &["src", ".cargo"], + dependencies: CLOUDFLARE_DEPENDENCIES, + manifest: ManifestSpec { + manifest_filename: "wrangler.toml", + build_target: "wasm32-unknown-unknown", + build_profile: "release", + build_features: &["cloudflare"], + }, + commands: CommandTemplates { + build: "wrangler build --cwd {crate_dir}", + deploy: "wrangler deploy --cwd {crate_dir}", + serve: "wrangler dev --cwd {crate_dir}", + emit_commands: true, + }, + logging: LoggingDefaults { + endpoint: None, + level: "info", + echo_stdout: None, + }, + readme: ReadmeInfo { + description: "{display} entrypoint.", + dev_heading: "{display} (local)", + dev_steps: &["`edgezero serve --adapter cloudflare`"], + }, + run_module: "edgezero_adapter_cloudflare", +}; + +static CLOUDFLARE_DEPENDENCIES: &[DependencySpec] = &[ + DependencySpec { + key: "dep_edgezero_core_cloudflare", + repo_crate: "crates/edgezero-core", + fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\", default-features = false }", + features: &[], + }, + DependencySpec { + key: "dep_edgezero_adapter_cloudflare", + repo_crate: "crates/edgezero-adapter-cloudflare", + fallback: "edgezero-adapter-cloudflare = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-cloudflare\", default-features = false }", + features: &[], + }, + DependencySpec { + key: "dep_edgezero_adapter_cloudflare_wasm", + repo_crate: "crates/edgezero-adapter-cloudflare", + fallback: "edgezero-adapter-cloudflare = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-cloudflare\", default-features = false, features = [\"cloudflare\"] }", + features: &["cloudflare"], + }, +]; + +// `wrangler.toml` is intentionally absent from the scaffold +// registration — same rationale as Axum's `axum.toml` and Spin's +// `spin.toml` / `runtime-config.toml`. It is written by the +// scaffold-time provision loop (see +// `generator::provision_all_selected_adapters` -> +// `Adapter::synthesise_baseline_manifest` -> `run::synthesise_wrangler_toml`). +// Registering a scaffold template would make the file exist +// before provision runs; provision's `write_baseline_to_disk` +// skips files that already exist (spec § "Adapter manifests are +// gitignored"), so the two baselines would diverge — the +// scaffold template would win at `edgezero new`, but the +// synthesiser would win on a clean clone. Single-source: only +// the synthesiser writes `wrangler.toml`. +static CLOUDFLARE_FILE_SPECS: &[AdapterFileSpec] = &[ + AdapterFileSpec { + template: "cf_Cargo_toml", + output: "Cargo.toml", + }, + AdapterFileSpec { + template: "cf_src_lib_rs", + output: "src/lib.rs", + }, + AdapterFileSpec { + template: "cf_src_main_rs", + output: "src/main.rs", + }, + AdapterFileSpec { + template: "cf_cargo_config_toml", + output: ".cargo/config.toml", + }, +]; + +static CLOUDFLARE_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ + TemplateRegistration { + name: "cf_Cargo_toml", + contents: include_str!("../templates/Cargo.toml.hbs"), + }, + TemplateRegistration { + name: "cf_src_lib_rs", + contents: include_str!("../templates/src/lib.rs.hbs"), + }, + TemplateRegistration { + name: "cf_src_main_rs", + contents: include_str!("../templates/src/main.rs.hbs"), + }, + TemplateRegistration { + name: "cf_cargo_config_toml", + contents: include_str!("../templates/.cargo/config.toml.hbs"), + }, +]; + +pub(super) const TARGET_TRIPLE: &str = "wasm32-unknown-unknown"; + +pub(super) const WRANGLER_INSTALL_HINT: &str = + "install the Cloudflare CLI (`npm install -g wrangler`) and try again"; + +struct CloudflareCliAdapter; + +impl Adapter for CloudflareCliAdapter { + fn deployed_fields(&self) -> &'static [&'static str] { + &["kv_namespaces", "preview_kv_namespaces"] + } + + fn execute( + &self, + action: AdapterAction, + args: &[String], + ctx: &AdapterExecContext<'_>, + ) -> Result<(), String> { + match action { + // `wrangler` is the native sign-in surface for Cloudflare + // Workers. EdgeZero stores no credentials — this is a thin + // shell-out. + AdapterAction::AuthLogin => { + run_native_cli("wrangler", &["login"], WRANGLER_INSTALL_HINT) + } + AdapterAction::AuthLogout => { + run_native_cli("wrangler", &["logout"], WRANGLER_INSTALL_HINT) + } + AdapterAction::AuthStatus => { + run_native_cli("wrangler", &["whoami"], WRANGLER_INSTALL_HINT) + } + AdapterAction::Build => run::build(args, ctx).map(|artifact| { + log::info!( + "[edgezero] Cloudflare build artifact -> {}", + artifact.display() + ); + }), + AdapterAction::Deploy => run::deploy(args, ctx), + AdapterAction::Serve => run::serve(args, ctx), + other => Err(format!("cloudflare adapter does not support {other:?}")), + } + } + + fn merged_id_kinds(&self) -> &'static [&'static str] { + // Both KV and Config back to Worker KV namespaces via the + // same `[[kv_namespaces]] binding = ` + // wrangler.toml entry. Declaring the same logical id under + // both kinds (e.g. `[stores.kv].ids = ["x"]` AND + // `[stores.config].ids = ["x"]`) resolves to a SINGLE + // underlying KV namespace at runtime — KV writes from the + // app silently clobber config-shaped entries (and vice + // versa). Provision compounds the hazard: the second + // binding would already be present from the first kind's + // `upsert_kv_namespace` and get reported as "already + // provisioned" instead of failing the collision. + // + // CLI `config validate` rejects this collision before any + // wrangler shell-out happens. + &["kv", "config"] + } + + fn name(&self) -> &'static str { + "cloudflare" + } + + // Cloudflare's per-adapter manifest is `wrangler.toml`; wrangler + // itself validates its own schema at deploy time, and the CLI + // has no adapter-specific shape check to layer on top. No-op + // matches the trait default. + #[inline] + fn validate_adapter_manifest( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _allow_component_refresh: bool, + ) -> Result<(), String> { + Ok(()) + } + + // Cloudflare has no adapter-specific key naming constraint on + // app-config keys — wrangler-side KV keys accept anything the + // runtime encodes. Trait default no-op. + #[inline] + fn validate_app_config_keys(&self, _keys: &[&str]) -> Result<(), String> { + Ok(()) + } + + /// Cloudflare appends each typed secret as a `=""` line into the + /// SAME `.dev.vars` that `provision_local` seeds with generated + /// `EDGEZERO__*` runtime-config lines (store `__NAME` / `__KEY` overlays, + /// plus `EDGEZERO__ADAPTER__*` / `EDGEZERO__LOGGING__*`), and both batches + /// share one `append_lines_dedup_with_header` pass. A typed secret whose + /// key falls anywhere in that reserved `EDGEZERO__` namespace would + /// collide with runtime config and resolve to configuration data instead + /// of the credential. Reject the collision here, in preflight, before any + /// write. + fn validate_typed_secrets(&self, entries: &[TypedSecretEntry<'_>]) -> Result<(), String> { + const RESERVED_PREFIX: &str = "EDGEZERO__"; + for entry in entries { + if entry + .key_value + .to_ascii_uppercase() + .starts_with(RESERVED_PREFIX) + { + return Err(format!( + "cloudflare: typed secret key `{}` is reserved: the `{RESERVED_PREFIX}` namespace is used for runtime configuration in `.dev.vars` (store overlays, `EDGEZERO__ADAPTER__*`, `EDGEZERO__LOGGING__*`, ...), so a secret there would collide with config and resolve to configuration data instead of the credential. Rename the secret.", + entry.key_value + )); + } + } + Ok(()) + } + + 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 { + match mode { + ProvisionMode::Local => provision_local::provision( + manifest_root, + adapter_manifest_path, + stores, + deployed, + dry_run, + ), + ProvisionMode::Cloud => provision_cloud::provision( + manifest_root, + adapter_manifest_path, + stores, + deployed, + dry_run, + ), + // ProvisionMode is #[non_exhaustive]; a future mode variant + // gets an explicit error so we don't accidentally dispatch + // via one of the two known arms. + other => Err(format!( + "cloudflare adapter does not implement provision mode {other:?}" + )), + } + } + + fn provision_typed( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + typed_secrets: &[TypedSecretEntry<'_>], + mode: ProvisionMode, + dry_run: bool, + ) -> Result { + // Cloud is a no-op: `wrangler secret put` is the tool for + // remote secret upload. `provision_typed` handles ONLY the + // local preview writeback — a `=""` placeholder + // per typed field, appended to the SAME `.dev.vars` file + // `provision_local` seeds with `EDGEZERO__STORES__…__NAME` / + // `__KEY` overlays. + if !matches!(mode, ProvisionMode::Local) { + return Ok(ProvisionOutcome::default()); + } + // Anchor `.dev.vars` on the RESOLVED wrangler.toml path so + // nested layouts (e.g. `adapter_manifest_path = + // "crates/app-demo-adapter-cloudflare/wrangler.toml"`) land + // the file in the same crate dir wrangler dev reads from, + // NOT at `manifest_root/.dev.vars`. Mirrors the placement + // `provision_local` uses for the __NAME / __KEY lines. + let wrangler_rel = adapter_manifest_path.unwrap_or("wrangler.toml"); + let wrangler_path = manifest_root.join(wrangler_rel); + let dev_vars_path = wrangler_path + .parent() + .unwrap_or(manifest_root) + .join(".dev.vars"); + let lines: Vec = typed_secrets + .iter() + .map(|entry| format!(r#"{}="""#, entry.key_value)) + .collect(); + append_lines_dedup_with_header( + &dev_vars_path, + Some(EDGEZERO_PROVISION_HEADER), + &lines, + dry_run, + ) + .map_err(|err| format!("write {}: {err}", dev_vars_path.display()))?; + let status_lines = vec![format!( + "cloudflare: wrote {} secret placeholders to {}", + typed_secrets.len(), + dev_vars_path.display() + )]; + Ok(ProvisionOutcome::from_status_lines(status_lines)) + } + + fn push_config_entries( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + push_cloud::write_entries( + manifest_root, + adapter_manifest_path, + store, + entries, + dry_run, + ) + } + + fn push_config_entries_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + push_cloud::write_entries_local( + manifest_root, + adapter_manifest_path, + store, + entries, + dry_run, + ) + } + + fn read_config_entry( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + key: &str, + _push_ctx: &AdapterPushContext<'_>, + ) -> Result { + push_cloud::read_wrangler_kv_key( + manifest_root, + adapter_manifest_path, + store, + key, + "--remote", + ) + } + + fn read_config_entry_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + key: &str, + _push_ctx: &AdapterPushContext<'_>, + ) -> Result { + push_cloud::read_wrangler_kv_key( + manifest_root, + adapter_manifest_path, + store, + key, + "--local", + ) + } + + // Cloudflare KV stores one envelope per key with no chunk fan-out, so + // there are no orphaned chunk entries to reclaim -- inherit the trait's + // "not implemented" default (spelled out for the `missing_trait_methods` + // lint). + fn gc_config_entries( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _store: &ResolvedStoreId, + _push_ctx: &AdapterPushContext<'_>, + _older_than_secs: u64, + _dry_run: bool, + ) -> Result, String> { + Err(format!( + "adapter `{}` does not implement `config gc`", + self.name() + )) + } + + fn preflight_config_write(&self, _key: &str, _body: &str) -> Result<(), String> { + Ok(()) + } + + fn single_store_kinds(&self) -> &'static [&'static str] { + //: cloudflare is Multi for KV (KV namespaces) and + // Config (KV namespaces), Single for Secrets (Worker + // Secrets is a single flat bag). + &["secrets"] + } + + fn synthesise_baseline_manifest( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + adapter_crate_path: Option<&str>, + _component_selector: Option<&str>, + app_name: &str, + _deployed: Option<&AdapterDeployedState>, + _allowed_outbound_hosts: &[String], + ) -> Result, String> { + let rel = + adapter_manifest_path.map_or_else(|| PathBuf::from("wrangler.toml"), PathBuf::from); + // Prefer the authoritative declared `.crate` for the crate name; + // fall back to the ancestor `Cargo.toml` search, then the scaffold + // convention `-adapter-cloudflare`. (An ancestor search alone + // could pick a nested package between the manifest and the crate.) + let crate_name = match cli_support::read_crate_name_at(manifest_root, adapter_crate_path)? { + Some(name) => name, + None => cli_support::read_adapter_crate_name(manifest_root, adapter_manifest_path) + .unwrap_or_else(|| { + if app_name.is_empty() { + "app-adapter-cloudflare".to_owned() + } else { + format!("{app_name}-adapter-cloudflare") + } + }), + }; + Ok(vec![(rel, run::synthesise_wrangler_toml(&crate_name))]) + } +} + +#[inline] +pub fn register() { + register_adapter(&CLOUDFLARE_ADAPTER); + register_adapter_blueprint(&CLOUDFLARE_BLUEPRINT); +} + +#[ctor(unsafe)] +fn register_ctor() { + register(); +} + +// Shared process-wide mutex serialising PATH-mutating tests across every +// submodule test suite in this crate. Tests in `provision_local`, `provision_cloud`, +// and `push_cloud` all install shell shims via `PathPrepend` and would otherwise +// race on the environment variable. +#[cfg(all(test, unix))] +use std::sync::Mutex as PathMutationMutex; + +#[cfg(all(test, unix))] +pub(crate) fn path_mutation_guard() -> &'static PathMutationMutex<()> { + use std::sync::OnceLock; + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| PathMutationMutex::new(())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + // Shared fixture names. Pinning these as consts (instead of + // inline `"sessions"` / `"app_config"` per call site) keeps the + // setup-vs-assertion pair in sync -- a typo in one place no + // longer silently divorces from the other, because both reference + // the same const. Also names the intent: these are the LOGICAL + // store ids the cloudflare adapter operates on, not arbitrary + // strings. + const TEST_SECRET_ID: &str = "default"; + + // ---------- provision_typed (Local mode) — secret placeholders ---------- + + #[test] + fn cloudflare_provision_typed_appends_secret_placeholders_to_dev_vars() { + // Fixture: nested wrangler.toml layout matching app-demo. + // provision_typed writes `=""` per entry into the + // `.dev.vars` NEXT TO the wrangler manifest (append_lines_dedup + // creates parent dirs, so no pre-seed of the wrangler.toml is + // required for this test). + let dir = tempdir().expect("tempdir"); + let entries = [TypedSecretEntry::new( + TEST_SECRET_ID, + "api_token", + "demo_api_token", + )]; + let outcome = CloudflareCliAdapter + .provision_typed( + dir.path(), + Some("crates/cf/wrangler.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision_typed succeeds"); + let dev_vars_path = dir.path().join("crates/cf/.dev.vars"); + assert!( + dev_vars_path.exists(), + ".dev.vars exists at nested path: {}", + dev_vars_path.display() + ); + let dev_vars = fs::read_to_string(&dev_vars_path).expect("read .dev.vars"); + assert!( + dev_vars.contains(r#"demo_api_token="""#), + "placeholder line present: {dev_vars}" + ); + assert!( + outcome + .status_lines + .iter() + .any(|line| line.contains(&dev_vars_path.display().to_string())), + "status line names the .dev.vars path: {:?}", + outcome.status_lines + ); + assert!( + outcome.deployed.is_none(), + "local provision_typed returns no deployed state" + ); + } + + #[test] + fn validate_typed_secrets_rejects_reserved_edgezero_namespace() { + // A typed secret key ANYWHERE in the reserved `EDGEZERO__` namespace + // (store overlays AND adapter/logging runtime config) would collide + // with runtime config in the SAME `.dev.vars`. Preflight must refuse + // it, case-insensitively. + for reserved in [ + "EDGEZERO__STORES__CONFIG__APP__KEY", + "EDGEZERO__ADAPTER__HOST", + "EDGEZERO__LOGGING__LEVEL", + "edgezero__adapter__port", + ] { + let entries = [TypedSecretEntry::new(TEST_SECRET_ID, "collision", reserved)]; + let Err(err) = CloudflareCliAdapter.validate_typed_secrets(&entries) else { + panic!("a reserved-namespace secret key must be rejected: {reserved}"); + }; + assert!( + err.contains("reserved") && err.contains("EDGEZERO__"), + "error explains the reserved-namespace collision for {reserved}: {err}" + ); + } + } + + #[test] + fn validate_typed_secrets_allows_ordinary_keys() { + let entries = [TypedSecretEntry::new( + TEST_SECRET_ID, + "api", + "demo_api_token", + )]; + CloudflareCliAdapter + .validate_typed_secrets(&entries) + .expect("an ordinary secret key must pass preflight"); + } + + #[test] + fn cloudflare_provision_typed_dev_vars_lands_next_to_wrangler_toml() { + // Locks the `wrangler_path.parent().join(".dev.vars")` + // anchor against drift: with `adapter_manifest_path = + // "crates/cf/wrangler.toml"`, `.dev.vars` MUST land at + // `temp/crates/cf/.dev.vars` and NOT at `temp/.dev.vars`. + let dir = tempdir().expect("tempdir"); + let entries = [TypedSecretEntry::new( + TEST_SECRET_ID, + "api_token", + "demo_api_token", + )]; + CloudflareCliAdapter + .provision_typed( + dir.path(), + Some("crates/cf/wrangler.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision_typed succeeds"); + assert!( + dir.path().join("crates/cf/.dev.vars").exists(), + ".dev.vars anchored on wrangler.toml parent" + ); + assert!( + !dir.path().join(".dev.vars").exists(), + "root-level .dev.vars must NOT be written" + ); + } + + #[test] + fn cloudflare_provision_typed_cloud_mode_is_a_no_op() { + // Cloud is a no-op: `wrangler secret put` is the remote + // path. Empty outcome, no `.dev.vars` written anywhere. + let dir = tempdir().expect("tempdir"); + let entries = [TypedSecretEntry::new( + TEST_SECRET_ID, + "api_token", + "demo_api_token", + )]; + let outcome = CloudflareCliAdapter + .provision_typed( + dir.path(), + Some("crates/cf/wrangler.toml"), + None, + &entries, + ProvisionMode::Cloud, + false, + ) + .expect("provision_typed Cloud succeeds"); + assert!( + outcome.status_lines.is_empty(), + "cloud mode emits no status lines: {:?}", + outcome.status_lines + ); + assert!( + outcome.deployed.is_none(), + "cloud mode returns no deployed state" + ); + assert!( + !dir.path().join("crates/cf/.dev.vars").exists(), + "cloud mode must NOT touch .dev.vars" + ); + assert!( + !dir.path().join(".dev.vars").exists(), + "cloud mode must NOT touch .dev.vars at manifest_root either" + ); + } + + #[test] + fn cloudflare_provision_typed_deduplicates_against_existing_dev_vars() { + // Operator has already filled in the real value. Re-running + // provision_typed must NOT clobber it with the empty + // placeholder — append_lines_dedup collapses keys. + let dir = tempdir().expect("tempdir"); + let dev_vars_dir = dir.path().join("crates/cf"); + fs::create_dir_all(&dev_vars_dir).expect("mkdir nested"); + let dev_vars_path = dev_vars_dir.join(".dev.vars"); + fs::write(&dev_vars_path, "demo_api_token=\"already_set\"\n").expect("seed .dev.vars"); + let entries = [TypedSecretEntry::new( + TEST_SECRET_ID, + "api_token", + "demo_api_token", + )]; + CloudflareCliAdapter + .provision_typed( + dir.path(), + Some("crates/cf/wrangler.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision_typed succeeds"); + let dev_vars = fs::read_to_string(&dev_vars_path).expect("read .dev.vars"); + assert!( + dev_vars.contains(r#"demo_api_token="already_set""#), + "operator's real value survives: {dev_vars}" + ); + assert!( + !dev_vars.contains(r#"demo_api_token="""#), + "empty-value placeholder must NOT be appended: {dev_vars}" + ); + let token_lines = dev_vars + .lines() + .filter(|line| { + let after_hash = line.trim_start().strip_prefix('#').unwrap_or(line); + after_hash.trim_start().starts_with("demo_api_token=") + }) + .count(); + assert_eq!( + token_lines, 1, + "exactly one demo_api_token line remains: {dev_vars}" + ); + } + + /// Renamed 2026-07 (deep self-review finding P1-f): the prior + /// name (`provision_local_push_after_provision_preserves_*`) + /// promised a push→provision integration test but the body only + /// re-runs `provision_typed` twice. The real invariant this + /// locks is: re-running `provision_typed` after an operator + /// hand-edits the placeholder MUST NOT clobber the edit. That + /// is the `append_lines_dedup` contract, not the push contract. + #[test] + fn provision_typed_local_re_run_preserves_operator_edit_to_dev_vars_secret() { + // First run seeds `SECRET_KEY=""` (empty placeholder) into + // `.dev.vars`. The operator hand-edits the file to + // `SECRET_KEY="real_value_operator_set"`. A subsequent + // `provision_typed` MUST NOT overwrite the operator's value + // with the empty placeholder — append_lines_dedup collapses + // commented + uncommented forms by normalised key, so the + // uncommented real value survives byte-for-byte. + let dir = tempdir().expect("tempdir"); + let entries = [TypedSecretEntry::new( + TEST_SECRET_ID, + "api_token", + "SECRET_KEY", + )]; + CloudflareCliAdapter + .provision_typed( + dir.path(), + Some("wrangler.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("first provision_typed writes empty placeholder"); + let dev_vars_path = dir.path().join(".dev.vars"); + let first = fs::read_to_string(&dev_vars_path).expect("read .dev.vars (first run)"); + assert!( + first.contains(r#"SECRET_KEY="""#), + "empty placeholder present after first run: {first}" + ); + // Simulate the operator's hand-edit. Rewrite just the + // SECRET_KEY line; everything else stays as provision wrote it. + let edited = first.replace( + r#"SECRET_KEY="""#, + r#"SECRET_KEY="real_value_operator_set""#, + ); + assert_ne!(edited, first, "operator edit actually mutated the file"); + fs::write(&dev_vars_path, &edited).expect("operator hand-edit"); + CloudflareCliAdapter + .provision_typed( + dir.path(), + Some("wrangler.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("re-run provision_typed after operator edit"); + let after = fs::read_to_string(&dev_vars_path).expect("read .dev.vars (second run)"); + assert!( + after.contains(r#"SECRET_KEY="real_value_operator_set""#), + "operator's value survives byte-for-byte: {after}" + ); + assert!( + !after.contains(r#"SECRET_KEY="""#), + "empty placeholder must NOT be re-appended: {after}" + ); + // Exactly one SECRET_KEY line remains after dedup. + let key_lines = after + .lines() + .filter(|line| { + let after_hash = line.trim_start().strip_prefix('#').unwrap_or(line); + after_hash.trim_start().starts_with("SECRET_KEY=") + }) + .count(); + assert_eq!( + key_lines, 1, + "exactly one SECRET_KEY line remains after dedup: {after}" + ); + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/cli/provision_cloud.rs b/crates/edgezero-adapter-cloudflare/src/cli/provision_cloud.rs new file mode 100644 index 00000000..60015731 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/cli/provision_cloud.rs @@ -0,0 +1,1417 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::io::ErrorKind; +use std::path::Path; +use std::process::Command; + +use edgezero_adapter::registry::{ + AdapterDeployedState, ProvisionOutcome, ProvisionStores, ResolvedStoreId, +}; + +use super::WRANGLER_INSTALL_HINT; +use super::provision_local::{ + check_kv_namespaces_writeback_shape, existing_real_namespace_id, read_namespace_id, + upsert_kv_namespace, +}; + +/// Cloud-mode `provision` arm: shells out to `wrangler kv namespace +/// create ` for every declared KV / config store that isn't +/// already provisioned, then writes the returned id back into +/// `wrangler.toml` via [`upsert_kv_namespace`]. Secret stores are +/// runtime-managed via `wrangler secret put` — the Cloud arm reports +/// each declared secret but performs no side effect. +pub(super) fn provision( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + stores: &ProvisionStores<'_>, + deployed: Option<&AdapterDeployedState>, + dry_run: bool, +) -> Result { + //: 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. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for provision" + .to_owned(), + ); + }; + let wrangler_path = manifest_root.join(rel); + + // Cloud provision MUTATES REMOTE ACCOUNT STATE (`wrangler kv namespace + // create`) and then records the namespace ids back into wrangler.toml. + // wrangler.toml is gitignored, so on a clean clone it is absent -- and + // creating remote namespaces first, then starting the writeback from + // an empty document, would orphan those namespaces behind a manifest + // that never declared them. Refuse BEFORE any account mutation (in + // dry-run too, so the preview models the real outcome), matching the + // Fastly adapter's preflight. + if !wrangler_path.exists() { + return Err(format!( + "{}: not found. Cloud provision records the KV namespaces it creates in wrangler.toml, \ + and must not create remote resources against a manifest that does not exist yet. Run \ + `provision --adapter cloudflare --local` first to synthesise the baseline manifest, \ + then re-run cloud provision.", + wrangler_path.display() + )); + } + + 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(); + // Preflight EVERY store's deterministic preconditions (local/tracked id + // conflicts, writeback-shape validity) BEFORE the first `wrangler` + // call. Without this an earlier store could create a namespace remotely + // and only then a later store's knowable-up-front conflict aborts the + // run, orphaning the freshly-created namespace. All checks are pure + // local reads, so a bad manifest fails before any account mutation. + for store in stores.kv.iter().chain(stores.config.iter()) { + preflight_one_kv_store(store, &wrangler_path, deployed)?; + } + let mut pending_error: Option = None; + for store in stores.kv.iter().chain(stores.config.iter()) { + if let Err(err) = provision_one_kv_store( + store, + &wrangler_path, + deployed, + dry_run, + &mut created_kv_ns, + &mut out, + ) { + // A store failed. Durable ids created by EARLIER stores are + // already in `created_kv_ns`; carry them out so the CLI + // checkpoints them into tracked `edgezero.toml` before + // surfacing the error, rather than losing an + // already-created namespace to a later store's failure. + pending_error = Some(err); + break; + } + } + if pending_error.is_none() { + for store in stores.secrets { + let logical = &store.logical; + let platform = &store.platform; + out.push(format!( + "cloudflare secret `{platform}` (logical id `{logical}`) is runtime-managed via `wrangler secret put`; nothing to provision" + )); + } + } + if out.is_empty() { + out.push("cloudflare has no declared stores to provision".to_owned()); + } + let created_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) + }; + let outcome = match created_deployed { + Some(state) => ProvisionOutcome::with_deployed(out, state), + None => ProvisionOutcome::from_status_lines(out), + }; + Ok(match pending_error { + Some(err) => outcome.with_error(err), + None => outcome, + }) +} + +/// Provision a single KV/config store's Cloudflare namespace. Split out +/// of the store loop so the caller can catch a mid-loop failure and still +/// surface the durable ids earlier stores created (see +/// [`ProvisionOutcome::error`]). A successful skip/restore/create returns +/// `Ok(())`; any writeback failure returns `Err`. +fn provision_one_kv_store( + store: &ResolvedStoreId, + wrangler_path: &Path, + deployed: Option<&AdapterDeployedState>, + dry_run: bool, + created_kv_ns: &mut BTreeMap, + out: &mut Vec, +) -> Result<(), String> { + let logical = &store.logical; + // The Cloudflare KV binding name is what the runtime + // calls `env.kv(...)` with -- it's resolved at request + // time from `EDGEZERO__STORES______NAME` + // (default = logical id). Provision must write the + // resolved PLATFORM name into wrangler.toml, otherwise + // the runtime will look up a binding the CLI never + // created. + let binding = &store.platform; + // Idempotency check BEFORE shelling out: if a + // [[kv_namespaces]] entry with `binding = ` + // is already present and has a real namespace id, skip. + // Without this guard a re-run of provision would invoke + // `wrangler kv namespace create` again and orphan the + // previously-created namespace -- wasting account quota. + // A placeholder id (anything that isn't a 32-char + // lowercase hex string, like the + // `local-dev-placeholder` the scaffold wrangler.toml + // writes) is treated as "not yet provisioned" so the + // entry gets rewritten with the real id. + // + // We deliberately do NOT cross-check the stored id + // against Cloudflare's API (e.g. by calling `wrangler + // kv namespace list` to confirm the id still exists). + // Verifying every entry on every provision run would + // add a network round-trip per id and require parsing + // yet another wrangler subcommand output. The skip + // line names the existing id explicitly so the operator + // can verify it themselves and, if the Cloudflare-side + // namespace was deleted out-of-band, remove the stale + // entry by hand before re-running provision. + // The team-tracked namespace id for this logical store (from + // committed `[adapters.cloudflare.deployed].kv_namespaces`), if + // any. `wrangler.toml` is gitignored and per-machine; this is + // the shared source of truth to reconcile against. + let tracked_id = deployed + .and_then(|state| state.sub_tables.get("kv_namespaces")) + .and_then(|namespaces| namespaces.get(logical.as_str())); + // Deterministic, network-free reconciliation: detect a stale local vs + // committed id conflict and surface the local real id (if any). The + // caller preflights this for EVERY store before the first `wrangler` + // call, so a knowable conflict aborts before any remote mutation. + let existing = reconcile_kv_namespace_id(store, wrangler_path, deployed)?; + if let Some(existing_id) = existing { + match tracked_id { + // Local matches the committed id: already the team's + // source of truth, nothing to write back. + Some(_) => { + out.push(format!( + "binding `{binding}` (logical id `{logical}`) already provisioned (id={existing_id}, matches tracked); skipping." + )); + } + // No tracked id. The local id is unverified, gitignored, + // per-machine state -- a stale, deleted, or wrong-account + // namespace. Do NOT promote it into tracked deployed + // state; the spec derives durable ids from + // `wrangler kv namespace create` output only, and an + // unconditional local promotion would silently make bad + // state the team's source of truth without any + // Cloudflare call. + None => { + out.push(format!( + "binding `{binding}` (logical id `{logical}`) has a local namespace id (id={existing_id} in {}) but no tracked id; NOT promoting an unverified gitignored id. If it is correct, set `[adapters.cloudflare.deployed].kv_namespaces.{logical}` in edgezero.toml by hand; to recreate, delete the [[kv_namespaces]] entry for binding `{binding}` and re-run provision.", + wrangler_path.display() + )); + } + } + return Ok(()); + } + // No real id locally. If the team already tracks one, this is a + // fresh clone (or a wiped local file): restore the committed id + // into wrangler.toml rather than calling `wrangler kv namespace + // create` and orphaning a DUPLICATE namespace on Cloudflare. + if let Some(tracked) = tracked_id { + // But a tracked id must be a REAL namespace id. A malformed value + // (hand-edited, a placeholder, or corruption -- e.g. `abc`) would + // otherwise be restored verbatim and make provision report success, + // while every later read/push against that fake id fails. Refuse + // loudly instead of silently trusting it. + if !is_real_namespace_id(tracked) { + return Err(format!( + "tracked KV namespace id `{tracked}` for binding `{binding}` (logical id `{logical}`) is not a valid Cloudflare namespace id (32-char lowercase hex). Fix or remove `[adapters.cloudflare.deployed].kv_namespaces.{logical}` in edgezero.toml; delete it to recreate the namespace on the next provision." + )); + } + check_kv_namespaces_writeback_shape(wrangler_path)?; + if dry_run { + out.push(format!( + "would restore tracked namespace id {tracked} for binding `{binding}` (logical id `{logical}`) into {}; no new namespace created", + wrangler_path.display() + )); + return Ok(()); + } + upsert_kv_namespace(wrangler_path, binding, tracked)?; + out.push(format!( + "restored tracked KV namespace id {tracked} for binding `{binding}` (logical id `{logical}`) into {}; no new namespace created", + wrangler_path.display() + )); + created_kv_ns.insert(logical.clone(), tracked.clone()); + return Ok(()); + } + // Pre-flight the writeback shape BEFORE shelling + // `wrangler kv namespace create`. `read_namespace_id` + // tolerates both `[[kv_namespaces]]` (array-of-tables) + // and `kv_namespaces = [{ binding = "...", id = "..." }]` + // (inline-array) forms, but `upsert_kv_namespace` only + // writes back through the array-of-tables shape. Without + // this guard, an inline-array manifest passes the + // "already provisioned?" probe (because no id is + // present), the remote `create` succeeds, and then the + // upsert errors out — leaving the freshly-created + // namespace orphaned on Cloudflare with no local + // writeback to track it. + // + // Refuse early so the operator fixes the manifest shape + // BEFORE any account-side mutation. + check_kv_namespaces_writeback_shape(wrangler_path)?; + if dry_run { + out.push(format!( + "would run `wrangler kv namespace create {binding}` and append [[kv_namespaces]] binding = \"{binding}\" to {} (logical id `{logical}`)", + wrangler_path.display() + )); + return Ok(()); + } + let namespace_id = create_kv_namespace(binding, wrangler_path)?; + // Record under the LOGICAL id (not the platform binding) BEFORE the + // writeback. 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. Recording before the upsert + // means a create-success / upsert-failure still checkpoints the + // durable id, so the caller can persist it into tracked deployed + // state instead of orphaning the namespace on Cloudflare. + created_kv_ns.insert(logical.clone(), namespace_id.clone()); + upsert_kv_namespace(wrangler_path, binding, &namespace_id)?; + out.push(format!( + "created KV namespace `{binding}` (logical id `{logical}`, namespace id={namespace_id}); written to {}", + wrangler_path.display() + )); + Ok(()) +} + +/// Deterministic, network-free reconciliation for one KV/config store: +/// detect a stale local (gitignored `wrangler.toml`) vs committed +/// (`[adapters.cloudflare.deployed]`) namespace-id conflict and return the +/// local real id if one is present. Shared between the preflight pass and +/// the mutating [`provision_one_kv_store`] so the conflict is a single +/// source of truth and can be surfaced for every store BEFORE any remote +/// mutation runs. +fn reconcile_kv_namespace_id( + store: &ResolvedStoreId, + wrangler_path: &Path, + deployed: Option<&AdapterDeployedState>, +) -> Result, String> { + let logical = &store.logical; + let binding = &store.platform; + let tracked_id = deployed + .and_then(|state| state.sub_tables.get("kv_namespaces")) + .and_then(|namespaces| namespaces.get(logical.as_str())); + let existing = existing_real_namespace_id(wrangler_path, binding)?; + if let (Some(existing_id), Some(tracked)) = (&existing, tracked_id) { + // Local disagrees with the committed id: the gitignored file is + // stale or hand-edited. Refuse rather than let the writeback + // silently replace the team's id. + if tracked != existing_id { + return Err(format!( + "namespace id conflict for logical store `{logical}`: gitignored `{}` declares \ + `{existing_id}`, but tracked `[adapters.cloudflare.deployed].kv_namespaces.{logical}` \ + is `{tracked}`. wrangler.toml is per-machine, so provision will not overwrite the \ + committed id with it. Resolve by hand: delete the stale [[kv_namespaces]] entry for \ + binding `{binding}` (the committed id is restored on the next run), or update the \ + tracked value in edgezero.toml.", + wrangler_path.display() + )); + } + } + Ok(existing) +} + +/// Preflight one KV/config store's deterministic preconditions (id +/// conflict and, when a writeback would occur, the manifest shape) using +/// only local reads. Run for EVERY store before the first `wrangler` +/// invocation so a knowable conflict or malformed manifest aborts before +/// any earlier store has mutated remote account state. +fn preflight_one_kv_store( + store: &ResolvedStoreId, + wrangler_path: &Path, + deployed: Option<&AdapterDeployedState>, +) -> Result<(), String> { + // A present local real id means a skip (no writeback); reconcile still + // catches the local/tracked conflict. An absent id means either the + // restore or the create path runs, and both write back -- so the shape + // must be valid up front. + if reconcile_kv_namespace_id(store, wrangler_path, deployed)?.is_none() { + check_kv_namespaces_writeback_shape(wrangler_path)?; + } + Ok(()) +} + +/// Shell out to `wrangler kv namespace create `, capture +/// stdout, and parse the resulting namespace id. The CLI's +/// `provision` command resolves this against the user's +/// `wrangler.toml` and writes the `[[kv_namespaces]]` entry. +/// +/// # Errors +/// Returns an error if `wrangler` isn't on `PATH`, the child fails +/// to spawn, the exit status is non-zero, or stdout doesn't +/// include a parseable `id = "..."` line. +fn create_kv_namespace(binding: &str, wrangler_path: &Path) -> Result { + // Anchor the create to THIS project's `wrangler.toml` via + // `--config`. Without it, `wrangler` picks up whatever config it + // discovers from the process cwd, which -- especially under + // `edgezero provision --manifest ` -- can create the + // namespace against the wrong Wrangler configuration / account and + // then record that foreign id in this project. + let config_arg = wrangler_path + .to_str() + .ok_or_else(|| format!("invalid wrangler.toml path {}", wrangler_path.display()))?; + let output = Command::new("wrangler") + .args(["kv", "namespace", "create", binding, "--config", config_arg]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") + } else { + format!("failed to spawn `wrangler`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "`wrangler kv namespace create {binding}` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + extract_namespace_id(&stdout).ok_or_else(|| { + format!( + "wrangler created `{binding}` but stdout did not include a parseable `id = \"...\"` line -- wrangler may have changed its output format; pin a known-compatible wrangler version or file an issue. Raw stdout:\n{stdout}" + ) + }) +} + +/// Pull the namespace id out of `wrangler kv namespace create` +/// stdout. Wrangler 3+ prints (something like): +/// +/// ```text +/// 🌀 Creating namespace with title "..." +/// ✨ Success! +/// Add the following to your configuration file in your kv_namespaces array: +/// [[kv_namespaces]] +/// binding = "my-kv" +/// id = "abc123..." +/// ``` +/// +/// We tolerate leading whitespace + surrounding decoration. To +/// avoid grabbing a stray informational line like +/// `id = ""` printed somewhere else in wrangler +/// output (or a hypothetical future `id = ...` line that names a +/// non-KV resource), we anchor to the `[[kv_namespaces]]` table +/// header AND require the value to be 32-char lowercase hex +/// (Cloudflare's actual namespace-id shape). The scan walks +/// lines top-down: when we see `[[kv_namespaces]]` we set a +/// scope flag; the next `id = "<32-char-hex>"` line within that +/// scope is the result. A new top-level header resets the scope. +fn extract_namespace_id(stdout: &str) -> Option { + let mut in_kv_namespaces = false; + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed == "[[kv_namespaces]]" { + in_kv_namespaces = true; + continue; + } + // Any other table header ends the scope so we don't reach + // forward into a sibling block. + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_kv_namespaces = false; + continue; + } + if !in_kv_namespaces { + continue; + } + let Some(after_id_kw) = trimmed.strip_prefix("id") else { + continue; + }; + let Some(after_eq) = after_id_kw.trim_start().strip_prefix('=') else { + continue; + }; + let Some(quoted) = after_eq.trim_start().strip_prefix('"') else { + continue; + }; + let Some((id, _)) = quoted.split_once('"') else { + continue; + }; + if is_real_namespace_id(id) { + return Some(id.to_owned()); + } + } + None +} + +/// Heuristic: is `id` a real Cloudflare KV namespace id (32-char +/// lowercase hex), as opposed to a scaffold placeholder like +/// `local-dev-placeholder`? Cloudflare's API consistently returns +/// 32-char lowercase hex, so we use that as a tight cheap signal. +/// +/// Additionally rejects hex-shape sentinels that LOOK like real +/// ids but are obviously hand-typed placeholders: anything with +/// fewer than 6 distinct hex characters (catches all-zeros, +/// all-`a`, `deadbeefdeadbeefdeadbeefdeadbeef`, etc.). A real id +/// generated by Cloudflare's API has effectively uniform random +/// hex distribution: expected distinct chars over 32 draws from +/// 16 symbols is ~14, and the dominant term P(=5 distinct) is on +/// the order of 10^-13 -- so false rejections of real ids are +/// astronomically unlikely. +pub(super) fn is_real_namespace_id(id: &str) -> bool { + if id.len() != 32 { + return false; + } + if !id + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return false; + } + // Distinct-byte count via a BTreeSet: 32 inserts is trivial, + // and the set form avoids the arithmetic-side-effect / + // silent-as / indexing-panic shapes the project's clippy + // profile rejects. + let distinct: BTreeSet = id.bytes().collect(); + distinct.len() >= 6 +} + +/// Look up the namespace id wrangler.toml has bound to `binding`, +/// rejecting placeholder ids (anything that isn't a 32-char +/// lowercase hex Cloudflare API id). +/// +/// Accepts both `[[kv_namespaces]]` (array-of-tables, what +/// `provision` writes and wrangler's own post-create hint prints) +/// and the inline-array form. Returns Err with a "did you run +/// provision?" hint if the binding is absent OR holds a placeholder +/// like `local-dev-placeholder` — without this check `push` would +/// shell out to `wrangler kv bulk put --namespace-id=`, +/// which fails at wrangler with a less actionable error. +pub(super) fn find_namespace_id(wrangler_path: &Path, binding: &str) -> Result { + // read_namespace_id returns Ok(None) for both + // missing-file AND binding-not-present; for `find_namespace_id` + // the user wants a "did you run provision?" hint in both cases, + // so collapse them into the same error message. + let raw = read_namespace_id(wrangler_path, binding)?.ok_or_else(|| { + format!( + "{}: no [[kv_namespaces]] entry with binding = {binding:?} (did you run `edgezero provision --adapter cloudflare`?)", + wrangler_path.display() + ) + })?; + if is_real_namespace_id(&raw) { + Ok(raw) + } else { + Err(format!( + "{}: binding {binding:?} has id {raw:?}, which doesn't look like a real Cloudflare KV namespace id (expected 32-char lowercase hex). This is usually a scaffold placeholder -- run `edgezero provision --adapter cloudflare` to create a real namespace and overwrite the entry.", + wrangler_path.display() + )) + } +} + +// `create_kv_namespace` is exercised indirectly via the +// `cloudflare_cloud_provision_returns_created_namespace_ids` test +// (which installs a fake `wrangler` shim on PATH and asserts +// against the parsed namespace id). +#[cfg(test)] +mod tests { + use super::super::CloudflareCliAdapter; + #[cfg(unix)] + use super::super::path_mutation_guard; + use super::*; + use edgezero_adapter::registry::{Adapter as _, ProvisionMode}; + use edgezero_core::test_env::PathPrepend; + use std::fs; + use std::path::PathBuf; + use tempfile::tempdir; + + const TEST_KV_ID: &str = "sessions"; + const TEST_KV_ID_ALT: &str = "cache"; + const TEST_CONFIG_ID: &str = "app_config"; + const TEST_SECRET_ID: &str = "default"; + + #[cfg(unix)] + fn fake_wrangler_returning( + stdout_body: &str, + stderr_body: &str, + exit_code: i32, + ) -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let script_path = dir.path().join("wrangler"); + let stdout_file = dir.path().join("stdout_payload.txt"); + let stderr_file = dir.path().join("stderr_payload.txt"); + fs::write(&stdout_file, stdout_body).expect("write stdout payload"); + fs::write(&stderr_file, stderr_body).expect("write stderr payload"); + let script = format!( + "#!/bin/sh\ncat '{stdout}'\ncat '{stderr}' >&2\nexit {code}\n", + stdout = stdout_file.display(), + stderr = stderr_file.display(), + code = exit_code, + ); + fs::write(&script_path, script).expect("write wrangler script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + dir + } + + fn write_wrangler(dir: &Path, contents: &str) -> PathBuf { + let path = dir.join("wrangler.toml"); + fs::write(&path, contents).expect("write wrangler.toml"); + path + } + + // ---------- extract_namespace_id ---------- + + #[test] + fn extract_namespace_id_parses_wrangler_3_output() { + // wrangler decorates these lines with unicode glyphs in real + // output; we drop them from the fixture to keep the source + // file ASCII-only (clippy::non_ascii_literal). The parser + // requires both the `[[kv_namespaces]]` anchor and a + // 32-char-lowercase-hex id. + let stdout = r#"Creating namespace with title "my-kv" +Success! +Add the following to your configuration file in your kv_namespaces array: +[[kv_namespaces]] +binding = "my-kv" +id = "00112233445566778899aabbccddeeff" +"#; + assert_eq!( + extract_namespace_id(stdout).as_deref(), + Some("00112233445566778899aabbccddeeff") + ); + } + + #[test] + fn extract_namespace_id_tolerates_extra_whitespace() { + let stdout = "[[kv_namespaces]]\n id = \"00112233445566778899aabbccddeeff\" \n"; + assert_eq!( + extract_namespace_id(stdout).as_deref(), + Some("00112233445566778899aabbccddeeff") + ); + } + + #[test] + fn extract_namespace_id_returns_none_on_missing_id_line() { + assert!(extract_namespace_id("nothing to see here").is_none()); + assert!(extract_namespace_id("").is_none()); + assert!( + extract_namespace_id("[[kv_namespaces]]\nid = \"\"").is_none(), + "empty value not a real id" + ); + } + + #[test] + fn extract_namespace_id_ignores_unrelated_lines_starting_with_id() { + // `identifier = "..."` doesn't match -- we strip exactly the + // prefix `id` then require `=`. Also doesn't match because + // there's no `[[kv_namespaces]]` anchor. + assert!(extract_namespace_id("[[kv_namespaces]]\nidentifier = \"x\"").is_none()); + } + + #[test] + fn extract_namespace_id_requires_kv_namespaces_anchor() { + // A bare `id = "<32-char-hex>"` line that isn't preceded by + // `[[kv_namespaces]]` must not match -- otherwise a future + // wrangler info line like `id = ""` printed + // somewhere else in stdout would be picked up as the + // namespace id and silently corrupt wrangler.toml on writeback. + let unanchored = "id = \"00112233445566778899aabbccddeeff\"\n"; + assert!(extract_namespace_id(unanchored).is_none()); + + // A different table header BEFORE the `id` line scopes us + // out of the kv-namespaces context. + let other_block = "[[d1_databases]]\nid = \"00112233445566778899aabbccddeeff\"\n"; + assert!(extract_namespace_id(other_block).is_none()); + } + + #[test] + fn extract_namespace_id_rejects_non_real_id_inside_kv_namespaces_anchor() { + // Even with the anchor, the value must look like a real + // Cloudflare id (32-char lowercase hex with the diversity + // floor). Shorter or non-hex values are skipped, not + // returned -- forces the operator to investigate stdout + // drift rather than silently writing a bogus id. + let stdout = "[[kv_namespaces]]\nbinding = \"my-kv\"\nid = \"abc123\"\n"; + assert!(extract_namespace_id(stdout).is_none()); + } + + #[test] + fn extract_namespace_id_returns_first_real_match_inside_kv_namespaces_anchor() { + // Pin: top-down scan, first qualifying line inside the + // `[[kv_namespaces]]` anchor wins. Real wrangler output has + // exactly one. A hypothetical future format with multiple + // qualifying lines would surface the earliest, but only + // values that look like real Cloudflare ids count. + let stdout = "[[kv_namespaces]]\n\ + id = \"00112233445566778899aabbccddeeff\"\n\ + id = \"ffeeddccbbaa99887766554433221100\"\n"; + assert_eq!( + extract_namespace_id(stdout).as_deref(), + Some("00112233445566778899aabbccddeeff") + ); + } + + // ---------- is_real_namespace_id ---------- + + #[test] + fn is_real_namespace_id_accepts_32_char_lowercase_hex_with_sufficient_diversity() { + // 16-distinct-char fixture: maximum diversity. + assert!(is_real_namespace_id("00112233445566778899aabbccddeeff")); + // Realistic randomish fixture: 14 distinct chars. + assert!(is_real_namespace_id("4a8f3c2b9e1d5670adef2839c4b6e1f0")); + } + + #[test] + fn is_real_namespace_id_rejects_placeholder_or_short_id() { + assert!(!is_real_namespace_id("local-dev-placeholder")); + assert!(!is_real_namespace_id("abc123")); + assert!(!is_real_namespace_id("")); + } + + #[test] + fn is_real_namespace_id_rejects_uppercase_or_non_hex() { + // Uppercase rejected: Cloudflare's API returns lowercase. + assert!(!is_real_namespace_id("00112233445566778899AABBCCDDEEFF")); + // Non-hex digits rejected. + assert!(!is_real_namespace_id("z0112233445566778899aabbccddeeff")); + } + + #[test] + fn is_real_namespace_id_rejects_hex_shape_sentinels() { + // 32-char lowercase hex but obvious hand-typed placeholder: + // distinct-hex-digit count is below the diversity floor. + // Real Cloudflare ids have effectively uniform random hex, + // so collisions with this guard are astronomical. + assert!( + !is_real_namespace_id("00000000000000000000000000000000"), + "all-zeros rejected" + ); + assert!( + !is_real_namespace_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + "all-a rejected" + ); + assert!( + !is_real_namespace_id("deadbeefdeadbeefdeadbeefdeadbeef"), + "deadbeef rejected (only 5 distinct chars: d,e,a,b,f)" + ); + // Boundary: a real-looking id with the diversity floor or + // more must still pass. + assert!( + is_real_namespace_id("00112233445566778899aabbccddeeff"), + "16-distinct-char fixture must still pass" + ); + // Exactly 6 distinct chars (a,b,c,d,e,f): on the boundary, + // must pass. + assert!( + is_real_namespace_id("aabbccddeeffaabbccddeeffaabbccdd"), + "6-distinct-char fixture (boundary) passes" + ); + } + + // ---------- provision (dry-run + error path) ---------- + + #[test] + fn provision_dry_run_does_not_invoke_wrangler() { + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let kv_ids: Vec = + ResolvedStoreId::from_logicals(&[TEST_KV_ID, TEST_KV_ID_ALT]); + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let secret_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_SECRET_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, + }; + let out = CloudflareCliAdapter + .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.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"); + } + + #[test] + fn provision_dry_run_writes_resolved_platform_name_into_binding() { + // Regression: provision used to receive only logical ids + // and write them verbatim into wrangler.toml. With the + // platform-name flow, an operator who sets + // `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config` + // sees `prod_config` land as the binding name (matching what + // the runtime resolves via `env.kv(...)`), with the logical + // id still mentioned for human-facing wording. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let config_ids = vec![ResolvedStoreId::new(TEST_CONFIG_ID, "prod_config")]; + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) + .expect("dry-run succeeds"); + assert_eq!(out.status_lines.len(), 1); + assert!( + out.status_lines[0].contains("wrangler kv namespace create prod_config"), + "dry-run uses platform name in the `wrangler` invocation: {out:?}" + ); + assert!( + out.status_lines[0].contains("binding = \"prod_config\""), + "dry-run writes platform name as the binding: {out:?}" + ); + assert!( + out.status_lines[0].contains("logical id `app_config`"), + "logical id is preserved for operator wording: {out:?}" + ); + } + + #[test] + fn provision_errors_when_adapter_manifest_path_missing() { + let dir = tempdir().expect("tempdir"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let err = CloudflareCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) + .expect_err("missing adapter manifest path must error"); + assert!( + err.contains("wrangler.toml"), + "error names what's missing: {err}" + ); + } + + #[test] + fn cloud_provision_refuses_when_wrangler_toml_is_missing() { + // wrangler.toml is gitignored, so a clean clone has none. Creating + // remote namespaces first and then writing them back from an empty + // document would orphan them behind a manifest that never declared + // them. Refuse BEFORE any account mutation -- in dry-run too. + let dir = tempdir().expect("tempdir"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + for dry_run in [true, false] { + let err = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + dry_run, + ) + .expect_err("cloud provision must refuse without a baseline manifest"); + assert!( + err.contains("provision --adapter cloudflare --local"), + "dry_run={dry_run}: error points at local provision: {err}" + ); + } + assert!( + !dir.path().join("wrangler.toml").exists(), + "refusal must not materialise a manifest" + ); + } + + #[test] + fn provision_dry_run_skips_bindings_already_provisioned_with_real_id() { + let dir = tempdir().expect("tempdir"); + // 32-char lowercase hex id == real Cloudflare namespace id. + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + // The committed id matches the local one, so this is genuinely + // "already provisioned" from the team's perspective. + let tracked = tracked_kv(TEST_KV_ID, "00112233445566778899aabbccddeeff"); + let out = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + Some(&tracked), + ProvisionMode::Cloud, + true, + ) + .expect("dry-run succeeds"); + assert_eq!(out.status_lines.len(), 1); + assert!( + 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"); + assert!( + after.contains("00112233445566778899aabbccddeeff"), + "did not touch existing id: {after}" + ); + } + + #[test] + fn provision_does_not_promote_unverified_local_namespace_id() { + // A real-looking id present ONLY in gitignored wrangler.toml, + // with no tracked id, must NOT be promoted into deployed + // writeback: it could be stale, deleted, or from another + // account, and the spec derives durable ids from + // `wrangler kv namespace create` output, not from reading the + // per-machine file. + let dir = tempdir().expect("tempdir"); + write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + 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(), + "an unverified local id with no tracked entry must NOT be promoted: {:?}", + out.deployed + ); + assert!( + out.status_lines + .iter() + .any(|line| line.contains("NOT promoting")), + "operator is told the local id was not promoted: {:?}", + out.status_lines + ); + } + + fn tracked_kv(logical: &str, namespace_id: &str) -> AdapterDeployedState { + let mut state = AdapterDeployedState::default(); + let mut namespaces = BTreeMap::new(); + namespaces.insert(logical.to_owned(), namespace_id.to_owned()); + state + .sub_tables + .insert("kv_namespaces".to_owned(), namespaces); + state + } + + /// A gitignored, per-machine `wrangler.toml` carrying a namespace + /// id that DISAGREES with the committed + /// `[adapters.cloudflare.deployed]` id must abort provision, not + /// let the writeback silently replace the team's id. + #[test] + fn provision_errors_when_local_namespace_id_conflicts_with_tracked() { + let dir = tempdir().expect("tempdir"); + write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let tracked = tracked_kv(TEST_KV_ID, "ffffffffffffffffffffffffffffffff"); + // A stale local vs tracked id is a deterministic precondition, so + // it is preflighted and aborts the whole provision before any + // remote mutation -- surfacing as a top-level Err, not an outcome + // that carries checkpointed ids. + let err = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + Some(&tracked), + ProvisionMode::Cloud, + true, + ) + .expect_err("a stale local namespace id must abort provision"); + assert!( + err.contains("00112233445566778899aabbccddeeff") + && err.contains("ffffffffffffffffffffffffffffffff") + && err.contains("conflict"), + "error must name both ids: {err}" + ); + } + + /// A conflict on a LATER store must abort the whole run in the + /// preflight, before an EARLIER store's create path can mutate remote + /// account state and orphan a freshly-created namespace. + #[test] + fn provision_aborts_before_creating_when_a_later_store_conflicts() { + let dir = tempdir().expect("tempdir"); + // `cache` carries a real local id; `sessions` has none. + write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"cache\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + // Provisioned in order: `sessions` (clean, would create) then + // `cache` (tracked id disagrees with the local id -> conflict). + let kv_ids: Vec = + ResolvedStoreId::from_logicals(&[TEST_KV_ID, TEST_KV_ID_ALT]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let tracked = tracked_kv(TEST_KV_ID_ALT, "ffffffffffffffffffffffffffffffff"); + let err = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + Some(&tracked), + ProvisionMode::Cloud, + true, + ) + .expect_err("a later store's conflict must abort the whole run"); + assert!( + err.contains("conflict") && err.contains(TEST_KV_ID_ALT), + "error must name the conflicting store: {err}" + ); + } + + /// A fresh clone has no real id in `wrangler.toml` but the team + /// already tracks one. Provision must RESTORE the tracked id into + /// `wrangler.toml` rather than create a duplicate namespace. + #[test] + fn provision_restores_tracked_id_into_fresh_wrangler_instead_of_creating() { + let _guard = path_mutation_guard().lock().expect("path guard"); + let dir = tempdir().expect("tempdir"); + // Placeholder id (fresh scaffold) -> not a "real" id. + write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", + ); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let tracked = tracked_kv(TEST_KV_ID, "abcdefabcdefabcdefabcdefabcdef00"); + // Real write (not dry-run): the restore path only touches the + // file via `upsert_kv_namespace` -- it never shells `wrangler` + // (that is the whole point), so it is safe without a fake CLI. + let outcome = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + Some(&tracked), + ProvisionMode::Cloud, + false, + ) + .expect("restore path must not shell out to wrangler"); + let after = fs::read_to_string(dir.path().join("wrangler.toml")).expect("read"); + assert!( + after.contains("abcdefabcdefabcdefabcdefabcdef00"), + "tracked id must be restored into wrangler.toml: {after}" + ); + let kv_ns = outcome + .deployed + .as_ref() + .and_then(|state| state.sub_tables.get("kv_namespaces")) + .expect("restored id surfaces in deployed"); + assert_eq!( + kv_ns.get(TEST_KV_ID).map(String::as_str), + Some("abcdefabcdefabcdefabcdefabcdef00") + ); + } + + #[test] + fn provision_rejects_malformed_tracked_namespace_id() { + // A malformed tracked id (hand-edited / corrupt -- e.g. `abc`) must + // NOT be restored verbatim: that would make provision report success + // while every later read/push against the fake id fails. Refuse it. + let _guard = path_mutation_guard().lock().expect("path guard"); + let dir = tempdir().expect("tempdir"); + write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", + ); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let tracked = tracked_kv(TEST_KV_ID, "abc"); + // Per-store failures surface via `outcome.error` (not `Err`), matching + // the partial-failure contract. + let outcome = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + Some(&tracked), + ProvisionMode::Cloud, + false, + ) + .expect("malformed-tracked-id surfaces via outcome.error, not Err"); + let err = outcome + .error + .as_deref() + .expect("a malformed tracked namespace id must be refused"); + assert!( + err.contains("abc") && err.contains("not a valid"), + "error explains the malformed tracked id: {err}" + ); + // The garbage id must NOT have been written back. + let after = fs::read_to_string(dir.path().join("wrangler.toml")).expect("read"); + assert!( + !after.contains("id = \"abc\""), + "malformed id must not be restored into wrangler.toml: {after}" + ); + } + + #[test] + fn provision_dry_run_treats_placeholder_id_as_unprovisioned() { + // A scaffolded wrangler.toml ships with placeholder ids the + // user is expected to overwrite by running provision. + // Dry-run should report the would-be create call, NOT the + // already-provisioned skip. + let dir = tempdir().expect("tempdir"); + write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", + ); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) + .expect("dry-run succeeds"); + assert_eq!(out.status_lines.len(), 1); + assert!( + out.status_lines[0].contains("would run `wrangler kv namespace create sessions`"), + "placeholder id is treated as unprovisioned: {out:?}" + ); + } + + #[test] + fn provision_with_no_declared_stores_says_so() { + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) + .expect("no-store provision is fine"); + assert_eq!( + out.status_lines, + vec!["cloudflare has no declared stores to provision"] + ); + // No wrangler was invoked (no stores) => no id to record. + assert!( + out.deployed.is_none(), + "no-store provision has nothing to write back: {:?}", + out.deployed + ); + } + + /// A store failing mid-loop must not discard the durable ids EARLIER + /// stores already created. Provision surfaces the failure through + /// `ProvisionOutcome::error` while still carrying the created ids in + /// `deployed`, so the CLI checkpoints them before propagating the + /// error -- otherwise a later store's failure would orphan an + /// already-created namespace on Cloudflare. + #[cfg(unix)] + #[test] + fn provision_keeps_earlier_created_ids_when_a_later_store_fails() { + use std::os::unix::fs::PermissionsExt as _; + let _guard = path_mutation_guard().lock().expect("path guard"); + let dir = tempdir().expect("tempdir"); + // Neither store has a local or tracked id, so both pass the + // deterministic preflight and reach the create path. The failure + // here is a NON-deterministic one the preflight cannot catch: the + // `wrangler kv namespace create` call for `cache` exits non-zero. + write_wrangler(dir.path(), "name = \"demo\"\n"); + // A fake wrangler that succeeds for `sessions` (returning a real + // id) but fails the create for `cache`. + let shim_dir = tempdir().expect("shim dir"); + let script_path = shim_dir.path().join("wrangler"); + let script = "#!/bin/sh\nfor a in \"$@\"; do\n if [ \"$a\" = \"cache\" ]; then\n echo 'create failed for cache' >&2\n exit 1\n fi\ndone\nprintf '[[kv_namespaces]]\\nbinding = \"ignored-by-parser\"\\nid = \"00112233445566778899aabbccddeeff\"\\n'\nexit 0\n"; + fs::write(&script_path, script).expect("write wrangler script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + let _path = PathPrepend::new(shim_dir.path()); + + // `sessions` first (creates), then `cache` (create fails -> the + // durable id from `sessions` must survive). + let kv_ids: Vec = + ResolvedStoreId::from_logicals(&[TEST_KV_ID, TEST_KV_ID_ALT]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) + .expect("partial failure surfaces via outcome.error, not Err"); + let err = out + .error + .as_deref() + .expect("the failing store records an error"); + assert!( + err.contains("cache") || err.contains(TEST_KV_ID_ALT), + "error names the failing store: {err}" + ); + // The earlier create must still be checkpointed. + let kv = out + .deployed + .as_ref() + .and_then(|state| state.sub_tables.get("kv_namespaces")) + .expect("earlier create is preserved despite the later failure"); + assert_eq!( + kv.get(TEST_KV_ID).map(String::as_str), + Some("00112233445566778899aabbccddeeff"), + "created id for `{TEST_KV_ID}` survives a later store's failure: {kv:?}" + ); + assert!( + !kv.contains_key(TEST_KV_ID_ALT), + "the failing store contributes no id: {kv:?}" + ); + } + + #[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). the 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 ---------- + + #[test] + fn find_namespace_id_reads_array_of_tables() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let id = find_namespace_id(&path, TEST_CONFIG_ID).expect("found"); + assert_eq!(id, "00112233445566778899aabbccddeeff"); + } + + #[test] + fn find_namespace_id_reads_inline_array() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\nkv_namespaces = [{ binding = \"app_config\", id = \"ffeeddccbbaa99887766554433221100\" }]\n", + ); + let id = find_namespace_id(&path, TEST_CONFIG_ID).expect("found"); + assert_eq!(id, "ffeeddccbbaa99887766554433221100"); + } + + #[test] + fn find_namespace_id_errors_with_provision_hint_when_binding_absent() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"other\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let err = find_namespace_id(&path, TEST_CONFIG_ID).expect_err("missing must error"); + assert!( + err.contains(TEST_CONFIG_ID) && err.contains("provision"), + "error names the binding and points at provision: {err}" + ); + } + + #[test] + fn find_namespace_id_rejects_placeholder_id_with_provision_hint() { + // A binding with `id = "local-dev-placeholder"` (or any + // other non-32-char-hex value) is treated the same as + // a missing binding: the operator needs to run provision + // before the id is usable for `wrangler kv bulk put`. + // Without this guard, push would shell out with the + // placeholder as `--namespace-id=...` and fail at wrangler + // with a less actionable error. + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"local-dev-placeholder\"\n", + ); + let err = + find_namespace_id(&path, TEST_CONFIG_ID).expect_err("placeholder id must be rejected"); + assert!( + err.contains("local-dev-placeholder") && err.contains("provision"), + "error names the placeholder and points at provision: {err}" + ); + } + + #[test] + fn find_namespace_id_errors_with_provision_hint_when_file_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("does-not-exist.toml"); + let err = + find_namespace_id(&path, TEST_CONFIG_ID).expect_err("missing wrangler.toml must error"); + assert!( + err.contains("provision"), + "error points at provision: {err}" + ); + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/cli/provision_local.rs b/crates/edgezero-adapter-cloudflare/src/cli/provision_local.rs new file mode 100644 index 00000000..e9f69d85 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/cli/provision_local.rs @@ -0,0 +1,1471 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::Path; + +use edgezero_adapter::env_file::{EDGEZERO_PROVISION_HEADER, append_lines_dedup_with_header}; +use edgezero_adapter::registry::{AdapterDeployedState, ProvisionOutcome, ProvisionStores}; + +use super::provision_cloud::is_real_namespace_id; + +/// If `path` already declares a `[[kv_namespaces]]` entry with +/// `binding = binding` AND its `id` looks like a real Cloudflare +/// namespace id, return that id. Returns `Ok(None)` if the binding +/// is absent OR present with a placeholder id (so provision can +/// treat both cases as "needs (re-)create"). A failure to read / +/// parse the file is a hard error -- provision needs an authoritative +/// answer. +pub(super) fn existing_real_namespace_id( + path: &Path, + binding: &str, +) -> Result, String> { + let Some(existing) = read_namespace_id(path, binding)? else { + return Ok(None); + }; + if is_real_namespace_id(&existing) { + Ok(Some(existing)) + } else { + Ok(None) + } +} + +/// Internal: look up `binding`'s `id` in `wrangler.toml` without +/// the "did you run provision?" error path that `find_namespace_id` +/// adds. Missing file -> `Ok(None)`. Returns the raw id whether or +/// not it looks like a real Cloudflare id. +/// +/// Errors loudly if `kv_namespaces` exists but is neither an +/// array-of-tables nor an inline-array (e.g. the operator typed +/// `kv_namespaces = "oops"`). Silently returning `None` there +/// surfaces downstream as "did you run provision?" -- misleading, +/// because the actual problem is a malformed manifest. +pub(super) fn read_namespace_id(path: &Path, binding: &str) -> Result, String> { + use toml_edit::{DocumentMut, Item, Value}; + + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + let id = match doc.get("kv_namespaces") { + Some(Item::ArrayOfTables(arr)) => arr.iter().find_map(|table| { + if table.get("binding").and_then(Item::as_str) == Some(binding) { + table.get("id").and_then(Item::as_str).map(str::to_owned) + } else { + None + } + }), + Some(Item::Value(Value::Array(arr))) => arr.iter().find_map(|item| { + let table = item.as_inline_table()?; + if table.get("binding").and_then(Value::as_str) == Some(binding) { + table.get("id").and_then(Value::as_str).map(str::to_owned) + } else { + None + } + }), + Some(other) => { + return Err(format!( + "{}: `kv_namespaces` exists but is neither `[[kv_namespaces]]` (array-of-tables) nor an inline array of `{{ binding, id }}` records; got TOML item of type `{}`", + path.display(), + item_kind(other) + )); + } + None => None, + }; + Ok(id) +} + +/// Refuse to provision a new namespace when `wrangler.toml`'s +/// `kv_namespaces` exists in a form that `upsert_kv_namespace` +/// can't write back to. Today that means the inline-array form +/// (`kv_namespaces = [{ binding = "...", id = "..." }]`), which +/// `read_namespace_id` tolerates but `upsert_kv_namespace`'s +/// `as_array_of_tables_mut()` rejects. Without this guard, the +/// orphan-namespace hazard documented in `upsert_kv_namespace` +/// reappears: `wrangler kv namespace create` succeeds, then +/// upsert errors out and the new namespace lingers on +/// Cloudflare with no local writeback to track it. Missing or +/// array-of-tables forms are OK. +pub(super) fn check_kv_namespaces_writeback_shape(path: &Path) -> Result<(), String> { + use toml_edit::{DocumentMut, Item, Value}; + + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + match doc.get("kv_namespaces") { + None | Some(Item::ArrayOfTables(_)) => Ok(()), + Some(Item::Value(Value::Array(_))) => Err(format!( + "{}: `kv_namespaces` is declared as an inline array (`kv_namespaces = [{{ binding = \"...\", id = \"...\" }}]`); provision can only write back through the `[[kv_namespaces]]` array-of-tables form. Convert each entry to a `[[kv_namespaces]]` block BEFORE re-running provision; otherwise a successful `wrangler kv namespace create` would leave the new namespace orphaned on Cloudflare with no local entry to track it.", + path.display() + )), + Some(other) => Err(format!( + "{}: `kv_namespaces` exists but is neither `[[kv_namespaces]]` (array-of-tables) nor an inline array of `{{ binding, id }}` records; got TOML item of type `{}`. Convert it manually before re-running provision.", + path.display(), + item_kind(other) + )), + } +} + +/// One-line label for a `toml_edit::Item` (for diagnostic +/// messages -- not a canonical TOML type description). +fn item_kind(item: &toml_edit::Item) -> &'static str { + use toml_edit::{Item, Value}; + match item { + Item::None => "none", + Item::Value(Value::String(_)) => "string", + Item::Value(Value::Integer(_)) => "integer", + Item::Value(Value::Float(_)) => "float", + Item::Value(Value::Boolean(_)) => "boolean", + Item::Value(Value::Datetime(_)) => "datetime", + Item::Value(Value::Array(_)) => "array", + Item::Value(Value::InlineTable(_)) => "inline-table", + Item::Table(_) => "table", + Item::ArrayOfTables(_) => "array-of-tables", + } +} + +/// Insert OR update the `[[kv_namespaces]]` entry for `binding`, +/// rewriting `id` if the binding already exists (e.g. provision +/// is replacing a `local-dev-placeholder`). Used by provision so +/// re-running on a scaffolded wrangler.toml replaces the placeholder +/// with the real id instead of silently skipping. +/// +/// The `id` value is updated IN PLACE, preserving its decor (a +/// trailing inline comment such as `id = "old" # note` survives the +/// rewrite) per the spec's byte-preserving merge contract. Sibling +/// fields under the same `[[kv_namespaces]]` table are untouched. +/// +/// Concurrency: provision is NOT safe to run concurrently against +/// the same `wrangler.toml`. Two concurrent runs may both miss the +/// idempotency check, both call `wrangler kv namespace create` +/// remotely, then race the file write -- the loser's namespace +/// becomes an orphan in the Cloudflare account. `EdgeZero` does not +/// take a lockfile; operators must serialise provision themselves. +/// Set `table[key] = new` while PRESERVING the existing value's decor +/// (a trailing inline comment on the line survives). `Table::insert` +/// replaces the whole `Item`, dropping that decor; for an in-place +/// value update we clone the old decor onto the replacement. When the +/// key is absent or isn't a scalar value, fall back to a plain insert. +fn set_str_preserving_decor(table: &mut toml_edit::Table, key: &str, new: &str) { + if let Some(existing) = table.get_mut(key).and_then(toml_edit::Item::as_value_mut) { + let mut replacement = toml_edit::Value::from(new); + *replacement.decor_mut() = existing.decor().clone(); + *existing = replacement; + } else { + table.insert(key, toml_edit::value(new)); + } +} + +pub(super) fn upsert_kv_namespace(path: &Path, binding: &str, id: &str) -> Result<(), String> { + use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, value}; + + // Treat NotFound as "start with empty document" symmetrically with + // `read_namespace_id` so the orphan-namespace hazard goes away: if + // wrangler.toml is missing entirely (e.g. operator deleted it + // between scaffold and provision), the upsert that follows a + // successful `wrangler kv namespace create` would otherwise error + // out, leaving the remote namespace orphaned. + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => String::new(), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let mut doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + + let entry = doc + .entry("kv_namespaces") + .or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())); + let arr_of_tables = entry.as_array_of_tables_mut().ok_or_else(|| { + format!( + "{}: `kv_namespaces` exists but is not an array-of-tables (`[[kv_namespaces]]`); convert it manually before re-running provision", + path.display() + ) + })?; + + let existing_idx = arr_of_tables + .iter() + .position(|table| table.get("binding").and_then(Item::as_str) == Some(binding)); + if let Some(idx) = existing_idx { + if let Some(existing) = arr_of_tables.get_mut(idx) { + set_str_preserving_decor(existing, "id", id); + } + } else { + let mut new_table = Table::new(); + new_table.insert("binding", value(binding)); + new_table.insert("id", value(id)); + arr_of_tables.push(new_table); + } + + fs::write(path, doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", path.display()))?; + Ok(()) +} + +/// Local-mode provision arm: rewrite `[[kv_namespaces]]` entries in +/// the adapter's `wrangler.toml` for every declared KV / config +/// store, applying the deployed-precedence rule. +/// +/// Precedence for the `id` cell of each entry: +/// 1. `deployed.sub_tables["kv_namespaces"][store.logical]` — the +/// cloud-side id recorded from a prior Cloud provision. +/// 2. The existing local `id` on a `[[kv_namespaces]]` entry whose +/// `binding` matches `store.platform`. Preserves operator-set +/// ids on file-based (no-cloud) setups. +/// 3. `format!("", store.logical)`. +/// +/// `preview_id` is written ONLY from +/// `deployed.sub_tables["preview_kv_namespaces"][store.logical]`; it +/// is never synthesised (matches the Cloud arm, which also omits +/// `preview_id` unless the operator provides one). +/// +/// **Lookups use `store.logical`** (env-overlay-independent, stable +/// across machines); **TOML cells use `store.platform`** (env-overlay +/// resolved binding name teammates can vary via +/// `EDGEZERO__STORES______NAME`). +/// +/// Assumes `wrangler.toml` already exists at the resolved path +/// (the CLI bootstrap writes it before provision runs); if it +/// is missing, returns an error naming the path rather than silently +/// re-synthesising, since the adapter trait does not receive an +/// `app_name` to synthesise with. +pub(super) fn provision( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + stores: &ProvisionStores<'_>, + deployed: Option<&AdapterDeployedState>, + dry_run: bool, +) -> Result { + use toml_edit::DocumentMut; + + // `build_dev_vars_lines` upper-cases each logical id into an + // `EDGEZERO__STORES______NAME` line; ids differing + // only by case would collapse onto one variable and `env_file`'s + // dedup would silently drop the loser. Reject before any write. + stores.reject_case_colliding_logical_ids()?; + + let wrangler_rel = adapter_manifest_path.unwrap_or("wrangler.toml"); + let wrangler_path = manifest_root.join(wrangler_rel); + if !wrangler_path.exists() { + return Err(format!( + "expected wrangler.toml at {} (the CLI bootstrap should have written it before provision ran)", + wrangler_path.display() + )); + } + let raw = fs::read_to_string(&wrangler_path) + .map_err(|err| format!("failed to read {}: {err}", wrangler_path.display()))?; + let mut doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", wrangler_path.display()))?; + + let mut status_lines: Vec = Vec::new(); + for store in stores.kv.iter().chain(stores.config.iter()) { + // Lookups use LOGICAL id. + let deployed_id = deployed + .and_then(|state| state.sub_tables.get("kv_namespaces")) + .and_then(|kv| kv.get(&store.logical)) + .map(String::as_str); + let deployed_preview = deployed + .and_then(|state| state.sub_tables.get("preview_kv_namespaces")) + .and_then(|kv| kv.get(&store.logical)) + .map(String::as_str); + // A tracked id must be a REAL Cloudflare namespace id -- consistent + // with cloud provision, which rejects the same malformed value. A + // hand-edited / corrupt id (e.g. `abc123`) written into the local + // wrangler.toml would make `wrangler dev` bind a fake namespace. + if let Some(id) = deployed_id + && !super::provision_cloud::is_real_namespace_id(id) + { + return Err(format!( + "tracked KV namespace id `{id}` for binding `{}` (logical id `{}`) is not a valid Cloudflare namespace id (32-char lowercase hex). Fix or remove `[adapters.cloudflare.deployed].kv_namespaces.{}` in edgezero.toml.", + store.platform, store.logical, store.logical + )); + } + let placeholder = format!("", store.logical); + + // TOML cells use PLATFORM binding. + let resolved_id = upsert_kv_namespace_entry( + &mut doc, + &wrangler_path, + &store.platform, + deployed_id, + deployed_preview, + &placeholder, + )?; + status_lines.push(format!( + "cloudflare: kv binding `{}` -> id `{}` (logical id `{}`) in {}", + store.platform, + resolved_id, + store.logical, + wrangler_path.display(), + )); + } + + if !dry_run { + fs::write(&wrangler_path, doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", wrangler_path.display()))?; + } + + // `.dev.vars` lives NEXT TO the resolved wrangler.toml so + // `wrangler dev` picks it up automatically for nested layouts + // (e.g. `adapter_manifest_path = "crates/cf/wrangler.toml"`). + let dev_vars_path = wrangler_path + .parent() + .unwrap_or(manifest_root) + .join(".dev.vars"); + let dev_vars_lines = build_dev_vars_lines(stores); + append_lines_dedup_with_header( + &dev_vars_path, + Some(EDGEZERO_PROVISION_HEADER), + &dev_vars_lines, + dry_run, + ) + .map_err(|err| format!("write {}: {err}", dev_vars_path.display()))?; + status_lines.push(format!( + "cloudflare: wrote {} .dev.vars entries to {}", + dev_vars_lines.len(), + dev_vars_path.display() + )); + + Ok(ProvisionOutcome::from_status_lines(status_lines)) +} + +/// Build the `.dev.vars` line set emitted by [`provision`]. +/// +/// One `EDGEZERO__STORES______NAME=""` +/// entry per declared store (KV / CONFIG / SECRETS). CONFIG stores +/// additionally get a **commented** `__KEY` placeholder — Cloudflare +/// has no way to preview the KEY overlay at provision time, so we +/// hint the shape and let the operator uncomment + fill it in. +/// +/// Dedup responsibility is delegated to +/// [`edgezero_adapter::env_file::append_lines_dedup`]: because the +/// commented and uncommented forms normalise to the same key, an +/// operator who already uncommented + edited a KEY line survives a +/// re-run of provision — the commented placeholder is not re-added. +fn build_dev_vars_lines(stores: &ProvisionStores<'_>) -> Vec { + let mut lines: Vec = Vec::new(); + for (kind, kind_stores) in [ + ("KV", stores.kv), + ("CONFIG", stores.config), + ("SECRETS", stores.secrets), + ] { + for store in kind_stores { + let logical_upper = store.logical.to_ascii_uppercase(); + let platform = &store.platform; + lines.push(format!( + r#"EDGEZERO__STORES__{kind}__{logical_upper}__NAME="{platform}""# + )); + } + } + for store in stores.config { + let logical_upper = store.logical.to_ascii_uppercase(); + let logical = &store.logical; + lines.push(format!( + r#"# EDGEZERO__STORES__CONFIG__{logical_upper}__KEY="{logical}_staging""# + )); + } + lines +} + +/// In-memory upsert of a single `[[kv_namespaces]]` entry inside +/// `doc`, matched by `binding = platform`. Precedence for the +/// resolved id and `preview_id` is documented on [`provision`]. +/// +/// Returns the id cell as written so the caller can name it in the +/// operator-facing status line. +/// +/// Errors if `kv_namespaces` exists but is not an array-of-tables -- +/// symmetric with [`upsert_kv_namespace`]'s check. Missing +/// `kv_namespaces` is created as an empty array-of-tables and the +/// new entry appended. +fn upsert_kv_namespace_entry( + doc: &mut toml_edit::DocumentMut, + path: &Path, + platform: &str, + deployed_id: Option<&str>, + deployed_preview: Option<&str>, + placeholder: &str, +) -> Result { + use toml_edit::{ArrayOfTables, Item, Table, value}; + + let entry = doc + .entry("kv_namespaces") + .or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())); + let arr = entry.as_array_of_tables_mut().ok_or_else(|| { + format!( + "{}: `kv_namespaces` exists but is not an array-of-tables (`[[kv_namespaces]]`); convert it manually before re-running provision", + path.display() + ) + })?; + + let existing_idx = arr + .iter() + .position(|table| table.get("binding").and_then(Item::as_str) == Some(platform)); + let resolved_id = if let Some(idx) = existing_idx { + // Existing entry: replace id from deployed if present, + // otherwise leave existing id in place (operator-set / + // prior placeholder). Only fall back to a fresh placeholder + // if the existing entry has NO id at all. + let existing_id = arr + .get(idx) + .and_then(|table| table.get("id").and_then(Item::as_str).map(str::to_owned)); + let resolved = deployed_id + .map(str::to_owned) + .or(existing_id) + .unwrap_or_else(|| placeholder.to_owned()); + if let Some(table) = arr.get_mut(idx) { + set_str_preserving_decor(table, "id", &resolved); + if let Some(preview) = deployed_preview { + set_str_preserving_decor(table, "preview_id", preview); + } + } + resolved + } else { + // No matching entry: append a new `[[kv_namespaces]]` table. + let resolved = deployed_id.unwrap_or(placeholder).to_owned(); + let mut new_table = Table::new(); + new_table.insert("binding", value(platform)); + new_table.insert("id", value(&resolved)); + if let Some(preview) = deployed_preview { + new_table.insert("preview_id", value(preview)); + } + arr.push(new_table); + resolved + }; + Ok(resolved_id) +} + +#[cfg(test)] +mod tests { + use super::super::CloudflareCliAdapter; + #[cfg(unix)] + use super::super::path_mutation_guard; + use super::super::run::synthesise_wrangler_toml; + use super::*; + use edgezero_adapter::env_file::EDGEZERO_PROVISION_HEADER; + use edgezero_adapter::registry::{ + Adapter as _, AdapterDeployedState, ProvisionMode, ProvisionStores, ResolvedStoreId, + }; + use edgezero_core::test_env::PathPrepend; + use std::collections::BTreeMap; + use std::path::PathBuf; + use tempfile::tempdir; + + const TEST_KV_ID: &str = "sessions"; + const TEST_CONFIG_ID: &str = "app_config"; + const TEST_SECRET_ID: &str = "default"; + + /// A wrangler shim that fails loudly if invoked. Used by + /// `provision_local_zero_cloud_calls` to prove local-mode + /// provision never shells out to the real Cloudflare CLI: + /// if provision returns `Ok(_)` with THIS script on PATH, + /// the shim was NEVER called. + #[cfg(unix)] + fn fake_wrangler_panicking() -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let script_path = dir.path().join("wrangler"); + fs::write( + &script_path, + "#!/bin/sh\necho 'wrangler was called during local provision' >&2\nexit 42\n", + ) + .expect("write fake wrangler"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + dir + } + + fn write_wrangler(dir: &Path, contents: &str) -> PathBuf { + let path = dir.join("wrangler.toml"); + fs::write(&path, contents).expect("write wrangler.toml"); + path + } + + /// Build an `AdapterDeployedState` with a single + /// `kv_namespaces. = ` mapping. Keeps the + /// per-test fixture terse. + fn deployed_kv(logical: &str, namespace_id: &str) -> AdapterDeployedState { + let mut kv = BTreeMap::new(); + kv.insert(logical.to_owned(), namespace_id.to_owned()); + let mut state = AdapterDeployedState::default(); + state.sub_tables.insert("kv_namespaces".to_owned(), kv); + state + } + + // ---------- read_namespace_id ---------- + + #[test] + fn read_namespace_id_errors_when_kv_namespaces_is_non_array_value() { + // `kv_namespaces = "oops"` is a malformed manifest. Silently + // returning None there bubbles up as "did you run provision?" + // -- a misleading error. The right surface is "manifest + // doesn't match the expected shape". + let dir = tempdir().expect("tempdir"); + let path = write_wrangler(dir.path(), "name = \"demo\"\nkv_namespaces = \"oops\"\n"); + let err = read_namespace_id(&path, TEST_CONFIG_ID) + .expect_err("non-array kv_namespaces must error"); + assert!( + err.contains("array-of-tables") || err.contains("inline array"), + "error names the expected shapes: {err}" + ); + assert!( + err.contains("string"), + "error names the offending kind: {err}" + ); + } + + // ---------- upsert_kv_namespace ---------- + + #[test] + fn upsert_kv_namespace_replaces_placeholder_id_for_existing_binding() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("id = \"00112233445566778899aabbccddeeff\""), + "placeholder replaced: {after}" + ); + assert!( + !after.contains("local-dev-placeholder"), + "placeholder removed: {after}" + ); + assert_eq!( + after.matches("binding = \"sessions\"").count(), + 1, + "no duplicate binding: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_appends_when_binding_absent() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler(dir.path(), "name = \"demo\"\n"); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("binding = \"sessions\"") + && after.contains("id = \"00112233445566778899aabbccddeeff\""), + "appended new entry: {after}" + ); + assert!( + after.contains("name = \"demo\""), + "preserved original keys: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_appends_next_to_existing_entries() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "[[kv_namespaces]]\nbinding = \"cache\"\nid = \"old\"\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("binding = \"cache\"") && after.contains("id = \"old\""), + "existing entry kept: {after}" + ); + assert!( + after.contains("binding = \"sessions\""), + "new entry added: {after}" + ); + assert_eq!( + after.matches("[[kv_namespaces]]").count(), + 2, + "two entries: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_preserves_top_comments() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "# managed by hand -- please keep this line\nname = \"my-worker\"\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("# managed by hand"), + "preserved comment: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_preserves_sibling_fields_on_existing_entry() { + // toml_edit replaces only the `id` Item when we update it; + // sibling fields on the same `[[kv_namespaces]]` table + // (e.g. `preview_id`, custom annotations the user added) + // must survive the rewrite. Pinning this so a future + // toml_edit upgrade or a refactor can't silently drop + // operator data. + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\npreview_id = \"local-preview\"\ndescription = \"hand-added by ops\"\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("id = \"00112233445566778899aabbccddeeff\""), + "id rewritten: {after}" + ); + assert!( + after.contains("preview_id = \"local-preview\""), + "preserved preview_id: {after}" + ); + assert!( + after.contains("description = \"hand-added by ops\""), + "preserved description: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_preserves_inline_comment_on_id_line() { + // Byte-preserving merge contract: updating `id` in place must + // keep a trailing inline comment on that line, not drop it as a + // plain `insert` (whole-item replace) would. + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\" # set by ops\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("00112233445566778899aabbccddeeff") && after.contains("# set by ops"), + "the id value updated but its trailing comment survives: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_creates_file_when_wrangler_toml_missing() { + // Orphan-namespace hazard: if `wrangler kv namespace create` + // succeeds but wrangler.toml is missing at writeback time, + // erroring here would leave the remote namespace orphaned + // with no local reference. Symmetric with read_namespace_id's + // NotFound -> Ok(None) behaviour: upsert treats NotFound as + // "start with empty document" and writes the entry. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("missing.toml"); + assert!(!path.exists(), "precondition: file must not exist"); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff") + .expect("missing file is permissive"); + let after = fs::read_to_string(&path).expect("file now exists"); + assert!( + after.contains("binding = \"sessions\""), + "created file with new entry: {after}" + ); + assert!( + after.contains("id = \"00112233445566778899aabbccddeeff\""), + "id written: {after}" + ); + } + + // ---------- writeback shape pre-check ---------- + + #[test] + fn check_kv_namespaces_writeback_shape_ok_when_file_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("missing.toml"); + check_kv_namespaces_writeback_shape(&path) + .expect("missing file is permissive (upsert creates it)"); + } + + #[test] + fn check_kv_namespaces_writeback_shape_ok_when_kv_namespaces_absent() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("wrangler.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write wrangler.toml"); + check_kv_namespaces_writeback_shape(&path).expect("no kv_namespaces => OK"); + } + + #[test] + fn check_kv_namespaces_writeback_shape_ok_when_array_of_tables() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("wrangler.toml"); + fs::write( + &path, + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", + ) + .expect("write wrangler.toml"); + check_kv_namespaces_writeback_shape(&path) + .expect("[[kv_namespaces]] is the writeback-supported shape"); + } + + #[test] + fn check_kv_namespaces_writeback_shape_rejects_inline_array_with_actionable_message() { + // Regression for the orphan-namespace hazard: pre-fix, a + // `kv_namespaces = [{ binding = "sessions" }]` manifest (no + // id present) made `read_namespace_id` return None ("not yet + // provisioned") so provision shelled `wrangler kv namespace + // create` successfully, then `upsert_kv_namespace`'s + // `as_array_of_tables_mut()` returned None and the upsert + // errored — leaving the freshly-created namespace orphaned + // on Cloudflare. The pre-flight rejects the inline-array + // shape BEFORE any account-side call. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("wrangler.toml"); + fs::write( + &path, + "name = \"demo\"\nkv_namespaces = [{ binding = \"sessions\" }]\n", + ) + .expect("write wrangler.toml"); + let err = check_kv_namespaces_writeback_shape(&path) + .expect_err("inline-array form must be rejected before provision shells out"); + assert!( + err.contains("inline array") + && err.contains("[[kv_namespaces]]") + && err.contains("orphaned"), + "error must name the inline-array form, the supported [[kv_namespaces]] form, AND the orphan hazard so the operator knows what's at stake: {err}" + ); + } + + // ---------- provision (Local mode) ---------- + + #[test] + fn cloudflare_local_provision_emits_bindings_with_placeholders_when_no_deployed() { + // [stores.kv].ids = ["sessions"], no deployed block. + // Expect the freshly-written entry to carry the placeholder id, + // and NOT emit a preview_id at all (deployed lookup only). + let dir = tempdir().expect("tempdir"); + let path = write_wrangler(dir.path(), &synthesise_wrangler_toml("demo")); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + assert!( + out.deployed.is_none(), + "local provision must not repopulate deployed: {:?}", + out.deployed + ); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("[[kv_namespaces]]"), + "array-of-tables header emitted: {after}" + ); + assert!( + after.contains("binding = \"sessions\""), + "binding named after logical (no env overlay): {after}" + ); + assert!( + after.contains("id = \"\""), + "placeholder id derived from logical: {after}" + ); + assert!( + !after.contains("preview_id"), + "preview_id must NOT be synthesised without deployed data: {after}" + ); + } + + #[test] + fn cloudflare_local_provision_uses_deployed_namespace_id_when_set() { + // Deployed carries a REAL 32-char namespace id. Expect the id cell in + // wrangler.toml to be that id (deployed wins over placeholder). + const REAL_ID: &str = "abcdefabcdefabcdefabcdefabcdef00"; + let dir = tempdir().expect("tempdir"); + let path = write_wrangler(dir.path(), &synthesise_wrangler_toml("demo")); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let state = deployed_kv(TEST_KV_ID, REAL_ID); + let out = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + Some(&state), + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + assert!(out.deployed.is_none()); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains(&format!("id = \"{REAL_ID}\"")), + "deployed id wins over placeholder: {after}" + ); + assert!( + !after.contains(""), + "no placeholder emitted when deployed provides an id: {after}" + ); + } + + #[test] + fn cloudflare_local_provision_rejects_malformed_tracked_id() { + // A malformed tracked id must be refused locally too -- cloud already + // rejects the same value, and writing it into wrangler.toml would bind + // a fake namespace under `wrangler dev`. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), &synthesise_wrangler_toml("demo")); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let state = deployed_kv(TEST_KV_ID, "abc123"); + let Err(err) = CloudflareCliAdapter.provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + Some(&state), + ProvisionMode::Local, + false, + ) else { + panic!("a malformed tracked id must be refused in local provision"); + }; + assert!( + err.contains("abc123") && err.contains("not a valid"), + "error explains the malformed tracked id: {err}" + ); + } + + #[test] + fn cloudflare_local_provision_preserves_sibling_operator_keys() { + // Operator hand-added `usage_model = "bundled"` on the + // [[kv_namespaces]] table. Provision must overwrite `id` from + // deployed but leave `usage_model` untouched. + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"operator-set\"\nusage_model = \"bundled\"\n", + ); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let state = deployed_kv(TEST_KV_ID, "0123456789abcdef0123456789abcd01"); + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + Some(&state), + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("id = \"0123456789abcdef0123456789abcd01\""), + "deployed id wins over existing local id: {after}" + ); + assert!( + after.contains("usage_model = \"bundled\""), + "operator sibling key preserved: {after}" + ); + assert_eq!( + after.matches("binding = \"sessions\"").count(), + 1, + "no duplicate binding entry: {after}" + ); + } + + #[test] + fn cloudflare_local_provision_falls_back_to_existing_local_id_when_no_deployed() { + // No deployed. Existing local id = "operator-set" is + // preserved (precedence: deployed -> existing -> placeholder). + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"operator-set\"\n", + ); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("id = \"operator-set\""), + "existing local id preserved when no deployed: {after}" + ); + assert!( + !after.contains(""), + "no placeholder emitted when existing id is present: {after}" + ); + } + + #[test] + fn cloudflare_local_provision_resolves_nested_adapter_manifest_path() { + // Mirrors the app-demo layout: adapter_manifest_path = + // "crates/cf/wrangler.toml". Pre-seed the nested file (Task + // 8b's CLI bootstrap does this before provision runs). + // Assert the upsert lands in the nested file and NOT in a + // sibling wrangler.toml at manifest_root. + let dir = tempdir().expect("tempdir"); + let nested_dir = dir.path().join("crates").join("cf"); + fs::create_dir_all(&nested_dir).expect("mkdir nested"); + let nested_path = nested_dir.join("wrangler.toml"); + fs::write(&nested_path, synthesise_wrangler_toml("demo")).expect("seed nested"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + CloudflareCliAdapter + .provision( + dir.path(), + Some("crates/cf/wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&nested_path).expect("read nested"); + assert!( + after.contains("binding = \"sessions\""), + "upsert landed in nested wrangler.toml: {after}" + ); + assert!( + after.contains("id = \"\""), + "placeholder id written into nested wrangler.toml: {after}" + ); + // A sibling wrangler.toml at manifest_root must NOT have + // been created. + assert!( + !dir.path().join("wrangler.toml").exists(), + "no sibling wrangler.toml at manifest_root: {}", + dir.path().display() + ); + } + + #[test] + fn cloudflare_local_provision_errors_if_manifest_absent() { + // Same nested path, but no pre-seed. The adapter trait + // doesn't receive app_name -- provision cannot synthesise + // the manifest itself; that's the job. + let dir = tempdir().expect("tempdir"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let err = CloudflareCliAdapter + .provision( + dir.path(), + Some("crates/cf/wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect_err("missing wrangler.toml must error"); + assert!( + err.contains("crates/cf/wrangler.toml") || err.contains("crates\\cf\\wrangler.toml"), + "error names the missing path: {err}" + ); + assert!( + err.contains("wrangler.toml"), + "error mentions wrangler.toml: {err}" + ); + } + + #[test] + fn cloudflare_local_provision_writes_platform_binding_looks_up_deployed_by_logical() { + // Env-overlay round-trip. Simulates + // EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config + // via ResolvedStoreId::new(logical, platform). + // + // Deployed is keyed by LOGICAL ("app_config"); the binding + // cell in wrangler.toml must be PLATFORM ("prod_config"). + // Bug that collapses the split would either write + // binding = "app_config" (wrong: platform ignored) + // OR fail to find the deployed id (wrong: lookup used + // platform instead of logical). + let dir = tempdir().expect("tempdir"); + let path = write_wrangler(dir.path(), &synthesise_wrangler_toml("demo")); + let config_ids = vec![ResolvedStoreId::new(TEST_CONFIG_ID, "prod_config")]; + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + let state = deployed_kv(TEST_CONFIG_ID, "0123456789abcdef0123456789abcd02"); + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + Some(&state), + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("binding = \"prod_config\""), + "binding cell uses PLATFORM name: {after}" + ); + assert!( + !after.contains("binding = \"app_config\""), + "logical id must NOT leak into the binding cell: {after}" + ); + assert!( + after.contains("id = \"0123456789abcdef0123456789abcd02\""), + "deployed id resolved via LOGICAL lookup: {after}" + ); + } + + // ---------- provision (Local mode) — .dev.vars emission ---------- + + #[test] + fn cloudflare_local_provision_writes_dev_vars_name_lines() { + // Fixture: [stores.config].ids = ["app_config"], + // [stores.kv].ids = ["sessions"]. No .dev.vars pre-existing. + // Provision must land the file next to wrangler.toml with a + // __NAME line per store and a commented __KEY placeholder for + // the config store. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), &synthesise_wrangler_toml("demo")); + 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: &[], + }; + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let dev_vars = fs::read_to_string(dir.path().join(".dev.vars")).expect("read .dev.vars"); + assert!( + dev_vars.contains(r#"EDGEZERO__STORES__KV__SESSIONS__NAME="sessions""#), + "KV __NAME line present: {dev_vars}" + ); + assert!( + dev_vars.contains(r#"EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME="app_config""#), + "CONFIG __NAME line present: {dev_vars}" + ); + assert!( + dev_vars + .contains(r#"# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY="app_config_staging""#), + "commented CONFIG __KEY placeholder present: {dev_vars}" + ); + } + + #[test] + fn cloudflare_local_provision_dev_vars_dedup_respects_commented_overrides() { + // Operator has already uncommented + edited the KEY line. + // Re-running provision must NOT re-add the commented + // placeholder — normalised_key collapses commented and + // uncommented forms, so the operator's value survives. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), &synthesise_wrangler_toml("demo")); + let dev_vars_path = dir.path().join(".dev.vars"); + fs::write( + &dev_vars_path, + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=\"real_staging\"\n", + ) + .expect("seed .dev.vars"); + + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let dev_vars = fs::read_to_string(&dev_vars_path).expect("read .dev.vars"); + assert!( + dev_vars.contains(r#"EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY="real_staging""#), + "operator's uncommented KEY line survives: {dev_vars}" + ); + assert!( + !dev_vars + .contains(r#"# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY="app_config_staging""#), + "commented placeholder must NOT be re-added: {dev_vars}" + ); + // Exactly one line whose normalised key matches the KEY + // env-var name. The uncommented one wins. + let key_lines = dev_vars + .lines() + .filter(|line| { + let after_hash = line.trim_start().strip_prefix('#').unwrap_or(line); + after_hash + .trim_start() + .starts_with("EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=") + }) + .count(); + assert_eq!( + key_lines, 1, + "exactly one KEY line remains after dedup: {dev_vars}" + ); + } + + #[test] + fn cloudflare_local_provision_dev_vars_uses_platform_name_when_env_overlay_active() { + // Env-overlay round-trip. Simulates + // EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config + // via ResolvedStoreId::new(logical, platform). The emitted + // __NAME line's VALUE must be the env-resolved platform + // (`prod_config`); the ENV-VAR KEY must still use the + // LOGICAL id in upper-case (`APP_CONFIG`) so the runtime's + // env-overlay lookup finds it. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), &synthesise_wrangler_toml("demo")); + let config_ids = vec![ResolvedStoreId::new(TEST_CONFIG_ID, "prod_config")]; + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let dev_vars = fs::read_to_string(dir.path().join(".dev.vars")).expect("read .dev.vars"); + assert!( + dev_vars.contains(r#"EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME="prod_config""#), + "value uses PLATFORM name, env-var key uses LOGICAL: {dev_vars}" + ); + assert!( + !dev_vars.contains("EDGEZERO__STORES__CONFIG__PROD_CONFIG__NAME="), + "platform name must NOT leak into the env-var key: {dev_vars}" + ); + } + + #[test] + fn synthesised_wrangler_toml_honors_renamed_adapter_crate() { + // Reviewer regression: with + // `[adapters.cloudflare.adapter].manifest = "crates/cf-worker/wrangler.toml"` + // + `[package].name = "cf-worker"`, clean-clone provision + // must emit `name = "cf-worker"` — NOT the fallback + // `demo-app-adapter-cloudflare`. Also covers the nested + // manifest shape: the Cargo.toml sits at + // `crates/cf-worker/Cargo.toml` while the manifest may be + // one directory deeper (`crates/cf-worker/config/wrangler.toml`). + let dir = tempdir().unwrap(); + let root = dir.path(); + let crate_dir = root.join("crates/cf-worker"); + fs::create_dir_all(crate_dir.join("config")).unwrap(); + fs::write( + crate_dir.join("Cargo.toml"), + "[package]\nname = \"cf-worker\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let outcome = CloudflareCliAdapter + .synthesise_baseline_manifest( + root, + Some("crates/cf-worker/config/wrangler.toml"), + Some("crates/cf-worker"), + None, + "demo-app", + None, + &[], + ) + .expect("baseline synthesis succeeds for nested renamed crate"); + let (rel, body) = outcome.into_iter().next().unwrap(); + assert_eq!(rel, PathBuf::from("crates/cf-worker/config/wrangler.toml")); + assert!( + body.contains(r#"name = "cf-worker""#), + "wrangler.toml must name the renamed adapter crate (cf-worker) — got: {body}" + ); + assert!( + !body.contains(r#"name = "demo-app-adapter-cloudflare""#), + "MUST NOT fall back to scaffold convention when the Cargo.toml exists further up: {body}" + ); + } + + // ---------- provision_local_ contract suite (spec §"Per-adapter test contract") ---------- + + #[test] + fn provision_local_first_run_writes_expected_files() { + // First-run fixture: empty crate dir, no wrangler.toml, no + // .dev.vars. The CLI's bootstrap layer (the + // `write_baseline_to_disk`) normally primes wrangler.toml via + // `synthesise_baseline_manifest` BEFORE provision runs; this + // test mirrors that step directly, then calls + // `provision(Local)` on the seed. + // + // Contract: `wrangler.toml` lands at the resolved path; + // `.dev.vars` lands next to it; BOTH files carry the + // `# edgezero-provision: v1` schema header (Section 5 review + // fix); wrangler.toml has a `[[kv_namespaces]]` entry bound to + // `sessions`; `.dev.vars` has the __NAME overlay line. + let dir = tempdir().expect("tempdir"); + let wrangler_path = dir.path().join("wrangler.toml"); + fs::write(&wrangler_path, synthesise_wrangler_toml("demo")) + .expect("bootstrap wrangler.toml"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("first-run local provision succeeds"); + assert!( + wrangler_path.exists(), + "wrangler.toml exists at resolved path" + ); + let dev_vars_path = dir.path().join(".dev.vars"); + assert!( + dev_vars_path.exists(), + ".dev.vars lands next to wrangler.toml: {}", + dev_vars_path.display() + ); + let wrangler = fs::read_to_string(&wrangler_path).expect("read wrangler.toml"); + assert!( + wrangler.starts_with(EDGEZERO_PROVISION_HEADER), + "wrangler.toml starts with schema header: {wrangler}" + ); + assert!( + wrangler.contains("[[kv_namespaces]]"), + "wrangler.toml has [[kv_namespaces]]: {wrangler}" + ); + assert!( + wrangler.contains("binding = \"sessions\""), + "wrangler.toml binds `sessions`: {wrangler}" + ); + let dev_vars = fs::read_to_string(&dev_vars_path).expect("read .dev.vars"); + assert!( + dev_vars.starts_with(EDGEZERO_PROVISION_HEADER), + ".dev.vars starts with schema header: {dev_vars}" + ); + assert!( + dev_vars.contains(r#"EDGEZERO__STORES__KV__SESSIONS__NAME="sessions""#), + ".dev.vars carries the __NAME overlay: {dev_vars}" + ); + } + + /// Locks the header-preservation contract for the case the sibling + /// first-run test misses. The seeded fixture there uses + /// `synthesise_wrangler_toml("demo")` which ALREADY carries the + /// header at line 1 -- a merge bug that stripped the header on + /// re-serialisation would pass `starts_with(EDGEZERO_PROVISION_HEADER)` + /// only because the seed matched, not because provision preserved it. + /// This test starts from a wrangler.toml with the schema header at + /// line 1 AND a couple of operator-added TOML lines, runs provision, + /// and asserts the header STILL sits at line 1 on the output. + #[test] + fn provision_local_preserves_schema_header_at_line_1_after_merge() { + let dir = tempdir().expect("tempdir"); + let wrangler_path = dir.path().join("wrangler.toml"); + // Seed matches the synthesiser shape, then adds an operator's + // `main =` line below the header. If provision's toml_edit + // round-trip re-orders root decor or drops the leading comment, + // the header slides down. + fs::write( + &wrangler_path, + "# edgezero-provision: v1\nname = \"demo\"\ncompatibility_date = \"2024-01-01\"\nmain = \"src/index.ts\"\n", + ) + .expect("seed wrangler.toml"); + + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("provision must succeed on a seeded wrangler.toml with operator edits"); + + let wrangler = fs::read_to_string(&wrangler_path).expect("read wrangler.toml"); + let first_line = wrangler.lines().next().unwrap_or_default(); + assert_eq!( + first_line, "# edgezero-provision: v1", + "schema header must sit at line 1 after merge (bare `.starts_with(...)` masks a merge bug that slides the header down): {wrangler}" + ); + // Operator's line still present. + assert!( + wrangler.contains("main = \"src/index.ts\""), + "operator's `main` key must survive the merge: {wrangler}" + ); + } + + #[test] + fn provision_local_re_provision_is_byte_identical() { + // Re-running provision on an already-provisioned fixture must + // produce byte-identical wrangler.toml and .dev.vars — the + // second run is a no-op at the file level. Any drift here + // (rewriting a differently-formatted TOML, re-appending the + // header, appending a duplicate __NAME line) would surface as + // a byte mismatch. + let dir = tempdir().expect("tempdir"); + let wrangler_path = dir.path().join("wrangler.toml"); + fs::write(&wrangler_path, synthesise_wrangler_toml("demo")) + .expect("bootstrap wrangler.toml"); + 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: &[], + }; + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("first local provision succeeds"); + let dev_vars_path = dir.path().join(".dev.vars"); + let wrangler_first = fs::read(&wrangler_path).expect("read wrangler.toml (first run)"); + let dev_vars_first = fs::read(&dev_vars_path).expect("read .dev.vars (first run)"); + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("second local provision succeeds"); + let wrangler_second = fs::read(&wrangler_path).expect("read wrangler.toml (second run)"); + let dev_vars_second = fs::read(&dev_vars_path).expect("read .dev.vars (second run)"); + assert_eq!( + wrangler_first, wrangler_second, + "wrangler.toml must be byte-identical across two provision runs" + ); + assert_eq!( + dev_vars_first, dev_vars_second, + ".dev.vars must be byte-identical across two provision runs" + ); + } + + #[cfg(unix)] + #[test] + fn provision_local_zero_cloud_calls() { + // Install a panicking `wrangler` shim on PATH: if ever + // invoked, it prints to stderr and exits 42, which surfaces + // as an `Err` out of any `Command::new("wrangler").output()` + // caller. `provision(Local)` MUST NOT shell out — it operates + // purely on local files (wrangler.toml + .dev.vars). A + // successful `Ok(_)` here is the proof: had a regression + // routed Local through a shell-out path, the shim would have + // failed loudly instead. + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let wrangler_path = dir.path().join("wrangler.toml"); + fs::write(&wrangler_path, synthesise_wrangler_toml("demo")) + .expect("bootstrap wrangler.toml"); + let fake = fake_wrangler_panicking(); + let _path = PathPrepend::new(fake.path()); + + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let secret_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_SECRET_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, + }; + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision must not shell out to wrangler"); + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/cli/push_cloud.rs b/crates/edgezero-adapter-cloudflare/src/cli/push_cloud.rs new file mode 100644 index 00000000..7fc628c1 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/cli/push_cloud.rs @@ -0,0 +1,1117 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf, absolute}; +use std::process::Command; + +use edgezero_adapter::registry::{ReadConfigEntry, ResolvedStoreId}; + +use super::WRANGLER_INSTALL_HINT; +use super::provision_cloud::{find_namespace_id, is_real_namespace_id}; +use super::provision_local::read_namespace_id; + +/// Absolute `--config` argument for wrangler. The commands run with +/// `current_dir(project_dir)` (the manifest's parent), so a +/// manifest-root-relative `wrangler_path` would be resolved a SECOND time +/// against that cwd (`crates/cf/wrangler.toml` -> `crates/cf/crates/cf/…`). +/// Absolutising it makes the anchor independent of the child's cwd. +fn wrangler_config_arg(wrangler_path: &Path) -> PathBuf { + absolute(wrangler_path).unwrap_or_else(|_| wrangler_path.to_path_buf()) +} + +/// Push `entries` to the remote KV namespace bound to `store` (looked +/// up in `wrangler.toml`) via `wrangler kv bulk put +/// --namespace-id= --remote`. **--remote** is mandatory — wrangler +/// v4 defaults to LOCAL storage otherwise. +/// +/// Dry-run reports the intended invocation + per-entry preview without +/// resolving the namespace id strictly (operators can preview the +/// keyset BEFORE running provision). Real runs err loudly on unresolved +/// bindings. +pub(super) fn write_entries( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + dry_run: bool, +) -> Result, String> { + // Read namespace id from wrangler.toml (matched by + // `binding = `), then `wrangler kv bulk put + // --namespace-id= --remote`. The + // CLI hands this writer one logical (root_key, envelope_json) + // entry; the bulk-put still works because it's one upsert + // per entry, and the one-entry case is degenerate. + // + // **--remote** is mandatory for the prod-push path: + // wrangler v4 defaults KV bulk-put to LOCAL storage when + // the command supports both — meaning a v4 user running + // `wrangler kv bulk put` without `--remote` would silently + // populate Miniflare state under `.wrangler/state` and + // report success while leaving the live Cloudflare + // namespace empty. Explicit `--remote` removes the + // ambiguity. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for config push" + .to_owned(), + ); + }; + let wrangler_path = manifest_root.join(rel); + let binding = store.platform.as_str(); + let logical = store.logical.as_str(); + // Dry-run is lenient about an UNPROVISIONED binding (no entry, or a + // scaffold placeholder id) so operators can preview the keyset BEFORE + // running provision. A MALFORMED wrangler.toml (unparseable, or a + // `kv_namespaces` of the wrong shape) is NOT suppressed: `?` propagates + // it so the dry-run fails loudly instead of printing a misleading + // `` preview. Real runs still err loudly so we don't + // silently push to a non-existent namespace. + if dry_run { + let resolved = + read_namespace_id(&wrangler_path, binding)?.filter(|id| is_real_namespace_id(id)); + let header = match resolved { + Some(ns_id) => format!( + "would run `wrangler kv bulk put --namespace-id={ns_id} --remote` with {} entries for binding `{binding}` (logical id `{logical}`)", + entries.len() + ), + None => format!( + "would run `wrangler kv bulk put --namespace-id= --remote` with {} entries for binding `{binding}` (logical id `{logical}`, binding not yet provisioned -- run `edgezero provision --adapter cloudflare` to resolve the namespace id)", + entries.len() + ), + }; + let mut out = vec![header]; + for (key, _) in entries { + out.push(format!(" would create entry `{key}`")); + } + return Ok(out); + } + let namespace_id = find_namespace_id(&wrangler_path, binding)?; + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to KV namespace `{binding}` (logical id `{logical}`, id={namespace_id})" + )]); + } + let payload = bulk_payload(entries)?; + let temp = tempfile::Builder::new() + .prefix("edgezero-cf-push-") + .suffix(".json") + .tempfile() + .map_err(|err| format!("failed to create temp file for wrangler bulk payload: {err}"))?; + fs::write(temp.path(), payload.as_bytes()) + .map_err(|err| format!("failed to write {}: {err}", temp.path().display()))?; + let temp_arg = temp + .path() + .to_str() + .ok_or_else(|| format!("temp file path {} is not UTF-8", temp.path().display()))?; + let namespace_arg = format!("--namespace-id={namespace_id}"); + // Run from the wrangler.toml's directory so wrangler picks + // up its `account_id` / `--env` resolution + persistence + // settings the same way `wrangler dev` / `wrangler deploy` + // do for this project. + let project_dir = wrangler_path.parent().unwrap_or(manifest_root); + let output = Command::new("wrangler") + .current_dir(project_dir) + .args([ + "kv", + "bulk", + "put", + temp_arg, + namespace_arg.as_str(), + "--remote", + ]) + // Anchor at the DECLARED manifest. Without `--config`, wrangler + // discovers the default `wrangler.toml` in `project_dir`, which for + // a declared `config/cloudflare.prod.toml` sitting beside a plain + // `wrangler.toml` would push to the WRONG account / namespace. + .arg("--config") + .arg(wrangler_config_arg(&wrangler_path)) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") + } else { + format!("failed to spawn `wrangler`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "`wrangler kv bulk put --remote` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(vec![format!( + "pushed {} entries to KV namespace `{binding}` (logical id `{logical}`, id={namespace_id})", + entries.len() + )]) +} + +/// Push `entries` to Miniflare's local KV storage via `wrangler kv +/// bulk put --binding --local`. +/// +/// Local mode does NOT resolve a namespace id — the scaffold ships +/// with `local-dev-placeholder` ids, so operators who haven't run +/// `edgezero provision` yet can still seed `.wrangler/state` from the +/// manifest. Wrangler stores local entries keyed by binding, not +/// namespace id, so `wrangler dev --local` / `edgezero serve --adapter +/// cloudflare` reads them back through the same binding name. +pub(super) fn write_entries_local( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + dry_run: bool, +) -> Result, String> { + // Local push: address the binding directly via + // `wrangler kv bulk put --binding --local`. + // Crucially we do NOT resolve a namespace id here — the + // scaffold ships with `local-dev-placeholder` ids, so an + // operator that hasn't run `edgezero provision` yet should + // still be able to seed `.wrangler/state` from the manifest + // (matching wrangler's own local KV docs). Wrangler stores + // local entries keyed by binding, not namespace id, so the + // follow-up `wrangler dev --local` / `edgezero serve + // --adapter cloudflare` reads them back through the same + // binding name. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for config push --local" + .to_owned(), + ); + }; + let wrangler_path = manifest_root.join(rel); + let project_dir = wrangler_path.parent().unwrap_or(manifest_root); + let binding = store.platform.as_str(); + let logical = store.logical.as_str(); + if dry_run { + let mut out = vec![format!( + "would run `wrangler kv bulk put --binding {binding} --local` with {} entries for binding `{binding}` (logical id `{logical}`)", + entries.len() + )]; + for (key, _) in entries { + out.push(format!(" would create local entry `{key}`")); + } + return Ok(out); + } + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to local KV namespace `{binding}` (logical id `{logical}`)" + )]); + } + let payload = bulk_payload(entries)?; + let temp = tempfile::Builder::new() + .prefix("edgezero-cf-push-local-") + .suffix(".json") + .tempfile() + .map_err(|err| format!("failed to create temp file for wrangler bulk payload: {err}"))?; + fs::write(temp.path(), payload.as_bytes()) + .map_err(|err| format!("failed to write {}: {err}", temp.path().display()))?; + let temp_arg = temp + .path() + .to_str() + .ok_or_else(|| format!("temp file path {} is not UTF-8", temp.path().display()))?; + let output = Command::new("wrangler") + .current_dir(project_dir) + .args([ + "kv", + "bulk", + "put", + temp_arg, + "--binding", + binding, + "--local", + ]) + // Anchor at the DECLARED manifest so a non-default filename beside + // a plain `wrangler.toml` seeds the right local namespace. + .arg("--config") + .arg(wrangler_config_arg(&wrangler_path)) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") + } else { + format!("failed to spawn `wrangler`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "`wrangler kv bulk put --binding {binding} --local` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(vec![format!( + "pushed {} entries to local KV namespace bound as `{binding}` (logical id `{logical}`); `.wrangler/state` updated", + entries.len() + )]) +} + +/// Render the entries as the `[{"key": "...", "value": "..."}, …]` +/// JSON wrangler expects for `kv bulk put`. Under the blob model the +/// CLI hands this writer one logical `(root_key, envelope_json)` entry; +/// Cloudflare passes the value through unchanged (the envelope is an +/// opaque string from the platform's perspective). +fn bulk_payload(entries: &[(String, String)]) -> Result { + let payload: Vec = entries + .iter() + .map(|(key, value)| serde_json::json!({ "key": key, "value": value })) + .collect(); + serde_json::to_string(&payload) + .map_err(|err| format!("failed to serialize wrangler bulk payload: {err}")) +} + +/// Read a single key from a Cloudflare KV namespace by shelling out to +/// `wrangler kv key get --binding `. +/// +/// `locality` is either `"--remote"` (live Cloudflare KV) or `"--local"` +/// (Miniflare `.wrangler/state`). The two read methods on the adapter call +/// this shared helper with the appropriate flag. +/// +/// # Mapping to `ReadConfigEntry` +/// - Success (exit 0) → `Present(stdout)`. +/// - Exit non-zero, stderr is an auth/config failure → `Err` (checked FIRST, +/// so an auth message mentioning "binding"/"not found" is never misread as +/// a missing store/key). +/// - Exit non-zero, stderr mentions "binding" → `MissingStore` (the KV +/// namespace binding itself doesn't exist in `wrangler.toml`). +/// - Exit non-zero, stderr contains "not found" / "does not exist" → `MissingKey`. +/// - Any other non-zero exit → `Err`. +pub(super) fn read_wrangler_kv_key( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + store: &ResolvedStoreId, + key: &str, + locality: &str, +) -> Result { + let rel = adapter_manifest_path.ok_or_else(|| { + "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for config diff" + .to_owned() + })?; + let wrangler_path = manifest_root.join(rel); + let binding = store.platform.as_str(); + let project_dir = wrangler_path.parent().unwrap_or(manifest_root); + let output = Command::new("wrangler") + .args(["kv", "key", "get", "--binding", binding, key, locality]) + // Anchor at the DECLARED manifest so diff/read resolves the same + // account / namespace a push would target, not a sibling default + // `wrangler.toml`. + .arg("--config") + .arg(wrangler_config_arg(&wrangler_path)) + .current_dir(project_dir) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") + } else { + format!("failed to spawn `wrangler`: {err}") + } + })?; + if output.status.success() { + let body = String::from_utf8(output.stdout) + .map_err(|err| format!("`wrangler kv key get` stdout is not UTF-8: {err}"))?; + // Wrangler 4.x (verified 4.64.0) returns exit 0 + stdout + // "Value not found" for a missing key instead of exit 1 + + // stderr. Detect that shape and map to MissingKey -- a + // missing key in the blob model is valid initial state + // (first push hasn't run yet), not corrupt remote state. + // Match the trimmed first line so trailing newlines or + // future variants like "Value not found.\n" still match. + let trimmed = body.trim(); + if trimmed.eq_ignore_ascii_case("value not found") + || trimmed.eq_ignore_ascii_case("value not found.") + { + return Ok(ReadConfigEntry::MissingKey); + } + return Ok(ReadConfigEntry::Present(body)); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let lower = stderr.to_ascii_lowercase(); + // An AUTH/CONFIG failure ("API token not found", "unauthorized", ...) is + // surfaced as an error and must be checked BEFORE any missing-store / + // missing-key mapping: such messages routinely mention "binding" or + // "not found", and misclassifying them would make a diff report the + // whole store as missing (everything added) instead of failing loudly. + if is_wrangler_auth_or_config_error(&lower) { + return Err(format!( + "`wrangler kv key get --binding {binding} {key} {locality}` failed to authenticate or resolve configuration; run `edgezero auth login --adapter cloudflare` and check `wrangler.toml`\nstderr: {}", + stderr.trim() + )); + } + // A missing BINDING / namespace (the KV store itself) is a missing store. + // Require an ABSENCE qualifier alongside "binding" -- a bare mention of + // "binding" (invalid binding syntax, a malformed-manifest diagnostic that + // names a binding, ...) is a real error, NOT an absent store, and mapping + // it to MissingStore would make a diff report the whole store as added. + // Current wrangler also reports a wholly unconfigured project as + // "No KV Namespaces configured!", which is the same missing-store case. + let binding_absent = lower.contains("binding") + && (lower.contains("not found") + || lower.contains("does not exist") + || lower.contains("not defined") + || lower.contains("no such") + || lower.contains("could not find") + || lower.contains("unknown")); + if binding_absent || lower.contains("no kv namespaces") { + return Ok(ReadConfigEntry::MissingStore); + } + // A genuinely absent KEY is a not-found (with no "binding" qualifier, + // handled above). + if lower.contains("not found") || lower.contains("does not exist") { + return Ok(ReadConfigEntry::MissingKey); + } + Err(format!( + "`wrangler kv key get --binding {binding} {key} {locality}` exited with status {}\nstderr: {}", + output.status, + stderr.trim() + )) +} + +/// Detect an AUTHENTICATION / CONFIGURATION failure in a (lowercased) +/// wrangler error, so a message like "API token not found" is NOT misread +/// as an absent key (which would let a diff report everything as added). +fn is_wrangler_auth_or_config_error(lower: &str) -> bool { + lower.contains("api token") + || lower.contains("api key") + || lower.contains("token") + || lower.contains("credential") + || lower.contains("unauthor") + || lower.contains("forbidden") + || lower.contains("403") + || lower.contains("permission") + || lower.contains("account_id") + || lower.contains("account id") +} + +#[cfg(test)] +mod tests { + use super::super::CloudflareCliAdapter; + #[cfg(unix)] + use super::super::path_mutation_guard; + use super::*; + use edgezero_adapter::registry::{ + Adapter as _, AdapterPushContext, ReadConfigEntry, ResolvedStoreId, + }; + use edgezero_core::test_env::PathPrepend; + use std::path::PathBuf; + use tempfile::tempdir; + + const TEST_CONFIG_ID: &str = "app_config"; + + #[cfg(unix)] + fn fake_wrangler_returning( + stdout_body: &str, + stderr_body: &str, + exit_code: i32, + ) -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let script_path = dir.path().join("wrangler"); + let stdout_file = dir.path().join("stdout_payload.txt"); + let stderr_file = dir.path().join("stderr_payload.txt"); + fs::write(&stdout_file, stdout_body).expect("write stdout payload"); + fs::write(&stderr_file, stderr_body).expect("write stderr payload"); + let script = format!( + "#!/bin/sh\ncat '{stdout}'\ncat '{stderr}' >&2\nexit {code}\n", + stdout = stdout_file.display(), + stderr = stderr_file.display(), + code = exit_code, + ); + fs::write(&script_path, script).expect("write wrangler script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + dir + } + + #[cfg(unix)] + fn fake_wrangler_argv_log(out_path: &Path) -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let script_path = dir.path().join("wrangler"); + let script = format!( + "#!/bin/sh\nfor arg in \"$@\"; do printf '%s\\n' \"$arg\" >> '{out}'; done\nprintf 'val'\n", + out = out_path.display(), + ); + fs::write(&script_path, script).expect("write script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + dir + } + + fn write_wrangler(dir: &Path, contents: &str) -> PathBuf { + let path = dir.join("wrangler.toml"); + fs::write(&path, contents).expect("write wrangler.toml"); + path + } + + // ---------- bulk_payload ---------- + + #[test] + fn bulk_payload_emits_wrangler_array_of_key_value_objects() { + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("service.timeout_ms".to_owned(), "1500".to_owned()), + ]; + let raw = bulk_payload(&entries).expect("payload"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + let array = parsed.as_array().expect("array"); + assert_eq!(array.len(), 2); + assert_eq!(array[0]["key"], "greeting"); + assert_eq!(array[0]["value"], "hello"); + assert_eq!(array[1]["key"], "service.timeout_ms"); + assert_eq!(array[1]["value"], "1500"); + } + + #[test] + fn bulk_payload_with_no_entries_is_empty_array() { + let raw = bulk_payload(&[]).expect("empty payload"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + assert_eq!(parsed, serde_json::json!([])); + } + + // ---------- push_config_entries (dry-run + error paths) ---------- + + #[test] + fn push_dry_run_resolves_namespace_id_and_does_not_invoke_wrangler() { + let dir = tempdir().expect("tempdir"); + let original = "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n"; + let path = write_wrangler(dir.path(), original); + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("feature.new_checkout".to_owned(), "false".to_owned()), + ]; + let out = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run succeeds"); + // Header + per-entry preview, matching the fastly dry-run shape. + assert_eq!(out.len(), 1 + entries.len(), "header + per-entry preview"); + assert!( + out[0].contains("would run `wrangler kv bulk put") + && out[0].contains("--namespace-id=00112233445566778899aabbccddeeff"), + "dry-run header names namespace id: {out:?}" + ); + assert!( + out.iter().any(|line| line.contains("`greeting`")), + "dry-run lists `greeting`: {out:?}" + ); + assert!( + out.iter() + .any(|line| line.contains("`feature.new_checkout`")), + "dry-run lists `feature.new_checkout`: {out:?}" + ); + let after = fs::read_to_string(&path).expect("read"); + assert_eq!(after, original, "dry-run must not mutate wrangler.toml"); + } + + #[test] + fn push_dry_run_is_lenient_when_binding_not_yet_provisioned() { + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let entries = vec![("greeting".to_owned(), "hello".to_owned())]; + let out = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run is lenient: pre-provision preview is allowed"); + assert!( + out[0].contains("") && out[0].contains("provision"), + "dry-run header explains the namespace is unresolved and points at provision: {out:?}" + ); + assert!( + out.iter().any(|line| line.contains("`greeting`")), + "dry-run still lists the entries it would push: {out:?}" + ); + } + + #[test] + fn push_dry_run_fails_on_malformed_kv_namespaces_shape() { + // A `kv_namespaces` of the wrong shape is a MALFORMED manifest, not + // an unprovisioned binding. Dry-run must fail loudly rather than + // suppress it as a lenient `` preview. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\nkv_namespaces = \"nope\"\n"); + let entries = vec![("greeting".to_owned(), "hello".to_owned())]; + let err = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect_err("a malformed manifest must fail dry-run, not preview "); + assert!( + err.contains("kv_namespaces"), + "error names the malformed key: {err}" + ); + } + + #[test] + fn push_dry_run_is_lenient_on_scaffold_placeholder_id() { + // A placeholder id means "not yet provisioned" -- still a lenient + // preview, distinct from a malformed manifest. + let dir = tempdir().expect("tempdir"); + write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"local-dev-placeholder\"\n", + ); + let entries = vec![("greeting".to_owned(), "hello".to_owned())]; + let out = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run is lenient for a placeholder id"); + assert!( + out[0].contains("") && out[0].contains("provision"), + "placeholder id previews as unresolved: {out:?}" + ); + } + + #[test] + fn push_errors_when_adapter_manifest_path_missing() { + let dir = tempdir().expect("tempdir"); + let entries = vec![("k".to_owned(), "v".to_owned())]; + let err = CloudflareCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect_err("missing adapter manifest path must error"); + assert!( + err.contains("wrangler.toml") && err.contains("config push"), + "error explains the missing manifest pointer: {err}" + ); + } + + #[test] + fn push_real_run_errors_with_provision_hint_when_binding_absent() { + // dry-run is now lenient (see + // `push_dry_run_is_lenient_when_binding_not_yet_provisioned`), + // but a real run still must err so we don't silently push + // to a non-existent namespace. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let entries = vec![("greeting".to_owned(), "hello".to_owned())]; + let err = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect_err("missing binding must error on real run"); + assert!( + err.contains("provision") && err.contains(TEST_CONFIG_ID), + "error points at provision: {err}" + ); + } + + #[test] + fn push_with_no_entries_reports_no_op_after_resolving_namespace() { + let dir = tempdir().expect("tempdir"); + write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let out = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[], + &AdapterPushContext::new(), + false, + ) + .expect("zero-entry push is fine"); + assert_eq!(out.len(), 1); + assert!( + out[0].contains("no config entries") + && out[0].contains("00112233445566778899aabbccddeeff"), + "status line names empty + namespace id: {out:?}" + ); + } + + /// Push-after-provision: `config push --local` seeds the local KV + /// store via `wrangler kv bulk put`; it must leave the + /// provision-written `.dev.vars` (which carries the operator's real + /// secret values) byte-for-byte intact. + #[cfg(unix)] + #[test] + fn push_after_provision_preserves_dev_vars_secret() { + use edgezero_adapter::registry::{ProvisionMode, TypedSecretEntry}; + + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + // 1. Provision writes the secret placeholder into `.dev.vars`. + CloudflareCliAdapter + .provision_typed( + project_dir.path(), + Some("wrangler.toml"), + None, + &[TypedSecretEntry::new("default", "field", "demo_api_token")], + ProvisionMode::Local, + false, + ) + .expect("provision_typed writes the placeholder"); + let dev_vars = project_dir.path().join(".dev.vars"); + let provisioned = fs::read_to_string(&dev_vars).expect("provision wrote .dev.vars"); + assert!( + provisioned.contains("demo_api_token=\"\""), + "provision must write the secret placeholder: {provisioned}" + ); + // 2. Operator fills in the real value. + fs::write( + &dev_vars, + provisioned.replace( + "demo_api_token=\"\"", + "demo_api_token=\"real-secret-value\"", + ), + ) + .expect("operator edit"); + + // 3. Push (fake wrangler); the `.dev.vars` secret must survive. + let fake = fake_wrangler_returning("", "", 0); + let _path = PathPrepend::new(fake.path()); + CloudflareCliAdapter + .push_config_entries_local( + project_dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[("greeting".to_owned(), "hello".to_owned())], + &AdapterPushContext::new().with_local(true), + false, + ) + .expect("push --local succeeds with fake wrangler"); + + assert!( + fs::read_to_string(&dev_vars) + .expect("read .dev.vars") + .contains("demo_api_token=\"real-secret-value\""), + "config push --local must not touch the operator's .dev.vars secret" + ); + } + + // ---------- read_config_entry / read_config_entry_local (fake wrangler) ---------- + + #[cfg(unix)] + #[test] + fn read_remote_returns_present_on_success() { + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let fake = fake_wrangler_returning("hello-cloudflare", "", 0); + let _path = PathPrepend::new(fake.path()); + let result = CloudflareCliAdapter + .read_config_entry( + project_dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("wrangler exit-0 must succeed"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present"); + }; + assert_eq!(value, "hello-cloudflare"); + } + + #[cfg(unix)] + #[test] + fn read_remote_returns_missing_key_on_not_found_stderr() { + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let fake = fake_wrangler_returning("", "Error: key not found", 1); + let _path = PathPrepend::new(fake.path()); + let result = CloudflareCliAdapter + .read_config_entry( + project_dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("not-found maps to MissingKey (not Err)"); + assert!( + matches!(result, ReadConfigEntry::MissingKey), + "not-found stderr => MissingKey" + ); + } + + /// An auth failure ("API token not found") must NOT be masked as a + /// missing key -- it is surfaced as an error. + #[cfg(unix)] + #[test] + fn read_remote_reports_error_on_auth_token_not_found() { + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let fake = fake_wrangler_returning("", "Error: API token not found", 1); + let _path = PathPrepend::new(fake.path()); + let result = CloudflareCliAdapter.read_config_entry( + project_dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ); + let Err(err) = result else { + panic!("an auth failure must be an error, not MissingKey"); + }; + assert!( + err.contains("API token"), + "the auth failure must surface: {err}" + ); + } + + /// Wrangler 4.x (verified 4.64.0) returns exit 0 + stdout + /// `"Value not found"` for a missing key instead of exit 1 + + /// stderr. The previous read path treated every exit-0 stdout + /// as a `Present` envelope, which made the next CLI step try + /// to parse `"Value not found"` as a `BlobEnvelope` and abort. + /// A missing key in the blob model is valid initial state -- + /// the first push hasn't run yet -- not corrupt remote state, + /// so it must map to `MissingKey`. + #[cfg(unix)] + #[test] + fn read_remote_returns_missing_key_on_wrangler_4_value_not_found_stdout() { + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let fake = fake_wrangler_returning("Value not found\n", "", 0); + let _path = PathPrepend::new(fake.path()); + let result = CloudflareCliAdapter + .read_config_entry( + project_dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("Wrangler 4.x exit-0 'Value not found' must map to MissingKey"); + if let ReadConfigEntry::Present(body) = &result { + panic!( + "expected MissingKey on Wrangler 4.x 'Value not found' stdout; \ + got Present({body:?})", + ); + } + assert!( + matches!(result, ReadConfigEntry::MissingKey), + "Wrangler 4.x stdout='Value not found' (exit 0) must classify as MissingKey", + ); + } + + #[cfg(unix)] + #[test] + fn read_remote_returns_missing_store_on_binding_stderr() { + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let fake = fake_wrangler_returning("", "Error: binding APP_CONFIG is not defined", 1); + let _path = PathPrepend::new(fake.path()); + let result = CloudflareCliAdapter + .read_config_entry( + project_dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("binding-error maps to MissingStore (not Err)"); + assert!( + matches!(result, ReadConfigEntry::MissingStore), + "binding stderr => MissingStore" + ); + } + + #[cfg(unix)] + #[test] + fn read_remote_reports_error_on_bare_binding_error_without_absence() { + // A "binding" error that is NOT an absence (invalid syntax, malformed + // manifest naming a binding) must surface as an error, not be + // misclassified as an absent store (which would report the whole + // store as added in a diff). + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let fake = fake_wrangler_returning("", "Error: invalid binding name `APP CONFIG`", 1); + let _path = PathPrepend::new(fake.path()); + let result = CloudflareCliAdapter.read_config_entry( + project_dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ); + let Err(err) = result else { + panic!("a bare binding error (no absence) must be an error, not MissingStore"); + }; + assert!( + err.contains("invalid binding"), + "the real binding error must surface: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn read_remote_maps_no_kv_namespaces_configured_to_missing_store() { + // Current wrangler reports a wholly unconfigured project as + // "No KV Namespaces configured!" -- the same missing-store condition + // as a missing binding, so it must map to MissingStore (not Err). + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let fake = fake_wrangler_returning("", "No KV Namespaces configured!", 1); + let _path = PathPrepend::new(fake.path()); + let result = CloudflareCliAdapter + .read_config_entry( + project_dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("'No KV Namespaces configured!' maps to MissingStore (not Err)"); + assert!( + matches!(result, ReadConfigEntry::MissingStore), + "'No KV Namespaces configured!' => MissingStore" + ); + } + + #[cfg(unix)] + #[test] + fn read_remote_reports_error_on_auth_failure_that_mentions_binding() { + // An auth/config failure whose message ALSO contains "binding" must + // surface as an error -- not be misclassified as MissingStore, which + // would make a diff report the whole store as added. + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let fake = fake_wrangler_returning( + "", + "Error: Unauthorized [10000] while resolving binding APP_CONFIG", + 1, + ); + let _path = PathPrepend::new(fake.path()); + let result = CloudflareCliAdapter.read_config_entry( + project_dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ); + let Err(err) = result else { + panic!("an auth failure mentioning 'binding' must be an error, not MissingStore"); + }; + assert!( + err.contains("authenticate") || err.contains("Unauthorized"), + "the auth failure must surface: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn read_local_uses_local_flag() { + // Verify that read_config_entry_local passes `--local` (not `--remote`) + // to wrangler. We capture argv via a fake wrangler and check the args. + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let argv_log = project_dir.path().join("argv.txt"); + let fake = fake_wrangler_argv_log(&argv_log); + let _path = PathPrepend::new(fake.path()); + let result = CloudflareCliAdapter + .read_config_entry_local( + project_dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("local read succeeds"); + assert!( + matches!(result, ReadConfigEntry::Present(_)), + "expected Present from local read" + ); + let captured = fs::read_to_string(&argv_log).expect("argv log"); + assert!( + captured.contains("--local"), + "read_local must pass --local to wrangler; got argv:\n{captured}" + ); + assert!( + !captured.contains("--remote"), + "read_local must NOT pass --remote; got argv:\n{captured}" + ); + } + + #[cfg(unix)] + #[test] + fn read_anchors_wrangler_at_the_declared_non_default_manifest() { + // A declared manifest with a NON-default filename sitting beside a + // plain `wrangler.toml` must be targeted via `--config`, or wrangler + // would discover the sibling default and read the wrong project. + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"decoy\"\n"); + fs::write( + project_dir.path().join("cloudflare.prod.toml"), + "name = \"prod\"\n", + ) + .expect("write declared manifest"); + let argv_log = project_dir.path().join("argv.txt"); + let fake = fake_wrangler_argv_log(&argv_log); + let _path = PathPrepend::new(fake.path()); + CloudflareCliAdapter + .read_config_entry_local( + project_dir.path(), + Some("cloudflare.prod.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("local read succeeds"); + let captured = fs::read_to_string(&argv_log).expect("argv log"); + assert!( + captured.contains("--config"), + "read must anchor wrangler with --config; got argv:\n{captured}" + ); + assert!( + captured.contains("cloudflare.prod.toml"), + "read must point --config at the DECLARED manifest; got argv:\n{captured}" + ); + } + + #[cfg(unix)] + #[test] + fn read_config_arg_is_not_doubled_for_a_nested_manifest() { + // The command runs with cwd = the manifest's parent, so a + // manifest-root-relative `--config` would be resolved AGAIN against + // that cwd (`crates/cf/wrangler.toml` -> `crates/cf/crates/cf/…`). + // The anchor must be absolute so no segment is doubled. + let _lock = path_mutation_guard().lock().expect("guard"); + let root = tempdir().expect("tempdir"); + let nested = root.path().join("crates/cf"); + fs::create_dir_all(&nested).expect("mkdir nested"); + write_wrangler(&nested, "name = \"prod\"\n"); + let argv_log = root.path().join("argv.txt"); + let fake = fake_wrangler_argv_log(&argv_log); + let _path = PathPrepend::new(fake.path()); + CloudflareCliAdapter + .read_config_entry_local( + root.path(), + Some("crates/cf/wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("local read succeeds"); + let captured = fs::read_to_string(&argv_log).expect("argv log"); + assert!( + !captured.contains("crates/cf/crates/cf"), + "the --config path must not be doubled under the changed cwd; got argv:\n{captured}" + ); + assert!( + captured + .lines() + .any(|line| line.ends_with("/crates/cf/wrangler.toml")), + "the --config anchor must be the absolute declared manifest; got argv:\n{captured}" + ); + } + + #[cfg(unix)] + #[test] + fn local_push_anchors_wrangler_at_the_declared_non_default_manifest() { + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"decoy\"\n"); + fs::write( + project_dir.path().join("cloudflare.prod.toml"), + "name = \"prod\"\n", + ) + .expect("write declared manifest"); + let argv_log = project_dir.path().join("argv.txt"); + let fake = fake_wrangler_argv_log(&argv_log); + let _path = PathPrepend::new(fake.path()); + CloudflareCliAdapter + .push_config_entries_local( + project_dir.path(), + Some("cloudflare.prod.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[("greeting".to_owned(), "hello".to_owned())], + &AdapterPushContext::new(), + false, + ) + .expect("local push succeeds"); + let captured = fs::read_to_string(&argv_log).expect("argv log"); + assert!( + captured.contains("--config") && captured.contains("cloudflare.prod.toml"), + "local push must anchor --config at the DECLARED manifest; got argv:\n{captured}" + ); + } + + #[test] + fn read_config_entry_requires_adapter_manifest_path() { + let dir = tempdir().expect("tempdir"); + let result = CloudflareCliAdapter.read_config_entry( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ); + match result { + Err(err) => assert!( + err.contains("[adapters.cloudflare.adapter].manifest"), + "error names the missing field: {err}" + ), + Ok(_) => panic!("expected Err when adapter_manifest_path is None"), + } + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/cli/run.rs b/crates/edgezero-adapter-cloudflare/src/cli/run.rs new file mode 100644 index 00000000..fd0f4534 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/cli/run.rs @@ -0,0 +1,388 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use edgezero_adapter::cli_support::{ + self, find_manifest_upwards, find_workspace_root, path_distance, read_package_name, +}; +use edgezero_adapter::registry::AdapterExecContext; +use walkdir::WalkDir; + +use super::TARGET_TRIPLE; + +/// # Errors +/// Returns an error if the Cloudflare wrangler build command fails. +pub(super) fn build( + extra_args: &[String], + ctx: &AdapterExecContext<'_>, +) -> Result { + let manifest = cli_support::declared_or_discovered_manifest(ctx, || { + find_wrangler_manifest(cli_support::discovery_base(ctx)?.as_path()) + })?; + // `Cargo.toml` lives at the declared crate root, which is NOT + // necessarily the manifest's parent -- a nested declared manifest + // like `crates/server/config/wrangler.toml` would otherwise resolve + // `crates/server/config/Cargo.toml`. + let crate_dir = cli_support::adapter_crate_dir(ctx, &manifest)?; + let cargo_manifest = crate_dir.join("Cargo.toml"); + let crate_name = read_package_name(&cargo_manifest)?; + + let mut command = Command::new("cargo"); + command + .args([ + "build", + "--release", + "--target", + TARGET_TRIPLE, + "--manifest-path", + cargo_manifest + .to_str() + .ok_or("invalid Cargo manifest path")?, + ]) + .args(extra_args) + // Anchor cargo at the crate root, not the process cwd. When the + // CLI dispatches through an absolute `EDGEZERO_MANIFEST` from + // outside the project, an unanchored `cargo` would discover the + // wrong `.cargo/config.toml` and resolve relative args against the + // caller's directory. + .current_dir(&crate_dir); + for (key, value) in ctx.env() { + command.env(key, value); + } + let status = command + .status() + .map_err(|err| format!("failed to run cargo build: {err}"))?; + if !status.success() { + return Err(format!("cargo build failed with status {status}")); + } + + let workspace_root = find_workspace_root(&crate_dir); + let artifact = locate_artifact(&workspace_root, &crate_dir, &crate_name, extra_args, ctx)?; + let pkg_dir = workspace_root.join("pkg"); + fs::create_dir_all(&pkg_dir) + .map_err(|err| format!("failed to create {}: {err}", pkg_dir.display()))?; + let dest = pkg_dir.join(format!("{}.wasm", crate_name.replace('-', "_"))); + fs::copy(&artifact, &dest) + .map_err(|err| format!("failed to copy artifact to {}: {err}", dest.display()))?; + + Ok(dest) +} + +/// # Errors +/// Returns an error if the Cloudflare wrangler deploy command fails. +pub(super) fn deploy(extra_args: &[String], ctx: &AdapterExecContext<'_>) -> Result<(), String> { + let manifest = cli_support::declared_or_discovered_manifest(ctx, || { + find_wrangler_manifest(cli_support::discovery_base(ctx)?.as_path()) + })?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; + let config = manifest + .to_str() + .ok_or_else(|| "invalid wrangler config path".to_owned())?; + + let mut command = Command::new("wrangler"); + command + .args(["deploy", "--config", config]) + .args(extra_args) + .current_dir(manifest_dir); + for (key, value) in ctx.env() { + command.env(key, value); + } + let status = command + .status() + .map_err(|err| format!("failed to run wrangler CLI: {err}"))?; + if !status.success() { + return Err(format!("wrangler deploy failed with status {status}")); + } + + Ok(()) +} + +/// # Errors +/// Returns an error if the Cloudflare wrangler dev command fails. +pub(super) fn serve(extra_args: &[String], ctx: &AdapterExecContext<'_>) -> Result<(), String> { + let manifest = cli_support::declared_or_discovered_manifest(ctx, || { + find_wrangler_manifest(cli_support::discovery_base(ctx)?.as_path()) + })?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; + let config = manifest + .to_str() + .ok_or_else(|| "invalid wrangler config path".to_owned())?; + + let mut command = Command::new("wrangler"); + command + .args(["dev", "--config", config]) + .args(extra_args) + .current_dir(manifest_dir); + for (key, value) in ctx.env() { + command.env(key, value); + } + let status = command + .status() + .map_err(|err| format!("failed to run wrangler CLI: {err}"))?; + if !status.success() { + return Err(format!("wrangler dev failed with status {status}")); + } + + Ok(()) +} + +fn find_wrangler_manifest(start: &Path) -> Result { + if let Some(found) = find_manifest_upwards(start, "wrangler.toml") { + return Ok(found); + } + + let root = find_workspace_root(start); + let mut candidates: Vec = WalkDir::new(&root) + .follow_links(true) + .max_depth(8) + .into_iter() + .filter_map(Result::ok) + .map(|entry| entry.path().to_path_buf()) + .filter(|path| { + path.file_name().is_some_and(|n| n == "wrangler.toml") + && path + .parent() + .is_some_and(|dir| dir.join("Cargo.toml").exists()) + }) + .collect(); + + if candidates.is_empty() { + return Err("could not locate wrangler.toml".to_owned()); + } + + candidates.sort_by_key(|path| { + let parent = path.parent().unwrap_or(Path::new("")); + path_distance(start, parent) + }); + + Ok(candidates.remove(0)) +} + +fn locate_artifact( + workspace_root: &Path, + crate_dir: &Path, + crate_name: &str, + build_args: &[String], + ctx: &AdapterExecContext<'_>, +) -> Result { + let release_name = format!("{}.wasm", crate_name.replace('-', "_")); + + // Resolve cargo's effective target dir the SAME way the build did + // (`--target-dir` arg, then `CARGO_TARGET_DIR`, then a + // `.cargo/config.toml` `[build] target-dir`). When an override is in + // play, look ONLY there -- falling back to the conventional `target/` + // paths could package a STALE artifact from an earlier default build. + match cli_support::resolve_cargo_target_dir(crate_dir, build_args, ctx) { + cli_support::CargoTargetDir::Explicit(dir) => { + let candidate = dir.join(TARGET_TRIPLE).join("release").join(&release_name); + return if candidate.exists() { + Ok(candidate) + } else { + Err(format!( + "compiled artifact `{release_name}` not found in the requested target directory {} (a custom target dir was set via --target-dir, CARGO_TARGET_DIR, or .cargo/config.toml); refusing to fall back to a conventional target path to avoid packaging a stale artifact", + candidate.display() + )) + }; + } + cli_support::CargoTargetDir::Conventional => {} + } + + let manifest_target = crate_dir + .join("target") + .join(TARGET_TRIPLE) + .join("release") + .join(&release_name); + if manifest_target.exists() { + return Ok(manifest_target); + } + + let workspace_target = workspace_root + .join("target") + .join(TARGET_TRIPLE) + .join("release") + .join(&release_name); + if workspace_target.exists() { + return Ok(workspace_target); + } + + Err(format!( + "compiled artifact not found for {crate_name} (looked in manifest and workspace target directories)" + )) +} + +/// Synthesised baseline `wrangler.toml` for scaffold-time and +/// clean-clone bootstrap (single source — the Cloudflare blueprint +/// has no scaffold `.hbs` template for `wrangler.toml`, so +/// `edgezero new` and clean-clone `provision --local` produce +/// byte-identical output; see the "Generated Adapter manifests" +/// note in the spec). +/// +/// The `name` field spells the adapter crate's Cargo package name. +/// The caller in `cli/mod.rs` reads this from the `Cargo.toml` +/// adjacent to the adapter manifest (honouring the operator's +/// `[adapters.cloudflare.adapter].crate` rename) and falls back +/// to the scaffold convention `-adapter-cloudflare` +/// only when no Cargo.toml is discoverable. `worker-build` reads +/// this field and expects it to match the Cargo package it builds. +/// +/// Built via `toml_edit::DocumentMut` (NOT raw `format!`) so any +/// legal name — including values with TOML-significant characters +/// like `"`, `\`, or newlines — is escaped correctly. +pub(super) fn synthesise_wrangler_toml(crate_name: &str) -> String { + use toml_edit::{DocumentMut, value}; + + let mut doc = DocumentMut::new(); + doc.decor_mut().set_prefix("# edgezero-provision: v1\n"); + // `Table::insert` returns the previous value (if any). We build a + // fresh document from `DocumentMut::new()`, so nothing to displace + // -- but the return is discarded intentionally. Using `insert` + // instead of `doc["..."] = ...` sidesteps `clippy::indexing_slicing` + // (the index form panics if the key is missing; `insert` doesn't). + doc.insert("name", value(crate_name)); + doc.insert("main", value("build/worker/shim.mjs")); + doc.insert("compatibility_date", value("2024-01-01")); + + // No `[build]` table: the spec's normative Cloudflare baseline + // (spec §"Cloudflare (wrangler.toml)") is exactly `name` + `main` + // + `compatibility_date`. EdgeZero drives builds through its own + // `edgezero build --adapter cloudflare` (which runs `cargo build` + // + artifact copy), not bare `wrangler deploy`, so emitting a + // `[build]` command exceeded that baseline. Operators who invoke wrangler directly add it by hand; + // the merge path preserves it. + + doc.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + // ---------- locate_artifact ---------- + + #[test] + fn locate_artifact_honors_target_dir_build_arg_over_stale_default() { + // A `--target-dir` build arg redirects cargo. Discovery must look + // ONLY there, even when a STALE artifact sits at the conventional + // workspace target from an earlier default build. + let dir = tempdir().unwrap(); + let workspace = dir.path(); + let crate_dir = workspace.join("service"); + fs::create_dir_all(&crate_dir).unwrap(); + let stale = workspace + .join("target") + .join(TARGET_TRIPLE) + .join("release/demo.wasm"); + fs::create_dir_all(stale.parent().unwrap()).unwrap(); + fs::write(&stale, "stale").unwrap(); + let fresh = crate_dir + .join("custom") + .join(TARGET_TRIPLE) + .join("release/demo.wasm"); + fs::create_dir_all(fresh.parent().unwrap()).unwrap(); + fs::write(&fresh, "fresh").unwrap(); + + let build_args = ["--target-dir".to_owned(), "custom".to_owned()]; + let located = locate_artifact( + workspace, + &crate_dir, + "demo", + &build_args, + &AdapterExecContext::new(), + ) + .unwrap(); + assert_eq!(located, fresh, "must select the custom-target artifact"); + } + + #[test] + fn locate_artifact_errors_when_explicit_target_dir_has_no_artifact() { + // An explicit target dir with no artifact must error rather than + // silently fall back to a stale conventional artifact. + let dir = tempdir().unwrap(); + let workspace = dir.path(); + let crate_dir = workspace.join("service"); + fs::create_dir_all(&crate_dir).unwrap(); + let stale = workspace + .join("target") + .join(TARGET_TRIPLE) + .join("release/demo.wasm"); + fs::create_dir_all(stale.parent().unwrap()).unwrap(); + fs::write(&stale, "stale").unwrap(); + + let build_args = ["--target-dir=custom".to_owned()]; + let err = locate_artifact( + workspace, + &crate_dir, + "demo", + &build_args, + &AdapterExecContext::new(), + ) + .expect_err("must not fall back to the stale conventional artifact"); + assert!(err.contains("stale"), "error explains the refusal: {err}"); + } + + #[test] + fn locate_artifact_conventional_search_still_works() { + let dir = tempdir().unwrap(); + let workspace = dir.path(); + let crate_dir = workspace.join("service"); + fs::create_dir_all(&crate_dir).unwrap(); + let artifact = workspace + .join("target") + .join(TARGET_TRIPLE) + .join("release/demo.wasm"); + fs::create_dir_all(artifact.parent().unwrap()).unwrap(); + fs::write(&artifact, "wasm").unwrap(); + + let located = locate_artifact( + workspace, + &crate_dir, + "demo", + &[], + &AdapterExecContext::new(), + ) + .unwrap(); + assert_eq!(located, artifact); + } + + // ---------- synthesise_wrangler_toml ---------- + + #[test] + fn synthesises_wrangler_toml_matches_spec_baseline_exactly() { + // Exact-content test: the + // synthesised wrangler.toml must equal the spec's normative + // Cloudflare baseline byte-for-byte -- no `[build]` table, no + // other extras. A loose `contains` check let the baseline + // drift above the spec; this pins it. + let out = synthesise_wrangler_toml("demo-adapter-cloudflare"); + let expected = "# edgezero-provision: v1\n\ + name = \"demo-adapter-cloudflare\"\n\ + main = \"build/worker/shim.mjs\"\n\ + compatibility_date = \"2024-01-01\"\n"; + assert_eq!(out, expected, "wrangler.toml baseline drifted from spec"); + } + + #[test] + fn synthesise_wrangler_toml_escapes_pathological_crate_names() { + // Adapter crate names come from Cargo.toml `[package].name` + // — Cargo restricts them to `[A-Za-z0-9_-]`, but the synth + // must still be defensive against TOML-hostile inputs so + // an operator that stashes something exotic into + // `[adapters..adapter].crate` doesn't produce + // invalid TOML. + for name in [ + r#"has"quote"#, + r"has\backslash", + "has\nnewline", + "has = equals", + ] { + let out = synthesise_wrangler_toml(name); + let doc: toml_edit::DocumentMut = out.parse().unwrap(); + assert_eq!(doc["name"].as_str(), Some(name), "input: {name:?}"); + } + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/templates/wrangler.toml.hbs b/crates/edgezero-adapter-cloudflare/src/templates/wrangler.toml.hbs deleted file mode 100644 index 4f076e09..00000000 --- a/crates/edgezero-adapter-cloudflare/src/templates/wrangler.toml.hbs +++ /dev/null @@ -1,6 +0,0 @@ -name = "{{proj_cloudflare}}" -main = "build/worker/shim.mjs" -compatibility_date = "2023-05-01" - -[build] -command = "worker-build --release" \ No newline at end of file diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs deleted file mode 100644 index 6840b0b8..00000000 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ /dev/null @@ -1,9570 +0,0 @@ -use std::cell::{Cell, RefCell}; -use std::collections::{BTreeMap, HashMap, HashSet}; -use std::env; -use std::fmt::Write as _; -use std::fs; -use std::io::{ErrorKind, Write as _}; -use std::path::{Path, PathBuf}; -use std::process::ChildStdin; -use std::process::Command; -use std::process::Stdio; -use std::process::id as process_id; -use std::time::{SystemTime, UNIX_EPOCH}; - -use crate::chunked_config::{ - CHUNK_KEY_INFIX, GcPointer, GcRootValue, ResolveFailure, chunk_key_generation, chunk_key_index, - chunk_lengths, gc_classify_root, gc_verify_generation, prepare_fastly_config_entries, - prior_chunk_keys, resolve_fastly_config_value_typed, sha256_hex, value_announces_our_kind, - value_is_future_format, value_is_inert_foreign, verify_writer_split_layout, -}; -use ctor::ctor; -use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, -}; -use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, ResolvedStoreId, - register_adapter, -}; -use edgezero_adapter::scaffold::{ - AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, - ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, -}; -use walkdir::WalkDir; - -static FASTLY_ADAPTER: FastlyCliAdapter = FastlyCliAdapter; - -static FASTLY_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { - id: "fastly", - display_name: "Fastly Compute@Edge", - crate_suffix: "adapter-fastly", - dependency_crate: "edgezero-adapter-fastly", - dependency_repo_path: "crates/edgezero-adapter-fastly", - template_registrations: FASTLY_TEMPLATE_REGISTRATIONS, - files: FASTLY_FILE_SPECS, - extra_dirs: &["src", ".cargo"], - dependencies: FASTLY_DEPENDENCIES, - manifest: ManifestSpec { - manifest_filename: "fastly.toml", - build_target: "wasm32-wasip1", - build_profile: "release", - build_features: &["fastly"], - }, - commands: CommandTemplates { - build: "fastly compute build -C {crate_dir}", - deploy: "fastly compute deploy -C {crate_dir}", - serve: "fastly compute serve -C {crate_dir}", - }, - logging: LoggingDefaults { - endpoint: Some("stdout"), - level: "info", - echo_stdout: Some(true), - }, - readme: ReadmeInfo { - description: "{display} entrypoint.", - dev_heading: "{display} (local)", - dev_steps: &["`cd {crate_dir}`", "`edgezero serve --adapter fastly`"], - }, - run_module: "edgezero_adapter_fastly", -}; - -static FASTLY_DEPENDENCIES: &[DependencySpec] = &[ - DependencySpec { - key: "dep_edgezero_core_fastly", - repo_crate: "crates/edgezero-core", - fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\", default-features = false }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_fastly", - repo_crate: "crates/edgezero-adapter-fastly", - fallback: "edgezero-adapter-fastly = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-fastly\", default-features = false }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_fastly_wasm", - repo_crate: "crates/edgezero-adapter-fastly", - fallback: "edgezero-adapter-fastly = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-fastly\", default-features = false, features = [\"fastly\"] }", - features: &["fastly"], - }, -]; - -static FASTLY_FILE_SPECS: &[AdapterFileSpec] = &[ - AdapterFileSpec { - template: "fastly_Cargo_toml", - output: "Cargo.toml", - }, - AdapterFileSpec { - template: "fastly_src_main_rs", - output: "src/main.rs", - }, - AdapterFileSpec { - template: "fastly_cargo_config_toml", - output: ".cargo/config.toml", - }, - AdapterFileSpec { - template: "fastly_fastly_toml", - output: "fastly.toml", - }, -]; - -static FASTLY_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ - TemplateRegistration { - name: "fastly_Cargo_toml", - contents: include_str!("templates/Cargo.toml.hbs"), - }, - TemplateRegistration { - name: "fastly_src_main_rs", - contents: include_str!("templates/src/main.rs.hbs"), - }, - TemplateRegistration { - name: "fastly_cargo_config_toml", - contents: include_str!("templates/.cargo/config.toml.hbs"), - }, - TemplateRegistration { - name: "fastly_fastly_toml", - contents: include_str!("templates/fastly.toml.hbs"), - }, -]; - -const FASTLY_INSTALL_HINT: &str = "install the Fastly CLI (https://www.fastly.com/documentation/reference/tools/cli/) and try again"; - -/// Hard-error message for a value written by a NEWER format this v1 CLI must not -/// overwrite. Shared by the read path so the wording stays consistent. -const FUTURE_FORMAT_READ_ERROR: &str = "the remote value uses a config format this CLI version does not recognise (a newer \ - `edgezero_kind` or envelope/pointer version); UPGRADE the CLI to push to this store rather \ - than overwrite a newer format."; - -struct FastlyCliAdapter; - -/// Outcome of scanning `fastly config-store list --json` for a -/// platform store id by `name`. Distinguishes three cases the -/// caller wants to act on differently: -/// -/// - `Found(id)` — happy path. -/// - `NotFound` — JSON parsed cleanly and the array contains -/// entries with well-formed `name` + `id` string fields, but no -/// entry matched `name`. Operator likely needs to run -/// `provision`. -/// - `SchemaDrift(detail)` — the JSON parsed but doesn't match -/// the expected shape (no `items` envelope nor bare array, OR -/// entries are missing `name` / `id` string fields, OR the -/// bytes didn't parse as JSON at all). Likely a fastly CLI -/// version bump that changed the output schema; surface the -/// detail so the operator can pin a known-compatible version. -#[derive(Debug)] -enum ConfigStoreLookup { - Found(String), - NotFound, - SchemaDrift(String), -} - -/// The reclamation plan for `config gc`: the orphan chunk entries to delete -/// (with their ages) plus the counts for the summary line. Produced by -/// `plan_gc_reclamation` (which owns every safety guard); consumed by -/// `gc_fastly_config_store` (which reports and deletes). -struct GcPlan { - /// Whole generations to reclaim, each a list of `(key, age_secs)`. Grouped, - /// not flat: a generation is provable only as a UNIT (see - /// `prove_generation`), so deleting part of one destroys the very evidence - /// that licenses deleting the rest. - doomed: Vec>, - /// The root keys retained as live/protected — the config entries GC will NOT - /// delete, sorted. Surfaced so a run shows what it is KEEPING, not only what - /// it would delete, making the sweep reviewable. - kept_roots: Vec, - live_count: usize, - retained_recent: usize, - roots: usize, - /// Chunk-shaped entries we could NOT prove our writer produced, so left - /// untouched. Surfaced so an operator can see we declined to judge them. - unprovable: usize, - /// Non-fatal problems to print — see `GcClassification::warnings`. - warnings: Vec, -} - -/// What one pass of `config gc`'s delete loop actually did. -struct GcDeleteOutcome { - /// Entries whose delete returned success. - deleted: usize, - /// Keys whose delete returned non-zero. - failed: Vec, - /// Survivors of a generation in which an earlier sibling's delete had - /// ALREADY succeeded before a later one failed. These are definitely an - /// incomplete generation now, so they can never be proved (or reclaimed) - /// again -- manual removal only. - stranded: Vec, - /// Members of a generation whose ONLY failure was on a delete with no - /// confirmed prior sibling success. A failed remote delete has UNKNOWN - /// outcome (Fastly may have committed it before returning an error), so we - /// cannot say whether the generation is still whole. A re-run reclaims it if - /// it is, or reports it as an unprovable fragment if it is not. - uncertain: Vec, -} - -/// The result of classifying a store's entries for reclamation. -struct GcClassification { - /// Chunk keys a live root pointer references, each verified against its - /// content-address. Never deletable. - live: HashSet, - /// Keys whose OWN value is a runtime-readable root — a valid direct envelope - /// or a pointer — regardless of what their key looks like. Never deletable. - protected: HashSet, - /// Count of entries classified as roots, for the summary line. - roots: usize, - /// Non-fatal problems the operator should see — currently roots that are - /// not runtime-readable and so can never be reclaimed automatically. - warnings: Vec, -} - -/// One `config-store-entry list` item. -/// -/// `item_value` IS captured — `config gc` must parse root pointers to learn -/// which chunks are live, and one listing avoids a `describe` per root. It is -/// the config payload: it may be read in memory but must NEVER be logged or -/// surfaced (see `redact_describe_response` / `redact_stderr`). -struct ConfigStoreItem { - created_at: String, - item_key: String, - item_value: String, -} - -/// Per-root plan for the LOCAL path's eager prune. -/// -/// Local reclamation is safe to do immediately: `fastly.toml` is a single -/// file that Viceroy reads at startup — there is no propagation window and no -/// POP that could still be serving the previous pointer. (The cloud path -/// cannot do this; see `reclaim_orphan_generations`.) -struct FastlyConfigGcPlan { - /// Exact keep-set this push writes for the root (chunk keys + root key). - new_keys: HashSet, - /// Prior chunk keys to consider deleting, or a warning to surface - /// (suspicious prior pointer) that skips GC for this root. - prior_keys: Result, String>, -} - -/// An exclusive, cross-process advisory lock covering a local `fastly.toml` -/// rewrite. Serialises concurrent pushes so their read-modify-write cycles -/// cannot interleave and lose each other's edits. -/// -/// The lock is a persistent sidecar file next to the manifest. It is never -/// unlinked — deleting it would reintroduce a create/lock race between two -/// processes each making their own lock file. Dropping the guard releases the -/// OS lock (closing the file descriptor). `File::lock` is advisory, so it only -/// coordinates other lockers, which is exactly the pushes we control. -struct ManifestLock { - _file: fs::File, - /// The REAL file the lock guards, resolved through any symlink. Callers read - /// and replace THIS path, so every alias operates on one target. - target: PathBuf, -} - -/// Removes a staging temp file on drop unless disarmed — so every early return -/// (permission failure, write failure, rename failure) cleans up after itself. -struct TempFileGuard { - path: Option, -} - -// The three `validate_*` trait methods exist on `Adapter` because -// spin requires them (variable-name regex, `[component.*]` -// discovery, flat-namespace collision). The trait surface is typed -// generically so any future adapter with similar constraints can -// override — but fastly has no equivalent platform requirements, -// so the no-op defaults are correct: -// -// - `validate_app_config_keys`: Fastly Config Store keys accept -// alphanumeric + `-` / `_` / `.` up to 256 chars. Any reasonable -// Rust struct field name passes; no regex check needed. -// - `validate_adapter_manifest`: would require shelling out to -// `fastly compute validate` at validate-time. We keep -// `config validate` pure-Rust so it stays fast and -// tool-independent. -// - `validate_typed_secrets`: Fastly's KV / Config / Secret -// stores are independent namespaces — no spin-style flat- -// namespace collision risk to detect. -// -// `single_store_kinds` IS overridden below — explicitly returns -// `&[]` for documentation, matching the inherited default. -#[expect( - clippy::missing_trait_methods, - reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `read_config_entry` and `read_config_entry_local` are both overridden below. `single_store_kinds` IS overridden below (returns `&[]`)." -)] -impl Adapter for FastlyCliAdapter { - fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { - match action { - // `fastly profile {create|delete|list}` is the native - // sign-in surface for Fastly Compute. EdgeZero stores no - // credentials — this is a thin shell-out. - AdapterAction::AuthLogin => { - run_native_cli("fastly", &["profile", "create"], FASTLY_INSTALL_HINT) - } - AdapterAction::AuthLogout => { - run_native_cli("fastly", &["profile", "delete"], FASTLY_INSTALL_HINT) - } - AdapterAction::AuthStatus => { - run_native_cli("fastly", &["profile", "list"], FASTLY_INSTALL_HINT) - } - AdapterAction::Build => { - let artifact = build(args)?; - log::info!("[edgezero] Fastly build complete -> {}", artifact.display()); - Ok(()) - } - AdapterAction::Deploy => deploy(args), - AdapterAction::Serve => serve(args), - other => Err(format!("fastly adapter does not support {other:?}")), - } - } - - fn gc_config_entries( - &self, - _manifest_root: &Path, - _adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - _push_ctx: &AdapterPushContext<'_>, - older_than_secs: u64, - dry_run: bool, - ) -> Result, String> { - gc_fastly_config_store(store.platform.as_str(), older_than_secs, dry_run) - } - - fn name(&self) -> &'static str { - "fastly" - } - - fn preflight_config_write(&self, key: &str, body: &str) -> Result<(), String> { - // Reject an infeasible push here, BEFORE the CLI's remote read, so it - // fails offline rather than after a list/describe. The write path - // re-checks, so this is a strict early gate, not the only one. - // - // An empty key is writer-valid but resolver-invalid (canonical chunk - // parsing rejects an empty root); reject it before any I/O. - if key.is_empty() { - return Err( - "config key is empty; provide a store id or a non-empty `--key`".to_owned(), - ); - } - let entry = [(key.to_owned(), String::new())]; - reject_reserved_root_keys(&entry)?; - // Run the full chunk expansion OFFLINE (no I/O): exactly what the write - // path does, so every body-dependent feasibility failure — the root key - // over the store limit, a DERIVED chunk key over it once the value - // chunks, or a pointer that would not fit the entry limit — is caught - // here, before the remote read, instead of after it. - prepare_fastly_config_entries(key, body)?; - Ok(()) - } - - fn provision( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - stores: &ProvisionStores<'_>, - dry_run: bool, - ) -> Result, String> { - // 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 - // writeback declares the resource link for `fastly - // compute deploy` and the local viceroy server. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.fastly.adapter].manifest must point at fastly.toml for provision" - .to_owned(), - ); - }; - let fastly_path = manifest_root.join(rel); - - let mut out = Vec::new(); - for (kind, ids) in [ - ("kv", stores.kv), - ("config", stores.config), - ("secret", stores.secrets), - ] { - for store in ids { - // Fastly setup tables key on the resource name the - // CLI creates. The runtime resolves that same name - // via `EDGEZERO__STORES______NAME`, - // so provision must use the env-resolved PLATFORM - // name -- the logical id stays in status lines for - // human-facing wording. - let logical = store.logical.as_str(); - let name = store.platform.as_str(); - if dry_run { - out.push(format!( - "would run `fastly {kind}-store create --name={name}` and append [setup.{kind}_stores.{name}] to {} (logical id `{logical}`)", - fastly_path.display() - )); - continue; - } - if setup_block_present(&fastly_path, kind, name)? { - out.push(format!( - "fastly {kind}-store `{name}` (logical id `{logical}`) already declared in {}; skipping. To force a fresh remote: delete the [setup.{kind}_stores.{name}] block AND run `fastly {kind}-store delete --name={name}` (the old remote store lingers otherwise), then re-run provision.", - fastly_path.display() - )); - continue; - } - create_fastly_store(kind, name)?; - // If the platform store was created but the - // writeback fails, remote state and the local - // manifest are out of sync. Re-running `provision` - // would attempt to create the platform store again - // and fail with "already exists". Surface the - // recovery path explicitly so the operator isn't - // stuck. - append_fastly_setup(&fastly_path, kind, name).map_err(|err| { - format!( - "fastly {kind}-store `{name}` (logical id `{logical}`) was created remotely, but writeback to {path} failed: {err}\n To recover, either:\n 1. Manually append `[setup.{kind}_stores.{name}]` to {path} and re-run, or\n 2. Delete the orphan remote store via `fastly {kind}-store delete --name={name}` and re-run `edgezero provision --adapter fastly`.", - path = fastly_path.display() - ) - })?; - // Fastly's `[setup._stores.]` table is - // consumed ONLY when `fastly compute deploy` is - // creating a NEW service. If `service_id` is - // already present in fastly.toml, the service has - // been deployed at least once and subsequent - // deploys skip `[setup]` entirely — so the store - // exists in the account but has no resource link - // tying it to a service version, and the running - // Compute service can't open it. - // - // Detect that case and EMIT the exact one-shot - // command the operator should run to link the - // store. We deliberately don't auto-run it: the - // link cones the active version (`--autoclone`), - // and silently mutating an already-deployed - // service is surprising. The instruction names - // both the store-id lookup AND the link command so - // the operator can audit before committing. - let post_create_note = resource_link_note(&fastly_path, kind, name)?; - let mut line = format!( - "created fastly {kind}-store `{name}` (logical id `{logical}`); appended setup tables to {}", - fastly_path.display() - ); - if let Some(note) = post_create_note { - line.push('\n'); - line.push_str(¬e); - } - out.push(line); - } - } - // EdgeZero runtime overrides live in a dedicated Fastly Config - // Store named `edgezero_runtime_env`. Compute@Edge has no - // process env, so `EDGEZERO__STORES__CONFIG____KEY` and - // similar overrides have to come from a platform Config Store - // the runtime opens by name (see - // `env_config_from_runtime_dictionary` in lib.rs). Provision - // owns the store creation alongside the operator's declared - // stores so the runtime override path is wired correctly out - // of the box; if the store already appears in - // `[setup.config_stores.edgezero_runtime_env]`, skip. - let runtime_env_kind = "config"; - let runtime_env_name = "edgezero_runtime_env"; - if dry_run { - out.push(format!( - "would run `fastly {runtime_env_kind}-store create --name={runtime_env_name}` and append [setup.{runtime_env_kind}_stores.{runtime_env_name}] to {} (EdgeZero runtime override store)", - fastly_path.display() - )); - } else if !setup_block_present(&fastly_path, runtime_env_kind, runtime_env_name)? { - create_fastly_store(runtime_env_kind, runtime_env_name)?; - append_fastly_setup(&fastly_path, runtime_env_kind, runtime_env_name).map_err( - |err| { - format!( - "fastly {runtime_env_kind}-store `{runtime_env_name}` was created remotely, but writeback to {path} failed: {err}\n Recover via `fastly {runtime_env_kind}-store delete --name={runtime_env_name}` then re-run `edgezero provision --adapter fastly`.", - path = fastly_path.display() - ) - }, - )?; - // Same already-deployed-service caveat as the declared-store - // path: if `service_id` is set in fastly.toml, the - // `[setup.config_stores.edgezero_runtime_env]` table won't - // be re-applied by the next `fastly compute deploy`, so the - // runtime can't open the store. Emit the resource-link - // remediation alongside the populate-keys hint. - let post_create_note = - resource_link_note(&fastly_path, runtime_env_kind, runtime_env_name)?; - let mut line = format!( - "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store); appended setup tables to {}\n Populate per-environment override keys with:\n fastly config-store-entry update --store-id= --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY --value=app_config_staging --upsert", - fastly_path.display() - ); - if let Some(note) = post_create_note { - line.push('\n'); - line.push_str(¬e); - } - out.push(line); - } else { - // Already declared; nothing to do. - } - - if out.is_empty() { - out.push("fastly has no declared stores to provision".to_owned()); - } - Ok(out) - } - - fn push_config_entries( - &self, - _manifest_root: &Path, - _adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - _push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - // Resolve the platform config-store id on demand via - // `fastly config-store list --json` (matched by name = - // `store.platform`), then `fastly config-store-entry update - // --store-id= --key= --upsert --stdin` per physical - // entry. Entries are logical blob-envelope entries from - // the CLI (one (key, envelope_json) per push); oversized - // Fastly values are expanded below into chunk entries plus - // a root pointer by `chunked_config::prepare_fastly_config_entries`. - let logical = store.logical.as_str(); - let name = store.platform.as_str(); - if entries.is_empty() { - return Ok(vec![format!( - "no config entries to push to fastly config-store `{name}` (logical id `{logical}`)" - )]); - } - // Reject reserved keys before any expansion or I/O. - reject_reserved_root_keys(entries)?; - reject_duplicate_root_keys(entries)?; - // Expand each logical root into its physical entries (chunks + pointer, or - // a single direct entry). Collecting them all first surfaces a - // pointer-too-large error before touching the remote store. A cloud push - // does NOT reclaim, so — unlike the local path — it keeps no per-root - // keep-set / root-value GC bookkeeping. - let mut physical_entries: Vec<(String, String)> = Vec::new(); - for (key, body) in entries { - let (expanded, ..) = expand_root(key, body)?; - physical_entries.extend(expanded); - } - if dry_run { - // Report intent without shelling out. Stays fully offline: no - // store-id resolution, no remote read (so no GC count). - let mut out = Vec::with_capacity(entries.len().saturating_mul(2).saturating_add(1)); - out.push(format!( - "would resolve fastly config-store `{name}` (logical id `{logical}`) via `fastly config-store list --json` and push entries:" - )); - for (key, body) in entries { - let expanded = prepare_fastly_config_entries(key, body) - .unwrap_or_else(|_| vec![(key.clone(), body.clone())]); - if expanded.len() == 1 { - out.push(format!( - " would push `{key}` as direct entry ({}B)", - body.len() - )); - } else { - let chunk_count = expanded.len().saturating_sub(1); - out.push(format!( - " would push `{key}` as chunked ({chunk_count} chunks + 1 pointer, {}B total)", - body.len() - )); - } - } - return Ok(out); - } - let resolved_id = - resolve_remote_config_store_id(name)?.ok_or_else(|| no_matching_store_error(name))?; - // NOTE: a cloud push does NOT reclaim orphaned chunks. - // - // Fastly's config store is eventually consistent, so a generation may - // only be deleted once the pointer that referenced it has stopped being - // served everywhere. Fastly records no pointer-supersession time - // (`updated_at` is NOT bumped by `update --upsert` -- verified against - // the live API), offers no compare-and-swap with which to record one - // safely, and chunk `created_at` is NOT a proxy for it (a chunked -> - // direct -> direct transition leaves the old generation with no - // "successor" at all). Every attempt to synthesise that fact is unsound. - // - // So reclamation is an explicit, operator-invoked `config gc`: the - // operator supplies the one fact the platform cannot -- that the current - // config has been live long enough that nothing is serving the old - // pointers. See the spec's "Cloud reclamation". - // Preflight: refuse if a generated chunk key would clobber an existing - // root-like sibling in the remote store. Uses a completeness-strict key - // listing (value-tolerant) and describes only the rare colliding keys. - let remote_keys = list_config_store_keys(&resolved_id)?; - reject_generated_key_collisions(&physical_entries, &remote_keys, |chunk_key| { - fetch_remote_config_store_entry(&resolved_id, chunk_key).map(Some) - })?; - push_entries_with_committer(&physical_entries, |key, value| { - create_config_store_entry(&resolved_id, key, value) - })?; - Ok(vec![format!( - "pushed {} physical entries ({} logical) to fastly config-store `{name}` (logical id `{logical}`, id={resolved_id})", - physical_entries.len(), - entries.len() - )]) - } - - fn push_config_entries_local( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - _push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - // Local-emulator path: edit - // `[local_server.config_stores..contents]` in - // `fastly.toml`. Viceroy reads it on startup, so a - // subsequent `fastly compute serve` exposes the new values - // to the wasm component. No shell-out to the production - // Fastly CLI -- the operator may not be authenticated and - // wouldn't want a local push to touch production anyway. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.fastly.adapter].manifest must point at fastly.toml for config push --local" - .to_owned(), - ); - }; - let fastly_path = manifest_root.join(rel); - let logical = store.logical.as_str(); - let name = store.platform.as_str(); - if entries.is_empty() { - return Ok(vec![format!( - "no config entries to push to `[local_server.config_stores.{name}]` in {} (logical id `{logical}`)", - fastly_path.display() - )]); - } - // Reject reserved keys before any expansion or I/O. - reject_reserved_root_keys(entries)?; - reject_duplicate_root_keys(entries)?; - // Expand each logical root once: flatten for the write, keep the - // exact per-root keep-set for GC (no prefix scan of the flattened set). - let mut physical_entries: Vec<(String, String)> = Vec::new(); - let mut gc_roots: Vec<(String, HashSet)> = Vec::with_capacity(entries.len()); - for (key, body) in entries { - let (expanded, new_keys, _new_root) = expand_root(key, body)?; - physical_entries.extend(expanded); - gc_roots.push((key.clone(), new_keys)); - } - if dry_run { - let counts = local_orphan_counts_for_dry_run(&fastly_path, name, entries); - let mut out = Vec::with_capacity(entries.len().saturating_mul(2).saturating_add(1)); - out.push(format!( - "would edit `[local_server.config_stores.{name}.contents]` in {} (logical id `{logical}`) with entries:", - fastly_path.display(), - )); - for (idx, (key, body)) in entries.iter().enumerate() { - let expanded = prepare_fastly_config_entries(key, body) - .unwrap_or_else(|_| vec![(key.clone(), body.clone())]); - if expanded.len() == 1 { - out.push(format!( - " would set `{key}` as direct entry ({}B)", - body.len() - )); - } else { - let chunk_count = expanded.len().saturating_sub(1); - out.push(format!( - " would set `{key}` as chunked ({chunk_count} chunks + 1 pointer, {}B total)", - body.len() - )); - } - match counts.get(idx).map(|(_, count)| count) { - Some(Ok(n)) => out.push(format!( - " would delete {n} orphan chunks from the previous generation of `{key}`" - )), - Some(Err(reason)) => out.push(format!( - " would delete an unknown number of orphan chunks from the previous generation of `{key}` (unknown: {reason})" - )), - None => {} - } - } - return Ok(out); - } - let warnings = - write_fastly_local_config_store(&fastly_path, name, &physical_entries, &gc_roots)?; - let mut out = vec![format!( - "wrote {} physical entries ({} logical) to `[local_server.config_stores.{name}.contents]` in {} (logical id `{logical}`); restart `fastly compute serve` to pick up changes", - physical_entries.len(), - entries.len(), - fastly_path.display() - )]; - out.extend(warnings); - Ok(out) - } - - fn read_config_entry( - &self, - _manifest_root: &Path, - _adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - _push_ctx: &AdapterPushContext<'_>, - ) -> Result { - // Shell out to `fastly config-store-entry describe - // --store-id= --key= --json`, resolve the store id on - // demand via `fastly config-store list --json`, then parse the - // JSON response. - let name = store.platform.as_str(); - // A TYPED absence: `Ok(None)` (list succeeded, no store matched) is the - // only path to MissingStore. Any operational failure stays `Err` and fails - // closed -- an incomplete read must never read as absence and authorise an - // overwrite of healthy remote state. - let Some(store_id) = resolve_remote_config_store_id(name)? else { - return Ok(ReadConfigEntry::MissingStore); - }; - let store_arg = format!("--store-id={store_id}"); - let key_arg = format!("--key={key}"); - let output = Command::new("fastly") - .args([ - "config-store-entry", - "describe", - store_arg.as_str(), - key_arg.as_str(), - "--json", - ]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - if output.status.success() { - let stdout = strict_stdout(output.stdout, "config-store-entry describe --json")?; - // Parse the JSON and extract the `item_value` field. - let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|_err| { - format!( - "failed to parse `fastly config-store-entry describe` JSON (parse error \ - redacted; response: {})", - redact_describe_response(&stdout) - ) - })?; - let value = parsed - .get("item_value") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "`fastly config-store-entry describe` JSON has no string `item_value` field; \ - fastly CLI may have changed its output schema. (response: {})", - redact_describe_response(&stdout) - ) - })?; - // Resolve chunk pointers: if `value` is a direct BlobEnvelope it - // passes through unchanged; if it is a chunk pointer the chunks - // are fetched from the same store and reconstructed. - // - // A chunk describe that fails could not be FULLY read. Confirm whether - // the chunk is genuinely ABSENT against the complete store listing - // (authoritative), never the describe 404: - // - CONFIRMED absent → resolve to a repairable `Corrupt`. The blob - // spec makes persistent chunk loss repairable by re-pushing, so a - // push can overwrite to fix it. - // - present-but-unreadable, or the listing itself failed → - // `fetch_failed`: an incomplete read that must be a HARD error, - // never an overwritable value. - let store_keys: RefCell, String>>> = RefCell::new(None); - let fetch_failed: Cell = Cell::new(false); - let resolved = resolve_fastly_config_value_typed(key, value.to_owned(), |chunk_key| { - match fetch_remote_config_store_entry(&store_id, chunk_key) { - Ok(found) => Ok(Some(found)), - Err(_describe_err) => { - match confirm_key_absent_cached(&store_keys, &store_id, chunk_key) { - Ok(true) => Ok(None), // genuinely gone → repairable Corrupt - Ok(false) => { - fetch_failed.set(true); - Err("a referenced chunk is present in the store but its value \ - could not be read (incomplete read)" - .to_owned()) - } - Err(list_err) => { - fetch_failed.set(true); - Err(list_err) - } - } - } - } - }); - return classify_resolved_read(resolved, value, fetch_failed.get()); - } - // The describe failed. Absence is CONFIRMED only by a complete listing - // that omits the key -- never by a describe 404, which a proxy/endpoint or - // auth failure produces just the same. A present key (or a listing that - // itself fails) is a hard error, so two such incomplete reads can never - // pass the pre-write recheck and authorise an overwrite. - if confirm_entry_absent(&store_id, key)? { - return Ok(ReadConfigEntry::MissingKey); - } - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!( - "`fastly config-store-entry describe --store-id={store_id} --key={key} --json` exited \ - with status {} but the key IS present in the store listing (an operational failure, \ - not absence); nothing was changed.\nstderr: {}", - output.status, - redact_stderr(&stderr) - )) - } - - fn read_config_entry_local( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - _push_ctx: &AdapterPushContext<'_>, - ) -> Result { - // Read from `[local_server.config_stores..contents]` - // in fastly.toml — the same section `push_config_entries_local` writes. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.fastly.adapter].manifest must point at fastly.toml for config diff --local" - .to_owned(), - ); - }; - let fastly_path = manifest_root.join(rel); - let name = store.platform.as_str(); - // A prior-state read failure must never BLOCK the command: the diff just - // cannot be computed, so it degrades to `Unsupported` ("cannot diff"). - // Downstream, a dry-run then reaches the writer's orphan-count - // degradation (spec 12.x) and a real push reaches the writer, which - // fails fatally on malformed TOML or overwrites otherwise. Erroring here - // would newly fail a dry-run that reads nothing today. - let raw = match fs::read_to_string(&fastly_path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => { - return Ok(ReadConfigEntry::MissingStore); - } - Err(_err) => { - return Ok(ReadConfigEntry::Unsupported( - "local fastly.toml could not be read; cannot diff the prior value", - )); - } - }; - let Ok(doc) = raw.parse::() else { - return Ok(ReadConfigEntry::Unsupported( - "local fastly.toml is not valid TOML; cannot diff the prior value", - )); - }; - // Descend `[local_server.config_stores..contents]` level by level. - // At each level an ABSENT key means the store isn't seeded yet - // (MissingStore), but a key that is PRESENT yet not a table is malformed - // store state — distinct outcomes. Collapsing the malformed case into - // MissingStore (as a plain `.get().and_then()` chain does) would render an - // inaccurate "all values added" diff, so it degrades to "cannot diff". - // - // `descend` returns Ok(None) for absent (-> MissingStore) and - // Err(Unsupported) for present-but-not-a-table. - let descend = |parent: &'_ toml_edit::Item, - child: &str| - -> Result, ReadConfigEntry> { - match parent.get(child) { - None => Ok(None), - Some(item) if item.is_table_like() => Ok(Some(item.clone())), - Some(_) => Err(ReadConfigEntry::Unsupported( - "a local config-store parent table is not a table; cannot diff the prior value", - )), - } - }; - let root_item = toml_edit::Item::Table(doc.as_table().clone()); - let contents_item = (|| { - let Some(local_server) = descend(&root_item, "local_server")? else { - return Ok(None); - }; - let Some(config_stores) = descend(&local_server, "config_stores")? else { - return Ok(None); - }; - let Some(store_tbl) = descend(&config_stores, name)? else { - return Ok(None); - }; - descend(&store_tbl, "contents") - })(); - let contents = match contents_item { - Ok(Some(item)) => item, - Ok(None) => return Ok(ReadConfigEntry::MissingStore), - Err(unsupported) => return Ok(unsupported), - }; - // `contents` MUST be a table of `key = "value"` pairs. (Guaranteed by - // `descend` above, but re-borrow as a table to index it.) - let Some(contents_tbl) = contents.as_table_like() else { - return Ok(ReadConfigEntry::Unsupported( - "local config-store `contents` is not a table; cannot diff the prior value", - )); - }; - // The contents table is `key = "value"` pairs. - match contents_tbl.get(key) { - Some(item) => { - let Some(value) = item.as_str() else { - return Ok(ReadConfigEntry::Unsupported( - "the local prior value is not a string; cannot diff the prior value", - )); - }; - // Resolve chunk pointers using the same toml contents table. - let resolved = - resolve_fastly_config_value_typed(key, value.to_owned(), |chunk_key| { - match contents_tbl.get(chunk_key) { - Some(chunk_item) => { - let chunk_val = chunk_item.as_str().ok_or_else(|| { - format!( - "chunk key `{chunk_key}` in {} is not a string", - fastly_path.display() - ) - })?; - Ok(Some(chunk_val.to_owned())) - } - None => Ok(None), - } - }); - // Same taxonomy as the cloud read, so recovery is uniform across - // targets: a valid envelope is `Present`; a non-envelope or - // corrupt/incomplete value is `Corrupt` (the local writer's - // fail-soft then overwrites it); an unknown/future kind is a hard - // error (do not clobber a newer format). There is no - // infrastructure fetch here -- the chunks are read from the local - // TOML table -- so `fetch_failed` is always false. - classify_resolved_read(resolved, value, false) - } - None => Ok(ReadConfigEntry::MissingKey), - } - } - - fn single_store_kinds(&self) -> &'static [&'static str] { - // Explicit `&[]` rather than inheriting the trait default, - // so the "Multi for every store kind" intent is documented - // at the call site. Fastly KV / Config / Secrets all - // support multiple distinct platform resources per kind, - // unlike spin's flat-namespace single-store model. - &[] - } -} - -impl ManifestLock { - fn acquire(manifest_path: &Path) -> Result { - // Key the lock on the REAL target, so a symlinked manifest and a direct - // path to the same file acquire the SAME lock rather than two different - // sidecars. Every manifest writer (config push AND provision) takes this - // lock, so their read-modify-writes serialise instead of clobbering. - let target = canonical_manifest_target(manifest_path)?; - // A hard-linked manifest cannot be safely replaced: two hard links share - // one inode but have distinct pathnames, so they key DIFFERENT sidecar - // locks (no mutual exclusion), and the atomic rename swaps in a NEW inode, - // breaking the link. We cannot detect the other names, so fail closed - // rather than silently diverge or break the link. - reject_hard_linked_manifest(&target)?; - let dir = target.parent().unwrap_or_else(|| Path::new(".")); - let file_name = target - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("fastly.toml"); - let lock_path = dir.join(format!(".{file_name}.edgezero-lock")); - let file = fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) - .truncate(false) - .open(&lock_path) - .map_err(|err| format!("failed to open lock file {}: {err}", lock_path.display()))?; - // Blocks until any other writer holding the lock releases it. - file.lock() - .map_err(|err| format!("failed to lock {}: {err}", lock_path.display()))?; - // Re-check AFTER the (possibly long) lock wait: a hard link created while - // we blocked would not have been visible to the pre-lock check above. The - // replacement path re-checks once more immediately before the rename. - reject_hard_linked_manifest(&target)?; - Ok(Self { - _file: file, - target, - }) - } - - /// The real file this lock guards. Callers read and replace THIS path. - fn target(&self) -> &Path { - &self.target - } -} - -impl TempFileGuard { - fn disarm(&mut self) { - self.path = None; - } -} - -impl Drop for TempFileGuard { - fn drop(&mut self) { - if let Some(path) = &self.path { - let _cleanup = fs::remove_file(path); - } - } -} - -/// Resolve a manifest path to the REAL file every alias shares, so a symlink and -/// a direct path lock and replace the SAME target. An existing file (or symlink) -/// canonicalizes directly; a not-yet-created file canonicalizes via its parent -/// so a fresh `fastly.toml` still keys on a stable location. -/// -/// FAILS CLOSED on an ambiguous chain: a symlink whose target cannot be read, or -/// a chain too deep / cyclic, returns `Err` rather than falling back to a writable -/// path that could replace an intermediate link. -fn canonical_manifest_target(path: &Path) -> Result { - // Follow the WHOLE symlink chain to the final target -- each hop may itself be - // a dangling symlink (fastly.toml -> middle.toml -> missing.toml). We write at - // the final target, preserving every intermediate link, and a direct writer to - // that same target keys on the same lock. - let mut current = path.to_owned(); - // Bounded to avoid spinning on a symlink cycle (canonicalize would ELOOP). - for _ in 0..40_u32 { - // Fully resolvable => the real existing file. - if let Ok(real) = fs::canonicalize(¤t) { - return Ok(real); - } - // Otherwise, if this hop is a symlink, follow one link and continue. - match fs::symlink_metadata(¤t) { - Ok(meta) if meta.file_type().is_symlink() => match fs::read_link(¤t) { - Ok(link) => { - current = if link.is_absolute() { - link - } else { - // A relative link resolves against the DIRECTORY holding it. - current - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(link) - }; - } - // A symlink we cannot read: refuse rather than guess a target. - Err(err) => { - return Err(format!( - "could not read the manifest symlink `{}` ({err}); refusing to write", - current.display() - )); - } - }, - // Not a symlink -- a plain not-yet-created file, or the final dangling - // target: this is where the write should land. - _ => return Ok(canonicalize_parent_join(¤t)), - } - } - // Exhausted the hop budget: a cyclic or absurdly deep chain. Fail closed. - Err(format!( - "the manifest symlink chain starting at `{}` is too deep or cyclic; refusing to write", - path.display() - )) -} - -/// Canonicalize `path`'s PARENT (which should exist) and rejoin the file name, -/// so a not-yet-created file still resolves to a stable absolute location. -fn canonicalize_parent_join(path: &Path) -> PathBuf { - let parent = match path.parent() { - Some(parent) if !parent.as_os_str().is_empty() => parent, - _ => Path::new("."), - }; - let file_name = path.file_name().unwrap_or(path.as_os_str()); - match fs::canonicalize(parent) { - Ok(real_parent) => real_parent.join(file_name), - Err(_) => path.to_owned(), - } -} - -/// Refuse to operate on a manifest that has MORE THAN ONE hard link. Such a file -/// cannot be replaced safely: the atomic rename installs a new inode (breaking -/// the link), and the path-based lock cannot serialise writers arriving via the -/// other names. Fail closed with a fix. A not-yet-created file, or a filesystem -/// that does not report a link count, is left alone. -/// -/// The link count is read via the platform `MetadataExt` -- `nlink()` on Unix, -/// `number_of_links()` on Windows (both stable, no extra deps) -- so Windows -/// hard-link aliases are caught too, not just Unix ones. On any other target the -/// count is unknown and the file is left alone. -fn reject_hard_linked_manifest(target: &Path) -> Result<(), String> { - #[cfg(unix)] - let link_count: Option = { - use std::os::unix::fs::MetadataExt as _; - fs::metadata(target).ok().map(|meta| meta.nlink()) - }; - #[cfg(windows)] - let link_count: Option = { - use std::os::windows::fs::MetadataExt as _; - fs::metadata(target) - .ok() - .and_then(|meta| meta.number_of_links()) - .map(u64::from) - }; - #[cfg(not(any(unix, windows)))] - let link_count: Option = None; - - if let Some(count) = link_count - && count > 1 - { - return Err(format!( - "{} has multiple hard links (link count {count}); refusing to replace it -- an atomic \ - rename would break the link and concurrent writers via the other names could \ - diverge. Remove the extra hard link(s), or use a symlink instead.", - target.display(), - )); - } - Ok(()) -} - -/// Fetch a single entry value from a remote Fastly Config Store entry by -/// key, using `fastly config-store-entry describe --store-id= --key= -/// --json`. Used by the chunk-pointer resolver to fan out to chunk entries. -/// -/// `Ok(value)` when the entry exists; `Err` on ANY failure, INCLUDING a -/// not-found. Absence is NOT decided here (a describe 404 is not proof) -- the -/// caller confirms it against the complete store listing. -/// -/// # Errors -/// Returns an error if `fastly` isn't on `PATH`, spawning fails, the JSON -/// cannot be parsed, or the CLI exits with a non-zero status (not-found included). -fn fetch_remote_config_store_entry(store_id: &str, key: &str) -> Result { - let store_arg = format!("--store-id={store_id}"); - let key_arg = format!("--key={key}"); - let output = Command::new("fastly") - .args([ - "config-store-entry", - "describe", - store_arg.as_str(), - key_arg.as_str(), - "--json", - ]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - if output.status.success() { - let stdout = strict_stdout(output.stdout, "config-store-entry describe --json")?; - let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|_err| { - format!( - "failed to parse `fastly config-store-entry describe` JSON for key \ - `{key}` (parse error redacted; response: {})", - redact_describe_response(&stdout) - ) - })?; - let value = parsed - .get("item_value") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "`fastly config-store-entry describe` JSON has no string `item_value` \ - field for key `{key}`; fastly CLI may have changed its output schema. \ - (response: {})", - redact_describe_response(&stdout) - ) - })?; - return Ok(value.to_owned()); - } - // `Err` on ANY non-success, INCLUDING a not-found. A describe 404 alone is not - // proof of absence -- a proxy/endpoint 404, an auth 404, or a gateway error - // all look the same -- so the caller CONFIRMS a genuine absence against the - // complete store listing rather than trusting this stderr. - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!( - "`fastly config-store-entry describe --store-id={store_id} --key={key} --json` \ - exited with status {}\nstderr: {}", - output.status, - redact_stderr(&stderr) - )) -} - -/// The COMPLETE set of item keys in a store, via `config-store-entry list`. -/// -/// Absence is CONFIRMED against this, never against a describe 404: the listing -/// is completeness-strict (fails closed on a paginated / non-bare-array view and -/// on a duplicate key), so a key's absence from it is authoritative. Tolerant of -/// empty item VALUES -- only keys are needed to confirm presence. -fn list_config_store_keys(store_id: &str) -> Result, String> { - let store_arg = format!("--store-id={store_id}"); - let output = Command::new("fastly") - .args(["config-store-entry", "list", store_arg.as_str(), "--json"]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "`fastly config-store-entry list --store-id={store_id} --json` exited with status {}\nstderr: {}", - output.status, - redact_stderr(&stderr) - )); - } - let stdout = strict_stdout(output.stdout, "config-store-entry list --json")?; - let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|_err| { - format!( - "failed to parse `fastly config-store-entry list` JSON (parse error redacted; \ - response: {})", - redact_describe_response(&stdout) - ) - })?; - let array = parsed.as_array().ok_or_else(|| { - format!( - "refusing to confirm absence: `fastly config-store-entry list --json` did not return a \ - bare array (response: {}). A paginated or partial view could hide a present key and \ - turn it into a false absence that authorises an overwrite.", - redact_describe_response(&stdout) - ) - })?; - let mut keys = HashSet::with_capacity(array.len()); - for (idx, entry) in array.iter().enumerate() { - let key = entry - .get("item_key") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "`fastly config-store-entry list` entry #{idx} is missing a string `item_key`; \ - refusing to confirm absence on an unreadable listing" - ) - })?; - if key.is_empty() { - return Err(format!( - "`fastly config-store-entry list` entry #{idx} has an empty `item_key`; refusing \ - to confirm absence on an unreadable listing" - )); - } - if !keys.insert(key.to_owned()) { - return Err(format!( - "`fastly config-store-entry list` returned duplicate key `{key}`; refusing to \ - confirm absence on an ambiguous listing" - )); - } - } - Ok(keys) -} - -/// Confirm `key` is ABSENT from the store via a complete listing (authoritative). -/// `Ok(true)` = the listing succeeded and omits the key. `Ok(false)` = the key IS -/// present (so a describe failure on it was operational, not absence). `Err` = the -/// listing itself failed. All three fail closed for the caller: only `Ok(true)` -/// is a genuine absence. -fn confirm_entry_absent(store_id: &str, key: &str) -> Result { - Ok(!list_config_store_keys(store_id)?.contains(key)) -} - -/// Cached form of [`confirm_entry_absent`] for chunk fetches: lists the store at -/// most ONCE per read (a whole lost generation would otherwise list per chunk). -fn confirm_key_absent_cached( - cache: &RefCell, String>>>, - store_id: &str, - key: &str, -) -> Result { - let mut slot = cache.borrow_mut(); - if slot.is_none() { - *slot = Some(list_config_store_keys(store_id)); - } - match slot.as_ref() { - Some(Ok(keys)) => Ok(!keys.contains(key)), - Some(Err(err)) => Err(err.clone()), - // Unreachable: populated just above. Fail closed rather than unwrap. - None => Err("internal error: store listing cache was not populated".to_owned()), - } -} - -/// Convert `fastly` stdout to a `String`, FAILING CLOSED on invalid UTF-8 rather -/// than substituting U+FFFD. A lossy replacement inside a JSON string could -/// mutate a stored root value or chunk and yield parseable-but-WRONG data on a -/// path that drives an overwrite or a deletion, violating the exact-read -/// invariant. Diagnostics only ever see redacted output, so stderr stays lossy. -fn strict_stdout(stdout: Vec, command: &str) -> Result { - String::from_utf8(stdout).map_err(|_err| { - format!( - "`fastly {command}` returned non-UTF-8 output; refusing to act on it -- a lossy \ - conversion could mutate a stored value. Nothing was changed." - ) - }) -} - -/// Does `body` parse AND integrity-verify as a `BlobEnvelope`? -/// -/// The typed-config key must hold a valid envelope. A resolved chunk pointer -/// already reconstructs and verifies one; a DIRECT or foreign value is checked -/// here. A value that is not a verifying envelope (invalid JSON, missing fields, -/// or a SHA mismatch) is corrupt FOR THE PUSH -- something to overwrite, not to -/// diff against. -fn body_is_valid_envelope(body: &str) -> bool { - use edgezero_core::blob_envelope::BlobEnvelope; - serde_json::from_str::(body).is_ok_and(|envelope| envelope.verify().is_ok()) -} - -/// Map a `resolve_fastly_config_value` result to a read outcome, distinguishing -/// the cases that must NOT be treated as overwritable corruption: -/// -/// - a FUTURE format (unknown/newer `edgezero_kind`, or a bumped envelope/pointer -/// `version`) → a hard error: overwriting a newer format with this v1 CLI would -/// lose it. Checked FIRST. Detected two ways: on the raw stored value (a direct -/// future envelope, or a future pointer version), AND via a typed -/// [`ResolveFailure::FutureFormat`] from the resolver -- the ONLY signal for a -/// newer INNER envelope reassembled from v1 chunks, which the raw value alone -/// cannot reveal. -/// - a resolve error where a chunk FETCH failed for infrastructure reasons -/// (`fetch_failed`) → a hard error: the read was incomplete, so a push must not -/// overwrite healthy remote state. -/// - `Ok(body)` that verifies as an envelope → `Present`. -/// - `Ok(body)` that is NOT a valid envelope (a malformed direct value, a SHA -/// mismatch, a foreign non-envelope) → `Corrupt` (repairable by overwrite). -/// - any other resolve error (bad/missing chunk, malformed pointer) → `Corrupt`. -fn classify_resolved_read( - resolved: Result, - raw_value: &str, - fetch_failed: bool, -) -> Result { - // A newer format is refused BEFORE anything else: on the raw value (direct - // future envelope or future pointer version) OR when the resolver typed the - // failure as a newer format (a future inner envelope only knowable after the - // chunks are reassembled). Overwriting a newer format with this v1 CLI would - // lose it. - if value_is_future_format(raw_value) - || resolved - .as_ref() - .err() - .is_some_and(ResolveFailure::is_future_format) - { - return Err(FUTURE_FORMAT_READ_ERROR.to_owned()); - } - match resolved { - // An INFRASTRUCTURE fetch failure: the read was incomplete, so a push must - // not overwrite. The resolver's message is already redacted (it names only - // a chunk POSITION, never a value), so surface it for diagnostics. - Err(err) if fetch_failed => Err(format!( - "a chunk fetch failed while reading the remote value ({}); the remote was not fully \ - read, so nothing was changed. Fix connectivity/auth and retry.", - err.into_message() - )), - Ok(body) if body_is_valid_envelope(&body) => Ok(ReadConfigEntry::Present(body)), - Ok(_) => Ok(ReadConfigEntry::Corrupt( - "remote value is not a valid config envelope; a push will overwrite it", - )), - // A confirmed-absent chunk, a hash mismatch, or a malformed pointer: the - // value was fully read and is provably unusable, so a push repairs it. - Err(_) => Ok(ReadConfigEntry::Corrupt( - "remote prior value could not be resolved (corrupt or incomplete chunk state); a push \ - will overwrite it", - )), - } -} - -/// Shell out to `fastly -store create --name=`. The -/// caller resolves `` from `EDGEZERO__STORES______NAME` -/// (falling back to the logical id), so this helper takes whatever the -/// caller hands it and does not re-translate. Returns `Ok(())` on success; -/// surfaces the CLI's stderr verbatim on failure (including the "already -/// exists" error, which is the caller's signal to fix the toml or use a -/// different name). -/// -/// # Errors -/// Returns an error if `fastly` isn't on `PATH`, the child fails to -/// spawn, or the exit status is non-zero. -fn create_fastly_store(kind: &str, name: &str) -> Result<(), String> { - let subcommand = format!("{kind}-store"); - let name_arg = format!("--name={name}"); - let output = Command::new("fastly") - .args([subcommand.as_str(), "create", name_arg.as_str()]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - if output.status.success() { - return Ok(()); - } - // Idempotency: the fastly CLI returns non-zero with an - // "already exists" message when a store of this name was - // created by a prior provision run. Treat that as success so - // the operator's recovery path -- "either manually append the - // setup block or delete the remote and re-run provision" -- - // doesn't get blocked. The append step is itself idempotent, - // so re-running provision after a writeback failure is the - // documented recovery and now actually works. - let stderr = String::from_utf8_lossy(&output.stderr); - if looks_like_already_exists(&stderr, kind) { - return Ok(()); - } - Err(format!( - "`fastly {subcommand} create --name={name}` exited with status {}\nstderr: {}", - output.status, - stderr.trim() - )) -} - -/// Heuristic: does the stderr blob look like a "store of this -/// kind, by this name, already exists" failure from the fastly -/// CLI? Different CLI versions phrase this slightly differently -/// ("a kv-store with that name already exists", -/// `"Conflict: duplicate kv_store name"`, etc.); we require BOTH -/// a conflict-signal keyword AND a store-kind reference so an -/// unrelated 409 ("Error: 409 Conflict on /service/...") cannot -/// be misread as idempotent success. The earlier wider heuristic -/// would have swallowed any stderr containing the word -/// "conflict" and let provision march on to writeback against a -/// nonexistent store, surfacing as a confusing deploy-time error. -fn looks_like_already_exists(stderr: &str, kind: &str) -> bool { - let lower = stderr.to_ascii_lowercase(); - let conflict_signal = lower.contains("already exists") - || (lower.contains("duplicate") && lower.contains("name")) - || lower.contains("conflict"); - if !conflict_signal { - return false; - } - // Accept the three common spellings of `-store` / - // `_store` / ` store` so a fastly CLI version - // bump that reshuffles punctuation still hits. - let dashed = format!("{kind}-store"); - let underscored = format!("{kind}_store"); - let spaced = format!("{kind} store"); - lower.contains(&dashed) || lower.contains(&underscored) || lower.contains(&spaced) -} - -/// Read the top-level `service_id` from `fastly.toml`. Returns -/// `Ok(None)` when the file is absent (scaffold state before first -/// `fastly compute deploy`) or when `service_id` is missing / -/// empty. Used by `provision` to detect when an already-deployed -/// service needs a separate resource-link step beyond `[setup]` -/// (which `compute deploy` only consumes on the FIRST deploy). -fn read_fastly_service_id(path: &Path) -> Result, String> { - let raw = match fs::read_to_string(path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(format!("failed to read {}: {err}", path.display())), - }; - let doc: toml_edit::DocumentMut = raw.parse().map_err(|_err| { - format!( - "failed to parse {} as TOML (details redacted: the error can quote a stored value)", - path.display() - ) - })?; - let svc = doc - .get("service_id") - .and_then(|item| item.as_str()) - .map(str::to_owned) - .filter(|svc_id| !svc_id.is_empty()); - Ok(svc) -} - -/// If fastly.toml declares `service_id`, the next -/// `fastly compute deploy` skips `[setup]` entirely (it only runs on -/// the FIRST deploy of a service). Any store created by provision -/// after that needs a separate `fastly resource-link create` to link -/// the platform store to the service version. This helper returns the -/// remediation note to surface in the provision output, or `None` -/// when the service hasn't been deployed yet (so the next -/// `compute deploy` will pick up the `[setup]` row automatically). -fn resource_link_note(path: &Path, kind: &str, name: &str) -> Result, String> { - let note = read_fastly_service_id(path)?.map(|svc_id| { - format!( - " fastly.toml declares `service_id = \"{svc_id}\"`, so this service is already deployed -- `[setup]` will NOT be re-run on the next `fastly compute deploy`. The store exists in the account but is NOT yet linked to the service. To finish provisioning, look up the store id with `fastly {kind}-store list --json` (match by name=`{name}`), then run:\n fastly resource-link create --service-id={svc_id} --resource-id= --version=latest --autoclone --name={name}\n (the link clones the active version so existing traffic is not affected until you `fastly service-version activate`)." - ) - }); - Ok(note) -} - -/// Probe `fastly.toml` for the existence of `[setup._stores.]`. -/// Treats a missing file as "not present" so the first provision call -/// can create it. -/// -/// Why only `[setup]` (no longer `[local_server]`): an empty -/// `[local_server._stores.]` table doesn't satisfy -/// fastly's local-server schema — config-stores need -/// `format = "inline-toml"` + a contents table, kv/secret stores -/// need a JSON `file = "..."` or an array of `{key, data}` entries. -/// Writing an empty table makes `fastly compute serve` skip the -/// declared store or error at startup. `provision`'s job is the -/// remote / `[setup]` half; local-server stanzas are written by -/// `edgezero config push --adapter fastly --local` -/// (config-stores only), and kv/secret local-server seeding is -/// hand-edited until we add equivalent writers for those kinds. -fn setup_block_present(path: &Path, kind: &str, id: &str) -> Result { - let raw = match fs::read_to_string(path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(false), - Err(err) => return Err(format!("failed to read {}: {err}", path.display())), - }; - let doc: toml_edit::DocumentMut = raw.parse().map_err(|_err| { - format!( - "failed to parse {} as TOML (details redacted: the error can quote a stored value)", - path.display() - ) - })?; - let plural = format!("{kind}_stores"); - Ok(doc - .get("setup") - .and_then(|root| root.get(plural.as_str())) - .and_then(|kind_tbl| kind_tbl.get(id)) - .is_some()) -} - -/// Append `[setup._stores.]` to `fastly.toml`. Creates -/// the file (and the parent `[setup]` table) if absent. The block -/// is written as an empty table — that's what -/// `fastly compute deploy` consumes the first time it creates a -/// service: the resource-link declaration is enough, and the -/// account-level resource itself is already created in the -/// preceding `create_fastly_store` shellout. -/// -/// We DON'T write `[local_server._stores.]` here: see -/// `setup_block_present`'s doc for the schema rationale. The local- -/// server seeding moved to `config push --local` (config-stores -/// only), so provision only owns the remote / setup half. -fn append_fastly_setup(path: &Path, kind: &str, id: &str) -> Result<(), String> { - use toml_edit::{DocumentMut, Item, table}; - - // Provision writes the SAME manifest as `config push --local`; take the same - // lock so a concurrent provision and push serialise instead of clobbering - // each other's edit, and operate on the real target the lock resolved. - let lock = ManifestLock::acquire(path)?; - let target = lock.target(); - - let raw = match fs::read_to_string(target) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => String::new(), - Err(err) => return Err(format!("failed to read {}: {err}", target.display())), - }; - let mut doc: DocumentMut = raw.parse().map_err(|_err| { - format!( - "failed to parse {} as TOML (details redacted: the error can quote a stored value)", - target.display() - ) - })?; - - let plural = format!("{kind}_stores"); - let parent_entry = doc.entry("setup").or_insert_with(table); - let parent_tbl = parent_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `setup` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - let kind_entry = parent_tbl - .entry(plural.as_str()) - .or_insert_with(|| Item::Table(toml_edit::Table::new())); - let kind_tbl = kind_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `setup.{plural}` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - if !kind_tbl.contains_key(id) { - kind_tbl.insert(id, Item::Table(toml_edit::Table::new())); - } - - atomically_replace_file(target, &raw, &doc.to_string())?; - Ok(()) -} - -/// Write the local-server config-store entries to `fastly.toml`: -/// `[local_server.config_stores.]` becomes -/// `format = "inline-toml"`, and `[local_server.config_stores..contents]` -/// gets the flat `key = "value"` pairs (overwriting any previous -/// values). Idempotent — re-running just rewrites `contents`. Other -/// blocks in `fastly.toml` (setup, scripts, the actual `[local_server]` -/// secret stores, etc.) are preserved via `toml_edit`. -/// Refuse before writing if any GENERATED chunk key would clobber an existing -/// value that is itself ROOT-LIKE (announces our `edgezero_kind`, is a newer -/// format, or classifies as a valid root) or that has a NESTED generation beneath -/// it. Chunk keys are content-addressed, so such a collision is pathological, but -/// overwriting one would destroy live or foreign config -- so fail closed. -/// -/// Logical ROOT keys are excluded here; overwriting a root is governed by the -/// downgrade/future guards. `sibling_keys` is the complete set of existing store -/// keys (for the nested-generation check); `existing_value_at` fetches the value -/// at a colliding key (only called for keys already present). -fn reject_generated_key_collisions( - entries: &[(String, String)], - sibling_keys: &HashSet, - mut existing_value_at: impl FnMut(&str) -> Result, String>, -) -> Result<(), String> { - for (key, _) in entries { - if !key.contains(CHUNK_KEY_INFIX) { - continue; // a logical root; the root-overwrite guards cover it - } - let has_nested_generation = sibling_keys - .iter() - .any(|other| other != key && chunk_key_generation(key, other).is_some()); - let clobbers_root_like = sibling_keys.contains(key) - && existing_value_at(key)?.is_some_and(|value| { - value_announces_our_kind(&value) - || value_is_future_format(&value) - || gc_classify_root(key, &value).is_ok() - }); - if has_nested_generation || clobbers_root_like { - return Err(format!( - "refusing to push: the generated chunk key `{key}` already holds a value that is \ - itself a root (or has a nested generation beneath it); overwriting it could \ - destroy live or foreign config. Nothing was changed." - )); - } - } - Ok(()) -} - -/// [`reject_generated_key_collisions`] against a local `contents` table. -fn reject_local_generated_key_collisions( - contents_tbl: &toml_edit::Table, - entries: &[(String, String)], -) -> Result<(), String> { - let sibling_keys: HashSet = contents_tbl - .iter() - .map(|(existing_key, _)| existing_key.to_owned()) - .collect(); - reject_generated_key_collisions(entries, &sibling_keys, |chunk_key| { - Ok(contents_tbl - .get(chunk_key) - .and_then(toml_edit::Item::as_str) - .map(str::to_owned)) - }) -} - -/// Ensure a local config-store entry is `format = "inline-toml"` -- the only -/// format compatible with the inline `contents` this writer emits. -/// -/// REFUSES an existing non-inline store rather than converting it. A -/// `format = "json"` / `"file"` store points at an EXTERNAL file that this writer -/// cannot safely rewrite: leaving `file` in place produces a manifest Viceroy -/// rejects ("unrecognized key 'file'"), and removing it would silently discard -/// the sibling entries that file holds (this writer only inserts the pushed -/// root). Migration is the operator's explicit choice, not a silent side effect. -fn ensure_inline_toml_format( - store_tbl: &mut toml_edit::Table, - platform_name: &str, -) -> Result<(), String> { - let existing = store_tbl.get("format").and_then(toml_edit::Item::as_str); - match existing { - Some("inline-toml") => Ok(()), - Some(other) => Err(format!( - "refusing to push: `local_server.config_stores.{platform_name}` uses `format = \ - \"{other}\"` (an external-file store), which is incompatible with the inline \ - `contents` this command writes. Converting it here would either produce a manifest \ - the local server rejects or silently discard the sibling entries the external file \ - holds. Migrate the store to `format = \"inline-toml\"` (or a fresh store id) yourself, \ - then re-run. Nothing was changed." - )), - None => { - // A brand-new or format-less entry: this writer owns it, so stamp the - // inline format it is about to fill. - store_tbl.insert("format", toml_edit::value("inline-toml")); - Ok(()) - } - } -} - -/// TOCTOU guard for the LOCAL writer: refuse to overwrite a root that now holds a -/// NEWER format, classified HERE under the write lock. The generic push's -/// pre-push future-format check ran BEFORE the lock, so a newer writer could have -/// installed a v2 value in between; without this the old writer would clobber it. -/// -/// The raw value alone does not reveal a future INNER envelope hidden behind a -/// valid v1 pointer -- that is only knowable after reconstruction. So each root -/// that is one of our pointers is RESOLVED against the locked `contents` table -/// (its chunks live there too); a typed `FutureFormat` from the resolver is -/// refused just like a raw future value. -fn reject_future_local_roots( - contents_tbl: &toml_edit::Table, - gc_roots: &[(String, HashSet)], -) -> Result<(), String> { - for (root_key, _) in gc_roots { - let Some(existing) = contents_tbl.get(root_key).and_then(toml_edit::Item::as_str) else { - continue; - }; - // Raw check: a direct future envelope, a future pointer version, or an - // unknown `edgezero_kind`. - let mut is_future = value_is_future_format(existing); - if !is_future { - // Resolve against the locked contents to catch a future INNER envelope - // behind a valid v1 pointer. Only `FutureFormat` blocks the write; a - // corrupt/incomplete v1 prior stays overwritable. - let resolved = resolve_fastly_config_value_typed(root_key, existing.to_owned(), |ck| { - Ok(contents_tbl - .get(ck) - .and_then(toml_edit::Item::as_str) - .map(str::to_owned)) - }); - is_future = matches!(resolved, Err(err) if err.is_future_format()); - } - if is_future { - return Err(format!( - "refusing to overwrite `{root_key}`: the local store now holds a value in a newer \ - format this CLI does not recognise (installed since the pre-push check). Upgrade \ - the CLI rather than clobber a newer format. Nothing was changed." - )); - } - } - Ok(()) -} - -fn write_fastly_local_config_store( - path: &Path, - platform_name: &str, - entries: &[(String, String)], - gc_roots: &[(String, HashSet)], -) -> Result, String> { - use toml_edit::{DocumentMut, Item, Table, Value, table}; - - // Hold a cross-process advisory lock for the WHOLE read-modify-write. Two - // concurrent local pushes would otherwise both read the file, each apply - // their own edit, and the later rename would discard the earlier push's - // change. Serialising here makes each push read what the previous one wrote - // and build on it, so both edits survive. Released when `_lock` drops. - let lock = ManifestLock::acquire(path)?; - // Read and replace the REAL target the lock guards, so a symlinked manifest - // and a direct path never diverge between the read, the compare, and the - // rename. - let target = lock.target(); - - let raw = match fs::read_to_string(target) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => String::new(), - Err(err) => return Err(format!("failed to read {}: {err}", target.display())), - }; - // Redacted: `toml_edit`'s parse error quotes the offending source LINE, which - // in a config-store `contents` table is a stored (possibly secret-bearing) - // value. The diff read redacts the same failure; the writer must too. - let mut doc: DocumentMut = raw.parse().map_err(|_err| { - format!( - "failed to parse {} as TOML (details redacted: the error can quote a stored value)", - target.display() - ) - })?; - - let local_server_entry = doc.entry("local_server").or_insert_with(table); - let local_server_tbl = local_server_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `local_server` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - let config_stores_entry = local_server_tbl - .entry("config_stores") - .or_insert_with(|| Item::Table(Table::new())); - let config_stores_tbl = config_stores_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `local_server.config_stores` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - - // Upsert into the existing per-store contents table so a - // `config push --key app_config_staging` does NOT wipe the - // previously-pushed `app_config` blob. Spec 12.7 requires - // default + staging keys to coexist so the runtime - // EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY env var can - // switch between them. (Earlier wholesale-replace was a - // misread of the "stale entries don't linger" property: - // that applies WITHIN a key (old chunks for the same root - // become unreferenced when a new chunk-set installs a new - // pointer), NOT across sibling keys.) - let store_entry = config_stores_tbl.entry(platform_name).or_insert_with(|| { - let mut tbl = Table::new(); - tbl.insert("format", toml_edit::value("inline-toml")); - tbl.insert("contents", Item::Table(Table::new())); - Item::Table(tbl) - }); - let store_tbl = store_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `local_server.config_stores.{platform_name}` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - ensure_inline_toml_format(store_tbl, platform_name)?; - let contents_entry = store_tbl - .entry("contents") - .or_insert_with(|| Item::Table(Table::new())); - let contents_tbl = contents_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `local_server.config_stores.{platform_name}.contents` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - reject_future_local_roots(contents_tbl, gc_roots)?; - reject_local_generated_key_collisions(contents_tbl, entries)?; - // Snapshot prior chunk keys per GC root BEFORE the upsert, using the - // exact keep-set the caller computed for each root (no prefix scan). - let mut plans: Vec = Vec::with_capacity(gc_roots.len()); - for (root_key, new_keys) in gc_roots { - let prior_keys = contents_tbl - .get(root_key) - .and_then(toml_edit::Item::as_str) - .map_or_else(|| Ok(Vec::new()), |value| prior_chunk_keys(root_key, value)); - plans.push(FastlyConfigGcPlan { - new_keys: new_keys.clone(), - prior_keys, - }); - } - - // Upsert the new physical entries. - for (key, value) in entries { - contents_tbl.insert(key, Item::Value(Value::from(value.clone()))); - } - - // Prune orphans in the same in-memory rewrite; a suspicious prior - // pointer (Err) warns and deletes nothing. - let mut warnings = Vec::new(); - for plan in &plans { - match orphan_chunk_keys(plan) { - Ok(orphans) => { - for key in orphans { - // Never remove an orphan that is itself protected -- a - // runtime-readable root, a value claiming our `edgezero_kind` - // namespace or written by a NEWER format, or a nested root with - // canonical chunks beneath it (deleting which would orphan that - // nested generation). Only a raw leaf PAYLOAD prunes. Shared - // with the dry-run count via `is_prunable_leaf`, so the preview - // can never disagree with what is removed here. - if !is_prunable_leaf(contents_tbl, &key) { - warnings.push(format!( - "warning: kept `{key}` -- it is a runtime-readable root, claims the \ - `edgezero_kind` namespace, or is a nested root with chunks beneath it; \ - not a prunable chunk payload" - )); - continue; - } - contents_tbl.remove(&key); - } - } - Err(err) => warnings.push(format!("warning: {err}")), - } - } - - atomically_replace_file(target, &raw, &doc.to_string())?; - Ok(warnings) -} - -/// Replace an already-canonical `target`'s contents ATOMICALLY. Callers pass -/// [`ManifestLock::target`] and hold the lock across the surrounding -/// read-modify-write, so this is not racing another writer; the re-read + compare -/// is a defence-in-depth corruption check, not the concurrency guard. -/// -/// In order: -/// -/// 1. Re-read `target` and require it to still hold the bytes this rewrite -/// started from (`expected_before`). A mismatch means something OUTSIDE our -/// writers mutated it, so fail rather than overwrite. -/// 2. Create a FRESH temp file in the target's directory with `create_new` -/// (`O_EXCL`): this never follows a file or symlink someone pre-planted at the -/// temp path, and successive names avoid collisions. The rename stays within -/// one directory so it cannot cross a filesystem boundary. -/// 3. Copy the target's existing permissions onto the temp BEFORE writing, so the -/// config bytes are never briefly readable under wider permissions than the -/// manifest allows, then write, then `rename` over the target. `rename` is -/// atomic on POSIX, so a concurrent reader sees either the old file or the new. -/// -/// A [`TempFileGuard`] removes the temp on any failure after it is created. -fn atomically_replace_file( - target: &Path, - expected_before: &str, - contents: &str, -) -> Result<(), String> { - let current = match fs::read_to_string(target) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => String::new(), - Err(err) => return Err(format!("failed to re-read {}: {err}", target.display())), - }; - if current != expected_before { - return Err(format!( - "{} changed on disk while this write was preparing its rewrite; nothing was written. \ - Re-run to pick up the other change.", - target.display() - )); - } - - let dir = target.parent().unwrap_or_else(|| Path::new(".")); - let file_name = target - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("fastly.toml"); - // Create a staging file that CANNOT be an attacker's pre-planted symlink: - // `create_new` fails if the path already exists (regular file or symlink), so - // we retry successive names until we own a fresh inode. - let mut attempt = 0_u32; - let (tmp_path, mut tmp_file) = loop { - let candidate = dir.join(format!( - ".{file_name}.edgezero-{}-{attempt}.tmp", - process_id() - )); - match fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&candidate) - { - Ok(file) => break (candidate, file), - Err(err) if err.kind() == ErrorKind::AlreadyExists => { - attempt = attempt.saturating_add(1); - if attempt > 1_024 { - return Err(format!( - "could not create a staging temp file next to {}", - target.display() - )); - } - } - Err(err) => return Err(format!("failed to create staging temp file: {err}")), - } - }; - let mut guard = TempFileGuard { - path: Some(tmp_path.clone()), - }; - - // Match the target's permissions BEFORE writing any bytes, so config content - // never lands under wider permissions than the manifest already had. A brand - // NEW manifest (NotFound) keeps the create default -- nothing to preserve -- - // but any OTHER metadata error means the target EXISTS yet we cannot read its - // mode, so we must NOT silently widen: fail rather than guess. - match fs::metadata(target) { - Ok(meta) => tmp_file - .set_permissions(meta.permissions()) - .map_err(|err| format!("failed to set permissions on the staging temp file: {err}"))?, - Err(err) if err.kind() == ErrorKind::NotFound => {} - Err(err) => { - return Err(format!( - "failed to read the permissions of {} (refusing to widen access): {err}", - target.display() - )); - } - } - tmp_file - .write_all(contents.as_bytes()) - .map_err(|err| format!("failed to write the staging temp file: {err}"))?; - // Flush to disk BEFORE the rename. A writeback error (ENOSPC/EIO) must surface - // HERE, while the known-good manifest is still untouched -- NOT be swallowed - // so the command "succeeds" after installing content that never reached disk. - // The guard removes the temp on this error. - tmp_file - .sync_all() - .map_err(|err| format!("failed to flush the staging temp file to disk: {err}"))?; - drop(tmp_file); - - // Re-check the hard-link count IMMEDIATELY before the rename. The lock-acquire - // check ran before this write blocked on the lock, and a hard link created - // during that wait (or since) would survive the byte comparison above only for - // the rename to break the alias. This is the last moment we can fail closed. - reject_hard_linked_manifest(target)?; - - fs::rename(&tmp_path, target) - .map_err(|err| format!("failed to replace {}: {err}", target.display()))?; - guard.disarm(); - // Sync the containing directory so the rename entry itself survives a crash. - // Best-effort: opening a directory as a file is not portable (Windows), and - // the critical durability -- the file's contents -- is already flushed above. - if let Ok(dir_handle) = fs::File::open(dir) { - let _dir_sync = dir_handle.sync_all(); - } - Ok(()) -} - -// ------------------------------------------------------------------- -// chunk GC helpers (Stage 7 re-push reclamation) -// ------------------------------------------------------------------- - -/// Expand ONE logical `(root_key, body)` into its physical entries, the -/// exact keep-set for that root, and the value written at the root key. -/// No cross-root prefix scanning (a free-form `--key` can't mislead it). -#[expect( - clippy::type_complexity, - reason = "one-off internal return; a named type would not aid readability" -)] -fn expand_root( - root_key: &str, - body: &str, -) -> Result<(Vec<(String, String)>, HashSet, String), String> { - let expanded = prepare_fastly_config_entries(root_key, body)?; - let new_keys: HashSet = expanded.iter().map(|(key, _)| key.clone()).collect(); - // prepare_* always emits the root entry LAST (root pointer or direct - // value). Make the invariant explicit rather than silently defaulting. - let new_root_value = expanded - .last() - .map(|(_, value)| value.clone()) - .ok_or_else(|| format!("internal: no physical entries produced for root `{root_key}`"))?; - Ok((expanded, new_keys, new_root_value)) -} - -/// Orphans = prior chunk keys not in the new keep-set. Propagates a -/// suspicious-pointer `Err` so the caller can warn and skip GC. -fn orphan_chunk_keys(plan: &FastlyConfigGcPlan) -> Result, String> { - match &plan.prior_keys { - Ok(prior) => Ok(prior - .iter() - .filter(|key| !plan.new_keys.contains(*key)) - .cloned() - .collect()), - Err(err) => Err(err.clone()), - } -} - -/// Reject logical keys that collide with the reserved chunk namespace. -/// `--key` is free-form, so this is enforced at the Fastly adapter -/// boundary: such a key would let a push write into another key's chunk -/// space, and could not be reclaimed correctly. -fn reject_reserved_root_keys(entries: &[(String, String)]) -> Result<(), String> { - for (key, _) in entries { - if key.contains(CHUNK_KEY_INFIX) { - return Err(format!( - "config key `{key}` contains the reserved infix `{CHUNK_KEY_INFIX}`, which collides with Fastly chunk storage; choose a different config key (or --key override)" - )); - } - } - Ok(()) -} - -/// Unix epoch seconds. Push-time only (the `cli` feature is native). -fn unix_now_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |elapsed| elapsed.as_secs()) -} - -/// Reject a batch that names the same logical root key more than once. -/// -/// The adapter trait takes an entry slice and does not enforce uniqueness, -/// but GC builds one plan per entry and snapshots every plan against the -/// SAME prior generation. With `[(root, A), (root, B)]` the last tuple wins -/// the upsert (root = B), yet A's plan would still reclaim `prior - A_keys` -/// — which includes B's freshly-written chunks — leaving the final pointer -/// referencing missing chunks. Rejecting is safer than silently coalescing: -/// a duplicated key is a caller bug, and picking a winner would hide it. -fn reject_duplicate_root_keys(entries: &[(String, String)]) -> Result<(), String> { - let mut seen: HashSet<&str> = HashSet::with_capacity(entries.len()); - for (key, _) in entries { - if !seen.insert(key.as_str()) { - return Err(format!( - "config key `{key}` appears more than once in a single push; each logical key must be pushed exactly once" - )); - } - } - Ok(()) -} - -/// Best-effort per-root orphan count for `config push --local --dry-run`. -/// Navigate to `[local_server.config_stores..contents]` for the -/// dry-run counter. `Ok(None)` when any level is absent (no prior state); -/// `Err` when a level is present but the wrong type — prior state the real -/// writer would reject, so the count must degrade to "unknown", not 0. -fn local_contents_table<'doc>( - doc: &'doc toml_edit::DocumentMut, - platform_name: &str, -) -> Result, String> { - let malformed = || "could not read prior state".to_owned(); - let Some(server_item) = doc.get("local_server") else { - return Ok(None); - }; - let Some(server) = server_item.as_table() else { - return Err(malformed()); - }; - let Some(stores_item) = server.get("config_stores") else { - return Ok(None); - }; - let Some(stores) = stores_item.as_table() else { - return Err(malformed()); - }; - let Some(store_item) = stores.get(platform_name) else { - return Ok(None); - }; - let Some(store) = store_item.as_table() else { - return Err(malformed()); - }; - let Some(contents_item) = store.get("contents") else { - return Ok(None); - }; - contents_item - .as_table() - .map_or_else(|| Err(malformed()), |table| Ok(Some(table))) -} - -/// Reads the current `fastly.toml` (offline) and, for each logical -/// `(root_key, body)`, counts `prior_chunk_keys(root, old) - new_keys` -/// where `new_keys` is the root's OWN expansion. Never fails the dry-run: -/// on a missing file / no prior pointer / direct prior value it reports -/// `Ok(0)`; on unreadable or malformed prior state it reports `Err(reason)` -/// which the caller renders as an "unknown" line. -/// Is `key` a plain, prunable chunk PAYLOAD in `contents`? `false` for a value -/// that must be KEPT: a runtime-readable root, a value claiming our -/// `edgezero_kind` namespace or written by a newer format, or a NESTED root (a -/// key with a canonical chunk beneath it). Only a raw leaf payload prunes. -/// -/// The single source of truth shared by the real prune (`write_fastly_local_ -/// config_store`) and the dry-run count, so the previewed number can never drift -/// from what `--yes` actually removes. (The dry-run reads the PRE-upsert table and -/// the prune the POST-upsert one, but a generated key with a nested generation is -/// already refused by `reject_generated_key_collisions`, so that asymmetry cannot -/// change the verdict.) -fn is_prunable_leaf(contents: &toml_edit::Table, key: &str) -> bool { - let value_protected = contents - .get(key) - .and_then(toml_edit::Item::as_str) - .is_some_and(|text| { - value_announces_our_kind(text) - || value_is_future_format(text) - || gc_classify_root(key, text).is_ok() - }); - let has_nested = contents - .iter() - .any(|(other, _)| other != key && chunk_key_generation(key, other).is_some()); - !(value_protected || has_nested) -} - -fn local_orphan_counts_for_dry_run( - path: &Path, - platform_name: &str, - entries: &[(String, String)], -) -> Vec<(String, Result)> { - use toml_edit::DocumentMut; - - // Parse the current file once (best-effort). Absent file => no prior. - let parsed: Result, String> = match fs::read_to_string(path) { - Ok(text) => text - .parse::() - .map(Some) - .map_err(|_err| "could not read prior state".to_owned()), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), - Err(_) => Err("could not read prior state".to_owned()), - }; - - entries - .iter() - .map(|(root_key, body)| { - let new_keys = match expand_root(root_key, body) { - Ok((_, keys, _)) => keys, - Err(err) => return (root_key.clone(), Err(err)), - }; - let count = match &parsed { - Err(reason) => Err(reason.clone()), - Ok(None) => Ok(0), - Ok(Some(doc)) => match local_contents_table(doc, platform_name) { - Err(reason) => Err(reason), - Ok(None) => Ok(0), - Ok(Some(contents)) => match contents.get(root_key) { - None => Ok(0), // no prior value for this root - Some(item) => match item.as_str() { - None => Err("could not read prior state".to_owned()), - Some(raw) => match prior_chunk_keys(root_key, raw) { - Ok(prior) => Ok(prior - .iter() - .filter(|key| !new_keys.contains(*key)) - // Count only what the real prune would remove: - // it must still be PRESENT (an absent key is a - // no-op remove, not a deletion) AND a prunable - // leaf by the SAME predicate the prune uses. - .filter(|key| { - contents.get(key.as_str()).is_some() - && is_prunable_leaf(contents, key) - }) - .count()), - Err(_) => Err("suspicious prior pointer".to_owned()), - }, - }, - }, - }, - }; - (root_key.clone(), count) - }) - .collect() -} - -// ------------------------------------------------------------------- -// `config push` helpers -// ------------------------------------------------------------------- - -/// Run `fastly config-store-entry list --store-id= --json` and return each -/// item's `item_key`, `item_value`, and `created_at`. -/// -/// The item VALUE is KEPT (not discarded): `config gc` classifies each root by -/// its value (`gc_classify_root`) and reconstructs live generations from the -/// chunk values, so all three fields are required. The value is used internally -/// only and is NEVER echoed into a diagnostic — parse failures redact it via -/// `redact_describe_response`. -fn list_config_store_entries(store_id: &str) -> Result, String> { - let store_arg = format!("--store-id={store_id}"); - let output = Command::new("fastly") - .args(["config-store-entry", "list", store_arg.as_str(), "--json"]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "`fastly config-store-entry list --store-id={store_id} --json` exited with status {}\nstderr: {}", - output.status, - redact_stderr(&stderr) - )); - } - let stdout = strict_stdout(output.stdout, "config-store-entry list --json")?; - let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|_err| { - format!( - "failed to parse `fastly config-store-entry list` JSON (parse error redacted; \ - response: {})", - redact_describe_response(&stdout) - ) - })?; - // A BARE ARRAY ONLY. The installed Fastly CLI returns the complete store as - // a top-level array with no cursor/paging flags. Any other shape (e.g. an - // `{"items":[...], ...}` envelope) may carry pagination metadata we do not - // follow -- and a page that omitted a ROOT while listing its chunks would - // make live chunks look orphaned. The completeness guard cannot see a root - // that isn't there, so we refuse rather than reclaim from a partial view. - let array = parsed.as_array().ok_or_else(|| { - format!( - "refusing to reclaim: `fastly config-store-entry list --json` did not return a bare \ - array (response: {}). This build only supports an unpaginated listing; a partial view \ - could hide a root and orphan its live chunks. Nothing was deleted.", - redact_describe_response(&stdout) - ) - })?; - // FAIL CLOSED on any malformed entry. A missing/non-string field on a - // reclamation input must NEVER be silently skipped or defaulted to empty: - // skipping a root hides the chunks it references (they'd look orphaned and - // get deleted while live), and an empty `item_value` makes a real root - // parse as "references nothing" — same catastrophe. If we can't read the - // listing exactly, we delete nothing. - let mut items = Vec::with_capacity(array.len()); - for (idx, entry) in array.iter().enumerate() { - // Name the offending KEY, not just the index: `item_key` is readable even - // when another field is empty, so the operator can see WHICH entry to fix. - let key_hint = entry - .get("item_key") - .and_then(serde_json::Value::as_str) - .filter(|key| !key.is_empty()) - .map_or_else(|| format!("#{idx}"), |key| format!("`{key}`")); - let field = |name: &str| -> Result { - let raw = entry - .get(name) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "`fastly config-store-entry list` entry {key_hint} is missing a string \ - `{name}` field; refusing to reclaim (nothing deleted)" - ) - })?; - // An EMPTY field is as dangerous as a missing one: an empty root value - // would classify as "references nothing" and orphan its live chunks. - // Reject it here rather than reason about it later -- but say what to - // look at, since a legitimate empty-valued sibling is otherwise a - // whole-store block with no obvious cause. - if raw.is_empty() { - return Err(format!( - "`fastly config-store-entry list` entry {key_hint} has an empty `{name}` field; \ - refusing to reclaim (nothing deleted). If this is a legitimate empty-valued \ - entry, remove it or give it a value before running `config gc`." - )); - } - Ok(raw.to_owned()) - }; - items.push(ConfigStoreItem { - created_at: field("created_at")?, - item_key: field("item_key")?, - item_value: field("item_value")?, - }); - } - - // DUPLICATE KEYS => fail closed. A key must appear once; a store cannot - // really hold two entries under one key, so duplicate rows mean we are not - // reading the store we think we are (a merged/paginated view, or a CLI - // change). Left alone, the last row silently wins for BOTH the live-set - // lookup and `created_at`, so conflicting rows could age a recent key into - // eligibility and schedule the same key for two deletes. - let mut seen: HashSet<&str> = HashSet::with_capacity(items.len()); - if let Some(duplicate) = items - .iter() - .find(|item| !seen.insert(item.item_key.as_str())) - { - return Err(format!( - "refusing to reclaim: `fastly config-store-entry list` returned key `{}` more than \ - once. A key is unique in a config store, so this listing does not describe one \ - consistent view of it (nothing was deleted).", - duplicate.item_key - )); - } - - Ok(items) -} - -/// RFC 3339 (`2026-07-13T03:27:42Z`) -> unix seconds, rounded UP on any fraction. -/// -/// `timestamp()` FLOORS the sub-second part, and the current time the age gate -/// compares against is also floored. A creation floored DOWN makes a key look -/// OLDER: a true age of 59.002s (created `...:42.998Z`) would compute as 60s and -/// pass a 60s `--older-than` almost a full second early. Rounding creation UP -/// keeps the computed age conservative -- a key never ages into deletion early. -fn parse_rfc3339_secs(raw: &str) -> Option { - let stamp = chrono::DateTime::parse_from_rfc3339(raw).ok()?; - let secs = stamp.timestamp(); - let rounded_up = if stamp.timestamp_subsec_nanos() > 0 { - secs.checked_add(1)? - } else { - secs - }; - u64::try_from(rounded_up).ok() -} - -/// Report what a sweep is KEEPING, not only what it would delete, so the run is -/// reviewable: each RETAINED root by key, plus the referenced-chunk total those -/// roots hold (already summarised). A root listed here is never a delete -/// candidate. -/// -/// "Retained"/"referenced", not "live": the set also includes a root that is -/// PROTECTED but not runtime-readable (e.g. one that fails the writer split check -/// and is warned about separately). Its chunks are conservatively protected, not -/// runtime-live, so `live_count` here is a count of REFERENCED chunks. -fn append_kept_roots_report(out: &mut Vec, kept_roots: &[String], live_count: usize) { - if kept_roots.is_empty() { - out.push("keeping 0 retained root(s)".to_owned()); - return; - } - out.push(format!( - "keeping {} retained root(s) ({live_count} referenced chunk(s) held by them):", - kept_roots.len() - )); - for key in kept_roots { - out.push(format!(" keeping `{key}`")); - } -} - -/// `config gc` for Fastly: delete chunk entries that no LIVE root pointer -/// references and that are older than the operator's `older_than_secs`. -/// -/// Why this is a separate, operator-invoked command rather than part of `config -/// push`: see `Adapter::gc_config_entries`. The operator's `--older-than` is the -/// safety assertion the platform cannot make. A dry-run prints exactly which -/// keys would go, with ages, so the assertion is reviewable. -/// -/// Fails CLOSED: if the listing is unreadable, or a root's value cannot be -/// classified, nothing is deleted. -fn gc_fastly_config_store( - store_name: &str, - older_than_secs: u64, - dry_run: bool, -) -> Result, String> { - // THE destructive boundary enforces its own precondition. The CLI rejects a - // zero window too, but `gc_config_entries` is a public trait method any - // caller can reach directly -- a safety rule that lives only in the CLI is - // not a safety rule. A zero window asserts nothing: it makes every orphan - // eligible, including one superseded a second ago whose pointer POPs are - // still serving. (A dry-run may preview at zero; it deletes nothing.) - if !dry_run && older_than_secs == 0 { - return Err( - "refusing to reclaim: a destructive `config gc` requires a non-zero `--older-than` \ - window. Zero asserts nothing -- it would make every orphan eligible, including \ - chunks a pointer POPs are still serving. Nothing was deleted." - .to_owned(), - ); - } - let resolved_id = resolve_remote_config_store_id(store_name)? - .ok_or_else(|| no_matching_store_error(store_name))?; - let items = list_config_store_entries(&resolved_id)?; - let plan = plan_gc_reclamation(&items, unix_now_secs(), older_than_secs)?; - let GcPlan { - doomed, - kept_roots, - live_count, - retained_recent, - roots, - unprovable, - warnings, - } = plan; - - let doomed_count: usize = doomed.iter().map(Vec::len).sum(); - let mut out = vec![format!( - "fastly config-store `{store_name}` (id={resolved_id}): {} entries, {roots} root(s), {live_count} referenced chunk(s), {doomed_count} orphan(s) in {} generation(s) older than {older_than_secs}s, {retained_recent} orphan(s) too recent", - items.len(), - doomed.len(), - )]; - out.extend(warnings); - append_kept_roots_report(&mut out, &kept_roots, live_count); - if unprovable > 0 { - // NEVER silent: these entries look like chunk keys but we could not - // prove our writer produced them, so we left them alone. Say so, or the - // summary reads as "everything reclaimable was reclaimed". - out.push(format!( - " {unprovable} chunk-shaped entr(ies) left untouched: they are not byte-identical to what this writer would produce (wrong content-address, a split this writer would not choose, an incomplete generation, or a count it would never emit), so EdgeZero cannot claim them" - )); - } - if doomed_count == 0 { - out.push("nothing to reclaim".to_owned()); - return Ok(out); - } - if dry_run { - // A dry-run only PLANS: list every candidate and stop. Nothing is - // attempted, so there is no confirmed/failed/skipped distinction yet. - for (key, age) in doomed.iter().flatten() { - out.push(format!(" would delete `{key}` (age {age}s)")); - } - // `--yes` ALWAYS requires an explicit non-zero `--older-than` (a - // destructive run must not guess the window), so the apply instruction - // names both -- "re-run with --yes" alone would be rejected. - out.push(format!( - "dry-run: {doomed_count} orphan chunk(s) planned for deletion; re-run with \ - `--yes --older-than ` (a non-zero window is required) to apply" - )); - return Ok(out); - } - // Real run: `doomed_count` is the PLANNED count. Do NOT pre-print each key as - // "deleting" -- execution stops at a generation's first failure, so some - // planned keys are never attempted. `execute_gc_deletes` reports the real - // per-key outcome (deleted / FAILED / skipped) as it happens. - out.push(format!( - "reclaiming {doomed_count} planned orphan chunk(s) across {} generation(s)", - doomed.len() - )); - - let GcDeleteOutcome { - deleted, - failed, - stranded, - uncertain, - } = execute_gc_deletes(&resolved_id, &doomed, &mut out); - out.push(format!( - "reclaimed {deleted} of {doomed_count} orphan chunk entries" - )); - if failed.is_empty() { - return Ok(out); - } - // Partial/total failure must be a non-zero exit so automation can see it. - let mut diagnostic = format!( - "{}\nconfig gc: {} of {doomed_count} deletes FAILED ({})", - out.join("\n"), - failed.len(), - failed.join(", ") - ); - // A generation whose only failure was on an unconfirmed delete: the outcome - // is UNKNOWN (Fastly may have committed it), so a re-run is worth trying but - // may find a fragment. - if !uncertain.is_empty() { - write!( - diagnostic, - ".\nNOTE: a failed remote delete has an unknown outcome -- Fastly may have applied it \ - before returning an error. Re-run `config gc`: it reclaims each affected generation \ - if it is still whole, or reports it as an unprovable fragment (\"left untouched\") if \ - a delete did commit. If reported as a fragment, remove the survivors by hand:\n{}", - recovery_commands(&resolved_id, &uncertain) - ) - .map_err(|err| format!("failed to format the gc diagnostic: {err}"))?; - } - // A generation with a CONFIRMED prior delete: definitely a fragment now. - if !stranded.is_empty() { - write!( - diagnostic, - ".\nWARNING: {} entr(ies) are now an INCOMPLETE generation because a sibling was \ - already deleted before the failure: {}. `config gc` proves a generation by \ - reassembling it, so it can no longer prove these and will never reclaim them -- \ - re-running will NOT help. They are inert (no pointer references them). Remove them \ - by hand once you are satisfied they are unreferenced:\n{}", - stranded.len(), - stranded.join(", "), - recovery_commands(&resolved_id, &stranded), - ) - .map_err(|err| format!("failed to format the gc diagnostic: {err}"))?; - } - Err(diagnostic) -} - -/// Render copy-pasteable `fastly config-store-entry delete` commands, one per -/// key, with EVERY interpolated value single-quoted for POSIX shells. -/// -/// Root keys are free-form (`--key `), and a chunk key preserves its -/// root, so a key can contain `$(...)`, spaces, or `;`. Pasting an unquoted -/// command could execute or misparse it, so this is not cosmetic. -/// -/// The escaping is POSIX/bash (Linux/macOS). A leading note makes that explicit, -/// because Windows `cmd` and PowerShell quote differently — an operator on those -/// shells must adapt the quoting rather than paste verbatim. -fn recovery_commands(store_id: &str, keys: &[String]) -> String { - let commands = keys - .iter() - .map(|key| { - format!( - " fastly config-store-entry delete --store-id={} --key={} --auto-yes", - shell_single_quote(store_id), - shell_single_quote(key), - ) - }) - .collect::>() - .join("\n"); - format!( - " # POSIX/bash (Linux/macOS). On Windows cmd/PowerShell the quoting \ - differs -- adapt it for your shell.\n{commands}" - ) -} - -/// Single-quote a value for a POSIX shell: wrap in `'...'` and rewrite each -/// embedded `'` as `'\''`. Inside single quotes every other byte -- `$`, spaces, -/// `;`, `$(...)`, backticks -- is literal, so this neutralises any hostile key. -fn shell_single_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\\''")) -} - -/// Delete each doomed generation, stopping a generation at its FIRST failure. -/// -/// A generation is provable only as a whole (`prove_generation` reassembles it), -/// so a half-deleted one can never be proved again: the next run sees a fragment, -/// cannot verify it, and correctly refuses to touch it — forever. Ploughing on -/// after a failure is therefore the one thing that turns a possibly-recoverable -/// error into permanent, unreclaimable litter. -/// -/// A failed remote delete has an UNKNOWN outcome — Fastly may commit it before -/// returning an error — so nothing here is promised as cleanly retryable. The -/// caller distinguishes two cases: a failure with a CONFIRMED prior sibling -/// delete strands the survivors for good (manual recovery), and a failure with -/// no confirmed prior delete leaves the generation in an UNCERTAIN state (a -/// re-run may reclaim it, or surface it as an unprovable fragment). Generations -/// are independent, so a failure in one does not stop the others. -fn execute_gc_deletes( - resolved_id: &str, - doomed: &[Vec<(String, u64)>], - out: &mut Vec, -) -> GcDeleteOutcome { - let mut outcome = GcDeleteOutcome { - deleted: 0, - failed: Vec::new(), - stranded: Vec::new(), - uncertain: Vec::new(), - }; - for generation in doomed { - let mut deleted_here: Vec<&str> = Vec::new(); - for (key, _) in generation { - match delete_config_store_entry(resolved_id, key) { - Ok(()) => { - outcome.deleted = outcome.deleted.saturating_add(1); - deleted_here.push(key.as_str()); - // CONFIRMED gone, per key, as it happens. - out.push(format!(" deleted `{key}`")); - } - Err(err) => { - out.push(format!(" FAILED to delete `{key}` ({err})")); - outcome.failed.push(key.clone()); - // Everything in this generation we have NOT confirmed deleted - // -- the failed key itself, plus the ones we never reached. - let unconfirmed: Vec = generation - .iter() - .map(|(member, _)| member.clone()) - .filter(|member| !deleted_here.contains(&member.as_str())) - .collect(); - // Distinguish the ones we NEVER ATTEMPTED (after the stop) - // from the failed key itself, so the report is not read as - // "all of these were tried and failed". - for skipped in unconfirmed.iter().filter(|member| *member != key) { - out.push(format!( - " skipped `{skipped}` (not attempted: this generation's delete stopped at the failure above)" - )); - } - if deleted_here.is_empty() { - // No sibling is CONFIRMED gone. The failed delete's - // outcome is unknown: if it did not commit, the - // generation is whole and a re-run reclaims it; if it - // did, the re-run finds a fragment and reports it. Either - // way we must not claim clean retryability. - outcome.uncertain.extend(unconfirmed); - } else { - // A sibling is CONFIRMED gone, so this generation is - // definitely a fragment no future run can prove. - outcome.stranded.extend(unconfirmed); - } - break; // stop THIS generation; the others are independent - } - } - } - } - outcome -} - -/// Classify a store's entries: the live chunk set, the protected root keys, and -/// the root count. -/// -/// Root-vs-chunk is decided by VALUE, not key shape. The runtime resolver reads -/// whatever value sits at a key, so ANY entry whose value is a valid direct -/// envelope or a chunk pointer is a runtime-readable root and must never be -/// deleted — even at a chunk-shaped key. Two ways that happens: -/// -/// - a pointer parked at a chunk-shaped key makes its references LIVE; -/// - a value that is itself a valid direct envelope (e.g. a small envelope whose -/// first 7 000-byte chunk is the whole envelope plus trailing whitespace, and -/// so still parses and verifies) is a root in its own right. -/// -/// Only a value that is NEITHER — a raw envelope fragment, which does not parse — -/// is a delete candidate. In normal operation a chunk payload is exactly such a -/// fragment, so this protects the pathological cases at no cost to real GC. -fn classify_store_entries( - items: &[ConfigStoreItem], - value_by_key: &HashMap<&str, &str>, -) -> Result { - let mut live: HashSet = HashSet::new(); - let mut protected: HashSet = HashSet::new(); - let mut roots = 0_usize; - let mut warnings: Vec = Vec::new(); - for item in items { - let is_chunk_shaped = chunk_key_generation_any(&item.item_key).is_some(); - let classified = match gc_classify_root(&item.item_key, &item.item_value) { - Ok(classified) => classified, - // A chunk-shaped key whose value we cannot classify is a genuine - // chunk fragment (a candidate) ONLY if BOTH hold: - // - the value ANNOUNCES no kind. A real chunk payload is a raw - // envelope fragment (no `edgezero_kind`); anything that DOES claim - // our namespace -- a parked pointer, an unknown/future kind -- is - // root-like or suspicious and must fail closed below. - // - NOTHING is nested beneath this key. A truncated/corrupt pointer - // at a chunk-shaped key is ALSO an unparseable fragment, but if it - // is a nested ROOT with its own generation, those nested chunks are - // proven independently and would be deleted while their (unreadable) - // root can no longer name them -- silent loss of a whole nested - // generation. If any canonical chunk of THIS key exists, treat the - // key as an unreadable nested root and FAIL CLOSED. A real leaf - // payload never has nested chunks, so normal GC is unaffected. - Err(_) - if is_chunk_shaped - && !value_announces_our_kind(&item.item_value) - && !value_is_future_format(&item.item_value) - && !items.iter().any(|other| { - other.item_key != item.item_key - && chunk_key_generation(&item.item_key, &other.item_key).is_some() - }) => - { - continue; // a leaf chunk payload: a delete candidate - } - // A definitively FOREIGN entry at an ORDINARY key — a plain string - // like `greeting = "hello"`, a scalar, or a complete JSON object - // without our discriminator. The runtime returns it verbatim and it - // references no chunks, so protect it as a zero-reference root. - // Aborting here would let one ordinary sibling block reclamation of - // every generation in the store. - // - // Three guards keep this from ever masking corruption: - // - the value must be provably inert (NOT a malformed object that - // could be a truncated/corrupt pointer, NOT a value claiming our - // namespace) -- otherwise we might orphan chunks a broken root - // still references; - // - it must NOT be a future format. A direct envelope from a newer - // writer classifies as `Foreign` (no `edgezero_kind`), so without - // this guard it would be waved through as a zero-reference root -- - // yet a newer format may reference chunks under a scheme this build - // cannot read, and GC would plan them for deletion. Fail closed; - // - the KEY must be outside our reserved `.__edgezero_chunks.` - // namespace. A non-canonical key that still lives in that - // namespace is not an ordinary sibling; we cannot say what it is, - // so it fails closed below rather than being waved through. - Err(_) - if value_is_inert_foreign(&item.item_value) - && !value_is_future_format(&item.item_value) - && !item.item_key.contains(CHUNK_KEY_INFIX) => - { - roots = roots.saturating_add(1); - protected.insert(item.item_key.clone()); - continue; - } - Err(err) => { - return Err(format!( - "refusing to reclaim: could not classify root `{}` ({err}); nothing was deleted", - item.item_key - )); - } - }; - // A runtime-readable root, wherever it lives: never a delete candidate. - roots = roots.saturating_add(1); - protected.insert(item.item_key.clone()); - let GcRootValue::Chunked(pointer) = classified else { - continue; // A direct envelope references no chunks. - }; - // The pointer's METADATA is self-consistent by here. That is not proof - // that it honestly describes its generation: a pointer can drop its last - // chunk ref AND restate `envelope_len` as the remaining sum, and every - // metadata check still passes while the dropped chunk silently leaves - // the live set and becomes deletable. So reassemble what it references - // and hold the bytes against its content-address. - let assembled = assemble_pointer_chunks(&item.item_key, &pointer, value_by_key)?; - // The reassembled value may be a NEWER inner format (a bumped envelope - // version, or an unknown `edgezero_kind`) that `BlobEnvelope` deserialize - // silently ignores. Such a format can reference ADDITIONAL generations this - // build cannot see, so trusting only the outer pointer's chunks as the live - // set would let GC delete those as orphans. The runtime resolver rejects - // this case; GC must too. Fail closed. - if value_is_future_format(&assembled) { - return Err(format!( - "refusing to reclaim: root `{}` reconstructs to a value in a newer format this \ - build does not recognise. It may reference generations this build cannot see, so \ - treating its outer chunks as the whole live set could delete live data. Nothing \ - was deleted.", - item.item_key - )); - } - gc_verify_generation(&pointer.envelope_sha256, &assembled).map_err(|err| { - format!( - "refusing to reclaim: root `{}` names a chunk set that does not reconstruct the \ - envelope it claims ({err}). Its chunk list is therefore not a trustworthy live \ - set, and treating it as one could delete a live chunk. Nothing was deleted.", - item.item_key - ) - })?; - // Same exact-split predicate the RUNTIME resolver applies. The content - // checks above only prove the bytes; a pointer whose boundaries are not - // the ones this writer emits reassembles correctly here but is REJECTED - // at runtime -- so GC would otherwise call it a healthy live root while - // the guest 500s on it, and its generation can never satisfy - // `prove_generation` either, making it permanently unreclaimable. - // - // We still protect it (fail-closed: never delete on a judgement we are - // unsure of), but we no longer call it healthy silently -- the operator - // gets told it is unreadable and will not be reclaimed automatically. - if let Err(err) = - verify_writer_split_layout(&item.item_key, &assembled, &chunk_lengths(&pointer.chunks)) - { - warnings.push(format!( - "warning: root `{}` is NOT runtime-readable ({err}). Its chunks are kept, but this \ - generation can never be proven writer-produced, so `config gc` will never reclaim \ - it. Re-run `config push` for this key to rewrite it, then re-run `config gc`.", - item.item_key - )); - } - live.extend(pointer.chunks.into_iter().map(|chunk| chunk.key)); - } - Ok(GcClassification { - live, - protected, - roots, - warnings, - }) -} - -/// The reclamation plan for one store: which orphan chunk entries to delete, and -/// the counts for the summary line. Deriving it is where every safety guard -/// lives, so it is fail-closed throughout — any unreadable/incomplete state -/// returns `Err` and the caller deletes nothing. -/// -/// The organising idea is that **content-addressing makes a chunk set -/// self-proving**: a chunk key embeds the SHA-256 of the whole envelope it -/// belongs to, so reassembling a generation either reproduces the -/// content-address its own keys name, or it does not. Every destructive decision -/// here rests on that hash — never on what the store's metadata claims about -/// itself, which is exactly what an inconsistent store gets wrong. -fn plan_gc_reclamation( - items: &[ConfigStoreItem], - now: u64, - older_than_secs: u64, -) -> Result { - let mut value_by_key: HashMap<&str, &str> = HashMap::with_capacity(items.len()); - let mut created_by_key: HashMap<&str, u64> = HashMap::with_capacity(items.len()); - for item in items { - let Some(created) = parse_rfc3339_secs(&item.created_at) else { - // Unparseable timestamp anywhere in the listing -> fail closed. On a - // DELETE path we will not guess an age. - return Err(format!( - "refusing to reclaim: entry `{}` has an unreadable `created_at`; nothing was deleted", - item.item_key - )); - }; - created_by_key.insert(item.item_key.as_str(), created); - value_by_key.insert(item.item_key.as_str(), item.item_value.as_str()); - } - - // ---- 1. Classify entries: live chunks, protected roots, root count ---- - let GcClassification { - live, - protected, - roots, - warnings, - } = classify_store_entries(items, &value_by_key)?; - - // ---- 2. Per-root live-config age (best-effort; see the guard below) ---- - // rsplit_once (the LAST infix): a chunk of a chunk-shaped root nests the infix - // twice, and its root is everything before the LAST one. Splitting on the - // first would attribute a nested chunk's age to the wrong (outer) root. - let root_live_since: HashMap<&str, u64> = live.iter().fold(HashMap::new(), |mut acc, key| { - if let Some((root, _)) = key.rsplit_once(CHUNK_KEY_INFIX) { - let created = *created_by_key.get(key.as_str()).unwrap_or(&0); - let slot = acc.entry(root).or_insert(0); - *slot = (*slot).max(created); - } - acc - }); - - // ---- 3. Candidates, grouped by GENERATION and proven writer-produced ---- - // A per-key decision cannot be safe: an entry is only ours if the whole - // generation it belongs to reassembles to the content-address its keys name. - // So group first, prove second, and delete whole generations or none -- a - // partial delete would leave a corrupt generation behind. - let mut groups: BTreeMap<(&str, String), Vec<&ConfigStoreItem>> = BTreeMap::new(); - for item in items { - if live.contains(&item.item_key) { - continue; - } - // A key whose own value is a runtime-readable root is never a candidate, - // even when its key is chunk-shaped (a valid direct envelope can sit at - // one). Excluding it here also means any real chunk sharing that - // generation drops to an incomplete group, which prove_generation then - // leaves untouched — safe: we leak rather than delete a possible root. - if protected.contains(&item.item_key) { - continue; - } - // rsplit_once (the LAST infix): the same nested-chunk correctness the - // live-set scan and classification use — a chunk of a chunk-shaped root - // is grouped under THAT root, not the outer one, so nested orphans are - // grouped (and thus reclaimed or reported), not silently dropped. - let Some((root, _)) = item.item_key.rsplit_once(CHUNK_KEY_INFIX) else { - continue; // a root - }; - let Some(generation) = chunk_key_generation(root, &item.item_key) else { - continue; // chunk-shaped but NOT canonical => never a key we emit - }; - groups.entry((root, generation)).or_default().push(item); - } - - let mut doomed: Vec> = Vec::new(); - let mut retained_recent = 0_usize; - let mut unprovable = 0_usize; - for ((root, generation), mut group) in groups { - if prove_generation(root, &generation, &group).is_err() { - // We cannot prove we wrote this, so we do not touch it. It may be an - // ordinary entry that merely LOOKS like a chunk key (a store can - // predate this feature or be shared, and push-time reserved-key - // rejection cannot protect what already exists), or a half-written - // generation. Skipped rather than fatal: one foreign entry must not - // block reclamation of the store forever. Reported in the summary. - unprovable = unprovable.saturating_add(group.len()); - continue; - } - - // Age the generation as a UNIT, by its youngest member: deleting a - // generation is one decision, so its most restrictive age governs. - let group_age = group - .iter() - .map(|item| { - now.saturating_sub(*created_by_key.get(item.item_key.as_str()).unwrap_or(&0)) - }) - .min() - .unwrap_or(0); - // BOTH ages must clear the operator's window; neither substitutes for - // the other, so take the more restrictive (the MINIMUM). - // - // - The chunks' OWN age is mandatory: a generation written seconds ago - // is inside the propagation window whatever its root looks like (e.g. - // a concurrent push wrote it and has not committed its pointer yet), - // so an old-looking root must never license deleting it. - // - The root's live-config age (when known) is an EXTRA restriction: it - // catches an old generation superseded recently, which its own age - // cannot see. - let effective_age = root_live_since.get(root).map_or(group_age, |live_since| { - group_age.min(now.saturating_sub(*live_since)) - }); - if effective_age < older_than_secs { - retained_recent = retained_recent.saturating_add(group.len()); - continue; - } - // Delete in canonical chunk-INDEX order (`.0`, `.1`, ...), NOT the remote - // listing order. Deletion stops at a generation's first failure, so a - // reordered listing would otherwise change the preview order and which - // siblings get stranded; sorting makes both deterministic. Every member is - // a canonical chunk of `root` (it passed the grouping filter), so - // `chunk_key_index` is `Some`; `None` sorts last defensively. - group.sort_by_key(|item| chunk_key_index(root, &item.item_key).unwrap_or(usize::MAX)); - doomed.push( - group - .iter() - .map(|item| { - let age = now - .saturating_sub(*created_by_key.get(item.item_key.as_str()).unwrap_or(&0)); - (item.item_key.clone(), age) - }) - .collect(), - ); - } - - let mut kept_roots: Vec = protected.into_iter().collect(); - kept_roots.sort(); - - Ok(GcPlan { - doomed, - kept_roots, - live_count: live.len(), - retained_recent, - roots, - unprovable, - warnings, - }) -} - -/// Reassemble the chunks a live pointer references, in index order, checking each -/// against the pointer's own per-chunk `len`/`sha256` along the way. -/// -/// Fails closed when a referenced key is absent from the listing. This subsumes -/// the old standalone completeness guard: an incomplete or paginated listing -/// cannot produce the bytes, so it can never reach a passing verification. -fn assemble_pointer_chunks( - root_key: &str, - pointer: &GcPointer, - value_by_key: &HashMap<&str, &str>, -) -> Result { - // NOT `with_capacity(pointer.envelope_len)`: that length is untrusted stored - // metadata. `validate_pointer_chunks` bounds it, but this is a destructive - // path -- do not reserve from a number the store supplied when growing from - // the bytes we actually read costs nothing. - let mut assembled = String::new(); - // The chunk KEY is pointer-controlled (a malformed pointer can carry any - // string there), so diagnostics name a POSITION, not the key. `root_key` is - // the operator's own logical entry key and is named for context, as the rest - // of the GC diagnostics do. - for (position, chunk) in pointer.chunks.iter().enumerate() { - let Some(value) = value_by_key.get(chunk.key.as_str()) else { - return Err(format!( - "refusing to reclaim: root `{root_key}` references chunk {position}, which is \ - absent from the store listing (the listing may be incomplete/paginated, or the \ - store is already inconsistent); nothing was deleted" - )); - }; - if value.len() != chunk.len { - return Err(format!( - "refusing to reclaim: root `{root_key}` says chunk {position} is {} bytes but the \ - store holds {}; nothing was deleted", - chunk.len, - value.len() - )); - } - if sha256_hex(value.as_bytes()) != chunk.sha256 { - return Err(format!( - "refusing to reclaim: the stored value of chunk {position} does not match the \ - SHA-256 that root `{root_key}` records for it; nothing was deleted" - )); - } - assembled.push_str(value); - } - if assembled.len() != pointer.envelope_len { - return Err(format!( - "refusing to reclaim: root `{root_key}` declares an envelope of {} bytes but its \ - chunks reassemble to {}; nothing was deleted", - pointer.envelope_len, - assembled.len() - )); - } - Ok(assembled) -} - -/// Is this candidate generation byte-identical to what THIS writer would have -/// produced for the bytes it contains? -/// -/// The gate on every delete. `group` is every listed entry sharing one -/// `(root, generation)`. -/// -/// **What this proves, precisely.** We reassemble the group in index order and -/// re-run `prepare_fastly_config_entries` over the result. If the writer, given -/// those exact bytes, would emit exactly these keys and these values, the entries -/// are indistinguishable from our own output: same direct-vs-chunked threshold, -/// same UTF-8-safe 7 000-byte boundaries, same content-addressed keys, same -/// count. A lone chunk fails automatically (an envelope small enough to store -/// directly round-trips to a single ROOT-keyed entry, and a large one to >= 2 -/// chunks), as does any set split at boundaries we would not choose. -/// -/// **What this does NOT prove: authorship.** Content-addressing is not a -/// signature. A foreign writer can pick envelope E, compute `H = sha256(E)`, -/// split E exactly as we would, and store the parts under our reserved -/// `.__edgezero_chunks.` namespace; that group is byte-identical to ours and we -/// will reclaim it. No preimage attack is needed, and no check over the stored -/// bytes alone can separate the two — telling them apart needs trusted -/// generation metadata or an authenticated marker, and the store offers neither -/// (any writer with store access could forge either). -/// -/// We accept that residual: the namespace is reserved by convention, push-time -/// validation rejects logical keys inside it, and anything passing this gate is -/// a faithful reproduction of our format. The spec documents it as a limitation -/// rather than claiming a guarantee we cannot make. -fn prove_generation( - root: &str, - generation: &str, - group: &[&ConfigStoreItem], -) -> Result<(), String> { - let mut ordered: Vec<(usize, &str)> = Vec::with_capacity(group.len()); - for item in group { - let index = item - .item_key - .rsplit_once('.') - .and_then(|(_, index)| index.parse::().ok()) - .ok_or_else(|| format!("`{}` has no readable index", item.item_key))?; - ordered.push((index, item.item_value.as_str())); - } - ordered.sort_by_key(|&(index, _)| index); - for (position, &(index, _)) in ordered.iter().enumerate() { - if index != position { - return Err(format!( - "indexes are not dense 0..n-1 (found {index} at position {position})" - )); - } - } - let assembled: String = ordered.iter().map(|&(_, value)| value).collect(); - - // 1. The bytes must be the generation the keys name, and a real envelope. - gc_verify_generation(generation, &assembled)?; - - // 2. ...and the writer, given those bytes, must produce EXACTLY these - // entries. This is what pins the split boundaries and the chunked-vs- - // direct threshold, so a set assembled by anything that does not - // reproduce our writer's output byte-for-byte is left alone. - let expected = prepare_fastly_config_entries(root, &assembled) - .map_err(|err| format!("this writer could not re-derive the generation ({err})"))?; - let Some(expected_chunks) = expected.get(..expected.len().saturating_sub(1)) else { - return Err("this writer produced no chunk entries for these bytes".to_owned()); - }; - if expected_chunks.is_empty() { - // The envelope fits directly, so the writer would never have chunked it: - // whatever these entries are, they are not ours. - return Err( - "these bytes fit the entry limit, so this writer would have stored them directly \ - rather than in chunks" - .to_owned(), - ); - } - if expected_chunks.len() != ordered.len() { - return Err(format!( - "this writer would split these bytes into {} chunk(s), not {}", - expected_chunks.len(), - ordered.len() - )); - } - for ((expected_key, expected_value), item) in - expected_chunks.iter().zip(group_in_index_order(group)) - { - if *expected_key != item.item_key { - return Err(format!( - "this writer would not have produced the key `{}`", - item.item_key - )); - } - if *expected_value != item.item_value { - return Err(format!( - "the stored value of `{}` is not the chunk this writer would have written at that \ - index", - item.item_key - )); - } - } - Ok(()) -} - -/// `group` sorted by chunk index, so it lines up with the writer's output order. -fn group_in_index_order<'item>(group: &[&'item ConfigStoreItem]) -> Vec<&'item ConfigStoreItem> { - let mut ordered: Vec<&ConfigStoreItem> = group.to_vec(); - ordered.sort_by_key(|item| { - item.item_key - .rsplit_once('.') - .and_then(|(_, index)| index.parse::().ok()) - .unwrap_or(usize::MAX) - }); - ordered -} - -/// Is this key a chunk key of ANY root? (`config gc` scans the whole store, so -/// it cannot scope to one root up front.) Validates the canonical shape. -fn chunk_key_generation_any(key: &str) -> Option { - // Split on the LAST infix, not the first: a chunk of a root that ITSELF - // contains the infix (a pointer parked at a chunk-shaped key with self-scoped - // chunks) has the infix twice, and its chunk suffix is after the LAST one. - // Splitting on the first would misread the doubly-nested chunk as a - // non-chunk, get it classified as an unclassifiable root, and abort the whole - // store's GC. For an ordinary single-infix key the root has no infix, so the - // last infix IS the first — this only changes the nested case. - let (root, _rest) = key.rsplit_once(CHUNK_KEY_INFIX)?; - chunk_key_generation(root, key) -} - -/// Drive a sequential per-entry commit loop and produce the -/// partial-failure diagnostic when the committer fails mid-way. -/// Pure (no I/O) so the diagnostic shape is unit-testable without -/// the fastly CLI on PATH; production calls it with a closure that -/// shells out via `create_config_store_entry`. On success returns -/// the count of committed entries; on failure returns an error -/// string. The FAILED entry's outcome is UNKNOWN — Fastly may have -/// committed it before returning the error — so the message does not -/// claim a clean boundary; it directs the operator to re-run the whole -/// idempotent push rather than hand-resume from a supposed cut point. -fn push_entries_with_committer( - entries: &[(String, String)], - mut committer: F, -) -> Result -where - F: FnMut(&str, &str) -> Result<(), String>, -{ - let mut pushed: Vec = Vec::with_capacity(entries.len()); - for (key, value) in entries { - if let Err(err) = committer(key, value) { - let remaining: Vec<&str> = entries - .iter() - .skip(pushed.len().saturating_add(1)) - .map(|(remaining_key, _)| remaining_key.as_str()) - .collect(); - return Err(format!( - "fastly push failed at entry `{key}` while committing {committed} of {total} entries.\n \ - The failed entry's outcome is UNKNOWN: Fastly may have committed it before the error \ - (a timeout can arrive after the write lands), including when it is the root pointer.\n \ - Recovery: re-run the SAME `config push`. It is idempotent -- chunk keys are content-addressed \ - and writes use `--upsert` -- so entries already written are rewritten harmlessly and any \ - missing ones are filled. Do NOT hand-delete the failed key.\n \ - Already written (a retry rewrites them): {pushed:?}\n \ - Failed: `{key}` (outcome unknown) -- {err}\n \ - Not attempted: {remaining:?}", - committed = pushed.len(), - total = entries.len(), - )); - } - pushed.push(key.clone()); - } - Ok(pushed.len()) -} - -/// Shell `fastly config-store-entry update --upsert --stdin` with -/// the value piped through stdin instead of `--value=` on -/// argv. -/// -/// Two reasons for this exact invocation: -/// -/// 1. `--upsert` (vs. the original `create` subcommand): the prior -/// `create` form errored on any key that already existed in the -/// config store, which made `config push` non-repeatable — -/// after the first push, every follow-up push triggered by a -/// config edit would fail at the first unchanged key. -/// `update --upsert` is documented as "insert or update", which -/// matches the convergent semantic the other config-push paths -/// already have (axum overwrites the JSON, cloudflare's -/// `wrangler kv bulk put` overwrites, spin's -/// `cloud key-value set` overwrites). -/// -/// 2. `--stdin` (vs. `--value=`): `--value=` exposed every -/// config entry's bytes in `ps`/`/proc//cmdline` listings -/// AND was bounded by the host's `ARG_MAX` (4 KiB to 256 KiB -/// depending on platform — easy to trip with a JSON blob). -/// `--stdin` reads the value from stdin instead — keeps value -/// bytes out of argv and lifts the size cap to whatever the OS -/// pipe buffer + the CLI's read accept (megabytes in practice). -fn create_config_store_entry(store_id: &str, key: &str, value: &str) -> Result<(), String> { - let store_arg = format!("--store-id={store_id}"); - let key_arg = format!("--key={key}"); - let mut child = Command::new("fastly") - .args([ - "config-store-entry", - "update", - store_arg.as_str(), - key_arg.as_str(), - "--upsert", - "--stdin", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - // Take stdin OUT of the child and hand it to a helper that writes the value - // and drops the handle on return — closing the pipe so the CLI sees EOF. - // Dropping on scope-exit rather than via an explicit `drop()` keeps this - // valid on targets where `ChildStdin` is a non-Drop stub. - // `child.wait_with_output()` then consumes child cleanly. - let stdin = child - .stdin - .take() - .ok_or_else(|| "failed to open stdin pipe to `fastly`".to_owned())?; - write_value_to_fastly_stdin(stdin, value)?; - let output = child - .wait_with_output() - .map_err(|err| format!("failed to wait on `fastly`: {err}"))?; - if output.status.success() { - return Ok(()); - } - Err(format!( - "`fastly config-store-entry update --store-id={store_id} --key={key} --upsert --stdin` exited with status {}\nstderr: {}", - output.status, - redact_stderr(&String::from_utf8_lossy(&output.stderr)) - )) -} - -/// Write `value` to the child's stdin, then drop the handle as it falls out of -/// scope on return — closing the pipe so the `fastly` CLI sees EOF. Taking -/// `stdin` by value gives a natural scope-end drop rather than an explicit -/// `drop()`, which also keeps this valid on targets where `ChildStdin` is a -/// non-Drop stub. -fn write_value_to_fastly_stdin(mut stdin: ChildStdin, value: &str) -> Result<(), String> { - stdin - .write_all(value.as_bytes()) - .map_err(|err| format!("failed to write value to `fastly` stdin: {err}")) -} - -fn delete_config_store_entry(store_id: &str, key: &str) -> Result<(), String> { - let store_arg = format!("--store-id={store_id}"); - let key_arg = format!("--key={key}"); - let output = Command::new("fastly") - .args([ - "config-store-entry", - "delete", - store_arg.as_str(), - key_arg.as_str(), - "--auto-yes", - ]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - if output.status.success() { - return Ok(()); - } - // EVERY non-zero delete is a failure -- no "already gone" special case. - // Pattern-matching stderr for "not found"/"404" cannot reliably tell "this - // key is already gone" from "the store does not exist", an auth failure, or - // a 500: messages like `config store abc does not exist while deleting key - // ` name the key AND say "does not exist". Reporting those as a - // successful reclamation is strictly worse than a retry, and a retry is - // free: `config gc` re-lists the store, so a key that really is gone simply - // will not appear as a candidate next run. - // Redact stderr: a Fastly error can quote the entry value back, which on the - // delete path would put a stored config value into CI logs. - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!( - "`fastly config-store-entry delete --store-id={store_id} --key={key} --auto-yes` exited with status {}\n{}", - output.status, - redact_stderr(&stderr) - )) -} - -/// Parse `fastly config-store list --json` output and return the -/// platform `id` of the store whose `name` matches `name`. Accepts -/// both a bare array (`[ {"id": "...", "name": "..."}, ... ]`) -/// and an `{"items": [...]}` envelope so this stays compatible -/// across fastly CLI versions. -fn find_config_store_id(stdout: &str, name: &str) -> ConfigStoreLookup { - let parsed: serde_json::Value = match serde_json::from_str(stdout) { - Ok(value) => value, - Err(err) => { - return ConfigStoreLookup::SchemaDrift(format!("stdout did not parse as JSON: {err}")); - } - }; - let Some(array) = parsed - .as_array() - .or_else(|| parsed.get("items").and_then(serde_json::Value::as_array)) - else { - return ConfigStoreLookup::SchemaDrift(format!( - "expected a bare array `[...]` or an `{{\"items\": [...]}}` envelope; got JSON of shape `{}`", - shape_summary(&parsed) - )); - }; - // FAIL CLOSED on any malformed or duplicate row: a `NotFound` here becomes a - // MissingStore that AUTHORISES an overwrite, so a listing we cannot read - // exactly must never look like a definite absence. A malformed row could BE - // the requested store (its unreadable `name` might have matched), and a - // duplicate name means we are not reading the store we think we are. Every row - // must carry a non-empty string `name` and `id`, and names must be unique. - let mut seen_names = HashSet::with_capacity(array.len()); - let mut found: Option = None; - for (idx, entry) in array.iter().enumerate() { - let name_field = entry - .get("name") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()); - let id_field = entry - .get("id") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()); - let (Some(entry_name), Some(entry_id)) = (name_field, id_field) else { - return ConfigStoreLookup::SchemaDrift(format!( - "store-list entry #{idx} is missing a non-empty string `name` or `id`; refusing to \ - treat a store as absent on a listing this build cannot read exactly" - )); - }; - if !seen_names.insert(entry_name.to_owned()) { - return ConfigStoreLookup::SchemaDrift(format!( - "store-list has a duplicate `name` (`{entry_name}`); refusing to resolve a store id \ - on an ambiguous listing" - )); - } - if entry_name == name { - found = Some(entry_id.to_owned()); - } - } - found.map_or(ConfigStoreLookup::NotFound, ConfigStoreLookup::Found) -} - -/// Summarise a `fastly ... describe` response for diagnostics WITHOUT -/// leaking its contents. -/// -/// The response body is the stored config value. App config may hold -/// credentials, internal endpoints, or security policy, and this adapter -/// performs no secret stripping — while CLI status lines are logged -/// verbatim and CI logs are commonly retained and shared. So a schema-drift -/// diagnostic must never echo the payload: report only its size and its -/// top-level *shape* (field names for an object, type otherwise), never a -/// value. -fn redact_describe_response(stdout: &str) -> String { - let len = stdout.len(); - serde_json::from_str::(stdout).map_or_else( - |_err| format!("{len} bytes, not valid JSON"), - |value| match value { - serde_json::Value::Object(map) => { - // Object KEYS are stored/provider-controlled data (a wrong-shape - // response could be `{"": ...}`), so only the COUNT is - // reported, never the key names. - format!("{len} bytes, JSON object with {} field(s)", map.len()) - } - other @ (serde_json::Value::Null - | serde_json::Value::Bool(_) - | serde_json::Value::Number(_) - | serde_json::Value::String(_) - | serde_json::Value::Array(_)) => { - format!("{len} bytes, JSON {}", shape_summary(&other)) - } - }, - ) -} - -/// Summarise a failing `fastly` invocation's stderr WITHOUT echoing it. -/// -/// The `describe` and `update --stdin` paths carry the stored config value, so -/// a Fastly error that quotes the payload back would put credentials straight -/// into CI logs — the same exposure as the stdout leak, via the failure branch. -/// Not-found *classification* still inspects stderr internally; only the -/// user-facing string is redacted. -fn redact_stderr(stderr: &str) -> String { - let len = stderr.trim().len(); - format!( - "{len} bytes suppressed (may echo the stored config value); re-run the `fastly` command directly to inspect it" - ) -} - -/// One-line type label for a `serde_json::Value` (for diagnostic -/// error messages — not a canonical JSON-schema description). -fn shape_summary(value: &serde_json::Value) -> &'static str { - match value { - serde_json::Value::Null => "null", - serde_json::Value::Bool(_) => "bool", - serde_json::Value::Number(_) => "number", - serde_json::Value::String(_) => "string", - serde_json::Value::Array(_) => "array", - serde_json::Value::Object(_) => "object", - } -} - -/// Resolve the platform config-store id on demand: shell out to -/// `fastly config-store list --json`, parse the JSON, match by -/// `name`. The provision flow doesn't persist this id, so push -/// has to re-fetch every time. -/// -/// Returns a TYPED absence: `Ok(None)` ONLY when the list call SUCCEEDS and no -/// store matches (a genuine absence). An operational failure (missing binary, -/// spawn/list failure, schema drift) stays `Err` -- callers that read for a diff -/// must not treat an operational failure as "store absent" and overwrite. -fn resolve_remote_config_store_id(name: &str) -> Result, String> { - let output = Command::new("fastly") - .args(["config-store", "list", "--json"]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - if !output.status.success() { - return Err(format!( - "`fastly config-store list --json` exited with status {}\nstderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )); - } - let stdout = strict_stdout(output.stdout, "config-store list --json")?; - match find_config_store_id(&stdout, name) { - ConfigStoreLookup::Found(id) => Ok(Some(id)), - ConfigStoreLookup::NotFound => Ok(None), - ConfigStoreLookup::SchemaDrift(detail) => Err(format!( - "could not parse `fastly config-store list --json` output: {detail}.\n The fastly CLI may have changed its JSON schema in a recent version. Please file a bug report at https://github.com/stackpop/edgezero/issues with the fastly CLI version (`fastly version`) and the raw stdout. Workaround: pin to a known-compatible fastly CLI version." - )), - } -} - -/// Message for a genuinely-absent store, for the write/GC callers that treat -/// absence as a hard error (they cannot operate on a store that does not exist). -fn no_matching_store_error(name: &str) -> String { - format!( - "no fastly config-store matches `{name}` (did you run `edgezero provision --adapter fastly`?)" - ) -} - -/// # Errors -/// Returns an error if the Fastly CLI build command fails. -#[inline] -pub fn build(extra_args: &[String]) -> Result { - let manifest = - find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; - let cargo_manifest = manifest_dir.join("Cargo.toml"); - let crate_name = read_package_name(&cargo_manifest)?; - - let status = Command::new("cargo") - .args([ - "build", - "--release", - "--target", - "wasm32-wasip1", - "--manifest-path", - cargo_manifest - .to_str() - .ok_or("invalid Cargo manifest path")?, - ]) - .args(extra_args) - .status() - .map_err(|err| format!("failed to run cargo build: {err}"))?; - if !status.success() { - return Err(format!("cargo build failed with status {status}")); - } - - let workspace_root = find_workspace_root(manifest_dir); - let artifact = locate_artifact(&workspace_root, manifest_dir, &crate_name)?; - let pkg_dir = workspace_root.join("pkg"); - fs::create_dir_all(&pkg_dir) - .map_err(|err| format!("failed to create {}: {err}", pkg_dir.display()))?; - let dest = pkg_dir.join(format!("{}.wasm", crate_name.replace('-', "_"))); - fs::copy(&artifact, &dest) - .map_err(|err| format!("failed to copy artifact to {}: {err}", dest.display()))?; - - Ok(dest) -} - -/// # Errors -/// Returns an error if the Fastly CLI deploy command fails. -#[inline] -pub fn deploy(extra_args: &[String]) -> Result<(), String> { - let manifest = - find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; - - let status = Command::new("fastly") - .args(["compute", "deploy"]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|err| format!("failed to run fastly CLI: {err}"))?; - if !status.success() { - return Err(format!("fastly compute deploy failed with status {status}")); - } - - Ok(()) -} - -fn find_fastly_manifest(start: &Path) -> Result { - if let Some(found) = find_manifest_upwards(start, "fastly.toml") { - return Ok(found); - } - - let root = find_workspace_root(start); - let mut candidates: Vec = WalkDir::new(&root) - .follow_links(true) - .max_depth(8) - .into_iter() - .filter_map(Result::ok) - .map(|entry| entry.path().to_path_buf()) - .filter(|path| { - path.file_name().is_some_and(|n| n == "fastly.toml") - && path - .parent() - .is_some_and(|dir| dir.join("Cargo.toml").exists()) - }) - .collect(); - - if candidates.is_empty() { - return Err("could not locate fastly.toml".to_owned()); - } - - candidates.sort_by_key(|path| { - let parent = path.parent().unwrap_or(Path::new("")); - path_distance(start, parent) - }); - - Ok(candidates.remove(0)) -} - -fn locate_artifact( - workspace_root: &Path, - manifest_dir: &Path, - crate_name: &str, -) -> Result { - let target_triple = "wasm32-wasip1"; - let release_name = format!("{}.wasm", crate_name.replace('-', "_")); - - if let Some(custom) = env::var_os("CARGO_TARGET_DIR") { - let candidate = PathBuf::from(custom) - .join(target_triple) - .join("release") - .join(&release_name); - if candidate.exists() { - return Ok(candidate); - } - } - - let manifest_target = manifest_dir - .join("target") - .join(target_triple) - .join("release") - .join(&release_name); - if manifest_target.exists() { - return Ok(manifest_target); - } - - let workspace_target = workspace_root - .join("target") - .join(target_triple) - .join("release") - .join(&release_name); - if workspace_target.exists() { - return Ok(workspace_target); - } - - Err(format!( - "compiled artifact not found (looked in {} and workspace target)", - manifest_dir.display() - )) -} - -#[inline] -pub fn register() { - register_adapter(&FASTLY_ADAPTER); - register_adapter_blueprint(&FASTLY_BLUEPRINT); -} - -#[ctor(unsafe)] -fn register_ctor() { - register(); -} - -/// # Errors -/// Returns an error if the Fastly CLI serve command (Viceroy) fails. -#[inline] -pub fn serve(extra_args: &[String]) -> Result<(), String> { - let manifest = - find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; - - let status = Command::new("fastly") - .args(["compute", "serve"]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|err| format!("failed to run fastly CLI: {err}"))?; - if !status.success() { - return Err(format!("fastly compute serve failed with status {status}")); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use edgezero_adapter::cli_support::read_package_name; - #[cfg(unix)] - use edgezero_core::test_env::PathPrepend; - use std::collections::HashSet; - - #[cfg(unix)] - use std::sync::Mutex; - use tempfile::tempdir; - - // Shared fixture names. Pinning these as consts (instead of - // inline `"sessions"` / `"app_config"` per call site) keeps the - // setup-vs-assertion pair in sync -- a typo in one place no - // longer silently divorces from the other, because both reference - // the same const. Also names the intent: these are the LOGICAL - // store ids the fastly adapter operates on, not arbitrary strings. - const TEST_KV_ID: &str = "sessions"; - const TEST_CONFIG_ID: &str = "app_config"; - const TEST_SECRET_ID: &str = "default"; - - #[test] - fn finds_closest_manifest_when_multiple_exist() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); - - let first = root.join("crates/first"); - fs::create_dir_all(&first).unwrap(); - fs::write(first.join("Cargo.toml"), "[package]\nname=\"first\"").unwrap(); - fs::write(first.join("fastly.toml"), "name=\"first\"").unwrap(); - - let second = root.join("examples/second"); - fs::create_dir_all(&second).unwrap(); - fs::write(second.join("Cargo.toml"), "[package]\nname=\"second\"").unwrap(); - fs::write(second.join("fastly.toml"), "name=\"second\"").unwrap(); - - let found = find_fastly_manifest(&second).unwrap(); - assert_eq!(found, second.join("fastly.toml")); - } - - #[test] - fn finds_manifest_in_current_directory() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); - fs::write(root.join("fastly.toml"), "name = \"demo\"").unwrap(); - - let manifest = find_fastly_manifest(root).expect("should find manifest"); - assert_eq!(manifest, root.join("fastly.toml")); - } - - #[test] - fn locate_artifact_considers_workspace_target() { - let dir = tempdir().unwrap(); - let workspace = dir.path(); - let manifest_dir = workspace.join("service"); - fs::create_dir_all(manifest_dir.join("target/wasm32-wasip1/release")).unwrap(); - let artifact = workspace.join("target/wasm32-wasip1/release/demo.wasm"); - fs::create_dir_all(artifact.parent().unwrap()).unwrap(); - fs::write(&artifact, "wasm").unwrap(); - - let located = locate_artifact(workspace, &manifest_dir, "demo").unwrap(); - assert_eq!(located, artifact); - } - - #[test] - fn read_package_falls_back_to_name() { - let dir = tempdir().unwrap(); - let manifest = dir.path().join("Cargo.toml"); - fs::write(&manifest, "name = \"demo\"").unwrap(); - let name = read_package_name(&manifest).unwrap(); - assert_eq!(name, "demo"); - } - - #[test] - fn read_package_prefers_package_table() { - let dir = tempdir().unwrap(); - let manifest = dir.path().join("Cargo.toml"); - fs::write(&manifest, "[package]\nname = \"demo\"\n").unwrap(); - let name = read_package_name(&manifest).unwrap(); - assert_eq!(name, "demo"); - } - - // ---------- push_entries_with_committer ---------- - - #[test] - fn push_entries_with_committer_returns_count_when_all_succeed() { - let entries = vec![ - ("a".to_owned(), "1".to_owned()), - ("b".to_owned(), "2".to_owned()), - ("c".to_owned(), "3".to_owned()), - ]; - let pushed = push_entries_with_committer(&entries, |_, _| Ok(())).expect("all succeed"); - assert_eq!(pushed, 3); - } - - #[test] - fn push_entries_with_committer_zero_entries_is_ok() { - let pushed = push_entries_with_committer(&[], |_, _| Ok(())).expect("empty is fine"); - assert_eq!(pushed, 0); - } - - #[test] - fn push_entries_with_committer_failure_surfaces_committed_failed_not_attempted() { - // Mock committer: succeed for first 2 keys, fail at third. - let entries = vec![ - ("k1".to_owned(), "v1".to_owned()), - ("k2".to_owned(), "v2".to_owned()), - ("k3".to_owned(), "v3".to_owned()), - ("k4".to_owned(), "v4".to_owned()), - ("k5".to_owned(), "v5".to_owned()), - ]; - let mut calls: usize = 0; - let err = push_entries_with_committer(&entries, |key, _| { - calls = calls.saturating_add(1); - if key == "k3" { - Err("simulated fastly stderr".to_owned()) - } else { - Ok(()) - } - }) - .expect_err("middle failure must error"); - // Committer was invoked for k1, k2, k3 and stopped. - assert_eq!(calls, 3_usize, "no retries beyond failure point"); - // Error names all three categories. - assert!(err.contains("k1") && err.contains("k2"), "committed: {err}"); - assert!( - err.contains("Failed: `k3`"), - "failed entry named exactly: {err}" - ); - assert!( - err.contains("k4") && err.contains("k5"), - "not-attempted: {err}" - ); - assert!(err.contains("simulated fastly stderr"), "inner err: {err}"); - // Counts are sane. - assert!( - err.contains("committing 2 of 5 entries"), - "committed/total count: {err}" - ); - // The failed entry's outcome is UNKNOWN and recovery is a full idempotent - // re-run, not a hand-resume from a claimed boundary. - assert!( - err.contains("UNKNOWN") && err.contains("outcome unknown"), - "failed outcome must be stated unknown: {err}" - ); - assert!( - err.contains("re-run the SAME") && err.contains("idempotent"), - "recovery must be a full idempotent re-run: {err}" - ); - assert!( - !err.contains("safe to skip on retry"), - "must not claim committed entries can be skipped from a known boundary: {err}" - ); - } - - #[test] - fn push_entries_with_committer_first_entry_failure_reports_zero_committed() { - let entries = vec![ - ("only".to_owned(), "val".to_owned()), - ("never".to_owned(), "tried".to_owned()), - ]; - let err = push_entries_with_committer(&entries, |_, _| Err("nope".to_owned())) - .expect_err("first-entry failure"); - assert!(err.contains("committing 0 of 2"), "zero committed: {err}"); - assert!( - err.contains("Failed: `only`"), - "first-entry failure named: {err}" - ); - assert!( - err.contains("never"), - "second entry as not-attempted: {err}" - ); - } - - #[test] - fn push_entries_with_committer_last_entry_failure_reports_n_minus_one_committed() { - let entries = vec![ - ("a".to_owned(), "1".to_owned()), - ("b".to_owned(), "2".to_owned()), - ("c".to_owned(), "3".to_owned()), - ]; - let err = push_entries_with_committer(&entries, |key, _| { - if key == "c" { - Err("late failure".to_owned()) - } else { - Ok(()) - } - }) - .expect_err("last-entry failure"); - assert!(err.contains("committing 2 of 3"), "n-1 committed: {err}"); - assert!( - err.contains("Not attempted: []"), - "zero not-attempted when the last entry fails: {err}" - ); - } - - // ---------- looks_like_already_exists ---------- - - #[test] - fn looks_like_already_exists_recognises_common_phrasings() { - // Real-shaped fastly CLI error strings (paraphrased; the - // CLI varies across versions). Each must be detected so - // create_fastly_store can treat it as idempotent success. - assert!(looks_like_already_exists( - "Error: a kv-store with that name already exists", - "kv", - )); - assert!(looks_like_already_exists( - "ERROR: Conflict (409): duplicate kv_store name", - "kv", - )); - assert!(looks_like_already_exists( - "A config-store with this name already exists", - "config", - )); - // Spaced form: some fastly CLI versions emit prose - // ("kv store"); accept it alongside the punctuated forms. - assert!(looks_like_already_exists( - "Error: kv store conflict: name already in use", - "kv", - )); - } - - #[test] - fn looks_like_already_exists_rejects_unrelated_errors() { - assert!(!looks_like_already_exists( - "Error: unauthenticated; run `fastly profile create`", - "kv", - )); - assert!(!looks_like_already_exists( - "Error: network unreachable", - "kv", - )); - assert!(!looks_like_already_exists("", "kv")); - } - - #[test] - fn looks_like_already_exists_rejects_unrelated_conflict_errors() { - // The earlier wider heuristic swallowed ANY stderr - // containing "conflict" or "already exists", which would - // misread an unrelated 409 from a different fastly - // subcommand (e.g. a service-version conflict during a - // parallel deploy) as idempotent store-create success. - // Now we require the kind context too, so unrelated - // conflicts surface as failures. - assert!( - !looks_like_already_exists( - "Error: 409 Conflict on /service/abc/version/42 -- already exists", - "kv", - ), - "service-version conflict must NOT be misread as kv-store idempotency" - ); - assert!( - !looks_like_already_exists( - "Error: invalid duplicate request; check name resolution", - "kv", - ), - "unrelated `duplicate ... name` AND-match must NOT trigger" - ); - // And the kind must match: a config-store conflict must - // not look-like-already-exists for a kv-store create call. - assert!( - !looks_like_already_exists("Error: a config-store with that name already exists", "kv",), - "wrong-kind conflict must NOT trigger" - ); - } - - // ---------- setup_block_present ---------- - - #[test] - fn setup_block_present_true_when_table_exists() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write( - &path, - "name = \"demo\"\n[setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n", - ) - .expect("write"); - assert!(setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); - } - - /// The three provisioning parsers must NOT echo a malformed fastly.toml's - /// source text (which can contain a stored secret) on a parse failure. - #[test] - fn provisioning_parsers_redact_malformed_toml() { - const SENTINEL: &str = "SUPER_SECRET_IN_A_BROKEN_LINE"; - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - // Malformed TOML whose offending line carries a secret. - fs::write(&path, format!("service_id = \"{SENTINEL}\" = broken\n")).expect("write"); - - let errs = [ - read_fastly_service_id(&path).expect_err("malformed toml must error"), - setup_block_present(&path, "kv", TEST_KV_ID).expect_err("malformed toml must error"), - append_fastly_setup(&path, "kv", TEST_KV_ID).expect_err("malformed toml must error"), - ]; - for err in &errs { - assert!( - !err.contains(SENTINEL), - "a parse error must not echo the stored value: {err}" - ); - assert!( - err.contains("redacted"), - "error should say it redacted: {err}" - ); - } - } - - #[test] - fn setup_block_present_false_when_id_missing() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n[setup.kv_stores.other]\n").expect("write"); - assert!(!setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); - } - - #[test] - fn setup_block_present_false_for_missing_file() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("does-not-exist.toml"); - assert!(!setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); - } - - #[test] - fn setup_block_present_true_when_only_setup_exists() { - // Post-F6 (PR #269 round 2): `setup_block_present` only - // checks `[setup._stores.]`. The pre-fix check - // ALSO required `[local_server._stores.]`, but - // writing an empty `[local_server.*]` table didn't match - // fastly's local-server schema (config-stores need - // `format` + contents, kv/secret stores need a JSON file - // or `{key, data}` entries). Local-server seeding moved - // to `config push --adapter fastly --local`, so probe - // only cares about `[setup]` now. - let dir = tempdir().expect("tempdir"); - let only_setup = dir.path().join("only_setup.toml"); - fs::write(&only_setup, "name = \"demo\"\n[setup.kv_stores.sessions]\n").expect("write"); - assert!( - setup_block_present(&only_setup, "kv", TEST_KV_ID).expect("probe"), - "[setup.*] alone is now sufficient: {only_setup:?}" - ); - - let only_local = dir.path().join("only_local.toml"); - fs::write( - &only_local, - "name = \"demo\"\n[local_server.kv_stores.sessions]\n", - ) - .expect("write"); - assert!( - !setup_block_present(&only_local, "kv", TEST_KV_ID).expect("probe"), - "[local_server.*] alone is NOT a provisioned-setup signal" - ); - } - - // ---------- append_fastly_setup ---------- - - #[test] - fn append_fastly_setup_creates_setup_table_in_minimal_file() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write"); - append_fastly_setup(&path, "kv", TEST_KV_ID).expect("append"); - let after = fs::read_to_string(&path).expect("read back"); - assert!( - after.contains("[setup.kv_stores.sessions]"), - "setup table added: {after}" - ); - // Post-F6: no `[local_server.*]` write — that empty stanza - // didn't satisfy fastly's local-server schema and made - // `fastly compute serve` error or skip the store. Local- - // server seeding is now `config push --adapter fastly - // --local`'s job. - assert!( - !after.contains("[local_server.kv_stores.sessions]"), - "[local_server.*] empty table no longer written by provision: {after}" - ); - assert!( - after.contains("name = \"demo\""), - "preserved original keys: {after}" - ); - } - - #[test] - fn append_fastly_setup_appends_alongside_existing_kind_tables() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "[setup.kv_stores.cache]\n").expect("write"); - append_fastly_setup(&path, "kv", TEST_KV_ID).expect("append"); - let after = fs::read_to_string(&path).expect("read back"); - assert!( - after.contains("[setup.kv_stores.cache]"), - "existing entry kept: {after}" - ); - assert!( - after.contains("[setup.kv_stores.sessions]"), - "new entry added: {after}" - ); - } - - #[test] - fn append_fastly_setup_is_idempotent_on_duplicate_id() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "[setup.kv_stores.sessions]\nfoo = \"keep\"\n").expect("write"); - append_fastly_setup(&path, "kv", TEST_KV_ID).expect("idempotent append"); - let after = fs::read_to_string(&path).expect("read back"); - assert!( - after.contains("foo = \"keep\""), - "did not stomp existing key: {after}" - ); - } - - #[test] - fn append_fastly_setup_creates_file_when_missing() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - // Note: no fs::write — file starts absent. - append_fastly_setup(&path, "config", TEST_CONFIG_ID).expect("create"); - let after = fs::read_to_string(&path).expect("read back"); - assert!(after.contains("[setup.config_stores.app_config]")); - assert!( - !after.contains("[local_server.config_stores.app_config]"), - "[local_server.*] no longer written by provision: {after}" - ); - } - - #[test] - fn append_fastly_setup_preserves_top_comments() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write( - &path, - "# managed by hand -- please keep this line\nname = \"demo\"\n", - ) - .expect("write"); - append_fastly_setup(&path, "secret", TEST_SECRET_ID).expect("append"); - let after = fs::read_to_string(&path).expect("read back"); - assert!( - after.contains("# managed by hand"), - "preserved comment: {after}" - ); - } - - // ---------- write_fastly_local_config_store (config push --local) ---------- - - #[test] - fn write_fastly_local_config_store_creates_inline_block_in_minimal_file() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write"); - let entries = vec![ - ("greeting".to_owned(), "hello".to_owned()), - ("service.timeout_ms".to_owned(), "1500".to_owned()), - ]; - write_fastly_local_config_store(&path, TEST_CONFIG_ID, &entries, &[]).expect("write"); - let after = fs::read_to_string(&path).expect("read back"); - assert!( - after.contains(&format!("[local_server.config_stores.{TEST_CONFIG_ID}]")), - "store table: {after}" - ); - assert!( - after.contains("format = \"inline-toml\""), - "format field: {after}" - ); - assert!( - after.contains(&format!( - "[local_server.config_stores.{TEST_CONFIG_ID}.contents]" - )), - "contents table: {after}" - ); - assert!(after.contains("greeting = \"hello\""), "key 1: {after}"); - assert!( - after.contains("\"service.timeout_ms\" = \"1500\""), - "dotted key quoted: {after}" - ); - assert!(after.contains("name = \"demo\""), "preserved: {after}"); - } - - /// An existing `format = "json"` / `"file"` store points at an EXTERNAL file. - /// Converting it here would either produce a manifest the local server rejects - /// (a stray `file` key) or silently discard the sibling entries that file - /// holds. The push must REFUSE and leave the manifest untouched, not convert. - #[test] - fn write_fastly_local_config_store_refuses_incompatible_format() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - let before = format!( - "name = \"demo\"\n\n[local_server.config_stores.{TEST_CONFIG_ID}]\nformat = \"json\"\nfile = \"cfg.json\"\n", - ); - fs::write(&path, &before).expect("write"); - let err = write_fastly_local_config_store( - &path, - TEST_CONFIG_ID, - &[("greeting".to_owned(), "hello".to_owned())], - &[], - ) - .expect_err("a non-inline store must be refused, not converted"); - assert!( - err.contains("refusing to push") && err.contains("inline-toml"), - "must refuse and point at migration: {err}" - ); - // The manifest is left exactly as it was. - let after = fs::read_to_string(&path).expect("read back"); - assert_eq!(after, before, "the manifest must be untouched on refusal"); - } - - #[test] - fn write_fastly_local_config_store_replaces_existing_block_on_re_push() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write"); - write_fastly_local_config_store( - &path, - TEST_CONFIG_ID, - &[("greeting".to_owned(), "stale".to_owned())], - &[], - ) - .expect("first write"); - write_fastly_local_config_store( - &path, - TEST_CONFIG_ID, - &[("greeting".to_owned(), "fresh".to_owned())], - &[], - ) - .expect("second write"); - let after = fs::read_to_string(&path).expect("read back"); - assert!(after.contains("greeting = \"fresh\""), "new value: {after}"); - assert!( - !after.contains("greeting = \"stale\""), - "stale value dropped: {after}" - ); - } - - #[test] - fn write_fastly_local_config_store_preserves_unrelated_blocks() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - let original = "\ -[setup.kv_stores.sessions] - -[[local_server.kv_stores.sessions]] -key = \"__init__\" -data = \"\" - -[scripts] -build = \"cargo build --release\" -"; - fs::write(&path, original).expect("write"); - write_fastly_local_config_store( - &path, - TEST_CONFIG_ID, - &[("greeting".to_owned(), "hi".to_owned())], - &[], - ) - .expect("write"); - let after = fs::read_to_string(&path).expect("read back"); - assert!( - after.contains("[setup.kv_stores.sessions]"), - "setup KV kept: {after}" - ); - assert!(after.contains("[scripts]"), "scripts table kept: {after}"); - assert!( - after.contains("build = \"cargo build --release\""), - "scripts value kept: {after}" - ); - assert!( - after.contains(&format!( - "[local_server.config_stores.{TEST_CONFIG_ID}.contents]" - )), - "new config_stores block added: {after}" - ); - } - - #[test] - fn write_fastly_local_config_store_creates_file_when_missing() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - // No fs::write — file absent. - write_fastly_local_config_store( - &path, - TEST_CONFIG_ID, - &[("greeting".to_owned(), "hi".to_owned())], - &[], - ) - .expect("write"); - let after = fs::read_to_string(&path).expect("read back"); - assert!(after.contains(&format!( - "[local_server.config_stores.{TEST_CONFIG_ID}.contents]" - ))); - assert!(after.contains("greeting = \"hi\"")); - } - - // ---------- provision (dry-run + error path) ---------- - - #[test] - fn provision_dry_run_does_not_invoke_fastly() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write"); - let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); - let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); - let secret_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_SECRET_ID]); - let stores = ProvisionStores { - config: &config_ids, - kv: &kv_ids, - secrets: &secret_ids, - }; - let out = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, 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!( - out[3].contains("would run `fastly config-store create --name=edgezero_runtime_env`"), - "runtime-env store row: {out:?}", - ); - // Manifest untouched. - let after = fs::read_to_string(&path).expect("read"); - assert_eq!(after, "name = \"demo\"\n", "dry-run mutated fastly.toml"); - } - - #[test] - fn provision_errors_when_adapter_manifest_path_missing() { - let dir = tempdir().expect("tempdir"); - let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); - let stores = ProvisionStores { - config: &[], - kv: &kv_ids, - secrets: &[], - }; - let err = FastlyCliAdapter - .provision(dir.path(), None, None, &stores, true) - .expect_err("missing adapter manifest path must error"); - assert!( - err.contains("fastly.toml"), - "error names what's missing: {err}" - ); - } - - #[test] - fn provision_with_no_declared_stores_says_so() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - // Pre-populate the runtime-env block so the provision flow's - // unconditional runtime-env step skips (otherwise it would - // shell out to real `fastly` to create the store). - fs::write( - &path, - "name = \"demo\"\n[setup.config_stores.edgezero_runtime_env]\n", - ) - .expect("write"); - let stores = ProvisionStores { - config: &[], - kv: &[], - secrets: &[], - }; - let out = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect("no-store provision is fine"); - assert_eq!(out, vec!["fastly has no declared stores to provision"]); - } - - #[test] - fn provision_skips_id_when_setup_block_already_present() { - // setup_block_present's role in the flow: re-running - // provision after the user already declared a store in - // fastly.toml must be a no-op (no shell-out to fastly). - // We can verify this in a real (non-dry-run) call because - // the skip path bypasses create_fastly_store entirely. - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write( - &path, - "[setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n\ - [setup.config_stores.edgezero_runtime_env]\n", - ) - .expect("write"); - let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); - let stores = ProvisionStores { - config: &[], - kv: &kv_ids, - secrets: &[], - }; - let out = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect("skip path succeeds without invoking fastly"); - assert_eq!(out.len(), 1); - assert!(out[0].contains("already declared"), "got: {out:?}"); - } - - /// When `fastly.toml` declares `service_id`, the next - /// `fastly compute deploy` skips `[setup]` entirely. provision - /// must emit the `fastly resource-link create` remediation for - /// every store it creates -- including the implicit - /// `edgezero_runtime_env` store the runtime override path - /// depends on. Without this, a freshly-provisioned override - /// store would not be linked to the already-deployed service - /// and the runtime would silently fall back to baked defaults. - #[test] - fn provision_emits_resource_link_note_for_runtime_env_on_existing_service() { - // Dry-run only -- we just want to drive the resource_link_note - // helper for the runtime-env store branch. The real-create - // path can't run in tests (would shell out to `fastly`). - // The dry-run output line for runtime-env doesn't include the - // note (the helper only fires on real create), so we test the - // helper directly here. - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\nservice_id = \"abc123svc\"\n").expect("write"); - let note = resource_link_note(&path, "config", "edgezero_runtime_env") - .expect("read service_id") - .expect("note present when service_id set"); - assert!( - note.contains("service_id = \"abc123svc\""), - "note quotes the service id: {note}" - ); - assert!( - note.contains("fastly config-store list --json"), - "note tells operator how to find the store id: {note}" - ); - assert!( - note.contains("name=`edgezero_runtime_env`"), - "note names the runtime override store: {note}" - ); - assert!( - note.contains( - "fastly resource-link create --service-id=abc123svc --resource-id= --version=latest --autoclone --name=edgezero_runtime_env" - ), - "note carries the full resource-link command: {note}" - ); - } - - /// And the inverse: no `service_id` (a service that hasn't been - /// deployed yet) means `[setup]` will be applied on the next - /// `compute deploy`, so no manual resource-link step is needed. - /// The helper must return `None` to avoid noisy false-positive - /// guidance. - #[test] - fn provision_skips_resource_link_note_when_service_undeployed() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write"); - let note = - resource_link_note(&path, "config", "edgezero_runtime_env").expect("read service_id"); - assert!( - note.is_none(), - "no service_id => no resource-link prompt: {note:?}" - ); - } - - // ---------- find_config_store_id ---------- - - #[test] - fn find_config_store_id_matches_bare_array_by_name() { - let stdout = format!( - r#"[ - {{"id": "abc123", "name": "{TEST_CONFIG_ID}"}}, - {{"id": "def456", "name": "other_store"}} - ]"# - ); - match find_config_store_id(&stdout, TEST_CONFIG_ID) { - ConfigStoreLookup::Found(id) => assert_eq!(id, "abc123"), - ConfigStoreLookup::NotFound => panic!("expected Found, got NotFound"), - ConfigStoreLookup::SchemaDrift(detail) => { - panic!("expected Found, got SchemaDrift({detail})") - } - } - } - - #[test] - fn find_config_store_id_tolerates_items_envelope() { - let stdout = format!( - r#"{{"items": [ - {{"id": "xyz789", "name": "{TEST_CONFIG_ID}"}} - ]}}"# - ); - match find_config_store_id(&stdout, TEST_CONFIG_ID) { - ConfigStoreLookup::Found(id) => assert_eq!(id, "xyz789"), - ConfigStoreLookup::NotFound => panic!("expected Found, got NotFound"), - ConfigStoreLookup::SchemaDrift(detail) => { - panic!("expected Found, got SchemaDrift({detail})") - } - } - } - - #[test] - fn find_config_store_id_distinguishes_not_found_from_match_failure() { - // JSON parses cleanly, entries are well-formed - // (`name` + `id` strings present), but no entry matches - // → NotFound. Operator likely needs to run `provision`. - let stdout = r#"[{"id": "abc", "name": "other"}]"#; - assert!(matches!( - find_config_store_id(stdout, "missing"), - ConfigStoreLookup::NotFound - )); - } - - #[test] - fn find_config_store_id_flags_schema_drift_on_malformed_json() { - // Unparseable bytes are NOT a "store not found" — they're - // a "fastly CLI output format changed" signal. Operator - // needs different recovery (file a bug, pin CLI version) - // than for the "store doesn't exist yet" case. - let drift = find_config_store_id("not json", "anything"); - assert!( - matches!(drift, ConfigStoreLookup::SchemaDrift(_)), - "non-JSON stdout must be schema drift, got {drift:?}" - ); - let empty = find_config_store_id("", "anything"); - assert!( - matches!(empty, ConfigStoreLookup::SchemaDrift(_)), - "empty stdout must be schema drift, got {empty:?}" - ); - } - - #[test] - fn find_config_store_id_flags_schema_drift_when_shape_unexpected() { - // JSON parses but the top-level is neither a bare array - // nor an `{items: [...]}` envelope. - let stdout = r#"{"namespace": "fastly", "list": []}"#; - match find_config_store_id(stdout, "any") { - ConfigStoreLookup::SchemaDrift(detail) => { - assert!( - detail.contains("bare array") || detail.contains("items"), - "schema-drift detail names the expected shapes: {detail}" - ); - } - ConfigStoreLookup::Found(id) => panic!("expected SchemaDrift, got Found({id})"), - ConfigStoreLookup::NotFound => panic!("expected SchemaDrift, got NotFound"), - } - } - - #[test] - fn find_config_store_id_flags_schema_drift_when_entries_lack_name_id() { - // Array of objects but none have BOTH string `name` and - // string `id` fields — suggests schema rename (e.g. - // fastly renamed `name` → `title`). - let stdout = format!(r#"[{{"title": "{TEST_CONFIG_ID}", "uid": "abc"}}]"#); - let drift = find_config_store_id(&stdout, TEST_CONFIG_ID); - assert!( - matches!(drift, ConfigStoreLookup::SchemaDrift(_)), - "entries lacking name/id must be schema drift, got {drift:?}" - ); - } - - #[test] - fn find_config_store_id_fails_closed_on_a_malformed_row() { - // A row that lacks a non-empty `name`/`id` could BE the requested store - // (its unreadable name might have matched). Treating the listing as a - // definite NotFound would authorise an overwrite of a store that exists, - // so a malformed row must be SchemaDrift (a hard error), not NotFound -- - // even when another row is well-formed. - let stdout = format!( - r#"[{{"name": "", "id": "abc"}}, {{"name": "{TEST_CONFIG_ID}", "id": "store-1"}}]"# - ); - let drift = find_config_store_id(&stdout, "some-other-store"); - assert!( - matches!(drift, ConfigStoreLookup::SchemaDrift(_)), - "an empty-name row must fail closed, got {drift:?}" - ); - } - - #[test] - fn find_config_store_id_rejects_duplicate_names() { - // A duplicate name means we are not reading one consistent view of the - // store, so resolving an id off it is ambiguous -> fail closed. - let stdout = format!( - r#"[{{"name": "{TEST_CONFIG_ID}", "id": "a"}}, {{"name": "{TEST_CONFIG_ID}", "id": "b"}}]"# - ); - let drift = find_config_store_id(&stdout, TEST_CONFIG_ID); - assert!( - matches!(drift, ConfigStoreLookup::SchemaDrift(_)), - "a duplicate name must fail closed, got {drift:?}" - ); - } - - #[test] - fn find_config_store_id_returns_not_found_for_empty_array() { - // Empty array IS a valid "store doesn't exist yet" signal, - // not schema drift — fastly CLI legitimately returns `[]` - // when no config-stores exist. - let drift = find_config_store_id("[]", "any"); - assert!( - matches!(drift, ConfigStoreLookup::NotFound), - "empty array must be NotFound, got {drift:?}" - ); - } - - #[test] - fn parse_rfc3339_secs_rounds_a_fraction_up() { - let whole = parse_rfc3339_secs("2026-01-01T00:00:42Z").expect("whole"); - // A fractional second rounds UP to the next whole second, so the computed - // age stays conservative and a key never ages into deletion early. - assert_eq!( - parse_rfc3339_secs("2026-01-01T00:00:42.998Z"), - Some(whole + 1), - "a fractional creation time must round UP, not floor" - ); - // Even a tiny fraction rounds up. - assert_eq!( - parse_rfc3339_secs("2026-01-01T00:00:42.000001Z"), - Some(whole + 1) - ); - // A whole-second stamp is unchanged. - assert_eq!(parse_rfc3339_secs("2026-01-01T00:00:42.000Z"), Some(whole)); - } - - // ---------- push_config_entries (dry-run + error paths) ---------- - - #[test] - fn push_dry_run_does_not_invoke_fastly() { - let dir = tempdir().expect("tempdir"); - let entries = vec![ - ("greeting".to_owned(), "hello".to_owned()), - ("feature.new_checkout".to_owned(), "false".to_owned()), - ]; - let out = FastlyCliAdapter - .push_config_entries( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - true, - ) - .expect("dry-run succeeds"); - // First line names the resolve+publish flow; then one preview line per - // key. A push no longer reclaims anything (see `config gc`), so there is - // no GC-intent line. - assert_eq!(out.len(), 1 + entries.len(), "header + per-entry preview"); - assert!( - out[0].contains("would resolve fastly config-store `app_config`") - && out[0].contains("push entries"), - "dry-run header describes the would-be flow: {out:?}" - ); - assert!( - out.iter().any(|line| line.contains("`greeting`")), - "dry-run lists `greeting`: {out:?}" - ); - assert!( - out.iter() - .any(|line| line.contains("`feature.new_checkout`")), - "dry-run lists `feature.new_checkout`: {out:?}" - ); - } - - #[test] - fn push_with_no_entries_reports_no_op_without_invoking_fastly() { - let dir = tempdir().expect("tempdir"); - let out = FastlyCliAdapter - .push_config_entries( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[], - &AdapterPushContext::new(), - false, - ) - .expect("zero-entry push is fine"); - assert_eq!(out.len(), 1); - assert!( - out[0].contains("no config entries"), - "status line names the no-op: {out:?}" - ); - } - - // ---------- read_config_entry_local ---------- - - #[test] - fn read_local_returns_missing_store_when_fastly_toml_absent() { - let dir = tempdir().expect("tempdir"); - // No fastly.toml written — file missing. - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("missing file is not an error"); - assert!( - matches!(result, ReadConfigEntry::MissingStore), - "absent fastly.toml => MissingStore" - ); - } - - #[test] - fn read_local_returns_missing_store_when_no_local_server_contents() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - // fastly.toml exists but has no [local_server.config_stores.*] block. - fs::write(&path, "name = \"demo\"\n[setup.config_stores.app_config]\n").expect("write"); - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("missing local_server block is not an error"); - assert!( - matches!(result, ReadConfigEntry::MissingStore), - "no local_server stanza => MissingStore" - ); - } - - #[test] - fn read_local_returns_missing_key_when_key_absent_from_contents() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - // Write a local_server block with a different key so the store exists - // but the requested key is absent. - fs::write( - &path, - format!( - "name = \"demo\"\n\ - [local_server.config_stores.{TEST_CONFIG_ID}]\n\ - format = \"inline-toml\"\n\ - [local_server.config_stores.{TEST_CONFIG_ID}.contents]\n\ - other_key = \"other_value\"\n" - ), - ) - .expect("write"); - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("missing key is not an error"); - assert!( - matches!(result, ReadConfigEntry::MissingKey), - "key absent from contents => MissingKey" - ); - } - - #[test] - fn read_local_returns_present_when_key_exists_in_contents() { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write initial toml"); - - // Use a valid BlobEnvelope value — the resolver requires BlobEnvelope - // or chunk-pointer JSON; raw strings are not accepted post-chunking. - let envelope_json = serde_json::to_string(&BlobEnvelope::new( - json!({"hello": "fastly"}), - "2026-06-22T00:00:00Z".into(), - )) - .expect("serialize"); - write_fastly_local_config_store( - &path, - TEST_CONFIG_ID, - &[("greeting".to_owned(), envelope_json.clone())], - &[], - ) - .expect("setup write"); - - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("key present"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present variant"); - }; - assert_eq!(value, envelope_json, "value matches what was written"); - } - - #[test] - fn read_local_roundtrips_with_push_local() { - // Write via push_config_entries_local, then read via - // read_config_entry_local — the two must agree on the value. - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write"); - - // push_config_entries_local passes the value through the chunk-pointer - // helper which stores it verbatim when ≤ 8 000 chars. The reader then - // resolves it through the same resolver that requires BlobEnvelope JSON. - let envelope_json = serde_json::to_string(&BlobEnvelope::new( - json!({"hello": "roundtrip"}), - "2026-06-22T00:00:00Z".into(), - )) - .expect("serialize"); - let entries = vec![("greeting".to_owned(), envelope_json.clone())]; - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - false, - ) - .expect("push succeeds"); - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("read succeeds"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present after push+read roundtrip"); - }; - assert_eq!(value, envelope_json, "roundtrip value matches"); - } - - #[test] - fn read_local_requires_adapter_manifest_path() { - let dir = tempdir().expect("tempdir"); - let result = FastlyCliAdapter.read_config_entry_local( - dir.path(), - None, // adapter_manifest_path missing - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ); - match result { - Err(err) => assert!( - err.contains("[adapters.fastly.adapter].manifest"), - "error names the missing field: {err}" - ), - Ok(_) => panic!("expected Err when adapter_manifest_path is None"), - } - } - - // ---------- read_config_entry (fake fastly, remote shell-out) ---------- - - /// Build a tempdir containing a `fastly` shim script that: - /// - Responds to `config-store list --json` with a store-list JSON containing - /// `TEST_CONFIG_ID` mapped to `store-abc123`. - /// - Responds to `config-store-entry describe ...` with `stdout_body` on - /// stdout and `stderr_body` on stderr, exiting with `exit_code`. - /// - /// Payloads are written to separate sibling files so shell-active chars - /// in the content don't get re-interpreted by the script. - #[cfg(unix)] - fn fake_fastly_returning( - stdout_body: &str, - stderr_body: &str, - exit_code: i32, - ) -> tempfile::TempDir { - fake_fastly_returning_with_keys(stdout_body, stderr_body, exit_code, &[]) - } - - /// As [`fake_fastly_returning`], but also serves `config-store-entry list` - /// with a bare array of the `entry_list_keys` as `item_key` entries. A - /// describe FAILURE is confirmed against this listing: keys present here read - /// as a present-but-unreadable hard error, keys absent read as `MissingKey`. - #[cfg(unix)] - fn fake_fastly_returning_with_keys( - stdout_body: &str, - stderr_body: &str, - exit_code: i32, - entry_list_keys: &[&str], - ) -> tempfile::TempDir { - use std::os::unix::fs::PermissionsExt as _; - let dir = tempdir().expect("tempdir"); - let script_path = dir.path().join("fastly"); - let stdout_file = dir.path().join("stdout_payload.txt"); - let stderr_file = dir.path().join("stderr_payload.txt"); - let list_file = dir.path().join("list_payload.txt"); - let entry_list_file = dir.path().join("entry_list_payload.txt"); - // Store-list JSON: bare array with one entry matching TEST_CONFIG_ID. - let list_json = format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#); - let entry_list_json = { - let items: Vec = entry_list_keys - .iter() - .map(|key| format!(r#"{{"item_key":{}}}"#, serde_json::to_string(key).unwrap())) - .collect(); - format!("[{}]", items.join(",")) - }; - fs::write(&stdout_file, stdout_body).expect("write stdout payload"); - fs::write(&stderr_file, stderr_body).expect("write stderr payload"); - fs::write(&list_file, list_json).expect("write list payload"); - fs::write(&entry_list_file, entry_list_json).expect("write entry list payload"); - let script = format!( - "#!/bin/sh\nif [ \"$1\" = \"config-store\" ]; then\n cat '{}'\n exit 0\nfi\nif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"list\" ]; then\n cat '{}'\n exit 0\nfi\ncat '{}'\ncat '{}' >&2\nexit {exit_code}\n", - list_file.display(), - entry_list_file.display(), - stdout_file.display(), - stderr_file.display(), - ); - fs::write(&script_path, script).expect("write fastly script"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod +x"); - dir - } - - /// Build a fake `fastly` that logs each argv token (one per line) to - /// `out_path`, handles the list call correctly, and exits 0 for both calls. - #[cfg(unix)] - fn fake_fastly_argv_log(out_path: &Path) -> tempfile::TempDir { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - use std::os::unix::fs::PermissionsExt as _; - let dir = tempdir().expect("tempdir"); - let script_path = dir.path().join("fastly"); - let list_file = dir.path().join("list_payload.txt"); - let entry_file = dir.path().join("entry_payload.txt"); - let list_json = format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#); - // item_value must be a valid BlobEnvelope JSON so the resolver accepts it. - let envelope_json = serde_json::to_string(&BlobEnvelope::new( - json!({"v": "logged"}), - "2026-06-22T00:00:00Z".into(), - )) - .expect("serialize"); - let entry_json = format!( - r#"{{"item_value":{},"store_id":"store-abc123"}}"#, - serde_json::to_string(&envelope_json).expect("escape") - ); - fs::write(&list_file, list_json).expect("write list payload"); - fs::write(&entry_file, &entry_json).expect("write entry payload"); - let script = format!( - "#!/bin/sh\nfor arg in \"$@\"; do printf '%s\\n' \"$arg\" >> '{}'; done\nif [ \"$1\" = \"config-store\" ]; then\n cat '{}'\n exit 0\nfi\nif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"list\" ]; then\n echo '[]'\n exit 0\nfi\ncat '{}'\nexit 0\n", - out_path.display(), - list_file.display(), - entry_file.display(), - ); - fs::write(&script_path, script).expect("write script"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod +x"); - dir - } - - /// Process-wide mutex serialising PATH-mutating tests so parallel - /// test threads don't race on the environment variable. - #[cfg(unix)] - fn path_mutation_guard() -> &'static Mutex<()> { - use std::sync::OnceLock; - static GUARD: OnceLock> = OnceLock::new(); - GUARD.get_or_init(|| Mutex::new(())) - } - - #[cfg(unix)] - #[test] - fn read_remote_returns_present_on_success() { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - // Fake fastly: list succeeds with app_config → store-abc123; - // describe returns valid JSON with item_value that is a BlobEnvelope. - let envelope = serde_json::to_string(&BlobEnvelope::new( - json!({"hello": "fastly"}), - "2026-06-22T00:00:00Z".into(), - )) - .expect("serialize"); - let entry_json = format!( - r#"{{"item_value":{},"store_id":"store-abc123"}}"#, - serde_json::to_string(&envelope).expect("escape") - ); - let fake = fake_fastly_returning(&entry_json, "", 0); - let _path = PathPrepend::new(fake.path()); - let result = FastlyCliAdapter - .read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("fake fastly exit-0 must succeed"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present"); - }; - assert_eq!(value, envelope); - } - - #[cfg(unix)] - #[test] - fn read_remote_returns_missing_key_when_confirmed_absent() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - // describe exits non-zero, and the complete store listing (empty here) - // CONFIRMS the key is absent → MissingKey (not decided by the 404 alone). - let fake = fake_fastly_returning("", "Error: item not found", 1); - let _path = PathPrepend::new(fake.path()); - let result = FastlyCliAdapter - .read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("not-found maps to MissingKey (not Err)"); - assert!( - matches!(result, ReadConfigEntry::MissingKey), - "not-found stderr => MissingKey" - ); - } - - /// The Fastly impl distinguishes store-not-found from key-not-found via - /// `resolve_remote_config_store_id`: when the list call exits non-zero and - /// the error string contains "not found", `read_config_entry` returns - /// `MissingStore` without ever calling the describe subcommand. - #[cfg(unix)] - #[test] - fn read_remote_fails_closed_when_the_list_call_itself_errors() { - use std::os::unix::fs::PermissionsExt as _; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - // The list call EXITS NON-ZERO with "not found"-shaped stderr. That is an - // OPERATIONAL failure (auth/network/server), not proof the store is absent - // -- the absence signal is a SUCCESSFUL list that omits the store. So this - // must fail closed (a hard error the operator retries), NEVER MissingStore: - // reading it as absence could authorise an overwrite of a store we never - // actually queried. - let fake_dir = tempdir().expect("tempdir"); - let stderr_file = fake_dir.path().join("stderr_payload.txt"); - fs::write(&stderr_file, "Error: config store not found for service").expect("write stderr"); - let script_path = fake_dir.path().join("fastly"); - let script = format!( - "#!/bin/sh\ncat '{stderr}' >&2\nexit 1\n", - stderr = stderr_file.display(), - ); - fs::write(&script_path, script).expect("write script"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod +x"); - let _path = PathPrepend::new(fake_dir.path()); - let result = FastlyCliAdapter.read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ); - assert!( - result.is_err(), - "a failed list call must fail closed, not read as MissingStore" - ); - } - - #[cfg(unix)] - #[test] - fn read_remote_returns_missing_store_when_the_store_is_genuinely_absent() { - use std::os::unix::fs::PermissionsExt as _; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - // The list call SUCCEEDS and returns a valid, empty store array. The store - // is genuinely absent -> `no fastly config-store matches` -> MissingStore. - let fake_dir = tempdir().expect("tempdir"); - let script_path = fake_dir.path().join("fastly"); - fs::write(&script_path, "#!/bin/sh\necho '[]'\nexit 0\n").expect("write script"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod +x"); - let _path = PathPrepend::new(fake_dir.path()); - let result = FastlyCliAdapter - .read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("a successful list that omits the store maps to MissingStore"); - assert!( - matches!(result, ReadConfigEntry::MissingStore), - "store absent from a successful list => MissingStore" - ); - } - - /// Verify that `read_config_entry` invokes - /// `fastly config-store-entry describe --store-id= --key= --json` - /// (after the resolve step that calls `fastly config-store list --json`). - #[cfg(unix)] - #[test] - fn read_remote_invokes_correct_argv() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let argv_log = dir.path().join("argv.txt"); - let fake = fake_fastly_argv_log(&argv_log); - let _path = PathPrepend::new(fake.path()); - let result = FastlyCliAdapter - .read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("argv-log fake must succeed"); - assert!( - matches!(result, ReadConfigEntry::Present(_)), - "expected Present from argv-log fake" - ); - let captured = fs::read_to_string(&argv_log).expect("argv log"); - // The describe call must include these args (resolve call args - // are also captured but we only assert the describe shape here). - assert!( - captured.contains("config-store-entry"), - "must invoke config-store-entry; got:\n{captured}" - ); - assert!( - captured.contains("describe"), - "must pass describe subcommand; got:\n{captured}" - ); - assert!( - captured.contains("--store-id=store-abc123"), - "must pass resolved store id; got:\n{captured}" - ); - assert!( - captured.contains("--key=greeting"), - "must pass --key=; got:\n{captured}" - ); - assert!( - captured.contains("--json"), - "must pass --json flag; got:\n{captured}" - ); - } - - // ---------- chunked push integration tests ---------- - - /// Build a valid `BlobEnvelope` JSON string of approximately `target_len` bytes. - fn make_test_envelope(target_len: usize) -> String { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - let pad = "x".repeat(target_len.saturating_add(64)); - let data = json!({ "pad": pad }); - let raw = - serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:00Z".into())).unwrap(); - if raw.len() >= target_len { - let overhead = raw.len().saturating_sub(pad.len()); - let adjusted = "x".repeat(target_len.saturating_sub(overhead)); - let data2 = json!({ "pad": adjusted }); - serde_json::to_string(&BlobEnvelope::new(data2, "2026-06-22T00:00:00Z".into())).unwrap() - } else { - raw - } - } - - /// Build a fake `fastly` script whose describe response depends on - /// the `--key=` argument: `key_responses` maps key names to JSON - /// item-value responses. Falls back to exit 1 "not found" for unknown keys. - #[cfg(unix)] - fn fake_fastly_with_key_dispatch( - _dir: &Path, - key_responses: &[(String, String)], - ) -> tempfile::TempDir { - use std::fmt::Write as _; - use std::os::unix::fs::PermissionsExt as _; - let fake_dir = tempdir().expect("tempdir"); - let list_file = fake_dir.path().join("list.json"); - let list_json = format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#); - fs::write(&list_file, list_json).expect("write list"); - // The `config-store-entry list` response: a bare array of the keys present - // in `key_responses`. Absence confirmation lists the store and checks - // membership, so a key omitted here reads as CONFIRMED absent. Only - // `item_key` is needed (the keys-only listing is value-tolerant). - let entry_list_file = fake_dir.path().join("entry_list.json"); - let entries_json = { - let items: Vec = key_responses - .iter() - .map(|(key, _)| { - format!(r#"{{"item_key":{}}}"#, serde_json::to_string(key).unwrap()) - }) - .collect(); - format!("[{}]", items.join(",")) - }; - fs::write(&entry_list_file, entries_json).expect("write entry list"); - // Write each key response to a named file. - let mut dispatch_lines = String::new(); - for (key, response) in key_responses { - let resp_file = fake_dir.path().join(format!("resp_{key}.json")); - fs::write(&resp_file, response).expect("write resp"); - // Use exact-match: iterate argv and compare each token literally - // so that a root key like "app_config" does NOT match a chunk key - // like "app_config.__edgezero_chunks.abc.0". - writeln!( - dispatch_lines, - " for arg in \"$@\"; do if [ \"$arg\" = \"--key={key}\" ]; then cat '{}'; exit 0; fi; done", - resp_file.display() - ) - .expect("write to String is infallible"); - } - // `config-store` (store list) and `config-store-entry list` are served - // from their files; a `describe` for an unknown key exits 1 "not found", - // which the caller then CONFIRMS against the entry list. - let script = format!( - "#!/bin/sh\nif [ \"$1\" = \"config-store\" ]; then\n cat '{}'\n exit 0\nfi\nif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"list\" ]; then\n cat '{}'\n exit 0\nfi\n{dispatch_lines}echo 'Error: item not found' >&2\nexit 1\n", - list_file.display(), - entry_list_file.display() - ); - let script_path = fake_dir.path().join("fastly"); - fs::write(&script_path, &script).expect("write script"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod"); - fake_dir - } - - /// Fake `fastly` for cloud chunk-GC tests. Logs each - /// `config-store-entry` op ("describe " / "update " / - /// "delete ", plus "delete-argv ") to `oplog`. - /// - /// `root_describe_seq` gives the successive raw `item_value`s returned when - /// the ROOT key is described (call 1 = the pre-commit prior read, call 2 = - /// the post-commit read-back). `entry_list` is served for - /// `config-store-entry list` and is what reclamation derives generations - /// and supersession times from. `fail_delete_key` makes that one delete - /// exit non-zero. `describe_hard_error` makes the FIRST describe of each key - /// fail hard (so the prior read errors while the read-back still works). - #[cfg(unix)] - fn fake_fastly_gc( - root_key: &str, - root_describe_seq: &[String], - entry_list: &[(String, String, String)], - fail_delete_key: Option<&str>, - describe_hard_error: bool, - oplog: &Path, - ) -> tempfile::TempDir { - use std::os::unix::fs::PermissionsExt as _; - // Rendered with handlebars. Triple-stache `{{{ }}}` disables HTML - // escaping (paths are not markup); the shell's own `${var}` / - // `$(( ))` use single braces so they are literal text to handlebars. - const TEMPLATE: &str = r#"#!/bin/sh -if [ "$1" = "config-store" ]; then cat '{{{list}}}'; exit 0; fi -sub="$2" -key="" -for arg in "$@"; do case "$arg" in --key=*) key="${arg#--key=}";; esac; done -if [ "$sub" = "list" ]; then printf 'list\n' >> '{{{oplog}}}'; cat '{{{entries}}}'; exit 0; fi -if [ "$sub" = "update" ]; then cat >/dev/null; printf 'update %s\n' "$key" >> '{{{oplog}}}'; exit 0; fi -if [ "$sub" = "delete" ]; then printf 'delete %s\n' "$key" >> '{{{oplog}}}'; printf 'delete-argv %s\n' "$*" >> '{{{oplog}}}'; if [ "$key" = "{{{fail}}}" ]; then echo 'Error: 404 item not found' >&2; exit 1; fi; exit 0; fi -if [ "$sub" = "describe" ]; then - printf 'describe %s\n' "$key" >> '{{{oplog}}}' - cfile='{{{dir}}}/count_'"$key" - n=0; [ -f "$cfile" ] && n=$(cat "$cfile"); n=$((n+1)); printf '%s' "$n" > "$cfile" - {{#if hard_error}}if [ "$n" = "1" ]; then echo 'Error: internal server error' >&2; exit 1; fi{{/if}} - rf='{{{dir}}}/resp_'"$key"'_'"$n"'.json' - if [ -f "$rf" ]; then cat "$rf"; exit 0; fi - echo 'Error: item not found' >&2; exit 1 -fi -echo 'unexpected' >&2; exit 1 -"#; - let dir = tempdir().expect("tempdir"); - let list_file = dir.path().join("list.json"); - fs::write( - &list_file, - format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#), - ) - .expect("list"); - let entries_file = dir.path().join("entries.json"); - fs::write(&entries_file, entry_list_json(entry_list)).expect("entries"); - for (index, value) in root_describe_seq.iter().enumerate() { - let wrapped = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(value).expect("escape") - ); - let nth = index.saturating_add(1); - fs::write( - dir.path().join(format!("resp_{root_key}_{nth}.json")), - wrapped, - ) - .expect("resp"); - } - let data = serde_json::json!({ - "list": list_file.display().to_string(), - "entries": entries_file.display().to_string(), - "oplog": oplog.display().to_string(), - "dir": dir.path().display().to_string(), - "fail": fail_delete_key.unwrap_or(""), - "hard_error": describe_hard_error, - }); - let script = handlebars::Handlebars::new() - .render_template(TEMPLATE, &data) - .expect("render fake fastly script"); - let script_path = dir.path().join("fastly"); - fs::write(&script_path, script).expect("script"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod"); - dir - } - - /// Like `fake_fastly_gc`, but serves a VERBATIM `config-store-entry list` - /// payload so a test can present a shape `entry_list_json` cannot build - /// (e.g. a paginated envelope). - #[cfg(unix)] - fn fake_fastly_gc_raw_list( - root_key: &str, - raw_listing: &str, - oplog: &Path, - ) -> tempfile::TempDir { - let dir = fake_fastly_gc(root_key, &[], &[], None, false, oplog); - fs::write(dir.path().join("entries.json"), raw_listing).expect("raw entries"); - dir - } - - /// A `config-store-entry list --json` payload. The item VALUE is a - /// placeholder: reclamation must only ever use keys and timestamps. - #[cfg(unix)] - fn entry_list_json(items: &[(String, String, String)]) -> String { - let entries: Vec = items - .iter() - .map(|(key, created, value)| { - serde_json::json!({ - "item_key": key, - "created_at": created, - "item_value": value, - }) - }) - .collect(); - serde_json::to_string(&entries).expect("entry list json") - } - - /// An RFC-3339 stamp `secs` in the past (the shape Fastly returns). - #[cfg(unix)] - fn stamp_secs_ago(secs: u64) -> String { - let delta = chrono::Duration::seconds(i64::try_from(secs).unwrap_or(0)); - let now = chrono::Utc::now(); - now.checked_sub_signed(delta) - .unwrap_or(now) - .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) - } - - /// Every chunk of `envelope` as the listing would return it: REAL keys and - /// REAL payload bytes. - /// - /// The values are not decorative. `config gc` proves a generation is ours by - /// reassembling it and hashing the result against the content-address its - /// keys name, so a placeholder value would (correctly) fail verification and - /// never be reclaimed. Fixtures must be honest for these tests to mean - /// anything. - #[cfg(unix)] - fn listed_generation( - root_key: &str, - envelope: &str, - secs_ago: u64, - ) -> Vec<(String, String, String)> { - let (chunks, _) = chunked_parts(root_key, envelope); - let stamp = stamp_secs_ago(secs_ago); - chunks - .into_iter() - .map(|(key, value)| (key, stamp.clone(), value)) - .collect() - } - - /// The ROOT entry as the listing would return it: its value is the pointer, - /// which is how `config gc` learns which chunks are live. - #[cfg(unix)] - fn listed_root(root_key: &str, envelope: &str, secs_ago: u64) -> (String, String, String) { - let (_, pointer) = chunked_parts(root_key, envelope); - (root_key.to_owned(), stamp_secs_ago(secs_ago), pointer) - } - - /// A chunked envelope with a distinct payload per tag, padded to `pad` - /// characters so a caller can force a given number of chunks (7 000 bytes - /// each). - #[cfg(unix)] - fn gen_envelope_padded(tag: &str, pad: usize) -> String { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - let data = json!({ tag: "x".repeat(pad) }); - serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:00Z".to_owned())) - .expect("envelope") - } - - /// A chunked envelope with a distinct payload per tag. - #[cfg(unix)] - fn gen_envelope(tag: &str) -> String { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - let data = json!({ tag: "x".repeat(FASTLY_CONFIG_ENTRY_LIMIT) }); - serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:00Z".to_owned())) - .expect("envelope") - } - - /// Split a chunked envelope into (chunk `(key, value)` pairs, root pointer). - #[cfg(unix)] - fn chunked_parts(root_key: &str, envelope: &str) -> (Vec<(String, String)>, String) { - let entries = prepare_fastly_config_entries(root_key, envelope).expect("expand"); - let (_, pointer) = entries.last().expect("pointer").clone(); - let chunks = entries[..entries.len().saturating_sub(1)].to_vec(); - (chunks, pointer) - } - - /// Just the chunk KEYS of a generation (for delete assertions). - #[cfg(unix)] - fn chunk_keys_of(root_key: &str, envelope: &str) -> Vec { - let (chunks, _) = chunked_parts(root_key, envelope); - chunks.into_iter().map(|(key, _)| key).collect() - } - - #[cfg(unix)] - fn oplog_has(oplog: &Path, line: &str) -> bool { - fs::read_to_string(oplog) - .unwrap_or_default() - .lines() - .any(|entry| entry == line) - } - - #[cfg(unix)] - #[test] - fn push_config_entries_rejects_reserved_key() { - let dir = tempdir().expect("tempdir"); - let bad_key = format!("app_config{CHUNK_KEY_INFIX}deadbeef.0"); - let err = FastlyCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(bad_key.clone(), "{}".to_owned())], - &AdapterPushContext::new(), - false, - ) - .expect_err("reserved key must be rejected"); - assert!(err.contains(&bad_key), "names the key: {err}"); - } - - /// Schema drift must never echo the config payload — including OBJECT KEYS, - /// which are provider/stored data. App config can hold credentials; CLI - /// status lines are logged verbatim and CI logs are retained/shared. Only a - /// size + field COUNT may be reported. - #[cfg(unix)] - #[test] - fn read_config_entry_schema_drift_does_not_leak_payload() { - const SENTINEL: &str = "SUPER_SECRET_TOKEN_abc123"; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - // The sentinel is an OBJECT KEY (not a value): the earlier redactor joined - // keys into the diagnostic, so this is what pins the key-disclosure fix. - let drift = format!(r#"{{"{SENTINEL}":"x"}}"#); - let fake = fake_fastly_returning(&drift, "", 0); - let _path = PathPrepend::new(fake.path()); - - let result = FastlyCliAdapter.read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "cfg", - &AdapterPushContext::new(), - ); - let Err(err) = result else { - panic!("schema drift must error") - }; - assert!( - !err.contains(SENTINEL), - "error must not leak an object KEY from the config payload: {err}" - ); - assert!( - err.contains("bytes") && err.contains("field(s)"), - "error should carry a redacted size + field COUNT: {err}" - ); - } - - /// The FAILURE branch leaks too: a Fastly error that quotes the stored - /// value back in stderr must not reach the user-facing error. - #[cfg(unix)] - #[test] - fn read_config_entry_stderr_failure_does_not_leak_payload() { - const SENTINEL: &str = "SUPER_SECRET_TOKEN_stderr1"; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - // A hard failure that echoes the value. The key IS present in the store - // listing, so absence confirmation fails and the read surfaces the - // (redacted) describe stderr on the hard-error path. - let stderr = format!("Error: internal failure processing value {SENTINEL}"); - let fake = fake_fastly_returning_with_keys("", &stderr, 1, &["cfg"]); - let _path = PathPrepend::new(fake.path()); - - let result = FastlyCliAdapter.read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "cfg", - &AdapterPushContext::new(), - ); - let Err(err) = result else { - panic!("hard stderr failure must error") - }; - assert!( - !err.contains(SENTINEL), - "stderr must be redacted, not echoed: {err}" - ); - assert!( - err.contains("suppressed"), - "error should say the stderr was suppressed: {err}" - ); - } - - /// The WRITE path leaks too: a failing `config-store-entry update --upsert` - /// whose stderr quotes the value being written must be redacted. - #[cfg(unix)] - #[test] - fn upsert_stderr_failure_does_not_leak_payload() { - const SENTINEL: &str = "SUPER_SECRET_TOKEN_upsert1"; - let _lock = path_mutation_guard().lock().expect("guard"); - // A fake `fastly` that fails every call, echoing the value in stderr. - let stderr = format!("Error: rejected value {SENTINEL}"); - let fake = fake_fastly_returning("", &stderr, 1); - let _path = PathPrepend::new(fake.path()); - - let err = create_config_store_entry("store-abc", "cfg", SENTINEL) - .expect_err("a failing upsert must error"); - assert!( - !err.contains(SENTINEL), - "upsert stderr must be redacted, not echoed: {err}" - ); - assert!( - err.contains("suppressed"), - "error should say the stderr was suppressed: {err}" - ); - } - - /// `config gc` reads `item_value` for every entry (to classify roots). A - /// malformed listing whose values carry secrets must fail closed WITHOUT - /// echoing any value. (Replaces the old push prior-read redaction tests, - /// which are now vacuous: a cloud push performs no pre-commit read.) - #[cfg(unix)] - #[test] - fn gc_list_failure_does_not_leak_payload() { - const SENTINEL: &str = "SUPER_SECRET_TOKEN_gc_list"; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - - let live = gen_envelope("live"); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - let good = entry_list_json(&listing); - // A valid entry whose VALUE contains the sentinel, plus a malformed - // sibling (no created_at) to trip the fail-closed path. - let mut array: serde_json::Value = serde_json::from_str(&good).unwrap(); - let arr = array.as_array_mut().unwrap(); - arr.push(serde_json::json!({ - "item_key": "some.__edgezero_chunks.deadbeef.0", - "item_value": SENTINEL, - })); - let fake = fake_fastly_gc( - TEST_CONFIG_ID, - &[], - &listing, - None, - false, - &dir.path().join("ops.log"), - ); - fs::write( - fake.path().join("entries.json"), - serde_json::to_string(&array).unwrap(), - ) - .expect("overwrite entries"); - let _path = PathPrepend::new(fake.path()); - - let err = run_gc(dir.path(), 86_400, false).expect_err("must fail closed"); - assert!( - !err.contains(SENTINEL), - "the fail-closed error must not echo a stored value: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn push_config_entries_writes_direct_entry_at_exactly_8000_chars() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let argv_log = dir.path().join("argv.txt"); - let fake = fake_fastly_argv_log(&argv_log); - let _path = PathPrepend::new(fake.path()); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); - assert_eq!(envelope.len(), FASTLY_CONFIG_ENTRY_LIMIT); - - let entries = vec![(TEST_CONFIG_ID.to_owned(), envelope)]; - let out = FastlyCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - false, - ) - .expect("push must succeed"); - // One physical entry written (direct). - let captured = fs::read_to_string(&argv_log).expect("argv log"); - assert!( - captured.contains(&format!("--key={TEST_CONFIG_ID}")), - "must write root key directly: {captured}" - ); - assert!( - out[0].contains("1 physical entries (1 logical)"), - "summary reports 1 physical entry: {out:?}" - ); - } - - #[cfg(unix)] - #[test] - fn push_config_entries_writes_chunks_and_root_pointer_for_8001_chars() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let argv_log = dir.path().join("argv.txt"); - let fake = fake_fastly_argv_log(&argv_log); - let _path = PathPrepend::new(fake.path()); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - assert!(envelope.len() > FASTLY_CONFIG_ENTRY_LIMIT); - - let entries = vec![(TEST_CONFIG_ID.to_owned(), envelope)]; - let out = FastlyCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - false, - ) - .expect("push must succeed"); - let captured = fs::read_to_string(&argv_log).expect("argv log"); - // At least one chunk key must appear before the root key. - assert!( - captured.contains(".__edgezero_chunks."), - "chunk keys must be written: {captured}" - ); - // Root pointer must also be written. - assert!( - captured.contains(&format!("--key={TEST_CONFIG_ID}")), - "root pointer must be written: {captured}" - ); - // Root key must be LAST in the log (chunk lines come before it). - let root_pos = captured.rfind(&format!("--key={TEST_CONFIG_ID}")).unwrap(); - let chunk_pos = captured.find(".__edgezero_chunks.").unwrap(); - assert!( - chunk_pos < root_pos, - "chunk writes must precede root pointer write: chunk_pos={chunk_pos} root_pos={root_pos}" - ); - assert!(out[0].contains("logical"), "summary line present: {out:?}"); - } - - #[cfg(unix)] - #[test] - fn push_config_entries_dry_run_reports_direct_vs_chunked() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - - let direct_envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); - let chunked_envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - - let entries = vec![ - ("cfg_direct".to_owned(), direct_envelope), - ("cfg_chunked".to_owned(), chunked_envelope), - ]; - let out = FastlyCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - true, // dry_run - ) - .expect("dry-run must not error"); - - // No shellout happens; output must describe intent. - let combined = out.join("\n"); - assert!( - combined.contains("would push `cfg_direct` as direct entry"), - "must report direct: {combined}" - ); - assert!( - combined.contains("would push `cfg_chunked` as chunked"), - "must report chunked: {combined}" - ); - } - - /// Spec 12.7: pushing two blobs under different root keys - /// (e.g. `app_config` + `app_config_staging`) must leave both - /// keys readable from the local fastly.toml so the runtime - /// `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` override can - /// switch between them. Prior to the upsert fix the second - /// push wholesale-replaced the per-store contents table. - #[cfg(unix)] - #[test] - fn push_config_entries_local_preserves_sibling_keys() { - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - fs::write(&fastly_toml, "name = \"demo\"\n").expect("seed"); - let store = ResolvedStoreId::from_logical(TEST_CONFIG_ID); - let ctx = AdapterPushContext::new(); - - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &store, - &[("app_config".to_owned(), "{\"envelope\":\"A\"}".to_owned())], - &ctx, - false, - ) - .expect("first push"); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &store, - &[( - "app_config_staging".to_owned(), - "{\"envelope\":\"B\"}".to_owned(), - )], - &ctx, - false, - ) - .expect("second push (sibling key)"); - - let raw = fs::read_to_string(&fastly_toml).expect("read"); - let doc: toml_edit::DocumentMut = raw.parse().expect("parse"); - let contents = doc - .get("local_server") - .and_then(|ls| ls.get("config_stores")) - .and_then(|cs| cs.get(TEST_CONFIG_ID)) - .and_then(|st| st.get("contents")) - .and_then(toml_edit::Item::as_table) - .expect("contents after sibling push"); - let app_config = contents - .get("app_config") - .and_then(toml_edit::Item::as_str) - .expect("default key must survive sibling push"); - assert_eq!( - app_config, "{\"envelope\":\"A\"}", - "default key value: {raw}" - ); - let staging = contents - .get("app_config_staging") - .and_then(toml_edit::Item::as_str) - .expect("staging key must be present"); - assert_eq!(staging, "{\"envelope\":\"B\"}", "staging key value: {raw}"); - } - - #[cfg(unix)] - #[test] - fn push_config_entries_local_writes_literal_dotted_chunk_keys() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - fs::write(&fastly_toml, "name = \"demo\"\n").expect("write"); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - let entries = vec![(TEST_CONFIG_ID.to_owned(), envelope)]; - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - false, - ) - .expect("local push must succeed"); - - let after = fs::read_to_string(&fastly_toml).expect("read back"); - // Chunk keys contain '.' and must appear as quoted string keys, - // not as TOML nested tables (which would look like [table.sub]). - assert!( - after.contains(".__edgezero_chunks."), - "chunk keys written to fastly.toml: {after}" - ); - // Parse with toml_edit and confirm chunk keys are string-keyed entries. - let doc: toml_edit::DocumentMut = after.parse().expect("must parse"); - let contents = doc - .get("local_server") - .and_then(|ls| ls.get("config_stores")) - .and_then(|cs| cs.get(TEST_CONFIG_ID)) - .and_then(|st| st.get("contents")) - .expect("contents table must exist"); - // At least one chunk key must be present as a string value (not a table). - let has_chunk_string = contents.as_table().is_some_and(|tbl| { - tbl.iter() - .any(|(key, val)| key.contains(".__edgezero_chunks.") && val.as_value().is_some()) - }); - assert!( - has_chunk_string, - "chunk keys must be literal string-valued entries, not nested tables: {after}" - ); - } - - #[cfg(unix)] - #[test] - fn push_config_entries_local_dry_run_reports_chunking_and_does_not_edit_fastly_toml() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - let original = "name = \"demo\"\n"; - fs::write(&fastly_toml, original).expect("write"); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - let entries = vec![(TEST_CONFIG_ID.to_owned(), envelope)]; - let out = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - true, // dry_run - ) - .expect("local dry-run must not error"); - - // File must be untouched. - let after = fs::read_to_string(&fastly_toml).expect("read back"); - assert_eq!(after, original, "dry-run must not edit fastly.toml"); - - // Output must describe chunking intent. - let combined = out.join("\n"); - assert!( - combined.contains("would set") && combined.contains("chunked"), - "must report chunked intent: {combined}" - ); - } - - // ---------- chunked read integration tests ---------- - - #[cfg(unix)] - #[test] - fn read_config_entry_resolves_direct_value_unchanged() { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - - let envelope = BlobEnvelope::new(json!({"hello": "world"}), "2026-06-22T00:00:00Z".into()); - let json_str = serde_json::to_string(&envelope).unwrap(); - let item_json = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(&json_str).unwrap() - ); - let fake = fake_fastly_returning(&item_json, "", 0); - let _path = PathPrepend::new(fake.path()); - - let result = FastlyCliAdapter - .read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "cfg", - &AdapterPushContext::new(), - ) - .expect("read must succeed"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present"); - }; - assert_eq!(value, json_str, "direct envelope passes through unchanged"); - } - - #[cfg(unix)] - #[test] - fn read_config_entry_reconstructs_chunked_envelope() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - let physical = prepare_fastly_config_entries(TEST_CONFIG_ID, &envelope).unwrap(); - let (_, pointer_json) = physical.last().unwrap(); - // Build a key→response map for every physical entry. - let mut key_responses: Vec<(String, String)> = Vec::new(); - for (pk, pv) in &physical { - let resp = format!(r#"{{"item_value":{}}}"#, serde_json::to_string(pv).unwrap()); - key_responses.push((pk.clone(), resp)); - } - // The root key should return the pointer. - let ptr_resp = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(pointer_json).unwrap() - ); - key_responses.push((TEST_CONFIG_ID.to_owned(), ptr_resp)); - - let fake = fake_fastly_with_key_dispatch(dir.path(), &key_responses); - let _path = PathPrepend::new(fake.path()); - - let result = FastlyCliAdapter - .read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - TEST_CONFIG_ID, - &AdapterPushContext::new(), - ) - .expect("chunked read must succeed"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present"); - }; - assert_eq!( - value, envelope, - "reconstructed envelope must equal original" - ); - } - - #[cfg(unix)] - #[test] - fn read_config_entry_reports_corrupt_on_a_confirmed_absent_chunk() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - let physical = prepare_fastly_config_entries(TEST_CONFIG_ID, &envelope).unwrap(); - let (_, pointer_json) = physical.last().unwrap(); - // Only provide the root pointer; omit chunk responses so the chunk fetch - // gets a CLEAN not-found (`Error: item not found`, no operational marker). - let ptr_resp = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(pointer_json).unwrap() - ); - let key_responses = vec![(TEST_CONFIG_ID.to_owned(), ptr_resp)]; - let fake = fake_fastly_with_key_dispatch(dir.path(), &key_responses); - let _path = PathPrepend::new(fake.path()); - - let result = FastlyCliAdapter.read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - TEST_CONFIG_ID, - &AdapterPushContext::new(), - ); - // The chunk describe fails, and the complete store listing (which holds - // only the root pointer) CONFIRMS the chunk is absent. The blob spec makes - // persistent chunk loss REPAIRABLE by re-pushing, so the read reports - // `Corrupt` (a push overwrites to repair), NOT a hard error -- otherwise - // `config push` could never fix it. Absence is confirmed by the listing, - // never by the describe 404 alone, so a proxy/auth failure (where the - // listing also fails, or shows the chunk present) stays a hard error. - assert!( - matches!(result, Ok(ReadConfigEntry::Corrupt(_))), - "a confirmed-absent chunk must be repairable Corrupt, not a hard error" - ); - } - - #[cfg(unix)] - #[test] - fn read_config_entry_reports_corrupt_on_chunk_hash_mismatch() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - let physical = prepare_fastly_config_entries(TEST_CONFIG_ID, &envelope).unwrap(); - let (_, pointer_json) = physical.last().unwrap(); - let mut key_responses: Vec<(String, String)> = Vec::new(); - // Corrupt first chunk's content. - let (first_chunk_key, first_chunk_val) = &physical[0]; - let corrupted: String = first_chunk_val.chars().map(|_| 'Z').collect(); - let corrupt_resp = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(&corrupted).unwrap() - ); - key_responses.push((first_chunk_key.clone(), corrupt_resp)); - // Remaining chunks as normal. - for (pk, pv) in physical - .iter() - .take(physical.len().saturating_sub(1)) - .skip(1) - { - key_responses.push(( - pk.clone(), - format!(r#"{{"item_value":{}}}"#, serde_json::to_string(pv).unwrap()), - )); - } - key_responses.push(( - TEST_CONFIG_ID.to_owned(), - format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(pointer_json).unwrap() - ), - )); - let fake = fake_fastly_with_key_dispatch(dir.path(), &key_responses); - let _path = PathPrepend::new(fake.path()); - - let result = FastlyCliAdapter.read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - TEST_CONFIG_ID, - &AdapterPushContext::new(), - ); - // A chunk-hash mismatch at an EXISTING entry is corrupt stored state the - // push repairs by overwriting, so the CLI read reports `Corrupt`. (The - // RUNTIME path keeps a hash mismatch as Internal — see config_store.rs.) - assert!( - matches!(result, Ok(ReadConfigEntry::Corrupt(_))), - "a chunk-hash mismatch at an existing entry must be Corrupt (repairable), not an error" - ); - } - - #[cfg(unix)] - #[test] - fn read_config_entry_reports_corrupt_for_malformed_pointer() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - // Root value ANNOUNCES our chunk-pointer kind but is malformed. The - // `describe` SUCCEEDS (the entry exists), so a resolve failure is CORRUPT - // stored state, not an IO error: the read must report `Corrupt` so a push - // can overwrite it (in-band repair), NOT hard-error and block recovery. - let bad_json = r#"{"edgezero_kind":"fastly_config_chunks","some_field":"x"}"#; - let item_json = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(bad_json).unwrap() - ); - let fake = fake_fastly_returning(&item_json, "", 0); - let _path = PathPrepend::new(fake.path()); - - let result = FastlyCliAdapter.read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "cfg", - &AdapterPushContext::new(), - ); - assert!( - matches!(result, Ok(ReadConfigEntry::Corrupt(_))), - "a malformed pointer at an EXISTING entry must be Corrupt (repairable), not an error" - ); - } - - /// The read taxonomy distinguishes repairable corruption from cases a push - /// must NOT overwrite: an infrastructure fetch failure (incomplete read) and - /// an unknown/future format both stay hard errors, while a malformed direct - /// value, a SHA mismatch, and a resolve error are repairable `Corrupt`. - #[test] - fn classify_resolved_read_separates_corrupt_from_infra_and_unknown() { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - - let envelope = serde_json::to_string(&BlobEnvelope::new( - json!({ "k": "v" }), - "2026-01-01T00:00:00Z".to_owned(), - )) - .expect("envelope"); - - // A valid envelope resolves to Present. - assert!(matches!( - classify_resolved_read(Ok(envelope.clone()), &envelope, false), - Ok(ReadConfigEntry::Present(_)) - )); - - // A direct value with a wrong `sha256` is NOT a valid envelope -> Corrupt. - let mut tampered_value: serde_json::Value = serde_json::from_str(&envelope).expect("parse"); - tampered_value["sha256"] = json!("0".repeat(64)); - let tampered = tampered_value.to_string(); - assert!(matches!( - classify_resolved_read(Ok(tampered.clone()), &tampered, false), - Ok(ReadConfigEntry::Corrupt(_)) - )); - - // Invalid JSON / a plain non-envelope value -> Corrupt (not Present). - assert!(matches!( - classify_resolved_read(Ok("not an envelope".to_owned()), "not an envelope", false), - Ok(ReadConfigEntry::Corrupt(_)) - )); - - // A resolve error caused by an INFRASTRUCTURE fetch failure stays a HARD - // error: the read was incomplete, so a push must not overwrite. - let infra = classify_resolved_read( - Err(ResolveFailure::Corrupt("boom".to_owned())), - "{\"edgezero_kind\":\"fastly_config_chunks\"}", - true, - ); - assert!( - infra - .as_ref() - .is_err_and(|err| err.contains("not fully read")), - "an infra fetch failure must be a hard error, not Corrupt" - ); - - // A value announcing an UNKNOWN/future kind is a HARD error (upgrade CLI), - // never offered for overwrite. - let unknown = classify_resolved_read( - Err(ResolveFailure::FutureFormat("x".to_owned())), - r#"{"edgezero_kind":"fastly_config_chunks_v2"}"#, - false, - ); - assert!( - unknown - .as_ref() - .is_err_and(|err| err.contains("does not recognise")), - "an unknown/future kind must be a hard error" - ); - - // A NEWER INNER envelope (a valid v1 pointer wrapping a v2 envelope) is - // only knowable AFTER reassembly: the raw value is a healthy v1 pointer, - // so the typed `FutureFormat` failure is the ONLY signal. It must be a - // hard error, never repairable Corrupt -- a downgrade push must not - // overwrite it. - let inner_future = classify_resolved_read( - Err(ResolveFailure::FutureFormat( - "newer inner envelope".to_owned(), - )), - r#"{"edgezero_kind":"fastly_config_chunks","version":1,"chunks":[]}"#, - false, - ); - assert!( - inner_future - .as_ref() - .is_err_and(|err| err.contains("UPGRADE")), - "a future INNER envelope (typed FutureFormat) must be a hard error, not Corrupt" - ); - - // An ordinary resolve error (bad/missing chunk) is repairable Corrupt. - assert!(matches!( - classify_resolved_read( - Err(ResolveFailure::Corrupt("bad chunk".to_owned())), - r#"{"edgezero_kind":"fastly_config_chunks","chunks":[]}"#, - false - ), - Ok(ReadConfigEntry::Corrupt(_)) - )); - - // A future ENVELOPE version (passed through as Ok) is a hard error, NOT - // the repairable Corrupt -- an older CLI must not overwrite it. - let mut v2_value: serde_json::Value = serde_json::from_str(&envelope).expect("parse"); - v2_value["version"] = json!(2_u32); - let v2_env = v2_value.to_string(); - assert!( - classify_resolved_read(Ok(v2_env.clone()), &v2_env, false) - .as_ref() - .is_err_and(|err| err.contains("UPGRADE")), - "a v2 direct envelope must be a hard error, not Corrupt" - ); - - // A future POINTER version (resolve fails on the version check) is a hard - // error too -- the pointer kind is ours, but the version is newer. - let v2_ptr = r#"{"edgezero_kind":"fastly_config_chunks","version":2,"chunks":[]}"#; - assert!( - classify_resolved_read( - Err(ResolveFailure::FutureFormat( - "unsupported version".to_owned() - )), - v2_ptr, - false - ) - .as_ref() - .is_err_and(|err| err.contains("UPGRADE")), - "a v2 pointer must be a hard error, not Corrupt" - ); - } - - // ---------- local read integration tests ---------- - - #[test] - fn read_config_entry_local_resolves_direct_value() { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - - let envelope = BlobEnvelope::new(json!({"x": 1_i32}), "2026-06-22T00:00:00Z".into()); - let json_str = serde_json::to_string(&envelope).unwrap(); - // Write directly as a single entry (not via push_config_entries_local so we - // control the exact TOML content). - write_fastly_local_config_store( - &fastly_toml, - TEST_CONFIG_ID, - &[("cfg".to_owned(), json_str.clone())], - &[], - ) - .expect("write"); - - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "cfg", - &AdapterPushContext::new(), - ) - .expect("local read must succeed"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present"); - }; - assert_eq!(value, json_str, "direct envelope passes through unchanged"); - } - - #[test] - fn read_config_entry_local_reconstructs_chunked_envelope() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - let physical = prepare_fastly_config_entries(TEST_CONFIG_ID, &envelope).unwrap(); - // Write all physical entries (chunks + pointer) to the local store. - write_fastly_local_config_store(&fastly_toml, TEST_CONFIG_ID, &physical, &[]) - .expect("write"); - - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - TEST_CONFIG_ID, - &AdapterPushContext::new(), - ) - .expect("local chunked read must succeed"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present"); - }; - assert_eq!( - value, envelope, - "reconstructed envelope must equal original" - ); - } - - /// a corrupt/invalid prior value must NOT abort the - /// local read, or the CLI push aborts on the diff read before the writer's - /// fail-soft ("overwrite, warn, prune nothing") can repair the state. - /// `config push` is how an operator recovers, so the read reports `Corrupt` - /// ("cannot diff; will overwrite") and lets the write proceed. - #[test] - fn read_config_entry_local_degrades_corrupt_prior_to_corrupt() { - use crate::chunked_config::{CHUNK_KEY_INFIX, POINTER_KIND}; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - - // A pointer-KIND value that is invalid (missing the chunks it needs). - // The resolver would error on this; the local read must NOT propagate - // that as `Err`. - let broken_pointer = format!( - r#"{{"edgezero_kind":"{POINTER_KIND}","version":1,"chunks":[{{"key":"cfg{CHUNK_KEY_INFIX}{sha}.0","len":10,"sha256":"x"}}],"data_sha256":"","envelope_len":10,"envelope_sha256":"{sha}"}}"#, - sha = "a".repeat(64), - ); - write_fastly_local_config_store( - &fastly_toml, - TEST_CONFIG_ID, - &[("cfg".to_owned(), broken_pointer)], - &[], - ) - .expect("write"); - - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "cfg", - &AdapterPushContext::new(), - ) - .expect("a corrupt local prior must NOT abort the read"); - assert!( - matches!(result, ReadConfigEntry::Corrupt(_)), - "a corrupt prior value must degrade to Corrupt so the push can overwrite it" - ); - } - - /// A `contents` that is not a table (a scalar or array) is malformed store - /// state. It must degrade to `Unsupported`, not fall through to `MissingKey` - /// (which would render an inaccurate "all values added" diff). - #[test] - fn read_config_entry_local_non_table_contents_is_unsupported() { - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - fs::write( - &fastly_toml, - format!("[local_server.config_stores.{TEST_CONFIG_ID}]\ncontents = 42\n"), - ) - .expect("seed"); - - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "cfg", - &AdapterPushContext::new(), - ) - .expect("a non-table contents must NOT abort the read"); - assert!( - matches!(result, ReadConfigEntry::Unsupported(_)), - "a non-table `contents` must degrade to Unsupported, not MissingKey" - ); - } - - /// A malformed PARENT table (`local_server` etc. as a scalar) must degrade to - /// Unsupported, not collapse to `MissingStore`'s "all values added" diff. - #[test] - fn read_config_entry_local_non_table_parent_is_unsupported() { - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - // `local_server` is a scalar, not a table. - fs::write(&fastly_toml, "local_server = 42\n").expect("seed"); - - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "cfg", - &AdapterPushContext::new(), - ) - .expect("a non-table parent must NOT abort the read"); - assert!( - matches!(result, ReadConfigEntry::Unsupported(_)), - "a non-table parent must degrade to Unsupported, not MissingStore" - ); - } - - /// Spec 12.3 + 9.3: a second oversized push must converge the - /// runtime on the NEW envelope — chunk keys are content-addressed - /// by the full-envelope SHA, so push B writes a new chunk-set and - /// installs a new root pointer. - /// - /// The local fastly.toml writer upserts per-key (so a sibling - /// `--key app_config_staging` push leaves `app_config` intact per - /// spec 12.7). Within the SAME root key, GC on re-push prunes the - /// prior generation: after envelope B's push, envelope A's chunks — - /// now unreferenced by the `app_config` pointer — are removed from - /// the contents table. A read after push B follows the active - /// pointer and reconstructs envelope B, not A. - #[cfg(unix)] - #[test] - #[expect( - clippy::too_many_lines, - reason = "linear test scenario: push A, inspect, push B, inspect, read; splitting would obscure the chunk-set comparison" - )] - fn second_oversized_push_converges_runtime_on_new_envelope() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - fs::write(&fastly_toml, "name = \"demo\"\n").expect("seed"); - - // First push: envelope A. Records the chunk-key set so we can - // confirm they are pruned by the second push's GC. - let envelope_a = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), envelope_a.clone())], - &AdapterPushContext::new(), - false, - ) - .expect("first push must succeed"); - - let after_a = fs::read_to_string(&fastly_toml).expect("read"); - let doc_a: toml_edit::DocumentMut = after_a.parse().expect("parse"); - let contents_a = doc_a - .get("local_server") - .and_then(|ls| ls.get("config_stores")) - .and_then(|cs| cs.get(TEST_CONFIG_ID)) - .and_then(|st| st.get("contents")) - .and_then(toml_edit::Item::as_table) - .expect("contents table after push A"); - let chunks_a: Vec = contents_a - .iter() - .map(|(key, _)| key.to_owned()) - .filter(|key| key.contains(".__edgezero_chunks.")) - .collect(); - assert!( - !chunks_a.is_empty(), - "push A must have produced chunk entries: {after_a}" - ); - - // Second push: a DIFFERENT oversized envelope B. The - // content-addressed chunk keys must shift to B's sha; GC then - // prunes the old A-chunks. Build envelope B with a distinct - // payload key so its SHA differs from A's even at the same - // total length. - let envelope_b = { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - let data = json!({ "alt": "x".repeat(FASTLY_CONFIG_ENTRY_LIMIT) }); - serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:01Z".to_owned())) - .expect("envelope B serialises") - }; - assert_ne!(envelope_a, envelope_b, "test fixtures must differ"); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), envelope_b.clone())], - &AdapterPushContext::new(), - false, - ) - .expect("second push must succeed"); - - let after_b = fs::read_to_string(&fastly_toml).expect("read"); - let doc_b: toml_edit::DocumentMut = after_b.parse().expect("parse"); - let contents_b = doc_b - .get("local_server") - .and_then(|ls| ls.get("config_stores")) - .and_then(|cs| cs.get(TEST_CONFIG_ID)) - .and_then(|st| st.get("contents")) - .and_then(toml_edit::Item::as_table) - .expect("contents table after push B"); - let chunks_b: Vec = contents_b - .iter() - .map(|(key, _)| key.to_owned()) - .filter(|key| key.contains(".__edgezero_chunks.")) - .collect(); - assert!( - !chunks_b.is_empty(), - "push B must have produced chunk entries: {after_b}" - ); - - // Chunk keys are content-addressed by envelope SHA, so the B - // push installs a fresh chunk-set whose keys are all distinct - // from A's. GC on re-push prunes the now-unreferenced A-chunks. - let new_b_chunks: Vec<&String> = chunks_b - .iter() - .filter(|key| !chunks_a.contains(*key)) - .collect(); - assert!( - !new_b_chunks.is_empty(), - "push B must have added at least one new content-addressed chunk: A-set={chunks_a:?} B-set={chunks_b:?}" - ); - // Old A-chunks are pruned: GC deletes the prior generation the - // old pointer referenced once B's pointer supersedes it. - for chunk_key in &chunks_a { - assert!( - !chunks_b.contains(chunk_key), - "old A-chunk `{chunk_key}` must be pruned from the local table after push B; B-set={chunks_b:?}" - ); - } - - // Runtime-correctness property: a fresh read after push B - // reconstructs envelope B (NOT envelope A). - let read = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - TEST_CONFIG_ID, - &AdapterPushContext::new(), - ) - .expect("local read after push B"); - let ReadConfigEntry::Present(value) = read else { - panic!("expected Present after push B"); - }; - assert_eq!( - value, envelope_b, - "read after second push must reconstruct envelope B, not A" - ); - assert_ne!( - value, envelope_a, - "old envelope A's chunks must be inert -- read must NOT return A" - ); - } - - // ---------- config gc (operator-invoked reclamation) ---------- - - #[cfg(unix)] - fn run_gc(dir: &Path, older_than_secs: u64, dry_run: bool) -> Result, String> { - FastlyCliAdapter.gc_config_entries( - dir, - None, - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &AdapterPushContext::new(), - older_than_secs, - dry_run, - ) - } - - /// gc never deletes a chunk the LIVE root pointer references, however old. - #[cfg(unix)] - #[test] - fn gc_never_deletes_live_chunks() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let live_chunks = chunk_keys_of(TEST_CONFIG_ID, &live); - // The live generation is ANCIENT, but it is referenced by the root. - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 999_999)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - let out = run_gc(dir.path(), 1, false).expect("gc succeeds"); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - for key in &live_chunks { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "live chunk `{key}` must never be reclaimed; log:\n{log}\nout: {out:?}" - ); - } - } - - /// gc reclaims unreferenced chunks older than the operator's threshold. - #[cfg(unix)] - #[test] - fn gc_reclaims_unreferenced_chunks_older_than_threshold() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let dead = gen_envelope("dead"); - let live_chunks = chunk_keys_of(TEST_CONFIG_ID, &live); - let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); - - // The live config has been stable for 2 days; the operator asserts a 1-day - // window. So everything superseded (<= when live went live, i.e. >= 2 - // days ago) is safely reclaimable. - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); // a week old - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - let out = run_gc(dir.path(), 86_400, false).expect("gc succeeds"); - for key in &dead_chunks { - assert!( - oplog_has(&oplog, &format!("delete {key}")), - "orphan `{key}` older than the threshold must be reclaimed; out: {out:?}" - ); - } - for key in &live_chunks { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "live chunk `{key}` must survive" - ); - } - } - - /// The soundness test (design-3 counterexample): a root whose - /// current config was deployed seconds ago must NOT have its prior generation - /// reclaimed, even if that generation's chunks are ANCIENT. The clock is the - /// live config's age, not the orphan chunk's own creation time. - #[cfg(unix)] - #[test] - fn gc_protects_recently_superseded_generation_with_old_chunks() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let prior = gen_envelope("prior"); - let prior_chunks = chunk_keys_of(TEST_CONFIG_ID, &prior); - - // Live config went live 30s ago; the prior generation's chunks are a year - // old but were superseded only 30s ago -> POPs may still serve them. - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 30)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 30)); - listing.extend(listed_generation(TEST_CONFIG_ID, &prior, 31_536_000)); // ~1 year - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - // Even a generous 1-day threshold must NOT delete the prior generation, - // because the live config has only been stable for 30 seconds. - run_gc(dir.path(), 86_400, false).expect("gc succeeds"); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - for key in &prior_chunks { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "a generation superseded 30s ago must be retained despite old chunks: `{key}`; log:\n{log}" - ); - } - } - - /// a live root whose pointer drops its - /// last chunk ref AND restates `envelope_len` as the remaining sum passes - /// every metadata check. The dropped chunk is then absent from the live set - /// and looks like a deletable orphan -- while the config still needs it. - /// - /// Guards the PLANNER's content verification (a unit test on - /// `gc_verify_generation` alone does not prove the planner calls it). - #[cfg(unix)] - #[test] - fn gc_fails_closed_when_a_live_pointer_underreports_its_chunks() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - // Padded so the generation is >= 3 chunks: this case needs a ref to - // drop that still leaves a plausible multi-chunk set behind. - let live = gen_envelope_padded("live", 20_000); - let (chunks, pointer_json) = chunked_parts(TEST_CONFIG_ID, &live); - assert!(chunks.len() >= 3, "need >= 3 chunks for this case"); - - // Doctor the pointer: drop the last ref, restate envelope_len to match - // the survivors. Generation, indexes, per-chunk lens and the sum all - // still agree -- only the CONTENT does not. - let mut pointer: serde_json::Value = serde_json::from_str(&pointer_json).expect("parse"); - let refs = pointer - .get_mut("chunks") - .and_then(serde_json::Value::as_array_mut) - .expect("chunks array"); - refs.pop().expect("drop the last chunk ref"); - let surviving_len: u64 = refs - .iter() - .filter_map(|chunk| chunk.get("len").and_then(serde_json::Value::as_u64)) - .sum(); - pointer["envelope_len"] = serde_json::json!(surviving_len); - let doctored = serde_json::to_string(&pointer).expect("serialise"); - - // The store still physically holds ALL the chunks, including the one the - // doctored pointer no longer names. - let orphaned_by_omission = chunks.last().expect("last chunk").0.clone(); - let stamp = stamp_secs_ago(999_999); - let mut listing = vec![(TEST_CONFIG_ID.to_owned(), stamp.clone(), doctored)]; - for (key, value) in chunks { - listing.push((key, stamp.clone(), value)); - } - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - let err = run_gc(dir.path(), 1, false).expect_err("must fail closed"); - assert!( - err.contains("does not reconstruct the envelope it claims"), - "expected a content-address mismatch on the live pointer, got: {err}" - ); - assert!( - !oplog_has(&oplog, &format!("delete {orphaned_by_omission}")), - "a chunk the live config still needs must never be deleted because its pointer \ - under-reported it: `{orphaned_by_omission}`" - ); - } - - /// a LONE entry whose value hashes to the generation - /// its own key names would otherwise "prove" itself and be deleted. But our - /// writer never emits a one-chunk generation (an oversized envelope always - /// splits into >= 2), so a group of one is never ours -- it is a root-like - /// value sitting at a chunk-shaped key. This is the case a pure hash check - /// cannot catch on its own. - #[cfg(unix)] - #[test] - fn gc_never_reclaims_a_lone_self_consistent_chunk() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 999_999)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); - - // A complete envelope stored at a chunk-shaped key whose generation IS - // that envelope's own SHA -- so it reassembles to its content-address. - let squatter_value = gen_envelope("someones-real-config"); - let self_sha = sha256_hex(squatter_value.as_bytes()); - let squatter_key = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{self_sha}.0"); - listing.push(( - squatter_key.clone(), - stamp_secs_ago(31_536_000), - squatter_value, - )); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - run_gc(dir.path(), 86_400, false).expect("gc succeeds"); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - assert!( - !oplog_has(&oplog, &format!("delete {squatter_key}")), - "a one-chunk 'generation' is never something this writer emitted, so it must not be \ - reclaimed even though it hashes to its own key: `{squatter_key}`; log:\n{log}" - ); - } - - /// a delete that fails on a generation's FIRST key has - /// an UNKNOWN outcome -- Fastly may have committed it before returning an - /// error. called this "whole and retryable", which is unsound: if the - /// failed delete did commit, a re-run finds a fragment. The honest report is - /// a NOTE that the outcome is uncertain, NOT a clean-retry promise. We still - /// stop the generation so a CONFIRMED partial delete cannot happen. - #[cfg(unix)] - #[test] - fn gc_first_delete_failure_is_reported_as_uncertain_not_clean_retry() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let dead = gen_envelope("dead"); - let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); - assert!(dead_chunks.len() >= 2, "need a multi-chunk generation"); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); - - // The FIRST chunk of the doomed generation fails to delete. - let fake = fake_fastly_gc( - TEST_CONFIG_ID, - &[], - &listing, - Some(&dead_chunks[0]), - false, - &oplog, - ); - let _path = PathPrepend::new(fake.path()); - - let err = run_gc(dir.path(), 86_400, false).expect_err("a failed delete is a failure"); - assert!( - err.contains("unknown outcome"), - "a failed delete's outcome is unknown and must be reported as such: {err}" - ); - assert!( - !err.contains("will retry them"), - "the disproven clean-retry promise must be gone: {err}" - ); - // The siblings must NOT have been ATTEMPTED -- stopping is what prevents a - // CONFIRMED partial delete. - for key in dead_chunks.iter().skip(1) { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "after the first failure the generation must be left alone: `{key}`" - ); - } - } - - /// the stateful case. A remote delete that COMMITS but - /// still reports failure leaves a real fragment. On the SECOND run that - /// missing key makes the generation unprovable, so it must be reported as - /// left-untouched (surfaced), never silently dropped. - #[cfg(unix)] - #[test] - fn gc_committed_but_failed_delete_surfaces_as_unprovable_next_run() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let dead = gen_envelope("dead"); - let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); - assert!(dead_chunks.len() >= 2, "need a multi-chunk generation"); - - // SECOND run's world: the first chunk's delete committed last time, so it - // is gone. The generation is now a fragment. - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - let mut dead_gen = listed_generation(TEST_CONFIG_ID, &dead, 604_800); - let survivor = dead_gen[1].0.clone(); - dead_gen.remove(0); // the committed-deleted chunk is absent now - listing.extend(dead_gen); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - let out = run_gc(dir.path(), 86_400, false).expect("gc succeeds"); - assert!( - !oplog_has(&oplog, &format!("delete {survivor}")), - "an unprovable fragment survivor must not be deleted: `{survivor}`" - ); - assert!( - out.iter() - .any(|line| line.contains("not byte-identical to what this writer would produce")), - "the surviving fragment must be SURFACED as left-untouched, not silently dropped: {out:?}" - ); - } - - /// if a delete fails PART-WAY through a generation, the - /// survivors are an incomplete generation that `prove_generation` can never - /// verify again -- so `gc` will never reclaim them. Claiming "re-run to - /// retry" there was false. Say plainly that recovery is manual. - #[cfg(unix)] - #[test] - fn gc_reports_stranded_survivors_as_manual_recovery() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - // Padded to >= 3 chunks so a mid-generation failure leaves survivors. - let live = gen_envelope("live"); - let dead = gen_envelope_padded("dead", 20_000); - let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); - assert!(dead_chunks.len() >= 3, "need >= 3 chunks"); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); - - // The SECOND chunk fails: the first is already gone by then. - let fake = fake_fastly_gc( - TEST_CONFIG_ID, - &[], - &listing, - Some(&dead_chunks[1]), - false, - &oplog, - ); - let _path = PathPrepend::new(fake.path()); - - let err = run_gc(dir.path(), 86_400, false).expect_err("a failed delete is a failure"); - assert!( - err.contains("INCOMPLETE generation") && err.contains("re-running will NOT help"), - "a stranded fragment must not be described as retryable: {err}" - ); - // It must name the survivors and how to remove them by hand. - for key in dead_chunks.iter().skip(2) { - assert!( - err.contains(key.as_str()), - "the operator needs the exact surviving keys: `{key}` missing from: {err}" - ); - } - assert!( - err.contains("fastly config-store-entry delete"), - "give the operator the recovery command: {err}" - ); - // And we stopped rather than deleting the rest. - for key in dead_chunks.iter().skip(2) { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "deletion must stop at the first failure in a generation: `{key}`" - ); - } - } - - /// root keys are free-form, so a chunk key can hold - /// shell metacharacters. Manual-recovery commands must render them so that - /// pasting cannot execute or misparse -- single-quoted, with embedded quotes - /// escaped. - #[test] - fn recovery_commands_are_shell_safe() { - // A key crafted to run `id` and to break argument parsing if unquoted. - let hostile = "app$(id).__edgezero_chunks.'; rm -rf /'.0".to_owned(); - let keys = [hostile.clone()]; - let rendered = recovery_commands("store-abc", &keys); - - // The dangerous substring is not sitting there unquoted. - assert!( - !rendered.contains("$(id)") || rendered.contains("'app$(id)"), - "shell-active text must be inside single quotes: {rendered}" - ); - // Every embedded single quote is closed-escaped-reopened, so no quote - // context leaks. - assert!( - rendered.contains(r"'\''"), - "embedded single quotes must be escaped as '\\'': {rendered}" - ); - // Sanity: what a POSIX shell would parse back out of our --key argument - // is EXACTLY the original key (round-trip through `sh`). - let key_arg = rendered - .split("--key=") - .nth(1) - .and_then(|rest| rest.split(" --auto-yes").next()) - .expect("a --key argument"); - let out = Command::new("sh") - .arg("-c") - .arg(format!("printf '%s' {key_arg}")) - .output() - .expect("run sh"); - assert_eq!( - String::from_utf8_lossy(&out.stdout), - hostile, - "the shell must parse the quoted argument back to the exact key" - ); - } - - /// a valid DIRECT envelope at a chunk-shaped key is a - /// runtime-readable root, but round 9 only protected POINTER values there. - /// - /// Construction: pad a small valid envelope with trailing JSON whitespace - /// past the entry limit. The writer chunks it; chunk 0 (the first 7 000 - /// bytes) is the whole envelope plus trailing spaces, which STILL parses and - /// verifies as that envelope. So chunk 0's key holds a valid direct envelope - /// -- a root -- yet the generation round-trips through the writer and passes - /// every proof, so GC deletes chunk 0. - #[cfg(unix)] - #[test] - fn valid_envelope_at_chunk_shaped_key_is_a_protected_root() { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - // A small valid envelope + trailing whitespace over the entry limit. - let envelope = BlobEnvelope::new(json!({"k":"v"}), "2026-06-22T00:00:00Z".into()); - let mut padded = serde_json::to_string(&envelope).unwrap(); - padded.push_str(&" ".repeat(8_200)); - let entries = prepare_fastly_config_entries(TEST_CONFIG_ID, &padded).expect("expand"); - assert!(entries.len() >= 3, "need >= 2 chunks + pointer"); - let holder_key = entries[0].0.clone(); - // Sanity: chunk 0's value IS a standalone valid envelope. - let parsed: BlobEnvelope = - serde_json::from_str(&entries[0].1).expect("chunk 0 must parse as an envelope"); - parsed.verify().expect("chunk 0 must verify as an envelope"); - - // Seed the store with the chunk entries only -- NO live pointer refers - // to them, so this generation looks orphaned. Aged old. - let stamp = stamp_secs_ago(604_800); - let live = gen_envelope("live"); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 999_999)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); - for (key, value) in &entries[..entries.len().saturating_sub(1)] { - listing.push((key.clone(), stamp.clone(), value.clone())); - } - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - drop(run_gc(dir.path(), 86_400, false)); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - assert!( - !oplog_has(&oplog, &format!("delete {holder_key}")), - "an entry whose value is a valid direct envelope is a runtime-readable root and must \ - never be deleted, whatever its key looks like: `{holder_key}`; log:\n{log}" - ); - // The SIBLING chunks must survive too: protecting the holder drops the - // generation to an incomplete group, which is left unprovable — so - // nothing in this generation is deleted, not just the holder. - for (key, _) in &entries[1..entries.len().saturating_sub(1)] { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "a sibling of a protected root must also survive (the group is left \ - unprovable): `{key}`; log:\n{log}" - ); - } - } - - /// A self-scoped pointer at a chunk-shaped holder key (its chunks nest the - /// infix twice) must NOT abort store-wide GC: the doubly-nested chunks are - /// recognised as chunks (via the LAST infix), so the holder classifies as a - /// root, its references are counted live, and other roots still reclaim. - #[cfg(unix)] - #[test] - fn gc_tolerates_a_self_scoped_pointer_at_a_chunk_shaped_root() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - // A pointer parked at a chunk-shaped key, with chunks scoped to itself. - let holder_key = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{}.0", "e".repeat(64)); - let nested = gen_envelope("nested"); - let nested_entries = prepare_fastly_config_entries(&holder_key, &nested).expect("expand"); - let (_, holder_pointer) = nested_entries.last().expect("pointer").clone(); - - // A normal live root, and a normal orphan generation that SHOULD reclaim. - let live = gen_envelope("live"); - let dead = gen_envelope("dead"); - let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); - let stamp = stamp_secs_ago(604_800); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); - listing.push((holder_key.clone(), stamp.clone(), holder_pointer)); - for (key, value) in &nested_entries[..nested_entries.len().saturating_sub(1)] { - listing.push((key.clone(), stamp.clone(), value.clone())); - } - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - // The run must SUCCEED (not abort) and still reclaim the ordinary orphan. - run_gc(dir.path(), 86_400, false).expect("store-wide GC must not abort"); - for key in &dead_chunks { - assert!( - oplog_has(&oplog, &format!("delete {key}")), - "an ordinary orphan must still be reclaimed despite the self-scoped pointer: `{key}`" - ); - } - assert!( - !oplog_has(&oplog, &format!("delete {holder_key}")), - "the chunk-shaped holder root must never be deleted" - ); - } - - /// A nested ORPHAN generation (chunks scoped to a chunk-shaped root, with NO - /// live pointer referencing them) must be grouped and reclaimed, not silently - /// dropped. Age and grouping split on the LAST infix, so the nested chunks are - /// attributed to their real (nested) root. - #[cfg(unix)] - #[test] - fn gc_reclaims_a_nested_orphan_generation() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - // A chunk-shaped root, and a full generation of chunks SCOPED to it — but - // no pointer references them, so they are orphaned. - let nested_root = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{}.0", "f".repeat(64)); - let nested = gen_envelope("nested-orphan"); - let nested_entries = prepare_fastly_config_entries(&nested_root, &nested).expect("expand"); - let nested_chunks: Vec = nested_entries[..nested_entries.len().saturating_sub(1)] - .iter() - .map(|(key, _)| key.clone()) - .collect(); - - let live = gen_envelope("live"); - let stamp = stamp_secs_ago(604_800); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - for (key, value) in &nested_entries[..nested_entries.len().saturating_sub(1)] { - listing.push((key.clone(), stamp.clone(), value.clone())); - } - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - run_gc(dir.path(), 86_400, false).expect("gc succeeds"); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - for key in &nested_chunks { - assert!( - oplog_has(&oplog, &format!("delete {key}")), - "a nested orphan generation must be reclaimed, not silently dropped: `{key}`; \ - log:\n{log}" - ); - } - } - - /// FAIL CLOSED: a MALFORMED pointer sitting at a chunk-shaped root that HAS a - /// nested generation beneath it must abort GC, not let that nested generation - /// be reclaimed. The truncated pointer cannot announce its discriminator, so - /// it looks like a chunk fragment -- but its nested chunks are proven - /// independently and would be deleted while their (unreadable) root can no - /// longer name them. That is exactly the truncated-pointer data loss the - /// spec forbids, so the whole run must refuse. - #[cfg(unix)] - #[test] - fn gc_fails_closed_on_a_malformed_pointer_at_a_chunk_shaped_root_with_nested_chunks() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let nested_root = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{}.0", "f".repeat(64)); - let nested = gen_envelope("nested"); - let nested_entries = prepare_fastly_config_entries(&nested_root, &nested).expect("expand"); - let nested_chunks: Vec = nested_entries[..nested_entries.len().saturating_sub(1)] - .iter() - .map(|(key, _)| key.clone()) - .collect(); - - let live = gen_envelope("live"); - let stamp = stamp_secs_ago(604_800); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - // A truncated pointer at the chunk-shaped nested root: it WAS a pointer, - // now cut off, so it cannot announce its `edgezero_kind`. - listing.push(( - nested_root.clone(), - stamp.clone(), - r#"{"chunks":[{"key":"#.to_owned(), - )); - // ...its aged, independently-provable nested generation. - for (key, value) in &nested_entries[..nested_entries.len().saturating_sub(1)] { - listing.push((key.clone(), stamp.clone(), value.clone())); - } - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - let err = run_gc(dir.path(), 86_400, false) - .expect_err("an unreadable nested root must fail closed, not be reclaimed"); - assert!( - err.contains("refusing to reclaim"), - "must fail closed, not delete: {err}" - ); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - for key in &nested_chunks { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "a nested generation under an unreadable root must NOT be deleted: `{key}`; \ - log:\n{log}" - ); - } - } - - /// Age attribution works per NESTED root: a nested orphan generation whose - /// nested root's live config went live RECENTLY must be RETAINED (POPs may - /// still serve the superseded generation), even though the orphan's own - /// chunks are old. This pins that `root_live_since` splits on the last infix. - #[cfg(unix)] - #[test] - fn gc_retains_a_nested_orphan_under_a_recently_changed_nested_root() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let nested_root = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{}.0", "a".repeat(64)); - - // The nested root's CURRENT (live) generation, created 30s ago. - let live_nested = gen_envelope("live-nested"); - let live_entries = prepare_fastly_config_entries(&nested_root, &live_nested).expect("exp"); - let (_, live_pointer) = live_entries.last().expect("pointer").clone(); - - // An OLD orphan generation under the SAME nested root (a week old). - let old_nested = gen_envelope("old-nested-orphan"); - let old_entries = prepare_fastly_config_entries(&nested_root, &old_nested).expect("exp"); - let old_chunks: Vec = old_entries[..old_entries.len().saturating_sub(1)] - .iter() - .map(|(key, _)| key.clone()) - .collect(); - - let live = gen_envelope("live"); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - // The nested root holds its live pointer; its live chunks are 30s old. - listing.push((nested_root.clone(), stamp_secs_ago(30), live_pointer)); - for (key, value) in &live_entries[..live_entries.len().saturating_sub(1)] { - listing.push((key.clone(), stamp_secs_ago(30), value.clone())); - } - // The old orphan chunks are a week old. - for (key, value) in &old_entries[..old_entries.len().saturating_sub(1)] { - listing.push((key.clone(), stamp_secs_ago(604_800), value.clone())); - } - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - // A generous 1-day window: the orphan's OWN chunks are older, but the - // nested root's live config is only 30s old, so its orphan is retained. - run_gc(dir.path(), 86_400, false).expect("gc succeeds"); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - for key in &old_chunks { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "a nested orphan under a recently-changed nested root must be retained: `{key}`; \ - log:\n{log}" - ); - } - } - - /// A generation is aged by its YOUNGEST member, so a generation with one - /// recent chunk is retained whole even if its other chunks are ancient. - #[cfg(unix)] - #[test] - fn gc_ages_a_generation_by_its_youngest_member() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - // `app_config` live is direct, so there is no live-config age signal — - // aging falls to the generation's own chunks. - let live_direct = gen_envelope_padded("live-direct", 100); - let dead = gen_envelope("dead"); - let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); - assert!(dead_chunks.len() >= 2, "need a multi-chunk generation"); - - let mut listing = vec![( - TEST_CONFIG_ID.to_owned(), - stamp_secs_ago(999_999), - live_direct, - )]; - // The doomed generation: chunk 0 written 30s ago (YOUNG), the rest a week - // ago. Its youngest-member age (30s) is under the 1-day window. - let dead_parts = chunked_parts(TEST_CONFIG_ID, &dead).0; - for (idx, (key, value)) in dead_parts.iter().enumerate() { - let age = if idx == 0 { 30 } else { 604_800 }; - listing.push((key.clone(), stamp_secs_ago(age), value.clone())); - } - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - run_gc(dir.path(), 86_400, false).expect("gc succeeds"); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - for key in &dead_chunks { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "a generation with a recent member must be retained WHOLE (aged by its youngest): \ - `{key}`; log:\n{log}" - ); - } - } - - /// A delete failure in one generation must not stop an INDEPENDENT - /// generation's deletes. - #[cfg(unix)] - #[test] - fn gc_failure_in_one_generation_does_not_stop_another() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let dead_a = gen_envelope("dead-a"); - let dead_b = gen_envelope("dead-b"); - let a_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead_a); - let b_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead_b); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - listing.extend(listed_generation(TEST_CONFIG_ID, &dead_a, 604_800)); - listing.extend(listed_generation(TEST_CONFIG_ID, &dead_b, 604_800)); - - // Generation A's first delete fails. - let fake = fake_fastly_gc( - TEST_CONFIG_ID, - &[], - &listing, - Some(&a_chunks[0]), - false, - &oplog, - ); - let _path = PathPrepend::new(fake.path()); - - let err = run_gc(dir.path(), 86_400, false).expect_err("a failed delete is a failure"); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - // Generation B must still have been reclaimed despite A's failure. - for key in &b_chunks { - assert!( - oplog_has(&oplog, &format!("delete {key}")), - "an independent generation must still be reclaimed after another one fails: \ - `{key}`; err: {err}; log:\n{log}" - ); - } - } - - /// key shape is not authoritative for ROOTS either. - /// - /// A valid pointer stored at a chunk-SHAPED key (`shadow.__edgezero_chunks. - /// .0`) is skipped by the live-set scan, which excludes chunk-shaped - /// keys up front. The runtime resolver follows any pointer it is given, so - /// that pointer's references ARE live -- but GC never sees them, calls the - /// generation orphaned, and deletes it. - #[cfg(unix)] - #[test] - fn pointer_at_chunk_shaped_key_keeps_its_references_live() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - // `app_config`'s CURRENT config is small enough to store directly, so - // its own root references no chunks at all. - let live_direct = gen_envelope_padded("live-direct", 100); - let mut listing = vec![( - TEST_CONFIG_ID.to_owned(), - stamp_secs_ago(999_999), - live_direct, - )]; - - // An older chunked generation of `app_config` still exists... - let referenced = gen_envelope("still-referenced"); - let referenced_chunks = chunk_keys_of(TEST_CONFIG_ID, &referenced); - listing.extend(listed_generation(TEST_CONFIG_ID, &referenced, 604_800)); - - // ...and a pointer at a CHUNK-SHAPED key references it. The resolver - // would happily follow this, so those chunks are LIVE. - let (_, referenced_pointer) = chunked_parts(TEST_CONFIG_ID, &referenced); - let shadow_key = format!("shadow{CHUNK_KEY_INFIX}{}.0", "d".repeat(64)); - listing.push((shadow_key, stamp_secs_ago(604_800), referenced_pointer)); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - // The RESULT does not matter here (it may Err after the fix if the - // shadow pointer's own chunks are incomplete); the invariant is purely - // that no LIVE-referenced chunk is deleted, which the oplog proves. - drop(run_gc(dir.path(), 86_400, false)); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - for key in &referenced_chunks { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "a chunk a live pointer references must never be deleted, whatever the KEY of \ - the entry holding that pointer looks like: `{key}`; log:\n{log}" - ); - } - } - - /// a FOREIGN writer needs NO preimage to satisfy a - /// content-address. Pick envelope E, compute H = sha256(E), split E however - /// you like, store the parts as `.__edgezero_chunks.H.0` / `.1`. Under - /// hash-only checking that group "proved" itself and was deleted. - /// - /// The round-trip closes it: the writer, given those same bytes, must emit - /// exactly these keys and values. A split at boundaries we would never - /// choose is not our output, so it is left alone. - #[cfg(unix)] - #[test] - fn gc_never_reclaims_a_foreign_content_addressed_group() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 999_999)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); - - // A foreign writer's data: a valid envelope, content-addressed under our - // reserved namespace, but split at ITS OWN boundary (not our 7 000-byte - // UTF-8-safe one). Everything hashes correctly -- no preimage needed. - let foreign = gen_envelope_padded("foreign-tool", 20_000); - let generation = sha256_hex(foreign.as_bytes()); - let (head, tail) = foreign.split_at(1_234); - let foreign_keys = [ - format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{generation}.0"), - format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{generation}.1"), - ]; - listing.push(( - foreign_keys[0].clone(), - stamp_secs_ago(31_536_000), - head.to_owned(), - )); - listing.push(( - foreign_keys[1].clone(), - stamp_secs_ago(31_536_000), - tail.to_owned(), - )); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - run_gc(dir.path(), 86_400, false).expect("gc succeeds"); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - for key in &foreign_keys { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "a group this writer would never have produced must not be reclaimed, however \ - well it hashes: `{key}`; log:\n{log}" - ); - } - } - - /// an entry can be chunk-SHAPED without being a chunk - /// -- a store may predate this feature or be shared, and push-time - /// reserved-key rejection cannot protect what already exists. Deleting one - /// would destroy live config. - /// - /// proof is CONTENT, not shape. A candidate generation is ours only - /// if it reassembles to the content-address its own keys name. Unprovable - /// entries are left UNTOUCHED and reported -- not fatal, because one foreign - /// entry must not block reclaiming the rest of the store forever. - #[cfg(unix)] - #[test] - fn gc_leaves_unprovable_chunk_shaped_entries_untouched() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let dead = gen_envelope("dead"); - let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 999_999)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); - // A real orphan generation: provable, old -> must still be reclaimed. - listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); - - // Pre-existing entries at chunk-shaped keys that we did NOT write: one - // holding somebody's real config envelope, one holding plain text. - // Both are old enough to look "eligible" on age alone. - let envelope_squatter = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{}.0", "b".repeat(64)); - let text_squatter = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{}.0", "c".repeat(64)); - listing.push(( - envelope_squatter.clone(), - stamp_secs_ago(31_536_000), - gen_envelope("someones-real-config"), - )); - listing.push(( - text_squatter.clone(), - stamp_secs_ago(31_536_000), - "just some plain text".to_owned(), - )); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - let out = run_gc(dir.path(), 86_400, false).expect("gc succeeds"); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - - for key in [&envelope_squatter, &text_squatter] { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "an entry we cannot prove we wrote must never be deleted: `{key}`; log:\n{log}" - ); - } - // Left untouched must not mean silently ignored. The wording must not - // over-claim either: these two entries fail for DIFFERENT reasons (a - // wrong content-address vs a count this writer never emits), so the - // summary says "not byte-identical to what this writer would produce" - // rather than naming one specific check. - assert!( - out.iter() - .any(|line| line.contains("not byte-identical to what this writer would produce")), - "the summary must report what it declined to judge; out: {out:?}" - ); - // ...and a genuine orphan generation is still reclaimed, so one foreign - // entry does not block the store. - for key in &dead_chunks { - assert!( - oplog_has(&oplog, &format!("delete {key}")), - "a provable orphan generation must still be reclaimed: `{key}`; log:\n{log}" - ); - } - } - - /// a key is unique in a config store, so duplicate rows - /// mean the listing is not one consistent view. Left alone, last-row-wins on - /// `created_at` could age a recent key into eligibility. - #[cfg(unix)] - #[test] - fn gc_fails_closed_on_duplicate_listing_keys() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let dead = gen_envelope("dead"); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - let mut orphans = listed_generation(TEST_CONFIG_ID, &dead, 30); - // The same key twice, with conflicting ages: young (real) then ancient. - let (dup_key, _, dup_value) = orphans[0].clone(); - orphans.push((dup_key.clone(), stamp_secs_ago(31_536_000), dup_value)); - listing.extend(orphans); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - let err = run_gc(dir.path(), 86_400, false).expect_err("must fail closed"); - assert!( - err.contains("more than once"), - "expected a refusal naming the duplicate key, got: {err}" - ); - assert!( - !oplog_has(&oplog, &format!("delete {dup_key}")), - "a duplicated row must not let a recent key be aged into eligibility" - ); - } - - /// `gc_config_entries` is a public trait method, so the - /// zero-window rule must live at the DESTRUCTIVE boundary, not only in the - /// CLI that usually calls it. Rejected before any `fastly` invocation. - #[cfg(unix)] - #[test] - fn gc_adapter_boundary_rejects_a_zero_window() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let dead = gen_envelope("dead"); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - // Straight at the adapter, bypassing the CLI's own gate. - let err = run_gc(dir.path(), 0, false).expect_err("a destructive zero window must fail"); - assert!( - err.contains("non-zero `--older-than`"), - "expected the boundary itself to reject zero, got: {err}" - ); - assert!( - !fs::read_to_string(&oplog) - .unwrap_or_default() - .contains("delete "), - "nothing may be deleted under a zero window" - ); - // A DRY-RUN at zero is still allowed: it previews and deletes nothing. - run_gc(dir.path(), 0, true).expect("a dry-run may preview at zero"); - } - - /// a root whose value is TRUNCATED/unparseable must fail - /// closed. It is pointer-shaped garbage -- we cannot tell what it references, - /// so its (live!) chunks must not be judged orphaned. Regression guard: the - /// push-path helper returns `Ok([])` for a non-pointer value, which on THIS - /// path would read as "references nothing" and reclaim the whole store. - #[cfg(unix)] - #[test] - fn gc_fails_closed_on_truncated_root_pointer() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let live_chunks = chunk_keys_of(TEST_CONFIG_ID, &live); - let (_, pointer) = chunked_parts(TEST_CONFIG_ID, &live); - // A write that landed half-way: a valid PREFIX of the real pointer that - // is no longer valid JSON. (Chars, not a byte slice -- never split a - // codepoint.) - let truncated: String = pointer.chars().take(40).collect(); - assert!( - serde_json::from_str::(&truncated).is_err(), - "fixture must be unparseable to exercise the classifier: {truncated}" - ); - - let mut listing = vec![( - TEST_CONFIG_ID.to_owned(), - stamp_secs_ago(999_999), - truncated, - )]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - let err = run_gc(dir.path(), 1, false).expect_err("must fail closed"); - assert!( - err.contains("refusing to reclaim"), - "expected a fail-closed refusal, got: {err}" - ); - for key in &live_chunks { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "nothing may be deleted when a root is unclassifiable: `{key}`" - ); - } - } - - /// an ENVELOPED listing (`{"items":[...]}`) may carry - /// pagination we do not follow. A page that omitted a root would make that - /// root's live chunks look orphaned -- and the completeness guard cannot see - /// a root that isn't there. Refuse the shape outright. - #[cfg(unix)] - #[test] - fn gc_fails_closed_on_enveloped_listing() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 999_999)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); - let enveloped = format!( - r#"{{"items":{},"next_cursor":"abc"}}"#, - entry_list_json(&listing) - ); - - let fake = fake_fastly_gc_raw_list(TEST_CONFIG_ID, &enveloped, &oplog); - let _path = PathPrepend::new(fake.path()); - - let err = run_gc(dir.path(), 1, false).expect_err("must fail closed"); - assert!( - err.contains("bare array") && err.contains("Nothing was deleted"), - "expected a refusal naming the unsupported listing shape, got: {err}" - ); - assert!( - !fs::read_to_string(&oplog) - .unwrap_or_default() - .contains("delete "), - "an unsupported listing shape must delete nothing" - ); - } - - /// a root with an EMPTY value is as dangerous as a - /// missing one -- it would classify as "references nothing" and orphan its - /// live chunks. The listing parser rejects it before any reasoning. - #[cfg(unix)] - #[test] - fn gc_fails_closed_on_empty_root_value() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let live_chunks = chunk_keys_of(TEST_CONFIG_ID, &live); - let mut listing = vec![( - TEST_CONFIG_ID.to_owned(), - stamp_secs_ago(999_999), - String::new(), - )]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - let err = run_gc(dir.path(), 1, false).expect_err("must fail closed"); - assert!( - err.contains("empty `item_value`"), - "expected a refusal naming the empty field, got: {err}" - ); - for key in &live_chunks { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "nothing may be deleted on an unreadable listing: `{key}`" - ); - } - } - - /// the orphan's OWN age is mandatory -- an old root does - /// not license deleting a chunk written seconds ago (e.g. by a concurrent - /// push that has not committed its pointer yet). Both ages must clear the - /// window; the more restrictive wins. - #[cfg(unix)] - #[test] - fn gc_retains_young_orphan_under_long_stable_root() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let fresh = gen_envelope("fresh"); - let fresh_chunks = chunk_keys_of(TEST_CONFIG_ID, &fresh); - - // The root's live config has been stable for a year -- so the live-config - // clock alone would happily reclaim. But these chunks were written 10s - // ago and no pointer names them yet. - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 31_536_000)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 31_536_000)); - listing.extend(listed_generation(TEST_CONFIG_ID, &fresh, 10)); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - run_gc(dir.path(), 86_400, false).expect("gc succeeds"); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - for key in &fresh_chunks { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "a chunk written 10s ago must be retained under a 1-day window regardless of \ - how stable its root is: `{key}`; log:\n{log}" - ); - } - } - - /// GC and the RUNTIME must agree on what a readable pointer is. A pointer - /// whose chunks reassemble to the correct bytes along boundaries this writer - /// would never choose is REJECTED by the runtime resolver, so GC must not - /// silently report it as a healthy root: the guest cannot read it, and its - /// generation can never satisfy `prove_generation`, so it is permanently - /// unreclaimable. GC still keeps it (fail-closed) but must SAY so. - #[cfg(unix)] - #[test] - fn gc_warns_that_a_non_writer_split_root_is_not_runtime_readable() { - use crate::chunked_config::CHUNK_PAYLOAD_TARGET; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - // Just over the entry limit => a full chunk plus a short remainder with - // room to absorb the shifted bytes. - let envelope = gen_envelope("shifted"); - let sha = sha256_hex(envelope.as_bytes()); - // Re-split 2 bytes early: still within every metadata bound the pointer - // validator checks, but NOT where this writer splits. - let cut = CHUNK_PAYLOAD_TARGET.saturating_sub(2); - let head = envelope.get(..cut).expect("ascii boundary"); - let tail = envelope.get(cut..).expect("ascii boundary"); - let key0 = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{sha}.0"); - let key1 = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{sha}.1"); - let pointer_json = serde_json::json!({ - "chunks": [ - {"key": key0, "len": head.len(), "sha256": sha256_hex(head.as_bytes())}, - {"key": key1, "len": tail.len(), "sha256": sha256_hex(tail.as_bytes())}, - ], - "data_sha256": "", - "edgezero_kind": "fastly_config_chunks", - "envelope_len": envelope.len(), - "envelope_sha256": sha, - "version": 1_u8, - }) - .to_string(); - - let stamp = stamp_secs_ago(604_800); - let listing = vec![ - (TEST_CONFIG_ID.to_owned(), stamp.clone(), pointer_json), - (key0.clone(), stamp.clone(), head.to_owned()), - (key1.clone(), stamp, tail.to_owned()), - ]; - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - let out = run_gc(dir.path(), 86_400, false).expect("gc must not abort on such a root"); - let rendered = out.join("\n"); - assert!( - rendered.contains("NOT runtime-readable"), - "GC must warn that this root is unreadable rather than call it healthy: {rendered}" - ); - // This root is PROTECTED (kept) but not runtime-live, so the report must - // list it as RETAINED and must NOT label it (or the store) "live". - assert!( - rendered.contains(&format!("keeping `{TEST_CONFIG_ID}`")) - && rendered.contains("retained root(s)"), - "an unreadable-but-protected root must be reported as retained: {rendered}" - ); - assert!( - !rendered.contains("live root") && !rendered.contains("live chunk"), - "an unreadable root's chunks are protected/referenced, never labeled live: {rendered}" - ); - // Fail-closed: nothing is deleted, including its chunks. - let log = fs::read_to_string(&oplog).unwrap_or_default(); - for key in [&key0, &key1] { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "must not delete a chunk of an unreadable root: `{key}`; log:\n{log}" - ); - } - } - - #[test] - fn kept_roots_report_wording_counts_and_empty_store() { - // Empty: a single, unambiguous "nothing retained" line and no root list. - let mut empty = Vec::new(); - append_kept_roots_report(&mut empty, &[], 0); - assert_eq!(empty, vec!["keeping 0 retained root(s)".to_owned()]); - - // Non-empty: a heading naming the RETAINED-root count and the - // REFERENCED-chunk count, then one line per root by key. - let mut out = Vec::new(); - append_kept_roots_report( - &mut out, - &["app_config".to_owned(), "app_config_staging".to_owned()], - 5, - ); - assert!( - out[0].contains("keeping 2 retained root(s)") - && out[0].contains("5 referenced chunk(s)"), - "heading names the retained-root and referenced-chunk counts: {out:?}" - ); - assert!(out.iter().any(|line| line == " keeping `app_config`")); - assert!( - out.iter() - .any(|line| line == " keeping `app_config_staging`") - ); - // Never the misleading "live" label -- a retained root may not be - // runtime-live, and its chunks are protected/referenced, not live. - assert!( - !out.iter() - .any(|line| line.contains("live root") || line.contains("live chunk")), - "must not label retained roots/chunks as live: {out:?}" - ); - } - - /// A legitimate FOREIGN sibling (the documented `greeting = "hello"`) must - /// NOT block store-wide GC. The runtime returns such a value verbatim, so GC - /// protects it as a zero-reference root and still reclaims an unrelated dead - /// generation, rather than aborting the whole pass. - #[cfg(unix)] - #[test] - fn gc_reclaims_despite_a_foreign_sibling_value() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let dead = gen_envelope("dead"); - let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); - - let mut listing = vec![ - listed_root(TEST_CONFIG_ID, &live, 172_800), - // A plain, non-envelope, non-pointer sibling entry. - ( - "greeting".to_owned(), - stamp_secs_ago(172_800), - "hello".to_owned(), - ), - ]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - run_gc(dir.path(), 86_400, false).expect("a foreign sibling must not abort GC"); - for key in &dead_chunks { - assert!( - oplog_has(&oplog, &format!("delete {key}")), - "the dead generation must still be reclaimed: `{key}`" - ); - } - assert!( - !oplog_has(&oplog, "delete greeting"), - "the foreign sibling must never be deleted" - ); - } - - /// A DIRECT envelope from a NEWER writer at an ordinary key classifies as - /// `Foreign` (no `edgezero_kind`), so without the future-format guard GC would - /// wave it through as a zero-reference root and reclaim an otherwise-dead - /// generation -- yet the newer format may reference those chunks under a - /// scheme this build cannot read. GC must FAIL CLOSED and delete nothing. - #[cfg(unix)] - #[test] - fn gc_fails_closed_on_a_future_direct_envelope_at_an_ordinary_key() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let dead = gen_envelope("dead"); - - // Envelope-shaped, no discriminator, VERSION 2 -> a newer direct envelope. - let future = r#"{"data":{"x":1},"sha256":"0000000000000000000000000000000000000000000000000000000000000000","generated_at":"2026-01-01T00:00:00Z","version":2}"#; - let mut listing = vec![( - "app_config".to_owned(), - stamp_secs_ago(172_800), - future.to_owned(), - )]; - listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - let result = run_gc(dir.path(), 86_400, false); - assert!( - result.is_err(), - "a future direct envelope must abort GC (fail closed)" - ); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - assert!( - !log.lines().any(|line| line.starts_with("delete ")), - "nothing may be deleted when GC fails closed; log:\n{log}" - ); - } - - /// A valid v1 pointer whose chunks reassemble to a NEWER inner format. GC can - /// validate the outer pointer and reassemble the bytes, but the reassembled - /// value may reference generations this build cannot see, so trusting only the - /// outer chunks as the live set could delete live data. GC must fail closed -- - /// `BlobEnvelope` deserialize alone would silently ignore the newer format. - #[cfg(unix)] - #[test] - fn gc_fails_closed_on_a_future_inner_generation() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - // A v1 envelope, chunked, then its inner `version` bumped to 2. The v1 - // pointer's content-address still matches the reassembled (v2) bytes. - let v1 = gen_envelope("live"); - let mut v2_value: serde_json::Value = serde_json::from_str(&v1).expect("parse"); - v2_value["version"] = serde_json::json!(2_u32); - let v2 = v2_value.to_string(); - - let mut listing = vec![listed_root(TEST_CONFIG_ID, &v2, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &v2, 172_800)); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - let result = run_gc(dir.path(), 86_400, false); - assert!( - result - .as_ref() - .is_err_and(|err| err.contains("newer format")), - "a future inner generation must abort GC with a newer-format error: {result:?}" - ); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - assert!( - !log.lines().any(|line| line.starts_with("delete ")), - "nothing may be deleted when GC fails closed; log:\n{log}" - ); - } - - /// A dry-run lists exactly what it would delete, and deletes nothing. - #[cfg(unix)] - #[test] - fn gc_dry_run_lists_but_deletes_nothing() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let dead = gen_envelope("dead"); - let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); - - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - let out = run_gc(dir.path(), 86_400, true).expect("dry-run succeeds"); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - assert!( - !log.lines().any(|line| line.starts_with("delete ")), - "a dry-run must not delete; log:\n{log}" - ); - let rendered = out.join("\n"); - assert!( - rendered.contains("would delete"), - "lists intent: {rendered}" - ); - for key in &dead_chunks { - assert!(rendered.contains(key.as_str()), "names `{key}`: {rendered}"); - } - // It must also report what it is KEEPING: the live root, by key. - assert!( - rendered.contains(&format!("keeping `{TEST_CONFIG_ID}`")), - "must name the retained live root: {rendered}" - ); - } - - /// An unreadable `created_at` on a DELETE path fails CLOSED. - #[cfg(unix)] - #[test] - fn gc_fails_closed_on_unreadable_timestamp() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let dead = gen_envelope("dead"); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 3_600)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 3_600)); - // An orphan whose timestamp is garbage. - let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); - for key in dead_chunks { - listing.push((key, "not-a-timestamp".to_owned(), "X".to_owned())); - } - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - let err = run_gc(dir.path(), 86_400, false).expect_err("must fail closed"); - assert!( - err.contains("unreadable") && err.contains("nothing was deleted"), - "must refuse to reclaim: {err}" - ); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - assert!( - !log.lines().any(|line| line.starts_with("delete ")), - "nothing may be deleted when the state is unreadable; log:\n{log}" - ); - } - - /// A root whose pointer cannot be classified fails CLOSED — we cannot know - /// what it references, so nothing may be deleted. - #[cfg(unix)] - #[test] - fn gc_fails_closed_on_unclassifiable_root() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let dead = gen_envelope("dead"); - // Root value is pointer-kind but invalid. - let bad = r#"{"edgezero_kind":"fastly_config_chunks","version":2}"#.to_owned(); - let mut listing = vec![(TEST_CONFIG_ID.to_owned(), stamp_secs_ago(3_600), bad)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - let err = run_gc(dir.path(), 86_400, false).expect_err("must fail closed"); - assert!( - err.contains("could not classify root") && err.contains("nothing was deleted"), - "must refuse to reclaim: {err}" - ); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - assert!( - !log.lines().any(|line| line.starts_with("delete ")), - "nothing may be deleted when a root is unclassifiable; log:\n{log}" - ); - } - - /// A listing entry missing a required field fails CLOSED — a defaulted/empty - /// field could make a real root look like it references nothing, deleting - /// live chunks. - #[cfg(unix)] - #[test] - fn gc_fails_closed_on_malformed_listing_entry() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - let good = entry_list_json(&listing); - // Inject an entry with NO item_value (drop that field entirely). - let mut array: serde_json::Value = serde_json::from_str(&good).unwrap(); - array.as_array_mut().unwrap().push(serde_json::json!({ - "item_key": "some.__edgezero_chunks.deadbeef.0", - "created_at": stamp_secs_ago(1000), - })); - // Serve that hand-built listing. - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - fs::write( - fake.path().join("entries.json"), - serde_json::to_string(&array).unwrap(), - ) - .expect("overwrite entries"); - let _path = PathPrepend::new(fake.path()); - - let err = run_gc(dir.path(), 86_400, false).expect_err("must fail closed"); - assert!( - err.contains("missing a string") && err.contains("item_value"), - "must name the missing field: {err}" - ); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - assert!( - !log.lines().any(|line| line.starts_with("delete ")), - "nothing may be deleted on a malformed listing; log:\n{log}" - ); - } - - /// A failed delete is a non-zero exit that names the failed key(s), so - /// automation can detect partial failure. - #[cfg(unix)] - #[test] - fn gc_delete_failure_is_non_zero_exit() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let dead = gen_envelope("dead"); - let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); - let fail_key = dead_chunks.first().expect("a chunk").clone(); - - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); - - let fake = fake_fastly_gc( - TEST_CONFIG_ID, - &[], - &listing, - Some(&fail_key), - false, - &oplog, - ); - let _path = PathPrepend::new(fake.path()); - - let err = run_gc(dir.path(), 86_400, false).expect_err("a failed delete must be non-zero"); - assert!( - err.contains("deletes FAILED") && err.contains(&fail_key), - "error names the failed key: {err}" - ); - } - - /// Every reclamation delete passes `--key` + `--auto-yes` and NEVER `--all`. - #[cfg(unix)] - #[test] - fn gc_delete_uses_key_and_auto_yes_never_all() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let dead = gen_envelope("dead"); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - run_gc(dir.path(), 86_400, false).expect("gc succeeds"); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - let argv_lines: Vec<&str> = log - .lines() - .filter(|line| line.starts_with("delete-argv ")) - .collect(); - assert!(!argv_lines.is_empty(), "a delete happened: {log}"); - for line in argv_lines { - assert!( - line.contains("--auto-yes"), - "delete passes --auto-yes: {line}" - ); - assert!(line.contains("--key="), "delete targets a --key: {line}"); - assert!( - !line.contains("--all"), - "delete must NEVER pass --all: {line}" - ); - } - } - - /// A non-canonical chunk-like key (short/uppercase SHA, leading-zero index) - /// is NOT a delete candidate — the destructive validator is canonical-only. - #[cfg(unix)] - #[test] - fn gc_never_deletes_non_canonical_keys() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - - let live = gen_envelope("live"); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - // Foreign-shaped keys under the reserved infix but not canonical. - let noncanonical = [ - format!("{TEST_CONFIG_ID}.__edgezero_chunks.abc123.0"), // short sha - format!("{TEST_CONFIG_ID}.__edgezero_chunks.{}.00", "a".repeat(64)), // leading-zero idx - format!("{TEST_CONFIG_ID}.__edgezero_chunks.{}.0", "A".repeat(64)), // uppercase - ]; - for key in &noncanonical { - listing.push((key.clone(), stamp_secs_ago(604_800), "X".to_owned())); - } - - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - // A key that is NOT canonical is not one we wrote, so it is not a - // reclamation candidate. It sits in our reserved namespace, though, so - // it is also not an ordinary root: we cannot say what it is. Since the - // GC classifier fails closed on any root it cannot classify, the run - // aborts and names it -- which satisfies this test's invariant (a - // non-canonical key is never deleted) the strict way. - let err = run_gc(dir.path(), 86_400, false).expect_err("must fail closed"); - assert!( - err.contains("refusing to reclaim"), - "expected a fail-closed refusal, got: {err}" - ); - let log = fs::read_to_string(&oplog).unwrap_or_default(); - for key in &noncanonical { - assert!( - !oplog_has(&oplog, &format!("delete {key}")), - "a non-canonical key must never be deleted: `{key}`; log:\n{log}" - ); - } - assert!( - !log.contains("delete "), - "a fail-closed run deletes nothing at all; log:\n{log}" - ); - } - - // ---------- local chunk GC ---------- - - /// Config shrinks from chunked back under the 8 000-char limit: the - /// new value is a direct envelope, so GC prunes every prior chunk. - #[cfg(unix)] - #[test] - fn push_config_entries_local_prunes_prior_chunks_when_value_shrinks_to_direct() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - fs::write(&fastly_toml, "name = \"demo\"\n").expect("seed"); - - let chunked = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), chunked)], - &AdapterPushContext::new(), - false, - ) - .expect("first push"); - - let direct = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), direct.clone())], - &AdapterPushContext::new(), - false, - ) - .expect("second push"); - - let after = fs::read_to_string(&fastly_toml).expect("read"); - let doc: toml_edit::DocumentMut = after.parse().expect("parse"); - let contents = doc - .get("local_server") - .and_then(|ls| ls.get("config_stores")) - .and_then(|cs| cs.get(TEST_CONFIG_ID)) - .and_then(|st| st.get("contents")) - .and_then(toml_edit::Item::as_table) - .expect("contents"); - - assert_eq!( - contents - .get(TEST_CONFIG_ID) - .and_then(toml_edit::Item::as_str), - Some(direct.as_str()), - "root holds the direct envelope" - ); - assert!( - !contents - .iter() - .any(|(key, _)| key.contains(CHUNK_KEY_INFIX)), - "prior chunks must be pruned: {after}" - ); - } - - /// The local prune must NOT delete a prior chunk key whose VALUE is a - /// runtime-readable root (a valid direct envelope). A small envelope padded - /// with trailing whitespace chunks so that chunk 0 is itself a whole, - /// verifying envelope; deleting it would drop live config. - #[test] - fn push_config_entries_local_keeps_a_chunk_key_holding_a_valid_envelope() { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - - // A padded envelope: chunk 0 is the whole envelope plus trailing spaces - // (still a valid, verifying envelope on its own). - let envelope = BlobEnvelope::new(json!({ "k": "v" }), "2026-06-22T00:00:00Z".into()); - let mut padded = serde_json::to_string(&envelope).unwrap(); - padded.push_str(&" ".repeat(8_200)); - let entries = prepare_fastly_config_entries(TEST_CONFIG_ID, &padded).expect("expand"); - let chunk0_key = entries[0].0.clone(); - // Confirm the fixture: chunk 0's value verifies as an envelope. - let parsed: BlobEnvelope = serde_json::from_str(&entries[0].1).expect("chunk0 parses"); - parsed.verify().expect("chunk0 verifies"); - - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), padded)], - &AdapterPushContext::new(), - false, - ) - .expect("first push"); - - // Re-push a direct value: the prior generation's chunks become orphans. - let direct = make_test_envelope(100); - let expected_deletions = entries.len().saturating_sub(2); // chunks minus the protected chunk0 - - // DRY-RUN first: its count must MATCH what the real prune deletes, i.e. - // it must exclude the protected root-like chunk0. - let dry = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), direct.clone())], - &AdapterPushContext::new(), - true, - ) - .expect("dry-run"); - assert!( - dry.join("\n") - .contains(&format!("would delete {expected_deletions} orphan chunks")), - "dry-run must count only the prunable orphans (excluding the protected root): {dry:?}" - ); - - let warnings = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), direct)], - &AdapterPushContext::new(), - false, - ) - .expect("second push"); - - let after = fs::read_to_string(&fastly_toml).expect("read"); - let doc: toml_edit::DocumentMut = after.parse().expect("parse"); - let contents = doc - .get("local_server") - .and_then(|ls| ls.get("config_stores")) - .and_then(|cs| cs.get(TEST_CONFIG_ID)) - .and_then(|st| st.get("contents")) - .and_then(toml_edit::Item::as_table) - .expect("contents"); - - assert!( - contents.contains_key(&chunk0_key), - "a chunk key holding a valid envelope is a runtime-readable root and must be kept: \ - {after}" - ); - assert!( - warnings - .iter() - .any(|warning| warning.contains("runtime-readable root")), - "the operator must be warned that the key was kept: {warnings:?}" - ); - } - - /// The local prune must NOT delete a prior chunk key whose value was written - /// by a NEWER format -- a v2 direct envelope (bumped version) stored under a - /// chunk-shaped key. Cloud GC fails closed on it; local prune must be - /// symmetric, or an older CLI destroys config a newer writer produced. - #[test] - fn push_config_entries_local_keeps_a_chunk_key_holding_a_future_envelope() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - let chunked = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(5_000)); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), chunked)], - &AdapterPushContext::new(), - false, - ) - .expect("seed"); - - // A v2 direct envelope (version bumped) parked at a chunk key. - let mut v2_value: serde_json::Value = serde_json::from_str( - &serde_json::to_string(&BlobEnvelope::new( - json!({ "k": "v" }), - "2026-01-01T00:00:00Z".to_owned(), - )) - .unwrap(), - ) - .unwrap(); - v2_value["version"] = json!(2_u32); - let v2 = v2_value.to_string(); - - let mut doc: toml_edit::DocumentMut = fs::read_to_string(&fastly_toml) - .expect("read") - .parse() - .expect("parse"); - let contents = doc["local_server"]["config_stores"][TEST_CONFIG_ID]["contents"] - .as_table_mut() - .expect("contents"); - let victim = contents - .iter() - .map(|(key, _)| key.to_owned()) - .find(|key| key.contains(CHUNK_KEY_INFIX)) - .expect("a chunk key"); - contents.insert(&victim, toml_edit::value(v2)); - fs::write(&fastly_toml, doc.to_string()).expect("write"); - - let direct = make_test_envelope(100); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), direct)], - &AdapterPushContext::new(), - false, - ) - .expect("re-push"); - - let after = fs::read_to_string(&fastly_toml).expect("read"); - let after_doc: toml_edit::DocumentMut = after.parse().expect("parse"); - assert!( - after_doc["local_server"]["config_stores"][TEST_CONFIG_ID]["contents"] - .as_table() - .expect("contents") - .contains_key(&victim), - "a v2 (future-format) envelope must be KEPT, not pruned: {after}" - ); - } - - /// The local prune must NOT delete a prior chunk key whose value claims our - /// `edgezero_kind` namespace with an UNKNOWN/future kind. The cloud GC path - /// fails closed on such a value; local replacement must be symmetric, or it - /// would destroy a newer-format entry an older CLI cannot understand. - #[test] - fn push_config_entries_local_keeps_a_chunk_key_holding_an_unknown_kind() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - - // Seed a real chunked generation, then overwrite ONE chunk value with a - // future-format value that claims our namespace but is not a v1 pointer. - let chunked = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(5_000)); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), chunked)], - &AdapterPushContext::new(), - false, - ) - .expect("seed"); - - let mut doc: toml_edit::DocumentMut = fs::read_to_string(&fastly_toml) - .expect("read") - .parse() - .expect("parse"); - let contents = doc["local_server"]["config_stores"][TEST_CONFIG_ID]["contents"] - .as_table_mut() - .expect("contents"); - let victim = contents - .iter() - .map(|(key, _)| key.to_owned()) - .find(|key| key.contains(CHUNK_KEY_INFIX)) - .expect("a chunk key"); - contents.insert( - &victim, - toml_edit::value(r#"{"edgezero_kind":"fastly_config_chunks_v2","new":true}"#), - ); - fs::write(&fastly_toml, doc.to_string()).expect("write"); - - // Re-push a direct value: every prior chunk becomes an orphan. - let direct = make_test_envelope(100); - let warnings = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), direct)], - &AdapterPushContext::new(), - false, - ) - .expect("re-push"); - - let after = fs::read_to_string(&fastly_toml).expect("read"); - let after_doc: toml_edit::DocumentMut = after.parse().expect("parse"); - let present = after_doc["local_server"]["config_stores"][TEST_CONFIG_ID]["contents"] - .as_table() - .expect("contents") - .contains_key(&victim); - assert!( - present, - "an unknown/future-kind value must be KEPT (symmetric with cloud GC fail-closed): {after}" - ); - assert!( - warnings - .iter() - .any(|warning| warning.contains("kept") && warning.contains("edgezero_kind")), - "the operator must be warned the namespace-claiming key was kept: {warnings:?}" - ); - } - - /// SYMMETRY with cloud GC: a local prune must NOT delete a truncated pointer - /// at a chunk-shaped key that HAS a canonical chunk nested beneath it — it is - /// a (broken) nested root, and removing it would orphan the nested chunks. - #[test] - fn push_config_entries_local_keeps_a_malformed_nested_root_holder() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - - // Seed a chunked generation, then turn ONE chunk key into a nested root: - // give it a truncated (unclassifiable) value AND a canonical chunk nested - // beneath it. - let chunked = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(5_000)); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), chunked)], - &AdapterPushContext::new(), - false, - ) - .expect("seed"); - - let mut doc: toml_edit::DocumentMut = fs::read_to_string(&fastly_toml) - .expect("read") - .parse() - .expect("parse"); - let contents = doc["local_server"]["config_stores"][TEST_CONFIG_ID]["contents"] - .as_table_mut() - .expect("contents"); - let holder = contents - .iter() - .map(|(key, _)| key.to_owned()) - .find(|key| key.contains(CHUNK_KEY_INFIX)) - .expect("a chunk key"); - // Truncated pointer at the holder (unclassifiable, announces no kind). - contents.insert(&holder, toml_edit::value(r#"{"chunks":[{"key":"#)); - // A canonical chunk nested BENEATH the holder. - let nested_chunk = format!("{holder}{CHUNK_KEY_INFIX}{}.0", "b".repeat(64)); - contents.insert(&nested_chunk, toml_edit::value("nested-payload")); - fs::write(&fastly_toml, doc.to_string()).expect("write"); - - // Re-push a direct value: every prior chunk becomes an orphan, including - // the holder (which the OLD pointer referenced). - let direct = make_test_envelope(100); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), direct)], - &AdapterPushContext::new(), - false, - ) - .expect("re-push"); - - let after = fs::read_to_string(&fastly_toml).expect("read"); - let after_doc: toml_edit::DocumentMut = after.parse().expect("parse"); - let after_contents = after_doc["local_server"]["config_stores"][TEST_CONFIG_ID]["contents"] - .as_table() - .expect("contents"); - assert!( - after_contents.contains_key(&holder), - "a nested-root holder with chunks beneath it must be KEPT, not pruned: {after}" - ); - } - - /// `preflight_config_write` rejects an infeasible push BEFORE any provider - /// I/O: a reserved key, an empty key, and a body whose DERIVED chunk keys - /// would exceed the store limit (caught by running expansion offline). - #[test] - fn preflight_config_write_rejects_infeasible_pushes_offline() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let small = make_test_envelope(100); - - let reserved = format!("app_config{CHUNK_KEY_INFIX}deadbeef.0"); - assert!( - FastlyCliAdapter - .preflight_config_write(&reserved, &small) - .is_err_and(|err| err.contains("reserved infix")), - "a reserved-namespace key must be rejected" - ); - - assert!( - FastlyCliAdapter - .preflight_config_write("", &small) - .is_err_and(|err| err.contains("empty")), - "an empty key must be rejected" - ); - - // A ~200-char root with a CHUNKED body: derived chunk keys (root + ~85) - // exceed the 255-char limit. Caught offline by expansion. - let long_root = "r".repeat(200); - let big = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - assert!( - FastlyCliAdapter - .preflight_config_write(&long_root, &big) - .is_err(), - "an over-limit derived chunk key must be rejected before I/O" - ); - - // A normal push passes. - FastlyCliAdapter - .preflight_config_write("app_config", &small) - .expect("a normal push must pass preflight"); - } - - /// A logical key containing the reserved chunk infix is rejected - /// before any file I/O (it would collide with the chunk namespace). - #[cfg(unix)] - #[test] - fn push_config_entries_local_rejects_reserved_key() { - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - let bad_key = format!("app_config{CHUNK_KEY_INFIX}deadbeef.0"); - - let err = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(bad_key.clone(), "{}".to_owned())], - &AdapterPushContext::new(), - false, - ) - .expect_err("reserved key must be rejected"); - assert!(err.contains(&bad_key), "error names the key: {err}"); - assert!( - !fastly_toml.exists(), - "rejection must happen before any write" - ); - } - - /// A suspicious prior pointer (pointer-kind but invalid) makes GC - /// warn and delete nothing — pre-seeded chunk keys must survive. - #[cfg(unix)] - #[test] - fn push_config_entries_local_warns_on_suspicious_prior_pointer_and_keeps_chunks() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - // Seed the root with a pointer-kind-but-invalid value AND a real - // chunk-like key so "no deletes" is non-vacuous. - let seed = concat!( - "name = \"demo\"\n\n", - "[local_server.config_stores.app_config]\n", - "format = \"inline-toml\"\n\n", - "[local_server.config_stores.app_config.contents]\n", - "app_config = \"{\\\"edgezero_kind\\\":\\\"fastly_config_chunks\\\",\\\"version\\\":1}\"\n", - "\"app_config.__edgezero_chunks.deadbeef.0\" = \"seeded-chunk-payload\"\n", - ); - fs::write(&fastly_toml, seed).expect("seed"); - - let direct = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); - let out = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), direct.clone())], - &AdapterPushContext::new(), - false, - ) - .expect("push must still succeed"); - - let combined = out.join("\n"); - assert!( - combined.contains("skipping chunk GC"), - "must warn about the suspicious prior pointer: {combined}" - ); - - let after = fs::read_to_string(&fastly_toml).expect("read"); - let doc: toml_edit::DocumentMut = after.parse().expect("parse"); - let contents = doc - .get("local_server") - .and_then(|ls| ls.get("config_stores")) - .and_then(|cs| cs.get(TEST_CONFIG_ID)) - .and_then(|st| st.get("contents")) - .and_then(toml_edit::Item::as_table) - .expect("contents"); - assert!( - contents - .get("app_config.__edgezero_chunks.deadbeef.0") - .is_some(), - "pre-seeded chunk key must survive a suspicious-pointer skip: {after}" - ); - assert_eq!( - contents - .get(TEST_CONFIG_ID) - .and_then(toml_edit::Item::as_str), - Some(direct.as_str()), - "new value still written" - ); - } - - /// TOCTOU guard: if the locked reread finds the root now holds a NEWER format - /// (installed between the pre-push check and the lock), the writer must REFUSE - /// to overwrite it -- an older writer must never clobber a newer format. - #[cfg(unix)] - #[test] - fn push_config_entries_local_refuses_to_overwrite_a_future_prior() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - // The root now holds a v2 direct envelope from a newer writer. - let seed = concat!( - "name = \"demo\"\n\n", - "[local_server.config_stores.app_config]\n", - "format = \"inline-toml\"\n\n", - "[local_server.config_stores.app_config.contents]\n", - "app_config = \"{\\\"data\\\":{},\\\"sha256\\\":\\\"x\\\",\\\"generated_at\\\":\\\"t\\\",\\\"version\\\":2}\"\n", - ); - fs::write(&fastly_toml, seed).expect("seed"); - - let direct = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); - let err = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), direct)], - &AdapterPushContext::new(), - false, - ) - .expect_err("a future prior must abort the push under the lock"); - assert!( - err.contains("newer format") && err.to_lowercase().contains("refusing"), - "must refuse to clobber a newer format: {err}" - ); - // The v2 value must survive untouched. - let after = fs::read_to_string(&fastly_toml).expect("read"); - assert!( - after.contains("\\\"version\\\":2"), - "the newer-format value must be left intact: {after}" - ); - } - - /// The locked downgrade guard must catch a future INNER envelope hidden behind - /// a VALID v1 pointer -- only knowable after reconstruction against the locked - /// contents. The raw pointer looks like healthy v1. - #[cfg(unix)] - #[test] - fn push_config_entries_local_refuses_a_future_inner_prior() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - - // A large v1 envelope, chunked, with its inner version bumped to 2. Seed - // the pointer + chunks as the prior local state. - let v1 = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - let mut v2_value: serde_json::Value = serde_json::from_str(&v1).expect("parse"); - v2_value["version"] = serde_json::json!(2_u32); - let v2 = v2_value.to_string(); - let seed_entries = prepare_fastly_config_entries(TEST_CONFIG_ID, &v2).expect("chunk"); - write_fastly_local_config_store(&fastly_toml, TEST_CONFIG_ID, &seed_entries, &[]) - .expect("seed the prior v2-inner generation"); - - let direct = make_test_envelope(100); - let err = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), direct)], - &AdapterPushContext::new(), - false, - ) - .expect_err("a future INNER prior must abort the push under the lock"); - assert!( - err.contains("newer format") && err.to_lowercase().contains("refusing"), - "must refuse to clobber a future inner envelope: {err}" - ); - } - - /// A generated chunk key must never clobber an existing root-like sibling. - #[cfg(unix)] - #[test] - fn push_config_entries_local_refuses_clobbering_a_root_like_chunk_sibling() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - - // The chunk keys the push will generate for this body. - let body = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - let generated = prepare_fastly_config_entries(TEST_CONFIG_ID, &body).expect("chunk"); - let chunk_key = generated - .iter() - .map(|(key, _)| key.clone()) - .find(|key| key.contains(CHUNK_KEY_INFIX)) - .expect("a generated chunk key"); - - // Pre-seed that EXACT key with a root-like value (a valid direct envelope). - let root_like = make_test_envelope(100); - write_fastly_local_config_store( - &fastly_toml, - TEST_CONFIG_ID, - &[(chunk_key.clone(), root_like)], - &[], - ) - .expect("seed a root-like value at a chunk-shaped key"); - - let err = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), body)], - &AdapterPushContext::new(), - false, - ) - .expect_err("a generated chunk key clobbering a root-like sibling must abort"); - assert!( - err.contains("refusing to push") && err.contains(&chunk_key), - "must refuse and name the colliding chunk key: {err}" - ); - } - - /// The dry-run count must EXCLUDE a prior chunk that is already absent from - /// the file: the real prune's `remove()` is a no-op there, so counting it - /// would over-report the number of deletions. - #[cfg(unix)] - #[test] - fn push_config_entries_local_dry_run_excludes_already_missing_prior_chunks() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - fs::write(&fastly_toml, "name = \"demo\"\n").expect("seed"); - - // Seed a multi-chunk generation. - let chunked = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(5_000)); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), chunked)], - &AdapterPushContext::new(), - false, - ) - .expect("seed"); - - // Manually delete ONE chunk entry: a prior chunk that is already gone. - let mut doc: toml_edit::DocumentMut = fs::read_to_string(&fastly_toml) - .expect("read") - .parse() - .expect("parse"); - let contents = doc["local_server"]["config_stores"][TEST_CONFIG_ID]["contents"] - .as_table_mut() - .expect("contents"); - let chunk_keys: Vec = contents - .iter() - .map(|(key, _)| key.to_owned()) - .filter(|key| key.contains(CHUNK_KEY_INFIX)) - .collect(); - assert!(chunk_keys.len() >= 2, "seed must have chunked"); - contents.remove(&chunk_keys[0]); - let present_after = chunk_keys.len().saturating_sub(1); - fs::write(&fastly_toml, doc.to_string()).expect("write"); - - // Dry-run a shrink-to-direct re-push: every remaining chunk is an orphan. - let direct = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); - let out = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), direct)], - &AdapterPushContext::new(), - true, - ) - .expect("dry-run"); - - let reported = out - .join("\n") - .split("would delete ") - .nth(1) - .and_then(|rest| rest.split_whitespace().next()) - .and_then(|n| n.parse::().ok()) - .expect("a numeric orphan count"); - assert_eq!( - reported, present_after, - "the already-absent chunk must not be counted (reported {reported}, present {present_after})" - ); - } - - /// Dry-run reports the orphan count and writes nothing. - #[cfg(unix)] - #[test] - fn push_config_entries_local_dry_run_reports_orphan_count() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - fs::write(&fastly_toml, "name = \"demo\"\n").expect("seed"); - - let envelope_a = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), envelope_a)], - &AdapterPushContext::new(), - false, - ) - .expect("seed push"); - let before = fs::read_to_string(&fastly_toml).expect("read"); - - let envelope_b = { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - let data = json!({ "alt": "y".repeat(FASTLY_CONFIG_ENTRY_LIMIT) }); - serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:02Z".to_owned())) - .expect("envelope B") - }; - let out = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), envelope_b)], - &AdapterPushContext::new(), - true, // dry_run - ) - .expect("dry-run"); - - let combined = out.join("\n"); - assert!( - combined.contains("would delete") && combined.contains("orphan chunks"), - "dry-run must report orphan count: {combined}" - ); - assert_eq!( - fs::read_to_string(&fastly_toml).expect("read"), - before, - "dry-run must not edit fastly.toml" - ); - } - - /// Two concurrent local pushes must not lose each other's edit. Each thread - /// adds a DISTINCT key; the cross-process lock serialises the whole - /// read-modify-write, so the second push reads what the first wrote and both - /// keys survive. Without the lock, both would read the same base and the - /// later rename would discard the earlier key -- the silent data loss. - #[cfg(unix)] - #[test] - fn concurrent_local_pushes_do_not_lose_edits() { - use std::sync::Arc; - use std::thread; - - let dir = tempdir().expect("tempdir"); - let path = Arc::new(dir.path().join("fastly.toml")); - fs::write(path.as_ref(), "name = \"demo\"\n").expect("seed"); - - // Many rounds to make the interleaving likely to hit the race window. - for round in 0_u32..25 { - let path_a = Arc::clone(&path); - let path_b = Arc::clone(&path); - let key_a = format!("alpha_{round}"); - let key_b = format!("beta_{round}"); - let (ka, kb) = (key_a.clone(), key_b.clone()); - let ta = thread::spawn(move || { - write_fastly_local_config_store( - &path_a, - TEST_CONFIG_ID, - &[(ka, "a".to_owned())], - &[], - ) - }); - let tb = thread::spawn(move || { - write_fastly_local_config_store( - &path_b, - TEST_CONFIG_ID, - &[(kb, "b".to_owned())], - &[], - ) - }); - ta.join().expect("thread a").expect("push a"); - tb.join().expect("thread b").expect("push b"); - - let after = fs::read_to_string(path.as_ref()).expect("read back"); - assert!( - after.contains(&format!("{key_a} = \"a\"")), - "round {round}: `{key_a}` was lost by a concurrent push:\n{after}" - ); - assert!( - after.contains(&format!("{key_b} = \"b\"")), - "round {round}: `{key_b}` was lost by a concurrent push:\n{after}" - ); - } - } - - /// A concurrent edit to `fastly.toml` between the push's read and its write - /// must NOT be clobbered: the rewrite refuses and reports, leaving the other - /// writer's file intact so no sibling change is silently lost. - #[cfg(unix)] - #[test] - fn local_rewrite_refuses_to_clobber_a_concurrent_edit() { - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - fs::write(&fastly_toml, "name = \"demo\"\n").expect("seed"); - - // Simulate: we read one thing, another writer moved the file, we write. - let stale_view = "name = \"demo\"\n"; - fs::write(&fastly_toml, "name = \"demo\"\nother = \"sibling edit\"\n") - .expect("concurrent write"); - - let err = atomically_replace_file(&fastly_toml, stale_view, "name = \"clobbered\"\n") - .expect_err("a concurrent edit must not be overwritten"); - assert!( - err.contains("changed on disk"), - "must report the conflict: {err}" - ); - assert_eq!( - fs::read_to_string(&fastly_toml).expect("read"), - "name = \"demo\"\nother = \"sibling edit\"\n", - "the other writer's file must survive untouched" - ); - } - - /// The happy path replaces contents and leaves no temp file behind. - #[cfg(unix)] - #[test] - fn local_rewrite_replaces_atomically_and_cleans_up() { - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - fs::write(&fastly_toml, "before\n").expect("seed"); - - atomically_replace_file(&fastly_toml, "before\n", "after\n").expect("replace"); - assert_eq!( - fs::read_to_string(&fastly_toml).expect("read"), - "after\n", - "contents must be replaced" - ); - let leftovers: Vec<_> = fs::read_dir(dir.path()) - .expect("read_dir") - .filter_map(Result::ok) - .filter(|entry| entry.file_name().to_string_lossy().contains(".tmp")) - .collect(); - assert!(leftovers.is_empty(), "no temp file may be left behind"); - } - - /// The atomic replace must PRESERVE the target's permissions: a 0600 manifest - /// must not widen to the umask default when it is replaced. - #[cfg(unix)] - #[test] - fn atomic_replace_preserves_restrictive_permissions() { - use std::os::unix::fs::PermissionsExt as _; - let dir = tempdir().expect("tempdir"); - let manifest = dir.path().join("fastly.toml"); - fs::write(&manifest, "before\n").expect("seed"); - fs::set_permissions(&manifest, fs::Permissions::from_mode(0o600)).expect("chmod"); - - atomically_replace_file(&manifest, "before\n", "after\n").expect("replace"); - - let mode = fs::metadata(&manifest).expect("meta").permissions().mode() & 0o777; - assert_eq!( - mode, 0o600, - "restrictive permissions must survive the replace" - ); - assert_eq!(fs::read_to_string(&manifest).expect("read"), "after\n"); - } - - /// A symlinked manifest must be updated THROUGH the link: the real file's - /// contents change and the symlink itself is preserved (not replaced with a - /// regular file). The lock and the replace both resolve to the real target. - #[cfg(unix)] - #[test] - fn local_rewrite_follows_a_symlinked_manifest() { - use std::os::unix::fs::symlink; - let dir = tempdir().expect("tempdir"); - let real = dir.path().join("real-fastly.toml"); - let link = dir.path().join("fastly.toml"); - fs::write(&real, "name = \"demo\"\n").expect("seed real"); - symlink(&real, &link).expect("symlink"); - - write_fastly_local_config_store( - &link, - TEST_CONFIG_ID, - &[("greeting".to_owned(), "hi".to_owned())], - &[], - ) - .expect("push through symlink"); - - assert!( - fs::symlink_metadata(&link) - .expect("lstat") - .file_type() - .is_symlink(), - "the manifest symlink must be preserved, not replaced with a file" - ); - assert!( - fs::read_to_string(&real) - .expect("read real") - .contains("greeting = \"hi\""), - "the real target behind the symlink must be updated" - ); - } - - /// A DANGLING manifest symlink (points at a not-yet-created file) must be - /// FOLLOWED: the write creates the intended target and preserves the symlink, - /// rather than replacing the link with a regular file and leaving the target - /// absent. - #[cfg(unix)] - #[test] - fn local_rewrite_follows_a_dangling_symlinked_manifest() { - use std::os::unix::fs::symlink; - let dir = tempdir().expect("tempdir"); - let target = dir.path().join("real-fastly.toml"); // does NOT exist yet - let link = dir.path().join("fastly.toml"); - symlink(&target, &link).expect("dangling symlink"); - assert!(!target.exists(), "target must start absent"); - - write_fastly_local_config_store( - &link, - TEST_CONFIG_ID, - &[("greeting".to_owned(), "hi".to_owned())], - &[], - ) - .expect("push through dangling symlink"); - - assert!( - fs::symlink_metadata(&link) - .expect("lstat") - .file_type() - .is_symlink(), - "the symlink must be preserved, not replaced with a regular file" - ); - assert!( - fs::read_to_string(&target) - .expect("target must now exist") - .contains("greeting = \"hi\""), - "the intended (formerly-missing) target must be created and written" - ); - } - - /// A MULTI-HOP dangling symlink chain (fastly.toml -> middle.toml -> - /// missing.toml) must be followed to the FINAL target: the last file is - /// created and every intermediate link is preserved. A direct write to the - /// final target also resolves to the same lock. - #[cfg(unix)] - #[test] - fn local_rewrite_follows_a_multi_hop_dangling_symlink_chain() { - use std::os::unix::fs::symlink; - let dir = tempdir().expect("tempdir"); - let final_target = dir.path().join("missing.toml"); // absent - let middle = dir.path().join("middle.toml"); - let link = dir.path().join("fastly.toml"); - symlink(&final_target, &middle).expect("middle -> missing"); - symlink(&middle, &link).expect("fastly -> middle"); - assert!(!final_target.exists(), "final target must start absent"); - - write_fastly_local_config_store( - &link, - TEST_CONFIG_ID, - &[("greeting".to_owned(), "hi".to_owned())], - &[], - ) - .expect("push through the symlink chain"); - - for intermediate in [&link, &middle] { - assert!( - fs::symlink_metadata(intermediate) - .expect("lstat") - .file_type() - .is_symlink(), - "every intermediate link must be preserved: {}", - intermediate.display() - ); - } - assert!( - fs::read_to_string(&final_target) - .expect("final target must now exist") - .contains("greeting = \"hi\""), - "the final target at the end of the chain must be created and written" - ); - // Symmetry: a direct write to the final target resolves to the same real - // file the chain does, so both share one lock. - assert_eq!( - canonical_manifest_target(&link).expect("chain resolves"), - canonical_manifest_target(&final_target).expect("direct resolves"), - "the chain and a direct path must resolve to the same lock target" - ); - } - - /// A HARD-LINKED manifest cannot be replaced safely (rename breaks the link; - /// path-based locks miss the other names), so the writer FAILS CLOSED with a - /// fix rather than silently diverging. - #[cfg(unix)] - #[test] - fn local_rewrite_refuses_a_hard_linked_manifest() { - let dir = tempdir().expect("tempdir"); - let manifest = dir.path().join("fastly.toml"); - let other = dir.path().join("other-name.toml"); - fs::write(&manifest, "name = \"demo\"\n").expect("seed"); - fs::hard_link(&manifest, &other).expect("hard link"); - - let err = write_fastly_local_config_store( - &manifest, - TEST_CONFIG_ID, - &[("greeting".to_owned(), "hi".to_owned())], - &[], - ) - .expect_err("a hard-linked manifest must be refused"); - assert!( - err.contains("hard link"), - "must explain the hard-link refusal: {err}" - ); - // Nothing was written -- the original content is intact. - assert_eq!( - fs::read_to_string(&manifest).expect("read"), - "name = \"demo\"\n", - "a refused write must not modify the manifest" - ); - } - - /// A provision write and a local push serialise on the SAME manifest lock, so - /// neither loses the other's edit even though they are different writers. - #[cfg(unix)] - #[test] - fn provision_and_push_serialise_on_the_manifest_lock() { - use std::sync::Arc; - use std::thread; - - let dir = tempdir().expect("tempdir"); - let manifest = Arc::new(dir.path().join("fastly.toml")); - fs::write(manifest.as_ref(), "name = \"demo\"\n").expect("seed"); - - for _round in 0_u32..25 { - let p_provision = Arc::clone(&manifest); - let p_push = Arc::clone(&manifest); - let provision = - thread::spawn(move || append_fastly_setup(&p_provision, "config", "app_config")); - let push = thread::spawn(move || { - write_fastly_local_config_store( - &p_push, - TEST_CONFIG_ID, - &[("greeting".to_owned(), "hi".to_owned())], - &[], - ) - }); - provision - .join() - .expect("provision thread") - .expect("provision"); - push.join().expect("push thread").expect("push"); - - let after = fs::read_to_string(manifest.as_ref()).expect("read"); - assert!( - after.contains("[setup.config_stores.app_config]"), - "provision's setup block must survive:\n{after}" - ); - assert!( - after.contains("greeting = \"hi\""), - "push's config edit must survive:\n{after}" - ); - } - } - - /// PARITY: the dry-run's reported orphan count equals the number of chunk - /// keys the real (non-dry-run) push actually deletes, on ONE fixture. A - /// divergence would make the dry-run a misleading preview of the delete. - #[cfg(unix)] - #[test] - fn push_config_entries_local_dry_run_count_matches_real_deletions() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - - fn count_chunk_keys(toml_src: &str) -> usize { - let doc: toml_edit::DocumentMut = toml_src.parse().expect("parse"); - doc.get("local_server") - .and_then(|ls| ls.get("config_stores")) - .and_then(|cs| cs.get(TEST_CONFIG_ID)) - .and_then(|st| st.get("contents")) - .and_then(toml_edit::Item::as_table) - .map_or(0, |table| { - table - .iter() - .filter(|(key, _)| key.contains(CHUNK_KEY_INFIX)) - .count() - }) - } - fn parse_would_delete_count(text: &str) -> Option { - let marker = "would delete "; - let idx = text.find(marker)?; - text.get(idx.saturating_add(marker.len())..)? - .split_whitespace() - .next()? - .parse::() - .ok() - } - - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - fs::write(&fastly_toml, "name = \"demo\"\n").expect("seed"); - - // Seed a multi-chunk generation, then measure how many chunk keys exist. - let chunked = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(5_000)); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), chunked)], - &AdapterPushContext::new(), - false, - ) - .expect("seed push"); - let seeded = fs::read_to_string(&fastly_toml).expect("read"); - let prior_chunk_count = count_chunk_keys(&seeded); - assert!(prior_chunk_count >= 2, "seed must have chunked: {seeded}"); - - // Dry-run a shrink-to-direct re-push: capture the reported orphan count. - let direct = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); - let out = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), direct.clone())], - &AdapterPushContext::new(), - true, // dry_run - ) - .expect("dry-run"); - let reported = parse_would_delete_count(&out.join("\n")) - .expect("dry-run must report a numeric orphan count"); - assert_eq!( - fs::read_to_string(&fastly_toml).expect("read"), - seeded, - "dry-run must not edit fastly.toml" - ); - - // Real re-push: count the chunk keys actually removed. - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), direct)], - &AdapterPushContext::new(), - false, - ) - .expect("real push"); - let after = fs::read_to_string(&fastly_toml).expect("read"); - let actually_deleted = prior_chunk_count.saturating_sub(count_chunk_keys(&after)); - - assert_eq!( - reported, actually_deleted, - "dry-run count {reported} must equal real deletions {actually_deleted}" - ); - assert_eq!( - reported, prior_chunk_count, - "a shrink-to-direct re-push orphans every prior chunk" - ); - } - - /// Real (non-dry-run) push over a MALFORMED prior pointer WARNS and deletes - /// nothing: its chunk list is untrustworthy, so no key is removed and the - /// root is simply overwritten with the new value. This is the real-push - /// counterpart to the dry-run "unknown" degradation on the same prior state. - #[cfg(unix)] - #[test] - fn push_config_entries_local_real_push_over_malformed_prior_warns_and_deletes_nothing() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - // A pointer-kind prior value missing its required fields — malformed, so - // `prior_chunk_keys` returns Err (warn, delete nothing). - let seed = concat!( - "name = \"demo\"\n\n", - "[local_server.config_stores.app_config]\n", - "format = \"inline-toml\"\n\n", - "[local_server.config_stores.app_config.contents]\n", - "app_config = \"{\\\"edgezero_kind\\\":\\\"fastly_config_chunks\\\",\\\"version\\\":1}\"\n", - ); - fs::write(&fastly_toml, seed).expect("seed"); - - let direct = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); - let out = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), direct.clone())], - &AdapterPushContext::new(), - false, - ) - .expect("real push must not fail on a malformed prior"); - - assert!( - out.iter().any(|line| line.contains("skipping chunk GC")), - "must warn about the malformed prior pointer: {out:?}" - ); - - let after = fs::read_to_string(&fastly_toml).expect("read"); - let doc: toml_edit::DocumentMut = after.parse().expect("parse"); - let contents = doc - .get("local_server") - .and_then(|ls| ls.get("config_stores")) - .and_then(|cs| cs.get(TEST_CONFIG_ID)) - .and_then(|st| st.get("contents")) - .and_then(toml_edit::Item::as_table) - .expect("contents"); - assert_eq!( - contents - .get(TEST_CONFIG_ID) - .and_then(toml_edit::Item::as_str), - Some(direct.as_str()), - "root is overwritten with the new direct envelope: {after}" - ); - } - - /// Dry-run of an identical re-push reports zero orphans (new keys - /// equal prior keys — regression for expanding `new_keys`). - #[cfg(unix)] - #[test] - fn push_config_entries_local_dry_run_identical_repush_counts_zero() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - fs::write(&fastly_toml, "name = \"demo\"\n").expect("seed"); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), envelope.clone())], - &AdapterPushContext::new(), - false, - ) - .expect("seed push"); - - let out = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), envelope)], - &AdapterPushContext::new(), - true, // dry_run, same bytes - ) - .expect("dry-run"); - - assert!( - out.join("\n").contains("would delete 0 orphan chunks"), - "identical re-push must count 0 orphans: {out:?}" - ); - } - - /// Dry-run over a suspicious prior pointer reports an unknown count - /// and does not fail. - #[cfg(unix)] - #[test] - fn push_config_entries_local_dry_run_suspicious_prior_pointer_unknown() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - let seed = concat!( - "name = \"demo\"\n\n", - "[local_server.config_stores.app_config]\n", - "format = \"inline-toml\"\n\n", - "[local_server.config_stores.app_config.contents]\n", - "app_config = \"{\\\"edgezero_kind\\\":\\\"fastly_config_chunks\\\",\\\"version\\\":1}\"\n", - ); - fs::write(&fastly_toml, seed).expect("seed"); - - let direct = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); - let out = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), direct)], - &AdapterPushContext::new(), - true, // dry_run - ) - .expect("dry-run must not fail on suspicious pointer"); - - assert!( - out.join("\n").contains("unknown: suspicious prior pointer"), - "dry-run must degrade to unknown: {out:?}" - ); - } - - /// A present-but-malformed `contents` (non-table) is prior state the - /// real writer would reject — the dry-run count must degrade to - /// `unknown: could not read prior state`, not silently report 0. - #[cfg(unix)] - #[test] - fn push_config_entries_local_dry_run_non_table_contents_unknown() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - let seed = concat!( - "name = \"demo\"\n\n", - "[local_server.config_stores.app_config]\n", - "format = \"inline-toml\"\n", - "contents = \"bad\"\n", - ); - fs::write(&fastly_toml, seed).expect("seed"); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - let out = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), envelope)], - &AdapterPushContext::new(), - true, // dry_run - ) - .expect("dry-run must not fail on malformed contents"); - - assert!( - out.join("\n") - .contains("unknown: could not read prior state"), - "non-table contents must degrade to unknown: {out:?}" - ); - } - - /// A duplicate root key in one batch is rejected before any I/O. - /// Otherwise the earlier tuple's GC plan would reclaim the chunks the - /// LAST tuple just installed, leaving the final pointer dangling. - /// Regression: prior B, batch `[(root, A), (root, B)]` — the root must - /// still resolve afterwards. - #[cfg(unix)] - #[test] - fn push_config_entries_local_rejects_duplicate_root_keys() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - fs::write(&fastly_toml, "name = \"demo\"\n").expect("seed"); - - let make = |tag: &str| { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - let data = json!({ tag: "x".repeat(FASTLY_CONFIG_ENTRY_LIMIT) }); - serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:00Z".to_owned())) - .expect("envelope") - }; - let envelope_a = make("aaa"); - let envelope_b = make("bbb"); - - // Prior generation B is live. - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), envelope_b.clone())], - &AdapterPushContext::new(), - false, - ) - .expect("seed push"); - let before = fs::read_to_string(&fastly_toml).expect("read"); - - // Duplicate-root batch must be rejected outright. - let err = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[ - (TEST_CONFIG_ID.to_owned(), envelope_a), - (TEST_CONFIG_ID.to_owned(), envelope_b.clone()), - ], - &AdapterPushContext::new(), - false, - ) - .expect_err("duplicate root keys must be rejected"); - assert!( - err.contains("more than once"), - "error explains the duplicate: {err}" - ); - assert_eq!( - fs::read_to_string(&fastly_toml).expect("read"), - before, - "rejection must happen before any write" - ); - - // The live root still resolves to B (nothing was reclaimed). - let read = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - TEST_CONFIG_ID, - &AdapterPushContext::new(), - ) - .expect("root must still resolve"); - let ReadConfigEntry::Present(value) = read else { - panic!("expected Present"); - }; - assert_eq!(value, envelope_b, "root still reconstructs envelope B"); - } - - /// GC of a chunked root must not touch a chunked SIBLING's chunks — - /// the prefix `app_config.__edgezero_chunks.` must not match - /// `app_config_staging.__edgezero_chunks.` (shared string prefix). - #[cfg(unix)] - #[test] - fn push_config_entries_local_gc_preserves_sibling_chunks() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - fs::write(&fastly_toml, "name = \"demo\"\n").expect("seed"); - - let make = |tag: &str| { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - let data = json!({ tag: "x".repeat(FASTLY_CONFIG_ENTRY_LIMIT) }); - serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:00Z".to_owned())) - .expect("envelope") - }; - let push = |key: &str, body: String| { - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(key.to_owned(), body)], - &AdapterPushContext::new(), - false, - ) - .expect("push"); - }; - - // app_config gen X, then a chunked sibling, then app_config gen Z. - push("app_config", make("x1")); - push("app_config_staging", make("staging")); - let staging_chunks = chunk_keys_of("app_config_staging", &make("staging")); - push("app_config", make("z2")); // GCs app_config's gen-X chunks - - let after = fs::read_to_string(&fastly_toml).expect("read"); - let doc: toml_edit::DocumentMut = after.parse().expect("parse"); - let contents = doc - .get("local_server") - .and_then(|ls| ls.get("config_stores")) - .and_then(|cs| cs.get(TEST_CONFIG_ID)) - .and_then(|st| st.get("contents")) - .and_then(toml_edit::Item::as_table) - .expect("contents"); - for key in &staging_chunks { - assert!( - contents.get(key).is_some(), - "sibling chunk `{key}` must survive app_config GC: {after}" - ); - } - } - - // ---- chunk GC helpers ---- - - #[test] - fn reject_reserved_root_keys_accepts_clean_keys() { - let entries = vec![ - ("app_config".to_owned(), "{}".to_owned()), - ("app_config_staging".to_owned(), "{}".to_owned()), - ]; - reject_reserved_root_keys(&entries).expect("clean keys accepted"); - } - - #[test] - fn reject_reserved_root_keys_rejects_infix_key() { - let bad = format!("app_config{CHUNK_KEY_INFIX}deadbeef.0"); - let entries = vec![(bad.clone(), "{}".to_owned())]; - let err = reject_reserved_root_keys(&entries).expect_err("reserved infix must reject"); - assert!(err.contains(&bad), "error names the key: {err}"); - assert!(err.contains("reserved"), "error explains why: {err}"); - } - - #[test] - fn orphan_chunk_keys_subtracts_new_keys() { - let mut new_keys = HashSet::new(); - new_keys.insert("keep".to_owned()); - let plan = FastlyConfigGcPlan { - new_keys, - prior_keys: Ok(vec![ - "gone1".to_owned(), - "keep".to_owned(), - "gone2".to_owned(), - ]), - }; - let orphans = orphan_chunk_keys(&plan).expect("ok"); - assert_eq!(orphans, vec!["gone1".to_owned(), "gone2".to_owned()]); - } - - #[test] - fn orphan_chunk_keys_propagates_prior_err() { - let plan = FastlyConfigGcPlan { - new_keys: HashSet::new(), - prior_keys: Err("suspicious".to_owned()), - }; - orphan_chunk_keys(&plan).unwrap_err(); - } - - #[test] - fn expand_root_direct_value_has_single_entry() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); - let (expanded, new_keys, new_root_value) = expand_root(TEST_CONFIG_ID, &envelope).unwrap(); - assert_eq!(expanded.len(), 1); - assert_eq!(new_root_value, envelope); - assert!(new_keys.contains(TEST_CONFIG_ID)); - assert_eq!(new_keys.len(), 1); - } - - #[test] - fn expand_root_chunked_value_carries_pointer_as_root_value() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - let (expanded, new_keys, new_root_value) = expand_root(TEST_CONFIG_ID, &envelope).unwrap(); - assert!(expanded.len() >= 2, "chunks + pointer"); - let (last_key, last_value) = expanded.last().unwrap(); - assert_eq!(last_key, TEST_CONFIG_ID); - assert_eq!(&new_root_value, last_value); - assert!(new_keys.contains(TEST_CONFIG_ID)); - assert_eq!(new_keys.len(), expanded.len()); - } -} diff --git a/crates/edgezero-adapter-fastly/src/cli/gc.rs b/crates/edgezero-adapter-fastly/src/cli/gc.rs new file mode 100644 index 00000000..58f0d424 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/gc.rs @@ -0,0 +1,2545 @@ +//! `config gc` reclamation core for the Fastly adapter. +//! +//! Operator-invoked garbage collection of orphaned chunk entries in a Fastly +//! config store. Deliberately separate from `config push`: on an +//! eventually-consistent store a chunk may only be deleted once the pointer that +//! referenced it has stopped being served everywhere, and Fastly records no +//! pointer-supersession time — so the operator supplies `--older-than` as the +//! safety assertion the platform cannot make. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fmt::Write as _; +use std::io::ErrorKind; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::chunked_config::{ + CHUNK_KEY_INFIX, GcPointer, GcRootValue, chunk_key_generation, chunk_key_index, chunk_lengths, + gc_classify_root, gc_verify_generation, prepare_fastly_config_entries, sha256_hex, + value_announces_our_kind, value_is_future_format, value_is_inert_foreign, + verify_writer_split_layout, +}; + +use super::FASTLY_INSTALL_HINT; +use super::push_cloud::{ + no_matching_store_error, redact_describe_response, redact_stderr, + resolve_remote_config_store_id, strict_stdout, +}; + +/// The reclamation plan for `config gc`: the orphan chunk entries to delete +/// (with their ages) plus the counts for the summary line. Produced by +/// `plan_gc_reclamation` (which owns every safety guard); consumed by +/// `gc_fastly_config_store` (which reports and deletes). +struct GcPlan { + /// Whole generations to reclaim, each a list of `(key, age_secs)`. Grouped, + /// not flat: a generation is provable only as a UNIT (see + /// `prove_generation`), so deleting part of one destroys the very evidence + /// that licenses deleting the rest. + doomed: Vec>, + /// The root keys retained as live/protected — the config entries GC will NOT + /// delete, sorted. Surfaced so a run shows what it is KEEPING, not only what + /// it would delete, making the sweep reviewable. + kept_roots: Vec, + live_count: usize, + retained_recent: usize, + roots: usize, + /// Chunk-shaped entries we could NOT prove our writer produced, so left + /// untouched. Surfaced so an operator can see we declined to judge them. + unprovable: usize, + /// Non-fatal problems to print — see `GcClassification::warnings`. + warnings: Vec, +} + +/// What one pass of `config gc`'s delete loop actually did. +struct GcDeleteOutcome { + /// Entries whose delete returned success. + deleted: usize, + /// Keys whose delete returned non-zero. + failed: Vec, + /// Survivors of a generation in which an earlier sibling's delete had + /// ALREADY succeeded before a later one failed. These are definitely an + /// incomplete generation now, so they can never be proved (or reclaimed) + /// again -- manual removal only. + stranded: Vec, + /// Members of a generation whose ONLY failure was on a delete with no + /// confirmed prior sibling success. A failed remote delete has UNKNOWN + /// outcome (Fastly may have committed it before returning an error), so we + /// cannot say whether the generation is still whole. A re-run reclaims it if + /// it is, or reports it as an unprovable fragment if it is not. + uncertain: Vec, +} + +/// The result of classifying a store's entries for reclamation. +struct GcClassification { + /// Chunk keys a live root pointer references, each verified against its + /// content-address. Never deletable. + live: HashSet, + /// Keys whose OWN value is a runtime-readable root — a valid direct envelope + /// or a pointer — regardless of what their key looks like. Never deletable. + protected: HashSet, + /// Count of entries classified as roots, for the summary line. + roots: usize, + /// Non-fatal problems the operator should see — currently roots that are + /// not runtime-readable and so can never be reclaimed automatically. + warnings: Vec, +} + +/// One `config-store-entry list` item. +/// +/// `item_value` IS captured — `config gc` must parse root pointers to learn +/// which chunks are live, and one listing avoids a `describe` per root. It is +/// the config payload: it may be read in memory but must NEVER be logged or +/// surfaced (see `redact_describe_response` / `redact_stderr`). +struct ConfigStoreItem { + created_at: String, + item_key: String, + item_value: String, +} + +/// Unix epoch seconds. Push-time only (the `cli` feature is native). +fn unix_now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_secs()) +} + +/// Run `fastly config-store-entry list --store-id= --json` and return each +/// item's `item_key`, `item_value`, and `created_at`. +/// +/// The item VALUE is KEPT (not discarded): `config gc` classifies each root by +/// its value (`gc_classify_root`) and reconstructs live generations from the +/// chunk values, so all three fields are required. The value is used internally +/// only and is NEVER echoed into a diagnostic — parse failures redact it via +/// `redact_describe_response`. +fn list_config_store_entries(store_id: &str) -> Result, String> { + let store_arg = format!("--store-id={store_id}"); + let output = Command::new("fastly") + .args(["config-store-entry", "list", store_arg.as_str(), "--json"]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "`fastly config-store-entry list --store-id={store_id} --json` exited with status {}\nstderr: {}", + output.status, + redact_stderr(&stderr) + )); + } + let stdout = strict_stdout(output.stdout, "config-store-entry list --json")?; + let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|_err| { + format!( + "failed to parse `fastly config-store-entry list` JSON (parse error redacted; \ + response: {})", + redact_describe_response(&stdout) + ) + })?; + // A BARE ARRAY ONLY. The installed Fastly CLI returns the complete store as + // a top-level array with no cursor/paging flags. Any other shape (e.g. an + // `{"items":[...], ...}` envelope) may carry pagination metadata we do not + // follow -- and a page that omitted a ROOT while listing its chunks would + // make live chunks look orphaned. The completeness guard cannot see a root + // that isn't there, so we refuse rather than reclaim from a partial view. + let array = parsed.as_array().ok_or_else(|| { + format!( + "refusing to reclaim: `fastly config-store-entry list --json` did not return a bare \ + array (response: {}). This build only supports an unpaginated listing; a partial view \ + could hide a root and orphan its live chunks. Nothing was deleted.", + redact_describe_response(&stdout) + ) + })?; + // FAIL CLOSED on any malformed entry. A missing/non-string field on a + // reclamation input must NEVER be silently skipped or defaulted to empty: + // skipping a root hides the chunks it references (they'd look orphaned and + // get deleted while live), and an empty `item_value` makes a real root + // parse as "references nothing" — same catastrophe. If we can't read the + // listing exactly, we delete nothing. + let mut items = Vec::with_capacity(array.len()); + for (idx, entry) in array.iter().enumerate() { + // Name the offending KEY, not just the index: `item_key` is readable even + // when another field is empty, so the operator can see WHICH entry to fix. + let key_hint = entry + .get("item_key") + .and_then(serde_json::Value::as_str) + .filter(|key| !key.is_empty()) + .map_or_else(|| format!("#{idx}"), |key| format!("`{key}`")); + let field = |name: &str| -> Result { + let raw = entry + .get(name) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "`fastly config-store-entry list` entry {key_hint} is missing a string \ + `{name}` field; refusing to reclaim (nothing deleted)" + ) + })?; + // An EMPTY field is as dangerous as a missing one: an empty root value + // would classify as "references nothing" and orphan its live chunks. + if raw.is_empty() { + return Err(format!( + "`fastly config-store-entry list` entry {key_hint} has an empty `{name}` field; \ + refusing to reclaim (nothing deleted). If this is a legitimate empty-valued \ + entry, remove it or give it a value before running `config gc`." + )); + } + Ok(raw.to_owned()) + }; + items.push(ConfigStoreItem { + created_at: field("created_at")?, + item_key: field("item_key")?, + item_value: field("item_value")?, + }); + } + + // DUPLICATE KEYS => fail closed. A key must appear once; a store cannot + // really hold two entries under one key, so duplicate rows mean we are not + // reading the store we think we are (a merged/paginated view, or a CLI + // change). Left alone, the last row silently wins for BOTH the live-set + // lookup and `created_at`, so conflicting rows could age a recent key into + // eligibility and schedule the same key for two deletes. + let mut seen: HashSet<&str> = HashSet::with_capacity(items.len()); + if let Some(duplicate) = items + .iter() + .find(|item| !seen.insert(item.item_key.as_str())) + { + return Err(format!( + "refusing to reclaim: `fastly config-store-entry list` returned key `{}` more than \ + once. A key is unique in a config store, so this listing does not describe one \ + consistent view of it (nothing was deleted).", + duplicate.item_key + )); + } + + Ok(items) +} + +/// RFC 3339 (`2026-07-13T03:27:42Z`) -> unix seconds, rounded UP on any fraction. +/// +/// `timestamp()` FLOORS the sub-second part, and the current time the age gate +/// compares against is also floored. A creation floored DOWN makes a key look +/// OLDER: a true age of 59.002s (created `...:42.998Z`) would compute as 60s and +/// pass a 60s `--older-than` almost a full second early. Rounding creation UP +/// keeps the computed age conservative -- a key never ages into deletion early. +fn parse_rfc3339_secs(raw: &str) -> Option { + let stamp = chrono::DateTime::parse_from_rfc3339(raw).ok()?; + let secs = stamp.timestamp(); + let rounded_up = if stamp.timestamp_subsec_nanos() > 0 { + secs.checked_add(1)? + } else { + secs + }; + u64::try_from(rounded_up).ok() +} + +/// Report what a sweep is KEEPING, not only what it would delete, so the run is +/// reviewable: each RETAINED root by key, plus the referenced-chunk total those +/// roots hold (already summarised). A root listed here is never a delete +/// candidate. +fn append_kept_roots_report(out: &mut Vec, kept_roots: &[String], live_count: usize) { + if kept_roots.is_empty() { + out.push("keeping 0 retained root(s)".to_owned()); + return; + } + out.push(format!( + "keeping {} retained root(s) ({live_count} referenced chunk(s) held by them):", + kept_roots.len() + )); + for key in kept_roots { + out.push(format!(" keeping `{key}`")); + } +} + +/// `config gc` for Fastly: delete chunk entries that no LIVE root pointer +/// references and that are older than the operator's `older_than_secs`. +/// +/// Why this is a separate, operator-invoked command rather than part of `config +/// push`: see `Adapter::gc_config_entries`. The operator's `--older-than` is the +/// safety assertion the platform cannot make. A dry-run prints exactly which +/// keys would go, with ages, so the assertion is reviewable. +/// +/// Fails CLOSED: if the listing is unreadable, or a root's value cannot be +/// classified, nothing is deleted. +pub(super) fn gc_fastly_config_store( + store_name: &str, + older_than_secs: u64, + dry_run: bool, +) -> Result, String> { + // THE destructive boundary enforces its own precondition. The CLI rejects a + // zero window too, but `gc_config_entries` is a public trait method any + // caller can reach directly -- a safety rule that lives only in the CLI is + // not a safety rule. A zero window asserts nothing: it makes every orphan + // eligible, including one superseded a second ago whose pointer POPs are + // still serving. (A dry-run may preview at zero; it deletes nothing.) + if !dry_run && older_than_secs == 0 { + return Err( + "refusing to reclaim: a destructive `config gc` requires a non-zero `--older-than` \ + window. Zero asserts nothing -- it would make every orphan eligible, including \ + chunks a pointer POPs are still serving. Nothing was deleted." + .to_owned(), + ); + } + let resolved_id = resolve_remote_config_store_id(store_name)? + .ok_or_else(|| no_matching_store_error(store_name))?; + let items = list_config_store_entries(&resolved_id)?; + let plan = plan_gc_reclamation(&items, unix_now_secs(), older_than_secs)?; + let GcPlan { + doomed, + kept_roots, + live_count, + retained_recent, + roots, + unprovable, + warnings, + } = plan; + + let doomed_count: usize = doomed.iter().map(Vec::len).sum(); + let mut out = vec![format!( + "fastly config-store `{store_name}` (id={resolved_id}): {} entries, {roots} root(s), {live_count} referenced chunk(s), {doomed_count} orphan(s) in {} generation(s) older than {older_than_secs}s, {retained_recent} orphan(s) too recent", + items.len(), + doomed.len(), + )]; + out.extend(warnings); + append_kept_roots_report(&mut out, &kept_roots, live_count); + if unprovable > 0 { + // NEVER silent: these entries look like chunk keys but we could not + // prove our writer produced them, so we left them alone. Say so, or the + // summary reads as "everything reclaimable was reclaimed". + out.push(format!( + " {unprovable} chunk-shaped entr(ies) left untouched: they are not byte-identical to what this writer would produce (wrong content-address, a split this writer would not choose, an incomplete generation, or a count it would never emit), so EdgeZero cannot claim them" + )); + } + if doomed_count == 0 { + out.push("nothing to reclaim".to_owned()); + return Ok(out); + } + if dry_run { + // A dry-run only PLANS: list every candidate and stop. Nothing is + // attempted, so there is no confirmed/failed/skipped distinction yet. + for (key, age) in doomed.iter().flatten() { + out.push(format!(" would delete `{key}` (age {age}s)")); + } + // `--yes` ALWAYS requires an explicit non-zero `--older-than` (a + // destructive run must not guess the window), so the apply instruction + // names both -- "re-run with --yes" alone would be rejected. + out.push(format!( + "dry-run: {doomed_count} orphan chunk(s) planned for deletion; re-run with \ + `--yes --older-than ` (a non-zero window is required) to apply" + )); + return Ok(out); + } + // Real run: `doomed_count` is the PLANNED count. Do NOT pre-print each key as + // "deleting" -- execution stops at a generation's first failure, so some + // planned keys are never attempted. `execute_gc_deletes` reports the real + // per-key outcome (deleted / FAILED / skipped) as it happens. + out.push(format!( + "reclaiming {doomed_count} planned orphan chunk(s) across {} generation(s)", + doomed.len() + )); + + let GcDeleteOutcome { + deleted, + failed, + stranded, + uncertain, + } = execute_gc_deletes(&resolved_id, &doomed, &mut out); + out.push(format!( + "reclaimed {deleted} of {doomed_count} orphan chunk entries" + )); + if failed.is_empty() { + return Ok(out); + } + // Partial/total failure must be a non-zero exit so automation can see it. + let mut diagnostic = format!( + "{}\nconfig gc: {} of {doomed_count} deletes FAILED ({})", + out.join("\n"), + failed.len(), + failed.join(", ") + ); + // A generation whose only failure was on an unconfirmed delete: the outcome + // is UNKNOWN (Fastly may have committed it), so a re-run is worth trying but + // may find a fragment. + if !uncertain.is_empty() { + write!( + diagnostic, + ".\nNOTE: a failed remote delete has an unknown outcome -- Fastly may have applied it \ + before returning an error. Re-run `config gc`: it reclaims each affected generation \ + if it is still whole, or reports it as an unprovable fragment (\"left untouched\") if \ + a delete did commit. If reported as a fragment, remove the survivors by hand:\n{}", + recovery_commands(&resolved_id, &uncertain) + ) + .map_err(|err| format!("failed to format the gc diagnostic: {err}"))?; + } + // A generation with a CONFIRMED prior delete: definitely a fragment now. + if !stranded.is_empty() { + write!( + diagnostic, + ".\nWARNING: {} entr(ies) are now an INCOMPLETE generation because a sibling was \ + already deleted before the failure: {}. `config gc` proves a generation by \ + reassembling it, so it can no longer prove these and will never reclaim them -- \ + re-running will NOT help. They are inert (no pointer references them). Remove them \ + by hand once you are satisfied they are unreferenced:\n{}", + stranded.len(), + stranded.join(", "), + recovery_commands(&resolved_id, &stranded), + ) + .map_err(|err| format!("failed to format the gc diagnostic: {err}"))?; + } + Err(diagnostic) +} + +/// Render copy-pasteable `fastly config-store-entry delete` commands, one per +/// key, with EVERY interpolated value single-quoted for POSIX shells. +fn recovery_commands(store_id: &str, keys: &[String]) -> String { + let commands = keys + .iter() + .map(|key| { + format!( + " fastly config-store-entry delete --store-id={} --key={} --auto-yes", + shell_single_quote(store_id), + shell_single_quote(key), + ) + }) + .collect::>() + .join("\n"); + format!( + " # POSIX/bash (Linux/macOS). On Windows cmd/PowerShell the quoting \ + differs -- adapt it for your shell.\n{commands}" + ) +} + +/// Single-quote a value for a POSIX shell: wrap in `'...'` and rewrite each +/// embedded `'` as `'\''`. Inside single quotes every other byte -- `$`, spaces, +/// `;`, `$(...)`, backticks -- is literal, so this neutralises any hostile key. +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +/// Delete each doomed generation, stopping a generation at its FIRST failure. +/// +/// A generation is provable only as a whole (`prove_generation` reassembles it), +/// so a half-deleted one can never be proved again. Generations are independent, +/// so a failure in one does not stop the others. +fn execute_gc_deletes( + resolved_id: &str, + doomed: &[Vec<(String, u64)>], + out: &mut Vec, +) -> GcDeleteOutcome { + let mut outcome = GcDeleteOutcome { + deleted: 0, + failed: Vec::new(), + stranded: Vec::new(), + uncertain: Vec::new(), + }; + for generation in doomed { + let mut deleted_here: Vec<&str> = Vec::new(); + for (key, _) in generation { + match delete_config_store_entry(resolved_id, key) { + Ok(()) => { + outcome.deleted = outcome.deleted.saturating_add(1); + deleted_here.push(key.as_str()); + // CONFIRMED gone, per key, as it happens. + out.push(format!(" deleted `{key}`")); + } + Err(err) => { + out.push(format!(" FAILED to delete `{key}` ({err})")); + outcome.failed.push(key.clone()); + // Everything in this generation we have NOT confirmed deleted + // -- the failed key itself, plus the ones we never reached. + let unconfirmed: Vec = generation + .iter() + .map(|(member, _)| member.clone()) + .filter(|member| !deleted_here.contains(&member.as_str())) + .collect(); + // Distinguish the ones we NEVER ATTEMPTED (after the stop) + // from the failed key itself, so the report is not read as + // "all of these were tried and failed". + for skipped in unconfirmed.iter().filter(|member| *member != key) { + out.push(format!( + " skipped `{skipped}` (not attempted: this generation's delete stopped at the failure above)" + )); + } + if deleted_here.is_empty() { + // No sibling is CONFIRMED gone. The failed delete's + // outcome is unknown: if it did not commit, the + // generation is whole and a re-run reclaims it; if it + // did, the re-run finds a fragment and reports it. + outcome.uncertain.extend(unconfirmed); + } else { + // A sibling is CONFIRMED gone, so this generation is + // definitely a fragment no future run can prove. + outcome.stranded.extend(unconfirmed); + } + break; // stop THIS generation; the others are independent + } + } + } + } + outcome +} + +/// Classify a store's entries: the live chunk set, the protected root keys, and +/// the root count. +/// +/// Root-vs-chunk is decided by VALUE, not key shape. The runtime resolver reads +/// whatever value sits at a key, so ANY entry whose value is a valid direct +/// envelope or a chunk pointer is a runtime-readable root and must never be +/// deleted — even at a chunk-shaped key. +fn classify_store_entries( + items: &[ConfigStoreItem], + value_by_key: &HashMap<&str, &str>, +) -> Result { + let mut live: HashSet = HashSet::new(); + let mut protected: HashSet = HashSet::new(); + let mut roots = 0_usize; + let mut warnings: Vec = Vec::new(); + for item in items { + let is_chunk_shaped = chunk_key_generation_any(&item.item_key).is_some(); + let classified = match gc_classify_root(&item.item_key, &item.item_value) { + Ok(classified) => classified, + // A chunk-shaped key whose value we cannot classify is a genuine + // chunk fragment (a candidate) ONLY if BOTH hold: the value ANNOUNCES + // no kind, AND NOTHING is nested beneath this key. + Err(_) + if is_chunk_shaped + && !value_announces_our_kind(&item.item_value) + && !value_is_future_format(&item.item_value) + && !items.iter().any(|other| { + other.item_key != item.item_key + && chunk_key_generation(&item.item_key, &other.item_key).is_some() + }) => + { + continue; // a leaf chunk payload: a delete candidate + } + // A definitively FOREIGN entry at an ORDINARY key. The runtime returns + // it verbatim and it references no chunks, so protect it as a + // zero-reference root. Three guards keep this from masking corruption. + Err(_) + if value_is_inert_foreign(&item.item_value) + && !value_is_future_format(&item.item_value) + && !item.item_key.contains(CHUNK_KEY_INFIX) => + { + roots = roots.saturating_add(1); + protected.insert(item.item_key.clone()); + continue; + } + Err(err) => { + return Err(format!( + "refusing to reclaim: could not classify root `{}` ({err}); nothing was deleted", + item.item_key + )); + } + }; + // A runtime-readable root, wherever it lives: never a delete candidate. + roots = roots.saturating_add(1); + protected.insert(item.item_key.clone()); + let GcRootValue::Chunked(pointer) = classified else { + continue; // A direct envelope references no chunks. + }; + // The pointer's METADATA is self-consistent by here. That is not proof + // that it honestly describes its generation, so reassemble what it + // references and hold the bytes against its content-address. + let assembled = assemble_pointer_chunks(&item.item_key, &pointer, value_by_key)?; + // The reassembled value may be a NEWER inner format that `BlobEnvelope` + // deserialize silently ignores. Fail closed. + if value_is_future_format(&assembled) { + return Err(format!( + "refusing to reclaim: root `{}` reconstructs to a value in a newer format this \ + build does not recognise. It may reference generations this build cannot see, so \ + treating its outer chunks as the whole live set could delete live data. Nothing \ + was deleted.", + item.item_key + )); + } + gc_verify_generation(&pointer.envelope_sha256, &assembled).map_err(|err| { + format!( + "refusing to reclaim: root `{}` names a chunk set that does not reconstruct the \ + envelope it claims ({err}). Its chunk list is therefore not a trustworthy live \ + set, and treating it as one could delete a live chunk. Nothing was deleted.", + item.item_key + ) + })?; + // Same exact-split predicate the RUNTIME resolver applies. A pointer whose + // boundaries are not the ones this writer emits reassembles correctly here + // but is REJECTED at runtime, so warn (still protecting it). + if let Err(err) = + verify_writer_split_layout(&item.item_key, &assembled, &chunk_lengths(&pointer.chunks)) + { + warnings.push(format!( + "warning: root `{}` is NOT runtime-readable ({err}). Its chunks are kept, but this \ + generation can never be proven writer-produced, so `config gc` will never reclaim \ + it. Re-run `config push` for this key to rewrite it, then re-run `config gc`.", + item.item_key + )); + } + live.extend(pointer.chunks.into_iter().map(|chunk| chunk.key)); + } + Ok(GcClassification { + live, + protected, + roots, + warnings, + }) +} + +/// The reclamation plan for one store: which orphan chunk entries to delete, and +/// the counts for the summary line. Deriving it is where every safety guard +/// lives, so it is fail-closed throughout — any unreadable/incomplete state +/// returns `Err` and the caller deletes nothing. +fn plan_gc_reclamation( + items: &[ConfigStoreItem], + now: u64, + older_than_secs: u64, +) -> Result { + let mut value_by_key: HashMap<&str, &str> = HashMap::with_capacity(items.len()); + let mut created_by_key: HashMap<&str, u64> = HashMap::with_capacity(items.len()); + for item in items { + let Some(created) = parse_rfc3339_secs(&item.created_at) else { + // Unparseable timestamp anywhere in the listing -> fail closed. On a + // DELETE path we will not guess an age. + return Err(format!( + "refusing to reclaim: entry `{}` has an unreadable `created_at`; nothing was deleted", + item.item_key + )); + }; + created_by_key.insert(item.item_key.as_str(), created); + value_by_key.insert(item.item_key.as_str(), item.item_value.as_str()); + } + + // ---- 1. Classify entries: live chunks, protected roots, root count ---- + let GcClassification { + live, + protected, + roots, + warnings, + } = classify_store_entries(items, &value_by_key)?; + + // ---- 2. Per-root live-config age (best-effort; see the guard below) ---- + // rsplit_once (the LAST infix): a chunk of a chunk-shaped root nests the infix + // twice, and its root is everything before the LAST one. + let root_live_since: HashMap<&str, u64> = live.iter().fold(HashMap::new(), |mut acc, key| { + if let Some((root, _)) = key.rsplit_once(CHUNK_KEY_INFIX) { + let created = *created_by_key.get(key.as_str()).unwrap_or(&0); + let slot = acc.entry(root).or_insert(0); + *slot = (*slot).max(created); + } + acc + }); + + // ---- 3. Candidates, grouped by GENERATION and proven writer-produced ---- + let mut groups: BTreeMap<(&str, String), Vec<&ConfigStoreItem>> = BTreeMap::new(); + for item in items { + if live.contains(&item.item_key) { + continue; + } + if protected.contains(&item.item_key) { + continue; + } + let Some((root, _)) = item.item_key.rsplit_once(CHUNK_KEY_INFIX) else { + continue; // a root + }; + let Some(generation) = chunk_key_generation(root, &item.item_key) else { + continue; // chunk-shaped but NOT canonical => never a key we emit + }; + groups.entry((root, generation)).or_default().push(item); + } + + let mut doomed: Vec> = Vec::new(); + let mut retained_recent = 0_usize; + let mut unprovable = 0_usize; + for ((root, generation), mut group) in groups { + if prove_generation(root, &generation, &group).is_err() { + // We cannot prove we wrote this, so we do not touch it. Skipped + // rather than fatal: one foreign entry must not block reclamation of + // the store forever. Reported in the summary. + unprovable = unprovable.saturating_add(group.len()); + continue; + } + + // Age the generation as a UNIT, by its youngest member. + let group_age = group + .iter() + .map(|item| { + now.saturating_sub(*created_by_key.get(item.item_key.as_str()).unwrap_or(&0)) + }) + .min() + .unwrap_or(0); + // BOTH ages must clear the operator's window; take the more restrictive. + let effective_age = root_live_since.get(root).map_or(group_age, |live_since| { + group_age.min(now.saturating_sub(*live_since)) + }); + if effective_age < older_than_secs { + retained_recent = retained_recent.saturating_add(group.len()); + continue; + } + // Delete in canonical chunk-INDEX order (`.0`, `.1`, ...), NOT the remote + // listing order, so preview order and stranding are deterministic. + group.sort_by_key(|item| chunk_key_index(root, &item.item_key).unwrap_or(usize::MAX)); + doomed.push( + group + .iter() + .map(|item| { + let age = now + .saturating_sub(*created_by_key.get(item.item_key.as_str()).unwrap_or(&0)); + (item.item_key.clone(), age) + }) + .collect(), + ); + } + + let mut kept_roots: Vec = protected.into_iter().collect(); + kept_roots.sort(); + + Ok(GcPlan { + doomed, + kept_roots, + live_count: live.len(), + retained_recent, + roots, + unprovable, + warnings, + }) +} + +/// Reassemble the chunks a live pointer references, in index order, checking each +/// against the pointer's own per-chunk `len`/`sha256` along the way. +/// +/// Fails closed when a referenced key is absent from the listing. +fn assemble_pointer_chunks( + root_key: &str, + pointer: &GcPointer, + value_by_key: &HashMap<&str, &str>, +) -> Result { + let mut assembled = String::new(); + // The chunk KEY is pointer-controlled, so diagnostics name a POSITION, not + // the key. + for (position, chunk) in pointer.chunks.iter().enumerate() { + let Some(value) = value_by_key.get(chunk.key.as_str()) else { + return Err(format!( + "refusing to reclaim: root `{root_key}` references chunk {position}, which is \ + absent from the store listing (the listing may be incomplete/paginated, or the \ + store is already inconsistent); nothing was deleted" + )); + }; + if value.len() != chunk.len { + return Err(format!( + "refusing to reclaim: root `{root_key}` says chunk {position} is {} bytes but the \ + store holds {}; nothing was deleted", + chunk.len, + value.len() + )); + } + if sha256_hex(value.as_bytes()) != chunk.sha256 { + return Err(format!( + "refusing to reclaim: the stored value of chunk {position} does not match the \ + SHA-256 that root `{root_key}` records for it; nothing was deleted" + )); + } + assembled.push_str(value); + } + if assembled.len() != pointer.envelope_len { + return Err(format!( + "refusing to reclaim: root `{root_key}` declares an envelope of {} bytes but its \ + chunks reassemble to {}; nothing was deleted", + pointer.envelope_len, + assembled.len() + )); + } + Ok(assembled) +} + +/// Is this candidate generation byte-identical to what THIS writer would have +/// produced for the bytes it contains? The gate on every delete. `group` is +/// every listed entry sharing one `(root, generation)`. +fn prove_generation( + root: &str, + generation: &str, + group: &[&ConfigStoreItem], +) -> Result<(), String> { + let mut ordered: Vec<(usize, &str)> = Vec::with_capacity(group.len()); + for item in group { + let index = item + .item_key + .rsplit_once('.') + .and_then(|(_, index)| index.parse::().ok()) + .ok_or_else(|| format!("`{}` has no readable index", item.item_key))?; + ordered.push((index, item.item_value.as_str())); + } + ordered.sort_by_key(|&(index, _)| index); + for (position, &(index, _)) in ordered.iter().enumerate() { + if index != position { + return Err(format!( + "indexes are not dense 0..n-1 (found {index} at position {position})" + )); + } + } + let assembled: String = ordered.iter().map(|&(_, value)| value).collect(); + + // 1. The bytes must be the generation the keys name, and a real envelope. + gc_verify_generation(generation, &assembled)?; + + // 2. ...and the writer, given those bytes, must produce EXACTLY these entries. + let expected = prepare_fastly_config_entries(root, &assembled) + .map_err(|err| format!("this writer could not re-derive the generation ({err})"))?; + let Some(expected_chunks) = expected.get(..expected.len().saturating_sub(1)) else { + return Err("this writer produced no chunk entries for these bytes".to_owned()); + }; + if expected_chunks.is_empty() { + // The envelope fits directly, so the writer would never have chunked it. + return Err( + "these bytes fit the entry limit, so this writer would have stored them directly \ + rather than in chunks" + .to_owned(), + ); + } + if expected_chunks.len() != ordered.len() { + return Err(format!( + "this writer would split these bytes into {} chunk(s), not {}", + expected_chunks.len(), + ordered.len() + )); + } + for ((expected_key, expected_value), item) in + expected_chunks.iter().zip(group_in_index_order(group)) + { + if *expected_key != item.item_key { + return Err(format!( + "this writer would not have produced the key `{}`", + item.item_key + )); + } + if *expected_value != item.item_value { + return Err(format!( + "the stored value of `{}` is not the chunk this writer would have written at that \ + index", + item.item_key + )); + } + } + Ok(()) +} + +/// `group` sorted by chunk index, so it lines up with the writer's output order. +fn group_in_index_order<'item>(group: &[&'item ConfigStoreItem]) -> Vec<&'item ConfigStoreItem> { + let mut ordered: Vec<&ConfigStoreItem> = group.to_vec(); + ordered.sort_by_key(|item| { + item.item_key + .rsplit_once('.') + .and_then(|(_, index)| index.parse::().ok()) + .unwrap_or(usize::MAX) + }); + ordered +} + +/// Is this key a chunk key of ANY root? (`config gc` scans the whole store, so +/// it cannot scope to one root up front.) Validates the canonical shape. +fn chunk_key_generation_any(key: &str) -> Option { + // Split on the LAST infix, not the first: a chunk of a root that ITSELF + // contains the infix has the infix twice, and its chunk suffix is after the + // LAST one. + let (root, _rest) = key.rsplit_once(CHUNK_KEY_INFIX)?; + chunk_key_generation(root, key) +} + +fn delete_config_store_entry(store_id: &str, key: &str) -> Result<(), String> { + let store_arg = format!("--store-id={store_id}"); + let key_arg = format!("--key={key}"); + let output = Command::new("fastly") + .args([ + "config-store-entry", + "delete", + store_arg.as_str(), + key_arg.as_str(), + "--auto-yes", + ]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if output.status.success() { + return Ok(()); + } + // EVERY non-zero delete is a failure -- no "already gone" special case, and + // redact stderr: a Fastly error can quote the entry value back. + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!( + "`fastly config-store-entry delete --store-id={store_id} --key={key} --auto-yes` exited with status {}\n{}", + output.status, + redact_stderr(&stderr) + )) +} + +#[cfg(test)] +mod tests { + #[cfg(unix)] + use super::super::path_mutation_guard; + use super::*; + use crate::cli::test_support::*; + #[cfg(unix)] + use edgezero_core::test_env::PathPrepend; + #[cfg(unix)] + use std::fs; + #[cfg(unix)] + use tempfile::tempdir; + + #[test] + fn parse_rfc3339_secs_rounds_a_fraction_up() { + let whole = parse_rfc3339_secs("2026-01-01T00:00:42Z").expect("whole"); + // A fractional second rounds UP to the next whole second, so the computed + // age stays conservative and a key never ages into deletion early. + assert_eq!( + parse_rfc3339_secs("2026-01-01T00:00:42.998Z"), + Some(whole + 1), + "a fractional creation time must round UP, not floor" + ); + // Even a tiny fraction rounds up. + assert_eq!( + parse_rfc3339_secs("2026-01-01T00:00:42.000001Z"), + Some(whole + 1) + ); + // A whole-second stamp is unchanged. + assert_eq!(parse_rfc3339_secs("2026-01-01T00:00:42.000Z"), Some(whole)); + } + + // ---------- config gc (operator-invoked reclamation) ---------- + + /// gc never deletes a chunk the LIVE root pointer references, however old. + #[cfg(unix)] + #[test] + fn gc_never_deletes_live_chunks() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let live_chunks = chunk_keys_of(TEST_CONFIG_ID, &live); + // The live generation is ANCIENT, but it is referenced by the root. + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 999_999)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let out = run_gc(dir.path(), 1, false).expect("gc succeeds"); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + for key in &live_chunks { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "live chunk `{key}` must never be reclaimed; log:\n{log}\nout: {out:?}" + ); + } + } + + /// gc reclaims unreferenced chunks older than the operator's threshold. + #[cfg(unix)] + #[test] + fn gc_reclaims_unreferenced_chunks_older_than_threshold() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let dead = gen_envelope("dead"); + let live_chunks = chunk_keys_of(TEST_CONFIG_ID, &live); + let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); + + // The live config has been stable for 2 days; the operator asserts a 1-day + // window. So everything superseded (<= when live went live, i.e. >= 2 + // days ago) is safely reclaimable. + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); // a week old + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let out = run_gc(dir.path(), 86_400, false).expect("gc succeeds"); + for key in &dead_chunks { + assert!( + oplog_has(&oplog, &format!("delete {key}")), + "orphan `{key}` older than the threshold must be reclaimed; out: {out:?}" + ); + } + for key in &live_chunks { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "live chunk `{key}` must survive" + ); + } + } + + /// The soundness test (design-3 counterexample): a root whose + /// current config was deployed seconds ago must NOT have its prior generation + /// reclaimed, even if that generation's chunks are ANCIENT. The clock is the + /// live config's age, not the orphan chunk's own creation time. + #[cfg(unix)] + #[test] + fn gc_protects_recently_superseded_generation_with_old_chunks() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let prior = gen_envelope("prior"); + let prior_chunks = chunk_keys_of(TEST_CONFIG_ID, &prior); + + // Live config went live 30s ago; the prior generation's chunks are a year + // old but were superseded only 30s ago -> POPs may still serve them. + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 30)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 30)); + listing.extend(listed_generation(TEST_CONFIG_ID, &prior, 31_536_000)); // ~1 year + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + // Even a generous 1-day threshold must NOT delete the prior generation, + // because the live config has only been stable for 30 seconds. + run_gc(dir.path(), 86_400, false).expect("gc succeeds"); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + for key in &prior_chunks { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "a generation superseded 30s ago must be retained despite old chunks: `{key}`; log:\n{log}" + ); + } + } + + /// a live root whose pointer drops its + /// last chunk ref AND restates `envelope_len` as the remaining sum passes + /// every metadata check. The dropped chunk is then absent from the live set + /// and looks like a deletable orphan -- while the config still needs it. + /// + /// Guards the PLANNER's content verification (a unit test on + /// `gc_verify_generation` alone does not prove the planner calls it). + #[cfg(unix)] + #[test] + fn gc_fails_closed_when_a_live_pointer_underreports_its_chunks() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + // Padded so the generation is >= 3 chunks: this case needs a ref to + // drop that still leaves a plausible multi-chunk set behind. + let live = gen_envelope_padded("live", 20_000); + let (chunks, pointer_json) = chunked_parts(TEST_CONFIG_ID, &live); + assert!(chunks.len() >= 3, "need >= 3 chunks for this case"); + + // Doctor the pointer: drop the last ref, restate envelope_len to match + // the survivors. Generation, indexes, per-chunk lens and the sum all + // still agree -- only the CONTENT does not. + let mut pointer: serde_json::Value = serde_json::from_str(&pointer_json).expect("parse"); + let refs = pointer + .get_mut("chunks") + .and_then(serde_json::Value::as_array_mut) + .expect("chunks array"); + refs.pop().expect("drop the last chunk ref"); + let surviving_len: u64 = refs + .iter() + .filter_map(|chunk| chunk.get("len").and_then(serde_json::Value::as_u64)) + .sum(); + pointer["envelope_len"] = serde_json::json!(surviving_len); + let doctored = serde_json::to_string(&pointer).expect("serialise"); + + // The store still physically holds ALL the chunks, including the one the + // doctored pointer no longer names. + let orphaned_by_omission = chunks.last().expect("last chunk").0.clone(); + let stamp = stamp_secs_ago(999_999); + let mut listing = vec![(TEST_CONFIG_ID.to_owned(), stamp.clone(), doctored)]; + for (key, value) in chunks { + listing.push((key, stamp.clone(), value)); + } + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let err = run_gc(dir.path(), 1, false).expect_err("must fail closed"); + assert!( + err.contains("does not reconstruct the envelope it claims"), + "expected a content-address mismatch on the live pointer, got: {err}" + ); + assert!( + !oplog_has(&oplog, &format!("delete {orphaned_by_omission}")), + "a chunk the live config still needs must never be deleted because its pointer \ + under-reported it: `{orphaned_by_omission}`" + ); + } + + /// a LONE entry whose value hashes to the generation + /// its own key names would otherwise "prove" itself and be deleted. But our + /// writer never emits a one-chunk generation (an oversized envelope always + /// splits into >= 2), so a group of one is never ours -- it is a root-like + /// value sitting at a chunk-shaped key. This is the case a pure hash check + /// cannot catch on its own. + #[cfg(unix)] + #[test] + fn gc_never_reclaims_a_lone_self_consistent_chunk() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 999_999)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); + + // A complete envelope stored at a chunk-shaped key whose generation IS + // that envelope's own SHA -- so it reassembles to its content-address. + let squatter_value = gen_envelope("someones-real-config"); + let self_sha = sha256_hex(squatter_value.as_bytes()); + let squatter_key = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{self_sha}.0"); + listing.push(( + squatter_key.clone(), + stamp_secs_ago(31_536_000), + squatter_value, + )); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + run_gc(dir.path(), 86_400, false).expect("gc succeeds"); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + assert!( + !oplog_has(&oplog, &format!("delete {squatter_key}")), + "a one-chunk 'generation' is never something this writer emitted, so it must not be \ + reclaimed even though it hashes to its own key: `{squatter_key}`; log:\n{log}" + ); + } + + /// a delete that fails on a generation's FIRST key has + /// an UNKNOWN outcome -- Fastly may have committed it before returning an + /// error. called this "whole and retryable", which is unsound: if the + /// failed delete did commit, a re-run finds a fragment. The honest report is + /// a NOTE that the outcome is uncertain, NOT a clean-retry promise. We still + /// stop the generation so a CONFIRMED partial delete cannot happen. + #[cfg(unix)] + #[test] + fn gc_first_delete_failure_is_reported_as_uncertain_not_clean_retry() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let dead = gen_envelope("dead"); + let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); + assert!(dead_chunks.len() >= 2, "need a multi-chunk generation"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); + + // The FIRST chunk of the doomed generation fails to delete. + let fake = fake_fastly_gc( + TEST_CONFIG_ID, + &[], + &listing, + Some(&dead_chunks[0]), + false, + &oplog, + ); + let _path = PathPrepend::new(fake.path()); + + let err = run_gc(dir.path(), 86_400, false).expect_err("a failed delete is a failure"); + assert!( + err.contains("unknown outcome"), + "a failed delete's outcome is unknown and must be reported as such: {err}" + ); + assert!( + !err.contains("will retry them"), + "the disproven clean-retry promise must be gone: {err}" + ); + // The siblings must NOT have been ATTEMPTED -- stopping is what prevents a + // CONFIRMED partial delete. + for key in dead_chunks.iter().skip(1) { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "after the first failure the generation must be left alone: `{key}`" + ); + } + } + + /// the stateful case. A remote delete that COMMITS but + /// still reports failure leaves a real fragment. On the SECOND run that + /// missing key makes the generation unprovable, so it must be reported as + /// left-untouched (surfaced), never silently dropped. + #[cfg(unix)] + #[test] + fn gc_committed_but_failed_delete_surfaces_as_unprovable_next_run() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let dead = gen_envelope("dead"); + let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); + assert!(dead_chunks.len() >= 2, "need a multi-chunk generation"); + + // SECOND run's world: the first chunk's delete committed last time, so it + // is gone. The generation is now a fragment. + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + let mut dead_gen = listed_generation(TEST_CONFIG_ID, &dead, 604_800); + let survivor = dead_gen[1].0.clone(); + dead_gen.remove(0); // the committed-deleted chunk is absent now + listing.extend(dead_gen); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let out = run_gc(dir.path(), 86_400, false).expect("gc succeeds"); + assert!( + !oplog_has(&oplog, &format!("delete {survivor}")), + "an unprovable fragment survivor must not be deleted: `{survivor}`" + ); + assert!( + out.iter() + .any(|line| line.contains("not byte-identical to what this writer would produce")), + "the surviving fragment must be SURFACED as left-untouched, not silently dropped: {out:?}" + ); + } + + /// if a delete fails PART-WAY through a generation, the + /// survivors are an incomplete generation that `prove_generation` can never + /// verify again -- so `gc` will never reclaim them. Claiming "re-run to + /// retry" there was false. Say plainly that recovery is manual. + #[cfg(unix)] + #[test] + fn gc_reports_stranded_survivors_as_manual_recovery() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + // Padded to >= 3 chunks so a mid-generation failure leaves survivors. + let live = gen_envelope("live"); + let dead = gen_envelope_padded("dead", 20_000); + let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); + assert!(dead_chunks.len() >= 3, "need >= 3 chunks"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); + + // The SECOND chunk fails: the first is already gone by then. + let fake = fake_fastly_gc( + TEST_CONFIG_ID, + &[], + &listing, + Some(&dead_chunks[1]), + false, + &oplog, + ); + let _path = PathPrepend::new(fake.path()); + + let err = run_gc(dir.path(), 86_400, false).expect_err("a failed delete is a failure"); + assert!( + err.contains("INCOMPLETE generation") && err.contains("re-running will NOT help"), + "a stranded fragment must not be described as retryable: {err}" + ); + // It must name the survivors and how to remove them by hand. + for key in dead_chunks.iter().skip(2) { + assert!( + err.contains(key.as_str()), + "the operator needs the exact surviving keys: `{key}` missing from: {err}" + ); + } + assert!( + err.contains("fastly config-store-entry delete"), + "give the operator the recovery command: {err}" + ); + // And we stopped rather than deleting the rest. + for key in dead_chunks.iter().skip(2) { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "deletion must stop at the first failure in a generation: `{key}`" + ); + } + } + + /// root keys are free-form, so a chunk key can hold + /// shell metacharacters. Manual-recovery commands must render them so that + /// pasting cannot execute or misparse -- single-quoted, with embedded quotes + /// escaped. + #[test] + fn recovery_commands_are_shell_safe() { + // A key crafted to run `id` and to break argument parsing if unquoted. + let hostile = "app$(id).__edgezero_chunks.'; rm -rf /'.0".to_owned(); + let keys = [hostile.clone()]; + let rendered = recovery_commands("store-abc", &keys); + + // The dangerous substring is not sitting there unquoted. + assert!( + !rendered.contains("$(id)") || rendered.contains("'app$(id)"), + "shell-active text must be inside single quotes: {rendered}" + ); + // Every embedded single quote is closed-escaped-reopened, so no quote + // context leaks. + assert!( + rendered.contains(r"'\''"), + "embedded single quotes must be escaped as '\\'': {rendered}" + ); + // Sanity: what a POSIX shell would parse back out of our --key argument + // is EXACTLY the original key (round-trip through `sh`). + let key_arg = rendered + .split("--key=") + .nth(1) + .and_then(|rest| rest.split(" --auto-yes").next()) + .expect("a --key argument"); + let out = Command::new("sh") + .arg("-c") + .arg(format!("printf '%s' {key_arg}")) + .output() + .expect("run sh"); + assert_eq!( + String::from_utf8_lossy(&out.stdout), + hostile, + "the shell must parse the quoted argument back to the exact key" + ); + } + + /// a valid DIRECT envelope at a chunk-shaped key is a + /// runtime-readable root, but earlier classification only protected + /// POINTER values there. + /// + /// Construction: pad a small valid envelope with trailing JSON whitespace + /// past the entry limit. The writer chunks it; chunk 0 (the first 7 000 + /// bytes) is the whole envelope plus trailing spaces, which STILL parses and + /// verifies as that envelope. So chunk 0's key holds a valid direct envelope + /// -- a root -- yet the generation round-trips through the writer and passes + /// every proof, so GC deletes chunk 0. + #[cfg(unix)] + #[test] + fn valid_envelope_at_chunk_shaped_key_is_a_protected_root() { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + // A small valid envelope + trailing whitespace over the entry limit. + let envelope = BlobEnvelope::new(json!({"k":"v"}), "2026-06-22T00:00:00Z".into()); + let mut padded = serde_json::to_string(&envelope).unwrap(); + padded.push_str(&" ".repeat(8_200)); + let entries = prepare_fastly_config_entries(TEST_CONFIG_ID, &padded).expect("expand"); + assert!(entries.len() >= 3, "need >= 2 chunks + pointer"); + let holder_key = entries[0].0.clone(); + // Sanity: chunk 0's value IS a standalone valid envelope. + let parsed: BlobEnvelope = + serde_json::from_str(&entries[0].1).expect("chunk 0 must parse as an envelope"); + parsed.verify().expect("chunk 0 must verify as an envelope"); + + // Seed the store with the chunk entries only -- NO live pointer refers + // to them, so this generation looks orphaned. Aged old. + let stamp = stamp_secs_ago(604_800); + let live = gen_envelope("live"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 999_999)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); + for (key, value) in &entries[..entries.len().saturating_sub(1)] { + listing.push((key.clone(), stamp.clone(), value.clone())); + } + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + drop(run_gc(dir.path(), 86_400, false)); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + assert!( + !oplog_has(&oplog, &format!("delete {holder_key}")), + "an entry whose value is a valid direct envelope is a runtime-readable root and must \ + never be deleted, whatever its key looks like: `{holder_key}`; log:\n{log}" + ); + // The SIBLING chunks must survive too: protecting the holder drops the + // generation to an incomplete group, which is left unprovable — so + // nothing in this generation is deleted, not just the holder. + for (key, _) in &entries[1..entries.len().saturating_sub(1)] { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "a sibling of a protected root must also survive (the group is left \ + unprovable): `{key}`; log:\n{log}" + ); + } + } + + /// A self-scoped pointer at a chunk-shaped holder key (its chunks nest the + /// infix twice) must NOT abort store-wide GC: the doubly-nested chunks are + /// recognised as chunks (via the LAST infix), so the holder classifies as a + /// root, its references are counted live, and other roots still reclaim. + #[cfg(unix)] + #[test] + fn gc_tolerates_a_self_scoped_pointer_at_a_chunk_shaped_root() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + // A pointer parked at a chunk-shaped key, with chunks scoped to itself. + let holder_key = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{}.0", "e".repeat(64)); + let nested = gen_envelope("nested"); + let nested_entries = prepare_fastly_config_entries(&holder_key, &nested).expect("expand"); + let (_, holder_pointer) = nested_entries.last().expect("pointer").clone(); + + // A normal live root, and a normal orphan generation that SHOULD reclaim. + let live = gen_envelope("live"); + let dead = gen_envelope("dead"); + let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); + let stamp = stamp_secs_ago(604_800); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); + listing.push((holder_key.clone(), stamp.clone(), holder_pointer)); + for (key, value) in &nested_entries[..nested_entries.len().saturating_sub(1)] { + listing.push((key.clone(), stamp.clone(), value.clone())); + } + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + // The run must SUCCEED (not abort) and still reclaim the ordinary orphan. + run_gc(dir.path(), 86_400, false).expect("store-wide GC must not abort"); + for key in &dead_chunks { + assert!( + oplog_has(&oplog, &format!("delete {key}")), + "an ordinary orphan must still be reclaimed despite the self-scoped pointer: `{key}`" + ); + } + assert!( + !oplog_has(&oplog, &format!("delete {holder_key}")), + "the chunk-shaped holder root must never be deleted" + ); + } + + /// A nested ORPHAN generation (chunks scoped to a chunk-shaped root, with NO + /// live pointer referencing them) must be grouped and reclaimed, not silently + /// dropped. Age and grouping split on the LAST infix, so the nested chunks are + /// attributed to their real (nested) root. + #[cfg(unix)] + #[test] + fn gc_reclaims_a_nested_orphan_generation() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + // A chunk-shaped root, and a full generation of chunks SCOPED to it — but + // no pointer references them, so they are orphaned. + let nested_root = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{}.0", "f".repeat(64)); + let nested = gen_envelope("nested-orphan"); + let nested_entries = prepare_fastly_config_entries(&nested_root, &nested).expect("expand"); + let nested_chunks: Vec = nested_entries[..nested_entries.len().saturating_sub(1)] + .iter() + .map(|(key, _)| key.clone()) + .collect(); + + let live = gen_envelope("live"); + let stamp = stamp_secs_ago(604_800); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + for (key, value) in &nested_entries[..nested_entries.len().saturating_sub(1)] { + listing.push((key.clone(), stamp.clone(), value.clone())); + } + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + run_gc(dir.path(), 86_400, false).expect("gc succeeds"); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + for key in &nested_chunks { + assert!( + oplog_has(&oplog, &format!("delete {key}")), + "a nested orphan generation must be reclaimed, not silently dropped: `{key}`; \ + log:\n{log}" + ); + } + } + + /// FAIL CLOSED: a MALFORMED pointer sitting at a chunk-shaped root that HAS a + /// nested generation beneath it must abort GC, not let that nested generation + /// be reclaimed. The truncated pointer cannot announce its discriminator, so + /// it looks like a chunk fragment -- but its nested chunks are proven + /// independently and would be deleted while their (unreadable) root can no + /// longer name them. That is exactly the truncated-pointer data loss the + /// spec forbids, so the whole run must refuse. + #[cfg(unix)] + #[test] + fn gc_fails_closed_on_a_malformed_pointer_at_a_chunk_shaped_root_with_nested_chunks() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let nested_root = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{}.0", "f".repeat(64)); + let nested = gen_envelope("nested"); + let nested_entries = prepare_fastly_config_entries(&nested_root, &nested).expect("expand"); + let nested_chunks: Vec = nested_entries[..nested_entries.len().saturating_sub(1)] + .iter() + .map(|(key, _)| key.clone()) + .collect(); + + let live = gen_envelope("live"); + let stamp = stamp_secs_ago(604_800); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + // A truncated pointer at the chunk-shaped nested root: it WAS a pointer, + // now cut off, so it cannot announce its `edgezero_kind`. + listing.push(( + nested_root.clone(), + stamp.clone(), + r#"{"chunks":[{"key":"#.to_owned(), + )); + // ...its aged, independently-provable nested generation. + for (key, value) in &nested_entries[..nested_entries.len().saturating_sub(1)] { + listing.push((key.clone(), stamp.clone(), value.clone())); + } + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let err = run_gc(dir.path(), 86_400, false) + .expect_err("an unreadable nested root must fail closed, not be reclaimed"); + assert!( + err.contains("refusing to reclaim"), + "must fail closed, not delete: {err}" + ); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + for key in &nested_chunks { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "a nested generation under an unreadable root must NOT be deleted: `{key}`; \ + log:\n{log}" + ); + } + } + + /// Age attribution works per NESTED root: a nested orphan generation whose + /// nested root's live config went live RECENTLY must be RETAINED (POPs may + /// still serve the superseded generation), even though the orphan's own + /// chunks are old. This pins that `root_live_since` splits on the last infix. + #[cfg(unix)] + #[test] + fn gc_retains_a_nested_orphan_under_a_recently_changed_nested_root() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let nested_root = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{}.0", "a".repeat(64)); + + // The nested root's CURRENT (live) generation, created 30s ago. + let live_nested = gen_envelope("live-nested"); + let live_entries = prepare_fastly_config_entries(&nested_root, &live_nested).expect("exp"); + let (_, live_pointer) = live_entries.last().expect("pointer").clone(); + + // An OLD orphan generation under the SAME nested root (a week old). + let old_nested = gen_envelope("old-nested-orphan"); + let old_entries = prepare_fastly_config_entries(&nested_root, &old_nested).expect("exp"); + let old_chunks: Vec = old_entries[..old_entries.len().saturating_sub(1)] + .iter() + .map(|(key, _)| key.clone()) + .collect(); + + let live = gen_envelope("live"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + // The nested root holds its live pointer; its live chunks are 30s old. + listing.push((nested_root.clone(), stamp_secs_ago(30), live_pointer)); + for (key, value) in &live_entries[..live_entries.len().saturating_sub(1)] { + listing.push((key.clone(), stamp_secs_ago(30), value.clone())); + } + // The old orphan chunks are a week old. + for (key, value) in &old_entries[..old_entries.len().saturating_sub(1)] { + listing.push((key.clone(), stamp_secs_ago(604_800), value.clone())); + } + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + // A generous 1-day window: the orphan's OWN chunks are older, but the + // nested root's live config is only 30s old, so its orphan is retained. + run_gc(dir.path(), 86_400, false).expect("gc succeeds"); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + for key in &old_chunks { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "a nested orphan under a recently-changed nested root must be retained: `{key}`; \ + log:\n{log}" + ); + } + } + + /// A generation is aged by its YOUNGEST member, so a generation with one + /// recent chunk is retained whole even if its other chunks are ancient. + #[cfg(unix)] + #[test] + fn gc_ages_a_generation_by_its_youngest_member() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + // `app_config` live is direct, so there is no live-config age signal — + // aging falls to the generation's own chunks. + let live_direct = gen_envelope_padded("live-direct", 100); + let dead = gen_envelope("dead"); + let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); + assert!(dead_chunks.len() >= 2, "need a multi-chunk generation"); + + let mut listing = vec![( + TEST_CONFIG_ID.to_owned(), + stamp_secs_ago(999_999), + live_direct, + )]; + // The doomed generation: chunk 0 written 30s ago (YOUNG), the rest a week + // ago. Its youngest-member age (30s) is under the 1-day window. + let dead_parts = chunked_parts(TEST_CONFIG_ID, &dead).0; + for (idx, (key, value)) in dead_parts.iter().enumerate() { + let age = if idx == 0 { 30 } else { 604_800 }; + listing.push((key.clone(), stamp_secs_ago(age), value.clone())); + } + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + run_gc(dir.path(), 86_400, false).expect("gc succeeds"); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + for key in &dead_chunks { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "a generation with a recent member must be retained WHOLE (aged by its youngest): \ + `{key}`; log:\n{log}" + ); + } + } + + /// A delete failure in one generation must not stop an INDEPENDENT + /// generation's deletes. + #[cfg(unix)] + #[test] + fn gc_failure_in_one_generation_does_not_stop_another() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let dead_a = gen_envelope("dead-a"); + let dead_b = gen_envelope("dead-b"); + let a_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead_a); + let b_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead_b); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + listing.extend(listed_generation(TEST_CONFIG_ID, &dead_a, 604_800)); + listing.extend(listed_generation(TEST_CONFIG_ID, &dead_b, 604_800)); + + // Generation A's first delete fails. + let fake = fake_fastly_gc( + TEST_CONFIG_ID, + &[], + &listing, + Some(&a_chunks[0]), + false, + &oplog, + ); + let _path = PathPrepend::new(fake.path()); + + let err = run_gc(dir.path(), 86_400, false).expect_err("a failed delete is a failure"); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + // Generation B must still have been reclaimed despite A's failure. + for key in &b_chunks { + assert!( + oplog_has(&oplog, &format!("delete {key}")), + "an independent generation must still be reclaimed after another one fails: \ + `{key}`; err: {err}; log:\n{log}" + ); + } + } + + /// key shape is not authoritative for ROOTS either. + /// + /// A valid pointer stored at a chunk-SHAPED key (`shadow.__edgezero_chunks. + /// .0`) is skipped by the live-set scan, which excludes chunk-shaped + /// keys up front. The runtime resolver follows any pointer it is given, so + /// that pointer's references ARE live -- but GC never sees them, calls the + /// generation orphaned, and deletes it. + #[cfg(unix)] + #[test] + fn pointer_at_chunk_shaped_key_keeps_its_references_live() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + // `app_config`'s CURRENT config is small enough to store directly, so + // its own root references no chunks at all. + let live_direct = gen_envelope_padded("live-direct", 100); + let mut listing = vec![( + TEST_CONFIG_ID.to_owned(), + stamp_secs_ago(999_999), + live_direct, + )]; + + // An older chunked generation of `app_config` still exists... + let referenced = gen_envelope("still-referenced"); + let referenced_chunks = chunk_keys_of(TEST_CONFIG_ID, &referenced); + listing.extend(listed_generation(TEST_CONFIG_ID, &referenced, 604_800)); + + // ...and a pointer at a CHUNK-SHAPED key references it. The resolver + // would happily follow this, so those chunks are LIVE. + let (_, referenced_pointer) = chunked_parts(TEST_CONFIG_ID, &referenced); + let shadow_key = format!("shadow{CHUNK_KEY_INFIX}{}.0", "d".repeat(64)); + listing.push((shadow_key, stamp_secs_ago(604_800), referenced_pointer)); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + // The RESULT does not matter here (it may Err after the fix if the + // shadow pointer's own chunks are incomplete); the invariant is purely + // that no LIVE-referenced chunk is deleted, which the oplog proves. + drop(run_gc(dir.path(), 86_400, false)); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + for key in &referenced_chunks { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "a chunk a live pointer references must never be deleted, whatever the KEY of \ + the entry holding that pointer looks like: `{key}`; log:\n{log}" + ); + } + } + + /// a FOREIGN writer needs NO preimage to satisfy a + /// content-address. Pick envelope E, compute H = sha256(E), split E however + /// you like, store the parts as `.__edgezero_chunks.H.0` / `.1`. Under + /// hash-only checking that group "proved" itself and was deleted. + /// + /// The round-trip closes it: the writer, given those same bytes, must emit + /// exactly these keys and values. A split at boundaries we would never + /// choose is not our output, so it is left alone. + #[cfg(unix)] + #[test] + fn gc_never_reclaims_a_foreign_content_addressed_group() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 999_999)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); + + // A foreign writer's data: a valid envelope, content-addressed under our + // reserved namespace, but split at ITS OWN boundary (not our 7 000-byte + // UTF-8-safe one). Everything hashes correctly -- no preimage needed. + let foreign = gen_envelope_padded("foreign-tool", 20_000); + let generation = sha256_hex(foreign.as_bytes()); + let (head, tail) = foreign.split_at(1_234); + let foreign_keys = [ + format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{generation}.0"), + format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{generation}.1"), + ]; + listing.push(( + foreign_keys[0].clone(), + stamp_secs_ago(31_536_000), + head.to_owned(), + )); + listing.push(( + foreign_keys[1].clone(), + stamp_secs_ago(31_536_000), + tail.to_owned(), + )); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + run_gc(dir.path(), 86_400, false).expect("gc succeeds"); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + for key in &foreign_keys { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "a group this writer would never have produced must not be reclaimed, however \ + well it hashes: `{key}`; log:\n{log}" + ); + } + } + + /// an entry can be chunk-SHAPED without being a chunk + /// -- a store may predate this feature or be shared, and push-time + /// reserved-key rejection cannot protect what already exists. Deleting one + /// would destroy live config. + /// + /// proof is CONTENT, not shape. A candidate generation is ours only + /// if it reassembles to the content-address its own keys name. Unprovable + /// entries are left UNTOUCHED and reported -- not fatal, because one foreign + /// entry must not block reclaiming the rest of the store forever. + #[cfg(unix)] + #[test] + fn gc_leaves_unprovable_chunk_shaped_entries_untouched() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let dead = gen_envelope("dead"); + let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 999_999)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); + // A real orphan generation: provable, old -> must still be reclaimed. + listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); + + // Pre-existing entries at chunk-shaped keys that we did NOT write: one + // holding somebody's real config envelope, one holding plain text. + // Both are old enough to look "eligible" on age alone. + let envelope_squatter = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{}.0", "b".repeat(64)); + let text_squatter = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{}.0", "c".repeat(64)); + listing.push(( + envelope_squatter.clone(), + stamp_secs_ago(31_536_000), + gen_envelope("someones-real-config"), + )); + listing.push(( + text_squatter.clone(), + stamp_secs_ago(31_536_000), + "just some plain text".to_owned(), + )); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let out = run_gc(dir.path(), 86_400, false).expect("gc succeeds"); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + + for key in [&envelope_squatter, &text_squatter] { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "an entry we cannot prove we wrote must never be deleted: `{key}`; log:\n{log}" + ); + } + // Left untouched must not mean silently ignored. The wording must not + // over-claim either: these two entries fail for DIFFERENT reasons (a + // wrong content-address vs a count this writer never emits), so the + // summary says "not byte-identical to what this writer would produce" + // rather than naming one specific check. + assert!( + out.iter() + .any(|line| line.contains("not byte-identical to what this writer would produce")), + "the summary must report what it declined to judge; out: {out:?}" + ); + // ...and a genuine orphan generation is still reclaimed, so one foreign + // entry does not block the store. + for key in &dead_chunks { + assert!( + oplog_has(&oplog, &format!("delete {key}")), + "a provable orphan generation must still be reclaimed: `{key}`; log:\n{log}" + ); + } + } + + /// a key is unique in a config store, so duplicate rows + /// mean the listing is not one consistent view. Left alone, last-row-wins on + /// `created_at` could age a recent key into eligibility. + #[cfg(unix)] + #[test] + fn gc_fails_closed_on_duplicate_listing_keys() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let dead = gen_envelope("dead"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + let mut orphans = listed_generation(TEST_CONFIG_ID, &dead, 30); + // The same key twice, with conflicting ages: young (real) then ancient. + let (dup_key, _, dup_value) = orphans[0].clone(); + orphans.push((dup_key.clone(), stamp_secs_ago(31_536_000), dup_value)); + listing.extend(orphans); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let err = run_gc(dir.path(), 86_400, false).expect_err("must fail closed"); + assert!( + err.contains("more than once"), + "expected a refusal naming the duplicate key, got: {err}" + ); + assert!( + !oplog_has(&oplog, &format!("delete {dup_key}")), + "a duplicated row must not let a recent key be aged into eligibility" + ); + } + + /// `gc_config_entries` is a public trait method, so the + /// zero-window rule must live at the DESTRUCTIVE boundary, not only in the + /// CLI that usually calls it. Rejected before any `fastly` invocation. + #[cfg(unix)] + #[test] + fn gc_adapter_boundary_rejects_a_zero_window() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let dead = gen_envelope("dead"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + // Straight at the adapter, bypassing the CLI's own gate. + let err = run_gc(dir.path(), 0, false).expect_err("a destructive zero window must fail"); + assert!( + err.contains("non-zero `--older-than`"), + "expected the boundary itself to reject zero, got: {err}" + ); + assert!( + !fs::read_to_string(&oplog) + .unwrap_or_default() + .contains("delete "), + "nothing may be deleted under a zero window" + ); + // A DRY-RUN at zero is still allowed: it previews and deletes nothing. + run_gc(dir.path(), 0, true).expect("a dry-run may preview at zero"); + } + + /// a root whose value is TRUNCATED/unparseable must fail + /// closed. It is pointer-shaped garbage -- we cannot tell what it references, + /// so its (live!) chunks must not be judged orphaned. Regression guard: the + /// push-path helper returns `Ok([])` for a non-pointer value, which on THIS + /// path would read as "references nothing" and reclaim the whole store. + #[cfg(unix)] + #[test] + fn gc_fails_closed_on_truncated_root_pointer() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let live_chunks = chunk_keys_of(TEST_CONFIG_ID, &live); + let (_, pointer) = chunked_parts(TEST_CONFIG_ID, &live); + // A write that landed half-way: a valid PREFIX of the real pointer that + // is no longer valid JSON. (Chars, not a byte slice -- never split a + // codepoint.) + let truncated: String = pointer.chars().take(40).collect(); + assert!( + serde_json::from_str::(&truncated).is_err(), + "fixture must be unparseable to exercise the classifier: {truncated}" + ); + + let mut listing = vec![( + TEST_CONFIG_ID.to_owned(), + stamp_secs_ago(999_999), + truncated, + )]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let err = run_gc(dir.path(), 1, false).expect_err("must fail closed"); + assert!( + err.contains("refusing to reclaim"), + "expected a fail-closed refusal, got: {err}" + ); + for key in &live_chunks { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "nothing may be deleted when a root is unclassifiable: `{key}`" + ); + } + } + + /// an ENVELOPED listing (`{"items":[...]}`) may carry + /// pagination we do not follow. A page that omitted a root would make that + /// root's live chunks look orphaned -- and the completeness guard cannot see + /// a root that isn't there. Refuse the shape outright. + #[cfg(unix)] + #[test] + fn gc_fails_closed_on_enveloped_listing() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 999_999)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); + let enveloped = format!( + r#"{{"items":{},"next_cursor":"abc"}}"#, + entry_list_json(&listing) + ); + + let fake = fake_fastly_gc_raw_list(TEST_CONFIG_ID, &enveloped, &oplog); + let _path = PathPrepend::new(fake.path()); + + let err = run_gc(dir.path(), 1, false).expect_err("must fail closed"); + assert!( + err.contains("bare array") && err.contains("Nothing was deleted"), + "expected a refusal naming the unsupported listing shape, got: {err}" + ); + assert!( + !fs::read_to_string(&oplog) + .unwrap_or_default() + .contains("delete "), + "an unsupported listing shape must delete nothing" + ); + } + + /// a root with an EMPTY value is as dangerous as a + /// missing one -- it would classify as "references nothing" and orphan its + /// live chunks. The listing parser rejects it before any reasoning. + #[cfg(unix)] + #[test] + fn gc_fails_closed_on_empty_root_value() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let live_chunks = chunk_keys_of(TEST_CONFIG_ID, &live); + let mut listing = vec![( + TEST_CONFIG_ID.to_owned(), + stamp_secs_ago(999_999), + String::new(), + )]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let err = run_gc(dir.path(), 1, false).expect_err("must fail closed"); + assert!( + err.contains("empty `item_value`"), + "expected a refusal naming the empty field, got: {err}" + ); + for key in &live_chunks { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "nothing may be deleted on an unreadable listing: `{key}`" + ); + } + } + + /// the orphan's OWN age is mandatory -- an old root does + /// not license deleting a chunk written seconds ago (e.g. by a concurrent + /// push that has not committed its pointer yet). Both ages must clear the + /// window; the more restrictive wins. + #[cfg(unix)] + #[test] + fn gc_retains_young_orphan_under_long_stable_root() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let fresh = gen_envelope("fresh"); + let fresh_chunks = chunk_keys_of(TEST_CONFIG_ID, &fresh); + + // The root's live config has been stable for a year -- so the live-config + // clock alone would happily reclaim. But these chunks were written 10s + // ago and no pointer names them yet. + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 31_536_000)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 31_536_000)); + listing.extend(listed_generation(TEST_CONFIG_ID, &fresh, 10)); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + run_gc(dir.path(), 86_400, false).expect("gc succeeds"); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + for key in &fresh_chunks { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "a chunk written 10s ago must be retained under a 1-day window regardless of \ + how stable its root is: `{key}`; log:\n{log}" + ); + } + } + + /// GC and the RUNTIME must agree on what a readable pointer is. A pointer + /// whose chunks reassemble to the correct bytes along boundaries this writer + /// would never choose is REJECTED by the runtime resolver, so GC must not + /// silently report it as a healthy root: the guest cannot read it, and its + /// generation can never satisfy `prove_generation`, so it is permanently + /// unreclaimable. GC still keeps it (fail-closed) but must SAY so. + #[cfg(unix)] + #[test] + fn gc_warns_that_a_non_writer_split_root_is_not_runtime_readable() { + use crate::chunked_config::CHUNK_PAYLOAD_TARGET; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + // Just over the entry limit => a full chunk plus a short remainder with + // room to absorb the shifted bytes. + let envelope = gen_envelope("shifted"); + let sha = sha256_hex(envelope.as_bytes()); + // Re-split 2 bytes early: still within every metadata bound the pointer + // validator checks, but NOT where this writer splits. + let cut = CHUNK_PAYLOAD_TARGET.saturating_sub(2); + let head = envelope.get(..cut).expect("ascii boundary"); + let tail = envelope.get(cut..).expect("ascii boundary"); + let key0 = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{sha}.0"); + let key1 = format!("{TEST_CONFIG_ID}{CHUNK_KEY_INFIX}{sha}.1"); + let pointer_json = serde_json::json!({ + "chunks": [ + {"key": key0, "len": head.len(), "sha256": sha256_hex(head.as_bytes())}, + {"key": key1, "len": tail.len(), "sha256": sha256_hex(tail.as_bytes())}, + ], + "data_sha256": "", + "edgezero_kind": "fastly_config_chunks", + "envelope_len": envelope.len(), + "envelope_sha256": sha, + "version": 1_u8, + }) + .to_string(); + + let stamp = stamp_secs_ago(604_800); + let listing = vec![ + (TEST_CONFIG_ID.to_owned(), stamp.clone(), pointer_json), + (key0.clone(), stamp.clone(), head.to_owned()), + (key1.clone(), stamp, tail.to_owned()), + ]; + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let out = run_gc(dir.path(), 86_400, false).expect("gc must not abort on such a root"); + let rendered = out.join("\n"); + assert!( + rendered.contains("NOT runtime-readable"), + "GC must warn that this root is unreadable rather than call it healthy: {rendered}" + ); + // This root is PROTECTED (kept) but not runtime-live, so the report must + // list it as RETAINED and must NOT label it (or the store) "live". + assert!( + rendered.contains(&format!("keeping `{TEST_CONFIG_ID}`")) + && rendered.contains("retained root(s)"), + "an unreadable-but-protected root must be reported as retained: {rendered}" + ); + assert!( + !rendered.contains("live root") && !rendered.contains("live chunk"), + "an unreadable root's chunks are protected/referenced, never labeled live: {rendered}" + ); + // Fail-closed: nothing is deleted, including its chunks. + let log = fs::read_to_string(&oplog).unwrap_or_default(); + for key in [&key0, &key1] { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "must not delete a chunk of an unreadable root: `{key}`; log:\n{log}" + ); + } + } + + #[test] + fn kept_roots_report_wording_counts_and_empty_store() { + // Empty: a single, unambiguous "nothing retained" line and no root list. + let mut empty = Vec::new(); + append_kept_roots_report(&mut empty, &[], 0); + assert_eq!(empty, vec!["keeping 0 retained root(s)".to_owned()]); + + // Non-empty: a heading naming the RETAINED-root count and the + // REFERENCED-chunk count, then one line per root by key. + let mut out = Vec::new(); + append_kept_roots_report( + &mut out, + &["app_config".to_owned(), "app_config_staging".to_owned()], + 5, + ); + assert!( + out[0].contains("keeping 2 retained root(s)") + && out[0].contains("5 referenced chunk(s)"), + "heading names the retained-root and referenced-chunk counts: {out:?}" + ); + assert!(out.iter().any(|line| line == " keeping `app_config`")); + assert!( + out.iter() + .any(|line| line == " keeping `app_config_staging`") + ); + // Never the misleading "live" label -- a retained root may not be + // runtime-live, and its chunks are protected/referenced, not live. + assert!( + !out.iter() + .any(|line| line.contains("live root") || line.contains("live chunk")), + "must not label retained roots/chunks as live: {out:?}" + ); + } + + /// A legitimate FOREIGN sibling (the documented `greeting = "hello"`) must + /// NOT block store-wide GC. The runtime returns such a value verbatim, so GC + /// protects it as a zero-reference root and still reclaims an unrelated dead + /// generation, rather than aborting the whole pass. + #[cfg(unix)] + #[test] + fn gc_reclaims_despite_a_foreign_sibling_value() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let dead = gen_envelope("dead"); + let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); + + let mut listing = vec![ + listed_root(TEST_CONFIG_ID, &live, 172_800), + // A plain, non-envelope, non-pointer sibling entry. + ( + "greeting".to_owned(), + stamp_secs_ago(172_800), + "hello".to_owned(), + ), + ]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + run_gc(dir.path(), 86_400, false).expect("a foreign sibling must not abort GC"); + for key in &dead_chunks { + assert!( + oplog_has(&oplog, &format!("delete {key}")), + "the dead generation must still be reclaimed: `{key}`" + ); + } + assert!( + !oplog_has(&oplog, "delete greeting"), + "the foreign sibling must never be deleted" + ); + } + + /// A DIRECT envelope from a NEWER writer at an ordinary key classifies as + /// `Foreign` (no `edgezero_kind`), so without the future-format guard GC would + /// wave it through as a zero-reference root and reclaim an otherwise-dead + /// generation -- yet the newer format may reference those chunks under a + /// scheme this build cannot read. GC must FAIL CLOSED and delete nothing. + #[cfg(unix)] + #[test] + fn gc_fails_closed_on_a_future_direct_envelope_at_an_ordinary_key() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let dead = gen_envelope("dead"); + + // Envelope-shaped, no discriminator, VERSION 2 -> a newer direct envelope. + let future = r#"{"data":{"x":1},"sha256":"0000000000000000000000000000000000000000000000000000000000000000","generated_at":"2026-01-01T00:00:00Z","version":2}"#; + let mut listing = vec![( + "app_config".to_owned(), + stamp_secs_ago(172_800), + future.to_owned(), + )]; + listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let result = run_gc(dir.path(), 86_400, false); + assert!( + result.is_err(), + "a future direct envelope must abort GC (fail closed)" + ); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + assert!( + !log.lines().any(|line| line.starts_with("delete ")), + "nothing may be deleted when GC fails closed; log:\n{log}" + ); + } + + /// A valid v1 pointer whose chunks reassemble to a NEWER inner format. GC can + /// validate the outer pointer and reassemble the bytes, but the reassembled + /// value may reference generations this build cannot see, so trusting only the + /// outer chunks as the live set could delete live data. GC must fail closed -- + /// `BlobEnvelope` deserialize alone would silently ignore the newer format. + #[cfg(unix)] + #[test] + fn gc_fails_closed_on_a_future_inner_generation() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + // A v1 envelope, chunked, then its inner `version` bumped to 2. The v1 + // pointer's content-address still matches the reassembled (v2) bytes. + let v1 = gen_envelope("live"); + let mut v2_value: serde_json::Value = serde_json::from_str(&v1).expect("parse"); + v2_value["version"] = serde_json::json!(2_u32); + let v2 = v2_value.to_string(); + + let mut listing = vec![listed_root(TEST_CONFIG_ID, &v2, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &v2, 172_800)); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let result = run_gc(dir.path(), 86_400, false); + assert!( + result + .as_ref() + .is_err_and(|err| err.contains("newer format")), + "a future inner generation must abort GC with a newer-format error: {result:?}" + ); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + assert!( + !log.lines().any(|line| line.starts_with("delete ")), + "nothing may be deleted when GC fails closed; log:\n{log}" + ); + } + + /// A dry-run lists exactly what it would delete, and deletes nothing. + #[cfg(unix)] + #[test] + fn gc_dry_run_lists_but_deletes_nothing() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let dead = gen_envelope("dead"); + let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); + + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let out = run_gc(dir.path(), 86_400, true).expect("dry-run succeeds"); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + assert!( + !log.lines().any(|line| line.starts_with("delete ")), + "a dry-run must not delete; log:\n{log}" + ); + let rendered = out.join("\n"); + assert!( + rendered.contains("would delete"), + "lists intent: {rendered}" + ); + for key in &dead_chunks { + assert!(rendered.contains(key.as_str()), "names `{key}`: {rendered}"); + } + // It must also report what it is KEEPING: the live root, by key. + assert!( + rendered.contains(&format!("keeping `{TEST_CONFIG_ID}`")), + "must name the retained live root: {rendered}" + ); + } + + /// An unreadable `created_at` on a DELETE path fails CLOSED. + #[cfg(unix)] + #[test] + fn gc_fails_closed_on_unreadable_timestamp() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let dead = gen_envelope("dead"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 3_600)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 3_600)); + // An orphan whose timestamp is garbage. + let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); + for key in dead_chunks { + listing.push((key, "not-a-timestamp".to_owned(), "X".to_owned())); + } + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let err = run_gc(dir.path(), 86_400, false).expect_err("must fail closed"); + assert!( + err.contains("unreadable") && err.contains("nothing was deleted"), + "must refuse to reclaim: {err}" + ); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + assert!( + !log.lines().any(|line| line.starts_with("delete ")), + "nothing may be deleted when the state is unreadable; log:\n{log}" + ); + } + + /// A root whose pointer cannot be classified fails CLOSED — we cannot know + /// what it references, so nothing may be deleted. + #[cfg(unix)] + #[test] + fn gc_fails_closed_on_unclassifiable_root() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let dead = gen_envelope("dead"); + // Root value is pointer-kind but invalid. + let bad = r#"{"edgezero_kind":"fastly_config_chunks","version":2}"#.to_owned(); + let mut listing = vec![(TEST_CONFIG_ID.to_owned(), stamp_secs_ago(3_600), bad)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let err = run_gc(dir.path(), 86_400, false).expect_err("must fail closed"); + assert!( + err.contains("could not classify root") && err.contains("nothing was deleted"), + "must refuse to reclaim: {err}" + ); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + assert!( + !log.lines().any(|line| line.starts_with("delete ")), + "nothing may be deleted when a root is unclassifiable; log:\n{log}" + ); + } + + /// A listing entry missing a required field fails CLOSED — a defaulted/empty + /// field could make a real root look like it references nothing, deleting + /// live chunks. + #[cfg(unix)] + #[test] + fn gc_fails_closed_on_malformed_listing_entry() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + let good = entry_list_json(&listing); + // Inject an entry with NO item_value (drop that field entirely). + let mut array: serde_json::Value = serde_json::from_str(&good).unwrap(); + array.as_array_mut().unwrap().push(serde_json::json!({ + "item_key": "some.__edgezero_chunks.deadbeef.0", + "created_at": stamp_secs_ago(1000), + })); + // Serve that hand-built listing. + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + fs::write( + fake.path().join("entries.json"), + serde_json::to_string(&array).unwrap(), + ) + .expect("overwrite entries"); + let _path = PathPrepend::new(fake.path()); + + let err = run_gc(dir.path(), 86_400, false).expect_err("must fail closed"); + assert!( + err.contains("missing a string") && err.contains("item_value"), + "must name the missing field: {err}" + ); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + assert!( + !log.lines().any(|line| line.starts_with("delete ")), + "nothing may be deleted on a malformed listing; log:\n{log}" + ); + } + + /// A failed delete is a non-zero exit that names the failed key(s), so + /// automation can detect partial failure. + #[cfg(unix)] + #[test] + fn gc_delete_failure_is_non_zero_exit() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let dead = gen_envelope("dead"); + let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); + let fail_key = dead_chunks.first().expect("a chunk").clone(); + + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); + + let fake = fake_fastly_gc( + TEST_CONFIG_ID, + &[], + &listing, + Some(&fail_key), + false, + &oplog, + ); + let _path = PathPrepend::new(fake.path()); + + let err = run_gc(dir.path(), 86_400, false).expect_err("a failed delete must be non-zero"); + assert!( + err.contains("deletes FAILED") && err.contains(&fail_key), + "error names the failed key: {err}" + ); + } + + /// Every reclamation delete passes `--key` + `--auto-yes` and NEVER `--all`. + #[cfg(unix)] + #[test] + fn gc_delete_uses_key_and_auto_yes_never_all() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let dead = gen_envelope("dead"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + run_gc(dir.path(), 86_400, false).expect("gc succeeds"); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + let argv_lines: Vec<&str> = log + .lines() + .filter(|line| line.starts_with("delete-argv ")) + .collect(); + assert!(!argv_lines.is_empty(), "a delete happened: {log}"); + for line in argv_lines { + assert!( + line.contains("--auto-yes"), + "delete passes --auto-yes: {line}" + ); + assert!(line.contains("--key="), "delete targets a --key: {line}"); + assert!( + !line.contains("--all"), + "delete must NEVER pass --all: {line}" + ); + } + } + + /// A non-canonical chunk-like key (short/uppercase SHA, leading-zero index) + /// is NOT a delete candidate — the destructive validator is canonical-only. + #[cfg(unix)] + #[test] + fn gc_never_deletes_non_canonical_keys() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + + let live = gen_envelope("live"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + // Foreign-shaped keys under the reserved infix but not canonical. + let noncanonical = [ + format!("{TEST_CONFIG_ID}.__edgezero_chunks.abc123.0"), // short sha + format!("{TEST_CONFIG_ID}.__edgezero_chunks.{}.00", "a".repeat(64)), // leading-zero idx + format!("{TEST_CONFIG_ID}.__edgezero_chunks.{}.0", "A".repeat(64)), // uppercase + ]; + for key in &noncanonical { + listing.push((key.clone(), stamp_secs_ago(604_800), "X".to_owned())); + } + + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + // A key that is NOT canonical is not one we wrote, so it is not a + // reclamation candidate. It sits in our reserved namespace, though, so + // it is also not an ordinary root: we cannot say what it is. Since the + // GC classifier fails closed on any root it cannot classify, the run + // aborts and names it -- which satisfies this test's invariant (a + // non-canonical key is never deleted) the strict way. + let err = run_gc(dir.path(), 86_400, false).expect_err("must fail closed"); + assert!( + err.contains("refusing to reclaim"), + "expected a fail-closed refusal, got: {err}" + ); + let log = fs::read_to_string(&oplog).unwrap_or_default(); + for key in &noncanonical { + assert!( + !oplog_has(&oplog, &format!("delete {key}")), + "a non-canonical key must never be deleted: `{key}`; log:\n{log}" + ); + } + assert!( + !log.contains("delete "), + "a fail-closed run deletes nothing at all; log:\n{log}" + ); + } +} diff --git a/crates/edgezero-adapter-fastly/src/cli/mod.rs b/crates/edgezero-adapter-fastly/src/cli/mod.rs new file mode 100644 index 00000000..005a2cca --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/mod.rs @@ -0,0 +1,1424 @@ +use std::collections::HashSet; +use std::fs; +use std::io::{ErrorKind, Write as _}; +use std::path::{Path, PathBuf}; +use std::process::id as process_id; + +use ctor::ctor; +use edgezero_adapter::cli_support; +use edgezero_adapter::cli_support::run_native_cli; +use edgezero_adapter::registry::{ + Adapter, AdapterAction, AdapterDeployedState, AdapterExecContext, AdapterPushContext, + ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, + TypedSecretEntry, register_adapter, +}; +use edgezero_adapter::scaffold::{ + AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, + ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, +}; + +use crate::chunked_config::{ + CHUNK_KEY_INFIX, ResolveFailure, chunk_key_generation, gc_classify_root, + prepare_fastly_config_entries, value_announces_our_kind, value_is_future_format, +}; + +mod gc; +mod provision_cloud; +mod provision_local; +mod push_cloud; +mod push_local; +mod run; +#[cfg(test)] +mod test_support; + +/// Fastly's INTERNAL runtime-override config store. Provision creates it and +/// writes the `__NAME` / `__KEY` overlays into it (local Viceroy block and the +/// cloud config-store). A user-declared store resolving to the SAME platform +/// name would be merged into it, cross-contaminating operator config with the +/// runtime overrides -- so the name is reserved. +pub(super) const RUNTIME_ENV_STORE_NAME: &str = "edgezero_runtime_env"; + +/// Reject a SINGLE resolved store whose PLATFORM name collides with the +/// reserved [`RUNTIME_ENV_STORE_NAME`]. Used by the config read/write +/// dispatch so a runtime env overlay can't route application config into the +/// internal runtime-override store (which provision manages) and overwrite +/// runtime configuration. +fn reject_reserved_store(store: &ResolvedStoreId) -> Result<(), String> { + if store.platform == RUNTIME_ENV_STORE_NAME { + return Err(format!( + "fastly: store `{}` (platform name `{}`) collides with the reserved runtime-override config store `{RUNTIME_ENV_STORE_NAME}` that EdgeZero manages. Rename the store id or its `EDGEZERO__STORES__..__NAME` override.", + store.logical, store.platform + )); + } + Ok(()) +} + +/// Reject any declared store whose PLATFORM name collides with the reserved +/// [`RUNTIME_ENV_STORE_NAME`], BEFORE provision writes anything. Shared by the +/// local and cloud provision arms. +pub(super) fn reject_reserved_store_names(stores: &ProvisionStores<'_>) -> Result<(), String> { + for (kind, group) in [ + ("kv", stores.kv), + ("config", stores.config), + ("secrets", stores.secrets), + ] { + for store in group { + if store.platform == RUNTIME_ENV_STORE_NAME { + return Err(format!( + "fastly: {kind} store `{}` (platform name `{}`) collides with the reserved runtime-override config store `{RUNTIME_ENV_STORE_NAME}` that provision manages. Rename the store id or its `EDGEZERO__STORES__{}__..__NAME` override.", + store.logical, + store.platform, + kind.to_ascii_uppercase(), + )); + } + } + } + Ok(()) +} + +static FASTLY_ADAPTER: FastlyCliAdapter = FastlyCliAdapter; + +static FASTLY_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { + id: "fastly", + display_name: "Fastly Compute@Edge", + crate_suffix: "adapter-fastly", + dependency_crate: "edgezero-adapter-fastly", + dependency_repo_path: "crates/edgezero-adapter-fastly", + template_registrations: FASTLY_TEMPLATE_REGISTRATIONS, + files: FASTLY_FILE_SPECS, + extra_dirs: &["src", ".cargo"], + dependencies: FASTLY_DEPENDENCIES, + manifest: ManifestSpec { + manifest_filename: "fastly.toml", + build_target: "wasm32-wasip1", + build_profile: "release", + build_features: &["fastly"], + }, + commands: CommandTemplates { + build: "fastly compute build -C {crate_dir}", + deploy: "fastly compute deploy -C {crate_dir}", + serve: "fastly compute serve -C {crate_dir}", + emit_commands: true, + }, + logging: LoggingDefaults { + endpoint: Some("stdout"), + level: "info", + echo_stdout: Some(true), + }, + readme: ReadmeInfo { + description: "{display} entrypoint.", + dev_heading: "{display} (local)", + dev_steps: &["`cd {crate_dir}`", "`edgezero serve --adapter fastly`"], + }, + run_module: "edgezero_adapter_fastly", +}; + +static FASTLY_DEPENDENCIES: &[DependencySpec] = &[ + DependencySpec { + key: "dep_edgezero_core_fastly", + repo_crate: "crates/edgezero-core", + fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\", default-features = false }", + features: &[], + }, + DependencySpec { + key: "dep_edgezero_adapter_fastly", + repo_crate: "crates/edgezero-adapter-fastly", + fallback: "edgezero-adapter-fastly = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-fastly\", default-features = false }", + features: &[], + }, + DependencySpec { + key: "dep_edgezero_adapter_fastly_wasm", + repo_crate: "crates/edgezero-adapter-fastly", + fallback: "edgezero-adapter-fastly = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-fastly\", default-features = false, features = [\"fastly\"] }", + features: &["fastly"], + }, +]; + +// `fastly.toml` is intentionally absent from the scaffold +// registration — same rationale as Axum, Cloudflare, and Spin. +// The scaffold-time provision loop +// (`generator::provision_all_selected_adapters` -> +// `Adapter::synthesise_baseline_manifest` -> `run::synthesise_fastly_toml`) +// is the single writer. Registering a scaffold template would +// make the file exist before provision runs; provision's +// `write_baseline_to_disk` skips files that already exist (spec § +// "Adapter manifests are gitignored"), so `edgezero new` and +// clean-clone `provision --local` would diverge. +static FASTLY_FILE_SPECS: &[AdapterFileSpec] = &[ + AdapterFileSpec { + template: "fastly_Cargo_toml", + output: "Cargo.toml", + }, + AdapterFileSpec { + template: "fastly_src_main_rs", + output: "src/main.rs", + }, + AdapterFileSpec { + template: "fastly_cargo_config_toml", + output: ".cargo/config.toml", + }, +]; + +static FASTLY_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ + TemplateRegistration { + name: "fastly_Cargo_toml", + contents: include_str!("../templates/Cargo.toml.hbs"), + }, + TemplateRegistration { + name: "fastly_src_main_rs", + contents: include_str!("../templates/src/main.rs.hbs"), + }, + TemplateRegistration { + name: "fastly_cargo_config_toml", + contents: include_str!("../templates/.cargo/config.toml.hbs"), + }, +]; + +pub(super) const FASTLY_INSTALL_HINT: &str = "install the Fastly CLI (https://www.fastly.com/documentation/reference/tools/cli/) and try again"; + +pub(super) struct FastlyCliAdapter; + +/// Outcome of scanning `fastly config-store list --json` for a +/// platform store id by `name`. Distinguishes three cases the +/// caller wants to act on differently: +/// +/// - `Found(id)` — happy path. +/// - `NotFound` — JSON parsed cleanly and the array contains +/// entries with well-formed `name` + `id` string fields, but no +/// entry matched `name`. Operator likely needs to run +/// `provision`. +/// - `SchemaDrift(detail)` — the JSON parsed but doesn't match +/// the expected shape (no `items` envelope nor bare array, OR +/// entries are missing `name` / `id` string fields, OR the +/// bytes didn't parse as JSON at all). Likely a fastly CLI +/// version bump that changed the output schema; surface the +/// detail so the operator can pin a known-compatible version. +#[derive(Debug)] +pub(super) enum ConfigStoreLookup { + Found(String), + NotFound, + SchemaDrift(String), +} + +// The three `validate_*` trait methods exist on `Adapter` because +// spin requires them (variable-name regex, `[component.*]` +// discovery, flat-namespace collision). The trait surface is typed +// generically so any future adapter with similar constraints can +// override: +// +// - `validate_app_config_keys`: Fastly Config Store keys accept +// alphanumeric + `-` / `_` / `.` up to 256 chars. Any reasonable +// Rust struct field name passes; no regex check needed — no-op. +// - `validate_adapter_manifest`: would require shelling out to +// `fastly compute validate` at validate-time. We keep +// `config validate` pure-Rust so it stays fast and +// tool-independent — no-op. +// - `validate_typed_secrets`: IS implemented. Fastly's KV / Config +// / Secret stores are independent namespaces, so there is no +// spin-style flat-namespace collision on the CLOUD path. The +// LOCAL path has its own: `provision --local` derives each +// secret's Viceroy env var as `key.to_ascii_uppercase()`, which +// is lossy — see the impl for the collision this rejects. +impl Adapter for FastlyCliAdapter { + fn deployed_fields(&self) -> &'static [&'static str] { + &["service_id"] + } + + fn execute( + &self, + action: AdapterAction, + args: &[String], + ctx: &AdapterExecContext<'_>, + ) -> Result<(), String> { + match action { + // `fastly profile {create|delete|list}` is the native + // sign-in surface for Fastly Compute. EdgeZero stores no + // credentials — this is a thin shell-out. + AdapterAction::AuthLogin => { + run_native_cli("fastly", &["profile", "create"], FASTLY_INSTALL_HINT) + } + AdapterAction::AuthLogout => { + run_native_cli("fastly", &["profile", "delete"], FASTLY_INSTALL_HINT) + } + AdapterAction::AuthStatus => { + run_native_cli("fastly", &["profile", "list"], FASTLY_INSTALL_HINT) + } + AdapterAction::Build => { + let artifact = run::build(args, ctx)?; + log::info!("[edgezero] Fastly build complete -> {}", artifact.display()); + Ok(()) + } + AdapterAction::Deploy => run::deploy(args, ctx), + AdapterAction::Serve => run::serve(args, ctx), + other => Err(format!("fastly adapter does not support {other:?}")), + } + } + + fn gc_config_entries( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + _push_ctx: &AdapterPushContext<'_>, + older_than_secs: u64, + dry_run: bool, + ) -> Result, String> { + gc::gc_fastly_config_store(store.platform.as_str(), older_than_secs, dry_run) + } + + fn name(&self) -> &'static str { + "fastly" + } + + fn preflight_config_write(&self, key: &str, body: &str) -> Result<(), String> { + // Reject an infeasible push here, BEFORE the CLI's remote read, so it + // fails offline rather than after a list/describe. The write path + // re-checks, so this is a strict early gate, not the only one. + // + // An empty key is writer-valid but resolver-invalid (canonical chunk + // parsing rejects an empty root); reject it before any I/O. + if key.is_empty() { + return Err( + "config key is empty; provide a store id or a non-empty `--key`".to_owned(), + ); + } + let entry = [(key.to_owned(), String::new())]; + reject_reserved_root_keys(&entry)?; + // Run the full chunk expansion OFFLINE (no I/O): exactly what the write + // path does, so every body-dependent feasibility failure — the root key + // over the store limit, a DERIVED chunk key over it once the value + // chunks, or a pointer that would not fit the entry limit — is caught + // here, before the remote read, instead of after it. + prepare_fastly_config_entries(key, body)?; + Ok(()) + } + + // Fastly's KV / Config / Secret stores are independent + // namespaces — no flat-namespace merging like Spin. + #[inline] + fn merged_id_kinds(&self) -> &'static [&'static str] { + &[] + } + + // No spin-style multi-component discovery in fastly.toml; the + // adapter's per-manifest validation is deferred to + // `fastly compute validate` at deploy time. + #[inline] + fn validate_adapter_manifest( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _allow_component_refresh: bool, + ) -> Result<(), String> { + Ok(()) + } + + // Fastly Config Store keys accept alphanumeric + `-` / `_` / + // `.` up to 256 chars — any reasonable Rust field name passes. + #[inline] + fn validate_app_config_keys(&self, _keys: &[&str]) -> Result<(), String> { + Ok(()) + } + + // Fastly Secret Store keys share Config Store's naming rules, so + // the key itself needs no canonicalisation check. The LOCAL path + // does: `provision --local` writes each key into `fastly.toml` as + // `{ key = "", env = "" }`, where the env name is + // `key.to_ascii_uppercase()` -- Viceroy sources the secret's value + // from that variable. The env name has no store qualifier, so two + // DISTINCT (store, key) secrets that upper-case to the same name -- + // whether they differ by case (`api_token` / `API_TOKEN`) or by + // store (`store_a`/`api_token` vs `store_b`/`api_token`) -- both + // read `$API_TOKEN` and silently collapse to one value. Reject at + // validation rather than let a wrong secret be served. + fn validate_typed_secrets(&self, entries: &[TypedSecretEntry<'_>]) -> Result<(), String> { + use std::collections::HashMap; + // A secret's production identity is (store, key): the same key + // in two DIFFERENT stores is two DIFFERENT secrets whose values + // may differ. But `provision --local` derives the Viceroy env + // var from the key alone (`key.to_ascii_uppercase()`), with no + // store qualifier, so both would read the same `$KEY` and + // silently resolve to one value. Reject any two DISTINCT + // (store, key) pairs that collide on the same env var -- whether + // they differ by case (`api_token` / `API_TOKEN`) or by store + // (`store_a`/`api_token` vs `store_b`/`api_token`). The same + // (store, key) referenced twice is fine (one secret, two refs). + let mut seen: HashMap = HashMap::with_capacity(entries.len()); + for entry in entries { + let env_name = entry.key_value.to_ascii_uppercase(); + if let Some((prev_store, prev_key, prev_field)) = seen.get(&env_name) { + if (*prev_store, *prev_key) != (entry.store_id, entry.key_value) { + return Err(format!( + "`#[secret]` fields `{prev_field}` (store `{prev_store}`, key `{prev_key}`) \ + and `{this_field}` (store `{this_store}`, key `{this_key}`) both map to the \ + Viceroy environment variable `{env_name}` in `fastly.toml` -- provision \ + derives it by upper-casing the key with no store qualifier, so these two \ + DISTINCT secrets would resolve to a single value. Pick keys that differ by \ + more than case, even across stores.", + this_field = entry.field_name, + this_store = entry.store_id, + this_key = entry.key_value, + )); + } + } else { + seen.insert( + env_name, + (entry.store_id, entry.key_value, entry.field_name.as_str()), + ); + } + } + Ok(()) + } + + 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 { + match mode { + ProvisionMode::Local => provision_local::provision( + manifest_root, + adapter_manifest_path, + stores, + deployed, + dry_run, + ), + ProvisionMode::Cloud => provision_cloud::provision( + manifest_root, + adapter_manifest_path, + stores, + deployed, + dry_run, + ), + // ProvisionMode is #[non_exhaustive]; a future mode variant + // is an explicit error so we don't dispatch via one of the + // two known arms by accident. + other => Err(format!( + "fastly adapter does not implement provision mode {other:?}" + )), + } + } + + fn provision_typed( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + typed_secrets: &[TypedSecretEntry<'_>], + mode: ProvisionMode, + dry_run: bool, + ) -> Result { + // Cloud secret storage uses `fastly secret-store-entry create` + // at deploy time. Local mode delegates to `provision_local` + // which seeds Viceroy's `[[local_server.secret_stores.]]` + // array-of-tables — cloud mode is a documented no-op. + if !matches!(mode, ProvisionMode::Local) { + return Ok(ProvisionOutcome::default()); + } + provision_local::provision_typed( + manifest_root, + adapter_manifest_path, + typed_secrets, + dry_run, + ) + } + + fn push_config_entries( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + reject_reserved_store(store)?; + push_cloud::write_entries(store, entries, dry_run) + } + + fn push_config_entries_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + reject_reserved_store(store)?; + push_local::write_entries( + manifest_root, + adapter_manifest_path, + store, + entries, + dry_run, + ) + } + + fn read_config_entry( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + key: &str, + _push_ctx: &AdapterPushContext<'_>, + ) -> Result { + reject_reserved_store(store)?; + push_cloud::read_entry(store, key) + } + + fn read_config_entry_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + key: &str, + _push_ctx: &AdapterPushContext<'_>, + ) -> Result { + reject_reserved_store(store)?; + push_local::read_entry(manifest_root, adapter_manifest_path, store, key) + } + + fn single_store_kinds(&self) -> &'static [&'static str] { + // Explicit `&[]` rather than inheriting the trait default, + // so the "Multi for every store kind" intent is documented + // at the call site. Fastly KV / Config / Secrets all + // support multiple distinct platform resources per kind, + // unlike spin's flat-namespace single-store model. + &[] + } + + fn synthesise_baseline_manifest( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + adapter_crate_path: Option<&str>, + _component_selector: Option<&str>, + app_name: &str, + deployed: Option<&AdapterDeployedState>, + _allowed_outbound_hosts: &[String], + ) -> Result, String> { + // The CLI's `deployed_state_for` translator copies + // `[adapters.fastly.deployed].service_id` into + // `deployed.fields["service_id"]` before calling this override, + // so the adapter reads the flat field bag and never links to + // `edgezero-core`. + let deployed_service_id = deployed + .and_then(|state| state.fields.get("service_id")) + .map(String::as_str); + let rel = adapter_manifest_path.map_or_else(|| PathBuf::from("fastly.toml"), PathBuf::from); + // Prefer the ACTUAL adapter crate name. The authoritative source + // is the declared `[adapters.fastly.adapter].crate`; fall back to + // the ancestor `Cargo.toml` search only when it's undeclared, and + // finally to the scaffold convention. (An ancestor search alone + // could pick a nested package between the manifest and the crate.) + let crate_name = match cli_support::read_crate_name_at(manifest_root, adapter_crate_path)? { + Some(name) => name, + None => cli_support::read_adapter_crate_name(manifest_root, adapter_manifest_path) + .unwrap_or_else(|| { + if app_name.is_empty() { + "app-adapter-fastly".to_owned() + } else { + format!("{app_name}-adapter-fastly") + } + }), + }; + Ok(vec![( + rel, + run::synthesise_fastly_toml(&crate_name, deployed_service_id), + )]) + } +} + +/// Hard-error message for a value written by a NEWER format this v1 CLI must not +/// overwrite. Shared by the read paths so the wording stays consistent. +const FUTURE_FORMAT_READ_ERROR: &str = "the remote value uses a config format this CLI version does not recognise (a newer \ + `edgezero_kind` or envelope/pointer version); UPGRADE the CLI to push to this store rather \ + than overwrite a newer format."; + +/// An exclusive, cross-process advisory lock covering a local `fastly.toml` +/// rewrite. Serialises concurrent pushes so their read-modify-write cycles +/// cannot interleave and lose each other's edits. +/// +/// The lock is a persistent sidecar file next to the manifest. It is never +/// unlinked — deleting it would reintroduce a create/lock race between two +/// processes each making their own lock file. Dropping the guard releases the +/// OS lock (closing the file descriptor). `File::lock` is advisory, so it only +/// coordinates other lockers, which is exactly the pushes we control. +pub(super) struct ManifestLock { + _file: fs::File, + /// The REAL file the lock guards, resolved through any symlink. Callers read + /// and replace THIS path, so every alias operates on one target. + target: PathBuf, +} + +/// Removes a staging temp file on drop unless disarmed — so every early return +/// (permission failure, write failure, rename failure) cleans up after itself. +struct TempFileGuard { + path: Option, +} + +impl ManifestLock { + pub(super) fn acquire(manifest_path: &Path) -> Result { + // Key the lock on the REAL target, so a symlinked manifest and a direct + // path to the same file acquire the SAME lock rather than two different + // sidecars. Every manifest writer (config push AND provision) takes this + // lock, so their read-modify-writes serialise instead of clobbering. + let target = canonical_manifest_target(manifest_path)?; + // A hard-linked manifest cannot be safely replaced: two hard links share + // one inode but have distinct pathnames, so they key DIFFERENT sidecar + // locks (no mutual exclusion), and the atomic rename swaps in a NEW inode, + // breaking the link. We cannot detect the other names, so fail closed + // rather than silently diverge or break the link. + reject_hard_linked_manifest(&target)?; + let dir = target.parent().unwrap_or_else(|| Path::new(".")); + let file_name = target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("fastly.toml"); + let lock_path = dir.join(format!(".{file_name}.edgezero-lock")); + let file = fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&lock_path) + .map_err(|err| format!("failed to open lock file {}: {err}", lock_path.display()))?; + // Blocks until any other writer holding the lock releases it. + file.lock() + .map_err(|err| format!("failed to lock {}: {err}", lock_path.display()))?; + // Re-check AFTER the (possibly long) lock wait: a hard link created while + // we blocked would not have been visible to the pre-lock check above. The + // replacement path re-checks once more immediately before the rename. + reject_hard_linked_manifest(&target)?; + Ok(Self { + _file: file, + target, + }) + } + + /// The real file this lock guards. Callers read and replace THIS path. + pub(super) fn target(&self) -> &Path { + &self.target + } +} + +impl TempFileGuard { + fn disarm(&mut self) { + self.path = None; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if let Some(path) = &self.path { + let _cleanup = fs::remove_file(path); + } + } +} + +/// Resolve a manifest path to the REAL file every alias shares, so a symlink and +/// a direct path lock and replace the SAME target. An existing file (or symlink) +/// canonicalizes directly; a not-yet-created file canonicalizes via its parent +/// so a fresh `fastly.toml` still keys on a stable location. +/// +/// FAILS CLOSED on an ambiguous chain: a symlink whose target cannot be read, or +/// a chain too deep / cyclic, returns `Err` rather than falling back to a writable +/// path that could replace an intermediate link. +fn canonical_manifest_target(path: &Path) -> Result { + // Follow the WHOLE symlink chain to the final target -- each hop may itself be + // a dangling symlink (fastly.toml -> middle.toml -> missing.toml). We write at + // the final target, preserving every intermediate link, and a direct writer to + // that same target keys on the same lock. + let mut current = path.to_owned(); + // Bounded to avoid spinning on a symlink cycle (canonicalize would ELOOP). + for _ in 0..40_u32 { + // Fully resolvable => the real existing file. + if let Ok(real) = fs::canonicalize(¤t) { + return Ok(real); + } + // Otherwise, if this hop is a symlink, follow one link and continue. + match fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => match fs::read_link(¤t) { + Ok(link) => { + current = if link.is_absolute() { + link + } else { + // A relative link resolves against the DIRECTORY holding it. + current + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(link) + }; + } + // A symlink we cannot read: refuse rather than guess a target. + Err(err) => { + return Err(format!( + "could not read the manifest symlink `{}` ({err}); refusing to write", + current.display() + )); + } + }, + // Not a symlink -- a plain not-yet-created file, or the final dangling + // target: this is where the write should land. + _ => return Ok(canonicalize_parent_join(¤t)), + } + } + // Exhausted the hop budget: a cyclic or absurdly deep chain. Fail closed. + Err(format!( + "the manifest symlink chain starting at `{}` is too deep or cyclic; refusing to write", + path.display() + )) +} + +/// Canonicalize `path`'s PARENT (which should exist) and rejoin the file name, +/// so a not-yet-created file still resolves to a stable absolute location. +fn canonicalize_parent_join(path: &Path) -> PathBuf { + let parent = match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + }; + let file_name = path.file_name().unwrap_or(path.as_os_str()); + match fs::canonicalize(parent) { + Ok(real_parent) => real_parent.join(file_name), + Err(_) => path.to_owned(), + } +} + +/// Refuse to operate on a manifest that has MORE THAN ONE hard link. Such a file +/// cannot be replaced safely: the atomic rename installs a new inode (breaking +/// the link), and the path-based lock cannot serialise writers arriving via the +/// other names. Fail closed with a fix. A not-yet-created file, or a filesystem +/// that does not report a link count, is left alone. +fn reject_hard_linked_manifest(target: &Path) -> Result<(), String> { + #[cfg(unix)] + let link_count: Option = { + use std::os::unix::fs::MetadataExt as _; + fs::metadata(target).ok().map(|meta| meta.nlink()) + }; + #[cfg(windows)] + let link_count: Option = { + use std::os::windows::fs::MetadataExt as _; + fs::metadata(target) + .ok() + .and_then(|meta| meta.number_of_links()) + .map(u64::from) + }; + #[cfg(not(any(unix, windows)))] + let link_count: Option = None; + + if let Some(count) = link_count + && count > 1 + { + return Err(format!( + "{} has multiple hard links (link count {count}); refusing to replace it -- an atomic \ + rename would break the link and concurrent writers via the other names could \ + diverge. Remove the extra hard link(s), or use a symlink instead.", + target.display(), + )); + } + Ok(()) +} + +/// Replace an already-canonical `target`'s contents ATOMICALLY. Callers pass +/// [`ManifestLock::target`] and hold the lock across the surrounding +/// read-modify-write, so this is not racing another writer; the re-read + compare +/// is a defence-in-depth corruption check, not the concurrency guard. +pub(super) fn atomically_replace_file( + target: &Path, + expected_before: &str, + contents: &str, +) -> Result<(), String> { + let current = match fs::read_to_string(target) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => String::new(), + Err(err) => return Err(format!("failed to re-read {}: {err}", target.display())), + }; + if current != expected_before { + return Err(format!( + "{} changed on disk while this write was preparing its rewrite; nothing was written. \ + Re-run to pick up the other change.", + target.display() + )); + } + + let dir = target.parent().unwrap_or_else(|| Path::new(".")); + let file_name = target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("fastly.toml"); + // Create a staging file that CANNOT be an attacker's pre-planted symlink: + // `create_new` fails if the path already exists (regular file or symlink), so + // we retry successive names until we own a fresh inode. + let mut attempt = 0_u32; + let (tmp_path, mut tmp_file) = loop { + let candidate = dir.join(format!( + ".{file_name}.edgezero-{}-{attempt}.tmp", + process_id() + )); + match fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&candidate) + { + Ok(file) => break (candidate, file), + Err(err) if err.kind() == ErrorKind::AlreadyExists => { + attempt = attempt.saturating_add(1); + if attempt > 1_024 { + return Err(format!( + "could not create a staging temp file next to {}", + target.display() + )); + } + } + Err(err) => return Err(format!("failed to create staging temp file: {err}")), + } + }; + let mut guard = TempFileGuard { + path: Some(tmp_path.clone()), + }; + + // Match the target's permissions BEFORE writing any bytes, so config content + // never lands under wider permissions than the manifest already had. A brand + // NEW manifest (NotFound) keeps the create default -- nothing to preserve -- + // but any OTHER metadata error means the target EXISTS yet we cannot read its + // mode, so we must NOT silently widen: fail rather than guess. + match fs::metadata(target) { + Ok(meta) => tmp_file + .set_permissions(meta.permissions()) + .map_err(|err| format!("failed to set permissions on the staging temp file: {err}"))?, + Err(err) if err.kind() == ErrorKind::NotFound => {} + Err(err) => { + return Err(format!( + "failed to read the permissions of {} (refusing to widen access): {err}", + target.display() + )); + } + } + tmp_file + .write_all(contents.as_bytes()) + .map_err(|err| format!("failed to write the staging temp file: {err}"))?; + // Flush to disk BEFORE the rename. A writeback error (ENOSPC/EIO) must surface + // HERE, while the known-good manifest is still untouched -- NOT be swallowed + // so the command "succeeds" after installing content that never reached disk. + // The guard removes the temp on this error. + tmp_file + .sync_all() + .map_err(|err| format!("failed to flush the staging temp file to disk: {err}"))?; + drop(tmp_file); + + // Re-check the hard-link count IMMEDIATELY before the rename. The lock-acquire + // check ran before this write blocked on the lock, and a hard link created + // during that wait (or since) would survive the byte comparison above only for + // the rename to break the alias. This is the last moment we can fail closed. + reject_hard_linked_manifest(target)?; + + fs::rename(&tmp_path, target) + .map_err(|err| format!("failed to replace {}: {err}", target.display()))?; + guard.disarm(); + // Sync the containing directory so the rename entry itself survives a crash. + // Best-effort: opening a directory as a file is not portable (Windows), and + // the critical durability -- the file's contents -- is already flushed above. + if let Ok(dir_handle) = fs::File::open(dir) { + let _dir_sync = dir_handle.sync_all(); + } + Ok(()) +} + +/// Expand ONE logical `(root_key, body)` into its physical entries, the +/// exact keep-set for that root, and the value written at the root key. +/// No cross-root prefix scanning (a free-form `--key` can't mislead it). +#[expect( + clippy::type_complexity, + reason = "one-off internal return; a named type would not aid readability" +)] +pub(super) fn expand_root( + root_key: &str, + body: &str, +) -> Result<(Vec<(String, String)>, HashSet, String), String> { + let expanded = prepare_fastly_config_entries(root_key, body)?; + let new_keys: HashSet = expanded.iter().map(|(key, _)| key.clone()).collect(); + // prepare_* always emits the root entry LAST (root pointer or direct + // value). Make the invariant explicit rather than silently defaulting. + let new_root_value = expanded + .last() + .map(|(_, value)| value.clone()) + .ok_or_else(|| format!("internal: no physical entries produced for root `{root_key}`"))?; + Ok((expanded, new_keys, new_root_value)) +} + +/// Reject logical keys that collide with the reserved chunk namespace. +/// `--key` is free-form, so this is enforced at the Fastly adapter +/// boundary: such a key would let a push write into another key's chunk +/// space, and could not be reclaimed correctly. +pub(super) fn reject_reserved_root_keys(entries: &[(String, String)]) -> Result<(), String> { + for (key, _) in entries { + if key.contains(CHUNK_KEY_INFIX) { + return Err(format!( + "config key `{key}` contains the reserved infix `{CHUNK_KEY_INFIX}`, which collides with Fastly chunk storage; choose a different config key (or --key override)" + )); + } + } + Ok(()) +} + +/// Reject a batch that names the same logical root key more than once. +/// +/// GC builds one plan per entry and snapshots every plan against the SAME prior +/// generation. With `[(root, A), (root, B)]` the last tuple wins the upsert +/// (root = B), yet A's plan would still reclaim `prior - A_keys` — which includes +/// B's freshly-written chunks — leaving the final pointer referencing missing +/// chunks. Rejecting is safer than silently coalescing. +pub(super) fn reject_duplicate_root_keys(entries: &[(String, String)]) -> Result<(), String> { + let mut seen: HashSet<&str> = HashSet::with_capacity(entries.len()); + for (key, _) in entries { + if !seen.insert(key.as_str()) { + return Err(format!( + "config key `{key}` appears more than once in a single push; each logical key must be pushed exactly once" + )); + } + } + Ok(()) +} + +/// Refuse before writing if any GENERATED chunk key would clobber an existing +/// value that is itself ROOT-LIKE (announces our `edgezero_kind`, is a newer +/// format, or classifies as a valid root) or that has a NESTED generation beneath +/// it. Chunk keys are content-addressed, so such a collision is pathological, but +/// overwriting one would destroy live or foreign config -- so fail closed. +/// +/// Logical ROOT keys are excluded here; overwriting a root is governed by the +/// downgrade/future guards. `sibling_keys` is the complete set of existing store +/// keys (for the nested-generation check); `existing_value_at` fetches the value +/// at a colliding key (only called for keys already present). +pub(super) fn reject_generated_key_collisions( + entries: &[(String, String)], + sibling_keys: &HashSet, + mut existing_value_at: impl FnMut(&str) -> Result, String>, +) -> Result<(), String> { + for (key, _) in entries { + if !key.contains(CHUNK_KEY_INFIX) { + continue; // a logical root; the root-overwrite guards cover it + } + let has_nested_generation = sibling_keys + .iter() + .any(|other| other != key && chunk_key_generation(key, other).is_some()); + let clobbers_root_like = sibling_keys.contains(key) + && existing_value_at(key)?.is_some_and(|value| { + value_announces_our_kind(&value) + || value_is_future_format(&value) + || gc_classify_root(key, &value).is_ok() + }); + if has_nested_generation || clobbers_root_like { + return Err(format!( + "refusing to push: the generated chunk key `{key}` already holds a value that is \ + itself a root (or has a nested generation beneath it); overwriting it could \ + destroy live or foreign config. Nothing was changed." + )); + } + } + Ok(()) +} + +/// Does `body` parse AND integrity-verify as a `BlobEnvelope`? +/// +/// A value that is not a verifying envelope (invalid JSON, missing fields, or a +/// SHA mismatch) is corrupt FOR THE PUSH -- something to overwrite, not to diff +/// against. +fn body_is_valid_envelope(body: &str) -> bool { + use edgezero_core::blob_envelope::BlobEnvelope; + serde_json::from_str::(body).is_ok_and(|envelope| envelope.verify().is_ok()) +} + +/// Map a `resolve_fastly_config_value` result to a read outcome, distinguishing +/// the cases that must NOT be treated as overwritable corruption: +/// +/// - a FUTURE format → a hard error (checked FIRST): overwriting a newer format +/// with this v1 CLI would lose it. Detected on the raw stored value AND via a +/// typed [`ResolveFailure::FutureFormat`] from the resolver. +/// - a resolve error where a chunk FETCH failed for infrastructure reasons +/// (`fetch_failed`) → a hard error: the read was incomplete. +/// - `Ok(body)` that verifies as an envelope → `Present`. +/// - `Ok(body)` that is NOT a valid envelope, or any other resolve error → `Corrupt`. +pub(super) fn classify_resolved_read( + resolved: Result, + raw_value: &str, + fetch_failed: bool, +) -> Result { + if value_is_future_format(raw_value) + || resolved + .as_ref() + .err() + .is_some_and(ResolveFailure::is_future_format) + { + return Err(FUTURE_FORMAT_READ_ERROR.to_owned()); + } + match resolved { + // An INFRASTRUCTURE fetch failure: the read was incomplete, so a push must + // not overwrite. The resolver's message is already redacted. + Err(err) if fetch_failed => Err(format!( + "a chunk fetch failed while reading the remote value ({}); the remote was not fully \ + read, so nothing was changed. Fix connectivity/auth and retry.", + err.into_message() + )), + Ok(body) if body_is_valid_envelope(&body) => Ok(ReadConfigEntry::Present(body)), + Ok(_) => Ok(ReadConfigEntry::Corrupt( + "remote value is not a valid config envelope; a push will overwrite it", + )), + // A confirmed-absent chunk, a hash mismatch, or a malformed pointer. + Err(_) => Ok(ReadConfigEntry::Corrupt( + "remote prior value could not be resolved (corrupt or incomplete chunk state); a push \ + will overwrite it", + )), + } +} + +#[inline] +pub fn register() { + register_adapter(&FASTLY_ADAPTER); + register_adapter_blueprint(&FASTLY_BLUEPRINT); +} + +#[ctor(unsafe)] +fn register_ctor() { + register(); +} + +// Shared process-wide mutex serialising PATH-mutating tests across every +// submodule test suite in this crate. Tests in `provision_local`, `push_cloud`, +// etc. all install shell shims via `PathPrepend` and would otherwise race on +// the environment variable. +#[cfg(all(test, unix))] +use std::sync::Mutex as PathMutationMutex; + +#[cfg(all(test, unix))] +pub(crate) fn path_mutation_guard() -> &'static PathMutationMutex<()> { + use std::sync::OnceLock; + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| PathMutationMutex::new(())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::test_support::{TEST_CONFIG_ID, make_test_envelope}; + use edgezero_adapter::TypedSecretEntry; + use edgezero_adapter::registry::{AdapterPushContext, ResolvedStoreId}; + use std::path::Path; + use tempfile::tempdir; + + #[test] + fn config_dispatch_rejects_reserved_runtime_env_store() { + // An env overlay that routes a config store's platform name to the + // reserved `edgezero_runtime_env` must be refused at the read/write + // dispatch -- not just during provision -- so app config can't + // overwrite the internal runtime-override store. + let store = ResolvedStoreId::new("app_config", "edgezero_runtime_env"); + let ctx = AdapterPushContext::new(); + let entries = [("k".to_owned(), "v".to_owned())]; + for result in [ + FastlyCliAdapter + .read_config_entry(Path::new("."), Some("fastly.toml"), None, &store, "k", &ctx) + .map(|_| ()), + FastlyCliAdapter + .push_config_entries( + Path::new("."), + Some("fastly.toml"), + None, + &store, + &entries, + &ctx, + true, + ) + .map(|_| ()), + FastlyCliAdapter + .read_config_entry_local( + Path::new("."), + Some("fastly.toml"), + None, + &store, + "k", + &ctx, + ) + .map(|_| ()), + FastlyCliAdapter + .push_config_entries_local( + Path::new("."), + Some("fastly.toml"), + None, + &store, + &entries, + &ctx, + true, + ) + .map(|_| ()), + ] { + let Err(err) = result else { + panic!("a config op against the reserved runtime store must be refused"); + }; + assert!( + err.contains("reserved") && err.contains("edgezero_runtime_env"), + "error explains the reserved-store collision: {err}" + ); + } + } + + #[test] + fn validate_typed_secrets_passes_with_no_collision() { + FastlyCliAdapter + .validate_typed_secrets(&[ + TypedSecretEntry::new("default", "field_a", "api_token"), + TypedSecretEntry::new("default", "field_b", "db_password"), + ]) + .expect("distinct keys must pass"); + } + + /// The SAME key in two DIFFERENT stores is two distinct secrets + /// (identity is (store, key)) whose values may differ, but both + /// derive the same `API_TOKEN` env var with no store qualifier -- + /// so provision would collapse them to one value. Reject it. + #[test] + fn validate_typed_secrets_rejects_same_key_across_two_stores() { + let err = FastlyCliAdapter + .validate_typed_secrets(&[ + TypedSecretEntry::new("store_a", "field_a", "api_token"), + TypedSecretEntry::new("store_b", "field_b", "api_token"), + ]) + .expect_err("same key in two stores collides on one Viceroy env var"); + assert!( + err.contains("store_a") && err.contains("store_b") && err.contains("API_TOKEN"), + "error names both stores and the shared env var: {err}" + ); + } + + /// The exact same (store, key) referenced twice is one secret with + /// two references -- not a collision. + #[test] + fn validate_typed_secrets_allows_same_store_and_key_referenced_twice() { + FastlyCliAdapter + .validate_typed_secrets(&[ + TypedSecretEntry::new("default", "field_a", "api_token"), + TypedSecretEntry::new("default", "field_b", "api_token"), + ]) + .expect("one secret referenced by two fields is fine"); + } + + /// Regression: keys + /// differing only in case both upper-case to `API_TOKEN`, so + /// `fastly.toml` gets two secret-store rows reading the same + /// Viceroy env var and the two secrets silently share a value. + #[test] + fn validate_typed_secrets_rejects_keys_differing_only_by_case() { + let err = FastlyCliAdapter + .validate_typed_secrets(&[ + TypedSecretEntry::new("default", "lower_field", "api_token"), + TypedSecretEntry::new("default", "upper_field", "API_TOKEN"), + ]) + .expect_err("keys differing only by case must collide on the derived env var"); + assert!( + err.contains("API_TOKEN") && err.contains("lower_field") && err.contains("upper_field"), + "error names the shared env var and BOTH fields: {err}" + ); + } + + /// The collision is on the derived env var, not the store, so it + /// must be caught across stores too. + #[test] + fn validate_typed_secrets_rejects_case_collision_across_stores() { + let err = FastlyCliAdapter + .validate_typed_secrets(&[ + TypedSecretEntry::new("store_a", "lower_field", "api_token"), + TypedSecretEntry::new("store_b", "upper_field", "Api_Token"), + ]) + .expect_err("case collision must be caught across stores"); + assert!(err.contains("API_TOKEN"), "{err}"); + } + + // ---- chunk GC helpers ---- + + #[test] + fn reject_reserved_root_keys_accepts_clean_keys() { + let entries = vec![ + ("app_config".to_owned(), "{}".to_owned()), + ("app_config_staging".to_owned(), "{}".to_owned()), + ]; + reject_reserved_root_keys(&entries).expect("clean keys accepted"); + } + + #[test] + fn reject_reserved_root_keys_rejects_infix_key() { + let bad = format!("app_config{CHUNK_KEY_INFIX}deadbeef.0"); + let entries = vec![(bad.clone(), "{}".to_owned())]; + let err = reject_reserved_root_keys(&entries).expect_err("reserved infix must reject"); + assert!(err.contains(&bad), "error names the key: {err}"); + assert!(err.contains("reserved"), "error explains why: {err}"); + } + + #[test] + fn expand_root_direct_value_has_single_entry() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); + let (expanded, new_keys, new_root_value) = expand_root(TEST_CONFIG_ID, &envelope).unwrap(); + assert_eq!(expanded.len(), 1); + assert_eq!(new_root_value, envelope); + assert!(new_keys.contains(TEST_CONFIG_ID)); + assert_eq!(new_keys.len(), 1); + } + + #[test] + fn expand_root_chunked_value_carries_pointer_as_root_value() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + let (expanded, new_keys, new_root_value) = expand_root(TEST_CONFIG_ID, &envelope).unwrap(); + assert!(expanded.len() >= 2, "chunks + pointer"); + let (last_key, last_value) = expanded.last().unwrap(); + assert_eq!(last_key, TEST_CONFIG_ID); + assert_eq!(&new_root_value, last_value); + assert!(new_keys.contains(TEST_CONFIG_ID)); + assert_eq!(new_keys.len(), expanded.len()); + } + + /// The read taxonomy distinguishes repairable corruption from cases a push + /// must NOT overwrite: an infrastructure fetch failure (incomplete read) and + /// an unknown/future format both stay hard errors, while a malformed direct + /// value, a SHA mismatch, and a resolve error are repairable `Corrupt`. + #[test] + fn classify_resolved_read_separates_corrupt_from_infra_and_unknown() { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + + let envelope = serde_json::to_string(&BlobEnvelope::new( + json!({ "k": "v" }), + "2026-01-01T00:00:00Z".to_owned(), + )) + .expect("envelope"); + + // A valid envelope resolves to Present. + assert!(matches!( + classify_resolved_read(Ok(envelope.clone()), &envelope, false), + Ok(ReadConfigEntry::Present(_)) + )); + + // A direct value with a wrong `sha256` is NOT a valid envelope -> Corrupt. + let mut tampered_value: serde_json::Value = serde_json::from_str(&envelope).expect("parse"); + tampered_value["sha256"] = json!("0".repeat(64)); + let tampered = tampered_value.to_string(); + assert!(matches!( + classify_resolved_read(Ok(tampered.clone()), &tampered, false), + Ok(ReadConfigEntry::Corrupt(_)) + )); + + // Invalid JSON / a plain non-envelope value -> Corrupt (not Present). + assert!(matches!( + classify_resolved_read(Ok("not an envelope".to_owned()), "not an envelope", false), + Ok(ReadConfigEntry::Corrupt(_)) + )); + + // A resolve error caused by an INFRASTRUCTURE fetch failure stays a HARD + // error: the read was incomplete, so a push must not overwrite. + let infra = classify_resolved_read( + Err(ResolveFailure::Corrupt("boom".to_owned())), + "{\"edgezero_kind\":\"fastly_config_chunks\"}", + true, + ); + assert!( + infra + .as_ref() + .is_err_and(|err| err.contains("not fully read")), + "an infra fetch failure must be a hard error, not Corrupt" + ); + + // A value announcing an UNKNOWN/future kind is a HARD error (upgrade CLI), + // never offered for overwrite. + let unknown = classify_resolved_read( + Err(ResolveFailure::FutureFormat("x".to_owned())), + r#"{"edgezero_kind":"fastly_config_chunks_v2"}"#, + false, + ); + assert!( + unknown + .as_ref() + .is_err_and(|err| err.contains("does not recognise")), + "an unknown/future kind must be a hard error" + ); + + // A NEWER INNER envelope (a valid v1 pointer wrapping a v2 envelope) is + // only knowable AFTER reassembly: the raw value is a healthy v1 pointer, + // so the typed `FutureFormat` failure is the ONLY signal. It must be a + // hard error, never repairable Corrupt -- a downgrade push must not + // overwrite it. + let inner_future = classify_resolved_read( + Err(ResolveFailure::FutureFormat( + "newer inner envelope".to_owned(), + )), + r#"{"edgezero_kind":"fastly_config_chunks","version":1,"chunks":[]}"#, + false, + ); + assert!( + inner_future + .as_ref() + .is_err_and(|err| err.contains("UPGRADE")), + "a future INNER envelope (typed FutureFormat) must be a hard error, not Corrupt" + ); + + // An ordinary resolve error (bad/missing chunk) is repairable Corrupt. + assert!(matches!( + classify_resolved_read( + Err(ResolveFailure::Corrupt("bad chunk".to_owned())), + r#"{"edgezero_kind":"fastly_config_chunks","chunks":[]}"#, + false + ), + Ok(ReadConfigEntry::Corrupt(_)) + )); + + // A future ENVELOPE version (passed through as Ok) is a hard error, NOT + // the repairable Corrupt -- an older CLI must not overwrite it. + let mut v2_value: serde_json::Value = serde_json::from_str(&envelope).expect("parse"); + v2_value["version"] = json!(2_u32); + let v2_env = v2_value.to_string(); + assert!( + classify_resolved_read(Ok(v2_env.clone()), &v2_env, false) + .as_ref() + .is_err_and(|err| err.contains("UPGRADE")), + "a v2 direct envelope must be a hard error, not Corrupt" + ); + + // A future POINTER version (resolve fails on the version check) is a hard + // error too -- the pointer kind is ours, but the version is newer. + let v2_ptr = r#"{"edgezero_kind":"fastly_config_chunks","version":2,"chunks":[]}"#; + assert!( + classify_resolved_read( + Err(ResolveFailure::FutureFormat( + "unsupported version".to_owned() + )), + v2_ptr, + false + ) + .as_ref() + .is_err_and(|err| err.contains("UPGRADE")), + "a v2 pointer must be a hard error, not Corrupt" + ); + } + + /// `preflight_config_write` rejects an infeasible push BEFORE any provider + /// I/O: a reserved key, an empty key, and a body whose DERIVED chunk keys + /// would exceed the store limit (caught by running expansion offline). + #[test] + fn preflight_config_write_rejects_infeasible_pushes_offline() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let small = make_test_envelope(100); + + let reserved = format!("app_config{CHUNK_KEY_INFIX}deadbeef.0"); + assert!( + FastlyCliAdapter + .preflight_config_write(&reserved, &small) + .is_err_and(|err| err.contains("reserved infix")), + "a reserved-namespace key must be rejected" + ); + + assert!( + FastlyCliAdapter + .preflight_config_write("", &small) + .is_err_and(|err| err.contains("empty")), + "an empty key must be rejected" + ); + + // A ~200-char root with a CHUNKED body: derived chunk keys (root + ~85) + // exceed the 255-char limit. Caught offline by expansion. + let long_root = "r".repeat(200); + let big = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + assert!( + FastlyCliAdapter + .preflight_config_write(&long_root, &big) + .is_err(), + "an over-limit derived chunk key must be rejected before I/O" + ); + + // A normal push passes. + FastlyCliAdapter + .preflight_config_write("app_config", &small) + .expect("a normal push must pass preflight"); + } + + /// The atomic replace must PRESERVE the target's permissions: a 0600 manifest + /// must not widen to the umask default when it is replaced. + #[cfg(unix)] + #[test] + fn atomic_replace_preserves_restrictive_permissions() { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let manifest = dir.path().join("fastly.toml"); + fs::write(&manifest, "before\n").expect("seed"); + fs::set_permissions(&manifest, fs::Permissions::from_mode(0o600)).expect("chmod"); + + atomically_replace_file(&manifest, "before\n", "after\n").expect("replace"); + + let mode = fs::metadata(&manifest).expect("meta").permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "restrictive permissions must survive the replace" + ); + assert_eq!(fs::read_to_string(&manifest).expect("read"), "after\n"); + } + + /// The happy path replaces contents and leaves no temp file behind. + #[cfg(unix)] + #[test] + fn local_rewrite_replaces_atomically_and_cleans_up() { + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + fs::write(&fastly_toml, "before\n").expect("seed"); + + atomically_replace_file(&fastly_toml, "before\n", "after\n").expect("replace"); + assert_eq!( + fs::read_to_string(&fastly_toml).expect("read"), + "after\n", + "contents must be replaced" + ); + let leftovers: Vec<_> = fs::read_dir(dir.path()) + .expect("read_dir") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "no temp file may be left behind"); + } + + /// A concurrent edit to `fastly.toml` between the push's read and its write + /// must NOT be clobbered: the rewrite refuses and reports, leaving the other + /// writer's file intact so no sibling change is silently lost. + #[cfg(unix)] + #[test] + fn local_rewrite_refuses_to_clobber_a_concurrent_edit() { + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + fs::write(&fastly_toml, "name = \"demo\"\n").expect("seed"); + + // Simulate: we read one thing, another writer moved the file, we write. + let stale_view = "name = \"demo\"\n"; + fs::write(&fastly_toml, "name = \"demo\"\nother = \"sibling edit\"\n") + .expect("concurrent write"); + + let err = atomically_replace_file(&fastly_toml, stale_view, "name = \"clobbered\"\n") + .expect_err("a concurrent edit must not be overwritten"); + assert!( + err.contains("changed on disk"), + "must report the conflict: {err}" + ); + assert_eq!( + fs::read_to_string(&fastly_toml).expect("read"), + "name = \"demo\"\nother = \"sibling edit\"\n", + "the other writer's file must survive untouched" + ); + } +} diff --git a/crates/edgezero-adapter-fastly/src/cli/provision_cloud.rs b/crates/edgezero-adapter-fastly/src/cli/provision_cloud.rs new file mode 100644 index 00000000..d351d711 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/provision_cloud.rs @@ -0,0 +1,1429 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::Path; +use std::process::Command; + +use edgezero_adapter::registry::{AdapterDeployedState, ProvisionOutcome, ProvisionStores}; + +use super::{FASTLY_INSTALL_HINT, ManifestLock, atomically_replace_file}; + +/// Cloud-mode `provision`: create Fastly platform stores via +/// `fastly -store create`, then write the corresponding +/// `[setup._stores.]` block to `fastly.toml`. Also +/// creates the `edgezero_runtime_env` config-store the runtime +/// override path depends on. +/// +/// Callers in `mod.rs` gate this on `ProvisionMode::Cloud`; Local +/// mode dispatches to `provision_local::provision`. +pub(super) fn provision( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + stores: &ProvisionStores<'_>, + deployed: Option<&AdapterDeployedState>, + dry_run: bool, +) -> Result { + // A user store named like the internal runtime-override config store + // would be created remotely AND merged into the runtime-env store -- + // reject BEFORE any account mutation (dry-run too, so the preview + // models the real outcome). + super::reject_reserved_store_names(stores)?; + // 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 + // writeback declares the resource link for `fastly + // compute deploy` and the local viceroy server. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.fastly.adapter].manifest must point at fastly.toml for provision".to_owned(), + ); + }; + let fastly_path = manifest_root.join(rel); + + // Cloud provision MUTATES REMOTE ACCOUNT STATE and then records the + // resource link in fastly.toml. `fastly.toml` is gitignored, so on a + // clean clone it is absent -- and creating the remote stores first + // would then materialise a file containing ONLY `[setup.*]`: a + // manifest with no `manifest_version` / `name` / `language` that + // `fastly compute build` rejects, guarding stores that are now + // orphaned in the account. Refuse BEFORE any account mutation (in + // dry-run too, so the preview models the real outcome). + if !fastly_path.exists() { + return Err(format!( + "{}: not found. Cloud provision records the stores it creates in fastly.toml, and \ + must not create remote resources against a manifest that does not exist yet. Run \ + `provision --adapter fastly --local` first to synthesise the baseline manifest, then \ + re-run cloud provision.", + fastly_path.display() + )); + } + + // Reconcile the service_id in PREFLIGHT so a tracked/local conflict + // aborts before any `fastly *-store create` runs or `[setup]` is + // written -- otherwise a known conflict could leave an orphaned + // remote store and a mutated manifest, and dry-run (which never + // reached the old post-create check) would miss it entirely. + let service_id = reconcile_service_id(&fastly_path, deployed)?; + + // Preflight the ENTIRE `[setup]` writeback shape BEFORE creating any + // remote store. `append_fastly_setup` requires `setup` and each + // `setup._stores` to be standard tables; if a malformed (but + // syntactically valid) manifest has e.g. `setup = "x"`, the writeback + // would fail AFTER `fastly *-store create` already ran, orphaning the + // remote resource. Checking here means a bad manifest aborts before + // any account mutation, and dry-run predicts the same failure. + assert_setup_writeback_shape(&fastly_path)?; + + let mut out = Vec::new(); + for (kind, ids) in [ + ("kv", stores.kv), + ("config", stores.config), + ("secret", stores.secrets), + ] { + for store in ids { + // Fastly setup tables key on the resource name the + // CLI creates. The runtime resolves that same name + // via `EDGEZERO__STORES______NAME`, + // so provision must use the env-resolved PLATFORM + // name -- the logical id stays in status lines for + // human-facing wording. + let logical = store.logical.as_str(); + let name = store.platform.as_str(); + // Check the skip condition FIRST, so dry-run models what the + // real run does: if the `[setup.*]` block is already present + // the real invocation skips the store, and dry-run must + // report "would skip", not "would create". + if setup_block_present(&fastly_path, kind, name)? { + let mut line = format!( + "fastly {kind}-store `{name}` (logical id `{logical}`) already declared in {}; skipping. To force a fresh remote: delete the [setup.{kind}_stores.{name}] block AND run `fastly {kind}-store delete --name={name}` (the old remote store lingers otherwise), then re-run provision.", + fastly_path.display() + ); + // Convergence: if the service is already deployed, `[setup]` + // is never re-run, so a store declared-but-not-linked stays + // unlinked. Re-emit the resource-link remediation on EVERY + // skip run so an operator who missed the first message can + // still recover -- provision is otherwise a dead end here. + if let Some(note) = resource_link_note(service_id.as_deref(), kind, name) { + line.push('\n'); + line.push_str(¬e); + } + out.push(line); + continue; + } + if dry_run { + out.push(format!( + "would run `fastly {kind}-store create --name={name}` and append [setup.{kind}_stores.{name}] to {} (logical id `{logical}`)", + fastly_path.display() + )); + continue; + } + create_fastly_store(kind, name)?; + // If the platform store was created but the + // writeback fails, remote state and the local + // manifest are out of sync. Re-running `provision` + // would attempt to create the platform store again + // and fail with "already exists". Surface the + // recovery path explicitly so the operator isn't + // stuck. + append_fastly_setup(&fastly_path, kind, name).map_err(|err| { + format!( + "fastly {kind}-store `{name}` (logical id `{logical}`) was created remotely, but writeback to {path} failed: {err}\n To recover, either:\n 1. Manually append `[setup.{kind}_stores.{name}]` to {path} and re-run, or\n 2. Delete the orphan remote store via `fastly {kind}-store delete --name={name}` and re-run `edgezero provision --adapter fastly`.", + path = fastly_path.display() + ) + })?; + // Fastly's `[setup._stores.]` table is + // consumed ONLY when `fastly compute deploy` is + // creating a NEW service. If `service_id` is + // already present in fastly.toml, the service has + // been deployed at least once and subsequent + // deploys skip `[setup]` entirely — so the store + // exists in the account but has no resource link + // tying it to a service version, and the running + // Compute service can't open it. + // + // Detect that case and EMIT the exact one-shot + // command the operator should run to link the + // store. We deliberately don't auto-run it: the + // link cones the active version (`--autoclone`), + // and silently mutating an already-deployed + // service is surprising. The instruction names + // both the store-id lookup AND the link command so + // the operator can audit before committing. + let post_create_note = resource_link_note(service_id.as_deref(), kind, name); + let mut line = format!( + "created fastly {kind}-store `{name}` (logical id `{logical}`); appended setup tables to {}", + fastly_path.display() + ); + if let Some(note) = post_create_note { + line.push('\n'); + line.push_str(¬e); + } + out.push(line); + } + } + // EdgeZero runtime overrides live in a dedicated Fastly Config + // Store named `edgezero_runtime_env`. Compute@Edge has no + // process env, so `EDGEZERO__STORES__CONFIG____KEY` and + // similar overrides have to come from a platform Config Store + // the runtime opens by name (see + // `env_config_from_runtime_dictionary` in lib.rs). Provision + // owns the store creation alongside the operator's declared + // stores so the runtime override path is wired correctly out + // of the box; if the store already appears in + // `[setup.config_stores.edgezero_runtime_env]`, skip. + let runtime_env_kind = "config"; + let runtime_env_name = "edgezero_runtime_env"; + // Check the skip condition FIRST -- BEFORE the dry-run branch -- the + // same way the declared-store loop above does. When the setup block + // already exists the real invocation skips silently, so a dry-run + // that reported "would create" would promise an account mutation + // that never happens. + if setup_block_present(&fastly_path, runtime_env_kind, runtime_env_name)? { + // Already declared; nothing to do, and nothing to report. + } else if dry_run { + out.push(format!( + "would run `fastly {runtime_env_kind}-store create --name={runtime_env_name}` and append [setup.{runtime_env_kind}_stores.{runtime_env_name}] to {} (EdgeZero runtime override store)", + fastly_path.display() + )); + } else { + create_fastly_store(runtime_env_kind, runtime_env_name)?; + append_fastly_setup(&fastly_path, runtime_env_kind, runtime_env_name).map_err(|err| { + format!( + "fastly {runtime_env_kind}-store `{runtime_env_name}` was created remotely, but writeback to {path} failed: {err}\n Recover via `fastly {runtime_env_kind}-store delete --name={runtime_env_name}` then re-run `edgezero provision --adapter fastly`.", + path = fastly_path.display() + ) + })?; + // Same already-deployed-service caveat as the declared-store + // path: if `service_id` is set in fastly.toml, the + // `[setup.config_stores.edgezero_runtime_env]` table won't + // be re-applied by the next `fastly compute deploy`, so the + // runtime can't open the store. Emit the resource-link + // remediation alongside the populate-keys hint. + let post_create_note = + resource_link_note(service_id.as_deref(), runtime_env_kind, runtime_env_name); + let mut line = format!( + "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store); appended setup tables to {}\n Populate per-environment override keys with:\n fastly config-store-entry update --store-id= --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY --value=app_config_staging --upsert", + fastly_path.display() + ); + if let Some(note) = post_create_note { + line.push('\n'); + line.push_str(¬e); + } + out.push(line); + } + + if out.is_empty() { + out.push("fastly has no declared stores to provision".to_owned()); + } + // Cloud provision does NOT write back `service_id`. Per spec + // §"Writeback ownership" (and the plan's "Fastly note"), Fastly's + // `service_id` is populated by `fastly compute deploy` -- which + // runs as the manifest `[adapters.fastly.commands].deploy` shell + // command, bypassing the adapter dispatch entirely -- and the + // operator does a documented ONE-TIME copy from `fastly.toml` into + // `[adapters.fastly.deployed].service_id`. An earlier build + // auto-captured the id here; that both exceeded the v1 contract + // AND opened a data-loss path where a stale, gitignored, + // per-machine `fastly.toml` silently replaced the team's committed + // id. Local provision still pins the + // TRACKED id INTO fastly.toml (the spec-blessed direction); only + // this reverse auto-capture is removed. + Ok(ProvisionOutcome::from_status_lines(out)) +} + +/// Shell out to `fastly -store create --name=`. The +/// caller resolves `` from `EDGEZERO__STORES______NAME` +/// (falling back to the logical id), so this helper takes whatever the +/// caller hands it and does not re-translate. Returns `Ok(())` on success; +/// surfaces the CLI's stderr verbatim on failure (including the "already +/// exists" error, which is the caller's signal to fix the toml or use a +/// different name). +/// +/// # Errors +/// Returns an error if `fastly` isn't on `PATH`, the child fails to +/// spawn, or the exit status is non-zero. +fn create_fastly_store(kind: &str, name: &str) -> Result<(), String> { + let subcommand = format!("{kind}-store"); + let name_arg = format!("--name={name}"); + let output = Command::new("fastly") + .args([subcommand.as_str(), "create", name_arg.as_str()]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if output.status.success() { + return Ok(()); + } + // Idempotency: the fastly CLI returns non-zero with an + // "already exists" message when a store of this name was + // created by a prior provision run. Treat that as success so + // the operator's recovery path -- "either manually append the + // setup block or delete the remote and re-run provision" -- + // doesn't get blocked. The append step is itself idempotent, + // so re-running provision after a writeback failure is the + // documented recovery and now actually works. + let stderr = String::from_utf8_lossy(&output.stderr); + if looks_like_already_exists(&stderr, kind) { + return Ok(()); + } + Err(format!( + "`fastly {subcommand} create --name={name}` exited with status {}\nstderr: {}", + output.status, + stderr.trim() + )) +} + +/// Heuristic: does the stderr blob look like a "store of this +/// kind, by this name, already exists" failure from the fastly +/// CLI? Different CLI versions phrase this slightly differently +/// ("a kv-store with that name already exists", +/// `"Conflict: duplicate kv_store name"`, etc.); we require BOTH +/// a conflict-signal keyword AND a store-kind reference so an +/// unrelated 409 ("Error: 409 Conflict on /service/...") cannot +/// be misread as idempotent success. The earlier wider heuristic +/// would have swallowed any stderr containing the word +/// "conflict" and let provision march on to writeback against a +/// nonexistent store, surfacing as a confusing deploy-time error. +fn looks_like_already_exists(stderr: &str, kind: &str) -> bool { + let lower = stderr.to_ascii_lowercase(); + let conflict_signal = lower.contains("already exists") + || (lower.contains("duplicate") && lower.contains("name")) + || lower.contains("conflict"); + if !conflict_signal { + return false; + } + // Accept the three common spellings of `-store` / + // `_store` / ` store` so a fastly CLI version + // bump that reshuffles punctuation still hits. + let dashed = format!("{kind}-store"); + let underscored = format!("{kind}_store"); + let spaced = format!("{kind} store"); + lower.contains(&dashed) || lower.contains(&underscored) || lower.contains(&spaced) +} + +/// Read the top-level `service_id` from `fastly.toml`. Returns +/// `Ok(None)` when the file is absent (scaffold state before first +/// `fastly compute deploy`) or when `service_id` is missing / +/// empty. Used by `provision` to detect when an already-deployed +/// service needs a separate resource-link step beyond `[setup]` +/// (which `compute deploy` only consumes on the FIRST deploy). +fn read_fastly_service_id(path: &Path) -> Result, String> { + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: toml_edit::DocumentMut = raw.parse().map_err(|_err| { + format!( + "failed to parse {} as TOML (details redacted: the error can quote a stored value)", + path.display() + ) + })?; + let svc = doc + .get("service_id") + .and_then(|item| item.as_str()) + .map(str::to_owned) + .filter(|svc_id| !svc_id.is_empty()); + Ok(svc) +} + +/// Reconcile the tracked vs local `service_id` BEFORE any account +/// mutation, returning the authoritative id (or `None` when the service +/// hasn't been deployed yet). +/// +/// Tracked `[adapters.fastly.deployed].service_id` in edgezero.toml is +/// the DURABLE AUTHORITY (spec §"Deployed state"): fastly.toml is +/// gitignored and per-machine, so a stale or regenerated copy must not +/// steer the resource-link command at the wrong service. Prefer tracked; +/// if the local file DISAGREES, refuse. Fall back to the local id only +/// when nothing is tracked (a service deployed via `fastly compute +/// deploy` before any provision captured its id). +/// +/// Called in preflight so a known conflict aborts BEFORE `provision` +/// creates any remote store or edits the manifest -- and so dry-run +/// surfaces the same conflict a real run would. +fn reconcile_service_id( + path: &Path, + deployed: Option<&AdapterDeployedState>, +) -> Result, String> { + let tracked = deployed + .and_then(|state| state.fields.get("service_id")) + .filter(|svc_id| !svc_id.is_empty()) + .cloned(); + let local = read_fastly_service_id(path)?; + match (tracked, local) { + (Some(tracked_id), Some(local_id)) if tracked_id != local_id => Err(format!( + "service_id conflict for the fastly adapter: gitignored `{}` declares `{local_id}`, \ + but tracked `[adapters.fastly.deployed].service_id` is `{tracked_id}`. fastly.toml \ + is per-machine, so provision will not recommend linking resources to the local id. \ + Resolve by hand: update the tracked value in edgezero.toml, or delete the stale \ + `service_id` from fastly.toml.", + path.display() + )), + (Some(tracked_id), _) => Ok(Some(tracked_id)), + (None, local_id) => Ok(local_id), + } +} + +/// If a `service_id` is recorded, the next `fastly compute deploy` skips +/// `[setup]` entirely (it only runs on the FIRST deploy of a service), +/// so any store provision creates afterwards needs a separate +/// `fastly resource-link create`. Build that remediation note from the +/// already-reconciled `service_id` (see [`reconcile_service_id`]), or +/// `None` when the service hasn't been deployed yet. +fn resource_link_note(service_id: Option<&str>, kind: &str, name: &str) -> Option { + service_id.map(|svc_id| { + format!( + " `service_id = \"{svc_id}\"` is recorded (tracked `[adapters.fastly.deployed]` takes precedence over the local fastly.toml), so this service is already deployed -- `[setup]` will NOT be re-run on the next `fastly compute deploy`. The store exists in the account but is NOT yet linked to the service. To finish provisioning, look up the store id with `fastly {kind}-store list --json` (match by name=`{name}`), then run:\n fastly resource-link create --service-id={svc_id} --resource-id= --version=latest --autoclone --name={name}\n (the link clones the active version so existing traffic is not affected until you `fastly service-version activate`)." + ) + }) +} + +/// Probe `fastly.toml` for the existence of `[setup._stores.]`. +/// Treats a missing file as "not present" so the first provision call +/// can create it. +/// +/// Why only `[setup]` (no longer `[local_server]`): an empty +/// `[local_server._stores.]` table doesn't satisfy +/// fastly's local-server schema — config-stores need +/// `format = "inline-toml"` + a contents table, kv/secret stores +/// need a JSON `file = "..."` or an array of `{key, data}` entries. +/// Writing an empty table makes `fastly compute serve` skip the +/// declared store or error at startup. `provision`'s job is the +/// remote / `[setup]` half; local-server stanzas are written by +/// `edgezero config push --adapter fastly --local` +/// (config-stores only), and kv/secret local-server seeding is +/// hand-edited until we add equivalent writers for those kinds. +fn setup_block_present(path: &Path, kind: &str, id: &str) -> Result { + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: toml_edit::DocumentMut = raw.parse().map_err(|_err| { + format!( + "failed to parse {} as TOML (details redacted: the error can quote a stored value)", + path.display() + ) + })?; + let plural = format!("{kind}_stores"); + Ok(doc + .get("setup") + .and_then(|root| root.get(plural.as_str())) + .and_then(|kind_tbl| kind_tbl.get(id)) + .is_some()) +} + +/// Append `[setup._stores.]` to `fastly.toml`. Creates +/// the file (and the parent `[setup]` table) if absent. The block +/// is written as an empty table — that's what +/// `fastly compute deploy` consumes the first time it creates a +/// service: the resource-link declaration is enough, and the +/// account-level resource itself is already created in the +/// preceding `create_fastly_store` shellout. +/// +/// We DON'T write `[local_server._stores.]` here: see +/// `setup_block_present`'s doc for the schema rationale. The local- +/// server seeding moved to `config push --local` (config-stores +/// only), so provision only owns the remote / setup half. +/// Validate that the `[setup]` writeback target is well-formed for EVERY +/// store kind before any remote store is created. `append_fastly_setup` +/// requires `setup` and each `setup._stores` to be standard tables; +/// this mirrors that requirement so a malformed-but-valid-TOML manifest is +/// rejected up front rather than after a `fastly *-store create` orphans a +/// remote resource. A missing file is fine -- the writeback creates it. +fn assert_setup_writeback_shape(path: &Path) -> Result<(), String> { + use toml_edit::DocumentMut; + + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + + let Some(setup) = doc.get("setup") else { + return Ok(()); + }; + let Some(setup_tbl) = setup.as_table() else { + return Err(format!( + "{}: `setup` exists but is not a table; refusing to create any remote store", + path.display() + )); + }; + for plural in ["kv_stores", "config_stores", "secret_stores"] { + let Some(item) = setup_tbl.get(plural) else { + continue; + }; + let Some(kind_tbl) = item.as_table() else { + return Err(format!( + "{}: `setup.{plural}` exists but is not a table; refusing to create any remote store", + path.display() + )); + }; + // Validate EVERY managed child too. `setup_block_present` treats any + // existing `setup..` value as "already provisioned" + // and skips it -- so a malformed scalar like `sessions = "broken"` + // would be silently skipped AFTER an earlier store was already + // created remotely, leaving partial state. A legitimate setup entry + // is a `[setup..]` block (standard or inline table). + for (name, child) in kind_tbl { + if child.as_table_like().is_none() { + return Err(format!( + "{}: `setup.{plural}.{name}` is not a table; a store setup entry must be a `[setup.{plural}.{name}]` block. Refusing to create any remote store against a malformed manifest.", + path.display() + )); + } + } + } + Ok(()) +} + +fn append_fastly_setup(path: &Path, kind: &str, id: &str) -> Result<(), String> { + use toml_edit::{DocumentMut, Item, table}; + + // Provision writes the SAME manifest as `config push --local`; take the same + // lock so a concurrent provision and push serialise instead of clobbering + // each other's edit, and operate on the real target the lock resolved. + let lock = ManifestLock::acquire(path)?; + let target = lock.target(); + + let raw = match fs::read_to_string(target) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => String::new(), + Err(err) => return Err(format!("failed to read {}: {err}", target.display())), + }; + let mut doc: DocumentMut = raw.parse().map_err(|_err| { + format!( + "failed to parse {} as TOML (details redacted: the error can quote a stored value)", + target.display() + ) + })?; + + let plural = format!("{kind}_stores"); + let parent_entry = doc.entry("setup").or_insert_with(table); + let parent_tbl = parent_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `setup` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + let kind_entry = parent_tbl + .entry(plural.as_str()) + .or_insert_with(|| Item::Table(toml_edit::Table::new())); + let kind_tbl = kind_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `setup.{plural}` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + if !kind_tbl.contains_key(id) { + kind_tbl.insert(id, Item::Table(toml_edit::Table::new())); + } + + atomically_replace_file(target, &raw, &doc.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::super::FastlyCliAdapter; + use super::super::provision_local::write_fastly_local_config_store; + use super::super::run::synthesise_fastly_toml; + use super::*; + use edgezero_adapter::registry::{ + Adapter as _, AdapterDeployedState, ProvisionMode, ResolvedStoreId, TypedSecretEntry, + }; + use tempfile::tempdir; + + // Shared fixture names. + const TEST_KV_ID: &str = "sessions"; + const TEST_CONFIG_ID: &str = "app_config"; + const TEST_SECRET_ID: &str = "default"; + + // ---------- looks_like_already_exists ---------- + + #[test] + fn looks_like_already_exists_recognises_common_phrasings() { + // Real-shaped fastly CLI error strings (paraphrased; the + // CLI varies across versions). Each must be detected so + // create_fastly_store can treat it as idempotent success. + assert!(looks_like_already_exists( + "Error: a kv-store with that name already exists", + "kv", + )); + assert!(looks_like_already_exists( + "ERROR: Conflict (409): duplicate kv_store name", + "kv", + )); + assert!(looks_like_already_exists( + "A config-store with this name already exists", + "config", + )); + // Spaced form: some fastly CLI versions emit prose + // ("kv store"); accept it alongside the punctuated forms. + assert!(looks_like_already_exists( + "Error: kv store conflict: name already in use", + "kv", + )); + } + + #[test] + fn looks_like_already_exists_rejects_unrelated_errors() { + assert!(!looks_like_already_exists( + "Error: unauthenticated; run `fastly profile create`", + "kv", + )); + assert!(!looks_like_already_exists( + "Error: network unreachable", + "kv", + )); + assert!(!looks_like_already_exists("", "kv")); + } + + #[test] + fn looks_like_already_exists_rejects_unrelated_conflict_errors() { + // The earlier wider heuristic swallowed ANY stderr + // containing "conflict" or "already exists", which would + // misread an unrelated 409 from a different fastly + // subcommand (e.g. a service-version conflict during a + // parallel deploy) as idempotent store-create success. + // Now we require the kind context too, so unrelated + // conflicts surface as failures. + assert!( + !looks_like_already_exists( + "Error: 409 Conflict on /service/abc/version/42 -- already exists", + "kv", + ), + "service-version conflict must NOT be misread as kv-store idempotency" + ); + assert!( + !looks_like_already_exists( + "Error: invalid duplicate request; check name resolution", + "kv", + ), + "unrelated `duplicate ... name` AND-match must NOT trigger" + ); + // And the kind must match: a config-store conflict must + // not look-like-already-exists for a kv-store create call. + assert!( + !looks_like_already_exists("Error: a config-store with that name already exists", "kv",), + "wrong-kind conflict must NOT trigger" + ); + } + + // ---------- setup_block_present ---------- + + #[test] + fn setup_block_present_true_when_table_exists() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "name = \"demo\"\n[setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n", + ) + .expect("write"); + assert!(setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); + } + + #[test] + fn setup_block_present_false_when_id_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n[setup.kv_stores.other]\n").expect("write"); + assert!(!setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); + } + + #[test] + fn setup_block_present_false_for_missing_file() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("does-not-exist.toml"); + assert!(!setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); + } + + #[test] + fn setup_block_present_true_when_only_setup_exists() { + // `setup_block_present` only checks + // `[setup._stores.]`. An earlier check + // ALSO required `[local_server._stores.]`, but + // writing an empty `[local_server.*]` table didn't match + // fastly's local-server schema (config-stores need + // `format` + contents, kv/secret stores need a JSON file + // or `{key, data}` entries). Local-server seeding moved + // to `config push --adapter fastly --local`, so probe + // only cares about `[setup]` now. + let dir = tempdir().expect("tempdir"); + let only_setup = dir.path().join("only_setup.toml"); + fs::write(&only_setup, "name = \"demo\"\n[setup.kv_stores.sessions]\n").expect("write"); + assert!( + setup_block_present(&only_setup, "kv", TEST_KV_ID).expect("probe"), + "[setup.*] alone is now sufficient: {only_setup:?}" + ); + + let only_local = dir.path().join("only_local.toml"); + fs::write( + &only_local, + "name = \"demo\"\n[local_server.kv_stores.sessions]\n", + ) + .expect("write"); + assert!( + !setup_block_present(&only_local, "kv", TEST_KV_ID).expect("probe"), + "[local_server.*] alone is NOT a provisioned-setup signal" + ); + } + + // ---------- assert_setup_writeback_shape ---------- + + #[test] + fn assert_setup_writeback_shape_accepts_missing_file() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + assert_setup_writeback_shape(&path).expect("missing file is writeable"); + } + + #[test] + fn assert_setup_writeback_shape_accepts_well_formed_setup() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "[setup.kv_stores.cache]\n").expect("write"); + assert_setup_writeback_shape(&path).expect("well-formed setup accepted"); + } + + #[test] + fn assert_setup_writeback_shape_rejects_non_table_setup() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "setup = \"nope\"\n").expect("write"); + let err = assert_setup_writeback_shape(&path).expect_err("non-table setup rejected"); + assert!(err.contains("`setup` exists but is not a table"), "{err}"); + } + + #[test] + fn assert_setup_writeback_shape_rejects_non_table_kind_stores() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "[setup]\nkv_stores = \"nope\"\n").expect("write"); + let err = assert_setup_writeback_shape(&path).expect_err("non-table kind rejected"); + assert!( + err.contains("`setup.kv_stores` exists but is not a table"), + "{err}" + ); + } + + #[test] + fn assert_setup_writeback_shape_rejects_scalar_child_entry() { + // A scalar child (`sessions = "broken"`) would be misread by + // `setup_block_present` as an already-provisioned store and skipped + // -- after earlier stores were created remotely. The preflight must + // reject it before the first remote mutation. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "[setup.kv_stores]\nsessions = \"broken\"\n").expect("write"); + let err = assert_setup_writeback_shape(&path).expect_err("scalar child rejected"); + assert!( + err.contains("`setup.kv_stores.sessions` is not a table"), + "{err}" + ); + } + + #[test] + fn assert_setup_writeback_shape_accepts_inline_table_child() { + // An inline-table child is a legitimate declaration. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "[setup.kv_stores]\nsessions = {}\n").expect("write"); + assert_setup_writeback_shape(&path).expect("inline-table child accepted"); + } + + // ---------- append_fastly_setup ---------- + + #[test] + fn append_fastly_setup_creates_setup_table_in_minimal_file() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + append_fastly_setup(&path, "kv", TEST_KV_ID).expect("append"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("[setup.kv_stores.sessions]"), + "setup table added: {after}" + ); + // Post-F6: no `[local_server.*]` write — that empty stanza + // didn't satisfy fastly's local-server schema and made + // `fastly compute serve` error or skip the store. Local- + // server seeding is now `config push --adapter fastly + // --local`'s job. + assert!( + !after.contains("[local_server.kv_stores.sessions]"), + "[local_server.*] empty table no longer written by provision: {after}" + ); + assert!( + after.contains("name = \"demo\""), + "preserved original keys: {after}" + ); + } + + #[test] + fn append_fastly_setup_appends_alongside_existing_kind_tables() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "[setup.kv_stores.cache]\n").expect("write"); + append_fastly_setup(&path, "kv", TEST_KV_ID).expect("append"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("[setup.kv_stores.cache]"), + "existing entry kept: {after}" + ); + assert!( + after.contains("[setup.kv_stores.sessions]"), + "new entry added: {after}" + ); + } + + #[test] + fn append_fastly_setup_is_idempotent_on_duplicate_id() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "[setup.kv_stores.sessions]\nfoo = \"keep\"\n").expect("write"); + append_fastly_setup(&path, "kv", TEST_KV_ID).expect("idempotent append"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("foo = \"keep\""), + "did not stomp existing key: {after}" + ); + } + + #[test] + fn append_fastly_setup_creates_file_when_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // Note: no fs::write — file starts absent. + append_fastly_setup(&path, "config", TEST_CONFIG_ID).expect("create"); + let after = fs::read_to_string(&path).expect("read back"); + assert!(after.contains("[setup.config_stores.app_config]")); + assert!( + !after.contains("[local_server.config_stores.app_config]"), + "[local_server.*] no longer written by provision: {after}" + ); + } + + #[test] + fn append_fastly_setup_preserves_top_comments() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "# managed by hand -- please keep this line\nname = \"demo\"\n", + ) + .expect("write"); + append_fastly_setup(&path, "secret", TEST_SECRET_ID).expect("append"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("# managed by hand"), + "preserved comment: {after}" + ); + } + + // ---------- provision (dry-run + error path) ---------- + + #[test] + fn provision_dry_run_does_not_invoke_fastly() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let secret_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_SECRET_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, + }; + let out = FastlyCliAdapter + .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.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.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. + let after = fs::read_to_string(&path).expect("read"); + assert_eq!(after, "name = \"demo\"\n", "dry-run mutated fastly.toml"); + } + + /// Spec contract: cloud provision + /// NEVER writes back `service_id`, even when `fastly.toml` already + /// declares one. Per spec §"Writeback ownership" the id is + /// populated by `fastly compute deploy` and copied into + /// `edgezero.toml` once, by hand. Auto-capturing it here exceeded + /// the v1 contract and let a stale gitignored `fastly.toml` + /// overwrite the team's committed id. + #[test] + fn cloud_provision_never_writes_back_service_id() { + let dir = tempdir().expect("tempdir"); + // fastly.toml declares a service_id (as it would after a first + // successful `fastly compute deploy`) -- and cloud provision + // must STILL leave `deployed` empty. + fs::write( + dir.path().join("fastly.toml"), + "manifest_version = 3\nname = \"demo\"\nservice_id = \"SVC_ALREADY_DEPLOYED\"\n\n[local_server]\n", + ) + .expect("write"); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + let outcome = FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + // Tracked id AGREES with fastly.toml's (a differing id is + // a conflict the preflight refuses; see + // `provision_cloud_refuses_service_id_conflict_before_any_mutation`). + // The point here is that even a KNOWN service_id is never + // captured back into `deployed`. + Some(&{ + let mut state = AdapterDeployedState::default(); + state + .fields + .insert("service_id".to_owned(), "SVC_ALREADY_DEPLOYED".to_owned()); + state + }), + ProvisionMode::Cloud, + true, // dry-run avoids invoking the real fastly CLI + ) + .expect("dry-run succeeds"); + assert!( + outcome.deployed.is_none(), + "cloud provision must never write back service_id: {:?}", + outcome.deployed + ); + } + + #[test] + fn provision_errors_when_adapter_manifest_path_missing() { + let dir = tempdir().expect("tempdir"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let err = FastlyCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) + .expect_err("missing adapter manifest path must error"); + assert!( + err.contains("fastly.toml"), + "error names what's missing: {err}" + ); + } + + #[test] + fn provision_skip_path_emits_resource_link_note_on_existing_service() { + // A store already declared in `[setup]` on an already-deployed + // service (service_id present) is SKIPPED -- but `[setup]` is never + // re-run, so the store stays unlinked. The skip line must re-emit + // the resource-link remediation so an operator who missed the first + // run can still recover. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "name = \"demo\"\nservice_id = \"SVC1\"\n\n[setup.kv_stores.sessions]\n", + ) + .expect("write"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let outcome = provision(dir.path(), Some("fastly.toml"), &stores, None, true) + .expect("dry-run provision succeeds"); + let joined = outcome.status_lines.join("\n"); + assert!( + joined.contains("skipping"), + "the store must be skipped: {joined}" + ); + assert!( + joined.contains("resource-link create") && joined.contains("SVC1"), + "the skip path must re-emit the resource-link remediation: {joined}" + ); + } + + #[test] + fn cloud_provision_refuses_when_fastly_toml_is_missing() { + // fastly.toml is gitignored, so a clean clone has none. Creating + // remote stores first and then writing a `[setup.*]`-only file + // would orphan those stores behind a manifest `fastly compute + // build` rejects. Refuse BEFORE any account mutation. + let dir = tempdir().expect("tempdir"); + // No fs::write -- fastly.toml absent. + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let err = provision(dir.path(), Some("fastly.toml"), &stores, None, false) + .expect_err("cloud provision must refuse without a baseline manifest"); + assert!( + err.contains("provision --adapter fastly --local"), + "error points at local provision to synthesise the baseline: {err}" + ); + assert!( + !dir.path().join("fastly.toml").exists(), + "refusal must not materialise a manifest" + ); + } + + #[test] + fn cloud_provision_dry_run_also_refuses_when_fastly_toml_is_missing() { + // The dry-run preview must model the real outcome, not promise + // creations the real run would refuse to perform. + let dir = tempdir().expect("tempdir"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let err = provision(dir.path(), Some("fastly.toml"), &stores, None, true) + .expect_err("dry-run must refuse too"); + assert!(err.contains("provision --adapter fastly --local"), "{err}"); + } + + #[test] + fn cloud_dry_run_does_not_claim_to_create_existing_runtime_env_store() { + // Regression: the runtime-env arm reported "would create" + // unconditionally, so a dry-run promised an account mutation the + // real run skips. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "name = \"demo\"\n[setup.config_stores.edgezero_runtime_env]\n", + ) + .expect("write"); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + let out = provision(dir.path(), Some("fastly.toml"), &stores, None, true) + .expect("dry-run succeeds"); + let combined = out.status_lines.join("\n"); + assert!( + !combined + .contains("would run `fastly config-store create --name=edgezero_runtime_env`"), + "dry-run must not claim to create an already-declared store: {combined}" + ); + } + + #[test] + fn reconcile_service_id_falls_back_to_tracked() { + // fastly.toml is gitignored: a teammate's regenerated manifest has + // no `service_id` even though the team's tracked deployed state + // says the service IS deployed. Reading only the local file would + // skip the remediation and leave the new store unlinked. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + let mut tracked = AdapterDeployedState::default(); + tracked + .fields + .insert("service_id".to_owned(), "tracked123".to_owned()); + let resolved = reconcile_service_id(&path, Some(&tracked)) + .expect("read") + .expect("tracked service_id must be used when the local file lacks one"); + assert_eq!(resolved, "tracked123"); + let note = resource_link_note(Some(&resolved), "kv", "sessions") + .expect("note present for a deployed service"); + assert!(note.contains("tracked123"), "note uses the id: {note}"); + } + + #[test] + fn reconcile_service_id_refuses_tracked_local_conflict_in_preflight() { + // The tracked id is the durable authority: even when a stale + // local fastly.toml carries a DIFFERENT id, provision must refuse + // rather than recommend linking to the wrong service. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\nservice_id = \"local999\"\n").expect("write"); + let mut tracked = AdapterDeployedState::default(); + tracked + .fields + .insert("service_id".to_owned(), "tracked123".to_owned()); + let err = reconcile_service_id(&path, Some(&tracked)) + .expect_err("a tracked/local service_id conflict must be refused"); + assert!( + err.contains("tracked123") && err.contains("local999") && err.contains("conflict"), + "error names both ids and the conflict: {err}" + ); + } + + #[test] + fn reconcile_service_id_uses_tracked_when_it_matches_local() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\nservice_id = \"same123\"\n").expect("write"); + let mut tracked = AdapterDeployedState::default(); + tracked + .fields + .insert("service_id".to_owned(), "same123".to_owned()); + let resolved = reconcile_service_id(&path, Some(&tracked)) + .expect("matching ids are fine") + .expect("id present"); + assert_eq!(resolved, "same123"); + } + + #[test] + fn provision_cloud_refuses_service_id_conflict_before_any_mutation() { + // The conflict must abort in preflight -- BEFORE any store is + // created or fastly.toml is edited -- and in dry-run too. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + let original = "name = \"demo\"\nservice_id = \"local999\"\n"; + fs::write(&path, original).expect("write"); + let mut tracked = AdapterDeployedState::default(); + tracked + .fields + .insert("service_id".to_owned(), "tracked123".to_owned()); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + for dry_run in [true, false] { + let err = provision( + dir.path(), + Some("fastly.toml"), + &stores, + Some(&tracked), + dry_run, + ) + .expect_err("a service_id conflict must abort provision"); + assert!(err.contains("conflict"), "dry_run={dry_run}: {err}"); + } + // fastly.toml must be byte-identical -- no mutation happened. + assert_eq!( + fs::read_to_string(&path).expect("read"), + original, + "conflict must abort before any manifest edit" + ); + } + + #[test] + fn provision_with_no_declared_stores_says_so() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // Pre-populate the runtime-env block so the provision flow's + // unconditional runtime-env step skips (otherwise it would + // shell out to real `fastly` to create the store). + fs::write( + &path, + "name = \"demo\"\n[setup.config_stores.edgezero_runtime_env]\n", + ) + .expect("write"); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + let out = FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) + .expect("no-store provision is fine"); + assert_eq!( + out.status_lines, + vec!["fastly has no declared stores to provision"] + ); + } + + #[test] + fn provision_skips_id_when_setup_block_already_present() { + // setup_block_present's role in the flow: re-running + // provision after the user already declared a store in + // fastly.toml must be a no-op (no shell-out to fastly). + // We can verify this in a real (non-dry-run) call because + // the skip path bypasses create_fastly_store entirely. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "[setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n\ + [setup.config_stores.edgezero_runtime_env]\n", + ) + .expect("write"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let out = FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) + .expect("skip path succeeds without invoking fastly"); + assert_eq!(out.status_lines.len(), 1); + assert!( + out.status_lines[0].contains("already declared"), + "got: {out:?}" + ); + } + + #[test] + fn provision_dry_run_reports_skip_for_already_declared_store() { + // Dry-run must model the real operation: a store whose + // `[setup.*]` block already exists is skipped by the real run, + // so dry-run reports "already declared; skipping", NOT + // "would create". + let dir = tempdir().expect("tempdir"); + fs::write( + dir.path().join("fastly.toml"), + "[setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n\ + [setup.config_stores.edgezero_runtime_env]\n", + ) + .expect("write"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let out = FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) + .expect("dry-run succeeds"); + assert!( + out.status_lines[0].contains("already declared") + && !out.status_lines[0].contains("would run"), + "dry-run must report the skip, not a would-create: {out:?}" + ); + } + + /// When `fastly.toml` declares `service_id`, the next + /// `fastly compute deploy` skips `[setup]` entirely. provision + /// must emit the `fastly resource-link create` remediation for + /// every store it creates -- including the implicit + /// `edgezero_runtime_env` store the runtime override path + /// depends on. Without this, a freshly-provisioned override + /// store would not be linked to the already-deployed service + /// and the runtime would silently fall back to baked defaults. + #[test] + fn provision_emits_resource_link_note_for_runtime_env_on_existing_service() { + // Dry-run only -- we just want to drive the resource_link_note + // helper for the runtime-env store branch. The real-create + // path can't run in tests (would shell out to `fastly`). + // The dry-run output line for runtime-env doesn't include the + // note (the helper only fires on real create), so we test the + // helper directly here. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\nservice_id = \"abc123svc\"\n").expect("write"); + let service_id = reconcile_service_id(&path, None).expect("read service_id"); + let note = resource_link_note(service_id.as_deref(), "config", "edgezero_runtime_env") + .expect("note present when service_id set"); + assert!( + note.contains("service_id = \"abc123svc\""), + "note quotes the service id: {note}" + ); + assert!( + note.contains("fastly config-store list --json"), + "note tells operator how to find the store id: {note}" + ); + assert!( + note.contains("name=`edgezero_runtime_env`"), + "note names the runtime override store: {note}" + ); + assert!( + note.contains( + "fastly resource-link create --service-id=abc123svc --resource-id= --version=latest --autoclone --name=edgezero_runtime_env" + ), + "note carries the full resource-link command: {note}" + ); + } + + /// And the inverse: no `service_id` (a service that hasn't been + /// deployed yet) means `[setup]` will be applied on the next + /// `compute deploy`, so no manual resource-link step is needed. + /// The helper must return `None` to avoid noisy false-positive + /// guidance. + #[test] + fn provision_skips_resource_link_note_when_service_undeployed() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + let service_id = reconcile_service_id(&path, None).expect("read service_id"); + let note = resource_link_note(service_id.as_deref(), "config", "edgezero_runtime_env"); + assert!( + note.is_none(), + "no service_id => no resource-link prompt: {note:?}" + ); + } + + /// Cloud mode is a no-op — real cloud secret storage uses + /// `fastly secret-store-entry create` at deploy time, not local + /// `.toml` writeback. Assert empty outcome + untouched manifest. + #[test] + fn fastly_provision_typed_cloud_mode_is_a_no_op() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + let baseline = synthesise_fastly_toml("demo", None); + fs::write(&path, &baseline).expect("write"); + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + let outcome = FastlyCliAdapter + .provision_typed( + dir.path(), + Some("fastly.toml"), + None, + &entries, + ProvisionMode::Cloud, + false, + ) + .expect("cloud mode is a no-op, must succeed"); + assert!( + outcome.status_lines.is_empty(), + "cloud outcome status_lines empty: {:?}", + outcome.status_lines + ); + assert!(outcome.deployed.is_none(), "cloud outcome deployed is None"); + let after = fs::read_to_string(&path).expect("read"); + assert_eq!(after, baseline, "fastly.toml untouched in cloud mode"); + } + + /// The three provisioning parsers must NOT echo a malformed fastly.toml's + /// source text (which can contain a stored secret) on a parse failure. + #[test] + fn provisioning_parsers_redact_malformed_toml() { + const SENTINEL: &str = "SUPER_SECRET_IN_A_BROKEN_LINE"; + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // Malformed TOML whose offending line carries a secret. + fs::write(&path, format!("service_id = \"{SENTINEL}\" = broken\n")).expect("write"); + + let errs = [ + read_fastly_service_id(&path).expect_err("malformed toml must error"), + setup_block_present(&path, "kv", TEST_KV_ID).expect_err("malformed toml must error"), + append_fastly_setup(&path, "kv", TEST_KV_ID).expect_err("malformed toml must error"), + ]; + for err in &errs { + assert!( + !err.contains(SENTINEL), + "a parse error must not echo the stored value: {err}" + ); + assert!( + err.contains("redacted"), + "error should say it redacted: {err}" + ); + } + } + + /// A provision write and a local push serialise on the SAME manifest lock, so + /// neither loses the other's edit even though they are different writers. + #[cfg(unix)] + #[test] + fn provision_and_push_serialise_on_the_manifest_lock() { + use std::sync::Arc; + use std::thread; + + let dir = tempdir().expect("tempdir"); + let manifest = Arc::new(dir.path().join("fastly.toml")); + fs::write( + manifest.as_ref(), + "name = \"demo\"\n\n[local_server.config_stores.app_config]\nformat = \"inline-toml\"\n\n[local_server.config_stores.app_config.contents]\n", + ) + .expect("seed"); + + for _round in 0_u32..25 { + let p_provision = Arc::clone(&manifest); + let p_push = Arc::clone(&manifest); + let provision = + thread::spawn(move || append_fastly_setup(&p_provision, "config", "app_config")); + let push = thread::spawn(move || { + write_fastly_local_config_store( + &p_push, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "hi".to_owned())], + &[], + ) + }); + provision + .join() + .expect("provision thread") + .expect("provision"); + push.join().expect("push thread").expect("push"); + + let after = fs::read_to_string(manifest.as_ref()).expect("read"); + assert!( + after.contains("[setup.config_stores.app_config]"), + "provision's setup block must survive:\n{after}" + ); + assert!( + after.contains("greeting = \"hi\""), + "push's config edit must survive:\n{after}" + ); + } + } +} diff --git a/crates/edgezero-adapter-fastly/src/cli/provision_local.rs b/crates/edgezero-adapter-fastly/src/cli/provision_local.rs new file mode 100644 index 00000000..98169225 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/provision_local.rs @@ -0,0 +1,2216 @@ +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +use edgezero_adapter::registry::{ + AdapterDeployedState, ProvisionOutcome, ProvisionStores, TypedSecretEntry, +}; + +use crate::chunked_config::{ + prior_chunk_keys, resolve_fastly_config_value_typed, value_is_future_format, +}; + +use super::push_local::{is_prunable_leaf, reject_local_generated_key_collisions}; +use super::{ManifestLock, atomically_replace_file}; + +/// Local-mode provision: seed Viceroy state in `fastly.toml` for the +/// declared stores + the `edgezero_runtime_env` runtime-override +/// store. NO shell-outs to `fastly` -- everything is a `toml_edit` +/// mutation, so operators can run `provision --local` without +/// authenticating. +/// +/// The manifest must already exist (the CLI bootstrap writes it +/// via `synthesise_fastly_toml`); we deliberately don't re-synthesise +/// here because the app name isn't in scope at this call site. +/// +/// `deployed.fields.get("service_id")`, when present, is upserted to +/// the top-level `service_id` key -- spec says the deployed +/// service-id wins over anything the operator pre-seeded from a stale +/// template. When `deployed` has no `service_id` we leave any existing +/// value alone (operator's local seed is authoritative). +/// +/// All other mutations (kv-store blocks, config-store blocks, runtime +/// override block) are idempotent — re-running is a no-op. +pub(super) fn provision( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + stores: &ProvisionStores<'_>, + deployed: Option<&AdapterDeployedState>, + dry_run: bool, +) -> Result { + use toml_edit::DocumentMut; + + // The `.env` writer upper-cases each logical id into an + // `EDGEZERO__STORES______NAME` line; ids differing + // only by case would collapse onto one variable and `env_file`'s + // dedup would silently drop the loser. Reject before any write. + stores.reject_case_colliding_logical_ids()?; + // A user store named like the internal runtime-override config store + // would be merged into it -- reject before any write. + super::reject_reserved_store_names(stores)?; + + let fastly_rel = adapter_manifest_path.unwrap_or("fastly.toml"); + let fastly_path = manifest_root.join(fastly_rel); + if !fastly_path.exists() { + return Err(format!( + "expected fastly.toml at {} (the CLI bootstrap should have written it before provision ran)", + fastly_path.display() + )); + } + let raw = fs::read_to_string(&fastly_path) + .map_err(|err| format!("failed to read {}: {err}", fastly_path.display()))?; + let mut doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", fastly_path.display()))?; + + let mut status_lines: Vec = Vec::new(); + + // 1. Upsert top-level `service_id` from deployed. Applies to BOTH + // synthesis and MERGE paths -- operators who pre-seeded + // fastly.toml from a stale template still get the cloud- + // authoritative id pinned. No cloud authority => leave any + // existing operator-set value alone. + // + // TOML root-key positioning matters here: once the parsed doc has + // any headed sub-table (`[scripts]`, `[local_server]`, …), a naive + // `doc.insert("service_id", …)` appends the scalar AFTER those + // headers, and the re-serialised file parses the value as + // `local_server.service_id`. `upsert_root_scalar_before_tables` + // preserves the "scalars before sub-tables" TOML rule regardless + // of insertion order. + if let Some(sid) = deployed.and_then(|state| state.fields.get("service_id")) { + upsert_root_scalar_before_tables(&mut doc, "service_id", sid.as_str()); + status_lines.push(format!( + "fastly: pinned service_id = \"{sid}\" from deployed" + )); + } + + // Path suffix threaded into each status line so the operator sees + // exactly which file each mutation landed in. Cheap to include + // per-line and load-bearing when the manifest lives in a nested + // adapter crate (`crates/demo-fastly/fastly.toml`) rather than at + // the project root. + let path_display = fastly_path.display().to_string(); + + // 2. [[local_server.kv_stores.]] per KV store. + for store in stores.kv { + upsert_local_kv_store(&mut doc, &store.platform)?; + status_lines.push(format!( + "fastly: wrote local kv_store `{}` (logical id `{}`) in {path_display}", + store.platform, store.logical + )); + } + + // 3. [local_server.config_stores.] + empty `.contents` + // sub-table per CONFIG store. `contents` MUST be a TOML table + // (not `contents = ""`) -- the `config push --local` writer + // edits it in place via `as_table_mut()`. + for store in stores.config { + upsert_local_config_store(&mut doc, &store.platform)?; + status_lines.push(format!( + "fastly: wrote local config_store `{}` (logical id `{}`) in {path_display}", + store.platform, store.logical + )); + } + + // 4. `edgezero_runtime_env` block: __NAME lines for all kinds + + // commented __KEY placeholders for CONFIG stores. Same + // discipline as Cloudflare `.dev.vars`. + if upsert_runtime_env_config_store(&mut doc, stores)? { + status_lines.push(format!( + "fastly: wrote edgezero_runtime_env block in {path_display}" + )); + } + + if !dry_run { + fs::write(&fastly_path, doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", fastly_path.display()))?; + } + + Ok(ProvisionOutcome::from_status_lines(status_lines)) +} + +/// Local-mode `provision_typed`: append `[[local_server.secret_stores.]]` +/// entries in `fastly.toml`. Cloud secret storage uses `fastly secret-store-entry +/// create` at deploy time — the caller in `mod.rs` gates this on `ProvisionMode::Local` +/// and returns `Ok(ProvisionOutcome::default())` for cloud mode. +pub(super) fn provision_typed( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + typed_secrets: &[TypedSecretEntry<'_>], + dry_run: bool, +) -> Result { + let fastly_rel = adapter_manifest_path.unwrap_or("fastly.toml"); + let fastly_path = manifest_root.join(fastly_rel); + if !fastly_path.exists() { + return Err(format!( + "expected fastly.toml at {} (the CLI bootstrap should have written it before provision ran)", + fastly_path.display() + )); + } + let raw = fs::read_to_string(&fastly_path) + .map_err(|err| format!("failed to read {}: {err}", fastly_path.display()))?; + let mut doc: toml_edit::DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", fastly_path.display()))?; + + let mut status_lines: Vec = Vec::new(); + let mut appended = 0_usize; + + let path_display = fastly_path.display().to_string(); + for entry in typed_secrets { + // Seed the Viceroy store under the PLATFORM name -- the name + // the runtime resolves via `EDGEZERO__STORES__SECRETS____NAME` + // and passes to `SecretStore::open`. Keying it by the logical + // `store_id` would leave the runtime opening a store this seed + // never created whenever an env override renames it. + let added = upsert_secret_store_entry(&mut doc, &entry.platform, entry.key_value)?; + if added { + appended = appended.saturating_add(1); + } + // Logical id in the human wording, platform name only when it + // differs (an env override is in play) so the operator can see + // exactly which Viceroy store was written. + let store_label = if entry.platform == entry.store_id { + entry.store_id.to_owned() + } else { + format!("{} (platform `{}`)", entry.store_id, entry.platform) + }; + status_lines.push(format!( + "fastly: wrote secret_store `{store_label}` key `{}` (env `{}`) in {path_display}", + entry.key_value, + entry.key_value.to_ascii_uppercase(), + )); + } + + if !dry_run && appended > 0 { + fs::write(&fastly_path, doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", fastly_path.display()))?; + } + + Ok(ProvisionOutcome::from_status_lines(status_lines)) +} + +/// Upsert a scalar key at the root of `doc`, guaranteeing it lands +/// BEFORE any headed sub-table. +/// +/// TOML root-key rule: once a `[header]` opens a sub-table, every +/// subsequent `key = "value"` line is parsed as a child of that header. +/// `toml_edit::DocumentMut::insert` appends at end-of-order, so +/// inserting a root scalar after the doc has picked up any `[scripts]` +/// / `[local_server]` header from parse silently produces +/// `local_server.` on re-emit. +/// +/// If the key already exists, we update the value IN PLACE preserving +/// its decor (a trailing inline comment survives) -- a plain `insert` +/// would replace the whole item and drop it. Only the fresh-insert +/// case needs the reorder dance: hoist every root-level sub-table / +/// array-of-tables out, insert the scalar, then re-attach the tables +/// in original order. +fn upsert_root_scalar_before_tables(doc: &mut toml_edit::DocumentMut, key: &str, val: &str) { + use toml_edit::value; + let table = doc.as_table_mut(); + if let Some(existing) = table.get_mut(key).and_then(toml_edit::Item::as_value_mut) { + let mut replacement = toml_edit::Value::from(val); + *replacement.decor_mut() = existing.decor().clone(); + *existing = replacement; + return; + } + if table.contains_key(key) { + table.insert(key, value(val)); + return; + } + let sub_table_keys: Vec = table + .iter() + .filter(|(_, item)| item.is_table() || item.is_array_of_tables()) + .map(|(name, _)| name.to_owned()) + .collect(); + let mut removed: Vec<(String, toml_edit::Item)> = Vec::with_capacity(sub_table_keys.len()); + for name in sub_table_keys { + if let Some(item) = table.remove(&name) { + removed.push((name, item)); + } + } + table.insert(key, value(val)); + for (name, item) in removed { + table.insert(&name, item); + } +} + +/// Append `[[local_server.kv_stores.]]` with a stub +/// `key = "__init__"` / `data = ""` row to `doc`, IFF no entry with +/// that platform name already exists. Idempotent. +fn upsert_local_kv_store( + doc: &mut toml_edit::DocumentMut, + platform_name: &str, +) -> Result<(), String> { + use toml_edit::{ArrayOfTables, Item, Table, value}; + + let local_server_entry = doc + .entry("local_server") + .or_insert_with(|| Item::Table(Table::new())); + let local_server_tbl = local_server_entry.as_table_mut().ok_or_else(|| { + "`local_server` exists but is not a table; refusing to edit in place".to_owned() + })?; + let kv_stores_entry = local_server_tbl + .entry("kv_stores") + .or_insert_with(|| Item::Table(Table::new())); + let kv_stores_tbl = kv_stores_entry.as_table_mut().ok_or_else(|| { + "`local_server.kv_stores` exists but is not a table; refusing to edit in place".to_owned() + })?; + // Idempotent: skip if an array-of-tables (or anything) already + // registered for this platform name. + if kv_stores_tbl.contains_key(platform_name) { + return Ok(()); + } + let mut arr = ArrayOfTables::new(); + let mut row = Table::new(); + row.insert("key", value("__init__")); + row.insert("data", value("")); + arr.push(row); + kv_stores_tbl.insert(platform_name, Item::ArrayOfTables(arr)); + Ok(()) +} + +/// Insert `[local_server.config_stores.]` with +/// `format = "inline-toml"` and an EMPTY `contents` sub-TABLE. The +/// empty table (NOT `contents = ""`) is load-bearing: the Fastly +/// `config push --local` writer edits `contents` in place via +/// `as_table_mut()` and refuses to proceed if it isn't a table. +/// Idempotent — skip if the block already exists. +fn upsert_local_config_store( + doc: &mut toml_edit::DocumentMut, + platform_name: &str, +) -> Result<(), String> { + use toml_edit::{Item, Table, value}; + + let local_server_entry = doc + .entry("local_server") + .or_insert_with(|| Item::Table(Table::new())); + let local_server_tbl = local_server_entry.as_table_mut().ok_or_else(|| { + "`local_server` exists but is not a table; refusing to edit in place".to_owned() + })?; + let config_stores_entry = local_server_tbl + .entry("config_stores") + .or_insert_with(|| Item::Table(Table::new())); + let config_stores_tbl = config_stores_entry.as_table_mut().ok_or_else(|| { + "`local_server.config_stores` exists but is not a table; refusing to edit in place" + .to_owned() + })?; + if config_stores_tbl.contains_key(platform_name) { + return Ok(()); + } + let mut store_tbl = Table::new(); + store_tbl.set_implicit(false); + store_tbl.insert("format", value("inline-toml")); + let mut contents_tbl = Table::new(); + contents_tbl.set_implicit(false); + store_tbl.insert("contents", Item::Table(contents_tbl)); + config_stores_tbl.insert(platform_name, Item::Table(store_tbl)); + Ok(()) +} + +/// Additive-merge the managed `__NAME` keys into an EXISTING +/// runtime-env `.contents` table, and append a commented `__KEY` hint +/// for each CONFIG store added THIS run (its `__NAME` was absent +/// before), so an incremental provision converges to the same shape a +/// clean first-write produces. Returns `true` when anything changed. +fn merge_runtime_env_keys( + contents_tbl: &mut toml_edit::Table, + managed_keys: &[(String, String)], + stores: &ProvisionStores<'_>, +) -> bool { + use std::collections::HashSet; + use toml_edit::value; + + // Keys present BEFORE this run — used to detect newly-added stores. + let existing_before: HashSet = + contents_tbl.iter().map(|(key, _)| key.to_owned()).collect(); + let mut added = false; + for (key, platform) in managed_keys { + if !contents_tbl.contains_key(key) { + contents_tbl.insert(key, value(platform.as_str())); + added = true; + } + } + let new_config_comment: String = stores + .config + .iter() + .filter(|store| { + !existing_before.contains(&format!( + "EDGEZERO__STORES__CONFIG__{}__NAME", + store.logical.to_ascii_uppercase() + )) + }) + .map(|store| { + let upper = store.logical.to_ascii_uppercase(); + let logical = store.logical.as_str(); + format!("\n# EDGEZERO__STORES__CONFIG__{upper}__KEY = \"{logical}_staging\"") + }) + .collect::>() + .concat(); + if !new_config_comment.is_empty() + && let Some(last) = contents_tbl.iter().last().map(|(key, _)| key.to_owned()) + && let Some(item) = contents_tbl.get_mut(&last) + && let Some(val) = item.as_value_mut() + { + // Append to any existing suffix rather than clobber it. + let mut suffix = val + .decor() + .suffix() + .and_then(toml_edit::RawString::as_str) + .unwrap_or("") + .to_owned(); + suffix.push_str(&new_config_comment); + val.decor_mut().set_suffix(suffix); + added = true; + } + added +} + +/// Ensure `[local_server.config_stores.edgezero_runtime_env]` exists +/// and add any missing managed keys to its `.contents` sub-table: +/// - one `EDGEZERO__STORES______NAME = ""` +/// line per declared store across ALL kinds (KV / CONFIG / SECRETS); +/// - one COMMENTED `# EDGEZERO__STORES__CONFIG____KEY = +/// "_staging"` placeholder per CONFIG store, mirroring the +/// Cloudflare `.dev.vars` discipline. Fastly has no way to preview +/// the KEY overlay at provision time — commented placeholders hint +/// the shape and let the operator uncomment + fill it in. +/// +/// **Additive merge** (spec §"Merge mechanics"): on re-provision after +/// adding a store, the block already exists — we open its `.contents` +/// table and insert only the managed keys that aren't present. +/// Operator-set values and non-managed keys are left byte-for-byte. +/// A commented `__KEY` placeholder is emitted for each CONFIG store +/// added in THIS run (its `__NAME` key was absent before), so an +/// incremental provision converges to the same shape a clean provision +/// would produce. Existing config stores are left untouched — their +/// hint may have been intentionally uncommented or removed. +/// +/// Returns `true` when the block was newly written OR at least one +/// key was added; `false` when nothing changed. +fn upsert_runtime_env_config_store( + doc: &mut toml_edit::DocumentMut, + stores: &ProvisionStores<'_>, +) -> Result { + use toml_edit::{Item, Table, value}; + + const RUNTIME_ENV_NAME: &str = "edgezero_runtime_env"; + + let local_server_entry = doc + .entry("local_server") + .or_insert_with(|| Item::Table(Table::new())); + let local_server_tbl = local_server_entry.as_table_mut().ok_or_else(|| { + "`local_server` exists but is not a table; refusing to edit in place".to_owned() + })?; + let config_stores_entry = local_server_tbl + .entry("config_stores") + .or_insert_with(|| Item::Table(Table::new())); + let config_stores_tbl = config_stores_entry.as_table_mut().ok_or_else(|| { + "`local_server.config_stores` exists but is not a table; refusing to edit in place" + .to_owned() + })?; + + // Compute the full managed __NAME key set once — used both for + // first-write insertion and for additive-merge gap-fill. + let managed_keys: Vec<(String, String)> = [ + ("KV", stores.kv), + ("CONFIG", stores.config), + ("SECRETS", stores.secrets), + ] + .into_iter() + .flat_map(|(kind_label, kind_stores)| { + kind_stores.iter().map(move |store| { + ( + format!( + "EDGEZERO__STORES__{kind_label}__{}__NAME", + store.logical.to_ascii_uppercase() + ), + store.platform.clone(), + ) + }) + }) + .collect(); + + let block_existed = config_stores_tbl.contains_key(RUNTIME_ENV_NAME); + if block_existed { + // Additive merge path. Open the existing block's `.contents` + // sub-table and insert only the managed keys that aren't there. + let store_entry = config_stores_tbl.get_mut(RUNTIME_ENV_NAME).ok_or_else(|| { + format!( + "`local_server.config_stores.{RUNTIME_ENV_NAME}` disappeared between contains_key and get_mut" + ) + })?; + let store_tbl = store_entry.as_table_mut().ok_or_else(|| { + format!( + "`local_server.config_stores.{RUNTIME_ENV_NAME}` exists but is not a table; refusing to edit in place" + ) + })?; + let contents_entry = store_tbl + .entry("contents") + .or_insert_with(|| Item::Table(Table::new())); + let contents_tbl = contents_entry.as_table_mut().ok_or_else(|| { + format!( + "`local_server.config_stores.{RUNTIME_ENV_NAME}.contents` exists but is not a table; refusing to edit in place" + ) + })?; + return Ok(merge_runtime_env_keys(contents_tbl, &managed_keys, stores)); + } + + // First-write path — build the whole block, including the + // commented __KEY placeholder decor. + let mut store_tbl = Table::new(); + store_tbl.set_implicit(false); + store_tbl.insert("format", value("inline-toml")); + + let mut contents_tbl = Table::new(); + contents_tbl.set_implicit(false); + for (key, platform) in &managed_keys { + contents_tbl.insert(key, value(platform.as_str())); + } + + // Commented `__KEY` placeholders for CONFIG stores. Toml_edit + // has no primitive for "commented key inside a table", so we + // stash the comment lines as a suffix on the last-inserted + // key/value's decor. The test asserts only presence-as-substring + // in the raw file text, so location within the block doesn't + // matter — but appending at the end keeps the __NAME contract + // uncontaminated (a re-parse still yields only real keys). + let comment_suffix: String = stores + .config + .iter() + .map(|store| { + let upper = store.logical.to_ascii_uppercase(); + let logical = store.logical.as_str(); + format!("\n# EDGEZERO__STORES__CONFIG__{upper}__KEY = \"{logical}_staging\"") + }) + .collect::>() + .concat(); + if !comment_suffix.is_empty() { + let last_key = contents_tbl.iter().last().map(|(key, _)| key.to_owned()); + if let Some(last) = last_key { + if let Some(item) = contents_tbl.get_mut(&last) + && let Some(val) = item.as_value_mut() + { + val.decor_mut().set_suffix(comment_suffix); + } + } else { + // Edge case: no declared stores at all (contents_tbl is + // empty). Attach the comments via the contents table's + // own decor so they survive serialisation. + contents_tbl.decor_mut().set_suffix(comment_suffix); + } + } + + store_tbl.insert("contents", Item::Table(contents_tbl)); + config_stores_tbl.insert(RUNTIME_ENV_NAME, Item::Table(store_tbl)); + Ok(true) +} + +/// Append one `[[local_server.secret_stores.]]` entry with +/// `key = ""` and `env = ""` — Fastly's +/// secret-store convention pairs the key name with the env var the +/// local runtime exposes it under. Idempotent: if the target array +/// already contains an entry with matching `key = ""` we +/// skip and leave sibling entries (including operator-adjusted `env` +/// values) alone. Returns `Ok(true)` when a new entry was appended, +/// `Ok(false)` when a matching key was already present. +/// +/// Refuses to clobber non-standard values: if the target +/// `secret_stores.` node exists but isn't an array of +/// tables, or if `local_server` / `local_server.secret_stores` +/// exist but aren't tables, the helper errors with a "refusing to +/// edit in place" diagnostic. +fn upsert_secret_store_entry( + doc: &mut toml_edit::DocumentMut, + store_id: &str, + key_value: &str, +) -> Result { + use toml_edit::{ArrayOfTables, Item, Table, value}; + + let local_server_entry = doc + .entry("local_server") + .or_insert_with(|| Item::Table(Table::new())); + let local_server_tbl = local_server_entry.as_table_mut().ok_or_else(|| { + "`local_server` exists but is not a table; refusing to edit in place".to_owned() + })?; + let secret_stores_entry = local_server_tbl + .entry("secret_stores") + .or_insert_with(|| Item::Table(Table::new())); + let secret_stores_tbl = secret_stores_entry.as_table_mut().ok_or_else(|| { + "`local_server.secret_stores` exists but is not a table; refusing to edit in place" + .to_owned() + })?; + let store_entry = secret_stores_tbl + .entry(store_id) + .or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())); + let store_arr = store_entry.as_array_of_tables_mut().ok_or_else(|| { + format!( + "`local_server.secret_stores.{store_id}` exists but is not an array of tables; refusing to edit in place" + ) + })?; + for existing in store_arr.iter() { + if existing.get("key").and_then(|item| item.as_str()) == Some(key_value) { + return Ok(false); + } + } + let mut row = Table::new(); + row.insert("key", value(key_value)); + row.insert("env", value(key_value.to_ascii_uppercase())); + store_arr.push(row); + Ok(true) +} + +/// The error `config push --local` reports when the provision-owned +/// `[local_server.config_stores..contents]` table is absent. +/// Shared so the writer and the read-only dry-run probe stay in sync. +fn missing_local_config_store_error(path: &Path, platform_name: &str) -> String { + format!( + "{}: `[local_server.config_stores.{platform_name}.contents]` is missing or not a table; run `provision --adapter fastly --local` first to create the local config store, then re-run config push", + path.display() + ) +} + +/// Read-only counterpart to [`write_fastly_local_config_store`]'s +/// structural precondition: succeeds only when the provision-owned +/// `[local_server.config_stores..contents]` table already +/// exists. +/// +/// `config push --local --dry-run` calls this so the preview models the +/// real run's refusal WITHOUT touching the file -- previewing an edit the +/// real push would reject is worse than no preview at all. +pub(super) fn assert_local_config_store_provisioned( + path: &Path, + platform_name: &str, +) -> Result<(), String> { + use std::io::ErrorKind; + use toml_edit::{DocumentMut, Item}; + + let raw = fs::read_to_string(path).map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!( + "{}: not found; run `provision --adapter fastly --local` first to create the local config store, then re-run config push", + path.display() + ) + } else { + format!("failed to read {}: {err}", path.display()) + } + })?; + let doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + doc.get("local_server") + .and_then(Item::as_table) + .and_then(|tbl| tbl.get("config_stores")) + .and_then(Item::as_table) + .and_then(|tbl| tbl.get(platform_name)) + .and_then(Item::as_table) + .and_then(|tbl| tbl.get("contents")) + .and_then(Item::as_table) + .ok_or_else(|| missing_local_config_store_error(path, platform_name))?; + Ok(()) +} + +/// Write the local-server config-store entries to `fastly.toml`: +/// `[local_server.config_stores.]` becomes +/// `format = "inline-toml"`, and `[local_server.config_stores..contents]` +/// gets the flat `key = "value"` pairs (overwriting any previous +/// values). Idempotent — re-running just rewrites `contents`. Other +/// blocks in `fastly.toml` (setup, scripts, the actual `[local_server]` +/// secret stores, etc.) are preserved via `toml_edit`. +pub(super) fn write_fastly_local_config_store( + path: &Path, + platform_name: &str, + entries: &[(String, String)], + gc_roots: &[(String, HashSet)], +) -> Result, String> { + use std::io::ErrorKind; + use toml_edit::{DocumentMut, Item, Value}; + + // Hold a cross-process advisory lock for the WHOLE read-modify-write. Two + // concurrent local pushes would otherwise both read the file, each apply + // their own edit, and the later rename would discard the earlier push's + // change. Serialising here makes each push read what the previous one wrote + // and build on it, so both edits survive. Released when `lock` drops. + let lock = ManifestLock::acquire(path)?; + // Read and replace the REAL target the lock guards, so a symlinked manifest + // and a direct path never diverge between the read, the compare, and the + // rename. + let target = lock.target(); + + // `config push --local` OWNS only the `key = value` pairs INSIDE an + // already-provisioned contents table. Creating the `[local_server]` + // header, the `config_stores` table, the per-store block, and its + // `format` / empty `contents` sub-table is provision's job (see + // `upsert_local_config_store`). Fabricating any of that here would + // let a stray push mint a config store the manifest never + // provisioned -- possibly with the wrong shape -- so a missing store + // block is an error that points the operator at provision, not + // something push papers over. + let raw = fs::read_to_string(target).map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!( + "{}: not found; run `provision --adapter fastly --local` first to create the local config store, then re-run config push", + target.display() + ) + } else { + format!("failed to read {}: {err}", target.display()) + } + })?; + // Redacted: `toml_edit`'s parse error quotes the offending source LINE, which + // in a config-store `contents` table is a stored (possibly secret-bearing) + // value. The diff read redacts the same failure; the writer must too. + let mut doc: DocumentMut = raw.parse().map_err(|_err| { + format!( + "failed to parse {} as TOML (details redacted: the error can quote a stored value)", + target.display() + ) + })?; + + // Navigate (error if absent) to the provision-owned per-store table, then its + // `contents`. Upsert into the EXISTING contents table so a + // `config push --key app_config_staging` does NOT wipe the previously-pushed + // `app_config` blob (spec 12.7 requires default + staging keys to coexist). + let store_tbl = doc + .get_mut("local_server") + .and_then(Item::as_table_mut) + .and_then(|tbl| tbl.get_mut("config_stores")) + .and_then(Item::as_table_mut) + .and_then(|tbl| tbl.get_mut(platform_name)) + .and_then(Item::as_table_mut) + .ok_or_else(|| missing_local_config_store_error(path, platform_name))?; + ensure_inline_toml_format(store_tbl, platform_name)?; + let contents_tbl = store_tbl + .get_mut("contents") + .and_then(Item::as_table_mut) + .ok_or_else(|| missing_local_config_store_error(path, platform_name))?; + + reject_future_local_roots(contents_tbl, gc_roots)?; + reject_local_generated_key_collisions(contents_tbl, entries)?; + // Snapshot prior chunk keys per GC root BEFORE the upsert, using the exact + // keep-set the caller computed for each root (no prefix scan). + let mut plans: Vec = Vec::with_capacity(gc_roots.len()); + for (root_key, new_keys) in gc_roots { + let prior_keys = contents_tbl + .get(root_key) + .and_then(toml_edit::Item::as_str) + .map_or_else(|| Ok(Vec::new()), |value| prior_chunk_keys(root_key, value)); + plans.push(FastlyConfigGcPlan { + new_keys: new_keys.clone(), + prior_keys, + }); + } + + // Upsert the new physical entries. + for (key, value) in entries { + contents_tbl.insert(key, Item::Value(Value::from(value.clone()))); + } + + // Prune orphans in the same in-memory rewrite; a suspicious prior pointer + // (Err) warns and deletes nothing. + let mut warnings = Vec::new(); + for plan in &plans { + match orphan_chunk_keys(plan) { + Ok(orphans) => { + for key in orphans { + // Never remove an orphan that is itself protected -- only a + // raw leaf PAYLOAD prunes. Shared with the dry-run count via + // `is_prunable_leaf`, so the preview can never disagree. + if !is_prunable_leaf(contents_tbl, &key) { + warnings.push(format!( + "warning: kept `{key}` -- it is a runtime-readable root, claims the \ + `edgezero_kind` namespace, or is a nested root with chunks beneath it; \ + not a prunable chunk payload" + )); + continue; + } + contents_tbl.remove(&key); + } + } + Err(err) => warnings.push(format!("warning: {err}")), + } + } + + atomically_replace_file(target, &raw, &doc.to_string())?; + Ok(warnings) +} + +/// Per-root plan for the LOCAL path's eager prune. +/// +/// Local reclamation is safe to do immediately: `fastly.toml` is a single file +/// that Viceroy reads at startup — there is no propagation window and no POP that +/// could still be serving the previous pointer. (The cloud path cannot do this.) +struct FastlyConfigGcPlan { + /// Exact keep-set this push writes for the root (chunk keys + root key). + new_keys: HashSet, + /// Prior chunk keys to consider deleting, or a warning to surface + /// (suspicious prior pointer) that skips GC for this root. + prior_keys: Result, String>, +} + +/// Orphans = prior chunk keys not in the new keep-set. Propagates a +/// suspicious-pointer `Err` so the caller can warn and skip GC. +fn orphan_chunk_keys(plan: &FastlyConfigGcPlan) -> Result, String> { + match &plan.prior_keys { + Ok(prior) => Ok(prior + .iter() + .filter(|key| !plan.new_keys.contains(*key)) + .cloned() + .collect()), + Err(err) => Err(err.clone()), + } +} + +/// Ensure a local config-store entry is `format = "inline-toml"` -- the only +/// format compatible with the inline `contents` this writer emits. +/// +/// REFUSES an existing non-inline store rather than converting it: a +/// `format = "json"` / `"file"` store points at an EXTERNAL file this writer +/// cannot safely rewrite. Migration is the operator's explicit choice. +fn ensure_inline_toml_format( + store_tbl: &mut toml_edit::Table, + platform_name: &str, +) -> Result<(), String> { + let existing = store_tbl.get("format").and_then(toml_edit::Item::as_str); + match existing { + Some("inline-toml") => Ok(()), + Some(other) => Err(format!( + "refusing to push: `local_server.config_stores.{platform_name}` uses `format = \ + \"{other}\"` (an external-file store), which is incompatible with the inline \ + `contents` this command writes. Converting it here would either produce a manifest \ + the local server rejects or silently discard the sibling entries the external file \ + holds. Migrate the store to `format = \"inline-toml\"` (or a fresh store id) yourself, \ + then re-run. Nothing was changed." + )), + None => { + // A brand-new or format-less entry: this writer owns it, so stamp the + // inline format it is about to fill. + store_tbl.insert("format", toml_edit::value("inline-toml")); + Ok(()) + } + } +} + +/// TOCTOU guard for the LOCAL writer: refuse to overwrite a root that now holds a +/// NEWER format, classified HERE under the write lock. The generic push's +/// pre-push future-format check ran BEFORE the lock, so a newer writer could have +/// installed a v2 value in between; without this the old writer would clobber it. +fn reject_future_local_roots( + contents_tbl: &toml_edit::Table, + gc_roots: &[(String, HashSet)], +) -> Result<(), String> { + for (root_key, _) in gc_roots { + let Some(existing) = contents_tbl.get(root_key).and_then(toml_edit::Item::as_str) else { + continue; + }; + // Raw check: a direct future envelope, a future pointer version, or an + // unknown `edgezero_kind`. + let mut is_future = value_is_future_format(existing); + if !is_future { + // Resolve against the locked contents to catch a future INNER envelope + // behind a valid v1 pointer. Only `FutureFormat` blocks the write; a + // corrupt/incomplete v1 prior stays overwritable. + let resolved = resolve_fastly_config_value_typed(root_key, existing.to_owned(), |ck| { + Ok(contents_tbl + .get(ck) + .and_then(toml_edit::Item::as_str) + .map(str::to_owned)) + }); + is_future = matches!(resolved, Err(err) if err.is_future_format()); + } + if is_future { + return Err(format!( + "refusing to overwrite `{root_key}`: the local store now holds a value in a newer \ + format this CLI does not recognise (installed since the pre-push check). Upgrade \ + the CLI rather than clobber a newer format. Nothing was changed." + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::super::FastlyCliAdapter; + #[cfg(unix)] + use super::super::path_mutation_guard; + use super::super::run::synthesise_fastly_toml; + use super::*; + use edgezero_adapter::registry::{ + Adapter as _, ProvisionMode, ResolvedStoreId, TypedSecretEntry, + }; + use edgezero_core::test_env::PathPrepend; + use tempfile::tempdir; + + // Shared fixture names. Pinning these as consts (instead of + // inline `"sessions"` / `"app_config"` per call site) keeps the + // setup-vs-assertion pair in sync -- a typo in one place no + // longer silently divorces from the other, because both reference + // the same const. Also names the intent: these are the LOGICAL + // store ids the fastly adapter operates on, not arbitrary strings. + const TEST_KV_ID: &str = "sessions"; + const TEST_CONFIG_ID: &str = "app_config"; + + /// A shell script named `fastly` that exits non-zero and prints an + /// unambiguous diagnostic to stderr — installed on `$PATH` to + /// detect any (forbidden) invocation of the platform CLI during a + /// Local-mode provision. Any call fails the test with `exit 42`. + #[cfg(unix)] + fn fake_fastly_panicking() -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let script = dir.path().join("fastly"); + fs::write( + &script, + "#!/usr/bin/env bash\necho 'fastly was called during local provision' >&2\nexit 42\n", + ) + .expect("write fake fastly"); + let mut perms = fs::metadata(&script).expect("stat").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script, perms).expect("chmod +x"); + dir + } + + // ---------- provision (local mode) ---------- + + #[test] + fn local_provision_rejects_reserved_runtime_env_store_name() { + // A user store whose PLATFORM name resolves to the reserved + // `edgezero_runtime_env` (here via an env overlay on a benign logical + // id) would be merged into provision's own runtime-override store. + // Refuse before any write. + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + let config_ids = vec![ResolvedStoreId::new("app_config", "edgezero_runtime_env")]; + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + let Err(err) = FastlyCliAdapter.provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) else { + panic!("a store colliding with the reserved runtime-env name must be refused"); + }; + assert!( + err.contains("reserved") && err.contains("edgezero_runtime_env"), + "error explains the reserved-name collision: {err}" + ); + } + + #[test] + fn synthesised_fastly_toml_honors_renamed_adapter_crate() { + use std::path::PathBuf; + + // Reviewer regression: with + // `[adapters.fastly.adapter].manifest = "crates/fast-edge/svc/fastly.toml"` + // + `[package].name = "fast-edge"`, clean-clone provision + // must emit `name = "fast-edge"` — NOT the fallback + // `demo-app-adapter-fastly`. Also covers the nested + // manifest shape (`crates/fast-edge/svc/fastly.toml`). + let dir = tempdir().expect("tempdir"); + let root = dir.path(); + let crate_dir = root.join("crates/fast-edge"); + fs::create_dir_all(crate_dir.join("svc")).unwrap(); + fs::write( + crate_dir.join("Cargo.toml"), + "[package]\nname = \"fast-edge\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let outcome = FastlyCliAdapter + .synthesise_baseline_manifest( + root, + Some("crates/fast-edge/svc/fastly.toml"), + Some("crates/fast-edge"), + None, + "demo-app", + None, + &[], + ) + .expect("baseline synthesis succeeds for nested renamed crate"); + let (rel, body) = outcome.into_iter().next().unwrap(); + assert_eq!(rel, PathBuf::from("crates/fast-edge/svc/fastly.toml")); + assert!( + body.contains(r#"name = "fast-edge""#), + "fastly.toml must name the renamed adapter crate (fast-edge): {body}" + ); + assert!( + !body.contains(r#"name = "demo-app-adapter-fastly""#), + "MUST NOT fall back to scaffold convention when the Cargo.toml exists further up: {body}" + ); + } + + /// Local provision writes `[[local_server.kv_stores.]]` + /// and `[local_server.config_stores.]` blocks. The + /// config-store block's `contents` MUST be a TOML table (not + /// `contents = ""`), because the Fastly `config push --local` + /// writer edits it in place via `as_table_mut()`. + #[test] + fn fastly_local_provision_writes_kv_and_config_store_blocks() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + 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: &[], + }; + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&path).expect("read"); + // KV: array-of-tables with the stub row. + assert!( + after.contains("[[local_server.kv_stores.sessions]]"), + "kv block present: {after}" + ); + // Reparse-and-index instead of `.contains("key = \"__init__\"")` + // + `.contains("data = \"\"")`: those substrings would pass for + // BOTH the correct nested-row form AND the scenario where the + // stub keys land at the doc root (same class as the shipped + // service_id bug). Lock the row on the actual + // `[[local_server.kv_stores.sessions]]` block. + let after_doc: toml_edit::DocumentMut = after.parse().expect("re-parse merged fastly.toml"); + let kv_row = after_doc + .get("local_server") + .and_then(|item| item.get("kv_stores")) + .and_then(|item| item.get("sessions")) + .and_then(toml_edit::Item::as_array_of_tables) + .and_then(|arr| arr.get(0)) + .expect("[[local_server.kv_stores.sessions]] with at least one row"); + assert_eq!( + kv_row.get("key").and_then(toml_edit::Item::as_str), + Some("__init__"), + "kv stub `key = \"__init__\"` must sit inside [[local_server.kv_stores.sessions]]: {after}" + ); + assert_eq!( + kv_row.get("data").and_then(toml_edit::Item::as_str), + Some(""), + "kv stub `data = \"\"` must sit inside [[local_server.kv_stores.sessions]]: {after}" + ); + // CONFIG: table block plus empty contents SUB-TABLE (not + // `contents = ""`). Re-parse to confirm shape. + assert!( + after.contains("[local_server.config_stores.app_config]"), + "config-store block header present: {after}" + ); + assert!( + after.contains(r#"format = "inline-toml""#), + "config-store format key present: {after}" + ); + assert!( + after.contains("[local_server.config_stores.app_config.contents]"), + "config-store contents sub-table header present: {after}" + ); + assert!( + !after.contains(r#"contents = """#), + "contents MUST NOT be an empty string: {after}" + ); + let doc: toml_edit::DocumentMut = after.parse().expect("re-parse"); + assert!( + doc["local_server"]["config_stores"]["app_config"]["contents"] + .as_table() + .is_some(), + "contents parses as a table (required by config push --local)" + ); + } + + /// Local provision writes the `edgezero_runtime_env` runtime- + /// override block: `__NAME` lines for ALL declared kinds and + /// commented `__KEY` placeholders for CONFIG stores only. + #[test] + fn fastly_local_provision_writes_edgezero_runtime_env() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + 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: &[], + }; + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("[local_server.config_stores.edgezero_runtime_env]"), + "runtime-env block header present: {after}" + ); + assert!( + after.contains("[local_server.config_stores.edgezero_runtime_env.contents]"), + "runtime-env contents sub-table header present: {after}" + ); + assert!( + after.contains(r#"EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME = "app_config""#), + "CONFIG __NAME line: {after}" + ); + assert!( + after.contains(r#"EDGEZERO__STORES__KV__SESSIONS__NAME = "sessions""#), + "KV __NAME line: {after}" + ); + assert!( + after.contains(r#"# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY = "app_config_staging""#), + "commented CONFIG __KEY placeholder present: {after}" + ); + } + + /// Regression: re-provision after adding a store MUST add the new + /// store's `__NAME` line into the existing `edgezero_runtime_env` + /// block's `.contents` sub-table. Prior impl short-circuited + /// `Ok(false)` as soon as the block existed, leaving new stores + /// invisible to the local runtime. Violates spec §"Merge + /// mechanics" — "preserve operator-set values; only add what's + /// missing". + #[test] + fn fastly_local_provision_additively_merges_new_stores_into_existing_runtime_env() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + + // First provision: only the KV store is declared. + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }, + None, + ProvisionMode::Local, + false, + ) + .expect("first provision succeeds"); + + let after_first = fs::read_to_string(&path).expect("read"); + assert!( + after_first.contains(r#"EDGEZERO__STORES__KV__SESSIONS__NAME = "sessions""#), + "first provision wrote the KV __NAME line: {after_first}" + ); + assert!( + !after_first.contains("APP_CONFIG__NAME"), + "first provision must NOT emit a CONFIG line for a store that wasn't declared: {after_first}" + ); + + // Second provision: operator added a CONFIG store (and the + // block from run 1 already exists). The new store's __NAME + // line MUST land inside the existing runtime-env contents + // sub-table. + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &[], + }, + None, + ProvisionMode::Local, + false, + ) + .expect("second provision succeeds"); + + let after_second = fs::read_to_string(&path).expect("read"); + // Additive: original KV line preserved. + assert!( + after_second.contains(r#"EDGEZERO__STORES__KV__SESSIONS__NAME = "sessions""#), + "second provision must preserve the KV __NAME line: {after_second}" + ); + // Additive: new CONFIG line inserted into the existing block. + assert!( + after_second.contains(r#"EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME = "app_config""#), + "second provision must ADD the new CONFIG __NAME line into the existing runtime-env block: {after_second}" + ); + // No duplicate runtime-env block header. + let block_header = "[local_server.config_stores.edgezero_runtime_env]"; + assert_eq!( + after_second.matches(block_header).count(), + 1, + "runtime-env block header must appear exactly once (no duplicate block emitted): {after_second}" + ); + // The newly-added CONFIG store must get its commented `__KEY` + // hint on the additive path too, so incremental provisioning + // converges to the same shape a clean provision would produce. + assert!( + after_second + .contains(r#"# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY = "app_config_staging""#), + "additive provision must emit the __KEY hint for the newly-added CONFIG store: {after_second}" + ); + } + + /// A missing `fastly.toml` is a bug in the CLI bootstrap path. + /// Provision must error CLEARLY -- naming the expected path -- + /// rather than silently re-synthesising (we don't have the app + /// name in scope here). + #[test] + fn fastly_local_provision_errors_if_manifest_absent() { + let dir = tempdir().expect("tempdir"); + // Do NOT pre-seed fastly.toml. + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + let err = FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect_err("missing manifest must error"); + assert!( + err.contains("fastly.toml"), + "error names the missing path: {err}" + ); + assert!( + err.contains(&dir.path().join("fastly.toml").display().to_string()), + "error contains the resolved absolute path: {err}" + ); + } + + /// Spec §"Fastly": the deployed `service_id` must be upserted + /// during BOTH synthesis AND merge. The synthesiser handles the + /// first-run bootstrap; THIS lock covers the merge case where + /// the operator pre-seeded fastly.toml from a stale template + /// before a deploy happened. + #[test] + fn fastly_local_provision_upserts_deployed_service_id_into_existing_manifest() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // Pre-seed WITHOUT service_id. + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + assert!( + !fs::read_to_string(&path) + .expect("read") + .contains("service_id"), + "baseline has no service_id" + ); + let mut deployed = AdapterDeployedState::default(); + deployed + .fields + .insert("service_id".to_owned(), "SVC1".to_owned()); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + Some(&deployed), + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains(r#"service_id = "SVC1""#), + "deployed service_id pinned into merged manifest: {after}" + ); + // Regression: `toml_edit::DocumentMut::insert` on a doc that + // already parsed `[local_server]` was appending `service_id` + // AFTER the header, so re-parse read it as + // `local_server.service_id` — a silent divergence that + // `.contains("service_id = \"SVC1\"")` never caught. Parse the + // re-emitted file and assert the key lives at the ROOT. + let reparsed: toml_edit::DocumentMut = + after.parse().expect("re-parse must succeed after upsert"); + assert_eq!( + reparsed.get("service_id").and_then(toml_edit::Item::as_str), + Some("SVC1"), + "service_id must live at the TOML root (not as local_server.service_id): {after}" + ); + } + + /// Inverse of the previous lock: when there's no cloud authority + /// (deployed = None), operator's local value wins. Provision must + /// NOT overwrite a `service_id` the operator set themselves. + #[test] + fn fastly_local_provision_leaves_operator_service_id_alone_when_deployed_absent() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", Some("operator-set"))).expect("write"); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains(r#"service_id = "operator-set""#), + "operator-set service_id survives when deployed absent: {after}" + ); + } + + /// `adapter_manifest_path` may be a NESTED relative path (e.g. + /// `crates/fastly/fastly.toml`). Provision must land its writes + /// in the nested file, NOT at a sibling under `manifest_root`. + #[test] + fn fastly_local_provision_resolves_nested_adapter_manifest_path() { + let dir = tempdir().expect("tempdir"); + let nested_rel = "crates/fastly/fastly.toml"; + let nested_path = dir.path().join(nested_rel); + fs::create_dir_all(nested_path.parent().expect("parent")).expect("mkdir"); + fs::write(&nested_path, synthesise_fastly_toml("demo", None)).expect("write"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + FastlyCliAdapter + .provision( + dir.path(), + Some(nested_rel), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&nested_path).expect("read nested"); + assert!( + after.contains("[[local_server.kv_stores.sessions]]"), + "merge lands in nested manifest: {after}" + ); + // And no sibling was created at manifest_root level. + let sibling = dir.path().join("fastly.toml"); + assert!( + !sibling.exists(), + "no sibling fastly.toml created at manifest_root" + ); + } + + /// Idempotency lock: running local provision twice on the same + /// fixture must leave the manifest bit-for-bit unchanged (mod the + /// first-run mutation). + #[test] + fn fastly_local_provision_is_idempotent_on_second_run() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + 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: &[], + }; + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("first run succeeds"); + let after_first = fs::read_to_string(&path).expect("read after first"); + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("second run succeeds"); + let after_second = fs::read_to_string(&path).expect("read after second"); + assert_eq!( + after_first, after_second, + "second provision is a no-op -- fastly.toml must be bit-for-bit unchanged" + ); + } + + // ---------- provision_typed (secret stores) ---------- + + /// Local `provision_typed` appends + /// `[[local_server.secret_stores.]]` entries with + /// `key = ""` and `env = ""` per + /// `TypedSecretEntry`, grouped by the entry's `store_id`. + #[test] + fn fastly_provision_typed_writes_secret_store_entries_under_resolved_store_id() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + let entries = [ + TypedSecretEntry::new("default", "api_token", "demo_api_token"), + TypedSecretEntry::new("vendor_secrets", "vendor_key", "vendor_demo_key"), + ]; + FastlyCliAdapter + .provision_typed( + dir.path(), + Some("fastly.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision_typed succeeds"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("[[local_server.secret_stores.default]]"), + "default store array-of-tables header present: {after}" + ); + assert!( + after.contains(r#"key = "demo_api_token""#), + "default store key line present: {after}" + ); + assert!( + after.contains(r#"env = "DEMO_API_TOKEN""#), + "default store env line uppercased: {after}" + ); + assert!( + after.contains("[[local_server.secret_stores.vendor_secrets]]"), + "vendor_secrets store array-of-tables header present: {after}" + ); + assert!( + after.contains(r#"key = "vendor_demo_key""#), + "vendor_secrets store key line present: {after}" + ); + assert!( + after.contains(r#"env = "VENDOR_DEMO_KEY""#), + "vendor_secrets store env line uppercased: {after}" + ); + // Confirm shape via re-parse: the per-store slot MUST be an + // ArrayOfTables (not a plain table) — Viceroy expects the + // array-of-tables form for secret-store entries. + let doc: toml_edit::DocumentMut = after.parse().expect("re-parse"); + assert!( + doc["local_server"]["secret_stores"]["default"] + .as_array_of_tables() + .is_some(), + "default is array-of-tables" + ); + assert!( + doc["local_server"]["secret_stores"]["vendor_secrets"] + .as_array_of_tables() + .is_some(), + "vendor_secrets is array-of-tables" + ); + } + + /// `adapter_manifest_path` may be a NESTED relative path. Entries + /// land in the nested `fastly.toml`, not at a sibling under + /// `manifest_root`. + #[test] + fn fastly_provision_typed_lands_in_resolved_fastly_toml_not_manifest_root() { + let dir = tempdir().expect("tempdir"); + let nested_rel = "crates/fastly/fastly.toml"; + let nested_path = dir.path().join(nested_rel); + fs::create_dir_all(nested_path.parent().expect("parent")).expect("mkdir"); + fs::write(&nested_path, synthesise_fastly_toml("demo", None)).expect("write"); + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + FastlyCliAdapter + .provision_typed( + dir.path(), + Some(nested_rel), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision_typed succeeds"); + let after = fs::read_to_string(&nested_path).expect("read nested"); + assert!( + after.contains("[[local_server.secret_stores.default]]"), + "entries land in nested manifest: {after}" + ); + assert!( + after.contains(r#"key = "demo_api_token""#), + "key line in nested manifest: {after}" + ); + let sibling = dir.path().join("fastly.toml"); + assert!( + !sibling.exists(), + "no sibling fastly.toml created at manifest_root" + ); + } + + /// Idempotency: a matching `key = ""` entry already in + /// the target array is preserved (including operator's non-matching + /// `env` override). No duplicate row is appended. + #[test] + fn fastly_provision_typed_deduplicates_matching_key() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + let mut seed = synthesise_fastly_toml("demo", None); + seed.push_str( + "\n[[local_server.secret_stores.default]]\nkey = \"demo_api_token\"\nenv = \"CUSTOM_ENV\"\n", + ); + fs::write(&path, &seed).expect("write"); + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + FastlyCliAdapter + .provision_typed( + dir.path(), + Some("fastly.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision_typed succeeds"); + let after = fs::read_to_string(&path).expect("read"); + // Operator's env override is preserved (not overwritten to the + // default `DEMO_API_TOKEN`). + assert!( + after.contains(r#"env = "CUSTOM_ENV""#), + "operator's env override preserved: {after}" + ); + assert!( + !after.contains(r#"env = "DEMO_API_TOKEN""#), + "adapter did NOT overwrite operator env: {after}" + ); + // Exactly one entry for the store with key = "demo_api_token". + let doc: toml_edit::DocumentMut = after.parse().expect("re-parse"); + let arr = doc["local_server"]["secret_stores"]["default"] + .as_array_of_tables() + .expect("default is array-of-tables"); + let matches: usize = arr + .iter() + .filter(|tbl| tbl.get("key").and_then(|item| item.as_str()) == Some("demo_api_token")) + .count(); + assert_eq!(matches, 1, "exactly one matching key entry: {after}"); + } + + /// Absent `fastly.toml` is a CLI bootstrap bug — error clearly + /// with the resolved absolute path, matching the + /// `provision_local` error style so both flows fail the same way. + #[test] + fn fastly_provision_typed_errors_if_manifest_absent() { + let dir = tempdir().expect("tempdir"); + // Do NOT pre-seed fastly.toml. + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + let err = FastlyCliAdapter + .provision_typed( + dir.path(), + Some("fastly.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect_err("missing manifest must error"); + assert!( + err.contains("fastly.toml"), + "error names the missing path: {err}" + ); + assert!( + err.contains(&dir.path().join("fastly.toml").display().to_string()), + "error contains the resolved absolute path: {err}" + ); + } + + // ---------- Section 9: provision_local_* contract suite ---------- + // + // Cross-adapter contract for `provision(mode=Local)`. Mirrors the + // Cloudflare/Spin/Axum suites so the four adapters share a single + // observable specification: the first run writes an expected set + // of files, re-provision is byte-identical, operator hand-edits to + // sibling entries survive a subsequent write, and Local mode never + // shells out to the platform CLI. + // + // Test #5 (additive merge of a new store into the existing + // `edgezero_runtime_env` block) is already covered by + // `fastly_local_provision_additively_merges_new_stores_into_existing_runtime_env` + // above — not re-implemented here to avoid duplicate coverage. + + /// Section 9.1 — First run: empty fixture with one KV and one + /// CONFIG store yields a `fastly.toml` with the edgezero-provision + /// header, per-kind `[local_server.*_stores.*]` blocks in their + /// expected shape, and an `edgezero_runtime_env.contents` + /// sub-table populated with `__NAME` lines for every declared + /// store. `contents` MUST remain a TABLE (spec regression guard). + #[test] + fn provision_local_first_run_writes_expected_files() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + 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: &[], + }; + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + assert!( + path.exists(), + "fastly.toml exists after first-run provision" + ); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.starts_with("# edgezero-provision: v1"), + "manifest starts with edgezero-provision header: {after}" + ); + // KV: array-of-tables with the stub row. Reparse-and-index -- + // see the sibling test's rationale (bare `.contains(...)` + // passes for both correct-nested and shipped root-drift bug). + let after_doc: toml_edit::DocumentMut = + after.parse().expect("re-parse first-run fastly.toml"); + let kv_row = after_doc + .get("local_server") + .and_then(|item| item.get("kv_stores")) + .and_then(|item| item.get("sessions")) + .and_then(toml_edit::Item::as_array_of_tables) + .and_then(|arr| arr.get(0)) + .expect("[[local_server.kv_stores.sessions]] with at least one row"); + assert_eq!( + kv_row.get("key").and_then(toml_edit::Item::as_str), + Some("__init__"), + "kv stub `key` inside the sessions block: {after}" + ); + assert_eq!( + kv_row.get("data").and_then(toml_edit::Item::as_str), + Some(""), + "kv stub `data` inside the sessions block: {after}" + ); + // CONFIG: table block with `format = "inline-toml"` plus an + // empty `contents` SUB-TABLE (never `contents = ""`). + assert!( + after.contains("[local_server.config_stores.app_config]"), + "config-store block header present: {after}" + ); + assert!( + after.contains(r#"format = "inline-toml""#), + "config-store format key present: {after}" + ); + assert!( + after.contains("[local_server.config_stores.app_config.contents]"), + "config-store contents sub-table header present: {after}" + ); + assert!( + !after.contains(r#"contents = """#), + "contents MUST NOT be an empty string (spec regression guard): {after}" + ); + // Runtime-env: __NAME line for every declared store. + assert!( + after.contains("[local_server.config_stores.edgezero_runtime_env.contents]"), + "runtime-env contents sub-table header present: {after}" + ); + assert!( + after.contains(r#"EDGEZERO__STORES__KV__SESSIONS__NAME = "sessions""#), + "KV __NAME line: {after}" + ); + assert!( + after.contains(r#"EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME = "app_config""#), + "CONFIG __NAME line: {after}" + ); + // Re-parse to confirm both `contents` slots are tables (the + // shape Viceroy + `config push --local` expect). + let doc: toml_edit::DocumentMut = after.parse().expect("re-parse"); + assert!( + doc["local_server"]["config_stores"]["app_config"]["contents"] + .as_table() + .is_some(), + "app_config.contents parses as a table" + ); + assert!( + doc["local_server"]["config_stores"]["edgezero_runtime_env"]["contents"] + .as_table() + .is_some(), + "edgezero_runtime_env.contents parses as a table" + ); + } + + /// Section 9.2 — Re-provision must be byte-identical. This is the + /// operator's contract that `provision --adapter fastly` is safe + /// to re-run: no drift in whitespace, no reordering, no re-emit of + /// entries that were already present. + #[test] + fn provision_local_re_provision_is_byte_identical() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + 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: &[], + }; + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("first provision succeeds"); + let after_first = fs::read_to_string(&path).expect("read after first"); + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("second provision succeeds"); + let after_second = fs::read_to_string(&path).expect("read after second"); + assert_eq!( + after_first, after_second, + "second provision is byte-identical to the first" + ); + } + + /// Section 9.3 — Fastly-specific: after the base `fastly.toml` is + /// provisioned and the operator hand-edits a + /// `[[local_server.secret_stores.default]]` entry with a custom + /// `env` mapping, a subsequent `provision_typed` call that adds a + /// DIFFERENT key must land the new entry as a sibling in the same + /// array-of-tables — WITHOUT rewriting the operator's `env` + /// mapping on the pre-existing row (idempotent-append semantics). + /// + /// Fastly is the only adapter where the operator maps a secret + /// store `key` to an OS env var via the `env` field; the writer + /// MUST NOT clobber that mapping when appending new keys. + /// + /// Renamed 2026-07 (deep self-review finding P1-f): the prior + /// name `provision_local_push_after_provision_preserves_*` + /// promised a push→provision integration test but the body only + /// re-runs `provision_typed` twice; the invariant is + /// re-provision idempotency, not push semantics. + #[test] + fn provision_typed_local_re_run_preserves_operator_env_mapping_on_secret_store_entry() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // Base manifest + the operator's hand-edited entry. The + // operator maps their secret's local `key = "custom_key"` to + // the real-world OS env var `REAL_ENV_MAPPING` — an override + // that must survive future writer runs. + let mut seed = synthesise_fastly_toml("demo", None); + seed.push_str( + "\n[[local_server.secret_stores.default]]\nkey = \"custom_key\"\nenv = \"REAL_ENV_MAPPING\"\n", + ); + fs::write(&path, &seed).expect("write"); + // A new secret arrives — a DIFFERENT key under the same store. + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "different_key", + )]; + FastlyCliAdapter + .provision_typed( + dir.path(), + Some("fastly.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision_typed succeeds"); + let after = fs::read_to_string(&path).expect("read"); + // Operator's exact env mapping survives byte-for-byte. + assert!( + after.contains(r#"env = "REAL_ENV_MAPPING""#), + "operator's `env = \"REAL_ENV_MAPPING\"` mapping preserved verbatim: {after}" + ); + assert!( + after.contains(r#"key = "custom_key""#), + "operator's original key row still present: {after}" + ); + // The new entry lands as a sibling row with the default + // key→env uppercasing. + assert!( + after.contains(r#"key = "different_key""#), + "new key row appended: {after}" + ); + assert!( + after.contains(r#"env = "DIFFERENT_KEY""#), + "new entry defaults to `env = \"\"`: {after}" + ); + // Re-parse: the array-of-tables now holds both rows, with the + // operator's row untouched. + let doc: toml_edit::DocumentMut = after.parse().expect("re-parse"); + let arr = doc["local_server"]["secret_stores"]["default"] + .as_array_of_tables() + .expect("default is array-of-tables"); + assert_eq!(arr.len(), 2, "two sibling entries after append: {after}"); + let custom = arr + .iter() + .find(|tbl| tbl.get("key").and_then(|item| item.as_str()) == Some("custom_key")) + .expect("custom_key row present"); + assert_eq!( + custom.get("env").and_then(|item| item.as_str()), + Some("REAL_ENV_MAPPING"), + "custom_key row's env mapping locked to the operator's value" + ); + let different = arr + .iter() + .find(|tbl| tbl.get("key").and_then(|item| item.as_str()) == Some("different_key")) + .expect("different_key row present"); + assert_eq!( + different.get("env").and_then(|item| item.as_str()), + Some("DIFFERENT_KEY"), + "different_key row's env defaults to KEY_UPPER" + ); + } + + /// The Viceroy `[[local_server.secret_stores.]]` table must + /// be keyed by the PLATFORM name (the entry's resolved + /// `EDGEZERO__STORES__SECRETS____NAME`), not the logical id. + /// The runtime opens the platform name, so seeding under the + /// logical id leaves the lookup opening an empty/absent store. + #[test] + fn provision_typed_seeds_secret_store_under_platform_name() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + // Logical id `vault`, env-resolved platform `prod_vault`. + let entries = [ + TypedSecretEntry::new("vault", "api_token", "api_token").with_platform("prod_vault") + ]; + FastlyCliAdapter + .provision_typed( + dir.path(), + Some("fastly.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision_typed succeeds"); + let after = fs::read_to_string(&path).expect("read"); + let doc: toml_edit::DocumentMut = after.parse().expect("re-parse"); + let stores = doc["local_server"]["secret_stores"] + .as_table() + .expect("secret_stores table"); + assert!( + stores.contains_key("prod_vault"), + "Viceroy store must be keyed by the platform name: {after}" + ); + assert!( + !stores.contains_key("vault"), + "must NOT seed under the logical id when a platform override exists: {after}" + ); + } + + /// Section 9.4 — Zero cloud calls. Local-mode provision is a pure + /// file writer; it must NEVER shell out to `fastly`. Install + /// `fake_fastly_panicking()` (a script that exits 42 on any call) + /// on `$PATH` before invoking provision. If provision ever calls + /// the platform CLI, the fake short-circuits and the invocation + /// bubbles up as an error — so `Ok(...)` is the load-bearing + /// signal that no cloud call happened. + #[cfg(unix)] + #[test] + fn provision_local_zero_cloud_calls() { + let _lock = path_mutation_guard().lock().expect("guard"); + let fake = fake_fastly_panicking(); + let _path = PathPrepend::new(fake.path()); + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + 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: &[], + }; + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds with a panicking fake fastly on PATH"); + } + + // ---------- write_fastly_local_config_store (config push --local) ---------- + // + // The writer is exported from provision_local.rs (per the split + // brief: local-server config-store writes are provision_local's + // territory). `config push --local` OWNS only the `key = value` + // pairs inside a contents table that provision already created -- + // these tests seed that provisioned block first, then exercise the + // upsert, and verify push refuses to fabricate a missing block. + + /// Write a `fastly.toml` whose head is `head` and that already + /// carries the provisioned `[local_server.config_stores.]` + /// block (with `format` + empty `contents`), exactly as + /// `provision --local` would leave it. + fn seed_provisioned_config_store(path: &Path, head: &str, platform: &str) { + let mut doc: toml_edit::DocumentMut = head.parse().expect("parse seed head"); + upsert_local_config_store(&mut doc, platform).expect("seed provisioned store block"); + fs::write(path, doc.to_string()).expect("write seed fastly.toml"); + } + + #[test] + fn write_fastly_local_config_store_upserts_keys_into_provisioned_contents() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + seed_provisioned_config_store(&path, "name = \"demo\"\n", TEST_CONFIG_ID); + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("service.timeout_ms".to_owned(), "1500".to_owned()), + ]; + write_fastly_local_config_store(&path, TEST_CONFIG_ID, &entries, &[]).expect("write"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains(&format!("[local_server.config_stores.{TEST_CONFIG_ID}]")), + "store table: {after}" + ); + assert!( + after.contains("format = \"inline-toml\""), + "format field: {after}" + ); + assert!( + after.contains(&format!( + "[local_server.config_stores.{TEST_CONFIG_ID}.contents]" + )), + "contents table: {after}" + ); + assert!(after.contains("greeting = \"hello\""), "key 1: {after}"); + assert!( + after.contains("\"service.timeout_ms\" = \"1500\""), + "dotted key quoted: {after}" + ); + assert!(after.contains("name = \"demo\""), "preserved: {after}"); + } + + #[test] + fn write_fastly_local_config_store_replaces_existing_key_on_re_push() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + seed_provisioned_config_store(&path, "name = \"demo\"\n", TEST_CONFIG_ID); + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "stale".to_owned())], + &[], + ) + .expect("first write"); + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "fresh".to_owned())], + &[], + ) + .expect("second write"); + let after = fs::read_to_string(&path).expect("read back"); + assert!(after.contains("greeting = \"fresh\""), "new value: {after}"); + assert!( + !after.contains("greeting = \"stale\""), + "stale value dropped: {after}" + ); + } + + #[test] + fn write_fastly_local_config_store_preserves_unrelated_blocks() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + let head = "\ +[setup.kv_stores.sessions] + +[[local_server.kv_stores.sessions]] +key = \"__init__\" +data = \"\" + +[scripts] +build = \"cargo build --release\" +"; + seed_provisioned_config_store(&path, head, TEST_CONFIG_ID); + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "hi".to_owned())], + &[], + ) + .expect("write"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("[setup.kv_stores.sessions]"), + "setup KV kept: {after}" + ); + assert!(after.contains("[scripts]"), "scripts table kept: {after}"); + assert!( + after.contains("build = \"cargo build --release\""), + "scripts value kept: {after}" + ); + assert!(after.contains("greeting = \"hi\""), "pushed key: {after}"); + } + + #[test] + fn write_fastly_local_config_store_errors_when_store_not_provisioned() { + // A `fastly.toml` that exists but has no provisioned store block + // must NOT be back-filled by push -- creating the block is + // provision's job. Push errors and points the operator there. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + let err = write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "hi".to_owned())], + &[], + ) + .expect_err("push must refuse to fabricate an unprovisioned store block"); + assert!( + err.contains("provision --adapter fastly --local") && err.contains(TEST_CONFIG_ID), + "error points at provision and names the store: {err}" + ); + // The file is left untouched. + let after = fs::read_to_string(&path).expect("read back"); + assert_eq!(after, "name = \"demo\"\n", "push must not edit on refusal"); + } + + #[test] + fn write_fastly_local_config_store_errors_when_file_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // No fs::write — file absent. + let err = write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "hi".to_owned())], + &[], + ) + .expect_err("push must not create fastly.toml from nothing"); + assert!( + err.contains("provision --adapter fastly --local"), + "error points at provision: {err}" + ); + assert!( + !path.exists(), + "push must not create the file on refusal: {}", + path.display() + ); + } + + /// A symlinked manifest must be updated THROUGH the link: the real file's + /// contents change and the symlink itself is preserved (not replaced with a + /// regular file). The lock and the replace both resolve to the real target. + #[cfg(unix)] + #[test] + fn local_rewrite_follows_a_symlinked_manifest() { + use std::os::unix::fs::symlink; + let dir = tempdir().expect("tempdir"); + let real = dir.path().join("real-fastly.toml"); + let link = dir.path().join("fastly.toml"); + seed_provisioned_config_store(&real, "name = \"demo\"\n", TEST_CONFIG_ID); + symlink(&real, &link).expect("symlink"); + + write_fastly_local_config_store( + &link, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "hi".to_owned())], + &[], + ) + .expect("push through symlink"); + + assert!( + fs::symlink_metadata(&link) + .expect("lstat") + .file_type() + .is_symlink(), + "the manifest symlink must be preserved, not replaced with a file" + ); + assert!( + fs::read_to_string(&real) + .expect("read real") + .contains("greeting = \"hi\""), + "the real target behind the symlink must be updated" + ); + } + + /// A HARD-LINKED manifest cannot be replaced safely (rename breaks the link; + /// path-based locks miss the other names), so the writer FAILS CLOSED with a + /// fix rather than silently diverging. + #[cfg(unix)] + #[test] + fn local_rewrite_refuses_a_hard_linked_manifest() { + let dir = tempdir().expect("tempdir"); + let manifest = dir.path().join("fastly.toml"); + let other = dir.path().join("other-name.toml"); + fs::write(&manifest, "name = \"demo\"\n").expect("seed"); + fs::hard_link(&manifest, &other).expect("hard link"); + + let err = write_fastly_local_config_store( + &manifest, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "hi".to_owned())], + &[], + ) + .expect_err("a hard-linked manifest must be refused"); + assert!( + err.contains("hard link"), + "must explain the hard-link refusal: {err}" + ); + // Nothing was written -- the original content is intact. + assert_eq!( + fs::read_to_string(&manifest).expect("read"), + "name = \"demo\"\n", + "a refused write must not modify the manifest" + ); + } + + /// Two concurrent local pushes must not lose each other's edit. Each thread + /// adds a DISTINCT key; the cross-process lock serialises the whole + /// read-modify-write, so the second push reads what the first wrote and both + /// keys survive. Without the lock, both would read the same base and the + /// later rename would discard the earlier key -- the silent data loss. + #[cfg(unix)] + #[test] + fn concurrent_local_pushes_do_not_lose_edits() { + use std::sync::Arc; + use std::thread; + + let dir = tempdir().expect("tempdir"); + let path = Arc::new(dir.path().join("fastly.toml")); + seed_provisioned_config_store(path.as_ref(), "name = \"demo\"\n", TEST_CONFIG_ID); + + // Many rounds to make the interleaving likely to hit the race window. + for round in 0_u32..25 { + let path_a = Arc::clone(&path); + let path_b = Arc::clone(&path); + let key_a = format!("alpha_{round}"); + let key_b = format!("beta_{round}"); + let (ka, kb) = (key_a.clone(), key_b.clone()); + let ta = thread::spawn(move || { + write_fastly_local_config_store( + &path_a, + TEST_CONFIG_ID, + &[(ka, "a".to_owned())], + &[], + ) + }); + let tb = thread::spawn(move || { + write_fastly_local_config_store( + &path_b, + TEST_CONFIG_ID, + &[(kb, "b".to_owned())], + &[], + ) + }); + ta.join().expect("thread a").expect("push a"); + tb.join().expect("thread b").expect("push b"); + + let after = fs::read_to_string(path.as_ref()).expect("read back"); + assert!( + after.contains(&format!("{key_a} = \"a\"")), + "round {round}: `{key_a}` was lost by a concurrent push:\n{after}" + ); + assert!( + after.contains(&format!("{key_b} = \"b\"")), + "round {round}: `{key_b}` was lost by a concurrent push:\n{after}" + ); + } + } + + #[test] + fn orphan_chunk_keys_subtracts_new_keys() { + let mut new_keys = HashSet::new(); + new_keys.insert("keep".to_owned()); + let plan = FastlyConfigGcPlan { + new_keys, + prior_keys: Ok(vec![ + "gone1".to_owned(), + "keep".to_owned(), + "gone2".to_owned(), + ]), + }; + let orphans = orphan_chunk_keys(&plan).expect("ok"); + assert_eq!(orphans, vec!["gone1".to_owned(), "gone2".to_owned()]); + } + + #[test] + fn orphan_chunk_keys_propagates_prior_err() { + let plan = FastlyConfigGcPlan { + new_keys: HashSet::new(), + prior_keys: Err("suspicious".to_owned()), + }; + orphan_chunk_keys(&plan).unwrap_err(); + } +} diff --git a/crates/edgezero-adapter-fastly/src/cli/push_cloud.rs b/crates/edgezero-adapter-fastly/src/cli/push_cloud.rs new file mode 100644 index 00000000..06c0fe5b --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/push_cloud.rs @@ -0,0 +1,1647 @@ +use std::cell::{Cell, RefCell}; +use std::collections::HashSet; +use std::io::{ErrorKind, Write as _}; +use std::process::{ChildStdin, Command, Stdio}; + +use edgezero_adapter::registry::{ReadConfigEntry, ResolvedStoreId}; + +use crate::chunked_config::{prepare_fastly_config_entries, resolve_fastly_config_value_typed}; + +use super::{ + ConfigStoreLookup, FASTLY_INSTALL_HINT, classify_resolved_read, expand_root, + reject_duplicate_root_keys, reject_generated_key_collisions, reject_reserved_root_keys, +}; + +/// Cloud-mode `push_config_entries`: resolve the platform config-store +/// id via `fastly config-store list --json`, then shell out per +/// physical entry to `fastly config-store-entry update --upsert --stdin`. +pub(super) fn write_entries( + store: &ResolvedStoreId, + entries: &[(String, String)], + dry_run: bool, +) -> Result, String> { + // Resolve the platform config-store id on demand via + // `fastly config-store list --json` (matched by name = + // `store.platform`), then `fastly config-store-entry update + // --store-id= --key= --upsert --stdin` per physical + // entry. Entries are logical blob-envelope entries from + // the CLI (one (key, envelope_json) per push); oversized + // Fastly values are expanded below into chunk entries plus + // a root pointer by `chunked_config::prepare_fastly_config_entries`. + let logical = store.logical.as_str(); + let name = store.platform.as_str(); + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to fastly config-store `{name}` (logical id `{logical}`)" + )]); + } + // Reject reserved keys before any expansion or I/O. + reject_reserved_root_keys(entries)?; + reject_duplicate_root_keys(entries)?; + // Expand each logical root into its physical entries (chunks + pointer, or + // a single direct entry). Collecting them all first surfaces a + // pointer-too-large error before touching the remote store. A cloud push + // does NOT reclaim, so — unlike the local path — it keeps no per-root + // keep-set / root-value GC bookkeeping. + let mut physical_entries: Vec<(String, String)> = Vec::new(); + for (key, body) in entries { + let (expanded, ..) = expand_root(key, body)?; + physical_entries.extend(expanded); + } + if dry_run { + // Report intent without shelling out. Stays fully offline: no + // store-id resolution, no remote read (so no GC count). + let mut out = Vec::with_capacity(entries.len().saturating_mul(2).saturating_add(1)); + out.push(format!( + "would resolve fastly config-store `{name}` (logical id `{logical}`) via `fastly config-store list --json` and push entries:" + )); + for (key, body) in entries { + let expanded = prepare_fastly_config_entries(key, body) + .unwrap_or_else(|_| vec![(key.clone(), body.clone())]); + if expanded.len() == 1 { + out.push(format!( + " would push `{key}` as direct entry ({}B)", + body.len() + )); + } else { + let chunk_count = expanded.len().saturating_sub(1); + out.push(format!( + " would push `{key}` as chunked ({chunk_count} chunks + 1 pointer, {}B total)", + body.len() + )); + } + } + return Ok(out); + } + let resolved_id = + resolve_remote_config_store_id(name)?.ok_or_else(|| no_matching_store_error(name))?; + // A cloud push does NOT reclaim orphaned chunks: Fastly's config store is + // eventually consistent and records no pointer-supersession time, so + // reclamation is the explicit, operator-invoked `config gc`. + // + // Preflight: refuse if a generated chunk key would clobber an existing + // root-like sibling in the remote store. Uses a completeness-strict key + // listing (value-tolerant) and describes only the rare colliding keys. + let remote_keys = list_config_store_keys(&resolved_id)?; + reject_generated_key_collisions(&physical_entries, &remote_keys, |chunk_key| { + fetch_remote_config_store_entry(&resolved_id, chunk_key).map(Some) + })?; + push_entries_with_committer(&physical_entries, |key, value| { + create_config_store_entry(&resolved_id, key, value) + })?; + Ok(vec![format!( + "pushed {} physical entries ({} logical) to fastly config-store `{name}` (logical id `{logical}`, id={resolved_id})", + physical_entries.len(), + entries.len() + )]) +} + +/// Cloud-mode `read_config_entry`: shell out to `fastly +/// config-store-entry describe --store-id= --key= --json`, +/// then resolve chunk pointers via the same store when needed. +pub(super) fn read_entry(store: &ResolvedStoreId, key: &str) -> Result { + let name = store.platform.as_str(); + // A TYPED absence: `Ok(None)` (list succeeded, no store matched) is the + // only path to MissingStore. Any operational failure stays `Err` and fails + // closed -- an incomplete read must never read as absence and authorise an + // overwrite of healthy remote state. + let Some(store_id) = resolve_remote_config_store_id(name)? else { + return Ok(ReadConfigEntry::MissingStore); + }; + let store_arg = format!("--store-id={store_id}"); + let key_arg = format!("--key={key}"); + let output = Command::new("fastly") + .args([ + "config-store-entry", + "describe", + store_arg.as_str(), + key_arg.as_str(), + "--json", + ]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if output.status.success() { + let stdout = strict_stdout(output.stdout, "config-store-entry describe --json")?; + // Parse the JSON and extract the `item_value` field. + let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|_err| { + format!( + "failed to parse `fastly config-store-entry describe` JSON (parse error \ + redacted; response: {})", + redact_describe_response(&stdout) + ) + })?; + let value = parsed + .get("item_value") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "`fastly config-store-entry describe` JSON has no string `item_value` field; \ + fastly CLI may have changed its output schema. (response: {})", + redact_describe_response(&stdout) + ) + })?; + // Resolve chunk pointers. A chunk describe that fails could not be FULLY + // read; confirm whether the chunk is genuinely ABSENT against the + // complete store listing (authoritative), never the describe 404. + let store_keys: RefCell, String>>> = RefCell::new(None); + let fetch_failed: Cell = Cell::new(false); + let resolved = resolve_fastly_config_value_typed(key, value.to_owned(), |chunk_key| { + match fetch_remote_config_store_entry(&store_id, chunk_key) { + Ok(found) => Ok(Some(found)), + Err(_describe_err) => { + match confirm_key_absent_cached(&store_keys, &store_id, chunk_key) { + Ok(true) => Ok(None), // genuinely gone → repairable Corrupt + Ok(false) => { + fetch_failed.set(true); + Err("a referenced chunk is present in the store but its value \ + could not be read (incomplete read)" + .to_owned()) + } + Err(list_err) => { + fetch_failed.set(true); + Err(list_err) + } + } + } + } + }); + return classify_resolved_read(resolved, value, fetch_failed.get()); + } + // The describe failed. Absence is CONFIRMED only by a complete listing + // that omits the key -- never by a describe 404, which a proxy/endpoint or + // auth failure produces just the same. + if confirm_entry_absent(&store_id, key)? { + return Ok(ReadConfigEntry::MissingKey); + } + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!( + "`fastly config-store-entry describe --store-id={store_id} --key={key} --json` exited \ + with status {} but the key IS present in the store listing (an operational failure, \ + not absence); nothing was changed.\nstderr: {}", + output.status, + redact_stderr(&stderr) + )) +} + +/// Fetch a single entry value from a remote Fastly Config Store entry by +/// key, using `fastly config-store-entry describe --store-id= --key= +/// --json`. Used by the chunk-pointer resolver to fan out to chunk entries. +/// +/// `Ok(value)` when the entry exists; `Err` on ANY failure, INCLUDING a +/// not-found. Absence is NOT decided here (a describe 404 is not proof) -- the +/// caller confirms it against the complete store listing. +fn fetch_remote_config_store_entry(store_id: &str, key: &str) -> Result { + let store_arg = format!("--store-id={store_id}"); + let key_arg = format!("--key={key}"); + let output = Command::new("fastly") + .args([ + "config-store-entry", + "describe", + store_arg.as_str(), + key_arg.as_str(), + "--json", + ]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if output.status.success() { + let stdout = strict_stdout(output.stdout, "config-store-entry describe --json")?; + let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|_err| { + format!( + "failed to parse `fastly config-store-entry describe` JSON for key \ + `{key}` (parse error redacted; response: {})", + redact_describe_response(&stdout) + ) + })?; + let value = parsed + .get("item_value") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "`fastly config-store-entry describe` JSON has no string `item_value` \ + field for key `{key}`; fastly CLI may have changed its output schema. \ + (response: {})", + redact_describe_response(&stdout) + ) + })?; + return Ok(value.to_owned()); + } + // `Err` on ANY non-success, INCLUDING a not-found. A describe 404 alone is not + // proof of absence, so the caller CONFIRMS a genuine absence against the + // complete store listing rather than trusting this stderr. + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!( + "`fastly config-store-entry describe --store-id={store_id} --key={key} --json` \ + exited with status {}\nstderr: {}", + output.status, + redact_stderr(&stderr) + )) +} + +/// The COMPLETE set of item keys in a store, via `config-store-entry list`. +/// +/// Absence is CONFIRMED against this, never against a describe 404: the listing +/// is completeness-strict (fails closed on a paginated / non-bare-array view and +/// on a duplicate key), so a key's absence from it is authoritative. +fn list_config_store_keys(store_id: &str) -> Result, String> { + let store_arg = format!("--store-id={store_id}"); + let output = Command::new("fastly") + .args(["config-store-entry", "list", store_arg.as_str(), "--json"]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "`fastly config-store-entry list --store-id={store_id} --json` exited with status {}\nstderr: {}", + output.status, + redact_stderr(&stderr) + )); + } + let stdout = strict_stdout(output.stdout, "config-store-entry list --json")?; + let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|_err| { + format!( + "failed to parse `fastly config-store-entry list` JSON (parse error redacted; \ + response: {})", + redact_describe_response(&stdout) + ) + })?; + let array = parsed.as_array().ok_or_else(|| { + format!( + "refusing to confirm absence: `fastly config-store-entry list --json` did not return a \ + bare array (response: {}). A paginated or partial view could hide a present key and \ + turn it into a false absence that authorises an overwrite.", + redact_describe_response(&stdout) + ) + })?; + let mut keys = HashSet::with_capacity(array.len()); + for (idx, entry) in array.iter().enumerate() { + let key = entry + .get("item_key") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "`fastly config-store-entry list` entry #{idx} is missing a string `item_key`; \ + refusing to confirm absence on an unreadable listing" + ) + })?; + if key.is_empty() { + return Err(format!( + "`fastly config-store-entry list` entry #{idx} has an empty `item_key`; refusing \ + to confirm absence on an unreadable listing" + )); + } + if !keys.insert(key.to_owned()) { + return Err(format!( + "`fastly config-store-entry list` returned duplicate key `{key}`; refusing to \ + confirm absence on an ambiguous listing" + )); + } + } + Ok(keys) +} + +/// Confirm `key` is ABSENT from the store via a complete listing (authoritative). +/// `Ok(true)` = the listing succeeded and omits the key. `Ok(false)` = the key IS +/// present. `Err` = the listing itself failed. All three fail closed for the +/// caller: only `Ok(true)` is a genuine absence. +fn confirm_entry_absent(store_id: &str, key: &str) -> Result { + Ok(!list_config_store_keys(store_id)?.contains(key)) +} + +/// Cached form of [`confirm_entry_absent`] for chunk fetches: lists the store at +/// most ONCE per read (a whole lost generation would otherwise list per chunk). +fn confirm_key_absent_cached( + cache: &RefCell, String>>>, + store_id: &str, + key: &str, +) -> Result { + let mut slot = cache.borrow_mut(); + if slot.is_none() { + *slot = Some(list_config_store_keys(store_id)); + } + match slot.as_ref() { + Some(Ok(keys)) => Ok(!keys.contains(key)), + Some(Err(err)) => Err(err.clone()), + // Unreachable: populated just above. Fail closed rather than unwrap. + None => Err("internal error: store listing cache was not populated".to_owned()), + } +} + +/// Convert `fastly` stdout to a `String`, FAILING CLOSED on invalid UTF-8 rather +/// than substituting U+FFFD. A lossy replacement inside a JSON string could +/// mutate a stored root value or chunk and yield parseable-but-WRONG data on a +/// path that drives an overwrite or a deletion. Diagnostics only ever see +/// redacted output, so stderr stays lossy. +pub(super) fn strict_stdout(stdout: Vec, command: &str) -> Result { + String::from_utf8(stdout).map_err(|_err| { + format!( + "`fastly {command}` returned non-UTF-8 output; refusing to act on it -- a lossy \ + conversion could mutate a stored value. Nothing was changed." + ) + }) +} + +// ------------------------------------------------------------------- +// `config push` helpers +// ------------------------------------------------------------------- + +/// Drive a sequential per-entry commit loop and produce the +/// partial-failure diagnostic when the committer fails mid-way. +/// Pure (no I/O) so the diagnostic shape is unit-testable without +/// the fastly CLI on PATH; production calls it with a closure that +/// shells out via `create_config_store_entry`. On success returns +/// the count of committed entries; on failure returns an error +/// string naming committed / failed / not-attempted keys so the +/// operator can resume from a known boundary. +fn push_entries_with_committer( + entries: &[(String, String)], + mut committer: F, +) -> Result +where + F: FnMut(&str, &str) -> Result<(), String>, +{ + let mut pushed: Vec = Vec::with_capacity(entries.len()); + for (key, value) in entries { + if let Err(err) = committer(key, value) { + let remaining: Vec<&str> = entries + .iter() + .skip(pushed.len().saturating_add(1)) + .map(|(remaining_key, _)| remaining_key.as_str()) + .collect(); + return Err(format!( + "fastly push failed at entry `{key}` while committing {committed} of {total} entries.\n \ + The failed entry's outcome is UNKNOWN: Fastly may have committed it before the error \ + (a timeout can arrive after the write lands), including when it is the root pointer.\n \ + Recovery: re-run the SAME `config push`. It is idempotent -- chunk keys are content-addressed \ + and writes use `--upsert` -- so entries already written are rewritten harmlessly and any \ + missing ones are filled. Do NOT hand-delete the failed key.\n \ + Already written (a retry rewrites them): {pushed:?}\n \ + Failed: `{key}` (outcome unknown) -- {err}\n \ + Not attempted: {remaining:?}", + committed = pushed.len(), + total = entries.len(), + )); + } + pushed.push(key.clone()); + } + Ok(pushed.len()) +} + +/// Shell `fastly config-store-entry update --upsert --stdin` with +/// the value piped through stdin instead of `--value=` on +/// argv. +/// +/// Two reasons for this exact invocation: +/// +/// 1. `--upsert` (vs. the original `create` subcommand): the prior +/// `create` form errored on any key that already existed in the +/// config store, which made `config push` non-repeatable — +/// after the first push, every follow-up push triggered by a +/// config edit would fail at the first unchanged key. +/// `update --upsert` is documented as "insert or update", which +/// matches the convergent semantic the other config-push paths +/// already have (axum overwrites the JSON, cloudflare's +/// `wrangler kv bulk put` overwrites, spin's +/// `cloud key-value set` overwrites). +/// +/// 2. `--stdin` (vs. `--value=`): `--value=` exposed every +/// config entry's bytes in `ps`/`/proc//cmdline` listings +/// AND was bounded by the host's `ARG_MAX` (4 KiB to 256 KiB +/// depending on platform — easy to trip with a JSON blob). +/// `--stdin` reads the value from stdin instead — keeps value +/// bytes out of argv and lifts the size cap to whatever the OS +/// pipe buffer + the CLI's read accept (megabytes in practice). +fn create_config_store_entry(store_id: &str, key: &str, value: &str) -> Result<(), String> { + let store_arg = format!("--store-id={store_id}"); + let key_arg = format!("--key={key}"); + let mut child = Command::new("fastly") + .args([ + "config-store-entry", + "update", + store_arg.as_str(), + key_arg.as_str(), + "--upsert", + "--stdin", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + // Take stdin OUT of the child and hand it to a helper that writes the value + // and drops the handle on return — closing the pipe so the CLI sees EOF. + // Do NOT early-return on a write error: if the child died before reading + // (bad args, auth failure), the write fails with BrokenPipe while the USEFUL + // diagnostic is the child's own stderr. Reap the child FIRST (avoids a + // zombie), then surface its stderr/status -- folding the pipe error in only + // as secondary context. + let stdin = child + .stdin + .take() + .ok_or_else(|| "failed to open stdin pipe to `fastly`".to_owned())?; + let write_result = write_value_to_fastly_stdin(stdin, value); + let output = child + .wait_with_output() + .map_err(|err| format!("failed to wait on `fastly`: {err}"))?; + // Redact stderr: a Fastly error can quote the stored config value back, which + // would put credentials into CI logs. + let stderr = redact_stderr(&String::from_utf8_lossy(&output.stderr)); + if let Err(err) = write_result { + return Err(format!( + "failed to write the value to `fastly` stdin ({err}); `fastly config-store-entry update --store-id={store_id} --key={key} --upsert --stdin` exited with status {}\nstderr: {}", + output.status, stderr + )); + } + if output.status.success() { + return Ok(()); + } + Err(format!( + "`fastly config-store-entry update --store-id={store_id} --key={key} --upsert --stdin` exited with status {}\nstderr: {}", + output.status, stderr + )) +} + +/// Write `value` to the child's stdin, then drop the handle as it falls out of +/// scope on return — closing the pipe so the `fastly` CLI sees EOF. Taking +/// `stdin` by value gives a natural scope-end drop rather than an explicit +/// `drop()`, which also keeps this valid on targets where `ChildStdin` is a +/// non-Drop stub. +fn write_value_to_fastly_stdin(mut stdin: ChildStdin, value: &str) -> Result<(), String> { + stdin + .write_all(value.as_bytes()) + .map_err(|err| format!("failed to write value to `fastly` stdin: {err}")) +} + +/// Parse `fastly config-store list --json` output and return the +/// platform `id` of the store whose `name` matches `name`. Accepts +/// both a bare array (`[ {"id": "...", "name": "..."}, ... ]`) +/// and an `{"items": [...]}` envelope so this stays compatible +/// across fastly CLI versions. +fn find_config_store_id(stdout: &str, name: &str) -> ConfigStoreLookup { + let parsed: serde_json::Value = match serde_json::from_str(stdout) { + Ok(value) => value, + Err(err) => { + return ConfigStoreLookup::SchemaDrift(format!("stdout did not parse as JSON: {err}")); + } + }; + let Some(array) = parsed + .as_array() + .or_else(|| parsed.get("items").and_then(serde_json::Value::as_array)) + else { + return ConfigStoreLookup::SchemaDrift(format!( + "expected a bare array `[...]` or an `{{\"items\": [...]}}` envelope; got JSON of shape `{}`", + shape_summary(&parsed) + )); + }; + // FAIL CLOSED on any malformed or duplicate row: a `NotFound` here becomes a + // MissingStore that AUTHORISES an overwrite, so a listing we cannot read + // exactly must never look like a definite absence. Every row must carry a + // non-empty string `name` and `id`, and names must be unique. + let mut seen_names = HashSet::with_capacity(array.len()); + let mut found: Option = None; + for (idx, entry) in array.iter().enumerate() { + let name_field = entry + .get("name") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()); + let id_field = entry + .get("id") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()); + let (Some(entry_name), Some(entry_id)) = (name_field, id_field) else { + return ConfigStoreLookup::SchemaDrift(format!( + "store-list entry #{idx} is missing a non-empty string `name` or `id`; refusing to \ + treat a store as absent on a listing this build cannot read exactly" + )); + }; + if !seen_names.insert(entry_name.to_owned()) { + return ConfigStoreLookup::SchemaDrift(format!( + "store-list has a duplicate `name` (`{entry_name}`); refusing to resolve a store id \ + on an ambiguous listing" + )); + } + if entry_name == name { + found = Some(entry_id.to_owned()); + } + } + found.map_or(ConfigStoreLookup::NotFound, ConfigStoreLookup::Found) +} + +/// One-line type label for a `serde_json::Value` (for diagnostic +/// error messages — not a canonical JSON-schema description). +fn shape_summary(value: &serde_json::Value) -> &'static str { + match value { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "bool", + serde_json::Value::Number(_) => "number", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", + } +} + +/// Resolve the platform config-store id on demand: shell out to +/// `fastly config-store list --json`, parse the JSON, match by `name`. +/// +/// Returns a TYPED absence: `Ok(None)` ONLY when the list call SUCCEEDS and no +/// store matches (a genuine absence). An operational failure (missing binary, +/// spawn/list failure, schema drift) stays `Err` -- callers that read for a diff +/// must not treat an operational failure as "store absent" and overwrite. +pub(super) fn resolve_remote_config_store_id(name: &str) -> Result, String> { + let output = Command::new("fastly") + .args(["config-store", "list", "--json"]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "`fastly config-store list --json` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let stdout = strict_stdout(output.stdout, "config-store list --json")?; + match find_config_store_id(&stdout, name) { + ConfigStoreLookup::Found(id) => Ok(Some(id)), + ConfigStoreLookup::NotFound => Ok(None), + ConfigStoreLookup::SchemaDrift(detail) => Err(format!( + "could not parse `fastly config-store list --json` output: {detail}.\n The fastly CLI may have changed its JSON schema in a recent version. Please file a bug report at https://github.com/stackpop/edgezero/issues with the fastly CLI version (`fastly version`) and the raw stdout. Workaround: pin to a known-compatible fastly CLI version." + )), + } +} + +/// Message for a genuinely-absent store, for the write/GC callers that treat +/// absence as a hard error (they cannot operate on a store that does not exist). +pub(super) fn no_matching_store_error(name: &str) -> String { + format!( + "no fastly config-store matches `{name}` (did you run `edgezero provision --adapter fastly`?)" + ) +} + +/// Summarise a `fastly ... describe` response for diagnostics WITHOUT +/// leaking its contents. The response body is the stored config value, so a +/// schema-drift diagnostic must never echo the payload: report only its size and +/// its top-level *shape*, never a value. +pub(super) fn redact_describe_response(stdout: &str) -> String { + let len = stdout.len(); + serde_json::from_str::(stdout).map_or_else( + |_err| format!("{len} bytes, not valid JSON"), + |value| match value { + serde_json::Value::Object(map) => { + // Object KEYS are stored/provider-controlled data, so only the + // COUNT is reported, never the key names. + format!("{len} bytes, JSON object with {} field(s)", map.len()) + } + other @ (serde_json::Value::Null + | serde_json::Value::Bool(_) + | serde_json::Value::Number(_) + | serde_json::Value::String(_) + | serde_json::Value::Array(_)) => { + format!("{len} bytes, JSON {}", shape_summary(&other)) + } + }, + ) +} + +/// Summarise a failing `fastly` invocation's stderr WITHOUT echoing it. The +/// `describe` and `update --stdin` paths carry the stored config value, so a +/// Fastly error that quotes the payload back would put credentials into CI logs. +pub(super) fn redact_stderr(stderr: &str) -> String { + let len = stderr.trim().len(); + format!( + "{len} bytes suppressed (may echo the stored config value); re-run the `fastly` command directly to inspect it" + ) +} + +#[cfg(test)] +mod tests { + use super::super::FastlyCliAdapter; + #[cfg(unix)] + use super::super::path_mutation_guard; + use super::*; + use crate::chunked_config::CHUNK_KEY_INFIX; + use crate::cli::test_support::*; + use edgezero_adapter::registry::{Adapter as _, AdapterPushContext}; + #[cfg(unix)] + use edgezero_core::test_env::PathPrepend; + #[cfg(unix)] + use std::fs; + use tempfile::tempdir; + + // ---------- push_entries_with_committer ---------- + + #[test] + fn push_entries_with_committer_returns_count_when_all_succeed() { + let entries = vec![ + ("a".to_owned(), "1".to_owned()), + ("b".to_owned(), "2".to_owned()), + ("c".to_owned(), "3".to_owned()), + ]; + let pushed = push_entries_with_committer(&entries, |_, _| Ok(())).expect("all succeed"); + assert_eq!(pushed, 3); + } + + #[test] + fn push_entries_with_committer_zero_entries_is_ok() { + let pushed = push_entries_with_committer(&[], |_, _| Ok(())).expect("empty is fine"); + assert_eq!(pushed, 0); + } + + #[test] + fn push_entries_with_committer_failure_surfaces_committed_failed_not_attempted() { + // Mock committer: succeed for first 2 keys, fail at third. + let entries = vec![ + ("k1".to_owned(), "v1".to_owned()), + ("k2".to_owned(), "v2".to_owned()), + ("k3".to_owned(), "v3".to_owned()), + ("k4".to_owned(), "v4".to_owned()), + ("k5".to_owned(), "v5".to_owned()), + ]; + let mut calls: usize = 0; + let err = push_entries_with_committer(&entries, |key, _| { + calls = calls.saturating_add(1); + if key == "k3" { + Err("simulated fastly stderr".to_owned()) + } else { + Ok(()) + } + }) + .expect_err("middle failure must error"); + // Committer was invoked for k1, k2, k3 and stopped. + assert_eq!(calls, 3_usize, "no retries beyond failure point"); + // Error names all three categories. + assert!(err.contains("k1") && err.contains("k2"), "committed: {err}"); + assert!( + err.contains("Failed: `k3`"), + "failed entry named exactly: {err}" + ); + assert!( + err.contains("k4") && err.contains("k5"), + "not-attempted: {err}" + ); + assert!(err.contains("simulated fastly stderr"), "inner err: {err}"); + // Counts are sane. + assert!( + err.contains("committing 2 of 5 entries"), + "committed/total count: {err}" + ); + // The failed entry's outcome is UNKNOWN and recovery is a full idempotent + // re-run, not a hand-resume from a claimed boundary. + assert!( + err.contains("UNKNOWN") && err.contains("outcome unknown"), + "failed outcome must be stated unknown: {err}" + ); + assert!( + err.contains("re-run the SAME") && err.contains("idempotent"), + "recovery must be a full idempotent re-run: {err}" + ); + assert!( + !err.contains("safe to skip on retry"), + "must not claim committed entries can be skipped from a known boundary: {err}" + ); + } + + #[test] + fn push_entries_with_committer_first_entry_failure_reports_zero_committed() { + let entries = vec![ + ("only".to_owned(), "val".to_owned()), + ("never".to_owned(), "tried".to_owned()), + ]; + let err = push_entries_with_committer(&entries, |_, _| Err("nope".to_owned())) + .expect_err("first-entry failure"); + assert!(err.contains("committing 0 of 2"), "zero committed: {err}"); + assert!( + err.contains("Failed: `only`"), + "first-entry failure named: {err}" + ); + assert!( + err.contains("never"), + "second entry as not-attempted: {err}" + ); + } + + #[test] + fn push_entries_with_committer_last_entry_failure_reports_n_minus_one_committed() { + let entries = vec![ + ("a".to_owned(), "1".to_owned()), + ("b".to_owned(), "2".to_owned()), + ("c".to_owned(), "3".to_owned()), + ]; + let err = push_entries_with_committer(&entries, |key, _| { + if key == "c" { + Err("late failure".to_owned()) + } else { + Ok(()) + } + }) + .expect_err("last-entry failure"); + assert!(err.contains("committing 2 of 3"), "n-1 committed: {err}"); + assert!( + err.contains("Not attempted: []"), + "zero not-attempted when the last entry fails: {err}" + ); + } + + // ---------- find_config_store_id ---------- + + #[test] + fn find_config_store_id_matches_bare_array_by_name() { + let stdout = format!( + r#"[ + {{"id": "abc123", "name": "{TEST_CONFIG_ID}"}}, + {{"id": "def456", "name": "other_store"}} + ]"# + ); + match find_config_store_id(&stdout, TEST_CONFIG_ID) { + ConfigStoreLookup::Found(id) => assert_eq!(id, "abc123"), + ConfigStoreLookup::NotFound => panic!("expected Found, got NotFound"), + ConfigStoreLookup::SchemaDrift(detail) => { + panic!("expected Found, got SchemaDrift({detail})") + } + } + } + + #[test] + fn find_config_store_id_tolerates_items_envelope() { + let stdout = format!( + r#"{{"items": [ + {{"id": "xyz789", "name": "{TEST_CONFIG_ID}"}} + ]}}"# + ); + match find_config_store_id(&stdout, TEST_CONFIG_ID) { + ConfigStoreLookup::Found(id) => assert_eq!(id, "xyz789"), + ConfigStoreLookup::NotFound => panic!("expected Found, got NotFound"), + ConfigStoreLookup::SchemaDrift(detail) => { + panic!("expected Found, got SchemaDrift({detail})") + } + } + } + + #[test] + fn find_config_store_id_distinguishes_not_found_from_match_failure() { + // JSON parses cleanly, entries are well-formed + // (`name` + `id` strings present), but no entry matches + // → NotFound. Operator likely needs to run `provision`. + let stdout = r#"[{"id": "abc", "name": "other"}]"#; + assert!(matches!( + find_config_store_id(stdout, "missing"), + ConfigStoreLookup::NotFound + )); + } + + #[test] + fn find_config_store_id_flags_schema_drift_on_malformed_json() { + // Unparseable bytes are NOT a "store not found" — they're + // a "fastly CLI output format changed" signal. Operator + // needs different recovery (file a bug, pin CLI version) + // than for the "store doesn't exist yet" case. + let drift = find_config_store_id("not json", "anything"); + assert!( + matches!(drift, ConfigStoreLookup::SchemaDrift(_)), + "non-JSON stdout must be schema drift, got {drift:?}" + ); + let empty = find_config_store_id("", "anything"); + assert!( + matches!(empty, ConfigStoreLookup::SchemaDrift(_)), + "empty stdout must be schema drift, got {empty:?}" + ); + } + + #[test] + fn find_config_store_id_flags_schema_drift_when_shape_unexpected() { + // JSON parses but the top-level is neither a bare array + // nor an `{items: [...]}` envelope. + let stdout = r#"{"namespace": "fastly", "list": []}"#; + match find_config_store_id(stdout, "any") { + ConfigStoreLookup::SchemaDrift(detail) => { + assert!( + detail.contains("bare array") || detail.contains("items"), + "schema-drift detail names the expected shapes: {detail}" + ); + } + ConfigStoreLookup::Found(id) => panic!("expected SchemaDrift, got Found({id})"), + ConfigStoreLookup::NotFound => panic!("expected SchemaDrift, got NotFound"), + } + } + + #[test] + fn find_config_store_id_flags_schema_drift_when_entries_lack_name_id() { + // Array of objects but none have BOTH string `name` and + // string `id` fields — suggests schema rename (e.g. + // fastly renamed `name` → `title`). + let stdout = format!(r#"[{{"title": "{TEST_CONFIG_ID}", "uid": "abc"}}]"#); + let drift = find_config_store_id(&stdout, TEST_CONFIG_ID); + assert!( + matches!(drift, ConfigStoreLookup::SchemaDrift(_)), + "entries lacking name/id must be schema drift, got {drift:?}" + ); + } + + #[test] + fn find_config_store_id_fails_closed_on_a_malformed_row() { + // A row that lacks a non-empty `name`/`id` could BE the requested store + // (its unreadable name might have matched). Treating the listing as a + // definite NotFound would authorise an overwrite of a store that exists, + // so a malformed row must be SchemaDrift (a hard error), not NotFound -- + // even when another row is well-formed. + let stdout = format!( + r#"[{{"name": "", "id": "abc"}}, {{"name": "{TEST_CONFIG_ID}", "id": "store-1"}}]"# + ); + let drift = find_config_store_id(&stdout, "some-other-store"); + assert!( + matches!(drift, ConfigStoreLookup::SchemaDrift(_)), + "an empty-name row must fail closed, got {drift:?}" + ); + } + + #[test] + fn find_config_store_id_rejects_duplicate_names() { + // A duplicate name means we are not reading one consistent view of the + // store, so resolving an id off it is ambiguous -> fail closed. + let stdout = format!( + r#"[{{"name": "{TEST_CONFIG_ID}", "id": "a"}}, {{"name": "{TEST_CONFIG_ID}", "id": "b"}}]"# + ); + let drift = find_config_store_id(&stdout, TEST_CONFIG_ID); + assert!( + matches!(drift, ConfigStoreLookup::SchemaDrift(_)), + "a duplicate name must fail closed, got {drift:?}" + ); + } + + #[test] + fn find_config_store_id_returns_not_found_for_empty_array() { + // Empty array IS a valid "store doesn't exist yet" signal, + // not schema drift — fastly CLI legitimately returns `[]` + // when no config-stores exist. + let drift = find_config_store_id("[]", "any"); + assert!( + matches!(drift, ConfigStoreLookup::NotFound), + "empty array must be NotFound, got {drift:?}" + ); + } + + // ---------- push_config_entries (dry-run + error paths) ---------- + + #[test] + fn push_dry_run_does_not_invoke_fastly() { + let dir = tempdir().expect("tempdir"); + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("feature.new_checkout".to_owned(), "false".to_owned()), + ]; + let out = FastlyCliAdapter + .push_config_entries( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run succeeds"); + // First line names the resolve+publish flow; then one preview line per + // key. A push no longer reclaims anything (see `config gc`), so there is + // no GC-intent line. + assert_eq!(out.len(), 1 + entries.len(), "header + per-entry preview"); + assert!( + out[0].contains("would resolve fastly config-store `app_config`") + && out[0].contains("push entries"), + "dry-run header describes the would-be flow: {out:?}" + ); + assert!( + out.iter().any(|line| line.contains("`greeting`")), + "dry-run lists `greeting`: {out:?}" + ); + assert!( + out.iter() + .any(|line| line.contains("`feature.new_checkout`")), + "dry-run lists `feature.new_checkout`: {out:?}" + ); + } + + #[test] + fn push_with_no_entries_reports_no_op_without_invoking_fastly() { + let dir = tempdir().expect("tempdir"); + let out = FastlyCliAdapter + .push_config_entries( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[], + &AdapterPushContext::new(), + false, + ) + .expect("zero-entry push is fine"); + assert_eq!(out.len(), 1); + assert!( + out[0].contains("no config entries"), + "status line names the no-op: {out:?}" + ); + } + + #[cfg(unix)] + #[test] + fn read_remote_returns_present_on_success() { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + // Fake fastly: list succeeds with app_config → store-abc123; + // describe returns valid JSON with item_value that is a BlobEnvelope. + let envelope = serde_json::to_string(&BlobEnvelope::new( + json!({"hello": "fastly"}), + "2026-06-22T00:00:00Z".into(), + )) + .expect("serialize"); + let entry_json = format!( + r#"{{"item_value":{},"store_id":"store-abc123"}}"#, + serde_json::to_string(&envelope).expect("escape") + ); + let fake = fake_fastly_returning(&entry_json, "", 0); + let _path = PathPrepend::new(fake.path()); + let result = FastlyCliAdapter + .read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("fake fastly exit-0 must succeed"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present"); + }; + assert_eq!(value, envelope); + } + + #[cfg(unix)] + #[test] + fn read_remote_returns_missing_key_when_confirmed_absent() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + // describe exits non-zero, and the complete store listing (empty here) + // CONFIRMS the key is absent → MissingKey (not decided by the 404 alone). + let fake = fake_fastly_returning("", "Error: item not found", 1); + let _path = PathPrepend::new(fake.path()); + let result = FastlyCliAdapter + .read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("not-found maps to MissingKey (not Err)"); + assert!( + matches!(result, ReadConfigEntry::MissingKey), + "not-found stderr => MissingKey" + ); + } + + /// The Fastly impl distinguishes store-not-found from key-not-found via + /// `resolve_remote_config_store_id`: when the list call exits non-zero and + /// the error string contains "not found", `read_config_entry` returns + /// `MissingStore` without ever calling the describe subcommand. + #[cfg(unix)] + #[test] + fn read_remote_fails_closed_when_the_list_call_itself_errors() { + use std::os::unix::fs::PermissionsExt as _; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + // The list call EXITS NON-ZERO with "not found"-shaped stderr. That is an + // OPERATIONAL failure (auth/network/server), not proof the store is absent + // -- the absence signal is a SUCCESSFUL list that omits the store. So this + // must fail closed (a hard error the operator retries), NEVER MissingStore: + // reading it as absence could authorise an overwrite of a store we never + // actually queried. + let fake_dir = tempdir().expect("tempdir"); + let stderr_file = fake_dir.path().join("stderr_payload.txt"); + fs::write(&stderr_file, "Error: config store not found for service").expect("write stderr"); + let script_path = fake_dir.path().join("fastly"); + let script = format!( + "#!/bin/sh\ncat '{stderr}' >&2\nexit 1\n", + stderr = stderr_file.display(), + ); + fs::write(&script_path, script).expect("write script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + let _path = PathPrepend::new(fake_dir.path()); + let result = FastlyCliAdapter.read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ); + assert!( + result.is_err(), + "a failed list call must fail closed, not read as MissingStore" + ); + } + + #[cfg(unix)] + #[test] + fn read_remote_returns_missing_store_when_the_store_is_genuinely_absent() { + use std::os::unix::fs::PermissionsExt as _; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + // The list call SUCCEEDS and returns a valid, empty store array. The store + // is genuinely absent -> `no fastly config-store matches` -> MissingStore. + let fake_dir = tempdir().expect("tempdir"); + let script_path = fake_dir.path().join("fastly"); + fs::write(&script_path, "#!/bin/sh\necho '[]'\nexit 0\n").expect("write script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + let _path = PathPrepend::new(fake_dir.path()); + let result = FastlyCliAdapter + .read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("a successful list that omits the store maps to MissingStore"); + assert!( + matches!(result, ReadConfigEntry::MissingStore), + "store absent from a successful list => MissingStore" + ); + } + + /// Verify that `read_config_entry` invokes + /// `fastly config-store-entry describe --store-id= --key= --json` + /// (after the resolve step that calls `fastly config-store list --json`). + #[cfg(unix)] + #[test] + fn read_remote_invokes_correct_argv() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let argv_log = dir.path().join("argv.txt"); + let fake = fake_fastly_argv_log(&argv_log); + let _path = PathPrepend::new(fake.path()); + let result = FastlyCliAdapter + .read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("argv-log fake must succeed"); + assert!( + matches!(result, ReadConfigEntry::Present(_)), + "expected Present from argv-log fake" + ); + let captured = fs::read_to_string(&argv_log).expect("argv log"); + // The describe call must include these args (resolve call args + // are also captured but we only assert the describe shape here). + assert!( + captured.contains("config-store-entry"), + "must invoke config-store-entry; got:\n{captured}" + ); + assert!( + captured.contains("describe"), + "must pass describe subcommand; got:\n{captured}" + ); + assert!( + captured.contains("--store-id=store-abc123"), + "must pass resolved store id; got:\n{captured}" + ); + assert!( + captured.contains("--key=greeting"), + "must pass --key=; got:\n{captured}" + ); + assert!( + captured.contains("--json"), + "must pass --json flag; got:\n{captured}" + ); + } + + #[cfg(unix)] + #[test] + fn push_config_entries_rejects_reserved_key() { + let dir = tempdir().expect("tempdir"); + let bad_key = format!("app_config{CHUNK_KEY_INFIX}deadbeef.0"); + let err = FastlyCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(bad_key.clone(), "{}".to_owned())], + &AdapterPushContext::new(), + false, + ) + .expect_err("reserved key must be rejected"); + assert!(err.contains(&bad_key), "names the key: {err}"); + } + + /// Schema drift must never echo the config payload — including OBJECT KEYS, + /// which are provider/stored data. App config can hold credentials; CLI + /// status lines are logged verbatim and CI logs are retained/shared. Only a + /// size + field COUNT may be reported. + #[cfg(unix)] + #[test] + fn read_config_entry_schema_drift_does_not_leak_payload() { + const SENTINEL: &str = "SUPER_SECRET_TOKEN_abc123"; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + // The sentinel is an OBJECT KEY (not a value): the earlier redactor joined + // keys into the diagnostic, so this is what pins the key-disclosure fix. + let drift = format!(r#"{{"{SENTINEL}":"x"}}"#); + let fake = fake_fastly_returning(&drift, "", 0); + let _path = PathPrepend::new(fake.path()); + + let result = FastlyCliAdapter.read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "cfg", + &AdapterPushContext::new(), + ); + let Err(err) = result else { + panic!("schema drift must error") + }; + assert!( + !err.contains(SENTINEL), + "error must not leak an object KEY from the config payload: {err}" + ); + assert!( + err.contains("bytes") && err.contains("field(s)"), + "error should carry a redacted size + field COUNT: {err}" + ); + } + + /// The FAILURE branch leaks too: a Fastly error that quotes the stored + /// value back in stderr must not reach the user-facing error. + #[cfg(unix)] + #[test] + fn read_config_entry_stderr_failure_does_not_leak_payload() { + const SENTINEL: &str = "SUPER_SECRET_TOKEN_stderr1"; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + // A hard failure that echoes the value. The key IS present in the store + // listing, so absence confirmation fails and the read surfaces the + // (redacted) describe stderr on the hard-error path. + let stderr = format!("Error: internal failure processing value {SENTINEL}"); + let fake = fake_fastly_returning_with_keys("", &stderr, 1, &["cfg"]); + let _path = PathPrepend::new(fake.path()); + + let result = FastlyCliAdapter.read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "cfg", + &AdapterPushContext::new(), + ); + let Err(err) = result else { + panic!("hard stderr failure must error") + }; + assert!( + !err.contains(SENTINEL), + "stderr must be redacted, not echoed: {err}" + ); + assert!( + err.contains("suppressed"), + "error should say the stderr was suppressed: {err}" + ); + } + + /// The WRITE path leaks too: a failing `config-store-entry update --upsert` + /// whose stderr quotes the value being written must be redacted. + #[cfg(unix)] + #[test] + fn upsert_stderr_failure_does_not_leak_payload() { + const SENTINEL: &str = "SUPER_SECRET_TOKEN_upsert1"; + let _lock = path_mutation_guard().lock().expect("guard"); + // A fake `fastly` that fails every call, echoing the value in stderr. + let stderr = format!("Error: rejected value {SENTINEL}"); + let fake = fake_fastly_returning("", &stderr, 1); + let _path = PathPrepend::new(fake.path()); + + let err = create_config_store_entry("store-abc", "cfg", SENTINEL) + .expect_err("a failing upsert must error"); + assert!( + !err.contains(SENTINEL), + "upsert stderr must be redacted, not echoed: {err}" + ); + assert!( + err.contains("suppressed"), + "error should say the stderr was suppressed: {err}" + ); + } + + /// `config gc` reads `item_value` for every entry (to classify roots). A + /// malformed listing whose values carry secrets must fail closed WITHOUT + /// echoing any value. (Replaces the old push prior-read redaction tests, + /// which are now vacuous: a cloud push performs no pre-commit read.) + #[cfg(unix)] + #[test] + fn gc_list_failure_does_not_leak_payload() { + const SENTINEL: &str = "SUPER_SECRET_TOKEN_gc_list"; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + + let live = gen_envelope("live"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + let good = entry_list_json(&listing); + // A valid entry whose VALUE contains the sentinel, plus a malformed + // sibling (no created_at) to trip the fail-closed path. + let mut array: serde_json::Value = serde_json::from_str(&good).unwrap(); + let arr = array.as_array_mut().unwrap(); + arr.push(serde_json::json!({ + "item_key": "some.__edgezero_chunks.deadbeef.0", + "item_value": SENTINEL, + })); + let fake = fake_fastly_gc( + TEST_CONFIG_ID, + &[], + &listing, + None, + false, + &dir.path().join("ops.log"), + ); + fs::write( + fake.path().join("entries.json"), + serde_json::to_string(&array).unwrap(), + ) + .expect("overwrite entries"); + let _path = PathPrepend::new(fake.path()); + + let err = run_gc(dir.path(), 86_400, false).expect_err("must fail closed"); + assert!( + !err.contains(SENTINEL), + "the fail-closed error must not echo a stored value: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn push_config_entries_writes_direct_entry_at_exactly_8000_chars() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let argv_log = dir.path().join("argv.txt"); + let fake = fake_fastly_argv_log(&argv_log); + let _path = PathPrepend::new(fake.path()); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); + assert_eq!(envelope.len(), FASTLY_CONFIG_ENTRY_LIMIT); + + let entries = vec![(TEST_CONFIG_ID.to_owned(), envelope)]; + let out = FastlyCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect("push must succeed"); + // One physical entry written (direct). + let captured = fs::read_to_string(&argv_log).expect("argv log"); + assert!( + captured.contains(&format!("--key={TEST_CONFIG_ID}")), + "must write root key directly: {captured}" + ); + assert!( + out[0].contains("1 physical entries (1 logical)"), + "summary reports 1 physical entry: {out:?}" + ); + } + + #[cfg(unix)] + #[test] + fn push_config_entries_writes_chunks_and_root_pointer_for_8001_chars() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let argv_log = dir.path().join("argv.txt"); + let fake = fake_fastly_argv_log(&argv_log); + let _path = PathPrepend::new(fake.path()); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + assert!(envelope.len() > FASTLY_CONFIG_ENTRY_LIMIT); + + let entries = vec![(TEST_CONFIG_ID.to_owned(), envelope)]; + let out = FastlyCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect("push must succeed"); + let captured = fs::read_to_string(&argv_log).expect("argv log"); + // At least one chunk key must appear before the root key. + assert!( + captured.contains(".__edgezero_chunks."), + "chunk keys must be written: {captured}" + ); + // Root pointer must also be written. + assert!( + captured.contains(&format!("--key={TEST_CONFIG_ID}")), + "root pointer must be written: {captured}" + ); + // Root key must be LAST in the log (chunk lines come before it). + let root_pos = captured.rfind(&format!("--key={TEST_CONFIG_ID}")).unwrap(); + let chunk_pos = captured.find(".__edgezero_chunks.").unwrap(); + assert!( + chunk_pos < root_pos, + "chunk writes must precede root pointer write: chunk_pos={chunk_pos} root_pos={root_pos}" + ); + assert!(out[0].contains("logical"), "summary line present: {out:?}"); + } + + #[cfg(unix)] + #[test] + fn push_config_entries_dry_run_reports_direct_vs_chunked() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + + let direct_envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); + let chunked_envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + + let entries = vec![ + ("cfg_direct".to_owned(), direct_envelope), + ("cfg_chunked".to_owned(), chunked_envelope), + ]; + let out = FastlyCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, // dry_run + ) + .expect("dry-run must not error"); + + // No shellout happens; output must describe intent. + let combined = out.join("\n"); + assert!( + combined.contains("would push `cfg_direct` as direct entry"), + "must report direct: {combined}" + ); + assert!( + combined.contains("would push `cfg_chunked` as chunked"), + "must report chunked: {combined}" + ); + } + + // ---------- chunked read integration tests ---------- + + #[cfg(unix)] + #[test] + fn read_config_entry_resolves_direct_value_unchanged() { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + + let envelope = BlobEnvelope::new(json!({"hello": "world"}), "2026-06-22T00:00:00Z".into()); + let json_str = serde_json::to_string(&envelope).unwrap(); + let item_json = format!( + r#"{{"item_value":{}}}"#, + serde_json::to_string(&json_str).unwrap() + ); + let fake = fake_fastly_returning(&item_json, "", 0); + let _path = PathPrepend::new(fake.path()); + + let result = FastlyCliAdapter + .read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "cfg", + &AdapterPushContext::new(), + ) + .expect("read must succeed"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present"); + }; + assert_eq!(value, json_str, "direct envelope passes through unchanged"); + } + + #[cfg(unix)] + #[test] + fn read_config_entry_reconstructs_chunked_envelope() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + let physical = prepare_fastly_config_entries(TEST_CONFIG_ID, &envelope).unwrap(); + let (_, pointer_json) = physical.last().unwrap(); + // Build a key→response map for every physical entry. + let mut key_responses: Vec<(String, String)> = Vec::new(); + for (pk, pv) in &physical { + let resp = format!(r#"{{"item_value":{}}}"#, serde_json::to_string(pv).unwrap()); + key_responses.push((pk.clone(), resp)); + } + // The root key should return the pointer. + let ptr_resp = format!( + r#"{{"item_value":{}}}"#, + serde_json::to_string(pointer_json).unwrap() + ); + key_responses.push((TEST_CONFIG_ID.to_owned(), ptr_resp)); + + let fake = fake_fastly_with_key_dispatch(dir.path(), &key_responses); + let _path = PathPrepend::new(fake.path()); + + let result = FastlyCliAdapter + .read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + TEST_CONFIG_ID, + &AdapterPushContext::new(), + ) + .expect("chunked read must succeed"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present"); + }; + assert_eq!( + value, envelope, + "reconstructed envelope must equal original" + ); + } + + #[cfg(unix)] + #[test] + fn read_config_entry_reports_corrupt_on_a_confirmed_absent_chunk() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + let physical = prepare_fastly_config_entries(TEST_CONFIG_ID, &envelope).unwrap(); + let (_, pointer_json) = physical.last().unwrap(); + // Only provide the root pointer; omit chunk responses so the chunk fetch + // gets a CLEAN not-found (`Error: item not found`, no operational marker). + let ptr_resp = format!( + r#"{{"item_value":{}}}"#, + serde_json::to_string(pointer_json).unwrap() + ); + let key_responses = vec![(TEST_CONFIG_ID.to_owned(), ptr_resp)]; + let fake = fake_fastly_with_key_dispatch(dir.path(), &key_responses); + let _path = PathPrepend::new(fake.path()); + + let result = FastlyCliAdapter.read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + TEST_CONFIG_ID, + &AdapterPushContext::new(), + ); + // The chunk describe fails, and the complete store listing (which holds + // only the root pointer) CONFIRMS the chunk is absent. The blob spec makes + // persistent chunk loss REPAIRABLE by re-pushing, so the read reports + // `Corrupt` (a push overwrites to repair), NOT a hard error -- otherwise + // `config push` could never fix it. Absence is confirmed by the listing, + // never by the describe 404 alone, so a proxy/auth failure (where the + // listing also fails, or shows the chunk present) stays a hard error. + assert!( + matches!(result, Ok(ReadConfigEntry::Corrupt(_))), + "a confirmed-absent chunk must be repairable Corrupt, not a hard error" + ); + } + + #[cfg(unix)] + #[test] + fn read_config_entry_reports_corrupt_on_chunk_hash_mismatch() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + let physical = prepare_fastly_config_entries(TEST_CONFIG_ID, &envelope).unwrap(); + let (_, pointer_json) = physical.last().unwrap(); + let mut key_responses: Vec<(String, String)> = Vec::new(); + // Corrupt first chunk's content. + let (first_chunk_key, first_chunk_val) = &physical[0]; + let corrupted: String = first_chunk_val.chars().map(|_| 'Z').collect(); + let corrupt_resp = format!( + r#"{{"item_value":{}}}"#, + serde_json::to_string(&corrupted).unwrap() + ); + key_responses.push((first_chunk_key.clone(), corrupt_resp)); + // Remaining chunks as normal. + for (pk, pv) in physical + .iter() + .take(physical.len().saturating_sub(1)) + .skip(1) + { + key_responses.push(( + pk.clone(), + format!(r#"{{"item_value":{}}}"#, serde_json::to_string(pv).unwrap()), + )); + } + key_responses.push(( + TEST_CONFIG_ID.to_owned(), + format!( + r#"{{"item_value":{}}}"#, + serde_json::to_string(pointer_json).unwrap() + ), + )); + let fake = fake_fastly_with_key_dispatch(dir.path(), &key_responses); + let _path = PathPrepend::new(fake.path()); + + let result = FastlyCliAdapter.read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + TEST_CONFIG_ID, + &AdapterPushContext::new(), + ); + // A chunk-hash mismatch at an EXISTING entry is corrupt stored state the + // push repairs by overwriting, so the CLI read reports `Corrupt`. (The + // RUNTIME path keeps a hash mismatch as Internal — see config_store.rs.) + assert!( + matches!(result, Ok(ReadConfigEntry::Corrupt(_))), + "a chunk-hash mismatch at an existing entry must be Corrupt (repairable), not an error" + ); + } + + #[cfg(unix)] + #[test] + fn read_config_entry_reports_corrupt_for_malformed_pointer() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + // Root value ANNOUNCES our chunk-pointer kind but is malformed. The + // `describe` SUCCEEDS (the entry exists), so a resolve failure is CORRUPT + // stored state, not an IO error: the read must report `Corrupt` so a push + // can overwrite it (in-band repair), NOT hard-error and block recovery. + let bad_json = r#"{"edgezero_kind":"fastly_config_chunks","some_field":"x"}"#; + let item_json = format!( + r#"{{"item_value":{}}}"#, + serde_json::to_string(bad_json).unwrap() + ); + let fake = fake_fastly_returning(&item_json, "", 0); + let _path = PathPrepend::new(fake.path()); + + let result = FastlyCliAdapter.read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "cfg", + &AdapterPushContext::new(), + ); + assert!( + matches!(result, Ok(ReadConfigEntry::Corrupt(_))), + "a malformed pointer at an EXISTING entry must be Corrupt (repairable), not an error" + ); + } +} diff --git a/crates/edgezero-adapter-fastly/src/cli/push_local.rs b/crates/edgezero-adapter-fastly/src/cli/push_local.rs new file mode 100644 index 00000000..5c670e02 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/push_local.rs @@ -0,0 +1,2254 @@ +use std::collections::HashSet; +use std::fs; +use std::io::ErrorKind; +use std::path::Path; + +use edgezero_adapter::registry::{ReadConfigEntry, ResolvedStoreId}; + +use crate::chunked_config::{ + chunk_key_generation, gc_classify_root, prepare_fastly_config_entries, prior_chunk_keys, + resolve_fastly_config_value_typed, value_announces_our_kind, value_is_future_format, +}; + +use super::provision_local::{ + assert_local_config_store_provisioned, write_fastly_local_config_store, +}; +use super::{classify_resolved_read, expand_root, reject_generated_key_collisions}; + +/// Local-emulator `push_config_entries_local`: edit +/// `[local_server.config_stores..contents]` in `fastly.toml`. +/// Viceroy reads it on startup, so a subsequent `fastly compute serve` +/// exposes the new values to the wasm component. No shell-out to the +/// production Fastly CLI -- the operator may not be authenticated and +/// wouldn't want a local push to touch production anyway. +pub(super) fn write_entries( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + dry_run: bool, +) -> Result, String> { + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.fastly.adapter].manifest must point at fastly.toml for config push --local" + .to_owned(), + ); + }; + let fastly_path = manifest_root.join(rel); + let logical = store.logical.as_str(); + let name = store.platform.as_str(); + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to `[local_server.config_stores.{name}]` in {} (logical id `{logical}`)", + fastly_path.display() + )]); + } + // Reject reserved / duplicate keys before any expansion or I/O. + super::reject_reserved_root_keys(entries)?; + super::reject_duplicate_root_keys(entries)?; + // Expand each logical root once: flatten for the write, keep the exact + // per-root keep-set for GC (no prefix scan of the flattened set). + let mut physical_entries: Vec<(String, String)> = Vec::new(); + let mut gc_roots: Vec<(String, HashSet)> = Vec::with_capacity(entries.len()); + for (key, body) in entries { + let (expanded, new_keys, _new_root) = expand_root(key, body)?; + physical_entries.extend(expanded); + gc_roots.push((key.clone(), new_keys)); + } + if dry_run { + // Model the real operation: the writer below refuses when the + // provision-owned `[local_server.config_stores..contents]` + // table is absent, so a dry-run that happily previewed the edit + // would promise a push the real run rejects. This probe is + // read-only -- a dry-run must not touch the file. + assert_local_config_store_provisioned(&fastly_path, name)?; + let counts = local_orphan_counts_for_dry_run(&fastly_path, name, entries); + let mut out = Vec::with_capacity(entries.len().saturating_mul(2).saturating_add(1)); + out.push(format!( + "would edit `[local_server.config_stores.{name}.contents]` in {} (logical id `{logical}`) with entries:", + fastly_path.display(), + )); + for (idx, (key, body)) in entries.iter().enumerate() { + let expanded = prepare_fastly_config_entries(key, body) + .unwrap_or_else(|_| vec![(key.clone(), body.clone())]); + if expanded.len() == 1 { + out.push(format!( + " would set `{key}` as direct entry ({}B)", + body.len() + )); + } else { + let chunk_count = expanded.len().saturating_sub(1); + out.push(format!( + " would set `{key}` as chunked ({chunk_count} chunks + 1 pointer, {}B total)", + body.len() + )); + } + match counts.get(idx).map(|(_, count)| count) { + Some(Ok(n)) => out.push(format!( + " would delete {n} orphan chunks from the previous generation of `{key}`" + )), + Some(Err(reason)) => out.push(format!( + " would delete an unknown number of orphan chunks from the previous generation of `{key}` (unknown: {reason})" + )), + None => {} + } + } + return Ok(out); + } + let warnings = + write_fastly_local_config_store(&fastly_path, name, &physical_entries, &gc_roots)?; + let mut out = vec![format!( + "wrote {} physical entries ({} logical) to `[local_server.config_stores.{name}.contents]` in {} (logical id `{logical}`); restart `fastly compute serve` to pick up changes", + physical_entries.len(), + entries.len(), + fastly_path.display() + )]; + out.extend(warnings); + Ok(out) +} + +/// Local-emulator `read_config_entry_local`: read from +/// `[local_server.config_stores..contents]` in fastly.toml +/// — the same section `push_config_entries_local` writes. +pub(super) fn read_entry( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + store: &ResolvedStoreId, + key: &str, +) -> Result { + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.fastly.adapter].manifest must point at fastly.toml for config diff --local" + .to_owned(), + ); + }; + let fastly_path = manifest_root.join(rel); + let name = store.platform.as_str(); + // A prior-state read failure must never BLOCK the command: the diff just + // cannot be computed, so it degrades to `Unsupported` ("cannot diff"). + // Erroring here would newly fail a dry-run that reads nothing today. + let raw = match fs::read_to_string(&fastly_path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => { + return Ok(ReadConfigEntry::MissingStore); + } + Err(_err) => { + return Ok(ReadConfigEntry::Unsupported( + "local fastly.toml could not be read; cannot diff the prior value", + )); + } + }; + let Ok(doc) = raw.parse::() else { + return Ok(ReadConfigEntry::Unsupported( + "local fastly.toml is not valid TOML; cannot diff the prior value", + )); + }; + // Descend `[local_server.config_stores..contents]` level by level. + // At each level an ABSENT key means the store isn't seeded yet + // (MissingStore), but a key that is PRESENT yet not a table is malformed + // store state — distinct outcomes. `descend` returns Ok(None) for absent + // (-> MissingStore) and Err(Unsupported) for present-but-not-a-table. + let descend = |parent: &'_ toml_edit::Item, + child: &str| + -> Result, ReadConfigEntry> { + match parent.get(child) { + None => Ok(None), + Some(item) if item.is_table_like() => Ok(Some(item.clone())), + Some(_) => Err(ReadConfigEntry::Unsupported( + "a local config-store parent table is not a table; cannot diff the prior value", + )), + } + }; + let root_item = toml_edit::Item::Table(doc.as_table().clone()); + let contents_item = (|| { + let Some(local_server) = descend(&root_item, "local_server")? else { + return Ok(None); + }; + let Some(config_stores) = descend(&local_server, "config_stores")? else { + return Ok(None); + }; + let Some(store_tbl) = descend(&config_stores, name)? else { + return Ok(None); + }; + descend(&store_tbl, "contents") + })(); + let contents = match contents_item { + Ok(Some(item)) => item, + Ok(None) => return Ok(ReadConfigEntry::MissingStore), + Err(unsupported) => return Ok(unsupported), + }; + let Some(contents_tbl) = contents.as_table_like() else { + return Ok(ReadConfigEntry::Unsupported( + "local config-store `contents` is not a table; cannot diff the prior value", + )); + }; + // The contents table is `key = "value"` pairs. + match contents_tbl.get(key) { + Some(item) => { + let Some(value) = item.as_str() else { + return Ok(ReadConfigEntry::Unsupported( + "the local prior value is not a string; cannot diff the prior value", + )); + }; + // Resolve chunk pointers using the same toml contents table. + let resolved = resolve_fastly_config_value_typed(key, value.to_owned(), |chunk_key| { + match contents_tbl.get(chunk_key) { + Some(chunk_item) => { + let chunk_val = chunk_item.as_str().ok_or_else(|| { + format!( + "chunk key `{chunk_key}` in {} is not a string", + fastly_path.display() + ) + })?; + Ok(Some(chunk_val.to_owned())) + } + None => Ok(None), + } + }); + // Same taxonomy as the cloud read: a valid envelope is `Present`; a + // non-envelope or corrupt/incomplete value is `Corrupt`; an + // unknown/future kind is a hard error. There is no infrastructure + // fetch here, so `fetch_failed` is always false. + classify_resolved_read(resolved, value, false) + } + None => Ok(ReadConfigEntry::MissingKey), + } +} + +/// Navigate to `[local_server.config_stores..contents]` for the +/// dry-run counter. `Ok(None)` when any level is absent (no prior state); +/// `Err` when a level is present but the wrong type — prior state the real +/// writer would reject, so the count must degrade to "unknown", not 0. +fn local_contents_table<'doc>( + doc: &'doc toml_edit::DocumentMut, + platform_name: &str, +) -> Result, String> { + let malformed = || "could not read prior state".to_owned(); + let Some(server_item) = doc.get("local_server") else { + return Ok(None); + }; + let Some(server) = server_item.as_table() else { + return Err(malformed()); + }; + let Some(stores_item) = server.get("config_stores") else { + return Ok(None); + }; + let Some(stores) = stores_item.as_table() else { + return Err(malformed()); + }; + let Some(store_item) = stores.get(platform_name) else { + return Ok(None); + }; + let Some(store) = store_item.as_table() else { + return Err(malformed()); + }; + let Some(contents_item) = store.get("contents") else { + return Ok(None); + }; + contents_item + .as_table() + .map_or_else(|| Err(malformed()), |table| Ok(Some(table))) +} + +/// Is `key` a plain, prunable chunk PAYLOAD in `contents`? `false` for a value +/// that must be KEPT: a runtime-readable root, a value claiming our +/// `edgezero_kind` namespace or written by a newer format, or a NESTED root (a +/// key with a canonical chunk beneath it). Only a raw leaf payload prunes. +/// +/// The single source of truth shared by the real prune +/// (`write_fastly_local_config_store`) and the dry-run count, so the previewed +/// number can never drift from what `--yes` actually removes. +pub(super) fn is_prunable_leaf(contents: &toml_edit::Table, key: &str) -> bool { + let value_protected = contents + .get(key) + .and_then(toml_edit::Item::as_str) + .is_some_and(|text| { + value_announces_our_kind(text) + || value_is_future_format(text) + || gc_classify_root(key, text).is_ok() + }); + let has_nested = contents + .iter() + .any(|(other, _)| other != key && chunk_key_generation(key, other).is_some()); + !(value_protected || has_nested) +} + +/// [`reject_generated_key_collisions`] against a local `contents` table. +pub(super) fn reject_local_generated_key_collisions( + contents_tbl: &toml_edit::Table, + entries: &[(String, String)], +) -> Result<(), String> { + let sibling_keys: HashSet = contents_tbl + .iter() + .map(|(existing_key, _)| existing_key.to_owned()) + .collect(); + reject_generated_key_collisions(entries, &sibling_keys, |chunk_key| { + Ok(contents_tbl + .get(chunk_key) + .and_then(toml_edit::Item::as_str) + .map(str::to_owned)) + }) +} + +/// Best-effort per-root orphan count for `config push --local --dry-run`. +/// Never fails the dry-run: on a missing file / no prior pointer / direct prior +/// value it reports `Ok(0)`; on unreadable or malformed prior state it reports +/// `Err(reason)` which the caller renders as an "unknown" line. +fn local_orphan_counts_for_dry_run( + path: &Path, + platform_name: &str, + entries: &[(String, String)], +) -> Vec<(String, Result)> { + use toml_edit::DocumentMut; + + // Parse the current file once (best-effort). Absent file => no prior. + let parsed: Result, String> = match fs::read_to_string(path) { + Ok(text) => text + .parse::() + .map(Some) + .map_err(|_err| "could not read prior state".to_owned()), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), + Err(_) => Err("could not read prior state".to_owned()), + }; + + entries + .iter() + .map(|(root_key, body)| { + let new_keys = match expand_root(root_key, body) { + Ok((_, keys, _)) => keys, + Err(err) => return (root_key.clone(), Err(err)), + }; + let count = match &parsed { + Err(reason) => Err(reason.clone()), + Ok(None) => Ok(0), + Ok(Some(doc)) => match local_contents_table(doc, platform_name) { + Err(reason) => Err(reason), + Ok(None) => Ok(0), + Ok(Some(contents)) => match contents.get(root_key) { + None => Ok(0), // no prior value for this root + Some(item) => match item.as_str() { + None => Err("could not read prior state".to_owned()), + Some(raw) => match prior_chunk_keys(root_key, raw) { + Ok(prior) => Ok(prior + .iter() + .filter(|key| !new_keys.contains(*key)) + // Count only what the real prune would remove: + // it must still be PRESENT and a prunable leaf + // by the SAME predicate the prune uses. + .filter(|key| { + contents.get(key.as_str()).is_some() + && is_prunable_leaf(contents, key) + }) + .count()), + Err(_) => Err("suspicious prior pointer".to_owned()), + }, + }, + }, + }, + }; + (root_key.clone(), count) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::super::FastlyCliAdapter; + use super::super::provision_local::write_fastly_local_config_store; + use super::*; + use crate::chunked_config::CHUNK_KEY_INFIX; + use crate::cli::test_support::chunk_keys_of; + use edgezero_adapter::registry::{Adapter as _, AdapterPushContext, ResolvedStoreId}; + use tempfile::tempdir; + + // Shared fixture names. + const TEST_CONFIG_ID: &str = "app_config"; + + /// Write a `fastly.toml` at `path` carrying `name = "demo"` plus the + /// provisioned `[local_server.config_stores.]` block (with + /// `format` + an empty `contents` table) that `provision --local` + /// creates. `config push --local` only upserts into that existing + /// table -- it refuses to fabricate the block -- so tests that push + /// must seed it first. + fn seed_provisioned(path: &Path, platform: &str) { + fs::write( + path, + format!( + "name = \"demo\"\n\n\ + [local_server.config_stores.{platform}]\n\ + format = \"inline-toml\"\n\n\ + [local_server.config_stores.{platform}.contents]\n" + ), + ) + .expect("seed provisioned fastly.toml"); + } + + /// Build a valid `BlobEnvelope` JSON string of approximately `target_len` bytes. + fn make_test_envelope(target_len: usize) -> String { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + let pad = "x".repeat(target_len.saturating_add(64)); + let data = json!({ "pad": pad }); + let raw = + serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:00Z".into())).unwrap(); + if raw.len() >= target_len { + let overhead = raw.len().saturating_sub(pad.len()); + let adjusted = "x".repeat(target_len.saturating_sub(overhead)); + let data2 = json!({ "pad": adjusted }); + serde_json::to_string(&BlobEnvelope::new(data2, "2026-06-22T00:00:00Z".into())).unwrap() + } else { + raw + } + } + + // ---------- read_config_entry_local ---------- + + #[test] + fn read_local_returns_missing_store_when_fastly_toml_absent() { + let dir = tempdir().expect("tempdir"); + // No fastly.toml written — file missing. + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("missing file is not an error"); + assert!( + matches!(result, ReadConfigEntry::MissingStore), + "absent fastly.toml => MissingStore" + ); + } + + #[test] + fn read_local_returns_missing_store_when_no_local_server_contents() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // fastly.toml exists but has no [local_server.config_stores.*] block. + fs::write(&path, "name = \"demo\"\n[setup.config_stores.app_config]\n").expect("write"); + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("missing local_server block is not an error"); + assert!( + matches!(result, ReadConfigEntry::MissingStore), + "no local_server stanza => MissingStore" + ); + } + + #[test] + fn read_local_returns_missing_key_when_key_absent_from_contents() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // Write a local_server block with a different key so the store exists + // but the requested key is absent. + fs::write( + &path, + format!( + "name = \"demo\"\n\ + [local_server.config_stores.{TEST_CONFIG_ID}]\n\ + format = \"inline-toml\"\n\ + [local_server.config_stores.{TEST_CONFIG_ID}.contents]\n\ + other_key = \"other_value\"\n" + ), + ) + .expect("write"); + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("missing key is not an error"); + assert!( + matches!(result, ReadConfigEntry::MissingKey), + "key absent from contents => MissingKey" + ); + } + + #[test] + fn read_local_returns_present_when_key_exists_in_contents() { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + seed_provisioned(&path, TEST_CONFIG_ID); + + // Use a valid BlobEnvelope value — the resolver requires BlobEnvelope + // or chunk-pointer JSON; raw strings are not accepted post-chunking. + let envelope_json = serde_json::to_string(&BlobEnvelope::new( + json!({"hello": "fastly"}), + "2026-06-22T00:00:00Z".into(), + )) + .expect("serialize"); + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), envelope_json.clone())], + &[], + ) + .expect("setup write"); + + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("key present"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present variant"); + }; + assert_eq!(value, envelope_json, "value matches what was written"); + } + + #[test] + fn read_local_roundtrips_with_push_local() { + // Write via push_config_entries_local, then read via + // read_config_entry_local — the two must agree on the value. + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + seed_provisioned(&path, TEST_CONFIG_ID); + + // push_config_entries_local passes the value through the chunk-pointer + // helper which stores it verbatim when ≤ 8 000 chars. The reader then + // resolves it through the same resolver that requires BlobEnvelope JSON. + let envelope_json = serde_json::to_string(&BlobEnvelope::new( + json!({"hello": "roundtrip"}), + "2026-06-22T00:00:00Z".into(), + )) + .expect("serialize"); + let entries = vec![("greeting".to_owned(), envelope_json.clone())]; + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect("push succeeds"); + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("read succeeds"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present after push+read roundtrip"); + }; + assert_eq!(value, envelope_json, "roundtrip value matches"); + } + + /// Push-after-provision: `config push --local` writes config into + /// `[local_server.config_stores.*]`; it must leave the operator's + /// hand-edited `[[local_server.secret_stores.]]` entry (real + /// `env` mapping) untouched. + #[test] + fn push_after_provision_preserves_secret_store_entry() { + use edgezero_adapter::registry::{ProvisionMode, TypedSecretEntry}; + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // Baseline + the provisioned config-store block the later + // `config push --local` upserts into (absolute dotted header, so + // it appends cleanly regardless of the baseline's trailing table). + let baseline = format!( + "{}\n[local_server.config_stores.{TEST_CONFIG_ID}]\nformat = \"inline-toml\"\n[local_server.config_stores.{TEST_CONFIG_ID}.contents]\n", + super::super::run::synthesise_fastly_toml("demo", None), + ); + fs::write(&path, baseline).expect("write baseline"); + // 1. Provision writes the `[[local_server.secret_stores.*]]` + // entry (env defaults to the upper-cased key). + FastlyCliAdapter + .provision_typed( + dir.path(), + Some("fastly.toml"), + None, + &[TypedSecretEntry::new("default", "field", "api_token")], + ProvisionMode::Local, + false, + ) + .expect("provision_typed writes the secret entry"); + // 2. Operator customises the env mapping on that entry. + let provisioned = fs::read_to_string(&path).expect("provision wrote fastly.toml"); + assert!( + provisioned.contains("[[local_server.secret_stores.default]]"), + "provision must write the secret_store entry: {provisioned}" + ); + fs::write( + &path, + provisioned.replace("env = \"API_TOKEN\"", "env = \"REAL_ENV_MAPPING\""), + ) + .expect("operator edit"); + + let envelope = serde_json::to_string(&BlobEnvelope::new( + json!({"hello": "world"}), + "2026-06-22T00:00:00Z".into(), + )) + .expect("serialize"); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[("greeting".to_owned(), envelope)], + &AdapterPushContext::new(), + false, + ) + .expect("push succeeds"); + + let after = fs::read_to_string(&path).expect("read"); + let doc: toml_edit::DocumentMut = after.parse().expect("re-parse"); + let arr = doc["local_server"]["secret_stores"]["default"] + .as_array_of_tables() + .expect("secret_stores.default preserved"); + assert_eq!( + arr.len(), + 1, + "operator's secret entry still present: {after}" + ); + let row = arr.get(0).expect("row"); + assert_eq!( + row.get("key").and_then(|item| item.as_str()), + Some("api_token") + ); + assert_eq!( + row.get("env").and_then(|item| item.as_str()), + Some("REAL_ENV_MAPPING"), + "config push must not disturb the operator's secret_store env mapping: {after}" + ); + } + + #[test] + fn read_local_requires_adapter_manifest_path() { + let dir = tempdir().expect("tempdir"); + let result = FastlyCliAdapter.read_config_entry_local( + dir.path(), + None, // adapter_manifest_path missing + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ); + match result { + Err(err) => assert!( + err.contains("[adapters.fastly.adapter].manifest"), + "error names the missing field: {err}" + ), + Ok(_) => panic!("expected Err when adapter_manifest_path is None"), + } + } + + // ---------- push_config_entries_local ---------- + + /// Spec 12.7: pushing two blobs under different root keys + /// (e.g. `app_config` + `app_config_staging`) must leave both + /// keys readable from the local fastly.toml so the runtime + /// `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` override can + /// switch between them. Prior to the upsert fix the second + /// push wholesale-replaced the per-store contents table. + #[cfg(unix)] + #[test] + fn push_config_entries_local_preserves_sibling_keys() { + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + let store = ResolvedStoreId::from_logical(TEST_CONFIG_ID); + let ctx = AdapterPushContext::new(); + + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &store, + &[("app_config".to_owned(), "{\"envelope\":\"A\"}".to_owned())], + &ctx, + false, + ) + .expect("first push"); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &store, + &[( + "app_config_staging".to_owned(), + "{\"envelope\":\"B\"}".to_owned(), + )], + &ctx, + false, + ) + .expect("second push (sibling key)"); + + let raw = fs::read_to_string(&fastly_toml).expect("read"); + let doc: toml_edit::DocumentMut = raw.parse().expect("parse"); + let contents = doc + .get("local_server") + .and_then(|ls| ls.get("config_stores")) + .and_then(|cs| cs.get(TEST_CONFIG_ID)) + .and_then(|st| st.get("contents")) + .and_then(toml_edit::Item::as_table) + .expect("contents after sibling push"); + let app_config = contents + .get("app_config") + .and_then(toml_edit::Item::as_str) + .expect("default key must survive sibling push"); + assert_eq!( + app_config, "{\"envelope\":\"A\"}", + "default key value: {raw}" + ); + let staging = contents + .get("app_config_staging") + .and_then(toml_edit::Item::as_str) + .expect("staging key must be present"); + assert_eq!(staging, "{\"envelope\":\"B\"}", "staging key value: {raw}"); + } + + #[cfg(unix)] + #[test] + fn push_config_entries_local_writes_literal_dotted_chunk_keys() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + let entries = vec![(TEST_CONFIG_ID.to_owned(), envelope)]; + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect("local push must succeed"); + + let after = fs::read_to_string(&fastly_toml).expect("read back"); + // Chunk keys contain '.' and must appear as quoted string keys, + // not as TOML nested tables (which would look like [table.sub]). + assert!( + after.contains(".__edgezero_chunks."), + "chunk keys written to fastly.toml: {after}" + ); + // Parse with toml_edit and confirm chunk keys are string-keyed entries. + let doc: toml_edit::DocumentMut = after.parse().expect("must parse"); + let contents = doc + .get("local_server") + .and_then(|ls| ls.get("config_stores")) + .and_then(|cs| cs.get(TEST_CONFIG_ID)) + .and_then(|st| st.get("contents")) + .expect("contents table must exist"); + // At least one chunk key must be present as a string value (not a table). + let has_chunk_string = contents.as_table().is_some_and(|tbl| { + tbl.iter() + .any(|(key, val)| key.contains(".__edgezero_chunks.") && val.as_value().is_some()) + }); + assert!( + has_chunk_string, + "chunk keys must be literal string-valued entries, not nested tables: {after}" + ); + } + + #[cfg(unix)] + #[test] + fn push_config_entries_local_dry_run_reports_chunking_and_does_not_edit_fastly_toml() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + let original = fs::read_to_string(&fastly_toml).expect("read seed"); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + let entries = vec![(TEST_CONFIG_ID.to_owned(), envelope)]; + let out = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, // dry_run + ) + .expect("local dry-run must not error"); + + // File must be untouched — including by the structural probe. + let after = fs::read_to_string(&fastly_toml).expect("read back"); + assert_eq!(after, original, "dry-run must not edit fastly.toml"); + + // Output must describe chunking intent. + let combined = out.join("\n"); + assert!( + combined.contains("would set") && combined.contains("chunked"), + "must report chunked intent: {combined}" + ); + } + + #[cfg(unix)] + #[test] + fn push_config_entries_local_dry_run_refuses_when_store_not_provisioned() { + // The dry-run must model the real operation: the real push + // refuses to fabricate an unprovisioned store block, so previewing + // a successful edit would promise something that cannot happen. + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + let original = "name = \"demo\"\n"; + fs::write(&fastly_toml, original).expect("write"); + + let err = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[("greeting".to_owned(), "{\"envelope\":\"A\"}".to_owned())], + &AdapterPushContext::new(), + true, // dry_run + ) + .expect_err("dry-run must surface the same refusal as the real push"); + assert!( + err.contains("provision --adapter fastly --local"), + "error points at provision: {err}" + ); + // The read-only probe must leave the file byte-identical. + let after = fs::read_to_string(&fastly_toml).expect("read back"); + assert_eq!(after, original, "dry-run probe must not edit fastly.toml"); + } + + // ---------- local read integration tests ---------- + + #[test] + fn read_config_entry_local_resolves_direct_value() { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + + let envelope = BlobEnvelope::new(json!({"x": 1_i32}), "2026-06-22T00:00:00Z".into()); + let json_str = serde_json::to_string(&envelope).unwrap(); + // Write directly as a single entry (not via push_config_entries_local so we + // control the exact TOML content). + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + write_fastly_local_config_store( + &fastly_toml, + TEST_CONFIG_ID, + &[("cfg".to_owned(), json_str.clone())], + &[], + ) + .expect("write"); + + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "cfg", + &AdapterPushContext::new(), + ) + .expect("local read must succeed"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present"); + }; + assert_eq!(value, json_str, "direct envelope passes through unchanged"); + } + + #[test] + fn read_config_entry_local_reconstructs_chunked_envelope() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + let physical = prepare_fastly_config_entries(TEST_CONFIG_ID, &envelope).unwrap(); + // Write all physical entries (chunks + pointer) to the local store. + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + write_fastly_local_config_store(&fastly_toml, TEST_CONFIG_ID, &physical, &[]) + .expect("write"); + + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + TEST_CONFIG_ID, + &AdapterPushContext::new(), + ) + .expect("local chunked read must succeed"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present"); + }; + assert_eq!( + value, envelope, + "reconstructed envelope must equal original" + ); + } + + /// Spec 12.3 + 9.3: a second oversized push must converge the + /// runtime on the NEW envelope — chunk keys are content-addressed + /// by the full-envelope SHA, so push B writes a new chunk-set and + /// installs a new root pointer. + /// + /// The local fastly.toml writer upserts per-key (so a sibling + /// `--key app_config_staging` push leaves `app_config` intact per + /// spec 12.7). Within the SAME root key, GC on re-push prunes the + /// prior generation: after envelope B's push, envelope A's chunks — + /// now unreferenced by the `app_config` pointer — are removed from + /// the contents table. A read after push B follows the active + /// pointer and reconstructs envelope B, not A. + #[cfg(unix)] + #[test] + #[expect( + clippy::too_many_lines, + reason = "linear test scenario: push A, inspect, push B, inspect, read; splitting would obscure the chunk-set comparison" + )] + fn second_oversized_push_converges_runtime_on_new_envelope() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + + // First push: envelope A. Records the chunk-key set so we can + // confirm they are pruned by the second push's GC. + let envelope_a = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), envelope_a.clone())], + &AdapterPushContext::new(), + false, + ) + .expect("first push must succeed"); + + let after_a = fs::read_to_string(&fastly_toml).expect("read"); + let doc_a: toml_edit::DocumentMut = after_a.parse().expect("parse"); + let contents_a = doc_a + .get("local_server") + .and_then(|ls| ls.get("config_stores")) + .and_then(|cs| cs.get(TEST_CONFIG_ID)) + .and_then(|st| st.get("contents")) + .and_then(toml_edit::Item::as_table) + .expect("contents table after push A"); + let chunks_a: Vec = contents_a + .iter() + .map(|(key, _)| key.to_owned()) + .filter(|key| key.contains(".__edgezero_chunks.")) + .collect(); + assert!( + !chunks_a.is_empty(), + "push A must have produced chunk entries: {after_a}" + ); + + // Second push: a DIFFERENT oversized envelope B. The + // content-addressed chunk keys must shift to B's sha; GC then + // prunes the old A-chunks. Build envelope B with a distinct + // payload key so its SHA differs from A's even at the same + // total length. + let envelope_b = { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + let data = json!({ "alt": "x".repeat(FASTLY_CONFIG_ENTRY_LIMIT) }); + serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:01Z".to_owned())) + .expect("envelope B serialises") + }; + assert_ne!(envelope_a, envelope_b, "test fixtures must differ"); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), envelope_b.clone())], + &AdapterPushContext::new(), + false, + ) + .expect("second push must succeed"); + + let after_b = fs::read_to_string(&fastly_toml).expect("read"); + let doc_b: toml_edit::DocumentMut = after_b.parse().expect("parse"); + let contents_b = doc_b + .get("local_server") + .and_then(|ls| ls.get("config_stores")) + .and_then(|cs| cs.get(TEST_CONFIG_ID)) + .and_then(|st| st.get("contents")) + .and_then(toml_edit::Item::as_table) + .expect("contents table after push B"); + let chunks_b: Vec = contents_b + .iter() + .map(|(key, _)| key.to_owned()) + .filter(|key| key.contains(".__edgezero_chunks.")) + .collect(); + assert!( + !chunks_b.is_empty(), + "push B must have produced chunk entries: {after_b}" + ); + + // Chunk keys are content-addressed by envelope SHA, so the B + // push installs a fresh chunk-set whose keys are all distinct + // from A's. GC on re-push prunes the now-unreferenced A-chunks. + let new_b_chunks: Vec<&String> = chunks_b + .iter() + .filter(|key| !chunks_a.contains(*key)) + .collect(); + assert!( + !new_b_chunks.is_empty(), + "push B must have added at least one new content-addressed chunk: A-set={chunks_a:?} B-set={chunks_b:?}" + ); + // Old A-chunks are pruned: GC deletes the prior generation the + // old pointer referenced once B's pointer supersedes it. + for chunk_key in &chunks_a { + assert!( + !chunks_b.contains(chunk_key), + "old A-chunk `{chunk_key}` must be pruned from the local table after push B; B-set={chunks_b:?}" + ); + } + + // Runtime-correctness property: a fresh read after push B + // reconstructs envelope B (NOT envelope A). + let read = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + TEST_CONFIG_ID, + &AdapterPushContext::new(), + ) + .expect("local read after push B"); + let ReadConfigEntry::Present(value) = read else { + panic!("expected Present after push B"); + }; + assert_eq!( + value, envelope_b, + "read after second push must reconstruct envelope B, not A" + ); + assert_ne!( + value, envelope_a, + "old envelope A's chunks must be inert -- read must NOT return A" + ); + } + + /// a corrupt/invalid prior value must NOT abort the + /// local read, or the CLI push aborts on the diff read before the writer's + /// fail-soft ("overwrite, warn, prune nothing") can repair the state. + /// `config push` is how an operator recovers, so the read reports `Corrupt` + /// ("cannot diff; will overwrite") and lets the write proceed. + #[test] + fn read_config_entry_local_degrades_corrupt_prior_to_corrupt() { + use crate::chunked_config::{CHUNK_KEY_INFIX, POINTER_KIND}; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + + // A pointer-KIND value that is invalid (missing the chunks it needs). + // The resolver would error on this; the local read must NOT propagate + // that as `Err`. + let broken_pointer = format!( + r#"{{"edgezero_kind":"{POINTER_KIND}","version":1,"chunks":[{{"key":"cfg{CHUNK_KEY_INFIX}{sha}.0","len":10,"sha256":"x"}}],"data_sha256":"","envelope_len":10,"envelope_sha256":"{sha}"}}"#, + sha = "a".repeat(64), + ); + write_fastly_local_config_store( + &fastly_toml, + TEST_CONFIG_ID, + &[("cfg".to_owned(), broken_pointer)], + &[], + ) + .expect("write"); + + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "cfg", + &AdapterPushContext::new(), + ) + .expect("a corrupt local prior must NOT abort the read"); + assert!( + matches!(result, ReadConfigEntry::Corrupt(_)), + "a corrupt prior value must degrade to Corrupt so the push can overwrite it" + ); + } + + /// A `contents` that is not a table (a scalar or array) is malformed store + /// state. It must degrade to `Unsupported`, not fall through to `MissingKey` + /// (which would render an inaccurate "all values added" diff). + #[test] + fn read_config_entry_local_non_table_contents_is_unsupported() { + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + fs::write( + &fastly_toml, + format!("[local_server.config_stores.{TEST_CONFIG_ID}]\ncontents = 42\n"), + ) + .expect("seed"); + + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "cfg", + &AdapterPushContext::new(), + ) + .expect("a non-table contents must NOT abort the read"); + assert!( + matches!(result, ReadConfigEntry::Unsupported(_)), + "a non-table `contents` must degrade to Unsupported, not MissingKey" + ); + } + + /// A malformed PARENT table (`local_server` etc. as a scalar) must degrade to + /// Unsupported, not collapse to `MissingStore`'s "all values added" diff. + #[test] + fn read_config_entry_local_non_table_parent_is_unsupported() { + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + // `local_server` is a scalar, not a table. + fs::write(&fastly_toml, "local_server = 42\n").expect("seed"); + + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "cfg", + &AdapterPushContext::new(), + ) + .expect("a non-table parent must NOT abort the read"); + assert!( + matches!(result, ReadConfigEntry::Unsupported(_)), + "a non-table parent must degrade to Unsupported, not MissingStore" + ); + } + + // ---------- local chunk GC ---------- + + /// Config shrinks from chunked back under the 8 000-char limit: the + /// new value is a direct envelope, so GC prunes every prior chunk. + #[cfg(unix)] + #[test] + fn push_config_entries_local_prunes_prior_chunks_when_value_shrinks_to_direct() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + + let chunked = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), chunked)], + &AdapterPushContext::new(), + false, + ) + .expect("first push"); + + let direct = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), direct.clone())], + &AdapterPushContext::new(), + false, + ) + .expect("second push"); + + let after = fs::read_to_string(&fastly_toml).expect("read"); + let doc: toml_edit::DocumentMut = after.parse().expect("parse"); + let contents = doc + .get("local_server") + .and_then(|ls| ls.get("config_stores")) + .and_then(|cs| cs.get(TEST_CONFIG_ID)) + .and_then(|st| st.get("contents")) + .and_then(toml_edit::Item::as_table) + .expect("contents"); + + assert_eq!( + contents + .get(TEST_CONFIG_ID) + .and_then(toml_edit::Item::as_str), + Some(direct.as_str()), + "root holds the direct envelope" + ); + assert!( + !contents + .iter() + .any(|(key, _)| key.contains(CHUNK_KEY_INFIX)), + "prior chunks must be pruned: {after}" + ); + } + + /// The local prune must NOT delete a prior chunk key whose VALUE is a + /// runtime-readable root (a valid direct envelope). A small envelope padded + /// with trailing whitespace chunks so that chunk 0 is itself a whole, + /// verifying envelope; deleting it would drop live config. + #[test] + fn push_config_entries_local_keeps_a_chunk_key_holding_a_valid_envelope() { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + + // A padded envelope: chunk 0 is the whole envelope plus trailing spaces + // (still a valid, verifying envelope on its own). + let envelope = BlobEnvelope::new(json!({ "k": "v" }), "2026-06-22T00:00:00Z".into()); + let mut padded = serde_json::to_string(&envelope).unwrap(); + padded.push_str(&" ".repeat(8_200)); + let entries = prepare_fastly_config_entries(TEST_CONFIG_ID, &padded).expect("expand"); + let chunk0_key = entries[0].0.clone(); + // Confirm the fixture: chunk 0's value verifies as an envelope. + let parsed: BlobEnvelope = serde_json::from_str(&entries[0].1).expect("chunk0 parses"); + parsed.verify().expect("chunk0 verifies"); + + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), padded)], + &AdapterPushContext::new(), + false, + ) + .expect("first push"); + + // Re-push a direct value: the prior generation's chunks become orphans. + let direct = make_test_envelope(100); + let expected_deletions = entries.len().saturating_sub(2); // chunks minus the protected chunk0 + + // DRY-RUN first: its count must MATCH what the real prune deletes, i.e. + // it must exclude the protected root-like chunk0. + let dry = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), direct.clone())], + &AdapterPushContext::new(), + true, + ) + .expect("dry-run"); + assert!( + dry.join("\n") + .contains(&format!("would delete {expected_deletions} orphan chunks")), + "dry-run must count only the prunable orphans (excluding the protected root): {dry:?}" + ); + + let warnings = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), direct)], + &AdapterPushContext::new(), + false, + ) + .expect("second push"); + + let after = fs::read_to_string(&fastly_toml).expect("read"); + let doc: toml_edit::DocumentMut = after.parse().expect("parse"); + let contents = doc + .get("local_server") + .and_then(|ls| ls.get("config_stores")) + .and_then(|cs| cs.get(TEST_CONFIG_ID)) + .and_then(|st| st.get("contents")) + .and_then(toml_edit::Item::as_table) + .expect("contents"); + + assert!( + contents.contains_key(&chunk0_key), + "a chunk key holding a valid envelope is a runtime-readable root and must be kept: \ + {after}" + ); + assert!( + warnings + .iter() + .any(|warning| warning.contains("runtime-readable root")), + "the operator must be warned that the key was kept: {warnings:?}" + ); + } + + /// The local prune must NOT delete a prior chunk key whose value was written + /// by a NEWER format -- a v2 direct envelope (bumped version) stored under a + /// chunk-shaped key. Cloud GC fails closed on it; local prune must be + /// symmetric, or an older CLI destroys config a newer writer produced. + #[test] + fn push_config_entries_local_keeps_a_chunk_key_holding_a_future_envelope() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + let chunked = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(5_000)); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), chunked)], + &AdapterPushContext::new(), + false, + ) + .expect("seed"); + + // A v2 direct envelope (version bumped) parked at a chunk key. + let mut v2_value: serde_json::Value = serde_json::from_str( + &serde_json::to_string(&BlobEnvelope::new( + json!({ "k": "v" }), + "2026-01-01T00:00:00Z".to_owned(), + )) + .unwrap(), + ) + .unwrap(); + v2_value["version"] = json!(2_u32); + let v2 = v2_value.to_string(); + + let mut doc: toml_edit::DocumentMut = fs::read_to_string(&fastly_toml) + .expect("read") + .parse() + .expect("parse"); + let contents = doc["local_server"]["config_stores"][TEST_CONFIG_ID]["contents"] + .as_table_mut() + .expect("contents"); + let victim = contents + .iter() + .map(|(key, _)| key.to_owned()) + .find(|key| key.contains(CHUNK_KEY_INFIX)) + .expect("a chunk key"); + contents.insert(&victim, toml_edit::value(v2)); + fs::write(&fastly_toml, doc.to_string()).expect("write"); + + let direct = make_test_envelope(100); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), direct)], + &AdapterPushContext::new(), + false, + ) + .expect("re-push"); + + let after = fs::read_to_string(&fastly_toml).expect("read"); + let after_doc: toml_edit::DocumentMut = after.parse().expect("parse"); + assert!( + after_doc["local_server"]["config_stores"][TEST_CONFIG_ID]["contents"] + .as_table() + .expect("contents") + .contains_key(&victim), + "a v2 (future-format) envelope must be KEPT, not pruned: {after}" + ); + } + + /// The local prune must NOT delete a prior chunk key whose value claims our + /// `edgezero_kind` namespace with an UNKNOWN/future kind. The cloud GC path + /// fails closed on such a value; local replacement must be symmetric, or it + /// would destroy a newer-format entry an older CLI cannot understand. + #[test] + fn push_config_entries_local_keeps_a_chunk_key_holding_an_unknown_kind() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + + // Seed a real chunked generation, then overwrite ONE chunk value with a + // future-format value that claims our namespace but is not a v1 pointer. + let chunked = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(5_000)); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), chunked)], + &AdapterPushContext::new(), + false, + ) + .expect("seed"); + + let mut doc: toml_edit::DocumentMut = fs::read_to_string(&fastly_toml) + .expect("read") + .parse() + .expect("parse"); + let contents = doc["local_server"]["config_stores"][TEST_CONFIG_ID]["contents"] + .as_table_mut() + .expect("contents"); + let victim = contents + .iter() + .map(|(key, _)| key.to_owned()) + .find(|key| key.contains(CHUNK_KEY_INFIX)) + .expect("a chunk key"); + contents.insert( + &victim, + toml_edit::value(r#"{"edgezero_kind":"fastly_config_chunks_v2","new":true}"#), + ); + fs::write(&fastly_toml, doc.to_string()).expect("write"); + + // Re-push a direct value: every prior chunk becomes an orphan. + let direct = make_test_envelope(100); + let warnings = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), direct)], + &AdapterPushContext::new(), + false, + ) + .expect("re-push"); + + let after = fs::read_to_string(&fastly_toml).expect("read"); + let after_doc: toml_edit::DocumentMut = after.parse().expect("parse"); + let present = after_doc["local_server"]["config_stores"][TEST_CONFIG_ID]["contents"] + .as_table() + .expect("contents") + .contains_key(&victim); + assert!( + present, + "an unknown/future-kind value must be KEPT (symmetric with cloud GC fail-closed): {after}" + ); + assert!( + warnings + .iter() + .any(|warning| warning.contains("kept") && warning.contains("edgezero_kind")), + "the operator must be warned the namespace-claiming key was kept: {warnings:?}" + ); + } + + /// SYMMETRY with cloud GC: a local prune must NOT delete a truncated pointer + /// at a chunk-shaped key that HAS a canonical chunk nested beneath it — it is + /// a (broken) nested root, and removing it would orphan the nested chunks. + #[test] + fn push_config_entries_local_keeps_a_malformed_nested_root_holder() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + + // Seed a chunked generation, then turn ONE chunk key into a nested root: + // give it a truncated (unclassifiable) value AND a canonical chunk nested + // beneath it. + let chunked = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(5_000)); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), chunked)], + &AdapterPushContext::new(), + false, + ) + .expect("seed"); + + let mut doc: toml_edit::DocumentMut = fs::read_to_string(&fastly_toml) + .expect("read") + .parse() + .expect("parse"); + let contents = doc["local_server"]["config_stores"][TEST_CONFIG_ID]["contents"] + .as_table_mut() + .expect("contents"); + let holder = contents + .iter() + .map(|(key, _)| key.to_owned()) + .find(|key| key.contains(CHUNK_KEY_INFIX)) + .expect("a chunk key"); + // Truncated pointer at the holder (unclassifiable, announces no kind). + contents.insert(&holder, toml_edit::value(r#"{"chunks":[{"key":"#)); + // A canonical chunk nested BENEATH the holder. + let nested_chunk = format!("{holder}{CHUNK_KEY_INFIX}{}.0", "b".repeat(64)); + contents.insert(&nested_chunk, toml_edit::value("nested-payload")); + fs::write(&fastly_toml, doc.to_string()).expect("write"); + + // Re-push a direct value: every prior chunk becomes an orphan, including + // the holder (which the OLD pointer referenced). + let direct = make_test_envelope(100); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), direct)], + &AdapterPushContext::new(), + false, + ) + .expect("re-push"); + + let after = fs::read_to_string(&fastly_toml).expect("read"); + let after_doc: toml_edit::DocumentMut = after.parse().expect("parse"); + let after_contents = after_doc["local_server"]["config_stores"][TEST_CONFIG_ID]["contents"] + .as_table() + .expect("contents"); + assert!( + after_contents.contains_key(&holder), + "a nested-root holder with chunks beneath it must be KEPT, not pruned: {after}" + ); + } + + /// A logical key containing the reserved chunk infix is rejected + /// before any file I/O (it would collide with the chunk namespace). + #[cfg(unix)] + #[test] + fn push_config_entries_local_rejects_reserved_key() { + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + let bad_key = format!("app_config{CHUNK_KEY_INFIX}deadbeef.0"); + + let err = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(bad_key.clone(), "{}".to_owned())], + &AdapterPushContext::new(), + false, + ) + .expect_err("reserved key must be rejected"); + assert!(err.contains(&bad_key), "error names the key: {err}"); + assert!( + !fastly_toml.exists(), + "rejection must happen before any write" + ); + } + + /// A suspicious prior pointer (pointer-kind but invalid) makes GC + /// warn and delete nothing — pre-seeded chunk keys must survive. + #[cfg(unix)] + #[test] + fn push_config_entries_local_warns_on_suspicious_prior_pointer_and_keeps_chunks() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + // Seed the root with a pointer-kind-but-invalid value AND a real + // chunk-like key so "no deletes" is non-vacuous. + let seed = concat!( + "name = \"demo\"\n\n", + "[local_server.config_stores.app_config]\n", + "format = \"inline-toml\"\n\n", + "[local_server.config_stores.app_config.contents]\n", + "app_config = \"{\\\"edgezero_kind\\\":\\\"fastly_config_chunks\\\",\\\"version\\\":1}\"\n", + "\"app_config.__edgezero_chunks.deadbeef.0\" = \"seeded-chunk-payload\"\n", + ); + fs::write(&fastly_toml, seed).expect("seed"); + + let direct = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); + let out = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), direct.clone())], + &AdapterPushContext::new(), + false, + ) + .expect("push must still succeed"); + + let combined = out.join("\n"); + assert!( + combined.contains("skipping chunk GC"), + "must warn about the suspicious prior pointer: {combined}" + ); + + let after = fs::read_to_string(&fastly_toml).expect("read"); + let doc: toml_edit::DocumentMut = after.parse().expect("parse"); + let contents = doc + .get("local_server") + .and_then(|ls| ls.get("config_stores")) + .and_then(|cs| cs.get(TEST_CONFIG_ID)) + .and_then(|st| st.get("contents")) + .and_then(toml_edit::Item::as_table) + .expect("contents"); + assert!( + contents + .get("app_config.__edgezero_chunks.deadbeef.0") + .is_some(), + "pre-seeded chunk key must survive a suspicious-pointer skip: {after}" + ); + assert_eq!( + contents + .get(TEST_CONFIG_ID) + .and_then(toml_edit::Item::as_str), + Some(direct.as_str()), + "new value still written" + ); + } + + /// TOCTOU guard: if the locked reread finds the root now holds a NEWER format + /// (installed between the pre-push check and the lock), the writer must REFUSE + /// to overwrite it -- an older writer must never clobber a newer format. + #[cfg(unix)] + #[test] + fn push_config_entries_local_refuses_to_overwrite_a_future_prior() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + // The root now holds a v2 direct envelope from a newer writer. + let seed = concat!( + "name = \"demo\"\n\n", + "[local_server.config_stores.app_config]\n", + "format = \"inline-toml\"\n\n", + "[local_server.config_stores.app_config.contents]\n", + "app_config = \"{\\\"data\\\":{},\\\"sha256\\\":\\\"x\\\",\\\"generated_at\\\":\\\"t\\\",\\\"version\\\":2}\"\n", + ); + fs::write(&fastly_toml, seed).expect("seed"); + + let direct = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); + let err = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), direct)], + &AdapterPushContext::new(), + false, + ) + .expect_err("a future prior must abort the push under the lock"); + assert!( + err.contains("newer format") && err.to_lowercase().contains("refusing"), + "must refuse to clobber a newer format: {err}" + ); + // The v2 value must survive untouched. + let after = fs::read_to_string(&fastly_toml).expect("read"); + assert!( + after.contains("\\\"version\\\":2"), + "the newer-format value must be left intact: {after}" + ); + } + + /// The locked downgrade guard must catch a future INNER envelope hidden behind + /// a VALID v1 pointer -- only knowable after reconstruction against the locked + /// contents. The raw pointer looks like healthy v1. + #[cfg(unix)] + #[test] + fn push_config_entries_local_refuses_a_future_inner_prior() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + + // A large v1 envelope, chunked, with its inner version bumped to 2. Seed + // the pointer + chunks as the prior local state. + let v1 = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + let mut v2_value: serde_json::Value = serde_json::from_str(&v1).expect("parse"); + v2_value["version"] = serde_json::json!(2_u32); + let v2 = v2_value.to_string(); + let seed_entries = prepare_fastly_config_entries(TEST_CONFIG_ID, &v2).expect("chunk"); + write_fastly_local_config_store(&fastly_toml, TEST_CONFIG_ID, &seed_entries, &[]) + .expect("seed the prior v2-inner generation"); + + let direct = make_test_envelope(100); + let err = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), direct)], + &AdapterPushContext::new(), + false, + ) + .expect_err("a future INNER prior must abort the push under the lock"); + assert!( + err.contains("newer format") && err.to_lowercase().contains("refusing"), + "must refuse to clobber a future inner envelope: {err}" + ); + } + + /// A generated chunk key must never clobber an existing root-like sibling. + #[cfg(unix)] + #[test] + fn push_config_entries_local_refuses_clobbering_a_root_like_chunk_sibling() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + + // The chunk keys the push will generate for this body. + let body = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + let generated = prepare_fastly_config_entries(TEST_CONFIG_ID, &body).expect("chunk"); + let chunk_key = generated + .iter() + .map(|(key, _)| key.clone()) + .find(|key| key.contains(CHUNK_KEY_INFIX)) + .expect("a generated chunk key"); + + // Pre-seed that EXACT key with a root-like value (a valid direct envelope). + let root_like = make_test_envelope(100); + write_fastly_local_config_store( + &fastly_toml, + TEST_CONFIG_ID, + &[(chunk_key.clone(), root_like)], + &[], + ) + .expect("seed a root-like value at a chunk-shaped key"); + + let err = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), body)], + &AdapterPushContext::new(), + false, + ) + .expect_err("a generated chunk key clobbering a root-like sibling must abort"); + assert!( + err.contains("refusing to push") && err.contains(&chunk_key), + "must refuse and name the colliding chunk key: {err}" + ); + } + + /// The dry-run count must EXCLUDE a prior chunk that is already absent from + /// the file: the real prune's `remove()` is a no-op there, so counting it + /// would over-report the number of deletions. + #[cfg(unix)] + #[test] + fn push_config_entries_local_dry_run_excludes_already_missing_prior_chunks() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + + // Seed a multi-chunk generation. + let chunked = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(5_000)); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), chunked)], + &AdapterPushContext::new(), + false, + ) + .expect("seed"); + + // Manually delete ONE chunk entry: a prior chunk that is already gone. + let mut doc: toml_edit::DocumentMut = fs::read_to_string(&fastly_toml) + .expect("read") + .parse() + .expect("parse"); + let contents = doc["local_server"]["config_stores"][TEST_CONFIG_ID]["contents"] + .as_table_mut() + .expect("contents"); + let chunk_keys: Vec = contents + .iter() + .map(|(key, _)| key.to_owned()) + .filter(|key| key.contains(CHUNK_KEY_INFIX)) + .collect(); + assert!(chunk_keys.len() >= 2, "seed must have chunked"); + contents.remove(&chunk_keys[0]); + let present_after = chunk_keys.len().saturating_sub(1); + fs::write(&fastly_toml, doc.to_string()).expect("write"); + + // Dry-run a shrink-to-direct re-push: every remaining chunk is an orphan. + let direct = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); + let out = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), direct)], + &AdapterPushContext::new(), + true, + ) + .expect("dry-run"); + + let reported = out + .join("\n") + .split("would delete ") + .nth(1) + .and_then(|rest| rest.split_whitespace().next()) + .and_then(|n| n.parse::().ok()) + .expect("a numeric orphan count"); + assert_eq!( + reported, present_after, + "the already-absent chunk must not be counted (reported {reported}, present {present_after})" + ); + } + + /// Dry-run reports the orphan count and writes nothing. + #[cfg(unix)] + #[test] + fn push_config_entries_local_dry_run_reports_orphan_count() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + + let envelope_a = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), envelope_a)], + &AdapterPushContext::new(), + false, + ) + .expect("seed push"); + let before = fs::read_to_string(&fastly_toml).expect("read"); + + let envelope_b = { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + let data = json!({ "alt": "y".repeat(FASTLY_CONFIG_ENTRY_LIMIT) }); + serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:02Z".to_owned())) + .expect("envelope B") + }; + let out = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), envelope_b)], + &AdapterPushContext::new(), + true, // dry_run + ) + .expect("dry-run"); + + let combined = out.join("\n"); + assert!( + combined.contains("would delete") && combined.contains("orphan chunks"), + "dry-run must report orphan count: {combined}" + ); + assert_eq!( + fs::read_to_string(&fastly_toml).expect("read"), + before, + "dry-run must not edit fastly.toml" + ); + } + + /// PARITY: the dry-run's reported orphan count equals the number of chunk + /// keys the real (non-dry-run) push actually deletes, on ONE fixture. A + /// divergence would make the dry-run a misleading preview of the delete. + #[cfg(unix)] + #[test] + fn push_config_entries_local_dry_run_count_matches_real_deletions() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + + fn count_chunk_keys(toml_src: &str) -> usize { + let doc: toml_edit::DocumentMut = toml_src.parse().expect("parse"); + doc.get("local_server") + .and_then(|ls| ls.get("config_stores")) + .and_then(|cs| cs.get(TEST_CONFIG_ID)) + .and_then(|st| st.get("contents")) + .and_then(toml_edit::Item::as_table) + .map_or(0, |table| { + table + .iter() + .filter(|(key, _)| key.contains(CHUNK_KEY_INFIX)) + .count() + }) + } + fn parse_would_delete_count(text: &str) -> Option { + let marker = "would delete "; + let idx = text.find(marker)?; + text.get(idx.saturating_add(marker.len())..)? + .split_whitespace() + .next()? + .parse::() + .ok() + } + + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + + // Seed a multi-chunk generation, then measure how many chunk keys exist. + let chunked = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(5_000)); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), chunked)], + &AdapterPushContext::new(), + false, + ) + .expect("seed push"); + let seeded = fs::read_to_string(&fastly_toml).expect("read"); + let prior_chunk_count = count_chunk_keys(&seeded); + assert!(prior_chunk_count >= 2, "seed must have chunked: {seeded}"); + + // Dry-run a shrink-to-direct re-push: capture the reported orphan count. + let direct = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); + let out = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), direct.clone())], + &AdapterPushContext::new(), + true, // dry_run + ) + .expect("dry-run"); + let reported = parse_would_delete_count(&out.join("\n")) + .expect("dry-run must report a numeric orphan count"); + assert_eq!( + fs::read_to_string(&fastly_toml).expect("read"), + seeded, + "dry-run must not edit fastly.toml" + ); + + // Real re-push: count the chunk keys actually removed. + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), direct)], + &AdapterPushContext::new(), + false, + ) + .expect("real push"); + let after = fs::read_to_string(&fastly_toml).expect("read"); + let actually_deleted = prior_chunk_count.saturating_sub(count_chunk_keys(&after)); + + assert_eq!( + reported, actually_deleted, + "dry-run count {reported} must equal real deletions {actually_deleted}" + ); + assert_eq!( + reported, prior_chunk_count, + "a shrink-to-direct re-push orphans every prior chunk" + ); + } + + /// Real (non-dry-run) push over a MALFORMED prior pointer WARNS and deletes + /// nothing: its chunk list is untrustworthy, so no key is removed and the + /// root is simply overwritten with the new value. This is the real-push + /// counterpart to the dry-run "unknown" degradation on the same prior state. + #[cfg(unix)] + #[test] + fn push_config_entries_local_real_push_over_malformed_prior_warns_and_deletes_nothing() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + // A pointer-kind prior value missing its required fields — malformed, so + // `prior_chunk_keys` returns Err (warn, delete nothing). + let seed = concat!( + "name = \"demo\"\n\n", + "[local_server.config_stores.app_config]\n", + "format = \"inline-toml\"\n\n", + "[local_server.config_stores.app_config.contents]\n", + "app_config = \"{\\\"edgezero_kind\\\":\\\"fastly_config_chunks\\\",\\\"version\\\":1}\"\n", + ); + fs::write(&fastly_toml, seed).expect("seed"); + + let direct = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); + let out = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), direct.clone())], + &AdapterPushContext::new(), + false, + ) + .expect("real push must not fail on a malformed prior"); + + assert!( + out.iter().any(|line| line.contains("skipping chunk GC")), + "must warn about the malformed prior pointer: {out:?}" + ); + + let after = fs::read_to_string(&fastly_toml).expect("read"); + let doc: toml_edit::DocumentMut = after.parse().expect("parse"); + let contents = doc + .get("local_server") + .and_then(|ls| ls.get("config_stores")) + .and_then(|cs| cs.get(TEST_CONFIG_ID)) + .and_then(|st| st.get("contents")) + .and_then(toml_edit::Item::as_table) + .expect("contents"); + assert_eq!( + contents + .get(TEST_CONFIG_ID) + .and_then(toml_edit::Item::as_str), + Some(direct.as_str()), + "root is overwritten with the new direct envelope: {after}" + ); + } + + /// Dry-run of an identical re-push reports zero orphans (new keys + /// equal prior keys — regression for expanding `new_keys`). + #[cfg(unix)] + #[test] + fn push_config_entries_local_dry_run_identical_repush_counts_zero() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), envelope.clone())], + &AdapterPushContext::new(), + false, + ) + .expect("seed push"); + + let out = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), envelope)], + &AdapterPushContext::new(), + true, // dry_run, same bytes + ) + .expect("dry-run"); + + assert!( + out.join("\n").contains("would delete 0 orphan chunks"), + "identical re-push must count 0 orphans: {out:?}" + ); + } + + /// Dry-run over a suspicious prior pointer reports an unknown count + /// and does not fail. + #[cfg(unix)] + #[test] + fn push_config_entries_local_dry_run_suspicious_prior_pointer_unknown() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + let seed = concat!( + "name = \"demo\"\n\n", + "[local_server.config_stores.app_config]\n", + "format = \"inline-toml\"\n\n", + "[local_server.config_stores.app_config.contents]\n", + "app_config = \"{\\\"edgezero_kind\\\":\\\"fastly_config_chunks\\\",\\\"version\\\":1}\"\n", + ); + fs::write(&fastly_toml, seed).expect("seed"); + + let direct = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); + let out = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), direct)], + &AdapterPushContext::new(), + true, // dry_run + ) + .expect("dry-run must not fail on suspicious pointer"); + + assert!( + out.join("\n").contains("unknown: suspicious prior pointer"), + "dry-run must degrade to unknown: {out:?}" + ); + } + + /// A duplicate root key in one batch is rejected before any I/O. + /// Otherwise the earlier tuple's GC plan would reclaim the chunks the + /// LAST tuple just installed, leaving the final pointer dangling. + /// Regression: prior B, batch `[(root, A), (root, B)]` — the root must + /// still resolve afterwards. + #[cfg(unix)] + #[test] + fn push_config_entries_local_rejects_duplicate_root_keys() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + + let make = |tag: &str| { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + let data = json!({ tag: "x".repeat(FASTLY_CONFIG_ENTRY_LIMIT) }); + serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:00Z".to_owned())) + .expect("envelope") + }; + let envelope_a = make("aaa"); + let envelope_b = make("bbb"); + + // Prior generation B is live. + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), envelope_b.clone())], + &AdapterPushContext::new(), + false, + ) + .expect("seed push"); + let before = fs::read_to_string(&fastly_toml).expect("read"); + + // Duplicate-root batch must be rejected outright. + let err = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[ + (TEST_CONFIG_ID.to_owned(), envelope_a), + (TEST_CONFIG_ID.to_owned(), envelope_b.clone()), + ], + &AdapterPushContext::new(), + false, + ) + .expect_err("duplicate root keys must be rejected"); + assert!( + err.contains("more than once"), + "error explains the duplicate: {err}" + ); + assert_eq!( + fs::read_to_string(&fastly_toml).expect("read"), + before, + "rejection must happen before any write" + ); + + // The live root still resolves to B (nothing was reclaimed). + let read = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + TEST_CONFIG_ID, + &AdapterPushContext::new(), + ) + .expect("root must still resolve"); + let ReadConfigEntry::Present(value) = read else { + panic!("expected Present"); + }; + assert_eq!(value, envelope_b, "root still reconstructs envelope B"); + } + + /// GC of a chunked root must not touch a chunked SIBLING's chunks — + /// the prefix `app_config.__edgezero_chunks.` must not match + /// `app_config_staging.__edgezero_chunks.` (shared string prefix). + #[cfg(unix)] + #[test] + fn push_config_entries_local_gc_preserves_sibling_chunks() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + seed_provisioned(&fastly_toml, TEST_CONFIG_ID); + + let make = |tag: &str| { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + let data = json!({ tag: "x".repeat(FASTLY_CONFIG_ENTRY_LIMIT) }); + serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:00Z".to_owned())) + .expect("envelope") + }; + let push = |key: &str, body: String| { + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(key.to_owned(), body)], + &AdapterPushContext::new(), + false, + ) + .expect("push"); + }; + + // app_config gen X, then a chunked sibling, then app_config gen Z. + push("app_config", make("x1")); + push("app_config_staging", make("staging")); + let staging_chunks = chunk_keys_of("app_config_staging", &make("staging")); + push("app_config", make("z2")); // GCs app_config's gen-X chunks + + let after = fs::read_to_string(&fastly_toml).expect("read"); + let doc: toml_edit::DocumentMut = after.parse().expect("parse"); + let contents = doc + .get("local_server") + .and_then(|ls| ls.get("config_stores")) + .and_then(|cs| cs.get(TEST_CONFIG_ID)) + .and_then(|st| st.get("contents")) + .and_then(toml_edit::Item::as_table) + .expect("contents"); + for key in &staging_chunks { + assert!( + contents.get(key).is_some(), + "sibling chunk `{key}` must survive app_config GC: {after}" + ); + } + } +} diff --git a/crates/edgezero-adapter-fastly/src/cli/run.rs b/crates/edgezero-adapter-fastly/src/cli/run.rs new file mode 100644 index 00000000..71b3b612 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/run.rs @@ -0,0 +1,465 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use edgezero_adapter::cli_support::{ + self, find_manifest_upwards, find_workspace_root, path_distance, read_package_name, +}; +use edgezero_adapter::registry::AdapterExecContext; +use walkdir::WalkDir; + +/// # Errors +/// Returns an error if the Fastly CLI build command fails. +#[inline] +pub fn build(extra_args: &[String], ctx: &AdapterExecContext<'_>) -> Result { + let manifest = cli_support::declared_or_discovered_manifest(ctx, || { + find_fastly_manifest(cli_support::discovery_base(ctx)?.as_path()) + })?; + // `Cargo.toml` lives at the declared crate root, which is NOT + // necessarily the manifest's parent -- a nested declared manifest + // like `crates/server/config/fastly.toml` would otherwise resolve + // `crates/server/config/Cargo.toml`. + let crate_dir = cli_support::adapter_crate_dir(ctx, &manifest)?; + let cargo_manifest = crate_dir.join("Cargo.toml"); + let crate_name = read_package_name(&cargo_manifest)?; + + let mut command = Command::new("cargo"); + command + .args([ + "build", + "--release", + "--target", + "wasm32-wasip1", + "--manifest-path", + cargo_manifest + .to_str() + .ok_or("invalid Cargo manifest path")?, + ]) + .args(extra_args) + // Anchor cargo at the crate root, not the process cwd. When the + // CLI dispatches through an absolute `EDGEZERO_MANIFEST` from + // outside the project, an unanchored `cargo` would discover the + // wrong `.cargo/config.toml` and resolve relative args against the + // caller's directory. + .current_dir(&crate_dir); + for (key, value) in ctx.env() { + command.env(key, value); + } + let status = command + .status() + .map_err(|err| format!("failed to run cargo build: {err}"))?; + if !status.success() { + return Err(format!("cargo build failed with status {status}")); + } + + let workspace_root = find_workspace_root(&crate_dir); + let artifact = locate_artifact(&workspace_root, &crate_dir, &crate_name, extra_args, ctx)?; + let pkg_dir = workspace_root.join("pkg"); + fs::create_dir_all(&pkg_dir) + .map_err(|err| format!("failed to create {}: {err}", pkg_dir.display()))?; + let dest = pkg_dir.join(format!("{}.wasm", crate_name.replace('-', "_"))); + fs::copy(&artifact, &dest) + .map_err(|err| format!("failed to copy artifact to {}: {err}", dest.display()))?; + + Ok(dest) +} + +/// # Errors +/// Returns an error if the Fastly CLI deploy command fails. +#[inline] +pub fn deploy(extra_args: &[String], ctx: &AdapterExecContext<'_>) -> Result<(), String> { + let manifest = cli_support::declared_or_discovered_manifest(ctx, || { + find_fastly_manifest(cli_support::discovery_base(ctx)?.as_path()) + })?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; + + let mut command = Command::new("fastly"); + command + .args(["compute", "deploy"]) + .args(extra_args) + .current_dir(manifest_dir); + for (key, value) in ctx.env() { + command.env(key, value); + } + let status = command + .status() + .map_err(|err| format!("failed to run fastly CLI: {err}"))?; + if !status.success() { + return Err(format!("fastly compute deploy failed with status {status}")); + } + + Ok(()) +} + +/// # Errors +/// Returns an error if the Fastly CLI serve command (Viceroy) fails. +#[inline] +pub fn serve(extra_args: &[String], ctx: &AdapterExecContext<'_>) -> Result<(), String> { + let manifest = cli_support::declared_or_discovered_manifest(ctx, || { + find_fastly_manifest(cli_support::discovery_base(ctx)?.as_path()) + })?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; + + let mut command = Command::new("fastly"); + command + .args(["compute", "serve"]) + .args(extra_args) + .current_dir(manifest_dir); + for (key, value) in ctx.env() { + command.env(key, value); + } + let status = command + .status() + .map_err(|err| format!("failed to run fastly CLI: {err}"))?; + if !status.success() { + return Err(format!("fastly compute serve failed with status {status}")); + } + + Ok(()) +} + +fn find_fastly_manifest(start: &Path) -> Result { + if let Some(found) = find_manifest_upwards(start, "fastly.toml") { + return Ok(found); + } + + let root = find_workspace_root(start); + let mut candidates: Vec = WalkDir::new(&root) + .follow_links(true) + .max_depth(8) + .into_iter() + .filter_map(Result::ok) + .map(|entry| entry.path().to_path_buf()) + .filter(|path| { + path.file_name().is_some_and(|n| n == "fastly.toml") + && path + .parent() + .is_some_and(|dir| dir.join("Cargo.toml").exists()) + }) + .collect(); + + if candidates.is_empty() { + return Err("could not locate fastly.toml".to_owned()); + } + + candidates.sort_by_key(|path| { + let parent = path.parent().unwrap_or(Path::new("")); + path_distance(start, parent) + }); + + Ok(candidates.remove(0)) +} + +fn locate_artifact( + workspace_root: &Path, + crate_dir: &Path, + crate_name: &str, + build_args: &[String], + ctx: &AdapterExecContext<'_>, +) -> Result { + let target_triple = "wasm32-wasip1"; + let release_name = format!("{}.wasm", crate_name.replace('-', "_")); + + // Resolve cargo's effective target dir the SAME way the build did + // (`--target-dir` arg, then `CARGO_TARGET_DIR`, then a + // `.cargo/config.toml` `[build] target-dir`). When an override is in + // play, look ONLY there -- falling back to the conventional `target/` + // paths could package a STALE artifact from an earlier default build. + match cli_support::resolve_cargo_target_dir(crate_dir, build_args, ctx) { + cli_support::CargoTargetDir::Explicit(dir) => { + let candidate = dir.join(target_triple).join("release").join(&release_name); + return if candidate.exists() { + Ok(candidate) + } else { + Err(format!( + "compiled artifact `{release_name}` not found in the requested target directory {} (a custom target dir was set via --target-dir, CARGO_TARGET_DIR, or .cargo/config.toml); refusing to fall back to a conventional target path to avoid packaging a stale artifact", + candidate.display() + )) + }; + } + cli_support::CargoTargetDir::Conventional => {} + } + + let manifest_target = crate_dir + .join("target") + .join(target_triple) + .join("release") + .join(&release_name); + if manifest_target.exists() { + return Ok(manifest_target); + } + + let workspace_target = workspace_root + .join("target") + .join(target_triple) + .join("release") + .join(&release_name); + if workspace_target.exists() { + return Ok(workspace_target); + } + + Err(format!( + "compiled artifact not found (looked in {} and workspace target)", + crate_dir.display() + )) +} + +/// Synthesised baseline `fastly.toml` for clean clones. Built via +/// `toml_edit::DocumentMut` (NOT raw `format!`) so any legal +/// `[app].name` — including names with TOML-significant characters +/// like `"`, `\`, or newlines — is escaped correctly. Manifest +/// validation today only length-bounds the name; raw interpolation +/// would produce invalid TOML for legal inputs. +/// +/// `service_id` from `[adapters.fastly.deployed]` is threaded +/// through as `Option<&str>`; when `None` the key is OMITTED so the +/// operator's first `fastly compute deploy` populates it (per spec +/// §"Writeback ownership" — we deliberately don't emit +/// `service_id = ""`). +pub(crate) fn synthesise_fastly_toml(crate_name: &str, service_id: Option<&str>) -> String { + use toml_edit::{DocumentMut, Item, Table, value}; + + // The `name` field spells the adapter crate's Cargo package + // name. The caller in `cli/mod.rs` reads this from the + // `Cargo.toml` adjacent to the adapter manifest (honouring the + // operator's `[adapters.fastly.adapter].crate` rename) and + // falls back to the scaffold convention + // `-adapter-fastly` only when no Cargo.toml is + // discoverable. `fastly compute build` reads this and expects + // it to match the Cargo package it builds. + + let mut doc = DocumentMut::new(); + doc.decor_mut().set_prefix("# edgezero-provision: v1\n"); + // `Table::insert` returns the previous value (if any). We build a + // fresh document from `DocumentMut::new()`, so nothing to displace + // -- but the return is discarded intentionally. Using `insert` + // instead of `doc["..."] = ...` sidesteps `clippy::indexing_slicing` + // (the index form panics if the key is missing; `insert` doesn't). + // No `authors` key: the spec's normative Fastly baseline + // (spec §"Fastly (fastly.toml)") is `manifest_version` + `name` + + // `language` + `[scripts].build` + `[local_server]`. Emitting an + // empty `authors = [""]` array exceeded that baseline. Field order matches the spec's shown + // baseline so the exact-content test can pin it verbatim. + doc.insert("manifest_version", value(3)); + doc.insert("name", value(crate_name)); + doc.insert("language", value("rust")); + if let Some(sid) = service_id { + doc.insert("service_id", value(sid)); + } + // `[scripts]` and `[local_server]` are the standard Fastly Compute + // scaffold tables. `scripts.build` pins the cargo target so + // `fastly compute build` reproduces the wasm artifact; the empty + // `[local_server]` header is a placeholder the operator fills in + // when seeding local viceroy state (config-store contents, + // per-request backends, etc.). + let mut scripts = Table::new(); + scripts.insert( + "build", + value("cargo build --profile release --target wasm32-wasip1"), + ); + doc.insert("scripts", Item::Table(scripts)); + doc.insert("local_server", Item::Table(Table::new())); + doc.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_adapter::cli_support::read_package_name; + use tempfile::tempdir; + + #[test] + fn finds_closest_manifest_when_multiple_exist() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + + let first = root.join("crates/first"); + fs::create_dir_all(&first).unwrap(); + fs::write(first.join("Cargo.toml"), "[package]\nname=\"first\"").unwrap(); + fs::write(first.join("fastly.toml"), "name=\"first\"").unwrap(); + + let second = root.join("examples/second"); + fs::create_dir_all(&second).unwrap(); + fs::write(second.join("Cargo.toml"), "[package]\nname=\"second\"").unwrap(); + fs::write(second.join("fastly.toml"), "name=\"second\"").unwrap(); + + let found = find_fastly_manifest(&second).unwrap(); + assert_eq!(found, second.join("fastly.toml")); + } + + #[test] + fn finds_manifest_in_current_directory() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + fs::write(root.join("fastly.toml"), "name = \"demo\"").unwrap(); + + let manifest = find_fastly_manifest(root).expect("should find manifest"); + assert_eq!(manifest, root.join("fastly.toml")); + } + + #[test] + fn locate_artifact_considers_workspace_target() { + let dir = tempdir().unwrap(); + let workspace = dir.path(); + let manifest_dir = workspace.join("service"); + fs::create_dir_all(manifest_dir.join("target/wasm32-wasip1/release")).unwrap(); + let artifact = workspace.join("target/wasm32-wasip1/release/demo.wasm"); + fs::create_dir_all(artifact.parent().unwrap()).unwrap(); + fs::write(&artifact, "wasm").unwrap(); + + let located = locate_artifact( + workspace, + &manifest_dir, + "demo", + &[], + &AdapterExecContext::new(), + ) + .unwrap(); + assert_eq!(located, artifact); + } + + #[test] + fn locate_artifact_honors_target_dir_build_arg_over_stale_default() { + // A `--target-dir` build arg redirects cargo. Discovery must look + // ONLY there, even when a STALE artifact sits at the conventional + // workspace target from an earlier default build. + let dir = tempdir().unwrap(); + let workspace = dir.path(); + let crate_dir = workspace.join("service"); + fs::create_dir_all(&crate_dir).unwrap(); + let stale = workspace.join("target/wasm32-wasip1/release/demo.wasm"); + fs::create_dir_all(stale.parent().unwrap()).unwrap(); + fs::write(&stale, "stale").unwrap(); + let fresh = crate_dir.join("custom/wasm32-wasip1/release/demo.wasm"); + fs::create_dir_all(fresh.parent().unwrap()).unwrap(); + fs::write(&fresh, "fresh").unwrap(); + + let build_args = ["--target-dir".to_owned(), "custom".to_owned()]; + let located = locate_artifact( + workspace, + &crate_dir, + "demo", + &build_args, + &AdapterExecContext::new(), + ) + .unwrap(); + assert_eq!(located, fresh, "must select the custom-target artifact"); + } + + #[test] + fn locate_artifact_errors_when_explicit_target_dir_has_no_artifact() { + // An explicit target dir with no artifact must error rather than + // silently fall back to a stale conventional artifact. + let dir = tempdir().unwrap(); + let workspace = dir.path(); + let crate_dir = workspace.join("service"); + fs::create_dir_all(&crate_dir).unwrap(); + let stale = workspace.join("target/wasm32-wasip1/release/demo.wasm"); + fs::create_dir_all(stale.parent().unwrap()).unwrap(); + fs::write(&stale, "stale").unwrap(); + + let env = [("CARGO_TARGET_DIR".to_owned(), "custom".to_owned())]; + let ctx = AdapterExecContext::new().with_env(&env); + let err = locate_artifact(workspace, &crate_dir, "demo", &[], &ctx) + .expect_err("must not fall back to the stale conventional artifact"); + assert!(err.contains("stale"), "error explains the refusal: {err}"); + } + + #[test] + fn read_package_falls_back_to_name() { + let dir = tempdir().unwrap(); + let manifest = dir.path().join("Cargo.toml"); + fs::write(&manifest, "name = \"demo\"").unwrap(); + let name = read_package_name(&manifest).unwrap(); + assert_eq!(name, "demo"); + } + + #[test] + fn read_package_prefers_package_table() { + let dir = tempdir().unwrap(); + let manifest = dir.path().join("Cargo.toml"); + fs::write(&manifest, "[package]\nname = \"demo\"\n").unwrap(); + let name = read_package_name(&manifest).unwrap(); + assert_eq!(name, "demo"); + } + + // ---------- synthesise_fastly_toml ---------- + + #[test] + fn synthesises_fastly_toml_matches_spec_baseline_exactly() { + // Exact-content test: the + // synthesised fastly.toml with no tracked service_id must equal + // the spec's normative Fastly baseline byte-for-byte -- no + // `authors` array, no other extras. + let out = synthesise_fastly_toml("demo-adapter-fastly", None); + let expected = "# edgezero-provision: v1\n\ + manifest_version = 3\n\ + name = \"demo-adapter-fastly\"\n\ + language = \"rust\"\n\n\ + [scripts]\n\ + build = \"cargo build --profile release --target wasm32-wasip1\"\n\n\ + [local_server]\n"; + assert_eq!(out, expected, "fastly.toml baseline drifted from spec"); + } + + #[test] + fn synthesises_fastly_toml_pins_service_id_when_deployed_present() { + let out = synthesise_fastly_toml("demo", Some("SVC1")); + // Reparse-and-index: substring `service_id = "SVC1"` passes + // for both the correct root form AND the shipped bug where + // service_id landed inside `[local_server]`. Explicitly assert + // it's at the ROOT of the doc. + let doc: toml_edit::DocumentMut = out.parse().expect("re-parse synthesised fastly.toml"); + assert_eq!( + doc.get("service_id").and_then(toml_edit::Item::as_str), + Some("SVC1"), + "service_id must live at the TOML root, not nested under a section: {out}" + ); + // Also assert no `local_server.service_id` -- that would be + // the exact silent-drift bug we're guarding against. + let local_server_carries_it = doc + .get("local_server") + .and_then(|item| item.as_table()) + .and_then(|tbl| tbl.get("service_id")) + .is_some(); + assert!( + !local_server_carries_it, + "service_id must NOT appear under `[local_server]`: {out}" + ); + } + + #[test] + fn synthesise_fastly_toml_escapes_pathological_crate_names() { + // Cargo restricts `[package].name` to `[A-Za-z0-9_-]`, but + // the synth must still be defensive against TOML-hostile + // inputs so an exotic value in + // `[adapters.fastly.adapter].crate` doesn't produce invalid + // TOML. + for name in [ + r#"has"quote"#, + r"has\backslash", + "has\nnewline", + "has = equals", + ] { + let out = synthesise_fastly_toml(name, None); + let doc: toml_edit::DocumentMut = out.parse().unwrap(); + assert_eq!(doc["name"].as_str(), Some(name), "input: {name:?}"); + } + } + + #[test] + fn synthesise_fastly_toml_escapes_pathological_service_ids() { + // `fastly compute deploy` may return arbitrary strings. + for sid in [r#"has"quote"#, r"has\slash", "has\nnewline"] { + let out = synthesise_fastly_toml("demo", Some(sid)); + let doc: toml_edit::DocumentMut = out.parse().unwrap(); + assert_eq!(doc["service_id"].as_str(), Some(sid), "input: {sid:?}"); + } + } +} diff --git a/crates/edgezero-adapter-fastly/src/cli/test_support.rs b/crates/edgezero-adapter-fastly/src/cli/test_support.rs new file mode 100644 index 00000000..74a64658 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/test_support.rs @@ -0,0 +1,412 @@ +//! Shared `#[cfg(test)]` helpers for the split `cli` test modules. +//! +//! These fixtures build fake `fastly` shim scripts and synthetic +//! config-store listings/envelopes used by the `gc`, `push_cloud`, +//! `push_local`, and `provision_*` test modules. They are centralised here so +//! the split modules share one honest set of fixtures. + +#![allow( + dead_code, + reason = "shared test fixtures; not every module exercises every helper" +)] + +#[cfg(unix)] +use super::FastlyCliAdapter; +#[cfg(unix)] +use crate::chunked_config::prepare_fastly_config_entries; +#[cfg(unix)] +use edgezero_adapter::registry::{Adapter as _, AdapterPushContext, ResolvedStoreId}; +use std::path::Path; +use tempfile::tempdir; + +/// Invoke `config gc` on the config store at `dir` via the adapter boundary. +#[cfg(unix)] +pub(crate) fn run_gc( + dir: &Path, + older_than_secs: u64, + dry_run: bool, +) -> Result, String> { + FastlyCliAdapter.gc_config_entries( + dir, + None, + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &AdapterPushContext::new(), + older_than_secs, + dry_run, + ) +} + +// Shared fixture names. Pinning these as consts keeps the setup-vs-assertion +// pair in sync -- a typo in one place no longer silently divorces from the +// other. These are the LOGICAL store ids the fastly adapter operates on. +pub(crate) const TEST_KV_ID: &str = "sessions"; +pub(crate) const TEST_CONFIG_ID: &str = "app_config"; +pub(crate) const TEST_SECRET_ID: &str = "default"; + +/// Build a tempdir containing a `fastly` shim that serves a store list with +/// `TEST_CONFIG_ID`, and returns `stdout_body`/`stderr_body`/`exit_code` for +/// describe calls. +#[cfg(unix)] +pub(crate) fn fake_fastly_returning( + stdout_body: &str, + stderr_body: &str, + exit_code: i32, +) -> tempfile::TempDir { + fake_fastly_returning_with_keys(stdout_body, stderr_body, exit_code, &[]) +} + +/// As [`fake_fastly_returning`], but also serves `config-store-entry list` +/// with a bare array of the `entry_list_keys` as `item_key` entries. A +/// describe FAILURE is confirmed against this listing: keys present here read +/// as a present-but-unreadable hard error, keys absent read as `MissingKey`. +#[cfg(unix)] +pub(crate) fn fake_fastly_returning_with_keys( + stdout_body: &str, + stderr_body: &str, + exit_code: i32, + entry_list_keys: &[&str], +) -> tempfile::TempDir { + use std::fs; + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let script_path = dir.path().join("fastly"); + let stdout_file = dir.path().join("stdout_payload.txt"); + let stderr_file = dir.path().join("stderr_payload.txt"); + let list_file = dir.path().join("list_payload.txt"); + let entry_list_file = dir.path().join("entry_list_payload.txt"); + // Store-list JSON: bare array with one entry matching TEST_CONFIG_ID. + let list_json = format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#); + let entry_list_json = { + let items: Vec = entry_list_keys + .iter() + .map(|key| format!(r#"{{"item_key":{}}}"#, serde_json::to_string(key).unwrap())) + .collect(); + format!("[{}]", items.join(",")) + }; + fs::write(&stdout_file, stdout_body).expect("write stdout payload"); + fs::write(&stderr_file, stderr_body).expect("write stderr payload"); + fs::write(&list_file, list_json).expect("write list payload"); + fs::write(&entry_list_file, entry_list_json).expect("write entry list payload"); + let script = format!( + "#!/bin/sh\nif [ \"$1\" = \"config-store\" ]; then\n cat '{}'\n exit 0\nfi\nif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"list\" ]; then\n cat '{}'\n exit 0\nfi\ncat '{}'\ncat '{}' >&2\nexit {exit_code}\n", + list_file.display(), + entry_list_file.display(), + stdout_file.display(), + stderr_file.display(), + ); + fs::write(&script_path, script).expect("write fastly script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + dir +} + +/// Build a fake `fastly` that logs each argv token (one per line) to +/// `out_path`, handles the list call correctly, and exits 0 for both calls. +#[cfg(unix)] +pub(crate) fn fake_fastly_argv_log(out_path: &Path) -> tempfile::TempDir { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + use std::fs; + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let script_path = dir.path().join("fastly"); + let list_file = dir.path().join("list_payload.txt"); + let entry_file = dir.path().join("entry_payload.txt"); + let list_json = format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#); + // item_value must be a valid BlobEnvelope JSON so the resolver accepts it. + let envelope_json = serde_json::to_string(&BlobEnvelope::new( + json!({"v": "logged"}), + "2026-06-22T00:00:00Z".into(), + )) + .expect("serialize"); + let entry_json = format!( + r#"{{"item_value":{},"store_id":"store-abc123"}}"#, + serde_json::to_string(&envelope_json).expect("escape") + ); + fs::write(&list_file, list_json).expect("write list payload"); + fs::write(&entry_file, &entry_json).expect("write entry payload"); + let script = format!( + "#!/bin/sh\nfor arg in \"$@\"; do printf '%s\\n' \"$arg\" >> '{}'; done\nif [ \"$1\" = \"config-store\" ]; then\n cat '{}'\n exit 0\nfi\nif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"list\" ]; then\n echo '[]'\n exit 0\nfi\ncat '{}'\nexit 0\n", + out_path.display(), + list_file.display(), + entry_file.display(), + ); + fs::write(&script_path, script).expect("write script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + dir +} + +/// Build a valid `BlobEnvelope` JSON string of approximately `target_len` bytes. +pub(crate) fn make_test_envelope(target_len: usize) -> String { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + let pad = "x".repeat(target_len.saturating_add(64)); + let data = json!({ "pad": pad }); + let raw = + serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:00Z".into())).unwrap(); + if raw.len() >= target_len { + let overhead = raw.len().saturating_sub(pad.len()); + let adjusted = "x".repeat(target_len.saturating_sub(overhead)); + let data2 = json!({ "pad": adjusted }); + serde_json::to_string(&BlobEnvelope::new(data2, "2026-06-22T00:00:00Z".into())).unwrap() + } else { + raw + } +} + +/// Build a fake `fastly` script whose describe response depends on +/// the `--key=` argument: `key_responses` maps key names to JSON +/// item-value responses. Falls back to exit 1 "not found" for unknown keys. +#[cfg(unix)] +pub(crate) fn fake_fastly_with_key_dispatch( + _dir: &Path, + key_responses: &[(String, String)], +) -> tempfile::TempDir { + use std::fmt::Write as _; + use std::fs; + use std::os::unix::fs::PermissionsExt as _; + let fake_dir = tempdir().expect("tempdir"); + let list_file = fake_dir.path().join("list.json"); + let list_json = format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#); + fs::write(&list_file, list_json).expect("write list"); + // The `config-store-entry list` response: a bare array of the keys present + // in `key_responses`. Absence confirmation lists the store and checks + // membership, so a key omitted here reads as CONFIRMED absent. Only + // `item_key` is needed (the keys-only listing is value-tolerant). + let entry_list_file = fake_dir.path().join("entry_list.json"); + let entries_json = { + let items: Vec = key_responses + .iter() + .map(|(key, _)| format!(r#"{{"item_key":{}}}"#, serde_json::to_string(key).unwrap())) + .collect(); + format!("[{}]", items.join(",")) + }; + fs::write(&entry_list_file, entries_json).expect("write entry list"); + // Write each key response to a named file. + let mut dispatch_lines = String::new(); + for (key, response) in key_responses { + let resp_file = fake_dir.path().join(format!("resp_{key}.json")); + fs::write(&resp_file, response).expect("write resp"); + // Use exact-match: iterate argv and compare each token literally + // so that a root key like "app_config" does NOT match a chunk key + // like "app_config.__edgezero_chunks.abc.0". + writeln!( + dispatch_lines, + " for arg in \"$@\"; do if [ \"$arg\" = \"--key={key}\" ]; then cat '{}'; exit 0; fi; done", + resp_file.display() + ) + .expect("write to String is infallible"); + } + // `config-store` (store list) and `config-store-entry list` are served + // from their files; a `describe` for an unknown key exits 1 "not found", + // which the caller then CONFIRMS against the entry list. + let script = format!( + "#!/bin/sh\nif [ \"$1\" = \"config-store\" ]; then\n cat '{}'\n exit 0\nfi\nif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"list\" ]; then\n cat '{}'\n exit 0\nfi\n{dispatch_lines}echo 'Error: item not found' >&2\nexit 1\n", + list_file.display(), + entry_list_file.display() + ); + let script_path = fake_dir.path().join("fastly"); + fs::write(&script_path, &script).expect("write script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod"); + fake_dir +} + +/// Fake `fastly` for cloud chunk-GC tests. Logs each `config-store-entry` op to +/// `oplog`. `root_describe_seq` gives the successive raw `item_value`s returned +/// when the ROOT key is described. `entry_list` is served for +/// `config-store-entry list`. `fail_delete_key` makes that one delete exit +/// non-zero. `describe_hard_error` makes the FIRST describe of each key fail. +#[cfg(unix)] +pub(crate) fn fake_fastly_gc( + root_key: &str, + root_describe_seq: &[String], + entry_list: &[(String, String, String)], + fail_delete_key: Option<&str>, + describe_hard_error: bool, + oplog: &Path, +) -> tempfile::TempDir { + use std::fs; + use std::os::unix::fs::PermissionsExt as _; + // Rendered with handlebars. Triple-stache `{{{ }}}` disables HTML + // escaping (paths are not markup); the shell's own `${var}` / + // `$(( ))` use single braces so they are literal text to handlebars. + const TEMPLATE: &str = r#"#!/bin/sh +if [ "$1" = "config-store" ]; then cat '{{{list}}}'; exit 0; fi +sub="$2" +key="" +for arg in "$@"; do case "$arg" in --key=*) key="${arg#--key=}";; esac; done +if [ "$sub" = "list" ]; then printf 'list\n' >> '{{{oplog}}}'; cat '{{{entries}}}'; exit 0; fi +if [ "$sub" = "update" ]; then cat >/dev/null; printf 'update %s\n' "$key" >> '{{{oplog}}}'; exit 0; fi +if [ "$sub" = "delete" ]; then printf 'delete %s\n' "$key" >> '{{{oplog}}}'; printf 'delete-argv %s\n' "$*" >> '{{{oplog}}}'; if [ "$key" = "{{{fail}}}" ]; then echo 'Error: 404 item not found' >&2; exit 1; fi; exit 0; fi +if [ "$sub" = "describe" ]; then + printf 'describe %s\n' "$key" >> '{{{oplog}}}' + cfile='{{{dir}}}/count_'"$key" + n=0; [ -f "$cfile" ] && n=$(cat "$cfile"); n=$((n+1)); printf '%s' "$n" > "$cfile" + {{#if hard_error}}if [ "$n" = "1" ]; then echo 'Error: internal server error' >&2; exit 1; fi{{/if}} + rf='{{{dir}}}/resp_'"$key"'_'"$n"'.json' + if [ -f "$rf" ]; then cat "$rf"; exit 0; fi + echo 'Error: item not found' >&2; exit 1 +fi +echo 'unexpected' >&2; exit 1 +"#; + let dir = tempdir().expect("tempdir"); + let list_file = dir.path().join("list.json"); + fs::write( + &list_file, + format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#), + ) + .expect("list"); + let entries_file = dir.path().join("entries.json"); + fs::write(&entries_file, entry_list_json(entry_list)).expect("entries"); + for (index, value) in root_describe_seq.iter().enumerate() { + let wrapped = format!( + r#"{{"item_value":{}}}"#, + serde_json::to_string(value).expect("escape") + ); + let nth = index.saturating_add(1); + fs::write( + dir.path().join(format!("resp_{root_key}_{nth}.json")), + wrapped, + ) + .expect("resp"); + } + let data = serde_json::json!({ + "list": list_file.display().to_string(), + "entries": entries_file.display().to_string(), + "oplog": oplog.display().to_string(), + "dir": dir.path().display().to_string(), + "fail": fail_delete_key.unwrap_or(""), + "hard_error": describe_hard_error, + }); + let script = handlebars::Handlebars::new() + .render_template(TEMPLATE, &data) + .expect("render fake fastly script"); + let script_path = dir.path().join("fastly"); + fs::write(&script_path, script).expect("script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod"); + dir +} + +/// Like `fake_fastly_gc`, but serves a VERBATIM `config-store-entry list` +/// payload so a test can present a shape `entry_list_json` cannot build. +#[cfg(unix)] +pub(crate) fn fake_fastly_gc_raw_list( + root_key: &str, + raw_listing: &str, + oplog: &Path, +) -> tempfile::TempDir { + use std::fs; + let dir = fake_fastly_gc(root_key, &[], &[], None, false, oplog); + fs::write(dir.path().join("entries.json"), raw_listing).expect("raw entries"); + dir +} + +/// A `config-store-entry list --json` payload. The item VALUE is a +/// placeholder: reclamation must only ever use keys and timestamps. +#[cfg(unix)] +pub(crate) fn entry_list_json(items: &[(String, String, String)]) -> String { + let entries: Vec = items + .iter() + .map(|(key, created, value)| { + serde_json::json!({ + "item_key": key, + "created_at": created, + "item_value": value, + }) + }) + .collect(); + serde_json::to_string(&entries).expect("entry list json") +} + +/// An RFC-3339 stamp `secs` in the past (the shape Fastly returns). +#[cfg(unix)] +pub(crate) fn stamp_secs_ago(secs: u64) -> String { + let delta = chrono::Duration::seconds(i64::try_from(secs).unwrap_or(0)); + let now = chrono::Utc::now(); + now.checked_sub_signed(delta) + .unwrap_or(now) + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +/// Every chunk of `envelope` as the listing would return it: REAL keys and +/// REAL payload bytes. +#[cfg(unix)] +pub(crate) fn listed_generation( + root_key: &str, + envelope: &str, + secs_ago: u64, +) -> Vec<(String, String, String)> { + let (chunks, _) = chunked_parts(root_key, envelope); + let stamp = stamp_secs_ago(secs_ago); + chunks + .into_iter() + .map(|(key, value)| (key, stamp.clone(), value)) + .collect() +} + +/// The ROOT entry as the listing would return it: its value is the pointer, +/// which is how `config gc` learns which chunks are live. +#[cfg(unix)] +pub(crate) fn listed_root( + root_key: &str, + envelope: &str, + secs_ago: u64, +) -> (String, String, String) { + let (_, pointer) = chunked_parts(root_key, envelope); + (root_key.to_owned(), stamp_secs_ago(secs_ago), pointer) +} + +/// A chunked envelope with a distinct payload per tag, padded to `pad` +/// characters so a caller can force a given number of chunks. +#[cfg(unix)] +pub(crate) fn gen_envelope_padded(tag: &str, pad: usize) -> String { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + let data = json!({ tag: "x".repeat(pad) }); + serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:00Z".to_owned())) + .expect("envelope") +} + +/// A chunked envelope with a distinct payload per tag. +#[cfg(unix)] +pub(crate) fn gen_envelope(tag: &str) -> String { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + let data = json!({ tag: "x".repeat(FASTLY_CONFIG_ENTRY_LIMIT) }); + serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:00Z".to_owned())) + .expect("envelope") +} + +/// Split a chunked envelope into (chunk `(key, value)` pairs, root pointer). +#[cfg(unix)] +pub(crate) fn chunked_parts(root_key: &str, envelope: &str) -> (Vec<(String, String)>, String) { + let entries = prepare_fastly_config_entries(root_key, envelope).expect("expand"); + let (_, pointer) = entries.last().expect("pointer").clone(); + let chunks = entries[..entries.len().saturating_sub(1)].to_vec(); + (chunks, pointer) +} + +/// Just the chunk KEYS of a generation (for delete assertions). +#[cfg(unix)] +pub(crate) fn chunk_keys_of(root_key: &str, envelope: &str) -> Vec { + let (chunks, _) = chunked_parts(root_key, envelope); + chunks.into_iter().map(|(key, _)| key).collect() +} + +#[cfg(unix)] +pub(crate) fn oplog_has(oplog: &Path, line: &str) -> bool { + use std::fs; + fs::read_to_string(oplog) + .unwrap_or_default() + .lines() + .any(|entry| entry == line) +} diff --git a/crates/edgezero-adapter-fastly/src/config_store.rs b/crates/edgezero-adapter-fastly/src/config_store.rs index bad34170..8c1847fa 100644 --- a/crates/edgezero-adapter-fastly/src/config_store.rs +++ b/crates/edgezero-adapter-fastly/src/config_store.rs @@ -168,21 +168,24 @@ fn is_transient_lookup(err: &LookupError) -> bool { } fn map_lookup_error(err: &LookupError) -> ConfigStoreError { - // `LookupError` is from the `fastly` crate; using a wildcard arm guards - // against new variants being added in upstream point releases without - // forcing us into a breaking match every bump. - #[expect( - clippy::wildcard_enum_match_arm, - reason = "external enum; new variants must remain unavailable→unavailable" - )] + // `LookupError` is #[non_exhaustive] on the fastly side; every current + // variant is enumerated so a new upstream variant forces a reviewer + // decision here rather than silently landing in the unavailable arm. match err { LookupError::KeyInvalid | LookupError::KeyTooLong => { ConfigStoreError::invalid_key("invalid config key") } - _ => { + LookupError::ConfigStoreInvalid + | LookupError::ValueTooLong + | LookupError::TooManyLookups + | LookupError::Other => { log::warn!("Fastly config store lookup failed: {err}"); ConfigStoreError::unavailable("config store temporarily unavailable") } + _future => { + log::warn!("Fastly config store lookup failed (unknown variant): {err}"); + ConfigStoreError::unavailable("config store temporarily unavailable") + } } } diff --git a/crates/edgezero-adapter-fastly/src/templates/fastly.toml.hbs b/crates/edgezero-adapter-fastly/src/templates/fastly.toml.hbs deleted file mode 100644 index e3ccb441..00000000 --- a/crates/edgezero-adapter-fastly/src/templates/fastly.toml.hbs +++ /dev/null @@ -1,12 +0,0 @@ -authors = [""] -description = "" -language = "rust" -manifest_version = 3 -name = "{{proj_fastly}}" -service_id = "" - -[local_server] - -[scripts] - build = "cargo build --profile release --target wasm32-wasip1" - diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs deleted file mode 100644 index 7b0c635f..00000000 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ /dev/null @@ -1,2718 +0,0 @@ -#![expect( - clippy::self_named_module_files, - reason = "Workspace lint policy denies BOTH `self_named_module_files` (wants `cli/mod.rs`) and `mod_module_files` (wants `cli.rs`) -- they contradict, so any file with submodules must opt out of one. The repo convention is the self-named form (`cli.rs` with submodules under `cli/`); allow accordingly." -)] -#![expect( - clippy::arbitrary_source_item_ordering, - reason = "submodule declarations sit between the `use` block and the rest of the file's items by Rust convention; the strict-ordering lint disagrees but no human convention puts `mod` blocks AFTER trait impls" -)] - -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use ctor::ctor; -use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, -}; -use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, ResolvedStoreId, - TypedSecretEntry, register_adapter, -}; -use edgezero_adapter::scaffold::{ - AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, - ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, -}; -use walkdir::WalkDir; - -mod push_cloud; -mod push_sqlite; -mod runtime_config; - -static SPIN_ADAPTER: SpinCliAdapter = SpinCliAdapter; - -static SPIN_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { - id: "spin", - display_name: "Spin (Fermyon)", - crate_suffix: "adapter-spin", - dependency_crate: "edgezero-adapter-spin", - dependency_repo_path: "crates/edgezero-adapter-spin", - template_registrations: SPIN_TEMPLATE_REGISTRATIONS, - files: SPIN_FILE_SPECS, - extra_dirs: &["src"], - dependencies: SPIN_DEPENDENCIES, - manifest: ManifestSpec { - manifest_filename: "spin.toml", - build_target: "wasm32-wasip2", - build_profile: "release", - build_features: &["spin"], - }, - commands: CommandTemplates { - build: "cargo build --target wasm32-wasip2 --release -p {crate}", - deploy: "spin deploy --from {crate_dir}", - serve: "spin up --from {crate_dir} --runtime-config-file {crate_dir}/runtime-config.toml", - }, - logging: LoggingDefaults { - endpoint: None, - level: "info", - echo_stdout: None, - }, - readme: ReadmeInfo { - description: "{display} entrypoint.", - dev_heading: "{display} (local)", - dev_steps: &["`edgezero serve --adapter spin`"], - }, - run_module: "edgezero_adapter_spin", -}; - -static SPIN_DEPENDENCIES: &[DependencySpec] = &[ - DependencySpec { - key: "dep_edgezero_core_spin", - repo_crate: "crates/edgezero-core", - fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\", default-features = false }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_spin", - repo_crate: "crates/edgezero-adapter-spin", - fallback: "edgezero-adapter-spin = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-spin\", default-features = false }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_spin_wasm", - repo_crate: "crates/edgezero-adapter-spin", - fallback: "edgezero-adapter-spin = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-spin\", default-features = false, features = [\"spin\"] }", - features: &["spin"], - }, -]; - -static SPIN_FILE_SPECS: &[AdapterFileSpec] = &[ - AdapterFileSpec { - template: "spin_Cargo_toml", - output: "Cargo.toml", - }, - AdapterFileSpec { - template: "spin_runtime_config_toml", - output: "runtime-config.toml", - }, - AdapterFileSpec { - template: "spin_src_lib_rs", - output: "src/lib.rs", - }, - AdapterFileSpec { - template: "spin_spin_toml", - output: "spin.toml", - }, -]; - -static SPIN_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ - TemplateRegistration { - name: "spin_Cargo_toml", - contents: include_str!("templates/Cargo.toml.hbs"), - }, - TemplateRegistration { - name: "spin_runtime_config_toml", - contents: include_str!("templates/runtime-config.toml.hbs"), - }, - TemplateRegistration { - name: "spin_src_lib_rs", - contents: include_str!("templates/src/lib.rs.hbs"), - }, - TemplateRegistration { - name: "spin_spin_toml", - contents: include_str!("templates/spin.toml.hbs"), - }, -]; - -const TARGET_TRIPLE: &str = "wasm32-wasip2"; - -const SPIN_INSTALL_HINT: &str = "install the Spin CLI (https://spinframework.dev/) and try again"; - -struct SpinCliAdapter; - -#[expect( - clippy::missing_trait_methods, - reason = "Stage 6: KV-backed config dropped Spin's `^[a-z][a-z0-9_]*$` key rule and the config-vs-secret collision check, so `validate_app_config_keys` falls back to the trait default `Ok(())`. `validate_typed_secrets` IS overridden below (secret-value canonicalisation + within-secrets uniqueness still apply). `validate_adapter_manifest` IS overridden below (Spin's multi-component disambiguation). `read_config_entry` and `read_config_entry_local` are both overridden below (four-branch SQLite-direct / Fermyon Cloud / non-Spin-backend dispatch)." -)] -impl Adapter for SpinCliAdapter { - fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { - match action { - // `spin cloud {login|logout|info}` is the native sign-in - // surface for Fermyon Cloud. EdgeZero stores no - // credentials — this is a thin shell-out. - AdapterAction::AuthLogin => { - run_native_cli("spin", &["cloud", "login"], SPIN_INSTALL_HINT) - } - AdapterAction::AuthLogout => { - run_native_cli("spin", &["cloud", "logout"], SPIN_INSTALL_HINT) - } - AdapterAction::AuthStatus => { - run_native_cli("spin", &["cloud", "info"], SPIN_INSTALL_HINT) - } - AdapterAction::Build => { - let artifact = build(args)?; - log::info!("[edgezero] Spin build complete -> {}", artifact.display()); - Ok(()) - } - AdapterAction::Deploy => deploy(args), - AdapterAction::Serve => serve(args), - other => Err(format!("spin adapter does not support {other:?}")), - } - } - - fn merged_id_kinds(&self) -> &'static [&'static str] { - // Both KV and Config back to `spin_sdk::key_value::Store` via - // the same `provision` path; declaring the same logical id - // under both kinds resolves to one underlying store with - // silent write-collisions. CLI validate rejects. - &["kv", "config"] - } - - fn name(&self) -> &'static str { - "spin" - } - - fn provision( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - component_selector: Option<&str>, - stores: &ProvisionStores<'_>, - dry_run: bool, - ) -> Result, String> { - //: 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 - // AND each declared CONFIG id (KV-backed since Stage 5 - // of the spin-kv-config plan), append the env-resolved - // platform label to the component's `key_value_stores` - // array. Secret variables are manually declared by the - // developer in spin.toml -- secrets stay on Spin - // variables for the platform's `secret = true` flagging. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.spin.adapter].manifest must point at spin.toml for provision".to_owned(), - ); - }; - let spin_path = manifest_root.join(rel); - - let mut out = Vec::new(); - // Resolve the component once if either KV or config has - // anything to provision. - let needs_component = !stores.kv.is_empty() || !stores.config.is_empty(); - if needs_component { - let component_id = resolve_spin_component(&spin_path, component_selector)?; - for (kind, store) in stores - .kv - .iter() - .map(|store| ("KV", store)) - .chain(stores.config.iter().map(|store| ("config", store))) - { - let logical = store.logical.as_str(); - // The label the runtime opens is what - // `EDGEZERO__STORES______NAME` - // resolves to (default = the logical id). Provision - // writes the PLATFORM label into - // `[component.X].key_value_stores` so that both the - // KV runtime lookup AND the KV-backed config - // runtime lookup match. - let label = store.platform.as_str(); - if dry_run { - out.push(format!( - "would ensure {kind} label `{label}` (logical id `{logical}`) is in [component.{component_id}].key_value_stores in {}", - spin_path.display() - )); - continue; - } - let added = ensure_kv_label_in_component(&spin_path, &component_id, label)?; - if added { - out.push(format!( - "added {kind} label `{label}` (logical id `{logical}`) to [component.{component_id}].key_value_stores in {}", - spin_path.display() - )); - } else { - out.push(format!( - "{kind} label `{label}` (logical id `{logical}`) already present in [component.{component_id}].key_value_stores in {}; skipping", - spin_path.display() - )); - } - } - } - for store in stores.secrets { - let logical = store.logical.as_str(); - let platform = store.platform.as_str(); - out.push(format!( - "spin secret id `{logical}` (platform name `{platform}`) requires manual `[variables].* secret = true` + `[component.*.variables].*` declarations in spin.toml; nothing to do here" - )); - } - if out.is_empty() { - out.push("spin has no declared stores to provision".to_owned()); - } - Ok(out) - } - - fn push_config_entries( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - dispatch_push( - manifest_root, - adapter_manifest_path, - store, - entries, - push_ctx, - dry_run, - ) - } - - fn push_config_entries_local( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - // `--local` lives in `push_ctx.local`. `dispatch_push` honours - // it by suppressing the Fermyon Cloud auto-detect so the - // operator can force a SQLite-direct write even when the - // manifest's deploy command shells to `spin deploy`. - dispatch_push( - manifest_root, - adapter_manifest_path, - store, - entries, - push_ctx, - dry_run, - ) - } - - fn read_config_entry( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - push_ctx: &AdapterPushContext<'_>, - ) -> Result { - // Four-branch dispatch mirroring `dispatch_push`: - // - // 1. `push_ctx.local` → delegate to `read_config_entry_local` - // (SQLite-direct, same as the `--local` write path). - // 2. Deploy command targets Fermyon Cloud → `Unsupported`. - // Fermyon Cloud's `spin cloud key-value list` enumerates - // STORES, not keys; there is no stable per-key get CLI in - // v1 (8.3 / 9.4 of the spec). NO shell-out. - // 3. `runtime-config.toml` declares a non-`spin` backend - // (Redis / AzureCosmos / Unknown) → error naming the backend - // and pointing at its native CLI, matching the writer at - // `cli.rs:639-650`. - // 4. Default / `type = "spin"` → SQLite-direct read. - // - // Errors from `runtime_config::read` and from - // `verify_label_declared` are PROPAGATED (not swallowed with - // `.ok()`). Silently falling through on a malformed - // runtime-config would let `config diff` report a result on a - // tree where the writer would have errored hard. - if push_ctx.local { - return self.read_config_entry_local( - manifest_root, - adapter_manifest_path, - component_selector, - store, - key, - push_ctx, - ); - } - - let spin_manifest_path = adapter_manifest_path - .map(|rel| manifest_root.join(rel)) - .ok_or_else(|| { - "[adapters.spin.adapter].manifest must point at spin.toml for config diff" - .to_owned() - })?; - let spin_manifest_dir = spin_manifest_path.parent().unwrap_or(manifest_root); - let runtime_config_path = push_ctx.runtime_config_path.map_or_else( - || spin_manifest_dir.join("runtime-config.toml"), - Path::to_path_buf, - ); - let runtime_config_dir = runtime_config_path.parent().unwrap_or(spin_manifest_dir); - let platform = store.platform.as_str(); - - // Branch 2: Fermyon Cloud auto-detect. - if push_cloud::deploy_command_targets_fermyon_cloud(push_ctx.manifest_adapter_deploy_cmd) { - return Ok(ReadConfigEntry::Unsupported( - "Spin Cloud key-value CLI exposes no `get`; remote read-back unsupported in v1", - )); - } - - // Branches 3 + 4: parse runtime-config, propagating parse errors, - // then dispatch on backend type. - let parsed = runtime_config::read(&runtime_config_path)?; - verify_label_declared(platform, &parsed, &runtime_config_path)?; - let backend = parsed.key_value_stores.get(platform); - match backend { - Some(runtime_config::KeyValueBackend::Redis { url }) => Err(format!( - "store `{platform}` is backed by `type = \"redis\"` (url: `{url}`) in {}; \ - use `redis-cli -u {url} GET ` to read entries from this store. \ - edgezero does not read from redis backends.", - runtime_config_path.display() - )), - Some(runtime_config::KeyValueBackend::AzureCosmos) => Err(format!( - "store `{platform}` is backed by `type = \"azure_cosmos\"` in {}; \ - use the Azure CosmosDB CLI to read this store. \ - edgezero does not read from azure_cosmos backends.", - runtime_config_path.display() - )), - Some(runtime_config::KeyValueBackend::Unknown { type_name }) => Err(format!( - "store `{platform}` is backed by an unrecognised type `{type_name}` in {}; \ - edgezero only reads from `type = \"spin\"` (SQLite) backends.", - runtime_config_path.display() - )), - // Branch 4: `type = "spin"` or missing stanza (default). - Some(runtime_config::KeyValueBackend::Spin { path }) => { - let db_path = push_sqlite::resolve_sqlite_path( - spin_manifest_dir, - runtime_config_dir, - path.as_deref(), - ); - read_sqlite_entry(&db_path, platform, key) - } - None => { - let db_path = - push_sqlite::resolve_sqlite_path(spin_manifest_dir, runtime_config_dir, None); - read_sqlite_entry(&db_path, platform, key) - } - } - } - - fn read_config_entry_local( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - push_ctx: &AdapterPushContext<'_>, - ) -> Result { - // Branch 1: `--local` forces SQLite-direct regardless of the - // runtime-config backend type or the Fermyon Cloud auto-detect. - // Mirrors the write path at `dispatch_push` branch 1 (cli.rs:572). - // - // We still enforce that any non-`default` label is declared in - // `runtime-config.toml` (same invariant as the writer) so the - // read path can't silently succeed on a tree where `spin up` - // would error with "unknown key_value_stores label X". - // - // An explicit `--runtime-config ` is honoured for path - // resolution; the backend `type` is ignored (SQLite is always - // the target for `--local`). - let spin_manifest_path = adapter_manifest_path - .map(|rel| manifest_root.join(rel)) - .ok_or_else(|| { - "[adapters.spin.adapter].manifest must point at spin.toml for config diff --local" - .to_owned() - })?; - let spin_manifest_dir = spin_manifest_path.parent().unwrap_or(manifest_root); - let runtime_config_path = push_ctx.runtime_config_path.map_or_else( - || spin_manifest_dir.join("runtime-config.toml"), - Path::to_path_buf, - ); - let runtime_config_dir = runtime_config_path.parent().unwrap_or(spin_manifest_dir); - let platform = store.platform.as_str(); - - // Parse runtime-config (propagating errors). - let parsed = runtime_config::read(&runtime_config_path)?; - verify_label_declared(platform, &parsed, &runtime_config_path)?; - - // Resolve the SQLite path: honour any explicit `path` in a - // `type = "spin"` stanza; fall back to Spin's default otherwise - // (matches the write path at dispatch_push branch 1). - let explicit_path = match parsed.key_value_stores.get(platform) { - Some(runtime_config::KeyValueBackend::Spin { path }) => path.as_deref(), - _ => None, - }; - let db_path = - push_sqlite::resolve_sqlite_path(spin_manifest_dir, runtime_config_dir, explicit_path); - read_sqlite_entry(&db_path, platform, key) - } - - fn single_store_kinds(&self) -> &'static [&'static str] { - //: Multi for KV AND Config (both label-backed via the - // Spin KV API since Stage 5 of the spin-kv-config plan). - // Single for Secrets (still flat-variable namespace). - &["secrets"] - } - - fn validate_adapter_manifest( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - component_selector: Option<&str>, - ) -> Result<(), String> { - // check 3: spin.toml must exist and either declare - // exactly one `[component.*]` or carry an explicit selector - // that matches one of the declared ids. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.spin.adapter].manifest must point at spin.toml for Spin component discovery".to_owned() - ); - }; - let spin_path = manifest_root.join(rel); - let raw = fs::read_to_string(&spin_path).map_err(|err| { - format!( - "failed to read spin manifest at {}: {err}", - spin_path.display() - ) - })?; - let parsed: toml::Value = toml::from_str(&raw) - .map_err(|err| format!("failed to parse {} as TOML: {err}", spin_path.display()))?; - let component_ids = collect_spin_component_ids(&parsed); - - if component_ids.is_empty() { - return Err(format!( - "{}: no [component.*] declarations found", - spin_path.display() - )); - } - - if let Some(selector) = component_selector { - if component_ids.iter().any(|id| id == selector) { - return Ok(()); - } - return Err(format!( - "[adapters.spin.adapter].component = {:?} is not declared in {} (available: {})", - selector, - spin_path.display(), - component_ids.join(", ") - )); - } - - if component_ids.len() == 1 { - return Ok(()); - } - Err(format!( - "{} declares {} components ({}) but [adapters.spin.adapter].component is unset; set one explicitly", - spin_path.display(), - component_ids.len(), - component_ids.join(", ") - )) - } - - fn validate_typed_secrets(&self, entries: &[TypedSecretEntry<'_>]) -> Result<(), String> { - use std::collections::HashMap; - // Stage 5+: KV-backed config no longer shares Spin's flat - // variable namespace, so config keys are NOT considered here - // (and the trait dropped the parameter in Stage 6+) — config - // can use arbitrary UTF-8 keys without colliding with - // `#[secret]` values. Secrets still resolve through - // `spin_sdk::variables`, so two checks remain: - // 1. each `#[secret]` value canonicalises (lowercase, no - // `.→__` — secrets don't get translated at runtime) - // to a valid Spin variable name, so invalid chars - // (dashes, digit-first) fail validation rather than - // at runtime with an opaque `InvalidName`; - // 2. no two `#[secret]` values collapse to the same - // lowercased Spin variable, since Spin's flat - // namespace cannot disambiguate them. - // Map lowercased-Spin-variable → original field name. When a - // second entry collapses to the same name, the existing entry - // tells us which field already claimed it. - let mut seen: HashMap = HashMap::with_capacity(entries.len()); - for entry in entries { - let spin_var = entry.key_value.to_ascii_lowercase(); - if !is_valid_spin_key(&spin_var) { - let reason = spin_key_rule_violation(&spin_var); - return Err(format!( - "`#[secret]` field `{field}` value `{value}` translates to Spin variable `{spin_var}`, which is not a valid Spin variable name. {reason}. Pick a `#[secret]` value that conforms.", - field = entry.field_name, - value = entry.key_value, - )); - } - if let Some(prev_field) = seen.insert(spin_var.clone(), entry.field_name.as_str()) { - return Err(format!( - "Spin variable `{spin_var}` would receive values from BOTH `#[secret]` field `{prev_field}` AND `#[secret]` field `{this_field}`; Spin's flat variable namespace cannot disambiguate them. Pick distinct `#[secret]` values whose lowercased forms differ.", - this_field = entry.field_name, - )); - } - } - Ok(()) - } -} - -fn is_valid_spin_key(key: &str) -> bool { - let mut chars = key.chars(); - let Some(first) = chars.next() else { - return false; - }; - if !first.is_ascii_lowercase() { - return false; - } - chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') -} - -/// Return a per-failure-mode diagnostic for a key that failed -/// `is_valid_spin_key`. Spin's variable-name rule -/// (`^[a-z][a-z0-9_]*$`) is one regex but the operator usually -/// wants to know WHICH bit they broke: digit-leading, uppercase, -/// or stray punctuation. Returns a short phrase to splice into -/// the caller's full error. -fn spin_key_rule_violation(key: &str) -> &'static str { - // Callers only invoke this AFTER `is_valid_spin_key` returned - // false; in production the per-char branches below exhaust the - // failure modes and the catch-all at the bottom is unreachable. - // It's kept defensively so a future regex tweak (e.g. allowing - // a new char class) doesn't crash the diagnostic helper with - // an unreachable!() before the caller can produce its error. - // - // Reachability notes for the per-mode branches: - // - `push_config_entries` translates keys via - // `translate_key_for_spin` (which lowercases) BEFORE this - // call, so the uppercase-first branch is unreachable from - // that site. It IS reachable from `validate_app_config_keys` - // and `validate_typed_secrets`, which check raw user input. - let mut chars = key.chars(); - let Some(first) = chars.next() else { - return "Spin variable names must not be empty"; - }; - if first.is_ascii_digit() { - return "Spin variable names must start with a lowercase letter, not a digit"; - } - if first.is_ascii_uppercase() { - return "Spin variable names must be lowercase (uppercase letters are not allowed)"; - } - if !first.is_ascii_lowercase() { - return "Spin variable names must start with a lowercase ASCII letter"; - } - for ch in chars { - if ch.is_ascii_uppercase() { - return "Spin variable names must be lowercase (uppercase letters are not allowed)"; - } - if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') { - return "Spin variable names may only contain lowercase letters, digits, and underscores"; - } - } - debug_assert!( - false, - "spin_key_rule_violation called with key `{key}` that satisfies the regex; check is_valid_spin_key + caller agreement" - ); - "Spin variable names must match `^[a-z][a-z0-9_]*$`" -} - -fn collect_spin_component_ids(parsed: &toml::Value) -> Vec { - parsed - .as_table() - .and_then(|root| root.get("component")) - .and_then(toml::Value::as_table) - .map(|components| components.keys().cloned().collect()) - .unwrap_or_default() -} - -/// Read `[application].name` from `spin.toml`. Required by the -/// Fermyon Cloud writer to address KV stores via the app-scoped -/// label model (`--app --label