diff --git a/.claude/settings.json b/.claude/settings.json index e7e94052f..fb3b322e8 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/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh index 36267f247..f10e19c37 100755 --- a/.github/actions/deploy-core/tests/run.sh +++ b/.github/actions/deploy-core/tests/run.sh @@ -2479,7 +2479,7 @@ test_fastly_logical_link_documentation() { local manifest="$REPO_ROOT/docs/guide/manifest-store-migration.md" local blob="$REPO_ROOT/docs/guide/blob-app-config-migration.md" local adoption="$REPO_ROOT/docs/guide/deploy-action-adoption.md" - local fastly_cli="$REPO_ROOT/crates/edgezero-adapter-fastly/src/cli.rs" + local fastly_cli="$REPO_ROOT/crates/edgezero-adapter-fastly/src/cli/staging.rs" local corpus="$WORK_DIR/fastly-logical-link-docs.md" local example_workflow="$WORK_DIR/application-deploy-workflow.yml" local example_deploy="$WORK_DIR/application-deploy-job.yml" diff --git a/.github/actions/deploy-fastly/tests/make-smoke-fixture.sh b/.github/actions/deploy-fastly/tests/make-smoke-fixture.sh index 24c21c781..0b41fc345 100755 --- a/.github/actions/deploy-fastly/tests/make-smoke-fixture.sh +++ b/.github/actions/deploy-fastly/tests/make-smoke-fixture.sh @@ -116,9 +116,11 @@ name = "fixture-app" [adapters.fastly.adapter] manifest = "adapter/fastly.toml" +crate = "adapter" [adapters.spin.adapter] manifest = "adapter/spin.toml" +crate = "adapter" TOML if [[ "$mode" == store-aware ]]; then cat >>edgezero.toml <<'TOML' @@ -154,6 +156,7 @@ version = "0.1.0" source = "fixture.wasm" TOML + printf '\n.edgezero/\n' >>.gitignore cargo generate-lockfile # Path dependencies live in the enclosing EdgeZero checkout, not this Git repo. git -C "$workspace" rev-parse HEAD >.edgezero-source-revision diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eff6eb8ee..a3a6d67b9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,10 +76,60 @@ jobs: if: matrix.suite == 'cli' 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. + # Exercise them explicitly to keep the coverage closure enforced by CI. - name: Nested AppConfig checker tests if: matrix.suite == 'cli' 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: Lifecycle fixture host tests if: matrix.suite == 'workspace' run: >- diff --git a/.gitignore b/.gitignore index beba4261f..dedf5750f 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,33 @@ 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.edgezero-*.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 +44,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 +84,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 000000000..0129225b5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,154 @@ +# 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 + +- **Adapter CLI execution and lifecycle contexts.** `Adapter::execute` takes + `&AdapterExecContext`, and `deploy` takes both `&AdapterDeployContext` and + `&AdapterExecContext`. `preflight_deploy` and `finalize_deploy` now also receive + execution context. Apply `exec.env()` to every child process and credential + lookup while retaining the adapter's selected working directory. Build + contexts with `AdapterExecContext::new().with_env(...).with_cwd(...)`; honor + declared manifest/crate/component paths instead of rediscovering them. +- **Adapter-owned local CLI metadata.** Implement `Adapter::local_layout` to + describe provision artifacts and the optional serve env file. Paths are + project-relative and receive the manifest, crate, and component declarations + through `AdapterLocalContext`. Mark secret-bearing env files with + `LocalArtifactKind::Env`, including custom filenames, and credential-bearing + TOML with `LocalArtifactKind::SensitiveToml`. The default describes + only the declared manifest. `AdapterBlueprint` literals now require + `tool_pins`, `install_notes`, and `local_ignore_patterns`; use empty slices + when unnecessary. Logging endpoints support the command template vocabulary + plus `{project_mod}`. `CommandTemplates` also requires `emit_commands`: choose + `false` when registry execution must honor the adapter manifest. +- **Explicit config cloud target.** `AdapterPushContext::cloud_target` and + `.with_cloud_target(bool)` replace deploy-command inference. Set + `[adapters..adapter].cloud = true` for a cloud target and use the context + builder instead of the removed deploy-command context field. + +- **`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 + +- Redact native TOML values and comments in local provisioning previews and + omit source excerpts from native provision parse errors. Serialize Fastly + local provision with deploy writeback under the same manifest lock. +- Preserve relative application-manifest selection across child working + directories, accept root adapter crates, and guard Spin artifact copies and + SQLite paths against intermediate symlinks, including paths containing `..`. + +- Preserve quoted multiline env records and original line endings during + provisioning. Readers, writers, and preview redaction share dotenv grammar; + generated values are encoded losslessly or rejected explicitly. +- Targeted provision/config commands validate the selected adapter's policy; + whole-project config validation still checks all registered adapters. Derived + artifacts are checked in the original tree before previews are staged. +- Remove inherited project-lock borrowing and deploy-wide shell locks. Rust + provision/config transactions hold real exclusive locks. Native Fastly deploy + writes a private manifest and merges only `service_id`, preserving unrelated + concurrent edits and refusing conflicting service changes. Immutable managed + deploys use a private native startup directory and forward execution context + to both Fastly and curl. Native Fastly serve requires a `fastly.toml` basename. +- Reject symlinked Cloudflare local persistence before reads as well as writes; + Wrangler can initialize local state while reading. + +- 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 4991d92bd..fe416391c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -709,6 +709,7 @@ name = "edgezero-adapter" version = "0.1.0" dependencies = [ "base16ct", + "regex", "serde", "serde_json", "sha2 0.11.0", @@ -741,6 +742,7 @@ dependencies = [ "thiserror 2.0.21", "tokio", "toml", + "toml_edit", "tower", "tracing", "walkdir", @@ -842,6 +844,7 @@ dependencies = [ "edgezero-adapter-fastly", "edgezero-adapter-spin", "edgezero-core", + "fs4", "futures", "handlebars", "log", @@ -854,6 +857,7 @@ dependencies = [ "tempfile", "thiserror 2.0.21", "toml", + "toml_edit", "validator", "walkdir", ] @@ -1069,6 +1073,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" @@ -3338,6 +3352,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index cde6c0c69..4631d96dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ publish = false version = "0.1.0" [workspace.dependencies] +regex = "1.13.1" anyhow = "1" async-compression = { version = "0.4", features = [ "futures-io", @@ -56,6 +57,12 @@ edgezero-cli = { path = "crates/edgezero-cli", default-features = false } fastly = "0.13" 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" @@ -193,5 +200,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 9979e39a5..a16af5899 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.rs b/crates/edgezero-adapter-axum/src/cli.rs deleted file mode 100644 index cc7b910fb..000000000 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ /dev/null @@ -1,1536 +0,0 @@ -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, - TypedSecretEntry, register_adapter, -}; -use edgezero_adapter::scaffold::{ - AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, - ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, -}; -use edgezero_core::addr; -use edgezero_core::manifest::ManifestLoader; -use toml::Value; -use walkdir::WalkDir; - -use crate::secret_env::secret_env_var; - -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://github.com/IABTechLab/edgezero.git\", package = \"edgezero-core\" }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_axum", - repo_crate: "crates/edgezero-adapter-axum", - fallback: "edgezero-adapter-axum = { git = \"https://github.com/IABTechLab/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, - axum_host: Option, - axum_manifest: PathBuf, - axum_port: Option, - cargo_manifest: PathBuf, - crate_dir: PathBuf, - crate_name: String, - env_host: Option, - env_port: Option, -} - -#[derive(Debug, Default)] -struct EdgezeroAxumConfig { - host: Option, - port: Option, -} - -#[expect( - clippy::missing_trait_methods, - reason = "axum has no validate_app_config_keys / validate_adapter_manifest requirements; those two trait defaults are intentionally inherited. `validate_typed_secrets` IS overridden below (each `#[secret]` value must map to one `EDGEZERO__SECRETS__` env var). `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. - // 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), - // The Fastly staging lifecycle is Fastly-only. - AdapterAction::DeployStaging - | AdapterAction::EmitVersion - | AdapterAction::Healthcheck - | AdapterAction::Rollback => Err(format!( - "axum adapter does not support the Fastly staging lifecycle action {action:?}" - )), - 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 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 - // 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 (default + staging must 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"] - } - - fn validate_typed_secrets(&self, entries: &[TypedSecretEntry<'_>]) -> Result<(), String> { - let mut claimed: BTreeMap> = BTreeMap::new(); - for entry in entries { - let var = secret_env_var(entry.key_value) - .map_err(|err| format!("`#[secret]` field `{}`: {err}", entry.field_name))?; - if let Some(prev) = claimed.get(&var) { - if prev.key_value != entry.key_value { - return Err(format!( - "`#[secret]` field `{prev_field}` (`{prev_value}`) and `#[secret]` field `{this_field}` (`{this_value}`) both read Axum env var `{var}`, which cannot tell them apart. Pick `#[secret]` values whose uppercased forms differ.", - prev_field = prev.field_name, - prev_value = prev.key_value, - this_field = entry.field_name, - this_value = entry.key_value, - )); - } - continue; - } - claimed.insert(var, entry); - } - Ok(()) - } -} - -#[inline] -pub fn register() { - register_adapter(&AXUM_ADAPTER); - register_adapter_blueprint(&AXUM_BLUEPRINT); -} - -#[ctor(unsafe)] -fn register_ctor() { - register(); -} - -fn build(extra_args: &[String]) -> Result<(), String> { - let project = locate_project()?; - run_cargo(&project, "build", extra_args) -} - -fn serve(extra_args: &[String]) -> Result<(), String> { - let project = locate_project()?; - run_cargo(&project, "run", extra_args) -} - -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() -> Result { - let cwd = env::current_dir().map_err(|err| err.to_string())?; - let manifest = find_axum_manifest(&cwd)?; - read_axum_project(&manifest) -} - -fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> Result<(), String> { - let resolution = resolve_subprocess_addr(project)?; - for warning in &resolution.warnings { - log::warn!("[edgezero] {warning}"); - } - - let bind_addr = resolution.addr; - let display = project.crate_dir.display(); - log::info!( - "[edgezero] Axum {subcommand} ({}) in {display} ({bind_addr})", - project.crate_name - ); - let mut command = Command::new("cargo"); - command.arg(subcommand); - command.arg("--manifest-path"); - command.arg( - project - .cargo_manifest - .to_str() - .ok_or_else(|| format!("invalid manifest path {}", project.cargo_manifest.display()))?, - ); - 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()); - let status = command - .status() - .map_err(|err| format!("failed to run cargo {subcommand}: {err}"))?; - if status.success() { - Ok(()) - } else { - Err(format!("cargo {subcommand} failed with status {status}")) - } -} - -fn resolve_subprocess_addr(project: &AxumProject) -> Result { - let axum_only = resolve_subprocess_addr_from_parts( - project.env_host.as_deref(), - project.env_port.as_deref(), - None, - None, - project.axum_host.as_deref(), - project.axum_port, - ); - debug_assert_eq!( - project.addr, axum_only.addr, - "cached AxumProject.addr must match a fresh axum-only resolution" - ); - - let edgezero = load_edgezero_axum_config(&project.axum_manifest)?; - Ok(resolve_subprocess_addr_from_parts( - project.env_host.as_deref(), - project.env_port.as_deref(), - edgezero.as_ref().and_then(|cfg| cfg.host.as_deref()), - edgezero.as_ref().and_then(|cfg| cfg.port), - project.axum_host.as_deref(), - project.axum_port, - )) -} - -fn resolve_subprocess_addr_from_parts( - env_host: Option<&str>, - env_port: Option<&str>, - edgezero_host: Option<&str>, - edgezero_port: Option, - axum_host: Option<&str>, - axum_port: Option, -) -> addr::BindAddrResolution { - let mut warnings = Vec::new(); - let host = resolve_subprocess_host(env_host, edgezero_host, axum_host, &mut warnings); - let port = resolve_subprocess_port(env_port, edgezero_port, axum_port, &mut warnings); - - addr::BindAddrResolution { - addr: SocketAddr::from((host, port)), - warnings, - } -} - -fn resolve_subprocess_host( - env_host: Option<&str>, - edgezero_host: Option<&str>, - axum_host: Option<&str>, - warnings: &mut Vec, -) -> IpAddr { - if let Some(value) = env_host { - match value.parse() { - Ok(host) => return host, - Err(_) => warnings.push(format!( - "EDGEZERO__ADAPTER__HOST={value:?} is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" - )), - } - } - - if let Some(value) = edgezero_host { - match value.parse() { - Ok(host) => return host, - Err(_) => warnings.push(format!( - "configured host={value:?} in edgezero.toml is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" - )), - } - } - - if let Some(value) = axum_host { - match value.parse() { - Ok(host) => return host, - Err(_) => warnings.push(format!( - "configured host={value:?} in axum.toml is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" - )), - } - } - - addr::DEFAULT_HOST -} - -fn resolve_subprocess_port( - env_port: Option<&str>, - edgezero_port: Option, - axum_port: Option, - warnings: &mut Vec, -) -> u16 { - if let Some(value) = env_port { - match value.parse::() { - Ok(0) => warnings.push( - "EDGEZERO__ADAPTER__PORT=\"0\" is not supported (would bind to a random OS port); falling back".to_owned(), - ), - Ok(port) => return port, - Err(_) => warnings.push(format!( - "EDGEZERO__ADAPTER__PORT={value:?} is not a valid port number; falling back" - )), - } - } - - match edgezero_port { - Some(0) => warnings.push( - "configured port=0 in edgezero.toml is not supported (would bind to a random OS port); falling back".to_owned(), - ), - Some(port) => return port, - None => {} - } - - match axum_port { - Some(0) => warnings.push( - "configured port=0 in axum.toml is not supported (would bind to a random OS port); falling back".to_owned(), - ), - Some(port) => return port, - None => {} - } - - addr::DEFAULT_PORT -} - -fn load_edgezero_axum_config(axum_manifest: &Path) -> Result, String> { - let Some(start_dir) = axum_manifest.parent() else { - return Ok(None); - }; - - let Some(manifest_path) = find_manifest_upwards(start_dir, "edgezero.toml") else { - return Ok(None); - }; - - let manifest = ManifestLoader::from_path(&manifest_path) - .map_err(|err| format!("failed to load {}: {err}", manifest_path.display()))?; - let Some(adapter) = manifest.manifest().adapters.get("axum") else { - return Ok(None); - }; - - Ok(Some(EdgezeroAxumConfig { - host: adapter.adapter.host.clone(), - port: adapter.adapter.port, - })) -} - -fn find_axum_manifest(start: &Path) -> Result { - if let Some(found) = find_manifest_upwards(start, "axum.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(walkdir::DirEntry::into_path) - .filter(|path| { - path.file_name().is_some_and(|name| name == "axum.toml") - && path - .parent() - .is_some_and(|dir| dir.join("Cargo.toml").exists()) - }) - .collect(); - - if candidates.is_empty() { - return Err("could not locate axum.toml".into()); - } - - candidates.sort_by_key(|path| { - let parent = path.parent().unwrap_or(Path::new("")); - path_distance(start, parent) - }); - - Ok(candidates.remove(0)) -} - -fn read_axum_project(manifest: &Path) -> Result { - // Per the hard-cutoff: only the canonical - // `EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT` env - // vars are honoured. The pre-rewrite `EDGEZERO_HOST` / - // `EDGEZERO_PORT` shim is gone -- the core runtime stopped - // reading those names, and keeping the axum wrapper compatible - // with them silently revived a precedence path the rest of - // the codebase had cut. Operators with legacy CI scripts must - // rename to the canonical form. - let env_host = env::var("EDGEZERO__ADAPTER__HOST").ok(); - let env_port = env::var("EDGEZERO__ADAPTER__PORT").ok(); - read_axum_project_with_env(manifest, env_host.as_deref(), env_port.as_deref()) -} - -fn read_axum_project_with_env( - manifest: &Path, - env_host: Option<&str>, - env_port: Option<&str>, -) -> Result { - let contents = fs::read_to_string(manifest) - .map_err(|err| format!("failed to read {}: {err}", manifest.display()))?; - let value: Value = toml::from_str(&contents) - .map_err(|err| format!("failed to parse {}: {err}", manifest.display()))?; - - let adapter = value - .get("adapter") - .and_then(Value::as_table) - .ok_or_else(|| format!("adapter table missing in {}", manifest.display()))?; - - let crate_dir_rel = adapter - .get("crate_dir") - .and_then(Value::as_str) - .ok_or_else(|| format!("adapter.crate_dir missing in {}", manifest.display()))?; - - let manifest_dir = manifest.parent().unwrap_or_else(|| Path::new(".")); - let crate_dir = manifest_dir.join(crate_dir_rel); - let cargo_manifest = crate_dir.join("Cargo.toml"); - if !cargo_manifest.exists() { - return Err(format!( - "Cargo.toml missing at {} referenced by {}", - cargo_manifest.display(), - manifest.display() - )); - } - - let crate_name = adapter.get("crate").and_then(Value::as_str).map_or_else( - || { - read_package_name(&cargo_manifest).unwrap_or_else(|_| { - crate_dir - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("axum-adapter") - .to_owned() - }) - }, - ToOwned::to_owned, - ); - - let config_host = adapter - .get("host") - .and_then(Value::as_str) - .map(str::to_owned); - let config_port = match adapter.get("port").and_then(Value::as_integer) { - Some(port_value) => Some(u16::try_from(port_value).map_err(|err| { - format!( - "adapter.port in {} must be between 0 and 65535 ({err})", - manifest.display() - ) - })?), - None => None, - }; - - let resolution = - addr::resolve_bind_addr(env_host, env_port, config_host.as_deref(), config_port); - for warning in &resolution.warnings { - log::warn!("[edgezero] {warning} (in {})", manifest.display()); - } - - Ok(AxumProject { - addr: resolution.addr, - axum_host: config_host, - axum_manifest: manifest.to_path_buf(), - axum_port: config_port, - cargo_manifest, - crate_dir, - crate_name, - env_host: env_host.map(str::to_owned), - env_port: env_port.map(str::to_owned), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use edgezero_adapter::cli_support::find_manifest_upwards; - use std::net::Ipv6Addr; - use tempfile::tempdir; - - #[test] - fn read_axum_project_loads_defaults() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write( - root.join("axum.toml"), - "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\n", - ) - .unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", - ) - .unwrap(); - - let project = - read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); - assert_eq!(project.crate_name, "demo"); - assert_eq!(project.crate_dir, root); - assert_eq!(project.cargo_manifest, root.join("Cargo.toml")); - assert_eq!(project.addr.port(), addr::DEFAULT_PORT); - assert_eq!(project.addr.ip(), addr::DEFAULT_HOST); - } - - #[test] - fn find_manifest_upwards_locates_parent() { - let dir = tempdir().unwrap(); - let root = dir.path(); - let nested = root.join("nested/level"); - fs::create_dir_all(&nested).unwrap(); - fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); - fs::write( - root.join("axum.toml"), - "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\n", - ) - .unwrap(); - - let found = find_manifest_upwards(&nested, "axum.toml").expect("manifest"); - assert_eq!(found, root.join("axum.toml")); - } - - #[test] - fn read_axum_project_uses_custom_port() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write( - root.join("axum.toml"), - "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = 4001\n", - ) - .unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", - ) - .unwrap(); - - let project = - read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); - assert_eq!(project.addr.port(), 4001); - } - - #[test] - fn read_axum_project_rejects_invalid_port() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write( - root.join("axum.toml"), - "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = 70000\n", - ) - .unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", - ) - .unwrap(); - - let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); - match result { - Ok(_) => panic!("expected error"), - Err(err) => assert!(err.contains("must be between 0 and 65535")), - } - } - - #[test] - fn read_axum_project_zero_port_falls_back_to_default() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write( - root.join("axum.toml"), - "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = 0\n", - ) - .unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", - ) - .unwrap(); - - let project = - read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); - assert_eq!(project.addr.port(), addr::DEFAULT_PORT); - } - - #[test] - fn read_axum_project_rejects_negative_port() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write( - root.join("axum.toml"), - "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = -1\n", - ) - .unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", - ) - .unwrap(); - - let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); - result.unwrap_err(); - } - - #[test] - fn read_axum_project_rejects_missing_adapter_table() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write(root.join("axum.toml"), "[other]\nkey = \"value\"\n").unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", - ) - .unwrap(); - - let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); - match result { - Ok(_) => panic!("expected error"), - Err(err) => assert!(err.contains("adapter table missing")), - } - } - - #[test] - fn read_axum_project_rejects_missing_crate_dir() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write(root.join("axum.toml"), "[adapter]\ncrate = \"demo\"\n").unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", - ) - .unwrap(); - - let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); - match result { - Ok(_) => panic!("expected error"), - Err(err) => assert!(err.contains("crate_dir missing")), - } - } - - #[test] - fn read_axum_project_rejects_missing_cargo_toml() { - let dir = tempdir().unwrap(); - let root = dir.path(); - let subdir = root.join("subdir"); - fs::create_dir_all(&subdir).unwrap(); - fs::write( - root.join("axum.toml"), - "[adapter]\ncrate = \"demo\"\ncrate_dir = \"subdir\"\n", - ) - .unwrap(); - // No Cargo.toml in subdir - - let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); - match result { - Ok(_) => panic!("expected error"), - Err(err) => assert!(err.contains("Cargo.toml missing")), - } - } - - #[test] - fn read_axum_project_falls_back_to_package_name() { - let dir = tempdir().unwrap(); - let root = dir.path(); - // No crate key in adapter table - fs::write(root.join("axum.toml"), "[adapter]\ncrate_dir = \".\"\n").unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname = \"my-package\"\nversion = \"0.1.0\"\n", - ) - .unwrap(); - - let project = - read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); - assert_eq!(project.crate_name, "my-package"); - } - - #[test] - fn read_axum_project_with_relative_crate_dir() { - let dir = tempdir().unwrap(); - let root = dir.path(); - let adapter_dir = root.join("crates/my-adapter"); - fs::create_dir_all(&adapter_dir).unwrap(); - fs::write( - root.join("axum.toml"), - "[adapter]\ncrate = \"my-adapter\"\ncrate_dir = \"crates/my-adapter\"\n", - ) - .unwrap(); - fs::write( - adapter_dir.join("Cargo.toml"), - "[package]\nname = \"my-adapter\"\nversion = \"0.1.0\"\n", - ) - .unwrap(); - - let project = - read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); - assert_eq!(project.crate_name, "my-adapter"); - assert_eq!(project.crate_dir, adapter_dir); - } - - #[test] - fn read_axum_project_accepts_max_valid_port() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write( - root.join("axum.toml"), - "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = 65535\n", - ) - .unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", - ) - .unwrap(); - - let project = - read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); - assert_eq!(project.addr.port(), u16::MAX); - } - - #[test] - fn read_axum_project_accepts_min_valid_port() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write( - root.join("axum.toml"), - "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = 1\n", - ) - .unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", - ) - .unwrap(); - - let project = - read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); - assert_eq!(project.addr.port(), 1); - } - - #[test] - fn read_axum_project_defaults_host_to_localhost() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write( - root.join("axum.toml"), - "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\n", - ) - .unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", - ) - .unwrap(); - - let project = - read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); - assert_eq!(project.addr.ip(), addr::DEFAULT_HOST); - } - - #[test] - fn read_axum_project_uses_custom_host() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write( - root.join("axum.toml"), - "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nhost = \"0.0.0.0\"\n", - ) - .unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", - ) - .unwrap(); - - let project = - read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); - assert_eq!(project.addr.ip(), IpAddr::from([0, 0, 0, 0])); - } - - #[test] - fn read_axum_project_invalid_host_falls_back_to_default() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write( - root.join("axum.toml"), - "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nhost = \"not-an-ip\"\n", - ) - .unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", - ) - .unwrap(); - - let project = - read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); - assert_eq!(project.addr.ip(), addr::DEFAULT_HOST); - } - - #[test] - fn find_axum_manifest_returns_error_when_not_found() { - let dir = tempdir().unwrap(); - let root = dir.path(); - // Create an empty directory with a Cargo.toml but no axum.toml - fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); - - let result = find_axum_manifest(root); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("could not locate axum.toml")); - } - - #[test] - fn find_axum_manifest_finds_in_current_dir() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); - fs::write( - root.join("axum.toml"), - "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\n", - ) - .unwrap(); - - let found = find_axum_manifest(root).expect("manifest"); - assert_eq!(found, root.join("axum.toml")); - } - - #[test] - fn find_axum_manifest_finds_closest() { - let dir = tempdir().unwrap(); - let root = dir.path(); - let nested = root.join("level1/level2"); - fs::create_dir_all(&nested).unwrap(); - - // Create axum.toml at root - fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); - fs::write( - root.join("axum.toml"), - "[adapter]\ncrate = \"root\"\ncrate_dir = \".\"\n", - ) - .unwrap(); - - // Create axum.toml at level1 - fs::write( - root.join("level1/Cargo.toml"), - "[package]\nname = \"level1\"\nversion = \"0.1.0\"\n", - ) - .unwrap(); - fs::write( - root.join("level1/axum.toml"), - "[adapter]\ncrate = \"level1\"\ncrate_dir = \".\"\n", - ) - .unwrap(); - - // Search from level2, should find level1's axum.toml (closer) - let found = find_axum_manifest(&nested).expect("manifest"); - assert_eq!(found, root.join("level1/axum.toml")); - } - - #[test] - fn deploy_returns_error() { - let result = deploy(&[]); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .contains("does not define a deploy command") - ); - } - - #[test] - fn adapter_name_is_axum() { - assert_eq!(AXUM_ADAPTER.name(), "axum"); - } - - #[test] - fn read_axum_project_env_overrides_config() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write( - root.join("axum.toml"), - "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nhost = \"127.0.0.1\"\nport = 3000\n", - ) - .unwrap(); - fs::write( - root.join("Cargo.toml"), - "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", - ) - .unwrap(); - - let result = - read_axum_project_with_env(&root.join("axum.toml"), Some("0.0.0.0"), Some("9999")); - - let project = result.expect("project"); - assert_eq!(project.addr.ip(), IpAddr::from([0, 0, 0, 0])); - assert_eq!(project.addr.port(), 9999); - } - - #[test] - fn resolve_subprocess_addr_prefers_edgezero_manifest_over_axum_manifest() { - let resolution = resolve_subprocess_addr_from_parts( - None, - None, - Some("0.0.0.0"), - Some(4000), - Some("127.0.0.1"), - Some(3000), - ); - - assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 4000))); - assert!(resolution.warnings.is_empty()); - } - - #[test] - fn resolve_subprocess_addr_falls_back_to_axum_manifest_when_edgezero_missing() { - let resolution = - resolve_subprocess_addr_from_parts(None, None, None, None, Some("0.0.0.0"), Some(3000)); - - assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 3000))); - assert!(resolution.warnings.is_empty()); - } - - #[test] - fn resolve_subprocess_addr_env_overrides_both_manifests() { - let resolution = resolve_subprocess_addr_from_parts( - Some("::1"), - Some("9000"), - Some("0.0.0.0"), - Some(4000), - Some("127.0.0.1"), - Some(3000), - ); - - assert_eq!( - resolution.addr, - SocketAddr::from((Ipv6Addr::LOCALHOST, 9000)) - ); - assert!(resolution.warnings.is_empty()); - } - - #[test] - fn resolve_subprocess_addr_invalid_edgezero_host_falls_back_to_axum_host() { - let resolution = resolve_subprocess_addr_from_parts( - None, - None, - Some("invalid-host"), - Some(4000), - Some("0.0.0.0"), - Some(3000), - ); - - assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 4000))); - assert_eq!(resolution.warnings.len(), 1); - } - - #[test] - fn resolve_subprocess_addr_edgezero_zero_port_falls_back_to_axum_port() { - let resolution = resolve_subprocess_addr_from_parts( - None, - None, - Some("127.0.0.1"), - Some(0), - Some("0.0.0.0"), - Some(3000), - ); - - 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"), - } - } - - /// 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}" - ); - } - - #[test] - fn validate_typed_secrets_accepts_keys_that_map_to_env_vars() { - let entries = [ - TypedSecretEntry::new("default", "api_token", "demo_api_token"), - TypedSecretEntry::new("default", "partners[0].key", "Partner_Key_2"), - ]; - assert_eq!(AXUM_ADAPTER.validate_typed_secrets(&entries), Ok(())); - } - - #[test] - fn validate_typed_secrets_rejects_key_outside_charset_naming_field() { - let entries = [ - TypedSecretEntry::new("default", "api_token", "demo_api_token"), - TypedSecretEntry::new("default", "partners[0].key", "partner-key"), - ]; - let err = AXUM_ADAPTER.validate_typed_secrets(&entries).unwrap_err(); - assert!(err.contains("`partners[0].key`"), "{err}"); - assert!(err.contains("partner-key"), "{err}"); - assert!(err.contains("ASCII letters, digits, or `_`"), "{err}"); - } - - #[test] - fn validate_typed_secrets_rejects_distinct_keys_that_share_an_env_var() { - let entries = [ - TypedSecretEntry::new("default", "api_token", "upstream_token"), - TypedSecretEntry::new("vault", "backup_token", "UPSTREAM_TOKEN"), - ]; - let err = AXUM_ADAPTER.validate_typed_secrets(&entries).unwrap_err(); - assert!(err.contains("`api_token`"), "{err}"); - assert!(err.contains("`backup_token`"), "{err}"); - assert!(err.contains("`EDGEZERO__SECRETS__UPSTREAM_TOKEN`"), "{err}"); - } - - #[test] - fn validate_typed_secrets_accepts_same_key_in_two_fields() { - let entries = [ - TypedSecretEntry::new("default", "api_token", "shared_token"), - TypedSecretEntry::new("vault", "backup_token", "shared_token"), - ]; - assert_eq!(AXUM_ADAPTER.validate_typed_secrets(&entries), Ok(())); - } -} 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 000000000..416b5c97f --- /dev/null +++ b/crates/edgezero-adapter-axum/src/cli/mod.rs @@ -0,0 +1,1608 @@ +use crate::secret_env::secret_env_var; +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::local::{AdapterLocalContext, AdapterLocalLayout, LocalArtifactKind}; +use edgezero_adapter::registry::{ + Adapter, AdapterAction, AdapterDeployContext, AdapterDeployedState, AdapterExecContext, + AdapterPushContext, DeployOwnership, 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://github.com/IABTechLab/edgezero.git\", package = \"edgezero-core\" }", + features: &[], + }, + DependencySpec { + key: "dep_edgezero_adapter_axum", + repo_crate: "crates/edgezero-adapter-axum", + fallback: "edgezero-adapter-axum = { git = \"https://github.com/IABTechLab/edgezero.git\", package = \"edgezero-adapter-axum\", default-features = false }", + features: &["axum"], + }, +]; + +static AXUM_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { + local_ignore_patterns: &["axum.toml"], + tool_pins: &[], + install_notes: &[], + 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 preflight_deploy( + &self, + _context: &AdapterDeployContext, + _args: &[String], + _exec: &AdapterExecContext<'_>, + ) -> Result { + Ok(DeployOwnership::ManifestCommand) + } + fn deploy( + &self, + context: &AdapterDeployContext, + args: &[String], + exec: &AdapterExecContext<'_>, + ) -> Result<(), String> { + let action = if context.staging { + AdapterAction::DeployStaging + } else { + AdapterAction::Deploy + }; + self.execute(action, args, exec) + } + fn finalize_deploy( + &self, + _context: &AdapterDeployContext, + _output: Option<&str>, + _exec: &AdapterExecContext<'_>, + ) -> Result<(), String> { + Ok(()) + } + fn validate_config_key_for_target( + &self, + _logical: &str, + _key: &str, + _staging: bool, + _local: bool, + ) -> Result<(), String> { + Ok(()) + } + + 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), + // The Fastly staging lifecycle is Fastly-only. + AdapterAction::DeployStaging + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback => Err(format!( + "axum adapter does not support the Fastly staging lifecycle action {action:?}" + )), + other => Err(format!("axum adapter does not support {other:?}")), + } + } + + fn local_layout( + &self, + context: &AdapterLocalContext<'_>, + ) -> Result { + let manifest = context.manifest().unwrap_or("axum.toml"); + Ok(AdapterLocalLayout::new(manifest) + .with_artifact(".edgezero/.env", LocalArtifactKind::Env) + .with_serve_env(".edgezero/.env")) + } + + 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 an `EDGEZERO__SECRETS__=` 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, + )); + } + } + let mut claimed: BTreeMap> = BTreeMap::new(); + for entry in entries { + let var = secret_env_var(entry.key_value) + .map_err(|err| format!("`#[secret]` field `{}`: {err}", entry.field_name))?; + if let Some(prev) = claimed.get(&var) { + if prev.key_value != entry.key_value { + return Err(format!( + "`#[secret]` field `{prev_field}` (`{prev_value}`) and `#[secret]` field `{this_field}` (`{this_value}`) both read Axum env var `{var}`, which cannot tell them apart. Pick `#[secret]` values whose uppercased forms differ.", + prev_field = prev.field_name, + prev_value = prev.key_value, + this_field = entry.field_name, + this_value = entry.key_value, + )); + } + continue; + } + claimed.insert(var, entry); + } + 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 `EDGEZERO__SECRETS__=` 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| secret_env_var(entry.key_value).map(|name| format!("{name}="))) + .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 validate_typed_secrets_accepts_same_key_in_two_fields() { + let entries = [ + TypedSecretEntry::new("default", "api_token", "shared_token"), + TypedSecretEntry::new("vault", "backup_token", "shared_token"), + ]; + assert_eq!(AXUM_ADAPTER.validate_typed_secrets(&entries), Ok(())); + } + + #[test] + fn validate_typed_secrets_rejects_distinct_keys_that_share_an_env_var() { + let entries = [ + TypedSecretEntry::new("default", "api_token", "upstream_token"), + TypedSecretEntry::new("vault", "backup_token", "UPSTREAM_TOKEN"), + ]; + let err = AXUM_ADAPTER.validate_typed_secrets(&entries).unwrap_err(); + assert!(err.contains("`api_token`"), "{err}"); + assert!(err.contains("`backup_token`"), "{err}"); + assert!(err.contains("`EDGEZERO__SECRETS__UPSTREAM_TOKEN`"), "{err}"); + } + + #[test] + fn validate_typed_secrets_rejects_key_outside_charset_naming_field() { + let entries = [ + TypedSecretEntry::new("default", "api_token", "demo_api_token"), + TypedSecretEntry::new("default", "partners[0].key", "partner-key"), + ]; + let err = AXUM_ADAPTER.validate_typed_secrets(&entries).unwrap_err(); + assert!(err.contains("`partners[0].key`"), "{err}"); + assert!(err.contains("partner-key"), "{err}"); + assert!(err.contains("ASCII letters, digits, or `_`"), "{err}"); + } + + #[test] + fn validate_typed_secrets_accepts_keys_that_map_to_env_vars() { + let entries = [ + TypedSecretEntry::new("default", "api_token", "demo_api_token"), + TypedSecretEntry::new("default", "partners[0].key", "Partner_Key_2"), + ]; + assert_eq!(AXUM_ADAPTER.validate_typed_secrets(&entries), Ok(())); + } + + #[test] + fn typed_provision_uses_runtime_secret_env_names() { + let dir = tempfile::tempdir().expect("temporary project"); + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + AxumCliAdapter + .provision_typed( + dir.path(), + None, + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision placeholders"); + let raw = fs::read_to_string(dir.path().join(".edgezero/.env")).expect("secret env file"); + assert!( + raw.lines() + .any(|line| line == "EDGEZERO__SECRETS__DEMO_API_TOKEN="), + "placeholder must match the runtime secret reader: {raw}" + ); + } + + #[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("EDGEZERO__SECRETS__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( + "EDGEZERO__SECRETS__DEMO_API_TOKEN=", + "EDGEZERO__SECRETS__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("EDGEZERO__SECRETS__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 == "EDGEZERO__SECRETS__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, + "EDGEZERO__SECRETS__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("EDGEZERO__SECRETS__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("EDGEZERO__SECRETS__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 [ + "EDGEZERO__SECRETS__DEMO_API_TOKEN=", + "EDGEZERO__SECRETS__DEMO_HMAC_KEY=", + "EDGEZERO__SECRETS__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 000000000..b359f0c7c --- /dev/null +++ b/crates/edgezero-adapter-axum/src/cli/provision_local.rs @@ -0,0 +1,762 @@ +use edgezero_adapter::env_records::encode_value; +use std::fs; +use std::path::Path; + +use edgezero_adapter::env_file::{ + EDGEZERO_PROVISION_HEADER, append_lines_dedup_with_header, upsert_lines_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 (name_lines, comment_lines) = match build_axum_env_lines(stores) { + Ok(lines) => lines, + Err(error) => return Err(error.to_owned()), + }; + // Managed `__NAME` overlay lines go through the CONVERGING upsert so a + // reprovision after a platform-name change lands the new value instead + // of retaining the stale one. The commented `__KEY` placeholders are + // operator-owned and stay on the insert-only append path so an + // operator who uncommented + edited one is never clobbered. + upsert_lines_with_header( + &env_path, + Some(EDGEZERO_PROVISION_HEADER), + &name_lines, + dry_run, + ) + .map_err(|err| format!("write {}: {err}", env_path.display()))?; + append_lines_dedup_with_header( + &env_path, + Some(EDGEZERO_PROVISION_HEADER), + &comment_lines, + dry_run, + ) + .map_err(|err| format!("write {}: {err}", env_path.display()))?; + let status_lines = vec![format!( + "axum: ensured {} + wrote {} lines to {}", + dot_edgezero.display(), + name_lines.len().saturating_add(comment_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. +/// +/// Returns `(name_lines, comment_lines)`: the `__NAME` overlay lines +/// (converging upsert path) and the commented `__KEY` placeholders +/// (operator-owned, insert-only append path) split apart. +fn build_axum_env_lines( + stores: &ProvisionStores<'_>, +) -> Result<(Vec, Vec), &'static str> { + let mut name_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(); + #[expect( + clippy::question_mark, + reason = "explicit variants keep encoded values out of diagnostic paths in CodeQL" + )] + match encode_value(&store.platform, false) { + Ok(platform) => name_lines.push(format!( + "EDGEZERO__STORES__{kind}__{logical_upper}__NAME={platform}" + )), + Err(error) => return Err(error), + } + } + } + let mut comment_lines: Vec = Vec::new(); + for store in stores.config { + let logical_upper = store.logical.to_ascii_uppercase(); + let logical = &store.logical; + comment_lines.push(format!( + "# EDGEZERO__STORES__CONFIG__{logical_upper}__KEY={logical}_staging" + )); + } + Ok((name_lines, comment_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_converges_changed_platform_name() { + // Operator changes a store's platform NAME (app_config -> + // prod_config) and REPROVISIONS. The `__NAME` overlay line must + // CONVERGE to the new value -- no stale `=app_config` line may + // linger -- while an operator-added sibling line survives. + 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"); + + // First provision with platform `app_config`. + let first_ids = vec![ResolvedStoreId::new("app_config", "app_config")]; + AxumCliAdapter + .provision( + dir.path(), + None, + None, + &ProvisionStores { + config: &first_ids, + kv: &[], + secrets: &[], + }, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + // Operator hand-adds a sibling secret line. + let first = fs::read_to_string(&env_path).unwrap(); + fs::write(&env_path, format!("{first}OPERATOR_SECRET=keepme\n")).unwrap(); + + // Reprovision with the platform renamed to `prod_config`. + let second_ids = vec![ResolvedStoreId::new("app_config", "prod_config")]; + AxumCliAdapter + .provision( + dir.path(), + None, + None, + &ProvisionStores { + config: &second_ids, + kv: &[], + secrets: &[], + }, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + + let after = fs::read_to_string(&env_path).unwrap(); + assert!( + after.contains("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config"), + "reprovision must converge the __NAME value to prod_config: {after}" + ); + assert!( + !after + .lines() + .any(|line| line == "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=app_config"), + "no stale =app_config __NAME line may linger: {after}" + ); + assert!( + after.contains("OPERATOR_SECRET=keepme"), + "operator-added sibling line must survive the reprovision: {after}" + ); + } + + #[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/run.rs b/crates/edgezero-adapter-axum/src/cli/run.rs new file mode 100644 index 000000000..1c90018cc --- /dev/null +++ b/crates/edgezero-adapter-axum/src/cli/run.rs @@ -0,0 +1,1134 @@ +use std::env; +use std::fs; +use std::net::{IpAddr, SocketAddr}; +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 edgezero_core::addr; +use edgezero_core::manifest::ManifestLoader; +use toml::Value; +use walkdir::WalkDir; + +#[derive(Debug)] +struct AxumProject { + addr: SocketAddr, + axum_host: Option, + axum_manifest: PathBuf, + axum_port: Option, + cargo_manifest: PathBuf, + crate_dir: PathBuf, + crate_name: String, + env_host: Option, + env_port: Option, +} + +#[derive(Debug, Default)] +struct EdgezeroAxumConfig { + host: Option, + port: Option, +} + +/// 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() +} + +pub(super) fn build(extra_args: &[String], ctx: &AdapterExecContext<'_>) -> Result<(), String> { + let project = locate_project(ctx)?; + run_cargo(&project, "build", extra_args, ctx) +} + +pub(super) fn serve(extra_args: &[String], ctx: &AdapterExecContext<'_>) -> Result<(), String> { + let project = locate_project(ctx)?; + run_cargo(&project, "run", extra_args, ctx) +} + +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 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) +} + +/// 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], + ctx: &AdapterExecContext<'_>, +) -> Result<(), String> { + let resolution = resolve_subprocess_addr(project)?; + for warning in &resolution.warnings { + log::warn!("[edgezero] {warning}"); + } + + let bind_addr = resolution.addr; + let display = project.crate_dir.display(); + log::info!( + "[edgezero] Axum {subcommand} ({}) in {display} ({bind_addr})", + project.crate_name + ); + let mut command = Command::new("cargo"); + command.arg(subcommand); + command.arg("--manifest-path"); + command.arg( + project + .cargo_manifest + .to_str() + .ok_or_else(|| format!("invalid manifest path {}", project.cargo_manifest.display()))?, + ); + command.args(extra_args); + command.current_dir(&project.crate_dir); + // 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}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("cargo {subcommand} failed with status {status}")) + } +} + +fn resolve_subprocess_addr(project: &AxumProject) -> Result { + let axum_only = resolve_subprocess_addr_from_parts( + project.env_host.as_deref(), + project.env_port.as_deref(), + None, + None, + project.axum_host.as_deref(), + project.axum_port, + ); + debug_assert_eq!( + project.addr, axum_only.addr, + "cached AxumProject.addr must match a fresh axum-only resolution" + ); + + let edgezero = load_edgezero_axum_config(&project.axum_manifest)?; + Ok(resolve_subprocess_addr_from_parts( + project.env_host.as_deref(), + project.env_port.as_deref(), + edgezero.as_ref().and_then(|cfg| cfg.host.as_deref()), + edgezero.as_ref().and_then(|cfg| cfg.port), + project.axum_host.as_deref(), + project.axum_port, + )) +} + +fn resolve_subprocess_addr_from_parts( + env_host: Option<&str>, + env_port: Option<&str>, + edgezero_host: Option<&str>, + edgezero_port: Option, + axum_host: Option<&str>, + axum_port: Option, +) -> addr::BindAddrResolution { + let mut warnings = Vec::new(); + let host = resolve_subprocess_host(env_host, edgezero_host, axum_host, &mut warnings); + let port = resolve_subprocess_port(env_port, edgezero_port, axum_port, &mut warnings); + + addr::BindAddrResolution { + addr: SocketAddr::from((host, port)), + warnings, + } +} + +fn resolve_subprocess_host( + env_host: Option<&str>, + edgezero_host: Option<&str>, + axum_host: Option<&str>, + warnings: &mut Vec, +) -> IpAddr { + if let Some(value) = env_host { + match value.parse() { + Ok(host) => return host, + Err(_) => warnings.push(format!( + "EDGEZERO__ADAPTER__HOST={value:?} is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" + )), + } + } + + if let Some(value) = edgezero_host { + match value.parse() { + Ok(host) => return host, + Err(_) => warnings.push(format!( + "configured host={value:?} in edgezero.toml is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" + )), + } + } + + if let Some(value) = axum_host { + match value.parse() { + Ok(host) => return host, + Err(_) => warnings.push(format!( + "configured host={value:?} in axum.toml is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" + )), + } + } + + addr::DEFAULT_HOST +} + +fn resolve_subprocess_port( + env_port: Option<&str>, + edgezero_port: Option, + axum_port: Option, + warnings: &mut Vec, +) -> u16 { + if let Some(value) = env_port { + match value.parse::() { + Ok(0) => warnings.push( + "EDGEZERO__ADAPTER__PORT=\"0\" is not supported (would bind to a random OS port); falling back".to_owned(), + ), + Ok(port) => return port, + Err(_) => warnings.push(format!( + "EDGEZERO__ADAPTER__PORT={value:?} is not a valid port number; falling back" + )), + } + } + + match edgezero_port { + Some(0) => warnings.push( + "configured port=0 in edgezero.toml is not supported (would bind to a random OS port); falling back".to_owned(), + ), + Some(port) => return port, + None => {} + } + + match axum_port { + Some(0) => warnings.push( + "configured port=0 in axum.toml is not supported (would bind to a random OS port); falling back".to_owned(), + ), + Some(port) => return port, + None => {} + } + + addr::DEFAULT_PORT +} + +fn load_edgezero_axum_config(axum_manifest: &Path) -> Result, String> { + let Some(start_dir) = axum_manifest.parent() else { + return Ok(None); + }; + + let Some(manifest_path) = find_manifest_upwards(start_dir, "edgezero.toml") else { + return Ok(None); + }; + + let manifest = ManifestLoader::from_path(&manifest_path) + .map_err(|err| format!("failed to load {}: {err}", manifest_path.display()))?; + let Some(adapter) = manifest.manifest().adapters.get("axum") else { + return Ok(None); + }; + + Ok(Some(EdgezeroAxumConfig { + host: adapter.adapter.host.clone(), + port: adapter.adapter.port, + })) +} + +fn find_axum_manifest(start: &Path) -> Result { + if let Some(found) = find_manifest_upwards(start, "axum.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(walkdir::DirEntry::into_path) + .filter(|path| { + path.file_name().is_some_and(|name| name == "axum.toml") + && path + .parent() + .is_some_and(|dir| dir.join("Cargo.toml").exists()) + }) + .collect(); + + if candidates.is_empty() { + return Err("could not locate axum.toml".into()); + } + + candidates.sort_by_key(|path| { + let parent = path.parent().unwrap_or(Path::new("")); + path_distance(start, parent) + }); + + Ok(candidates.remove(0)) +} + +fn read_axum_project(manifest: &Path) -> Result { + // Per the spec hard-cutoff: only the canonical + // `EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT` env + // vars are honoured. The pre-rewrite `EDGEZERO_HOST` / + // `EDGEZERO_PORT` shim is gone -- the core runtime stopped + // reading those names, and keeping the axum wrapper compatible + // with them silently revived a precedence path the rest of + // the codebase had cut. Operators with legacy CI scripts must + // rename to the canonical form. + let env_host = env::var("EDGEZERO__ADAPTER__HOST").ok(); + let env_port = env::var("EDGEZERO__ADAPTER__PORT").ok(); + read_axum_project_with_env(manifest, env_host.as_deref(), env_port.as_deref()) +} + +fn read_axum_project_with_env( + manifest: &Path, + env_host: Option<&str>, + env_port: Option<&str>, +) -> Result { + let contents = fs::read_to_string(manifest) + .map_err(|err| format!("failed to read {}: {err}", manifest.display()))?; + let value: Value = toml::from_str(&contents) + .map_err(|err| format!("failed to parse {}: {err}", manifest.display()))?; + + let adapter = value + .get("adapter") + .and_then(Value::as_table) + .ok_or_else(|| format!("adapter table missing in {}", manifest.display()))?; + + let crate_dir_rel = adapter + .get("crate_dir") + .and_then(Value::as_str) + .ok_or_else(|| format!("adapter.crate_dir missing in {}", manifest.display()))?; + + let manifest_dir = manifest.parent().unwrap_or_else(|| Path::new(".")); + let crate_dir = manifest_dir.join(crate_dir_rel); + let cargo_manifest = crate_dir.join("Cargo.toml"); + if !cargo_manifest.exists() { + return Err(format!( + "Cargo.toml missing at {} referenced by {}", + cargo_manifest.display(), + manifest.display() + )); + } + + let crate_name = adapter.get("crate").and_then(Value::as_str).map_or_else( + || { + read_package_name(&cargo_manifest).unwrap_or_else(|_| { + crate_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("axum-adapter") + .to_owned() + }) + }, + ToOwned::to_owned, + ); + + let config_host = adapter + .get("host") + .and_then(Value::as_str) + .map(str::to_owned); + let config_port = match adapter.get("port").and_then(Value::as_integer) { + Some(port_value) => Some(u16::try_from(port_value).map_err(|err| { + format!( + "adapter.port in {} must be between 0 and 65535 ({err})", + manifest.display() + ) + })?), + None => None, + }; + + let resolution = + addr::resolve_bind_addr(env_host, env_port, config_host.as_deref(), config_port); + for warning in &resolution.warnings { + log::warn!("[edgezero] {warning} (in {})", manifest.display()); + } + + Ok(AxumProject { + addr: resolution.addr, + axum_host: config_host, + axum_manifest: manifest.to_path_buf(), + axum_port: config_port, + cargo_manifest, + crate_dir, + crate_name, + env_host: env_host.map(str::to_owned), + env_port: env_port.map(str::to_owned), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_adapter::cli_support::find_manifest_upwards; + 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(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + assert_eq!(project.crate_name, "demo"); + assert_eq!(project.crate_dir, root); + assert_eq!(project.cargo_manifest, root.join("Cargo.toml")); + assert_eq!(project.addr.port(), addr::DEFAULT_PORT); + assert_eq!(project.addr.ip(), addr::DEFAULT_HOST); + } + + #[test] + fn find_manifest_upwards_locates_parent() { + let dir = tempdir().unwrap(); + let root = dir.path(); + let nested = root.join("nested/level"); + fs::create_dir_all(&nested).unwrap(); + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\n", + ) + .unwrap(); + + let found = find_manifest_upwards(&nested, "axum.toml").expect("manifest"); + assert_eq!(found, root.join("axum.toml")); + } + + #[test] + fn read_axum_project_uses_custom_port() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = 4001\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + assert_eq!(project.addr.port(), 4001); + } + + #[test] + fn read_axum_project_rejects_invalid_port() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = 70000\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); + match result { + Ok(_) => panic!("expected error"), + Err(err) => assert!(err.contains("must be between 0 and 65535")), + } + } + + #[test] + fn read_axum_project_zero_port_falls_back_to_default() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = 0\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + assert_eq!(project.addr.port(), addr::DEFAULT_PORT); + } + + #[test] + fn read_axum_project_rejects_negative_port() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = -1\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); + result.unwrap_err(); + } + + #[test] + fn read_axum_project_rejects_missing_adapter_table() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("axum.toml"), "[other]\nkey = \"value\"\n").unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); + match result { + Ok(_) => panic!("expected error"), + Err(err) => assert!(err.contains("adapter table missing")), + } + } + + #[test] + fn read_axum_project_rejects_missing_crate_dir() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("axum.toml"), "[adapter]\ncrate = \"demo\"\n").unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); + match result { + Ok(_) => panic!("expected error"), + Err(err) => assert!(err.contains("crate_dir missing")), + } + } + + #[test] + fn read_axum_project_rejects_missing_cargo_toml() { + let dir = tempdir().unwrap(); + let root = dir.path(); + let subdir = root.join("subdir"); + fs::create_dir_all(&subdir).unwrap(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \"subdir\"\n", + ) + .unwrap(); + // No Cargo.toml in subdir + + let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); + match result { + Ok(_) => panic!("expected error"), + Err(err) => assert!(err.contains("Cargo.toml missing")), + } + } + + #[test] + fn read_axum_project_falls_back_to_package_name() { + let dir = tempdir().unwrap(); + let root = dir.path(); + // No crate key in adapter table + fs::write(root.join("axum.toml"), "[adapter]\ncrate_dir = \".\"\n").unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"my-package\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + assert_eq!(project.crate_name, "my-package"); + } + + #[test] + fn read_axum_project_with_relative_crate_dir() { + let dir = tempdir().unwrap(); + let root = dir.path(); + let adapter_dir = root.join("crates/my-adapter"); + fs::create_dir_all(&adapter_dir).unwrap(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"my-adapter\"\ncrate_dir = \"crates/my-adapter\"\n", + ) + .unwrap(); + fs::write( + adapter_dir.join("Cargo.toml"), + "[package]\nname = \"my-adapter\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + assert_eq!(project.crate_name, "my-adapter"); + assert_eq!(project.crate_dir, adapter_dir); + } + + #[test] + fn read_axum_project_accepts_max_valid_port() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = 65535\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + assert_eq!(project.addr.port(), u16::MAX); + } + + #[test] + fn read_axum_project_accepts_min_valid_port() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = 1\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + assert_eq!(project.addr.port(), 1); + } + + #[test] + fn read_axum_project_defaults_host_to_localhost() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + assert_eq!(project.addr.ip(), addr::DEFAULT_HOST); + } + + #[test] + fn read_axum_project_uses_custom_host() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nhost = \"0.0.0.0\"\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + assert_eq!(project.addr.ip(), IpAddr::from([0, 0, 0, 0])); + } + + #[test] + fn read_axum_project_invalid_host_falls_back_to_default() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nhost = \"not-an-ip\"\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + assert_eq!(project.addr.ip(), addr::DEFAULT_HOST); + } + + #[test] + fn find_axum_manifest_returns_error_when_not_found() { + let dir = tempdir().unwrap(); + let root = dir.path(); + // Create an empty directory with a Cargo.toml but no axum.toml + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + + let result = find_axum_manifest(root); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("could not locate axum.toml")); + } + + #[test] + fn find_axum_manifest_finds_in_current_dir() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\n", + ) + .unwrap(); + + let found = find_axum_manifest(root).expect("manifest"); + assert_eq!(found, root.join("axum.toml")); + } + + #[test] + fn find_axum_manifest_finds_closest() { + let dir = tempdir().unwrap(); + let root = dir.path(); + let nested = root.join("level1/level2"); + fs::create_dir_all(&nested).unwrap(); + + // Create axum.toml at root + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"root\"\ncrate_dir = \".\"\n", + ) + .unwrap(); + + // Create axum.toml at level1 + fs::write( + root.join("level1/Cargo.toml"), + "[package]\nname = \"level1\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + fs::write( + root.join("level1/axum.toml"), + "[adapter]\ncrate = \"level1\"\ncrate_dir = \".\"\n", + ) + .unwrap(); + + // Search from level2, should find level1's axum.toml (closer) + let found = find_axum_manifest(&nested).expect("manifest"); + assert_eq!(found, root.join("level1/axum.toml")); + } + + #[test] + fn deploy_returns_error() { + let result = deploy(&[]); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .contains("does not define a deploy command") + ); + } + + #[test] + fn read_axum_project_env_overrides_config() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nhost = \"127.0.0.1\"\nport = 3000\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let result = + read_axum_project_with_env(&root.join("axum.toml"), Some("0.0.0.0"), Some("9999")); + + let project = result.expect("project"); + assert_eq!(project.addr.ip(), IpAddr::from([0, 0, 0, 0])); + assert_eq!(project.addr.port(), 9999); + } + + #[test] + fn resolve_subprocess_addr_prefers_edgezero_manifest_over_axum_manifest() { + let resolution = resolve_subprocess_addr_from_parts( + None, + None, + Some("0.0.0.0"), + Some(4000), + Some("127.0.0.1"), + Some(3000), + ); + + assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 4000))); + assert!(resolution.warnings.is_empty()); + } + + #[test] + fn resolve_subprocess_addr_falls_back_to_axum_manifest_when_edgezero_missing() { + let resolution = + resolve_subprocess_addr_from_parts(None, None, None, None, Some("0.0.0.0"), Some(3000)); + + assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 3000))); + assert!(resolution.warnings.is_empty()); + } + + #[test] + fn resolve_subprocess_addr_env_overrides_both_manifests() { + let resolution = resolve_subprocess_addr_from_parts( + Some("::1"), + Some("9000"), + Some("0.0.0.0"), + Some(4000), + Some("127.0.0.1"), + Some(3000), + ); + + assert_eq!( + resolution.addr, + SocketAddr::from((Ipv6Addr::LOCALHOST, 9000)) + ); + assert!(resolution.warnings.is_empty()); + } + + #[test] + fn resolve_subprocess_addr_invalid_edgezero_host_falls_back_to_axum_host() { + let resolution = resolve_subprocess_addr_from_parts( + None, + None, + Some("invalid-host"), + Some(4000), + Some("0.0.0.0"), + Some(3000), + ); + + assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 4000))); + assert_eq!(resolution.warnings.len(), 1); + } + + #[test] + fn resolve_subprocess_addr_edgezero_zero_port_falls_back_to_axum_port() { + let resolution = resolve_subprocess_addr_from_parts( + None, + None, + Some("127.0.0.1"), + Some(0), + Some("0.0.0.0"), + Some(3000), + ); + + assert_eq!(resolution.addr, SocketAddr::from(([127, 0, 0, 1], 3000))); + assert_eq!(resolution.warnings.len(), 1); + } +} diff --git a/crates/edgezero-adapter-axum/src/config_store.rs b/crates/edgezero-adapter-axum/src/config_store.rs index 19b7ddfd7..7ff621b4d 100644 --- a/crates/edgezero-adapter-axum/src/config_store.rs +++ b/crates/edgezero-adapter-axum/src/config_store.rs @@ -16,7 +16,7 @@ use std::collections::HashMap; use std::env; use std::fs; use std::io::ErrorKind; -use std::path::{Path, PathBuf}; +use std::path::{Path, PathBuf, absolute}; use async_trait::async_trait; use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; @@ -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 => { @@ -162,20 +174,50 @@ impl ConfigStore for AxumConfigStore { } } -/// Walk up from the process cwd looking for an ancestor that -/// contains an `edgezero.toml` file (the manifest marker, same -/// convention cargo uses for `Cargo.toml`). Returns the first -/// matching ancestor, or `None` if the walk hits the filesystem -/// root without finding one. +/// Resolve the project root that anchors the runtime `.edgezero` +/// directory. +/// +/// When `EDGEZERO_MANIFEST` is set (the manifest path can be renamed +/// away from the default `edgezero.toml`), the root is that manifest's +/// parent directory — the SAME root `provision --local` resolves the +/// manifest from — so runtime reads land exactly where provisioning +/// wrote. Only when the variable is unset does discovery fall back to +/// the upward `edgezero.toml` search below. /// /// 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 { + if let Some(root) = manifest_root_from_env() { + return Some(root); + } find_project_root_dir_from(&env::current_dir().ok()?) } +/// Derive the project root from an explicitly set `EDGEZERO_MANIFEST`. +/// +/// Mirrors the CLI's `manifest_root_from` — the manifest's parent is +/// the project root — but resolves a bare or relative manifest to an +/// ABSOLUTE path against the current working directory FIRST, so the +/// derived root stays stable regardless of a later cwd change. The Axum +/// runtime relaunches the child with cwd set to the adapter crate dir +/// (see `cli/run.rs`) before resolving `.edgezero`; a relative `.` root +/// would then resolve against the adapter crate dir instead of the +/// project root where `provision --local` wrote. Anchoring on the +/// launch cwd up front keeps runtime reads and provision writes on the +/// same directory. Returns `None` when the variable is unset (so the +/// caller falls back to the `edgezero.toml` upward search) or when the +/// current directory can't be determined. +fn manifest_root_from_env() -> Option { + let manifest = absolute(env::var("EDGEZERO_MANIFEST").ok()?).ok()?; + let root = manifest + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map_or_else(|| PathBuf::from("."), Path::to_path_buf); + Some(root) +} + /// Test-visible inner walk: same behaviour as /// [`find_project_root_dir`] but with the starting directory passed /// in explicitly so unit tests don't depend on the process cwd. @@ -199,9 +241,30 @@ mod tests { }); use super::*; + use edgezero_core::test_env::{EnvOverride, env_lock}; 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())]); @@ -271,6 +334,56 @@ mod tests { ); } + #[test] + fn find_project_root_dir_honors_renamed_manifest_env() { + // A renamed top-level manifest (`EDGEZERO_MANIFEST=/project.local.toml`) + // must anchor the runtime `.edgezero` directory on that + // manifest's parent — the same root `provision --local` writes + // under — NOT the `edgezero.toml` upward-search fallback. Note + // the tempdir deliberately has NO `edgezero.toml`, so a fallback + // would resolve somewhere else entirely. + let _lock = env_lock().lock().expect("env lock"); + let temp = tempdir().expect("tempdir"); + let manifest = temp.path().join("project.local.toml"); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest); + + let resolved = find_project_root_dir().expect("manifest env must resolve a root"); + assert_eq!( + fs::canonicalize(&resolved).expect("canonicalize resolved"), + fs::canonicalize(temp.path()).expect("canonicalize tempdir"), + "runtime root must be the renamed manifest's parent directory" + ); + } + + #[test] + fn find_project_root_dir_anchors_bare_manifest_env_to_cwd() { + // A BARE relative `EDGEZERO_MANIFEST=project.local.toml` must + // anchor the runtime `.edgezero` on the ABSOLUTE launch cwd, not + // a relative `.`. The Axum runtime relaunches the child with cwd + // set to the adapter crate dir before resolving `.edgezero`, so a + // relative `.` root would drift to that crate dir instead of the + // project root where `provision --local` wrote. Resolving the + // manifest to absolute up front keeps the two in agreement + // regardless of the later cwd change. Setting cwd in a test is + // racy, so assert on absoluteness + equality with the current + // cwd under the env lock rather than mutating cwd. + let _lock = env_lock().lock().expect("env lock"); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", "project.local.toml"); + + let resolved = find_project_root_dir().expect("bare manifest env must resolve a root"); + assert!( + resolved.is_absolute(), + "bare manifest env must resolve an ABSOLUTE root, got `{}`", + resolved.display() + ); + let cwd = env::current_dir().expect("cwd"); + assert_eq!( + fs::canonicalize(&resolved).expect("canonicalize resolved"), + fs::canonicalize(&cwd).expect("canonicalize cwd"), + "bare manifest env must anchor on the absolute launch cwd, not `.`" + ); + } + #[test] fn axum_config_store_from_path_returns_empty_for_missing_file() { let temp = tempdir().expect("tempdir"); diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index d8d1adc4c..31b2f249c 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")?); @@ -402,7 +430,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 { @@ -662,6 +690,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 30fc6e192..000000000 --- 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 5d93ee3d1..000000000 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ /dev/null @@ -1,2146 +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://github.com/IABTechLab/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://github.com/IABTechLab/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://github.com/IABTechLab/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), - // The Fastly staging lifecycle is Fastly-only. - AdapterAction::DeployStaging - | AdapterAction::EmitVersion - | AdapterAction::Healthcheck - | AdapterAction::Rollback => Err(format!( - "cloudflare adapter does not support the Fastly staging lifecycle action {action:?}" - )), - 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 000000000..9a9f88201 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/cli/mod.rs @@ -0,0 +1,916 @@ +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::local::{AdapterLocalContext, AdapterLocalLayout, LocalArtifactKind}; +use edgezero_adapter::registry::{ + Adapter, AdapterAction, AdapterDeployContext, AdapterDeployedState, AdapterExecContext, + AdapterPushContext, DeployOwnership, 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 { + local_ignore_patterns: &["wrangler.toml", ".dev.vars", ".wrangler/"], + tool_pins: &["nodejs 24.20.0"], + install_notes: &[], + 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 { + // Omit the `[adapters.cloudflare.commands]` shell block. A static + // shell override (a) bakes `--cwd ` at scaffold time, so a + // custom `[adapters.cloudflare.adapter].manifest` like + // `config/prod.toml` is ignored (wrangler loads the default + // `wrangler.toml` instead); and (b) `wrangler build` is not a valid + // Wrangler v4 command AND would need `build/worker/shim.mjs`, which + // nothing generates. With no block, build/serve/deploy route through + // the registry adapter, which passes `--config ` and lets + // `wrangler deploy`/`dev` run the `[build]` `worker-build` command the + // synthesised `wrangler.toml` declares (generating the shim). These + // strings are unused while `emit_commands` is false; kept for parity. + build: "wrangler deploy --dry-run --config {manifest}", + deploy: "wrangler deploy --config {manifest}", + serve: "wrangler dev --config {manifest}", + emit_commands: false, + }, + 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://github.com/IABTechLab/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://github.com/IABTechLab/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://github.com/IABTechLab/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 preflight_deploy( + &self, + _context: &AdapterDeployContext, + _args: &[String], + _exec: &AdapterExecContext<'_>, + ) -> Result { + Ok(DeployOwnership::ManifestCommand) + } + fn deploy( + &self, + context: &AdapterDeployContext, + args: &[String], + exec: &AdapterExecContext<'_>, + ) -> Result<(), String> { + let action = if context.staging { + AdapterAction::DeployStaging + } else { + AdapterAction::Deploy + }; + self.execute(action, args, exec) + } + fn finalize_deploy( + &self, + _context: &AdapterDeployContext, + _output: Option<&str>, + _exec: &AdapterExecContext<'_>, + ) -> Result<(), String> { + Ok(()) + } + fn validate_config_key_for_target( + &self, + _logical: &str, + _key: &str, + _staging: bool, + _local: bool, + ) -> Result<(), String> { + Ok(()) + } + + 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), + // The Fastly staging lifecycle is Fastly-only. + AdapterAction::DeployStaging + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback => Err(format!( + "cloudflare adapter does not support the Fastly staging lifecycle action {action:?}" + )), + 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 local_layout( + &self, + context: &AdapterLocalContext<'_>, + ) -> Result { + let manifest = context.manifest().unwrap_or("wrangler.toml"); + let parent = Path::new(manifest) + .parent() + .unwrap_or_else(|| Path::new("")); + Ok( + AdapterLocalLayout::new_with_kind(manifest, LocalArtifactKind::SensitiveToml) + .with_artifact(parent.join(".dev.vars"), LocalArtifactKind::Env), + ) + } + + 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. Reject any key that + /// cannot round-trip through the `.dev.vars` `KEY=VALUE` format — an empty + /// key, or one containing `=`, a newline/carriage-return, leading/trailing + /// whitespace, or a leading `#` — because such a line would corrupt the + /// file or parse back as a different key/value. Also reject keys anywhere + /// in the reserved `EDGEZERO__` namespace: a secret there would collide + /// with runtime config and resolve to configuration data instead of the + /// credential. All checks run here, in preflight, before any write. + fn validate_typed_secrets(&self, entries: &[TypedSecretEntry<'_>]) -> Result<(), String> { + 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 `.dev.vars` readers treat as a comment") + } else if !key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-')) + { + Some("contains characters outside the dotenv key alphabet [A-Za-z0-9_.-]") + } 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 \ + `.dev.vars`; 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!( + "cloudflare: typed secret key `{key}` (field `{field}`) {rejection}; cloudflare \ + writes typed secrets as `=\"\"` lines in `.dev.vars`, 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::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") + } + }), + }; + // `main` must climb from the manifest dir back to the crate root's + // `build/worker/shim.mjs` (see `run::wrangler_main_relpath`) so a + // nested manifest still resolves the worker-build output. + let main_rel = run::wrangler_main_relpath(&rel, adapter_crate_path); + Ok(vec![( + rel, + run::synthesise_wrangler_toml(&crate_name, &main_rel), + )]) + } +} + +#[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 validate_typed_secrets_rejects_keys_that_cannot_round_trip() { + // Each key would corrupt the `.dev.vars` `=""` line or parse + // back as a different key/value: empty, `=`, `#`-prefixed, an + // embedded newline, and leading/trailing whitespace. + for bad in [ + "", + "partner=token", + "#commented", + "with\nnewline", + " padded", + "padded ", + ] { + let entries = [TypedSecretEntry::new(TEST_SECRET_ID, "field", bad)]; + CloudflareCliAdapter + .validate_typed_secrets(&entries) + .expect_err( + "a key that cannot round-trip through a .dev.vars line must be rejected", + ); + } + } + + #[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 000000000..600157312 --- /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 000000000..7b054f739 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/cli/provision_local.rs @@ -0,0 +1,1638 @@ +use edgezero_adapter::env_records::encode_value; +use std::fs; +use std::io::ErrorKind; +use std::path::Path; + +use edgezero_adapter::env_file::{ + EDGEZERO_PROVISION_HEADER, append_lines_dedup_with_header, upsert_lines_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 {} (parse details redacted)", + 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 {} (parse details redacted)", + 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 {} (parse details redacted)", + 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 {} (parse details redacted)", + 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 (name_lines, comment_lines) = match build_dev_vars_lines(stores) { + Ok(lines) => lines, + Err(error) => return Err(error.to_owned()), + }; + // Managed `__NAME` overlay lines go through the CONVERGING upsert so a + // reprovision after a platform-name change lands the new value. The + // commented `__KEY` placeholders are operator-owned and stay on the + // insert-only append path (an uncommented + edited one survives). + upsert_lines_with_header( + &dev_vars_path, + Some(EDGEZERO_PROVISION_HEADER), + &name_lines, + dry_run, + ) + .map_err(|err| format!("write {}: {err}", dev_vars_path.display()))?; + append_lines_dedup_with_header( + &dev_vars_path, + Some(EDGEZERO_PROVISION_HEADER), + &comment_lines, + dry_run, + ) + .map_err(|err| format!("write {}: {err}", dev_vars_path.display()))?; + status_lines.push(format!( + "cloudflare: wrote {} .dev.vars entries to {}", + name_lines.len().saturating_add(comment_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. +/// +/// Returns `(name_lines, comment_lines)`: the `__NAME` overlay lines +/// (converging upsert path) and the commented `__KEY` placeholders +/// (operator-owned, insert-only append path) split apart. +fn build_dev_vars_lines( + stores: &ProvisionStores<'_>, +) -> Result<(Vec, Vec), &'static str> { + let mut name_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(); + #[expect( + clippy::question_mark, + reason = "explicit variants keep encoded values out of diagnostic paths in CodeQL" + )] + match encode_value(&store.platform, true) { + Ok(platform) => name_lines.push(format!( + "EDGEZERO__STORES__{kind}__{logical_upper}__NAME={platform}" + )), + Err(error) => return Err(error), + } + } + } + let mut comment_lines: Vec = Vec::new(); + for store in stores.config { + let logical_upper = store.logical.to_ascii_uppercase(); + let logical = &store.logical; + comment_lines.push(format!( + r#"# EDGEZERO__STORES__CONFIG__{logical_upper}__KEY="{logical}_staging""# + )); + } + Ok((name_lines, comment_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"; + + #[test] + fn local_provision_parse_errors_redact_values() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("wrangler.toml"); + let original = "[vars]\ntoken='PRIVATE_PARSE_SENTINEL' broken\n"; + fs::write(&path, original).unwrap(); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + let error = provision(dir.path(), Some("wrangler.toml"), &stores, None, false).unwrap_err(); + assert!(error.contains("parse"), "{error}"); + assert!(!error.contains("PRIVATE_PARSE_SENTINEL"), "{error}"); + assert_eq!(fs::read_to_string(path).unwrap(), original); + } + + /// 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", "build/worker/shim.mjs"), + ); + 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", "build/worker/shim.mjs"), + ); + 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", "build/worker/shim.mjs"), + ); + 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", "build/worker/shim.mjs"), + ) + .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", "build/worker/shim.mjs"), + ); + 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", "build/worker/shim.mjs"), + ); + 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", "build/worker/shim.mjs"), + ); + 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_converges_changed_platform_name() { + // Operator changes a store's platform NAME (app_config -> + // prod_config) and REPROVISIONS. The `__NAME` overlay line must + // CONVERGE to the new value with no stale `="app_config"` line + // lingering, while an operator-added sibling line survives. + let dir = tempdir().expect("tempdir"); + write_wrangler( + dir.path(), + &synthesise_wrangler_toml("demo", "build/worker/shim.mjs"), + ); + let dev_vars_path = dir.path().join(".dev.vars"); + + // First provision with platform `app_config`. + let first_ids = vec![ResolvedStoreId::new(TEST_CONFIG_ID, "app_config")]; + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &ProvisionStores { + config: &first_ids, + kv: &[], + secrets: &[], + }, + None, + ProvisionMode::Local, + false, + ) + .expect("first local provision succeeds"); + // Operator hand-adds a sibling secret line. + let first = fs::read_to_string(&dev_vars_path).expect("read .dev.vars"); + fs::write( + &dev_vars_path, + format!("{first}OPERATOR_SECRET=\"keepme\"\n"), + ) + .expect("append operator line"); + + // Reprovision with the platform renamed to `prod_config`. + let second_ids = vec![ResolvedStoreId::new(TEST_CONFIG_ID, "prod_config")]; + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &ProvisionStores { + config: &second_ids, + kv: &[], + secrets: &[], + }, + None, + ProvisionMode::Local, + false, + ) + .expect("reprovision succeeds"); + + let after = fs::read_to_string(&dev_vars_path).expect("read .dev.vars"); + assert!( + after.contains(r#"EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME="prod_config""#), + "reprovision must converge the __NAME value to prod_config: {after}" + ); + assert!( + !after + .lines() + .any(|line| line == r#"EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME="app_config""#), + "no stale =app_config __NAME line may linger: {after}" + ); + assert!( + after.contains(r#"OPERATOR_SECRET="keepme""#), + "operator-added sibling line must survive: {after}" + ); + } + + #[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", "build/worker/shim.mjs"), + ); + 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", "build/worker/shim.mjs"), + ) + .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", "build/worker/shim.mjs")` 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", "build/worker/shim.mjs"), + ) + .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", "build/worker/shim.mjs"), + ) + .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 000000000..8e52695f4 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/cli/push_cloud.rs @@ -0,0 +1,1192 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf, absolute}; +use std::process::Command; + +use edgezero_adapter::env_file::reject_symlink_components; +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}`)" + )]); + } + // Refuse a symlinked `.wrangler` / `.wrangler/state` before shelling out: + // wrangler's `--local` push writes Miniflare state under + // `.wrangler/state`, and a checked-in symlink at either component would + // redirect those writes OUTSIDE the project tree. Walk from the project + // dir down so a symlinked intermediate `.wrangler` is caught too; + // components that don't exist yet (first push) pass. Only the LOCAL path + // needs this -- the remote push writes to Cloudflare, not `.wrangler`. + let wrangler_state = project_dir.join(".wrangler").join("state"); + reject_symlink_components(project_dir, &wrangler_state)?; + 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); + if locality == "--local" { + reject_symlink_components(project_dir, &project_dir.join(".wrangler/state"))?; + } + 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}" + ); + } + + /// A symlinked `.wrangler` directory (pointing outside the project) + /// must be refused BEFORE `wrangler` is ever spawned, so the `--local` + /// push can't redirect Miniflare's `.wrangler/state` writes off the + /// tree. No fake wrangler is installed here: if the guard were missing, + /// the shell-out would fail with a "not found" error instead of the + /// symlink refusal this asserts. + #[cfg(unix)] + #[test] + fn local_push_refuses_a_symlinked_wrangler_dir() { + use std::os::unix::fs::symlink; + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let outside = tempdir().expect("outside tempdir"); + symlink(outside.path(), project_dir.path().join(".wrangler")).expect("symlink .wrangler"); + + let err = 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_err("a symlinked .wrangler must be refused before shelling out"); + assert!( + err.contains("symlink"), + "error must name the symlink refusal: {err}" + ); + // The guard must fire before any wrangler shell-out -- the error is + // the symlink refusal, not a spawn/exit failure. + assert!( + !err.contains("wrangler kv bulk put") && !err.contains("not found on PATH"), + "refusal must precede the wrangler invocation: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn local_read_refuses_symlinked_miniflare_state_before_vendor_io() { + use std::os::unix::fs::symlink; + let _lock = path_mutation_guard().lock().unwrap(); + let project = tempdir().unwrap(); + let outside = tempdir().unwrap(); + write_wrangler(project.path(), "name = 'demo'\n"); + fs::create_dir_all(project.path().join(".wrangler")).unwrap(); + symlink(outside.path(), project.path().join(".wrangler/state")).unwrap(); + let error = read_wrangler_kv_key( + project.path(), + Some("wrangler.toml"), + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "root", + "--local", + ) + .err() + .expect("symlink refusal"); + assert!(error.contains("symlink"), "{error}"); + assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0); + } + + #[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 000000000..8d931bfa2 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/cli/run.rs @@ -0,0 +1,463 @@ +use std::fs; +use std::path::{Path, PathBuf, absolute}; +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()) + })?; + // Run wrangler from the CRATE ROOT, not the manifest dir. `wrangler + // deploy`/`dev` run the `[build]` `worker-build` command in the CWD -- + // which is where `Cargo.toml` lives and where the `build/worker/shim.mjs` + // it emits must land. For a NESTED manifest (e.g. + // `crates/server/config/wrangler.toml`) the manifest dir is NOT the crate + // root, so running there leaves worker-build unable to find `Cargo.toml` + // and the shim in the wrong place. The synthesised `main` is written + // relative to the manifest dir (see `wrangler_main_relpath`) so it still + // resolves to the crate-root output. + let crate_dir = cli_support::adapter_crate_dir(ctx, &manifest)?; + let config = absolute(&manifest) + .unwrap_or(manifest.clone()) + .to_str() + .ok_or_else(|| "invalid wrangler config path".to_owned())? + .to_owned(); + + let mut command = Command::new("wrangler"); + command + .args(["deploy", "--config", config.as_str()]) + .args(extra_args) + .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 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()) + })?; + // Run from the crate root, same rationale as `deploy` above. + let crate_dir = cli_support::adapter_crate_dir(ctx, &manifest)?; + let config = absolute(&manifest) + .unwrap_or(manifest.clone()) + .to_str() + .ok_or_else(|| "invalid wrangler config path".to_owned())? + .to_owned(); + + let mut command = Command::new("wrangler"); + command + .args(["dev", "--config", config.as_str()]) + .args(extra_args) + .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 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. +/// The `main` value for a wrangler.toml at `manifest_rel` (relative to the +/// project root) whose crate root is `crate_rel`. Wrangler resolves `main` +/// against the config-file's directory, while `worker-build` emits +/// `build/worker/shim.mjs` at the CRATE ROOT (where `Cargo.toml` lives and +/// where wrangler runs it). So for a manifest nested BELOW the crate root the +/// value must climb back up with `../` before `build/worker/shim.mjs`. +pub(super) fn wrangler_main_relpath(manifest_rel: &Path, crate_rel: Option<&str>) -> String { + const SHIM: &str = "build/worker/shim.mjs"; + let manifest_dir = manifest_rel.parent().unwrap_or_else(|| Path::new("")); + let Some(crate_root) = crate_rel else { + return SHIM.to_owned(); + }; + // How far the manifest dir sits BELOW the crate root. + let Ok(suffix) = manifest_dir.strip_prefix(Path::new(crate_root)) else { + // Manifest not under the declared crate (unusual layout) -- fall back + // to the crate-root-adjacent default rather than guess. + return SHIM.to_owned(); + }; + let depth = suffix.components().count(); + if depth == 0 { + return SHIM.to_owned(); + } + format!("{}{SHIM}", "../".repeat(depth)) +} + +pub(super) fn synthesise_wrangler_toml(crate_name: &str, main_rel: &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)); + // `main` is written RELATIVE TO THE MANIFEST DIR (wrangler resolves it + // against the config file's location), pointing at the crate-root + // `build/worker/shim.mjs` `worker-build` emits. For a manifest at the + // crate root this is just `build/worker/shim.mjs`; for a NESTED manifest + // it prefixes enough `../` to climb back to the crate root. + doc.insert("main", value(main_rel)); + doc.insert("compatibility_date", value("2024-01-01")); + + // `[build] command = "worker-build --release"`: `main` points at + // `build/worker/shim.mjs`, the wasm-bindgen glue that ONLY `worker-build` + // produces (a plain `cargo build` emits just the raw wasm). Without this + // command a fresh scaffold cannot serve or deploy -- `wrangler dev` / + // `wrangler deploy` load `main` but nothing ever creates the shim. + // `wrangler` runs this `[build].command` automatically before dev/deploy, + // so `edgezero serve` / `edgezero deploy` (which shell out to wrangler + // with `--config`) get the shim built for them. + let mut build_table = toml_edit::Table::new(); + build_table.insert("command", value("worker-build --release")); + doc.insert("build", toml_edit::Item::Table(build_table)); + + 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); + } + + // ---------- wrangler_main_relpath ---------- + + #[test] + fn wrangler_main_relpath_climbs_to_the_crate_root() { + // Manifest at the crate root -> plain path. + assert_eq!( + wrangler_main_relpath(Path::new("wrangler.toml"), None), + "build/worker/shim.mjs" + ); + assert_eq!( + wrangler_main_relpath(Path::new("crates/cf/wrangler.toml"), Some("crates/cf")), + "build/worker/shim.mjs" + ); + // Nested one level below the crate root -> one `../`. + assert_eq!( + wrangler_main_relpath( + Path::new("crates/cf/config/wrangler.toml"), + Some("crates/cf") + ), + "../build/worker/shim.mjs" + ); + // Nested two levels. + assert_eq!( + wrangler_main_relpath(Path::new("crates/cf/a/b/wrangler.toml"), Some("crates/cf")), + "../../build/worker/shim.mjs" + ); + } + + // ---------- synthesise_wrangler_toml ---------- + + #[test] + fn synthesises_wrangler_toml_matches_spec_baseline_exactly() { + // Exact-content test: the synthesised wrangler.toml must equal the + // Cloudflare baseline byte-for-byte. The `[build]` command is REQUIRED + // (not an extra): `main` points at the `worker-build`-generated shim, + // so without it a fresh scaffold cannot serve or deploy. A loose + // `contains` check let the baseline drift; this pins it. + let out = synthesise_wrangler_toml("demo-adapter-cloudflare", "build/worker/shim.mjs"); + let expected = "# edgezero-provision: v1\n\ + name = \"demo-adapter-cloudflare\"\n\ + main = \"build/worker/shim.mjs\"\n\ + compatibility_date = \"2024-01-01\"\n\ + \n\ + [build]\n\ + command = \"worker-build --release\"\n"; + assert_eq!(out, expected, "wrangler.toml baseline drifted"); + } + + #[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, "build/worker/shim.mjs"); + 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 4f076e096..000000000 --- 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/Cargo.toml b/crates/edgezero-adapter-fastly/Cargo.toml index f080586fe..98c272ea2 100644 --- a/crates/edgezero-adapter-fastly/Cargo.toml +++ b/crates/edgezero-adapter-fastly/Cargo.toml @@ -18,6 +18,7 @@ cli = [ "dep:ctor", "dep:toml_edit", "dep:walkdir", + "dep:tempfile", ] fastly = ["dep:fastly", "dep:log-fastly"] @@ -47,6 +48,7 @@ sha2 = { workspace = true } thiserror = { workspace = true } toml_edit = { workspace = true, optional = true } walkdir = { workspace = true, optional = true } +tempfile = { workspace = true, optional = true } [dev-dependencies] edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs deleted file mode 100644 index 01efe05cc..000000000 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ /dev/null @@ -1,15102 +0,0 @@ -#![expect( - clippy::arbitrary_source_item_ordering, - reason = "the managed deployment planning and execution state machine is kept as one cohesive block" -)] - -use std::cell::{Cell, RefCell}; -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use std::env; -use std::fmt::{self, Write as _}; -use std::fs; -use std::io::{ErrorKind, Write as _}; -use std::net::IpAddr; -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::thread; -use std::time::{Duration, 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, AdapterDeployContext, AdapterPushContext, DeployOwnership, - DeployStoreIds, ProvisionStores, ReadConfigEntry, ResolvedStoreId, register_adapter, -}; -use edgezero_adapter::release::{VerifiedApplicationRelease, verify_application_release}; -use edgezero_adapter::scaffold::{ - AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, - ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, -}; -use edgezero_core::env_config::{EnvConfig, merge_env_defaults}; -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://github.com/IABTechLab/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://github.com/IABTechLab/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://github.com/IABTechLab/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"; - -/// Env var carrying the Fastly API token (read by the Fastly CLI and -/// forwarded to the Fastly API via the `Fastly-Key` header). Part of -/// the managed Fastly deployment lifecycle. -const FASTLY_API_TOKEN_ENV: &str = "FASTLY_API_TOKEN"; -/// Env var carrying the default Fastly service id, used when -/// `--service-id` is not passed explicitly. -const FASTLY_SERVICE_ID_ENV: &str = "FASTLY_SERVICE_ID"; - -/// Bound every Fastly API call so an outage or a stalled connection cannot hang the -/// job — potentially until the surrounding workflow timeout, hours later — during a -/// time-sensitive operation like a rollback. `curl` exits 28 when either limit is -/// hit, which `curl_config_capture` turns into an explicit timeout error. -const FASTLY_API_CONNECT_TIMEOUT_SECS: u64 = 10; -const FASTLY_API_MAX_TIME_SECS: u64 = 30; -/// curl's exit code for an operation that exceeded `--connect-timeout`/`--max-time`. -const CURL_EXIT_TIMEOUT: i32 = 28; - -/// Non-targeting Fastly CLI global booleans accepted by the managed deploy -/// path. Lifecycle-owned `--autoclone` is deliberately absent. -const MANAGED_DEPLOY_GLOBAL_BOOL_FLAGS: &[&str] = &[ - "--accept-defaults", - "-d", - "--auto-yes", - "-y", - "--debug-mode", - "--non-interactive", - "-i", - "--quiet", - "-q", - "--verbose", - "-v", -]; - -/// Version-scoped logging endpoint collections exposed by the Fastly API. -/// `service logging debug` streams endpoint errors and is not a collection. -const FASTLY_LOGGING_PROVIDER_KINDS: &[&str] = &[ - "azureblob", - "bigquery", - "cloudfiles", - "datadog", - "digitalocean", - "elasticsearch", - "ftp", - "gcs", - "pubsub", - "grafanacloudlogs", - "heroku", - "honeycomb", - "https", - "kafka", - "kinesis", - "logentries", - "loggly", - "logshuttle", - "newrelic", - "newrelicotlp", - "openstack", - "papertrail", - "s3", - "scalyr", - "sftp", - "splunk", - "sumologic", - "syslog", -]; - -/// 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; - -#[derive(Debug, Eq, PartialEq)] -struct ReleaseManagedDeployArgs { - comment: Option, - globals: Vec, -} - -struct FastlyApiToken(String); - -impl fmt::Debug for FastlyApiToken { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("FastlyApiToken([REDACTED])") - } -} - -impl FastlyApiToken { - fn as_str(&self) -> &str { - &self.0 - } -} - -#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq)] -struct ServiceVersionRecord { - #[serde(alias = "Active")] - active: bool, - #[serde(alias = "Environments")] - environments: Vec, - #[serde(alias = "Locked")] - locked: bool, - #[serde(alias = "Number")] - number: u64, -} - -#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq)] -struct ServiceEnvironmentRecord { - #[serde(alias = "ServiceVersion")] - active_version: u64, - #[serde(alias = "Name")] - name: String, - #[serde(alias = "ServiceID")] - service_id: String, -} - -#[derive(Debug, serde::Deserialize)] -struct ComputePackageRecord { - metadata: ComputePackageMetadata, - service_id: String, - version: u64, -} - -#[derive(Debug, serde::Deserialize)] -struct ComputePackageMetadata { - files_hash: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum VersionSource { - Active(u64), - InitialDraft(u64), - Retired(u64), - Staging(u64), -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum PublishTarget { - Production, - Staging, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum StagingRollbackDecision { - Deactivate, - NoopDraft, -} - -#[derive(Debug)] -struct InitialDraftSnapshot { - configuration: VersionConfigurationSnapshot, - links: serde_json::Value, - metadata: serde_json::Value, - version: ServiceVersionRecord, -} - -#[derive(Debug)] -struct InactiveSourceSnapshot { - links: serde_json::Value, - metadata: serde_json::Value, - version: ServiceVersionRecord, -} - -#[derive(Clone, Debug, PartialEq)] -struct LoggingProviderSnapshot { - endpoints: serde_json::Value, - kind: &'static str, -} - -#[derive(Clone, Debug, PartialEq)] -struct VersionConfigurationSnapshot { - backends: serde_json::Value, - domains: serde_json::Value, - healthchecks: serde_json::Value, - logging: Vec, - settings: serde_json::Value, -} - -#[derive(Debug)] -enum EditableVersionSource { - CloneActive { active_version: u64 }, - CloneRetired(Box), - CloneStaging(Box), - InitialDraft(Box), -} - -#[derive(Debug)] -struct ManagedDeployPlan { - arguments: ReleaseManagedDeployArgs, - links: LinkReconciliation, - package_files_hash: String, - package_sha256: String, - release: VerifiedApplicationRelease, - service_id: String, - source_configuration: VersionConfigurationSnapshot, - source_links: Vec, - target: PublishTarget, - token: FastlyApiToken, - version_source: EditableVersionSource, -} - -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] -enum ResourceKind { - Config, - Kv, - Secret, -} - -impl ResourceKind { - fn display_name(self) -> &'static str { - match self { - Self::Config => "Config Store", - Self::Kv => "KV Store", - Self::Secret => "Secret Store", - } - } - - fn selector_kind(self) -> &'static str { - match self { - Self::Config => "config", - Self::Kv => "kv", - Self::Secret => "secrets", - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct DesiredResourceLink { - alias: String, - kind: ResourceKind, - resource_id: String, - selected_name: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct ExistingResourceLink { - alias: String, - kind: ResourceKind, - link_id: String, - resource_id: String, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -struct LinkReconciliation { - create: Vec, - delete_link_ids: Vec, -} - -#[derive(Clone, Debug)] -struct StoreInventory { - by_name: BTreeMap, -} - -#[derive(serde::Deserialize)] -struct PaginatedStoreInventoryPage { - #[serde(alias = "Data")] - data: Vec, - #[serde(alias = "Meta")] - meta: PaginatedStoreInventoryMeta, -} - -#[derive(serde::Deserialize)] -struct PaginatedStoreInventoryRecord { - #[serde(alias = "StoreID")] - id: String, - #[serde(alias = "Name")] - name: String, -} - -#[derive(serde::Deserialize)] -struct PaginatedStoreInventoryMeta { - next_cursor: Option, -} - -#[derive(Clone, Debug)] -struct ResourceInventories { - config: StoreInventory, - kind_by_resource_id: BTreeMap, - kv: StoreInventory, - secret: StoreInventory, -} - -impl ResourceInventories { - fn for_kind(&self, kind: ResourceKind) -> &StoreInventory { - match kind { - ResourceKind::Config => &self.config, - ResourceKind::Kv => &self.kv, - ResourceKind::Secret => &self.secret, - } - } - - #[cfg_attr( - not(test), - expect( - dead_code, - reason = "the pure JSON constructor is retained for clean-cutover planner tests" - ) - )] - fn from_json(raw_config: &str, raw_kv: &str, raw_secret: &str) -> Result { - let config = parse_store_inventory(ResourceKind::Config, raw_config)?; - let kv = parse_store_inventory(ResourceKind::Kv, raw_kv)?; - let secret = parse_store_inventory(ResourceKind::Secret, raw_secret)?; - Self::from_inventories(config, kv, secret) - } - - fn from_inventories( - config: StoreInventory, - kv: StoreInventory, - secret: StoreInventory, - ) -> Result { - let mut kind_by_resource_id = BTreeMap::new(); - for (kind, inventory) in [ - (ResourceKind::Config, &config), - (ResourceKind::Kv, &kv), - (ResourceKind::Secret, &secret), - ] { - for resource_id in inventory.by_name.values() { - if let Some(prior_kind) = kind_by_resource_id.insert(resource_id.clone(), kind) { - return Err(format!( - "Fastly resource id `{resource_id}` appears in both {} and {} inventories; resource kind is ambiguous", - prior_kind.display_name(), - kind.display_name() - )); - } - } - } - Ok(Self { - config, - kind_by_resource_id, - kv, - secret, - }) - } - - fn resolve(&self, kind: ResourceKind, name: &str) -> Result { - self.for_kind(kind) - .by_name - .get(name) - .cloned() - .ok_or_else(|| { - format!( - "selected Fastly {} `{name}` does not exist in the complete provider inventory", - kind.display_name() - ) - }) - } -} - -fn parse_store_inventory(kind: ResourceKind, raw: &str) -> Result { - let parsed: serde_json::Value = serde_json::from_str(raw).map_err(|error| { - format!( - "failed to parse Fastly {} inventory as JSON: {error}", - kind.display_name() - ) - })?; - let rows = parsed.as_array().ok_or_else(|| { - format!( - "Fastly {} inventory must be one complete bare JSON array; paginated or enveloped results are not accepted", - kind.display_name() - ) - })?; - let mut records = Vec::with_capacity(rows.len()); - for (index, row) in rows.iter().enumerate() { - let object = row.as_object().ok_or_else(|| { - format!( - "Fastly {} inventory record #{index} is not an object", - kind.display_name() - ) - })?; - let field = |name| { - object - .get(name) - .and_then(serde_json::Value::as_str) - .filter(|field_value| !field_value.is_empty()) - }; - let (Some(name), Some(id)) = (field("name"), field("id")) else { - return Err(format!( - "Fastly {} inventory record #{index} requires non-empty string `name` and `id` fields", - kind.display_name() - )); - }; - records.push((name.to_owned(), id.to_owned())); - } - store_inventory_from_records(kind, records) -} - -fn store_inventory_from_records( - kind: ResourceKind, - records: impl IntoIterator, -) -> Result { - let mut by_name = BTreeMap::new(); - let mut ids = BTreeSet::new(); - for (index, (name, id)) in records.into_iter().enumerate() { - if id.is_empty() - || !id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) - { - return Err(format!( - "Fastly {} inventory record #{index} has invalid resource id", - kind.display_name() - )); - } - if name.is_empty() - || name.chars().all(char::is_whitespace) - || name.chars().any(char::is_control) - { - return Err(format!( - "Fastly {} inventory record #{index} has invalid store name", - kind.display_name() - )); - } - if by_name.insert(name.clone(), id.clone()).is_some() { - return Err(format!( - "Fastly {} inventory contains duplicate name `{name}`", - kind.display_name() - )); - } - if !ids.insert(id.clone()) { - return Err(format!( - "Fastly {} inventory contains duplicate resource id `{id}`", - kind.display_name() - )); - } - } - Ok(StoreInventory { by_name }) -} - -fn percent_encode_query_value(value: &str) -> Result { - let mut encoded = String::with_capacity(value.len()); - for byte in value.bytes() { - if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { - encoded.push(char::from(byte)); - } else { - write!(encoded, "%{byte:02X}") - .map_err(|error| format!("failed to encode Fastly pagination cursor: {error}"))?; - } - } - Ok(encoded) -} - -fn collect_paginated_store_inventory( - kind: ResourceKind, - first_path: &str, - mut fetch: impl FnMut(&str) -> Result, -) -> Result { - let mut path = first_path.to_owned(); - let mut seen_cursors = BTreeSet::new(); - let mut records = Vec::new(); - loop { - let raw = fetch(&path)?; - let page: PaginatedStoreInventoryPage = serde_json::from_str(&raw).map_err(|_error| { - format!( - "Fastly {} inventory page has an invalid paginated response shape (payload redacted)", - kind.display_name() - ) - })?; - records.extend(page.data.into_iter().map(|record| (record.name, record.id))); - let Some(cursor) = page.meta.next_cursor.filter(|cursor| !cursor.is_empty()) else { - break; - }; - if !seen_cursors.insert(cursor.clone()) { - return Err(format!( - "Fastly {} inventory repeated a pagination cursor; completeness cannot be proven", - kind.display_name() - )); - } - path = format!( - "{first_path}&cursor={}", - percent_encode_query_value(&cursor)? - ); - } - store_inventory_from_records(kind, records) -} - -fn fetch_complete_paginated_store_inventory( - kind: ResourceKind, - endpoint: &str, - token: &str, -) -> Result { - let first_path = format!("{endpoint}?limit=100"); - collect_paginated_store_inventory(kind, &first_path, |path| fastly_api_get(path, token)) -} - -fn desired_resource_links( - stores: &RuntimeStoreIds, - environment: &EnvConfig, - inventories: &ResourceInventories, -) -> Result, String> { - let mut desired_by_identity = BTreeMap::<(ResourceKind, String), DesiredResourceLink>::new(); - - for (kind, logical_ids) in [ - (ResourceKind::Config, &stores.config), - (ResourceKind::Kv, &stores.kv), - (ResourceKind::Secret, &stores.secrets), - ] { - for logical_id in logical_ids { - let selected_name = environment - .store_name_checked(kind.selector_kind(), logical_id) - .map_err(|error| format!("invalid Fastly deploy environment: {error}"))?; - let resource_id = inventories.resolve(kind, &selected_name)?; - let desired = DesiredResourceLink { - kind, - alias: logical_id.clone(), - selected_name: selected_name.clone(), - resource_id, - }; - let identity = (kind, logical_id.clone()); - if desired_by_identity.insert(identity, desired).is_some() { - return Err(format!( - "Fastly {} logical store id `{logical_id}` is declared more than once", - kind.display_name() - )); - } - } - } - Ok(desired_by_identity.into_values().collect()) -} - -fn plan_link_reconciliation( - desired: &[DesiredResourceLink], - existing: &[ExistingResourceLink], - inventories: &ResourceInventories, -) -> Result { - // Fastly exposes Config, KV, and Secret Stores through separate typed - // runtime APIs, so a resource-link identity is `(kind, alias)`. Reusing an - // alias across kinds is valid; two links with the same kind and alias are - // ambiguous to EdgeZero and fail closed below. - let mut desired_identities = BTreeSet::new(); - for link in desired { - let identity = (link.kind, link.alias.as_str()); - if !desired_identities.insert(identity) { - return Err(format!( - "desired Fastly {} resource-link alias `{}` is duplicated", - link.kind.display_name(), - link.alias - )); - } - } - let mut existing_by_identity = BTreeMap::new(); - let mut existing_ids = BTreeSet::new(); - for link in existing { - if !existing_ids.insert(link.link_id.as_str()) { - return Err(format!( - "Fastly resource-link inventory contains duplicate link id `{}`", - link.link_id - )); - } - match inventories.kind_by_resource_id.get(&link.resource_id) { - Some(kind) if *kind == link.kind => {} - Some(kind) => { - return Err(format!( - "Fastly resource link `{}` reports {} but resource `{}` belongs to {}", - link.alias, - link.kind.display_name(), - link.resource_id, - kind.display_name() - )); - } - // Account inventory visibility can be narrower than the resource - // links inherited by this service version. The link response is - // authoritative for an undeclared inherited link, which must - // survive reconciliation even when the token cannot list its - // physical resource. - None => {} - } - let identity = (link.kind, link.alias.as_str()); - if existing_by_identity.insert(identity, link).is_some() { - return Err(format!( - "Fastly resource-link inventory contains duplicate {} alias `{}`", - link.kind.display_name(), - link.alias - )); - } - } - - let mut create = Vec::new(); - let mut delete_link_ids = Vec::new(); - for desired_link in desired { - let identity = (desired_link.kind, desired_link.alias.as_str()); - match existing_by_identity.get(&identity) { - Some(existing_link) if existing_link.resource_id == desired_link.resource_id => {} - Some(existing_link) => { - delete_link_ids.push(existing_link.link_id.clone()); - create.push(desired_link.clone()); - } - None => create.push(desired_link.clone()), - } - } - - delete_link_ids.sort(); - delete_link_ids.dedup(); - create.sort_by(|left, right| (left.kind, &left.alias).cmp(&(right.kind, &right.alias))); - Ok(LinkReconciliation { - create, - delete_link_ids, - }) -} - -#[expect( - clippy::too_many_lines, - reason = "the read-only checkpoint intentionally assembles every validated input into one reviewable plan" -)] -fn build_managed_deploy_plan( - context: &AdapterDeployContext, - args: &[String], -) -> Result { - let arguments = parse_release_managed_deploy_args(args)?; - let target = if context.staging { - PublishTarget::Staging - } else { - PublishTarget::Production - }; - let environment = effective_deploy_environment(context)?; - let release_root = context - .application_release_root - .as_deref() - .ok_or_else(|| "managed Fastly deployment requires --application-release".to_owned())?; - let application_manifest = context - .application_manifest_path - .as_deref() - .ok_or_else(|| { - "managed Fastly deployment requires the exact loaded application manifest path" - .to_owned() - })?; - let adapter_manifest = context.adapter_manifest_path.as_deref().ok_or_else(|| { - "managed Fastly deployment requires the exact referenced Fastly manifest path".to_owned() - })?; - let release = verify_application_release( - release_root, - application_manifest, - adapter_manifest, - "fastly", - 1, - )?; - let service_id = resolve_managed_plan_service_id(context, &release)?; - let token = FastlyApiToken(require_token()?); - if token.as_str().is_empty() { - return Err(format!( - "{FASTLY_API_TOKEN_ENV} must be non-empty in the environment" - )); - } - - let stores = RuntimeStoreIds::from(&context.stores); - for logical_id in &stores.config { - let key = environment - .store_key_checked(ResourceKind::Config.selector_kind(), logical_id) - .map_err(|error| format!("invalid Fastly deploy environment: {error}"))?; - validate_fastly_config_key(logical_id, &key, target == PublishTarget::Staging, false)?; - } - let cwd = release - .adapter_manifest() - .parent() - .ok_or_else(|| "verified Fastly manifest has no parent directory".to_owned())?; - let package_files_hash = compute_package_files_hash(release.package(), cwd, token.as_str())?; - - let raw_config_inventory = run_fastly_json_capture(&["config-store", "list", "--json"], cwd)?; - let config_inventory = parse_store_inventory(ResourceKind::Config, &raw_config_inventory)?; - let kv_inventory = fetch_complete_paginated_store_inventory( - ResourceKind::Kv, - "/resources/stores/kv", - token.as_str(), - )?; - let secret_inventory = fetch_complete_paginated_store_inventory( - ResourceKind::Secret, - "/resources/stores/secret", - token.as_str(), - )?; - let inventories = - ResourceInventories::from_inventories(config_inventory, kv_inventory, secret_inventory)?; - let desired_links = desired_resource_links(&stores, &environment, &inventories)?; - - let versions_raw = fastly_api_get(&format!("/service/{service_id}/version"), token.as_str())?; - let versions = parse_service_versions(&versions_raw)?; - let selected_source = select_version_source(&versions)?; - let source_version = match selected_source { - VersionSource::Active(version) - | VersionSource::InitialDraft(version) - | VersionSource::Retired(version) - | VersionSource::Staging(version) => version, - }; - let links_raw = run_fastly_json_capture( - &[ - "service", - "resource-link", - "list", - &format!("--service-id={service_id}"), - &format!("--version={source_version}"), - "--json", - ], - cwd, - )?; - let (source_links, links_snapshot) = parse_resource_links(&links_raw)?; - let source_configuration = - read_version_configuration_snapshot_for(&service_id, token.as_str(), source_version)?; - - let links = plan_link_reconciliation(&desired_links, &source_links, &inventories)?; - - let version_source = match selected_source { - VersionSource::Active(active_version) => { - EditableVersionSource::CloneActive { active_version } - } - VersionSource::InitialDraft(draft_version) => { - let version = versions - .iter() - .find(|version| version.number == draft_version) - .cloned() - .ok_or_else(|| { - format!("selected initial draft version {draft_version} disappeared") - })?; - let metadata = exact_version_metadata(&versions_raw, draft_version)?; - EditableVersionSource::InitialDraft(Box::new(snapshot_initial_draft( - version, - metadata, - links_snapshot, - source_configuration.clone(), - ))) - } - VersionSource::Retired(inactive_version) | VersionSource::Staging(inactive_version) => { - let version = versions - .iter() - .find(|version| version.number == inactive_version) - .cloned() - .ok_or_else(|| { - format!("selected inactive version {inactive_version} disappeared") - })?; - let metadata = exact_version_metadata(&versions_raw, inactive_version)?; - let snapshot = Box::new(InactiveSourceSnapshot { - links: links_snapshot, - metadata, - version, - }); - if matches!(selected_source, VersionSource::Retired(_)) { - EditableVersionSource::CloneRetired(snapshot) - } else { - EditableVersionSource::CloneStaging(snapshot) - } - } - }; - let package_sha256 = release.package_sha256().to_owned(); - - Ok(ManagedDeployPlan { - arguments, - links, - package_files_hash, - package_sha256, - release, - service_id, - source_configuration, - source_links, - target, - token, - version_source, - }) -} - -fn deploy_managed_with_context( - context: &AdapterDeployContext, - args: &[String], -) -> Result<(), String> { - let plan = build_managed_deploy_plan(context, args)?; - execute_managed_deploy_plan(&plan) -} - -fn execute_managed_deploy_plan(plan: &ManagedDeployPlan) -> Result<(), String> { - execute_managed_deploy_plan_with_emit(plan, &mut |line| log::info!("{line}")) -} - -fn execute_managed_deploy_plan_with_emit( - plan: &ManagedDeployPlan, - emit: &mut dyn FnMut(&str), -) -> Result<(), String> { - let cwd = plan - .release - .adapter_manifest() - .parent() - .ok_or_else(|| "verified Fastly manifest has no parent directory".to_owned())?; - emit(&format!("package-sha256={}", plan.package_sha256)); - let version = prepare_managed_version(plan, cwd, emit)?; - - if let Some(comment) = plan.arguments.comment.as_deref() { - run_fastly_status( - &[ - "service".to_owned(), - "version".to_owned(), - "update".to_owned(), - format!("--service-id={}", plan.service_id), - format!("--version={version}"), - format!("--comment={comment}"), - ], - cwd, - )?; - } - - let inherited = read_version_links(&plan.service_id, version, cwd)?; - require_same_link_resources(&plan.source_links, &inherited, "inherited draft")?; - let delete_identities = planned_delete_identities(plan)?; - let expected_links = expected_final_link_resources(plan, &delete_identities)?; - let inherited_by_identity = links_by_identity(&inherited)?; - for identity in &delete_identities { - let link = inherited_by_identity.get(identity).ok_or_else(|| { - format!( - "planned stale Fastly {} resource link `{}` is absent from the draft", - identity.0.display_name(), - identity.1 - ) - })?; - run_fastly_status( - &[ - "service".to_owned(), - "resource-link".to_owned(), - "delete".to_owned(), - format!("--service-id={}", plan.service_id), - format!("--version={version}"), - format!("--id={}", link.link_id), - ], - cwd, - )?; - } - for link in &plan.links.create { - run_fastly_status( - &[ - "service".to_owned(), - "resource-link".to_owned(), - "create".to_owned(), - format!("--service-id={}", plan.service_id), - format!("--version={version}"), - format!("--resource-id={}", link.resource_id), - format!("--name={}", link.alias), - ], - cwd, - )?; - } - let reconciled = read_version_links(&plan.service_id, version, cwd)?; - require_exact_link_resources(&expected_links, &reconciled, "reconciled draft")?; - - // Capture the versioned Compute configuration only after EdgeZero has - // finished every intended mutation, then compare it again in the immediate - // publication barrier below. Package identity and resource links have - // dedicated checks alongside this snapshot. - let expected_configuration = read_version_configuration_snapshot(plan, version)?; - if expected_configuration != plan.source_configuration { - return Err(format!( - "Fastly version {version} protected Compute configuration no longer matches its source" - )); - } - - revalidate_managed_draft(plan, version, &expected_links, &expected_configuration, cwd)?; - - match plan.target { - PublishTarget::Staging => run_fastly_status( - &[ - "service".to_owned(), - "version".to_owned(), - "stage".to_owned(), - format!("--service-id={}", plan.service_id), - format!("--version={version}"), - ], - cwd, - ), - PublishTarget::Production => fastly_api_put( - &format!("/service/{}/version/{version}/activate", plan.service_id), - plan.token.as_str(), - ) - .map(|_status| ()), - } -} - -struct CapturedFastlyCommand { - combined: String, - status: String, - success: bool, -} - -fn prepare_managed_version( - plan: &ManagedDeployPlan, - cwd: &Path, - emit: &mut dyn FnMut(&str), -) -> Result { - let package = plan.release.package().to_str().ok_or_else(|| { - "verified Fastly package path is not valid UTF-8 and cannot be passed to the Fastly CLI" - .to_owned() - })?; - let version = match &plan.version_source { - EditableVersionSource::CloneActive { active_version } => { - revalidate_active_source(plan, *active_version, cwd, None)?; - clone_managed_source(plan, *active_version, cwd, emit)? - } - EditableVersionSource::CloneRetired(snapshot) - | EditableVersionSource::CloneStaging(snapshot) => { - revalidate_inactive_source(plan, snapshot, cwd, None)?; - require_source_configuration(plan, snapshot.version.number)?; - clone_managed_source(plan, snapshot.version.number, cwd, emit)? - } - EditableVersionSource::InitialDraft(snapshot) => { - revalidate_initial_draft_before_update(plan, snapshot, cwd)?; - let version = snapshot.version.number; - emit(&format!("version={version}")); - version - } - }; - let mut update = vec![ - "compute".to_owned(), - "update".to_owned(), - format!("--service-id={}", plan.service_id), - format!("--version={version}"), - ]; - update.push(format!("--package={package}")); - update.extend(plan.arguments.globals.iter().cloned()); - if !has_non_interactive(&plan.arguments.globals) { - update.push("--non-interactive".to_owned()); - } - - let outcome = run_fastly_capture_outcome(&update, cwd)?; - let reported_version = parse_fastly_version(&outcome.combined); - if !outcome.success { - let redacted_output = outcome.combined.replace(plan.token.as_str(), "[REDACTED]"); - return Err(format!( - "`fastly {}` exited with status {}\n{}", - update.join(" "), - outcome.status, - redacted_output.trim() - )); - } - - if let Some(reported) = reported_version - && reported != version - { - return Err(format!( - "Fastly updated version {reported}, but the verified draft was version {version}" - )); - } - Ok(version) -} - -fn clone_managed_source( - plan: &ManagedDeployPlan, - source_version: u64, - cwd: &Path, - emit: &mut dyn FnMut(&str), -) -> Result { - let raw = fastly_api_put_capture( - &format!( - "/service/{}/version/{source_version}/clone", - plan.service_id - ), - plan.token.as_str(), - )?; - let version = parse_cloned_version(&raw, &plan.service_id, source_version)?; - emit(&format!("version={version}")); - - match &plan.version_source { - EditableVersionSource::CloneActive { active_version } => { - revalidate_active_source(plan, *active_version, cwd, Some(version))?; - } - EditableVersionSource::CloneRetired(snapshot) - | EditableVersionSource::CloneStaging(snapshot) => { - revalidate_inactive_source(plan, snapshot, cwd, Some(version))?; - require_source_configuration(plan, snapshot.version.number)?; - } - EditableVersionSource::InitialDraft(_) => { - return Err("internal managed deploy clone source mismatch".to_owned()); - } - } - - let versions_raw = fastly_api_get( - &format!("/service/{}/version", plan.service_id), - plan.token.as_str(), - )?; - let versions = parse_service_versions(&versions_raw)?; - let draft = versions - .iter() - .find(|candidate| candidate.number == version) - .ok_or_else(|| format!("cloned Fastly draft version {version} is absent"))?; - if draft.active || draft.locked || !draft.environments.is_empty() { - return Err(format!( - "cloned Fastly version {version} is not an unpublished editable draft" - )); - } - let cloned_links = read_version_links(&plan.service_id, version, cwd)?; - require_same_link_resources(&plan.source_links, &cloned_links, "fresh clone")?; - let cloned_configuration = read_version_configuration_snapshot(plan, version)?; - if cloned_configuration != plan.source_configuration { - return Err(format!( - "cloned Fastly version {version} does not match the preflight source configuration" - )); - } - Ok(version) -} - -fn parse_cloned_version(raw: &str, service_id: &str, source_version: u64) -> Result { - let value: serde_json::Value = serde_json::from_str(raw) - .map_err(|_error| "Fastly clone response is malformed".to_owned())?; - let object = value - .as_object() - .ok_or_else(|| "Fastly clone response must be one JSON object".to_owned())?; - let cloned_service = object - .get("service_id") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| "Fastly clone response has no service_id".to_owned())?; - let version = object - .get("number") - .and_then(serde_json::Value::as_u64) - .ok_or_else(|| "Fastly clone response has no numeric version".to_owned())?; - if cloned_service != service_id || version == source_version { - return Err( - "Fastly clone response does not identify a new version for the requested service" - .to_owned(), - ); - } - Ok(version) -} - -fn run_fastly_capture_outcome( - fastly_args: &[String], - cwd: &Path, -) -> Result { - let output = Command::new("fastly") - .args(fastly_args) - .current_dir(cwd) - .output() - .map_err(|error| { - if error.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to run fastly CLI: {error}") - } - })?; - let mut combined = String::from_utf8_lossy(&output.stdout).into_owned(); - combined.push_str(&String::from_utf8_lossy(&output.stderr)); - Ok(CapturedFastlyCommand { - combined, - status: output.status.to_string(), - success: output.status.success(), - }) -} - -fn is_canonical_sha512_hex(value: &str) -> bool { - value.len() == 128 - && value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) -} - -fn parse_package_files_hash_output(output: &str) -> Result { - let hashes = output - .lines() - .map(str::trim) - .filter(|line| is_canonical_sha512_hex(line)) - .collect::>(); - if hashes.len() != 1 { - return Err( - "Fastly package hash command did not return one unambiguous SHA-512 files hash" - .to_owned(), - ); - } - hashes.into_iter().next().map(str::to_owned).ok_or_else(|| { - "Fastly package hash command did not return one unambiguous SHA-512 files hash".to_owned() - }) -} - -fn compute_package_files_hash(package: &Path, cwd: &Path, token: &str) -> Result { - let package_path = package.to_str().ok_or_else(|| { - "verified Fastly package path is not valid UTF-8 and cannot be hashed by the Fastly CLI" - .to_owned() - })?; - let outcome = run_fastly_capture_outcome( - &[ - "compute".to_owned(), - "hash-files".to_owned(), - format!("--package={package_path}"), - "--skip-build".to_owned(), - "--non-interactive".to_owned(), - "--quiet".to_owned(), - ], - cwd, - )?; - if !outcome.success { - let redacted = outcome.combined.replace(token, "[REDACTED]"); - return Err(format!( - "Fastly package hash command exited with status {}\n{}", - outcome.status, - redacted.trim() - )); - } - parse_package_files_hash_output(&outcome.combined) -} - -fn parse_package_metadata_files_hash( - raw: &str, - expected_service_id: &str, - expected_version: u64, -) -> Result { - let package: ComputePackageRecord = serde_json::from_str(raw) - .map_err(|_error| "Fastly package metadata response is malformed".to_owned())?; - if package.service_id != expected_service_id || package.version != expected_version { - return Err( - "Fastly package metadata does not identify the requested service version".to_owned(), - ); - } - if !is_canonical_sha512_hex(&package.metadata.files_hash) { - return Err("Fastly package metadata contains an invalid files hash".to_owned()); - } - Ok(package.metadata.files_hash) -} - -fn read_version_links( - service_id: &str, - version: u64, - cwd: &Path, -) -> Result, String> { - read_version_links_with_snapshot(service_id, version, cwd).map(|(links, _snapshot)| links) -} - -fn read_version_links_with_snapshot( - service_id: &str, - version: u64, - cwd: &Path, -) -> Result<(Vec, serde_json::Value), String> { - let service_arg = format!("--service-id={service_id}"); - let version_arg = format!("--version={version}"); - let raw = run_fastly_json_capture( - &[ - "service", - "resource-link", - "list", - &service_arg, - &version_arg, - "--json", - ], - cwd, - )?; - parse_resource_links(&raw) -} - -type ResourceLinkIdentity = (ResourceKind, String); - -fn links_by_identity( - links: &[ExistingResourceLink], -) -> Result, String> { - let mut by_identity = BTreeMap::new(); - let mut ids = BTreeSet::new(); - for link in links { - if !ids.insert(link.link_id.as_str()) { - return Err(format!( - "Fastly resource-link inventory contains duplicate link id `{}`", - link.link_id - )); - } - let identity = (link.kind, link.alias.clone()); - if by_identity.insert(identity, link.clone()).is_some() { - return Err(format!( - "Fastly resource-link inventory contains duplicate {} alias `{}`", - link.kind.display_name(), - link.alias - )); - } - } - Ok(by_identity) -} - -fn link_resource_map( - links: &[ExistingResourceLink], -) -> Result, String> { - Ok(links_by_identity(links)? - .into_iter() - .map(|(identity, link)| (identity, link.resource_id)) - .collect()) -} - -fn require_same_link_resources( - expected: &[ExistingResourceLink], - actual: &[ExistingResourceLink], - label: &str, -) -> Result<(), String> { - let expected_resources = link_resource_map(expected)?; - require_exact_link_resources(&expected_resources, actual, label) -} - -fn require_exact_link_resources( - expected: &BTreeMap, - actual: &[ExistingResourceLink], - label: &str, -) -> Result<(), String> { - let actual_resources = link_resource_map(actual)?; - if actual_resources == *expected { - Ok(()) - } else { - Err(format!( - "Fastly {label} resource links changed unexpectedly; refusing to publish" - )) - } -} - -fn planned_delete_identities( - plan: &ManagedDeployPlan, -) -> Result, String> { - let by_id = plan - .source_links - .iter() - .map(|link| (link.link_id.as_str(), (link.kind, link.alias.as_str()))) - .collect::>(); - plan.links - .delete_link_ids - .iter() - .map(|id| { - by_id - .get(id.as_str()) - .map(|(kind, alias)| (*kind, (*alias).to_owned())) - .ok_or_else(|| { - format!("planned Fastly resource-link deletion `{id}` has no source link") - }) - }) - .collect() -} - -fn expected_final_link_resources( - plan: &ManagedDeployPlan, - delete_identities: &BTreeSet, -) -> Result, String> { - let mut expected = link_resource_map(&plan.source_links)?; - for identity in delete_identities { - if expected.remove(identity).is_none() { - return Err(format!( - "planned stale Fastly {} resource-link alias `{}` has no source link", - identity.0.display_name(), - identity.1 - )); - } - } - for link in &plan.links.create { - let identity = (link.kind, link.alias.clone()); - if expected - .insert(identity, link.resource_id.clone()) - .is_some() - { - return Err(format!( - "planned Fastly {} resource-link creation `{}` collides with an inherited identity", - link.kind.display_name(), - link.alias - )); - } - } - Ok(expected) -} - -fn revalidate_initial_draft_before_update( - plan: &ManagedDeployPlan, - snapshot: &InitialDraftSnapshot, - cwd: &Path, -) -> Result<(), String> { - let versions_raw = fastly_api_get( - &format!("/service/{}/version", plan.service_id), - plan.token.as_str(), - )?; - let versions = parse_service_versions(&versions_raw)?; - if select_version_source(&versions)? != VersionSource::InitialDraft(snapshot.version.number) { - return Err("Fastly initial draft selection changed after preflight".to_owned()); - } - let current = versions - .iter() - .find(|version| version.number == snapshot.version.number) - .ok_or_else(|| "Fastly initial draft disappeared after preflight".to_owned())?; - if current != &snapshot.version - || exact_version_metadata(&versions_raw, snapshot.version.number)? != snapshot.metadata - { - return Err("Fastly initial draft metadata changed after preflight".to_owned()); - } - let service_id = &plan.service_id; - let version = snapshot.version.number; - let (_current_links, links_value) = read_version_links_with_snapshot(service_id, version, cwd)?; - if links_value != snapshot.links { - return Err("Fastly initial draft links changed after preflight".to_owned()); - } - require_protected_initial_snapshot(plan, snapshot)?; - Ok(()) -} - -fn revalidate_active_source( - plan: &ManagedDeployPlan, - active_version: u64, - cwd: &Path, - excluded_target: Option, -) -> Result<(), String> { - let versions_raw = fastly_api_get( - &format!("/service/{}/version", plan.service_id), - plan.token.as_str(), - )?; - let versions = parse_service_versions(&versions_raw)?; - let source_versions = source_versions_excluding(&versions, excluded_target); - if select_version_source(&source_versions)? != VersionSource::Active(active_version) { - return Err("Fastly active source selection changed after preflight".to_owned()); - } - let source = source_versions - .iter() - .find(|version| version.number == active_version) - .ok_or_else(|| "Fastly active source disappeared after preflight".to_owned())?; - if !source.locked { - return Err("Fastly active source is unexpectedly editable".to_owned()); - } - let current_links = read_version_links(&plan.service_id, active_version, cwd)?; - require_same_link_resources(&plan.source_links, ¤t_links, "active source")?; - require_source_configuration(plan, active_version) -} - -fn require_source_configuration( - plan: &ManagedDeployPlan, - source_version: u64, -) -> Result<(), String> { - let current = read_version_configuration_snapshot(plan, source_version)?; - if current == plan.source_configuration { - Ok(()) - } else { - Err(format!( - "Fastly source version {source_version} protected Compute configuration changed after preflight" - )) - } -} - -fn revalidate_inactive_source( - plan: &ManagedDeployPlan, - snapshot: &InactiveSourceSnapshot, - cwd: &Path, - excluded_target: Option, -) -> Result<(), String> { - let versions_raw = fastly_api_get( - &format!("/service/{}/version", plan.service_id), - plan.token.as_str(), - )?; - let versions = parse_service_versions(&versions_raw)?; - let source_versions = source_versions_excluding(&versions, excluded_target); - let expected = match &plan.version_source { - EditableVersionSource::CloneRetired(_) => VersionSource::Retired(snapshot.version.number), - EditableVersionSource::CloneStaging(_) => VersionSource::Staging(snapshot.version.number), - EditableVersionSource::CloneActive { .. } | EditableVersionSource::InitialDraft(_) => { - return Err("internal managed deploy inactive source mismatch".to_owned()); - } - }; - if select_version_source(&source_versions)? != expected { - return Err("Fastly inactive source selection changed after preflight".to_owned()); - } - let current = versions - .iter() - .find(|version| version.number == snapshot.version.number) - .ok_or_else(|| "Fastly inactive source disappeared after preflight".to_owned())?; - if current != &snapshot.version - || exact_version_metadata(&versions_raw, snapshot.version.number)? != snapshot.metadata - { - return Err("Fastly inactive source metadata changed after preflight".to_owned()); - } - let (_current_links, links_value) = - read_version_links_with_snapshot(&plan.service_id, snapshot.version.number, cwd)?; - if links_value != snapshot.links { - return Err("Fastly inactive source links changed after preflight".to_owned()); - } - Ok(()) -} - -fn require_protected_initial_snapshot( - plan: &ManagedDeployPlan, - snapshot: &InitialDraftSnapshot, -) -> Result<(), String> { - let current = read_version_configuration_snapshot(plan, snapshot.version.number)?; - if current == snapshot.configuration { - Ok(()) - } else { - Err("Fastly initial draft protected configuration changed after preflight".to_owned()) - } -} - -fn revalidate_managed_draft( - plan: &ManagedDeployPlan, - version: u64, - expected_links: &BTreeMap, - expected_configuration: &VersionConfigurationSnapshot, - cwd: &Path, -) -> Result<(), String> { - let versions_raw = fastly_api_get( - &format!("/service/{}/version", plan.service_id), - plan.token.as_str(), - )?; - let versions = parse_service_versions(&versions_raw)?; - let source_state = match &plan.version_source { - EditableVersionSource::CloneActive { active_version } => { - VersionSource::Active(*active_version) - } - EditableVersionSource::CloneRetired(snapshot) => { - VersionSource::Retired(snapshot.version.number) - } - EditableVersionSource::CloneStaging(snapshot) => { - VersionSource::Staging(snapshot.version.number) - } - EditableVersionSource::InitialDraft(snapshot) => { - VersionSource::InitialDraft(snapshot.version.number) - } - }; - require_managed_draft_state(source_state, &versions, version)?; - match &plan.version_source { - EditableVersionSource::CloneRetired(snapshot) - | EditableVersionSource::CloneStaging(snapshot) => { - revalidate_inactive_source(plan, snapshot, cwd, Some(version))?; - } - EditableVersionSource::CloneActive { .. } | EditableVersionSource::InitialDraft(_) => {} - } - let links = read_version_links(&plan.service_id, version, cwd)?; - require_exact_link_resources(expected_links, &links, "final draft")?; - let package_raw = fastly_api_get( - &format!("/service/{}/version/{version}/package", plan.service_id), - plan.token.as_str(), - )?; - let package_files_hash = - parse_package_metadata_files_hash(&package_raw, &plan.service_id, version)?; - require_package_identity(&plan.package_files_hash, &package_files_hash, version)?; - let current_configuration = read_version_configuration_snapshot(plan, version)?; - require_draft_configuration(expected_configuration, ¤t_configuration, version) -} - -fn source_versions_excluding( - versions: &[ServiceVersionRecord], - excluded_target: Option, -) -> Vec { - versions - .iter() - .filter(|version| Some(version.number) != excluded_target) - .cloned() - .collect() -} - -fn require_managed_draft_state( - source: VersionSource, - versions: &[ServiceVersionRecord], - version: u64, -) -> Result<(), String> { - let draft = versions - .iter() - .find(|candidate| candidate.number == version) - .ok_or_else(|| format!("Fastly draft version {version} disappeared before publication"))?; - if draft.active || draft.locked || !draft.environments.is_empty() { - return Err(format!( - "Fastly version {version} is no longer an unpublished editable draft" - )); - } - - let active_versions = versions - .iter() - .filter(|candidate| candidate.active) - .map(|candidate| candidate.number) - .collect::>(); - match source { - VersionSource::Active(active_version) if active_versions != [active_version] => { - return Err("Fastly active version changed before publication".to_owned()); - } - VersionSource::Active(_) => {} - _ if !active_versions.is_empty() => { - return Err("Fastly active version appeared before publication".to_owned()); - } - VersionSource::Retired(source_version) if version == source_version => { - return Err("managed Fastly retired clone version did not advance".to_owned()); - } - VersionSource::Staging(source_version) if version == source_version => { - return Err("managed Fastly staged clone version did not advance".to_owned()); - } - VersionSource::InitialDraft(source_version) if version != source_version => { - return Err("managed Fastly initial draft version changed".to_owned()); - } - VersionSource::Retired(_) | VersionSource::Staging(_) | VersionSource::InitialDraft(_) => {} - } - Ok(()) -} - -fn require_package_identity(expected: &str, actual: &str, version: u64) -> Result<(), String> { - if actual == expected { - Ok(()) - } else { - Err(format!( - "Fastly version {version} package identity changed before publication" - )) - } -} - -fn require_draft_configuration( - expected: &VersionConfigurationSnapshot, - actual: &VersionConfigurationSnapshot, - version: u64, -) -> Result<(), String> { - if actual == expected { - Ok(()) - } else { - Err(format!( - "Fastly version {version} protected Compute configuration changed before publication" - )) - } -} - -fn read_version_configuration_snapshot( - plan: &ManagedDeployPlan, - version: u64, -) -> Result { - read_version_configuration_snapshot_for(&plan.service_id, plan.token.as_str(), version) -} - -fn read_version_configuration_snapshot_for( - service_id: &str, - token: &str, - version: u64, -) -> Result { - let base = format!("/service/{service_id}/version/{version}"); - let domains = parse_snapshot_array( - "domains", - &fastly_api_get(&format!("{base}/domain"), token)?, - )?; - let backends = parse_snapshot_array( - "backends", - &fastly_api_get(&format!("{base}/backend"), token)?, - )?; - let healthchecks = parse_snapshot_array( - "health checks", - &fastly_api_get(&format!("{base}/healthcheck"), token)?, - )?; - let logging = snapshot_logging_providers(&base, token)?; - let settings = parse_snapshot_object( - "settings", - &fastly_api_get(&format!("{base}/settings"), token)?, - )?; - Ok(VersionConfigurationSnapshot { - backends, - domains, - healthchecks, - logging, - settings, - }) -} - -fn resolve_managed_plan_service_id( - context: &AdapterDeployContext, - release: &VerifiedApplicationRelease, -) -> Result { - if let Some(service_id) = context.service_id.as_deref() { - validate_service_id(service_id)?; - return Ok(service_id.to_owned()); - } - effective_fastly_service_id(release.adapter_manifest())? - .map(|selected| selected.id) - .ok_or_else(|| { - format!( - "managed Fastly deployment requires a service id in typed context, the verified Fastly manifest, or {FASTLY_SERVICE_ID_ENV}" - ) - }) -} - -fn run_fastly_json_capture(args: &[&str], cwd: &Path) -> Result { - let output = Command::new("fastly") - .args(args) - .current_dir(cwd) - .output() - .map_err(|error| { - if error.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to run read-only Fastly command: {error}") - } - })?; - if !output.status.success() { - return Err(format!( - "read-only `fastly {}` exited with status {}\nstderr: {}", - args.join(" "), - output.status, - redact_stderr(&String::from_utf8_lossy(&output.stderr)) - )); - } - strict_stdout(output.stdout, "read-only Fastly JSON command") -} - -fn parse_resource_links( - raw: &str, -) -> Result<(Vec, serde_json::Value), String> { - let snapshot: serde_json::Value = serde_json::from_str(raw) - .map_err(|error| format!("failed to parse Fastly resource-link inventory: {error}"))?; - let rows = snapshot.as_array().ok_or_else(|| { - "Fastly resource-link inventory must be one complete bare JSON array".to_owned() - })?; - let mut links = Vec::with_capacity(rows.len()); - for (index, row) in rows.iter().enumerate() { - let object = row.as_object().ok_or_else(|| { - format!("Fastly resource-link inventory record #{index} is not an object") - })?; - let required = |field: &str| { - object - .get(field) - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_owned) - .ok_or_else(|| { - format!( - "Fastly resource-link inventory record #{index} requires non-empty string `{field}`" - ) - }) - }; - let resource_type = required("resource_type")?; - let kind = match resource_type.as_str() { - "config" => ResourceKind::Config, - "kv-store" => ResourceKind::Kv, - "secret-store" => ResourceKind::Secret, - _ => { - return Err(format!( - "Fastly resource-link inventory record #{index} has unknown `resource_type` `{resource_type}`" - )); - } - }; - links.push(ExistingResourceLink { - link_id: required("id")?, - alias: required("name")?, - kind, - resource_id: required("resource_id")?, - }); - } - Ok((links, snapshot)) -} - -fn snapshot_initial_draft( - version: ServiceVersionRecord, - metadata: serde_json::Value, - links: serde_json::Value, - configuration: VersionConfigurationSnapshot, -) -> InitialDraftSnapshot { - InitialDraftSnapshot { - configuration, - links, - metadata, - version, - } -} - -fn snapshot_logging_providers( - version_base: &str, - token: &str, -) -> Result, String> { - FASTLY_LOGGING_PROVIDER_KINDS - .iter() - .map(|&kind| { - let endpoints = parse_snapshot_array( - &format!("logging/{kind}"), - &fastly_api_get(&format!("{version_base}/logging/{kind}"), token)?, - )?; - Ok(LoggingProviderSnapshot { endpoints, kind }) - }) - .collect() -} - -fn exact_version_metadata(raw: &str, number: u64) -> Result { - let value: serde_json::Value = serde_json::from_str(raw) - .map_err(|error| format!("failed to preserve Fastly version metadata: {error}"))?; - value - .as_array() - .and_then(|versions| { - versions.iter().find(|version| { - version.get("number").and_then(serde_json::Value::as_u64) == Some(number) - }) - }) - .cloned() - .ok_or_else(|| format!("selected Fastly version {number} has no exact metadata record")) -} - -fn parse_snapshot_array(label: &str, raw: &str) -> Result { - let mut value: serde_json::Value = serde_json::from_str(raw) - .map_err(|error| format!("failed to parse Fastly {label} snapshot: {error}"))?; - let rows = value - .as_array_mut() - .ok_or_else(|| format!("Fastly {label} snapshot must be a complete JSON array"))?; - for (index, row) in rows.iter_mut().enumerate() { - let object = row - .as_object_mut() - .ok_or_else(|| format!("Fastly {label} snapshot record #{index} must be an object"))?; - remove_snapshot_metadata(object); - row.sort_all_objects(); - } - rows.sort_by_cached_key(ToString::to_string); - Ok(value) -} - -fn remove_snapshot_metadata(object: &mut serde_json::Map) { - for field in [ - "created_at", - "locked", - "service_id", - "updated_at", - "version", - ] { - object.remove(field); - } -} - -fn parse_snapshot_object(label: &str, raw: &str) -> Result { - let mut value: serde_json::Value = serde_json::from_str(raw) - .map_err(|error| format!("failed to parse Fastly {label} snapshot: {error}"))?; - let object = value - .as_object_mut() - .ok_or_else(|| format!("Fastly {label} snapshot must be a complete JSON object"))?; - remove_snapshot_metadata(object); - value.sort_all_objects(); - Ok(value) -} - -/// 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), -} - -#[derive(Clone, Copy, Debug)] -enum FastlyServiceIdSource { - Environment, - Manifest, -} - -#[derive(Debug)] -struct SelectedFastlyService { - id: String, - source: FastlyServiceIdSource, -} - -/// 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, -} - -struct EntryCommitFailure { - committed: Vec, - error: String, - failed_key: String, - not_attempted: Vec, - total: usize, -} - -#[derive(Debug, Default, PartialEq, Eq)] -struct RuntimeStoreIds { - config: Vec, - kv: Vec, - secrets: Vec, -} - -impl From<&DeployStoreIds> for RuntimeStoreIds { - fn from(stores: &DeployStoreIds) -> Self { - Self { - config: stores.config.clone(), - kv: stores.kv.clone(), - secrets: stores.secrets.clone(), - } - } -} - -fn owns_managed_deploy(context: &AdapterDeployContext) -> bool { - context.application_release_root.is_some() || context.staging || !context.stores.is_empty() -} - -// 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 deploy(&self, context: &AdapterDeployContext, args: &[String]) -> Result<(), String> { - if owns_managed_deploy(context) { - deploy_managed_with_context(context, args) - } else { - validate_effective_deploy_service_id(context)?; - scan_reserved_deploy_args(args)?; - deploy_with_context(context, args) - } - } - - 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), - AdapterAction::DeployStaging => Err( - "Fastly staging requires typed deploy context and --application-release".to_owned(), - ), - AdapterAction::EmitVersion => emit_active_version(args), - AdapterAction::Healthcheck => healthcheck(args), - AdapterAction::Rollback => rollback(args), - other => Err(format!("fastly adapter does not support {other:?}")), - } - } - - fn finalize_deploy( - &self, - context: &AdapterDeployContext, - command_output: Option<&str>, - ) -> Result<(), String> { - if context.staging { - return Ok(()); - } - - let Some(service_id) = context.service_id.as_deref() else { - return Ok(()); - }; - validate_service_id(service_id)?; - if let Some(version) = command_output.and_then(parse_fastly_version) { - let token = require_token()?; - verify_version_active( - service_id, - version, - &token, - "after manifest-command deployment", - )?; - log::info!("version={version}"); - return Ok(()); - } - emit_active_version_for(service_id, true).map_err(|error| { - format!( - "deploy succeeded but the activated version could not be resolved from the deploy output or Fastly API: {error}" - ) - }) - } - - 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 validate_config_key_for_target( - &self, - logical_store_id: &str, - key: &str, - staging: bool, - local: bool, - ) -> Result<(), String> { - validate_fastly_config_key(logical_store_id, key, staging, local) - } - - fn preflight_deploy( - &self, - context: &AdapterDeployContext, - args: &[String], - ) -> Result { - validate_effective_deploy_service_id(context)?; - if owns_managed_deploy(context) { - parse_release_managed_deploy_args(args)?; - Ok(DeployOwnership::AdapterManaged) - } else { - scan_reserved_deploy_args(args)?; - Ok(DeployOwnership::ManifestCommand) - } - } - - 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 selected physical resource - // is created through the Fastly CLI. A default logical/physical mapping - // can be represented by `[setup]`; a distinct physical name requires an - // explicit service resource link under the logical alias. - 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 manifest_dir = fastly_path.parent().unwrap_or(manifest_root); - let selected_service = effective_fastly_service_id(&fastly_path)?; - - // Fastly's `[setup._stores.]` key is both the physical - // resource name and the package-visible resource-link alias. It cannot - // express "physical A under logical alias B". That mapping needs an - // existing service so the operator can create an explicit resource link. - if selected_service.is_none() { - for store in stores.kv.iter().chain(stores.config).chain(stores.secrets) { - if store.logical != store.platform { - return Err(format!( - "Fastly store `{physical}` is selected for logical id `{logical}`, but no service is selected. Fastly setup cannot bind a physical store under a different logical alias. Set `service_id` in fastly.toml or `FASTLY_SERVICE_ID` before provisioning this mapping, or remove the selector so both names are `{logical}`.", - physical = store.platform, - logical = store.logical, - )); - } - } - } - let mut out = Vec::new(); - for (kind, ids) in [ - ("kv", stores.kv), - ("config", stores.config), - ("secret", stores.secrets), - ] { - for store in ids { - let logical = store.logical.as_str(); - let name = store.platform.as_str(); - let setup_can_represent_mapping = logical == name; - if dry_run { - if setup_can_represent_mapping { - out.push(format!( - "would run `fastly {kind}-store create --name={name}` and append [setup.{kind}_stores.{logical}] to {}", - fastly_path.display() - )); - } else { - out.push(format!( - "would run `fastly {kind}-store create --name={name}` for logical id `{logical}`; the selected service requires an explicit resource link under alias `{logical}`" - )); - } - continue; - } - if setup_can_represent_mapping && setup_block_present(&fastly_path, kind, logical)? - { - out.push(format!( - "fastly {kind}-store `{name}` already declared in {}; skipping. To force a fresh remote: delete the [setup.{kind}_stores.{logical}] 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_in(kind, name, manifest_dir)?; - if setup_can_represent_mapping { - // If the platform store was created but writeback fails, - // surface the recovery path. Resource creation is - // idempotent, so the operator can safely re-run provision. - append_fastly_setup(&fastly_path, kind, logical).map_err(|err| { - format!( - "fastly {kind}-store `{name}` was created remotely, but writeback to {path} failed: {err}\n To recover, either:\n 1. Manually append `[setup.{kind}_stores.{logical}]` 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(selected_service.as_ref(), kind, name, logical); - let mut line = if setup_can_represent_mapping { - format!( - "created fastly {kind}-store `{name}`; appended [setup.{kind}_stores.{logical}] to {}", - fastly_path.display() - ) - } else { - format!( - "created fastly {kind}-store `{name}` for logical id `{logical}`; fastly.toml was not changed because `[setup]` cannot express a distinct physical resource name" - ) - }; - 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()); - } - 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.logical.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.logical.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; - } -} - -#[expect( - clippy::missing_trait_methods, - reason = "`Drop::pin_drop` is unstable (`pin_ergonomics`) and conflicts with `drop`" -)] -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 -/// has already resolved and validated the selector. A provider response saying -/// that the same-kind store already exists is treated as idempotent success; -/// other failures retain the CLI diagnostic. -/// -/// # 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_in(kind: &str, name: &str, cwd: &Path) -> Result<(), String> { - let subcommand = format!("{kind}-store"); - let name_arg = format!("--name={name}"); - let mut command = Command::new("fastly"); - command - .args([subcommand.as_str(), "create", name_arg.as_str()]) - .current_dir(cwd); - let output = command.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. 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(|service_id| !service_id.is_empty()); - Ok(svc) -} - -fn select_fastly_service_id( - manifest_id: Option, - environment_id: Option, -) -> Result, String> { - if let Some(manifest) = manifest_id.as_deref() { - validate_service_id(manifest)?; - } - if let Some(environment) = environment_id.as_deref() { - validate_service_id(environment)?; - } - if let (Some(manifest), Some(environment)) = (&manifest_id, &environment_id) - && manifest != environment - { - return Err(format!( - "fastly.toml service_id `{manifest}` conflicts with {FASTLY_SERVICE_ID_ENV} `{environment}`; make them agree before provisioning" - )); - } - - let selected = match (manifest_id, environment_id) { - (Some(id), _) => Some(SelectedFastlyService { - id, - source: FastlyServiceIdSource::Manifest, - }), - (None, Some(id)) => Some(SelectedFastlyService { - id, - source: FastlyServiceIdSource::Environment, - }), - (None, None) => None, - }; - Ok(selected) -} - -fn effective_fastly_service_id(path: &Path) -> Result, String> { - let manifest_id = read_fastly_service_id(path)?; - let environment_id = match env::var(FASTLY_SERVICE_ID_ENV) { - Ok(id) => Some(id), - Err(env::VarError::NotPresent) => None, - Err(env::VarError::NotUnicode(_)) => { - return Err(format!( - "invalid service id from {FASTLY_SERVICE_ID_ENV}: expected ASCII letters and digits only" - )); - } - }; - select_fastly_service_id(manifest_id, environment_id) -} - -/// If a service is selected through fastly.toml or `FASTLY_SERVICE_ID`, the -/// next `fastly compute deploy` targets an existing service and skips `[setup]`. -/// Any store created by provision then needs a separate resource link. -fn resource_link_note( - selected: Option<&SelectedFastlyService>, - kind: &str, - physical_name: &str, - logical_alias: &str, -) -> Option { - selected.map(|service| { - let svc_id = &service.id; - let selection = match service.source { - FastlyServiceIdSource::Manifest => { - format!("fastly.toml declares `service_id = \"{svc_id}\"`") - } - FastlyServiceIdSource::Environment => { - format!("`{FASTLY_SERVICE_ID_ENV}` selects service `{svc_id}`") - } - }; - format!( - " {selection}, 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=`{physical_name}`), then run:\n fastly service resource-link create --service-id={svc_id} --resource-id= --version=latest --autoclone --name={logical_alias}\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. -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 writing one root - // key does not wipe an unrelated sibling. Earlier wholesale replacement - // misread the "stale entries don't linger" property: that applies within - // one key, where old chunks become unreferenced after a new pointer is - // installed, 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 the common sequential commit mechanics while leaving recovery policy -/// to the operation that owns the writes. -fn commit_entries_with_committer( - entries: &[(String, String)], - mut committer: F, -) -> Result -where - F: FnMut(&str, &str) -> Result<(), String>, -{ - let mut written_keys = Vec::with_capacity(entries.len()); - for (index, (key, value)) in entries.iter().enumerate() { - if let Err(error) = committer(key, value) { - return Err(EntryCommitFailure { - committed: written_keys, - error, - failed_key: key.clone(), - not_attempted: entries - .iter() - .skip(index.saturating_add(1)) - .map(|(remaining_key, _)| remaining_key.clone()) - .collect(), - total: entries.len(), - }); - } - written_keys.push(key.clone()); - } - Ok(written_keys.len()) -} - -/// Commit config-push entries and retain its chunk-aware retry guidance. -fn push_entries_with_committer( - entries: &[(String, String)], - committer: F, -) -> Result -where - F: FnMut(&str, &str) -> Result<(), String>, -{ - commit_entries_with_committer(entries, committer).map_err(|failure| { - format!( - "fastly push failed at entry `{failed_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): {already_written:?}\n \ - Failed: `{failed_key}` (outcome unknown) -- {error}\n \ - Not attempted: {not_attempted:?}", - failed_key = failure.failed_key, - committed = failure.committed.len(), - total = failure.total, - already_written = failure.committed, - error = failure.error, - not_attempted = failure.not_attempted, - ) - }) -} - -/// 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> { - create_config_store_entry_with_cwd(store_id, key, value, None) -} - -fn create_config_store_entry_with_cwd( - store_id: &str, - key: &str, - value: &str, - cwd: Option<&Path>, -) -> Result<(), String> { - let store_arg = format!("--store-id={store_id}"); - let key_arg = format!("--key={key}"); - let mut command = Command::new("fastly"); - command.args([ - "config-store-entry", - "update", - store_arg.as_str(), - key_arg.as_str(), - "--upsert", - "--stdin", - ]); - if let Some(command_cwd) = cwd { - command.current_dir(command_cwd); - } - let mut child = command - .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> { - match classify_remote_config_store(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/IABTechLab/edgezero/issues with the fastly CLI version (`fastly version`) and the raw stdout. Workaround: pin to a known-compatible fastly CLI version." - )), - } -} - -/// Look a config store up by name and return the raw [`ConfigStoreLookup`], so -/// callers can tell "the account has no such store" (`NotFound`) apart from "the -/// lookup itself failed" (`Err` — CLI missing / non-zero exit — or -/// `SchemaDrift`). A staged deploy relies on that distinction to decide whether -/// to skip config isolation (genuinely no store) or fail closed (couldn't tell). -/// -/// `Err` is only for a failure to OBTAIN an answer; a successful listing that -/// simply doesn't contain `name` is `Ok(ConfigStoreLookup::NotFound)`. -fn classify_remote_config_store(name: &str) -> Result { - classify_remote_config_store_with_cwd(name, None) -} - -fn classify_remote_config_store_with_cwd( - name: &str, - cwd: Option<&Path>, -) -> Result { - let mut command = Command::new("fastly"); - command.args(["config-store", "list", "--json"]); - if let Some(command_cwd) = cwd { - command.current_dir(command_cwd); - } - let output = command.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() - )); - } - // Adopt main's strict UTF-8 gate (fail closed on undecodable stdout) but - // return the raw lookup so staging callers keep the 3-way verdict; the - // SchemaDrift -> Err mapping lives in resolve_remote_config_store_id. - let stdout = strict_stdout(output.stdout, "config-store list --json")?; - Ok(find_config_store_id(&stdout, name)) -} - -/// 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) -} - -/// Whether `args` already carries the Fastly CLI's non-interactive -/// switch, in either its long (`--non-interactive`) or short (`-i`) -/// form. Used to avoid passing the flag twice when a caller already -/// supplied it via `deploy-args` passthrough. -fn has_non_interactive(args: &[String]) -> bool { - args.iter() - .any(|arg| arg == "--non-interactive" || arg == "-i") -} - -/// Build the argv for `fastly compute deploy`, appending -/// `--non-interactive` (a Fastly CLI *global* flag, supported by -/// `compute deploy`) unless the caller already passed it. Without it a -/// production deploy can block on an interactive prompt in CI. -fn build_compute_deploy_args(extra_args: &[String]) -> Vec { - let mut argv = vec!["compute".to_owned(), "deploy".to_owned()]; - argv.extend_from_slice(extra_args); - if !has_non_interactive(extra_args) { - argv.push("--non-interactive".to_owned()); - } - argv -} - -/// Legacy direct entry point for callers using [`AdapterAction::Deploy`]. -/// `EdgeZero`'s main deploy path uses the typed [`Adapter::deploy`] hook. -/// -/// # Errors -/// Returns an error when the Fastly CLI cannot deploy the package. -#[inline] -pub fn deploy(extra_args: &[String]) -> Result<(), String> { - let context = legacy_deploy_context(extra_args, false); - deploy_with_context(&context, extra_args) -} - -fn deploy_with_context( - context: &AdapterDeployContext, - extra_args: &[String], -) -> Result<(), String> { - let manifest_path = resolve_deploy_manifest_path(context)?; - validate_deploy_service_id_for_manifest(context, &manifest_path)?; - let manifest_dir = manifest_path.parent().ok_or_else(|| { - format!( - "fastly manifest path {} has no parent directory", - manifest_path.display() - ) - })?; - let without_manifest = args_without_flag_value(extra_args, "--manifest-path"); - let mut forwarded = args_without_flag_value(&without_manifest, "--service-id"); - scan_reserved_deploy_args(&forwarded)?; - if let Some(service_id) = context.service_id.as_deref() { - forwarded.extend(["--service-id".to_owned(), service_id.to_owned()]); - } - - let status = Command::new("fastly") - .args(build_compute_deploy_args(&forwarded)) - .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(()) -} - -// =================================================================== -// Fastly lifecycle -// =================================================================== -// -// The adapter-managed deployment path verifies the immutable application release, -// uploads its recorded package with `compute update` to an exact unreachable draft, -// reconciles and reads back exact logical resource links and the package identity, and -// stages or activates only after verification. It emits `version=` and -// `package-sha256=`. The bare store-free production manifest command is a -// compatibility path outside this managed lifecycle. -// -// These entry points also back the `healthcheck` and `rollback` app-CLI subcommands: -// -// * healthcheck → curl the domain (production) or the version's -// resolved staging IP (`--staging`); non-zero exit when unhealthy. -// * rollback → activate the explicit `--rollback-to` version -// (production) or deactivate `` (staging) via the Fastly API. -// Rollback prints `rolled-back-to=`. -// -// Provider HTTP calls shell out to `curl` (matching the lifecycle action -// conventions and avoiding a WASM-incompatible HTTP client -// in the adapter). The `FASTLY_API_TOKEN` is passed to `curl` via a -// `--config -` stdin file rather than on argv, so it never appears in -// `ps` / `/proc//cmdline` (same discipline as -// `create_config_store_entry`'s `--stdin`). - -/// Value that follows `flag` in a `--flag value` arg slice, if present. -fn arg_value<'args>(args: &'args [String], flag: &str) -> Option<&'args str> { - args.iter() - .position(|arg| arg == flag) - .and_then(|idx| idx.checked_add(1)) - .and_then(|idx| args.get(idx)) - .map(String::as_str) -} - -/// Whether a boolean `flag` (e.g. `--staging`) is present in `args`. -fn arg_flag(args: &[String], flag: &str) -> bool { - args.iter().any(|arg| arg == flag) -} - -/// Copy of `args` with `--flag value` removed (both tokens). Used to -/// forward operator passthrough (e.g. `--comment`) to `fastly compute -/// update` without re-passing `--service-id`, which is threaded -/// explicitly. -fn args_without_flag_value(args: &[String], flag: &str) -> Vec { - let mut out = Vec::with_capacity(args.len()); - let mut skip = false; - for arg in args { - if skip { - skip = false; - continue; - } - if arg == flag { - skip = true; - continue; - } - out.push(arg.clone()); - } - out -} - -fn scan_reserved_deploy_args(args: &[String]) -> Result<(), String> { - for arg in args { - let reserved_flag = match arg.as_str() { - "--service-id" | "-s" | "--service-name" | "--version" | "--autoclone" | "--token" - | "-t" => Some(arg.as_str()), - value if value.starts_with("--service-id=") => Some("--service-id"), - value if value.starts_with("--service-name=") => Some("--service-name"), - value if value.starts_with("--version=") => Some("--version"), - value if value.starts_with("--autoclone=") => Some("--autoclone"), - value if value.starts_with("--token=") => Some("--token"), - value if value.starts_with("-s") && !value.starts_with("--") && value.len() > 2 => { - Some("-s") - } - value if value.starts_with("-t") && !value.starts_with("--") && value.len() > 2 => { - Some("-t") - } - _ => None, - }; - if let Some(flag) = reserved_flag { - return Err(format!( - "Fastly deploy argument `{flag}` is reserved for the EdgeZero deployment lifecycle" - )); - } - } - Ok(()) -} - -fn set_managed_deploy_value( - slot: &mut Option, - flag: &str, - value: &str, -) -> Result<(), String> { - if value.is_empty() { - return Err(format!( - "Fastly deploy argument `{flag}` requires a non-empty value" - )); - } - if slot.is_some() { - return Err(format!( - "Fastly deploy argument `{flag}` may be provided only once" - )); - } - *slot = Some(value.to_owned()); - Ok(()) -} - -fn detached_managed_deploy_value<'args>( - args: &'args [String], - index: usize, - flag: &str, -) -> Result<&'args str, String> { - let value = args - .get(index.saturating_add(1)) - .ok_or_else(|| format!("Fastly deploy argument `{flag}` requires a value"))?; - if value.is_empty() || value.starts_with('-') { - return Err(format!( - "Fastly deploy argument `{flag}` requires a non-empty value" - )); - } - Ok(value) -} - -fn parse_release_managed_deploy_args(args: &[String]) -> Result { - scan_reserved_deploy_args(args)?; - let mut parsed = ReleaseManagedDeployArgs { - comment: None, - globals: Vec::new(), - }; - let mut index = 0; - while let Some(arg) = args.get(index) { - if arg == "--comment" { - let value = detached_managed_deploy_value(args, index, "--comment")?; - set_managed_deploy_value(&mut parsed.comment, "--comment", value)?; - index = index.saturating_add(2); - } else if let Some(value) = arg.strip_prefix("--comment=") { - set_managed_deploy_value(&mut parsed.comment, "--comment", value)?; - index = index.saturating_add(1); - } else if arg == "--package" - || arg == "-p" - || arg.starts_with("--package=") - || (arg.starts_with("-p") && arg.len() > 2) - { - return Err( - "Fastly deploy argument `--package/-p` is owned by the immutable application release" - .to_owned(), - ); - } else if MANAGED_DEPLOY_GLOBAL_BOOL_FLAGS.contains(&arg.as_str()) { - parsed.globals.push(arg.clone()); - index = index.saturating_add(1); - } else if let Some((flag, _)) = arg.split_once('=') - && MANAGED_DEPLOY_GLOBAL_BOOL_FLAGS.contains(&flag) - { - return Err(format!( - "Fastly boolean deploy argument `{flag}` does not accept a value" - )); - } else if arg.starts_with('-') { - return Err(format!("unsupported Fastly deploy argument {arg:?}")); - } else { - return Err(format!( - "unexpected positional Fastly deploy argument {arg:?}" - )); - } - } - Ok(parsed) -} - -/// Resolve the target service id from `--service-id` or, failing that, -/// `FASTLY_SERVICE_ID`. -fn resolve_service_id(args: &[String]) -> Result { - if let Some(value) = arg_value(args, "--service-id") { - return Ok(value.to_owned()); - } - env::var(FASTLY_SERVICE_ID_ENV).map_err(|_err| { - format!("no service id: pass `--service-id ` or set {FASTLY_SERVICE_ID_ENV}") - }) -} - -fn validate_effective_deploy_service_id(context: &AdapterDeployContext) -> Result<(), String> { - if let Some(service_id) = context.service_id.as_deref() { - return validate_service_id(service_id); - } - if let Some(manifest_path) = context.adapter_manifest_path.as_deref() { - return validate_deploy_service_id_for_manifest(context, manifest_path); - } - match env::var(FASTLY_SERVICE_ID_ENV) { - Ok(service_id) => validate_service_id(&service_id), - Err(env::VarError::NotPresent) => Ok(()), - Err(env::VarError::NotUnicode(_)) => Err(format!( - "invalid service id from {FASTLY_SERVICE_ID_ENV}: expected ASCII letters and digits only" - )), - } -} - -fn validate_deploy_service_id_for_manifest( - context: &AdapterDeployContext, - manifest_path: &Path, -) -> Result<(), String> { - if let Some(service_id) = context.service_id.as_deref() { - return validate_service_id(service_id); - } - effective_fastly_service_id(manifest_path)?; - Ok(()) -} - -fn effective_deploy_environment(context: &AdapterDeployContext) -> Result { - let variables = merge_env_defaults(context.variable_defaults.iter(), env::vars()); - let environment = EnvConfig::from_vars(variables); - for (kind, logical_ids) in [ - (ResourceKind::Config, &context.stores.config), - (ResourceKind::Kv, &context.stores.kv), - (ResourceKind::Secret, &context.stores.secrets), - ] { - for logical_id in logical_ids { - environment - .store_name_checked(kind.selector_kind(), logical_id) - .map_err(|error| format!("invalid Fastly deploy environment: {error}"))?; - if kind == ResourceKind::Config { - environment - .store_key_checked(kind.selector_kind(), logical_id) - .map_err(|error| format!("invalid Fastly deploy environment: {error}"))?; - } - } - } - Ok(environment) -} - -fn validate_fastly_config_key( - logical_store_id: &str, - key: &str, - _staging: bool, - _local: bool, -) -> Result<(), String> { - let expected = logical_store_id; - if key == expected { - Ok(()) - } else { - Err(format!( - "Fastly uses logical config key `{expected}` for every target; remove the conflicting --key or EDGEZERO__STORES__CONFIG__{}__KEY override and select the environment's physical store with __NAME", - logical_store_id.to_ascii_uppercase() - )) - } -} - -/// Read the required Fastly API token from the environment. -fn require_token() -> Result { - env::var(FASTLY_API_TOKEN_ENV) - .map_err(|_err| format!("{FASTLY_API_TOKEN_ENV} must be set in the environment")) -} - -/// Whether an HTTP status counts as healthy (2xx only). -/// -/// A passing probe gates against an automatic rollback, so a 3xx is deliberately -/// NOT healthy: a staged version answering `301` to an error page (the probe does -/// not follow redirects) would otherwise mask a bad deploy as healthy. -fn is_healthy_status(code: u16) -> bool { - (200..300).contains(&code) -} - -/// Digits immediately following `marker` in `lower` (a lowercased -/// haystack), for the LAST occurrence of `marker`. The number must be -/// terminated by `terminator` — so a partial/confusable match (e.g. a -/// semver `15.2.0`) yields `None` rather than a bogus version. -fn last_version_after(lower: &str, marker: &str, terminator: char) -> Option { - let mut result = None; - for (idx, _) in lower.match_indices(marker) { - let after = idx.saturating_add(marker.len()); - let Some(rest) = lower.get(after..) else { - continue; - }; - let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); - if digits.is_empty() || rest.chars().nth(digits.len()) != Some(terminator) { - continue; - } - if let Ok(parsed) = digits.parse::() { - result = Some(parsed); - } - } - result -} - -/// Parse a Fastly service version out of Fastly CLI output, accepting -/// ONLY the shapes the CLI actually emits, in precedence order: -/// -/// 1. Our canonical `version=` contract line. -/// 2. The CLI's success line, whose Go format string is -/// `"Updated package (service %s, version %v)"` (and -/// `"Deployed package (...)"` for `compute deploy`) — matched as -/// `, version )`. This names the version the package landed on, -/// so it wins over (3). -/// 3. The `--autoclone` notice, `"... Now operating on version %d."` — -/// the freshly-cloned draft, used when the success line is absent. -/// -/// Everything else yields `None` and the caller FAILS CLOSED. -/// -/// Deliberately strict. The previous implementation took ANY digits -/// appearing after the word "version" and let the last match win, so: -/// * `Uploaded package to service 12345, version unchanged` parsed as -/// version 12345, and -/// * the autoclone notice's *pre-clone* version -/// (`Service version 3 is not editable...`) could beat the real one, -/// since stdout and stderr are concatenated and their relative order -/// is not guaranteed. -/// -/// A misparse here stages, comments, or rolls back the WRONG service -/// version, so ambiguity must be an error, not a guess. -fn parse_fastly_version(text: &str) -> Option { - let lower = text.to_ascii_lowercase(); - parse_canonical_version_line(&lower) - .or_else(|| last_version_after(&lower, ", version ", ')')) - .or_else(|| last_version_after(&lower, "now operating on version ", '.')) -} - -/// Last standalone `version=` line (the whole trimmed line must be -/// exactly that, so a `--version=active` flag echoed in a command line -/// cannot masquerade as one). -fn parse_canonical_version_line(lower: &str) -> Option { - lower.lines().rev().find_map(|line| { - let digits = line.trim().strip_prefix("version=")?; - (!digits.is_empty() && digits.chars().all(|ch| ch.is_ascii_digit())) - .then(|| digits.parse().ok()) - .flatten() - }) -} - -/// Parse `fastly service version list --json` (or the Fastly API -/// `/service//version` array) for the `number` of the `active` -/// version. -/// Resolve the active version from a Fastly version-list JSON. -/// -/// `Ok(Some(n))` — exactly one version is active. `Ok(None)` — the list parsed -/// but NO version is active (a first-ever deploy; the caller records an empty -/// rollback target and proceeds). `Err(_)` — the payload could not be parsed as -/// a version list, OR it is MALFORMED (a selection/safety field is missing or -/// has the wrong type, or MORE THAN ONE active version exists). All are -/// OPERATIONAL failures the caller must -/// NOT silently treat as "no active version" — otherwise a garbled or ambiguous -/// response would fail open and let a production deploy proceed with no rollback -/// target. -/// -/// The ENTIRE list is scanned (not short-circuited at the first active entry) so -/// that a malformed field or a second active version anywhere in the response -/// is caught rather than ignored. -fn resolve_active_version(json: &str) -> Result, String> { - let versions = parse_service_versions(json)?; - let mut active_version: Option = None; - for version in versions { - if version.active { - if active_version.is_some() { - return Err(format!( - "the Fastly version list reports more than one active version ({} and {}); the response is ambiguous, refusing to pick one", - active_version.unwrap_or_default(), - version.number, - )); - } - active_version = Some(version.number); - } - } - Ok(active_version) -} - -fn parse_service_versions(json: &str) -> Result, String> { - let versions: Vec = serde_json::from_str(json) - .map_err(|error| format!("failed to parse the Fastly version list as JSON: {error}"))?; - if versions.is_empty() { - return Err( - "the Fastly version list is empty; a service always has at least an initial version, so this response cannot be trusted".to_owned(), - ); - } - let mut numbers = HashSet::with_capacity(versions.len()); - for version in &versions { - if !numbers.insert(version.number) { - return Err(format!( - "the Fastly version list contains duplicate version number {}; refusing an ambiguous response", - version.number - )); - } - for environment in &version.environments { - if environment.name.is_empty() || environment.service_id.is_empty() { - return Err(format!( - "Fastly version {} contains an incomplete environment record", - version.number - )); - } - } - } - Ok(versions) -} - -fn select_version_source(versions: &[ServiceVersionRecord]) -> Result { - let staging_record_count = versions - .iter() - .flat_map(|version| &version.environments) - .filter(|environment| environment.name == "staging") - .count(); - if staging_record_count > 1 { - return Err(format!( - "the Fastly version list reports more than one staging environment record ({staging_record_count}); the response is ambiguous" - )); - } - - let active_versions = versions - .iter() - .filter(|version| version.active) - .collect::>(); - if active_versions.len() > 1 { - return Err("the Fastly version list reports more than one active version".to_owned()); - } - if let Some(active_version) = active_versions.first() { - return Ok(VersionSource::Active(active_version.number)); - } - - let highest = versions - .iter() - .map(|version| version.number) - .max() - .ok_or_else(|| "the Fastly version list is empty".to_owned())?; - let drafts = versions - .iter() - .filter(|version| !version.active && !version.locked && version.environments.is_empty()) - .collect::>(); - let staging_versions = versions - .iter() - .filter(|version| { - !version.active - && version.environments.len() == 1 - && version.environments.iter().all(|environment| { - environment.name == "staging" && environment.active_version == version.number - }) - }) - .collect::>(); - let retired = versions - .iter() - .filter(|version| !version.active && version.locked && version.environments.is_empty()) - .collect::>(); - match (drafts.as_slice(), staging_versions.as_slice()) { - ([draft], []) if draft.number == highest && retired.is_empty() => { - return Ok(VersionSource::InitialDraft(draft.number)); - } - ([draft], []) if !retired.is_empty() => { - return Err(format!( - "editable Fastly draft {} exists beside retired version(s); EdgeZero cannot prove the draft belongs to a retired source. Restore a single unambiguous source version before retrying", - draft.number - )); - } - ([], [staging_version]) => return Ok(VersionSource::Staging(staging_version.number)), - ([draft], [staging_version]) => { - return Err(format!( - "editable Fastly draft {draft_version} exists beside staged version {staging_version}; EdgeZero cannot prove the draft belongs to the staged source. Lock the orphan with `fastly service version lock --version={draft_version}` (and pass `--service-id` when fastly.toml does not select the service), then retry", - draft_version = draft.number, - staging_version = staging_version.number, - )); - } - _ => {} - } - if drafts.is_empty() - && staging_versions.is_empty() - && let Some(retired_version) = retired.iter().find(|version| version.number == highest) - { - return Ok(VersionSource::Retired(retired_version.number)); - } - if drafts.len() == 1 && staging_versions.len() <= 1 { - return Err(format!( - "first deployment requires the highest service version ({highest}) to be the initialized editable draft" - )); - } - if staging_versions.len() > 1 || drafts.len() > 1 { - return Err(format!( - "deployment without an active version requires one highest editable draft, staged source, or retired source; found {} drafts, {} staged versions, and {} retired versions", - drafts.len(), - staging_versions.len(), - retired.len() - )); - } - Err(format!( - "deployment without an active version requires one highest editable draft, staged source, or retired source; found {} drafts, {} staged versions, and {} retired versions", - drafts.len(), - staging_versions.len(), - retired.len() - )) -} - -/// Best-effort staleness guard for a production rollback: the version being -/// rolled back FROM (`from_version`, the caller's `--version`) must still be the -/// ACTIVE version. A rollback can run long after its deploy; if a newer version -/// was activated since, activating the old target would clobber it — so refuse. -/// -/// This narrows but does NOT close the race: the caller reads the active version -/// and activates in two separate requests, and Fastly's activate endpoint has no -/// precondition, so a deploy landing between them can still be clobbered. -/// Service-scoped serialization is required to eliminate it. -fn ensure_rollback_from_is_active( - active: Option, - from_version: u64, - service_id: &str, -) -> Result<(), String> { - match active { - Some(active_version) if active_version == from_version => Ok(()), - Some(active_version) => Err(format!( - "refusing to roll back service {service_id}: the active version is now {active_version}, not the {from_version} being rolled back from -- a newer deploy is live and rolling back would clobber it" - )), - None => Err(format!( - "refusing to roll back service {service_id}: it has no active version" - )), - } -} - -fn staging_rollback_decision( - versions: &[ServiceVersionRecord], - requested_version: u64, - service_id: &str, -) -> Result { - let staging_records = versions - .iter() - .flat_map(|version| { - version - .environments - .iter() - .filter(|environment| environment.name == "staging") - .map(move |environment| (version.number, environment)) - }) - .collect::>(); - if staging_records.len() > 1 { - return Err(format!( - "Fastly service {service_id} reports more than one staging environment record; refusing staging rollback" - )); - } - let version = versions - .iter() - .find(|candidate| candidate.number == requested_version) - .ok_or_else(|| { - format!( - "Fastly version {requested_version} is absent from service {service_id}; refusing staging rollback" - ) - })?; - let is_staging_version = matches!( - staging_records.as_slice(), - [(record_version, environment)] - if *record_version == requested_version - && environment.active_version == requested_version - ); - if is_staging_version { - return Ok(StagingRollbackDecision::Deactivate); - } - let unpublished_draft = !version.active && !version.locked && version.environments.is_empty(); - if unpublished_draft { - return Ok(StagingRollbackDecision::NoopDraft); - } - Err(format!( - "Fastly version {requested_version} is neither the exact staged version nor an unpublished editable draft; refusing staging rollback" - )) -} - -/// Staging IP for one exact domain in a Fastly -/// `GET /service//version//domain?include=staging_ips` response. -/// -/// The response is an ARRAY of domain objects, and the staging address -/// is a SINGULAR, nullable STRING field named `staging_ip` on each -/// domain (`staging_ips` is only the `include=` query-param value, never -/// a field name). Verified against the go-fastly `Domain` model, whose -/// field is `StagingIP` with the mapstructure tag `staging_ip`, and its -/// recorded API fixture `fixtures/domains/list_with_staging_ips.yaml`, -/// plus Fastly's "working with staging" guide. -fn parse_staging_ip(json: &str, domain: &str) -> Result { - #[derive(serde::Deserialize)] - struct DomainRecord { - name: String, - staging_ip: Option, - } - - let records: Vec = serde_json::from_str(json).map_err(|error| { - format!( - "Fastly staging domain inventory has an invalid response shape (payload redacted): {error}" - ) - })?; - let mut matches = records.iter().filter(|record| record.name == domain); - let record = matches.next().ok_or_else(|| { - format!("Fastly staging domain inventory does not contain domain `{domain}`") - })?; - if matches.next().is_some() { - return Err(format!( - "Fastly staging domain inventory contains duplicate domain `{domain}`" - )); - } - record - .staging_ip - .as_deref() - .filter(|ip| !ip.is_empty()) - .map(str::to_owned) - .ok_or_else(|| format!("Fastly staging domain `{domain}` has no staging IP")) -} - -/// Build the `curl` argv for a health probe. Production probes the -/// domain directly; staging reroutes the TLS connection to the -/// resolved staging IP via `--connect-to :::443`. `path` is the -/// URL path (always begins with '/'), applied identically to both. -fn build_curl_probe_args( - domain: &str, - path: &str, - staging_ip: Option<&str>, - timeout_secs: u64, -) -> Vec { - let mut args = vec![ - // `-q` first so curl never merges `~/.curlrc` into a probe (a planted - // `proxy`/`output` there could otherwise redirect or corrupt the check). - "-q".to_owned(), - "-sS".to_owned(), - // Disable curl's URL globbing: a valid probe path may contain `[` `]` `{` - // `}` (e.g. `/health?ids[0]=1`), which curl would otherwise treat as a - // glob — failing with exit 3 or firing multiple requests, and so - // mis-reporting a healthy deployment as unhealthy. - "--globoff".to_owned(), - "-o".to_owned(), - "/dev/null".to_owned(), - "-w".to_owned(), - "%{http_code}".to_owned(), - "--max-time".to_owned(), - timeout_secs.to_string(), - ]; - if let Some(ip) = staging_ip { - // `--connect-to ::HOST:PORT` reroutes the TLS connection to the staging - // IP. An IPv6 literal must be bracketed or curl mis-parses the colons; - // the caller has already validated `ip` parses as an `IpAddr`. - let target = if ip.contains(':') { - format!("::[{ip}]:443") - } else { - format!("::{ip}:443") - }; - args.push("--connect-to".to_owned()); - args.push(target); - } - args.push(format!("https://{domain}{path}")); - args -} - -/// Validate a caller-supplied probe path. It is appended to -/// `https://{domain}` to form one curl argument, so it must begin with -/// '/' and carry no whitespace or control characters that would break -/// the URL or smuggle a second token. -fn validate_probe_path(path: &str) -> Result<(), String> { - if !path.starts_with('/') { - return Err(format!("healthcheck --path must begin with '/': '{path}'")); - } - if path.chars().any(|ch| ch.is_whitespace() || ch.is_control()) { - return Err(format!( - "healthcheck --path must not contain whitespace or control characters: '{path}'" - )); - } - Ok(()) -} - -/// Retry a health probe. Returns `Ok(code)` on the first healthy -/// status, or `Err((last_code, message))` after exhausting attempts. -/// `between` runs between attempts (not after the last) so it can be a -/// no-op in tests. -fn probe_with_retries( - retry: u32, - mut prober: P, - mut between: S, -) -> Result, String)> -where - P: FnMut() -> Result, - S: FnMut(), -{ - let attempts = retry.max(1); - let mut last_code = None; - let mut last_msg = "no probe attempts were made".to_owned(); - for attempt in 0..attempts { - match prober() { - Ok(code) if is_healthy_status(code) => return Ok(code), - Ok(code) => { - last_code = Some(code); - last_msg = format!("unhealthy HTTP status {code}"); - } - Err(err) => last_msg = err, - } - if attempt.saturating_add(1) < attempts { - between(); - } - } - Err((last_code, last_msg)) -} - -/// Run `fastly ` in `cwd`, inheriting stdio, and map a non-zero -/// exit to an error. -fn run_fastly_status(fastly_args: &[String], cwd: &Path) -> Result<(), String> { - let status = Command::new("fastly") - .args(fastly_args) - .current_dir(cwd) - .status() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to run fastly CLI: {err}") - } - })?; - if status.success() { - Ok(()) - } else { - Err(format!( - "`fastly {}` exited with status {status}", - fastly_args.join(" ") - )) - } -} - -/// Run `fastly ` in `cwd` capturing stdout+stderr (combined) for -/// version parsing. Errors on a non-zero exit. -/// Run `curl -q -sS --config -`, piping `config` (which carries the -/// `Fastly-Key` header + url) through stdin so the token never touches -/// argv. Returns stdout on a zero exit. -/// -/// `-q` MUST be the first argument: without it curl reads `~/.curlrc` -/// (or `$CURL_HOME/.curlrc`) and merges it into this token-bearing -/// config, so a `proxy = …` directive planted by an earlier same-job -/// build step could exfiltrate the `Fastly-Key` header. `--connect-timeout` -/// / `--max-time` bound the call. -fn curl_config_capture(config: &str) -> Result { - let connect_timeout = FASTLY_API_CONNECT_TIMEOUT_SECS.to_string(); - let max_time = FASTLY_API_MAX_TIME_SECS.to_string(); - let mut child = Command::new("curl") - .args([ - "-q", - "-sS", - "--connect-timeout", - &connect_timeout, - "--max-time", - &max_time, - "--config", - "-", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - "`curl` not found on PATH; install curl and retry".to_owned() - } else { - format!("failed to spawn `curl`: {err}") - } - })?; - // Take stdin OUT of the child and hand it to a helper BY VALUE, so it drops at - // that helper's scope end — a natural drop rather than an explicit `drop(stdin)`, - // which trips `clippy::drop_non_drop` on wasm targets where `ChildStdin` is not - // `Drop`. The drop must precede `wait_with_output` so curl sees EOF (same pattern - // as `write_value_to_fastly_stdin` on the fastly path). - let stdin = child - .stdin - .take() - .ok_or_else(|| "failed to open stdin pipe to `curl`".to_owned())?; - write_config_to_curl_stdin(stdin, config)?; - let output = child - .wait_with_output() - .map_err(|err| format!("failed to wait on `curl`: {err}"))?; - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) - } else if output.status.code() == Some(CURL_EXIT_TIMEOUT) { - Err(format!( - "`curl` timed out after connect-timeout {FASTLY_API_CONNECT_TIMEOUT_SECS}s / max-time {FASTLY_API_MAX_TIME_SECS}s: {}", - String::from_utf8_lossy(&output.stderr).trim() - )) - } else { - Err(format!( - "`curl` exited with status {}: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )) - } -} - -/// Write `config` to curl's stdin, taking the handle BY VALUE so it drops at this -/// function's scope end. That natural drop closes the pipe (curl sees EOF) without -/// an explicit `drop(stdin)`, which trips `clippy::drop_non_drop` on wasm targets -/// where `ChildStdin` is not `Drop` (mirrors `write_value_to_fastly_stdin`). -fn write_config_to_curl_stdin(mut stdin: ChildStdin, config: &str) -> Result<(), String> { - stdin - .write_all(config.as_bytes()) - .map_err(|err| format!("failed to write curl config to stdin: {err}")) -} - -/// Wrap `value` in a curl-config double-quoted string, escaping the -/// characters that would otherwise let a value terminate its quote and -/// inject additional curl options. Within a curl `--config` file a -/// double-quoted value only honours the escapes `\\`, `\"`, `\n`, `\r`, -/// `\t` (and the config is parsed line-by-line, so a raw newline ends -/// the directive regardless of quoting). We escape backslash and quote -/// so the value cannot break out of the quotes, and map raw control -/// characters to their escape form so NO raw newline (or CR/tab) is -/// ever written into the config file. This is the second half of the -/// injection defence: untrusted identifiers are also validated (see -/// `validate_service_id` / `validate_version_str` / `validate_domain`), -/// but the token is a secret we cannot constrain to a charset, so it -/// relies on this escaping alone. -fn curl_quote(value: &str) -> String { - let mut out = String::with_capacity(value.len().saturating_add(2)); - out.push('"'); - for ch in value.chars() { - match ch { - '\\' => out.push_str("\\\\"), - '"' => out.push_str("\\\""), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - other => out.push(other), - } - } - out.push('"'); - out -} - -/// Validate an operator-supplied Fastly service id before it is -/// interpolated into an API URL. Fastly service ids contain only ASCII -/// letters and digits. -/// Values carrying a quote, newline, or space could inject curl options via -/// the `--config` file. -fn validate_service_id(id: &str) -> Result<(), String> { - if !id.is_empty() && id.chars().all(|ch| ch.is_ascii_alphanumeric()) { - Ok(()) - } else { - Err(format!( - "invalid service id {id:?}: expected ASCII letters and digits only" - )) - } -} - -/// Validate a service-version string is a plain non-negative integer -/// before it is interpolated into an API URL. Returns the parsed value -/// so callers can reuse it. -fn validate_version_str(version: &str) -> Result { - version.parse::().map_err(|err| { - format!("invalid version {version:?}: expected a non-negative integer: {err}") - }) -} - -/// Validate a domain is a plausible hostname before it is placed into a -/// `curl` URL. Rejects anything outside the DNS label charset -/// (`[A-Za-z0-9-.]`), empty / over-long values, leading/trailing dots, -/// and empty labels so an injected quote / slash / space / newline -/// cannot smuggle curl options or a second URL. -fn validate_domain(domain: &str) -> Result<(), String> { - let charset_ok = domain - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '.'); - let shape_ok = !domain.is_empty() - && domain.len() <= 253 - && !domain.starts_with('.') - && !domain.ends_with('.') - && !domain.contains(".."); - if charset_ok && shape_ok { - Ok(()) - } else { - Err(format!( - "invalid domain {domain:?}: expected a hostname like `example.com`" - )) - } -} - -/// `GET https://api.fastly.com` with the `Fastly-Key` header; -/// returns the response body ONLY on a 2xx status. Both the header (carrying the -/// secret token) and the URL are written through `curl_quote` so neither can -/// inject curl options into the `--config` document. -/// -/// The HTTP status is captured explicitly via `write-out` (as the PUT helper -/// does) and required to be 2xx before the body is trusted. `--fail` alone would -/// reject 4xx/5xx but still accept a 3xx — whose (array-shaped) body could -/// otherwise be parsed as version data. No `location` directive is set, so a -/// redirect is never followed. -fn fastly_api_get(path: &str, token: &str) -> Result { - let header = curl_quote(&format!("Fastly-Key: {token}")); - let url = curl_quote(&format!("https://api.fastly.com{path}")); - // `write-out` appends the status on its own trailing line AFTER the body. - let config = format!("header = {header}\nurl = {url}\nwrite-out = \"\\n%{{http_code}}\"\n"); - let out = curl_config_capture(&config) - .map_err(|err| format!("Fastly API GET {path} failed: {err}"))?; - let (body, status_line) = out - .rsplit_once('\n') - .ok_or_else(|| format!("Fastly API GET {path}: no HTTP status in the curl output"))?; - let status: u16 = status_line.trim().parse().map_err(|err| { - format!( - "Fastly API GET {path}: could not parse the HTTP status {:?}: {err}", - status_line.trim() - ) - })?; - if !(200..300).contains(&status) { - return Err(format!("Fastly API GET {path} returned HTTP {status}")); - } - Ok(body.to_owned()) -} - -/// `PUT https://api.fastly.com` with the `Fastly-Key` header; -/// returns the HTTP status, erroring on non-2xx. Fastly's version -/// activate/deactivate endpoints require `PUT` (not `POST`). Header and -/// URL are escaped via `curl_quote`; the literal `request`, `output`, -/// and `write-out` directives are fixed constants. -fn fastly_api_put(path: &str, token: &str) -> Result { - let header = curl_quote(&format!("Fastly-Key: {token}")); - let url = curl_quote(&format!("https://api.fastly.com{path}")); - let config = format!( - "request = \"PUT\"\nheader = {header}\nurl = {url}\noutput = \"/dev/null\"\nwrite-out = \"%{{http_code}}\"\n" - ); - let out = curl_config_capture(&config)?; - let code: u16 = out.trim().parse().map_err(|err| { - format!( - "could not parse HTTP status from curl output {:?}: {err}", - out.trim() - ) - })?; - if (200..300).contains(&code) { - Ok(code) - } else { - Err(format!("Fastly API PUT {path} returned HTTP {code}")) - } -} - -/// `PUT https://api.fastly.com` and return a non-empty response body. -/// This is used for mutations, such as cloning a version, whose response -/// identifies the newly-created provider object needed for recovery. -fn fastly_api_put_capture(path: &str, token: &str) -> Result { - let header = curl_quote(&format!("Fastly-Key: {token}")); - let url = curl_quote(&format!("https://api.fastly.com{path}")); - let config = format!( - "request = \"PUT\"\nheader = {header}\nurl = {url}\nwrite-out = \"\\n%{{http_code}}\"\n" - ); - let out = curl_config_capture(&config)?; - let (body, status_line) = out - .rsplit_once('\n') - .ok_or_else(|| format!("Fastly API PUT {path}: no HTTP status in the curl output"))?; - let code: u16 = status_line.trim().parse().map_err(|error| { - format!( - "Fastly API PUT {path}: could not parse the HTTP status {:?}: {error}", - status_line.trim() - ) - })?; - if !(200..300).contains(&code) { - return Err(format!("Fastly API PUT {path} returned HTTP {code}")); - } - if body.trim().is_empty() { - return Err(format!("Fastly API PUT {path} returned an empty response")); - } - Ok(body.to_owned()) -} - -fn legacy_deploy_context(args: &[String], staging: bool) -> AdapterDeployContext { - AdapterDeployContext { - adapter_manifest_path: arg_value(args, "--manifest-path").map(PathBuf::from), - application_manifest_path: None, - application_release_root: None, - service_id: arg_value(args, "--service-id").map(str::to_owned), - stores: DeployStoreIds::default(), - staging, - variable_defaults: BTreeMap::default(), - } -} - -/// Resolve the directory containing the Fastly manifest selected by the -/// application manifest. Fall back to discovery for direct adapter callers. -fn resolve_deploy_manifest_path(context: &AdapterDeployContext) -> Result { - if let Some(path) = context.adapter_manifest_path.as_deref() { - return Ok(path.to_path_buf()); - } - find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path()) -} - -/// Production companion to `deploy`: resolve the active service version via the -/// Fastly API and emit it as a `version=` line. -/// -/// Distinguishes "confirmed no active version" from an operational failure: a -/// service with no active version yet (a first-ever deploy) is NOT an error — it -/// emits an empty `version=` line and succeeds, so the caller records an empty -/// rollback target. Only a real failure (API/auth error, or a version list that -/// cannot be parsed) returns `Err`, so the caller can fail closed instead of -/// silently proceeding without a rollback target. -/// -/// `--require-active` flips the no-active-version case to an error: it is passed -/// by the production-`deploy` version fallback, where a version was JUST -/// activated, so "no active version" is not a valid first-deploy state but an -/// operational failure the CLI must not report as success. -fn emit_active_version(args: &[String]) -> Result<(), String> { - let service_id = resolve_service_id(args)?; - validate_service_id(&service_id)?; - emit_active_version_for(&service_id, arg_flag(args, "--require-active")) -} - -fn emit_active_version_for(service_id: &str, require_active: bool) -> Result<(), String> { - let token = require_token()?; - let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; - if let Some(version) = active_version_or_require(&json, require_active, service_id)? { - log::info!("version={version}"); - } else { - // Confirmed no active version (first-ever deploy), and it was not - // required. Emit an explicit empty line so the caller records an empty - // rollback target and succeeds — distinct from a failure (`Err`). - log::info!("version="); - log::info!( - "service {service_id} has no active version yet; emitting an empty rollback target" - ); - } - Ok(()) -} - -/// Resolve the active version and apply the `--require-active` policy. -/// -/// `Ok(Some(n))` — a version is active. `Ok(None)` — no active version and -/// `require_active` is false (a first-ever `active-version` call; the caller -/// records an empty rollback target). `Err` — the response was malformed -/// ([`resolve_active_version`]), OR no version is active while `require_active` -/// is true. The latter is the production-`deploy` fallback: a version was JUST -/// activated, so "no active version" is an error, not a valid empty result. -fn active_version_or_require( - json: &str, - require_active: bool, - service_id: &str, -) -> Result, String> { - match resolve_active_version(json)? { - Some(version) => Ok(Some(version)), - None if require_active => Err(format!( - "the deploy reported success but the Fastly API returns no active version for service {service_id}; refusing to report a deploy with no resolvable version" - )), - None => Ok(None), - } -} - -/// Require `version` to be the currently ACTIVE service version — the -/// production healthcheck's version contract. -/// -/// The production probe hits the live domain, which serves whatever version is -/// active, so "healthcheck version N" is only a true statement about N while N is -/// active. `phase` (`before probing` / `after probing`) names when the check ran, -/// so a version activated by a concurrent deploy is reported clearly rather than -/// masquerading as a healthy `version`. -fn verify_version_active( - service_id: &str, - version: u64, - token: &str, - phase: &str, -) -> Result<(), String> { - let json = fastly_api_get(&format!("/service/{service_id}/version"), token)?; - version_active_verdict(resolve_active_version(&json)?, version, service_id, phase) -} - -/// The pure decision behind [`verify_version_active`], split out so the version -/// contract is unit-testable without a live Fastly API. -fn version_active_verdict( - active: Option, - version: u64, - service_id: &str, - phase: &str, -) -> Result<(), String> { - match active { - Some(active_version) if active_version == version => Ok(()), - Some(active_version) => Err(format!( - "production healthcheck version {version} is not active {phase}: service {service_id} currently has version {active_version} active, so the live-domain probe reflects version {active_version}, not {version}" - )), - None => Err(format!( - "production healthcheck version {version} could not be confirmed active {phase}: service {service_id} has no active version" - )), - } -} - -/// `healthcheck --adapter fastly ...`: probe the domain -/// (production) or the version's staging IP (`--staging`), retrying up -/// to `--retry` times. Emits `status-code` / `healthy` and returns -/// `Err` (non-zero exit) when unhealthy after retries. -/// -/// `--domain`, `--service-id` and `--version` are REQUIRED and validated -/// on BOTH the production and the staging path. GitHub Actions' `required: -/// true` does not actually fail a workflow when an input is omitted or -/// empty, so this is the real guard: a production healthcheck must never -/// probe on behalf of an absent/empty version it never verified — the -/// caller chains that same version into rollback. -/// -/// On the PRODUCTION path the probe reaches whatever version is live, so when a -/// token is available `version` is verified ACTIVE before and after the probe -/// (see [`verify_version_active`]); without a token the check is service-level. -fn healthcheck(args: &[String]) -> Result<(), String> { - let domain = - arg_value(args, "--domain").ok_or_else(|| "healthcheck requires --domain".to_owned())?; - validate_domain(domain)?; - let service_id = resolve_service_id(args)?; - validate_service_id(&service_id)?; - let version_str = - arg_value(args, "--version").ok_or_else(|| "healthcheck requires --version".to_owned())?; - let version = validate_version_str(version_str)?; - let path = arg_value(args, "--path").unwrap_or("/"); - validate_probe_path(path)?; - let retry = arg_value(args, "--retry") - .and_then(|value| value.parse().ok()) - .unwrap_or(3_u32); - let retry_delay = arg_value(args, "--retry-delay") - .and_then(|value| value.parse().ok()) - .unwrap_or(5_u64); - let timeout = arg_value(args, "--timeout") - .and_then(|value| value.parse().ok()) - .unwrap_or(10_u64); - // curl reads `--max-time 0` as "no limit", so a zero timeout lets a single - // probe run indefinitely. Require a positive value. - if timeout == 0 { - return Err("healthcheck --timeout must be a positive number of seconds".to_owned()); - } - - let is_staging = arg_flag(args, "--staging"); - let staging_ip = if is_staging { - let token = require_token()?; - let json = fastly_api_get( - &format!("/service/{service_id}/version/{version}/domain?include=staging_ips"), - &token, - )?; - let ip = parse_staging_ip(&json, domain)?; - // Require a real `IpAddr` before it reaches curl's `--connect-to`, which - // also settles IPv4-vs-IPv6 formatting. - ip.parse::().map_err(|err| { - format!("resolved staging IP {ip:?} is not a valid IP address: {err}") - })?; - Some(ip) - } else { - None - }; - - // Production version contract: the probe hits the live domain, which serves - // whatever version is ACTIVE — not necessarily `version`. When a token is - // available, require `version` to be active both BEFORE and AFTER the probe, so - // a version activated concurrently (by another deploy) cannot be reported as a - // healthy `version`. Without a token the production check is inherently - // service-level — say so rather than imply a version-specific guarantee. The - // staging path already targets the specific version's staging IP, so it needs - // no such check. - let production_token = if is_staging { - None - } else { - match env::var(FASTLY_API_TOKEN_ENV) { - Ok(token) if !token.is_empty() => Some(token), - _ => { - log::info!( - "no {FASTLY_API_TOKEN_ENV} available; production healthcheck is service-level (probes the live domain for service {service_id}, not specifically version {version})" - ); - None - } - } - }; - if let Some(token) = production_token.as_deref() { - verify_version_active(&service_id, version, token, "before probing")?; - } - - let curl_args = build_curl_probe_args(domain, path, staging_ip.as_deref(), timeout); - let delay = Duration::from_secs(retry_delay); - let outcome = probe_with_retries(retry, || curl_status(&curl_args), || thread::sleep(delay)); - match outcome { - Ok(code) => { - // Confirm `version` is STILL active, so a deploy that activated a newer - // version during the probe+retries is not reported as a healthy `version`. - if let Some(token) = production_token.as_deref() { - verify_version_active(&service_id, version, token, "after probing")?; - } - log::info!("status-code={code}"); - log::info!("healthy=true"); - Ok(()) - } - Err((last_code, msg)) => { - if let Some(code) = last_code { - log::info!("status-code={code}"); - } - log::info!("healthy=false"); - Err(format!( - "healthcheck for {domain} failed after {} attempt(s): {msg}", - retry.max(1) - )) - } - } -} - -/// Run a single `curl` health probe, returning the HTTP status. A -/// transport failure (timeout, DNS, refused) surfaces as `Err` so the -/// retry loop treats it as an unhealthy attempt. -fn curl_status(args: &[String]) -> Result { - let output = Command::new("curl").args(args).output().map_err(|err| { - if err.kind() == ErrorKind::NotFound { - "`curl` not found on PATH; install curl and retry".to_owned() - } else { - format!("failed to spawn `curl`: {err}") - } - })?; - if !output.status.success() { - return Err(format!( - "curl transport failure (status {}): {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )); - } - let stdout = String::from_utf8_lossy(&output.stdout); - stdout.trim().parse::().map_err(|err| { - format!( - "could not parse HTTP status from curl output {:?}: {err}", - stdout.trim() - ) - }) -} - -/// `rollback --adapter fastly ...`: production activates the explicit -/// `--rollback-to` version (Fastly cannot infer a previous version); -/// staging deactivates ``. -fn rollback(args: &[String]) -> Result<(), String> { - let service_id = resolve_service_id(args)?; - validate_service_id(&service_id)?; - let version_str = - arg_value(args, "--version").ok_or_else(|| "rollback requires --version".to_owned())?; - let version = validate_version_str(version_str)?; - let token = require_token()?; - - if arg_flag(args, "--staging") { - let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; - let versions = parse_service_versions(&json)?; - match staging_rollback_decision(&versions, version, &service_id)? { - StagingRollbackDecision::Deactivate => { - // Fastly's environment-scoped deactivate is - // `PUT .../deactivate/staging`; a plain `.../deactivate` - // targets production activation. - fastly_api_put( - &format!("/service/{service_id}/version/{version}/deactivate/staging"), - &token, - )?; - log::info!( - "[edgezero] deactivated staged version {version} on Fastly service {service_id}" - ); - } - StagingRollbackDecision::NoopDraft => log::info!( - "[edgezero] Fastly version {version} is an unpublished draft; staging rollback has nothing to deactivate" - ), - } - } else { - // Production rollback re-activates an EXPLICIT target. Fastly's version - // list has no field distinguishing a previously-live version from a - // staged one (`staging`/`deployed` are documented "Unused"; `locked` - // only means "not editable"), so the target cannot be inferred — it is - // captured before the superseding deploy and passed in as --rollback-to. - let previous = arg_value(args, "--rollback-to") - .and_then(|raw| validate_version_str(raw).ok()) - .ok_or_else(|| { - "production rollback requires a valid --rollback-to version".to_owned() - })?; - // Best-effort staleness check: the version being rolled back FROM - // (`--version`) must STILL be the active version. A rollback workflow can - // run long after its deploy — if a newer version was activated meanwhile, - // activating the old target would clobber that newer deploy, so refuse. - // - // This is NOT atomic: Fastly's activate endpoint has no precondition, so - // a deploy that lands BETWEEN this read and the activate below can still - // be clobbered. It narrows the window (catching the common much-later - // rollback) but does not close it — serialise deploys and rollbacks per - // SERVICE (a service-scoped concurrency group) to eliminate the race. - let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; - ensure_rollback_from_is_active(resolve_active_version(&json)?, version, &service_id)?; - // Fastly's activate endpoint requires `PUT` (not `POST`). - fastly_api_put( - &format!("/service/{service_id}/version/{previous}/activate"), - &token, - )?; - log::info!("rolled-back-to={previous}"); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use edgezero_adapter::cli_support::read_package_name; - use edgezero_core::env_config::EnvConfig; - #[cfg(unix)] - use edgezero_core::test_env::{EnvOverride, PathPrepend}; - #[cfg(unix)] - use std::collections::BTreeMap; - use std::collections::HashSet; - #[cfg(unix)] - use std::iter::once; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt as _; - #[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"; - - // `PathPrepend` (RAII $PATH guard) is the shared helper imported above from - // `edgezero_core::test_env`; the merge with edition-2024 main replaced our - // local copy with it (its `set_var` calls are wrapped for 2024's unsafe-env). - - // ── Fastly staging lifecycle helpers ────────────────────────────── - - #[test] - fn arg_value_reads_flag_value() { - let args = vec![ - "--service-id".to_owned(), - "SVC1".to_owned(), - "--version".to_owned(), - "42".to_owned(), - ]; - assert_eq!(arg_value(&args, "--service-id"), Some("SVC1")); - assert_eq!(arg_value(&args, "--version"), Some("42")); - assert_eq!(arg_value(&args, "--missing"), None); - } - - #[test] - fn arg_value_none_when_flag_is_last() { - let args = vec!["--version".to_owned()]; - assert_eq!(arg_value(&args, "--version"), None); - } - - #[test] - fn arg_flag_detects_presence() { - let args = vec!["--staging".to_owned()]; - assert!(arg_flag(&args, "--staging")); - assert!(!arg_flag(&args, "--nope")); - } - - #[test] - fn args_without_flag_value_strips_pair() { - let args = vec![ - "--service-id".to_owned(), - "SVC1".to_owned(), - "--comment".to_owned(), - "ci".to_owned(), - ]; - assert_eq!( - args_without_flag_value(&args, "--service-id"), - vec!["--comment".to_owned(), "ci".to_owned()] - ); - } - - #[test] - fn resolve_service_id_prefers_flag() { - let args = vec!["--service-id".to_owned(), "SVCFROMARG".to_owned()]; - assert_eq!(resolve_service_id(&args).unwrap(), "SVCFROMARG"); - } - - // ── managed deploy argument validation ──────────────────────── - - fn owned(args: &[&str]) -> Vec { - args.iter().map(|arg| (*arg).to_owned()).collect() - } - - #[cfg(unix)] - fn fake_provider_invocation_marker(marker: &Path) -> tempfile::TempDir { - use std::os::unix::fs::PermissionsExt as _; - - let dir = tempdir().expect("provider fake dir"); - for binary in ["fastly", "curl"] { - let script_path = dir.path().join(binary); - fs::write( - &script_path, - format!( - "#!/bin/sh\nprintf '%s\\n' \"$0 $*\" >> '{}'\nexit 0\n", - marker.display() - ), - ) - .expect("write provider fake"); - let mut permissions = fs::metadata(&script_path) - .expect("provider fake metadata") - .permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&script_path, permissions).expect("chmod provider fake"); - } - dir - } - - #[cfg(unix)] - fn assert_provider_not_invoked(marker: &Path) { - assert!( - !marker.exists() - || fs::read_to_string(marker) - .expect("provider marker") - .is_empty(), - "invalid input must be rejected before the provider fake is invoked: {}", - fs::read_to_string(marker).unwrap_or_default() - ); - } - - fn assert_service_id_error(error: &str) { - assert!( - error.contains("ASCII letters and digits only"), - "service-id error must state the exact accepted alphabet: {error}" - ); - } - - #[test] - fn deploy_arg_scan_rejects_every_reserved_spelling() { - for args in [ - owned(&["--service-id", "service1"]), - owned(&["--service-id=service1"]), - owned(&["-s", "service1"]), - owned(&["-s=service1"]), - owned(&["-sservice1"]), - owned(&["--service-name", "demo"]), - owned(&["--service-name=demo"]), - owned(&["--version", "active"]), - owned(&["--version=active"]), - owned(&["--autoclone"]), - owned(&["--token", "secret"]), - owned(&["--token=secret"]), - owned(&["-t", "secret"]), - owned(&["-t=secret"]), - owned(&["-tsecret"]), - ] { - let error = scan_reserved_deploy_args(&args) - .expect_err("lifecycle-owned deploy argument must be rejected"); - assert!( - error.contains("reserved") || error.contains("lifecycle"), - "reserved argument error must explain ownership for {args:?}: {error}" - ); - } - } - - #[test] - fn deploy_arg_scan_redacts_inline_token_values() { - const SENTINEL: &str = "SUPER_SECRET_TOKEN_SENTINEL"; - for (arg, expected_flag) in [ - (format!("--token={SENTINEL}"), "--token"), - (format!("-t={SENTINEL}"), "-t"), - (format!("-t{SENTINEL}"), "-t"), - ] { - let error = scan_reserved_deploy_args(&[arg]) - .expect_err("inline credential arguments must be reserved"); - assert!( - !error.contains(SENTINEL), - "reserved-argument error leaked a credential: {error}" - ); - assert!( - error.contains(expected_flag), - "reserved-argument error must identify {expected_flag}: {error}" - ); - } - } - - #[test] - fn deploy_arg_scan_allows_unrelated_long_flags_beginning_with_s_or_t() { - scan_reserved_deploy_args(&owned(&[ - "--skip-build", - "--status", - "--timeout=30", - "--trace", - ])) - .expect("long flags must not be mistaken for attached -s or -t values"); - } - - #[test] - fn package_files_hash_parsers_require_exact_provider_identity() { - let hash = "a".repeat(128); - assert_eq!( - parse_package_files_hash_output(&format!("notice\n{hash}\n")) - .expect("canonical Fastly CLI hash"), - hash - ); - parse_package_files_hash_output("abc").expect_err("short hash must fail"); - parse_package_files_hash_output(&format!("{}\n{}", "a".repeat(128), "b".repeat(128))) - .expect_err("conflicting hashes must fail"); - - let response = serde_json::json!({ - "service_id": "SVC1", - "version": 8_u64, - "metadata": { "files_hash": "a".repeat(128) } - }) - .to_string(); - assert_eq!( - parse_package_metadata_files_hash(&response, "SVC1", 8) - .expect("matching package metadata"), - "a".repeat(128) - ); - parse_package_metadata_files_hash(&response, "OTHER", 8) - .expect_err("wrong service must fail"); - parse_package_metadata_files_hash(&response, "SVC1", 9) - .expect_err("wrong version must fail"); - } - - #[test] - fn deploy_arg_preflight_scans_store_free_manifest_deploys() { - let context = AdapterDeployContext { - service_id: Some("SVC1".to_owned()), - ..AdapterDeployContext::default() - }; - assert_eq!( - FastlyCliAdapter - .preflight_deploy(&context, &owned(&["--comment", "release"])) - .expect("safe manifest-command argument"), - DeployOwnership::ManifestCommand - ); - FastlyCliAdapter - .preflight_deploy(&context, &owned(&["-tsecret"])) - .expect_err("reserved scan must run before store-free manifest dispatch"); - assert_eq!( - FastlyCliAdapter - .preflight_deploy(&context, &owned(&["--package", "manifest-package.tar.gz"]),), - Ok(DeployOwnership::ManifestCommand), - "store-free manifest commands retain provider package passthrough" - ); - } - - #[test] - fn managed_deploy_preflight_rejects_release_owned_package_spellings() { - let context = AdapterDeployContext { - service_id: Some("SVC1".to_owned()), - application_release_root: Some(PathBuf::from("release")), - ..AdapterDeployContext::default() - }; - for args in [ - owned(&["--package", "app.tar.gz"]), - owned(&["--package=app.tar.gz"]), - owned(&["-p", "app.tar.gz"]), - owned(&["-p=app.tar.gz"]), - owned(&["-papp.tar.gz"]), - ] { - let error = FastlyCliAdapter - .preflight_deploy(&context, &args) - .expect_err("the immutable release owns the package path"); - assert!(error.contains("--package/-p"), "{args:?}: {error}"); - } - } - - #[test] - fn managed_deploy_preflight_owns_release_staging_and_each_store_kind() { - let direct = AdapterDeployContext { - service_id: Some("SVC1".to_owned()), - application_manifest_path: Some(PathBuf::from("edgezero.toml")), - ..AdapterDeployContext::default() - }; - assert_eq!( - FastlyCliAdapter.preflight_deploy(&direct, &[]), - Ok(DeployOwnership::ManifestCommand) - ); - - let mut managed = direct.clone(); - managed.application_release_root = Some(PathBuf::from("release")); - assert_eq!( - FastlyCliAdapter.preflight_deploy(&managed, &[]), - Ok(DeployOwnership::AdapterManaged) - ); - - managed = direct.clone(); - managed.staging = true; - assert_eq!( - FastlyCliAdapter.preflight_deploy(&managed, &[]), - Ok(DeployOwnership::AdapterManaged) - ); - - for stores in [ - DeployStoreIds { - config: vec!["config".to_owned()], - ..DeployStoreIds::default() - }, - DeployStoreIds { - kv: vec!["kv".to_owned()], - ..DeployStoreIds::default() - }, - DeployStoreIds { - secrets: vec!["secret".to_owned()], - ..DeployStoreIds::default() - }, - ] { - managed = direct.clone(); - managed.stores = stores; - assert_eq!( - FastlyCliAdapter.preflight_deploy(&managed, &[]), - Ok(DeployOwnership::AdapterManaged) - ); - } - } - - #[cfg(unix)] - #[test] - fn managed_deploy_requires_application_release_before_provider_mutation() { - let _lock = path_mutation_guard().lock().expect("guard"); - let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "test-token"); - let context = AdapterDeployContext { - service_id: Some("SVC1".to_owned()), - staging: true, - ..AdapterDeployContext::default() - }; - - let error = FastlyCliAdapter - .deploy(&context, &[]) - .expect_err("managed staging deploy requires an immutable release"); - assert!(error.contains("--application-release"), "{error}"); - } - - #[cfg(unix)] - #[test] - fn manifest_command_finalization_binds_reported_version_to_active_service_version() { - let _lock = path_mutation_guard().lock().expect("guard"); - - let fake = tempdir().expect("provider fake"); - let marker = fake.path().join("provider.log"); - let curl = fake.path().join("curl"); - fs::write( - &curl, - format!( - "#!/bin/sh\ncat >/dev/null\nprintf 'called\\n' >> '{}'\nprintf '[{{\"number\":8,\"active\":true,\"locked\":true,\"staging\":false,\"deployed\":true,\"environments\":[{{\"active_version\":8,\"name\":\"production\",\"service_id\":\"SVC1\"}}]}}]\\n200'\n", - marker.display() - ), - ) - .expect("curl fake"); - let mut permissions = fs::metadata(&curl).expect("curl metadata").permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&curl, permissions).expect("curl executable"); - let _path = PathPrepend::new(fake.path()); - let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "test-token"); - let context = AdapterDeployContext { - service_id: Some("SVC1".to_owned()), - ..AdapterDeployContext::default() - }; - - FastlyCliAdapter - .finalize_deploy(&context, Some("version=8")) - .expect("active version finalization"); - assert!( - marker.exists(), - "finalization must consult the exact service" - ); - - let error = FastlyCliAdapter - .finalize_deploy(&context, Some("version=9")) - .expect_err("a version from another service or deployment must fail closed"); - assert!(error.contains('8') && error.contains('9'), "{error}"); - } - - #[test] - fn deploy_plan_final_argument_parser_accepts_comment_and_global_booleans() { - let parsed = parse_release_managed_deploy_args(&owned(&[ - "--comment=publisher deployment", - "--accept-defaults", - "-d", - "--auto-yes", - "-y", - "--debug-mode", - "--non-interactive", - "-i", - "--quiet", - "-q", - "--verbose", - "-v", - ])) - .expect("final managed argument forms"); - assert_eq!(parsed.comment.as_deref(), Some("publisher deployment")); - assert_eq!( - parsed.globals, - owned(&[ - "--accept-defaults", - "-d", - "--auto-yes", - "-y", - "--debug-mode", - "--non-interactive", - "-i", - "--quiet", - "-q", - "--verbose", - "-v" - ]) - ); - - let detached = - parse_release_managed_deploy_args(&owned(&["--comment", "publisher deployment"])) - .expect("detached comment form"); - assert_eq!(detached.comment.as_deref(), Some("publisher deployment")); - - let option_like = parse_release_managed_deploy_args(&owned(&["--comment=--json"])) - .expect("an attached option-like comment remains a literal value"); - assert_eq!(option_like.comment.as_deref(), Some("--json")); - } - - #[test] - fn deploy_plan_final_argument_parser_rejects_every_package_spelling() { - for args in [ - owned(&["--package", "app.tar.gz"]), - owned(&["--package=app.tar.gz"]), - owned(&["-p", "app.tar.gz"]), - owned(&["-p=app.tar.gz"]), - owned(&["-papp.tar.gz"]), - ] { - let error = parse_release_managed_deploy_args(&args) - .expect_err("the immutable release owns the package path"); - assert!(error.contains("--package/-p"), "{args:?}: {error}"); - } - } - - #[cfg(unix)] - #[test] - fn deploy_environment_parent_overrides_defaults_then_falls_back_to_logical_ids() { - let _lock = path_mutation_guard().lock().expect("guard"); - let config_name = "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME"; - let manifest_config_name = "EDGEZERO__stores__config__app_config__name"; - let kv_name = "EDGEZERO__STORES__KV__SESSIONS__NAME"; - let config_key = "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY"; - let _parent = EnvOverride::set(config_name, "parent_config"); - let _no_parent_kv = EnvOverride::remove(kv_name); - let _parent_key = EnvOverride::set(config_key, "parent_key"); - let context = AdapterDeployContext { - stores: DeployStoreIds { - config: vec!["app_config".to_owned(), "feature_flags".to_owned()], - kv: vec!["sessions".to_owned()], - secrets: vec!["default".to_owned()], - }, - variable_defaults: BTreeMap::from([ - ( - manifest_config_name.to_owned(), - "manifest_config".to_owned(), - ), - (config_key.to_owned(), "manifest_key".to_owned()), - (kv_name.to_owned(), "manifest_sessions".to_owned()), - ]), - ..AdapterDeployContext::default() - }; - - let environment = - effective_deploy_environment(&context).expect("valid effective environment"); - assert_eq!( - environment.store_name("config", "app_config"), - "parent_config", - "the parent process must override the manifest default" - ); - assert_eq!( - environment.store_name("kv", "sessions"), - "manifest_sessions", - "the manifest default must fill an absent parent value" - ); - assert_eq!( - environment.store_key("config", "app_config"), - "parent_key", - "the parent config key must override the manifest default" - ); - assert_eq!( - environment.store_name("config", "feature_flags"), - "feature_flags", - "an absent selector must fall back to the logical ID" - ); - assert_eq!( - environment.store_name("secrets", "default"), - "default", - "the logical id must fill an absent optional Secret Store selector" - ); - } - - #[cfg(unix)] - #[test] - fn deploy_environment_rejects_invalid_present_store_selectors() { - let _lock = path_mutation_guard().lock().expect("guard"); - let selector = "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME"; - let _no_parent_selector = EnvOverride::remove(selector); - let base = AdapterDeployContext { - stores: DeployStoreIds { - config: vec!["app_config".to_owned()], - ..DeployStoreIds::default() - }, - ..AdapterDeployContext::default() - }; - - let mut invalid_selector = base.clone(); - invalid_selector - .variable_defaults - .insert(selector.to_owned(), String::new()); - let selector_error = effective_deploy_environment(&invalid_selector) - .expect_err("a present empty selector must not fall back to the logical id"); - assert!( - selector_error.contains(selector), - "error names invalid selector: {selector_error}" - ); - - let _invalid_parent_selector = EnvOverride::set(selector, "bad\nselector"); - let mut invalid_parent = base.clone(); - invalid_parent - .variable_defaults - .insert(selector.to_owned(), "valid_manifest_selector".to_owned()); - let parent_error = effective_deploy_environment(&invalid_parent) - .expect_err("an invalid parent selector must not fall back to a valid default"); - assert!( - parent_error.contains(selector), - "error names parent selector: {parent_error}" - ); - } - - #[cfg(unix)] - #[test] - fn deploy_environment_rejects_invalid_value_before_provider_cli() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let manifest = dir.path().join("fastly.toml"); - fs::write(&manifest, "name = \"app\"\n").expect("manifest"); - let marker = dir.path().join("provider.log"); - let fake = fake_provider_invocation_marker(&marker); - let _path = PathPrepend::new(fake.path()); - let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "token"); - let selector = "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME"; - let _no_parent_selector = EnvOverride::remove(selector); - let context = AdapterDeployContext { - adapter_manifest_path: Some(manifest), - application_manifest_path: None, - application_release_root: None, - service_id: Some("SVC1".to_owned()), - staging: true, - stores: DeployStoreIds { - config: vec!["app_config".to_owned()], - ..DeployStoreIds::default() - }, - variable_defaults: BTreeMap::from([(selector.to_owned(), String::new())]), - }; - - let error = FastlyCliAdapter - .deploy(&context, &[]) - .expect_err("invalid runtime environment must fail managed deploy"); - assert!(error.contains(selector), "error names selector: {error}"); - assert_provider_not_invoked(&marker); - } - - // ── non-interactive CI safety (`--non-interactive`) ─────────────── - - #[test] - fn build_compute_deploy_args_is_non_interactive() { - // Without this a production deploy can block on an interactive - // prompt in CI. - let argv = build_compute_deploy_args(&owned(&["--service-id", "SVC1"])); - assert_eq!( - argv, - owned(&[ - "compute", - "deploy", - "--service-id", - "SVC1", - "--non-interactive" - ]) - ); - } - - #[test] - fn build_compute_deploy_args_does_not_duplicate_caller_flag() { - for flag in ["--non-interactive", "-i"] { - let argv = build_compute_deploy_args(&owned(&[flag])); - assert_eq!( - argv.iter() - .filter(|arg| *arg == "--non-interactive" || *arg == "-i") - .count(), - 1, - "must not pass the non-interactive switch twice ({flag})" - ); - } - } - - // ── healthcheck / rollback input validation ─────────────────────── - // - // GitHub Actions' `required: true` does NOT fail when an input is - // omitted or empty, so the CLI is the real guard. An absent / empty / - // malformed `--service-id` or `--version` must be rejected on BOTH - // the production and the staging path — a production healthcheck - // that probes anyway "verifies" a version it never looked at, and - // the caller chains that same version into rollback. - - #[test] - fn healthcheck_rejects_missing_or_empty_required_values_on_production() { - for (args, needle) in [ - ( - owned(&["--domain", "example.com", "--service-id", "SVC1"]), - "--version", - ), - ( - owned(&[ - "--domain", - "example.com", - "--service-id", - "SVC1", - "--version", - "", - ]), - "invalid version", - ), - ( - owned(&[ - "--domain", - "example.com", - "--service-id", - "SVC1", - "--version", - "15.2.0", - ]), - "invalid version", - ), - ( - owned(&[ - "--domain", - "example.com", - "--service-id", - "", - "--version", - "7", - ]), - "invalid service id", - ), - ( - owned(&["--domain", "", "--service-id", "SVC1", "--version", "7"]), - "invalid domain", - ), - ( - owned(&["--service-id", "SVC1", "--version", "7"]), - "--domain", - ), - ] { - let err = healthcheck(&args).expect_err("must reject absent/empty required value"); - assert!( - err.contains(needle), - "expected {needle:?} in error for {args:?}, got: {err}" - ); - } - } - - #[test] - fn healthcheck_rejects_empty_required_values_on_staging() { - for args in [ - owned(&[ - "--staging", - "--domain", - "example.com", - "--service-id", - "", - "--version", - "7", - ]), - owned(&[ - "--staging", - "--domain", - "example.com", - "--service-id", - "SVC1", - "--version", - "", - ]), - ] { - healthcheck(&args).expect_err("staging must reject empty required values"); - } - } - - #[test] - fn rollback_rejects_missing_or_invalid_required_values() { - for staging in [&[][..], &["--staging".to_owned()][..]] { - for bad in [ - owned(&["--service-id", "SVC1"]), - owned(&["--service-id", "SVC1", "--version", ""]), - owned(&["--service-id", "SVC1", "--version", "12abc"]), - owned(&["--service-id", "", "--version", "7"]), - ] { - let mut args = bad.clone(); - args.extend_from_slice(staging); - rollback(&args).expect_err("rollback must reject invalid required values"); - } - } - } - - // ── curl-config escaping + input validation (injection defence) ─── - - #[test] - fn curl_quote_escapes_quotes_and_backslashes() { - assert_eq!(curl_quote("plain"), "\"plain\""); - assert_eq!(curl_quote("a\"b"), "\"a\\\"b\""); - assert_eq!(curl_quote("a\\b"), "\"a\\\\b\""); - } - - #[test] - fn curl_quote_never_emits_raw_control_characters() { - // A token carrying a `"` and a newline must not be able to - // terminate its quoted value and inject a second `url = "..."` - // directive. The `"` is escaped and the newline is folded to a - // `\n` escape so NO raw newline reaches the curl config file. - let token = "tok\"en\nurl = \"https://evil.example\""; - let quoted = curl_quote(token); - assert!(quoted.starts_with('"') && quoted.ends_with('"')); - assert!(!quoted.contains('\n'), "no raw newline: {quoted}"); - assert!(!quoted.contains('\r')); - // The only unescaped `"` are the wrapping pair; every interior - // quote is preceded by a backslash. - assert_eq!(quoted, "\"tok\\\"en\\nurl = \\\"https://evil.example\\\"\""); - // A tab folds too. - assert_eq!(curl_quote("a\tb"), "\"a\\tb\""); - } - - #[test] - fn validate_service_id_accepts_fastly_handle() { - validate_service_id("SU1Z0isxPaozGVKXdv0eY").expect("alphanumeric handle"); - } - - #[test] - fn validate_service_id_rejects_punctuation() { - for invalid in ["SVC1_", "SVC-1", "SVC__OTHER"] { - let error = validate_service_id(invalid).expect_err("punctuation is not valid"); - assert_service_id_error(&error); - } - } - - #[test] - fn validate_service_id_rejects_injection_and_empty() { - // The canonical attack: a service id that closes the url value - // and appends a second url directive. - validate_service_id("abc\nurl = \"http://evil\"").expect_err("newline injection"); - validate_service_id("abc\"def").expect_err("quote"); - validate_service_id("has space").expect_err("space"); - validate_service_id("has/slash").expect_err("slash"); - validate_service_id("").expect_err("empty"); - } - - #[cfg(unix)] - #[test] - fn deploy_rejects_invalid_service_id_before_provider_cli() { - let _lock = path_mutation_guard().lock().expect("guard"); - let app = tempdir().expect("app dir"); - let manifest = app.path().join("fastly.toml"); - fs::write(&manifest, "name = \"app\"\n").expect("manifest"); - let marker = app.path().join("provider.log"); - let fake = fake_provider_invocation_marker(&marker); - let _path = PathPrepend::new(fake.path()); - let args = owned(&[ - "--manifest-path", - manifest.to_str().expect("utf8 manifest"), - "--service-id", - "SVC-1", - ]); - - let error = deploy(&args).expect_err("invalid service id must fail deploy"); - assert_service_id_error(&error); - assert_provider_not_invoked(&marker); - } - - #[cfg(unix)] - #[test] - fn active_version_rejects_invalid_service_id_before_provider_api() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let marker = dir.path().join("provider.log"); - let fake = fake_provider_invocation_marker(&marker); - let _path = PathPrepend::new(fake.path()); - let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "token"); - - let error = emit_active_version(&owned(&["--service-id", "SVC_1"])) - .expect_err("invalid service id must fail active-version capture"); - assert_service_id_error(&error); - assert_provider_not_invoked(&marker); - } - - #[cfg(unix)] - #[test] - fn healthcheck_rejects_invalid_service_id_before_provider_api() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let marker = dir.path().join("provider.log"); - let fake = fake_provider_invocation_marker(&marker); - let _path = PathPrepend::new(fake.path()); - let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "token"); - let args = owned(&[ - "--domain", - "example.com", - "--service-id", - "SVC-1", - "--version", - "7", - ]); - - let error = healthcheck(&args).expect_err("invalid service id must fail healthcheck"); - assert_service_id_error(&error); - assert_provider_not_invoked(&marker); - } - - #[cfg(unix)] - #[test] - fn rollback_rejects_invalid_service_id_before_provider_api() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let marker = dir.path().join("provider.log"); - let fake = fake_provider_invocation_marker(&marker); - let _path = PathPrepend::new(fake.path()); - let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "token"); - let args = owned(&[ - "--service-id", - "SVC_1", - "--version", - "7", - "--rollback-to", - "6", - ]); - - let error = rollback(&args).expect_err("invalid service id must fail rollback"); - assert_service_id_error(&error); - assert_provider_not_invoked(&marker); - } - - #[cfg(unix)] - #[test] - fn provision_rejects_invalid_service_id_before_provider_cli() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - fs::write( - dir.path().join("fastly.toml"), - "name = \"app\"\nservice_id = \"SVC-1\"\n", - ) - .expect("manifest"); - let marker = dir.path().join("provider.log"); - let fake = fake_provider_invocation_marker(&marker); - let _path = PathPrepend::new(fake.path()); - let _service_env = EnvOverride::remove(FASTLY_SERVICE_ID_ENV); - let kv = vec![ResolvedStoreId::from_logical("sessions")]; - let stores = ProvisionStores { - config: &[], - kv: &kv, - secrets: &[], - }; - - let error = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect_err("invalid manifest service id must fail provision"); - assert_service_id_error(&error); - assert_provider_not_invoked(&marker); - } - - #[cfg(unix)] - #[test] - fn adapter_deploy_rejects_invalid_service_id_before_provider_cli() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let manifest = dir.path().join("fastly.toml"); - fs::write(&manifest, "name = \"app\"\nservice_id = \"SVC_1\"\n").expect("manifest"); - let marker = dir.path().join("provider.log"); - let fake = fake_provider_invocation_marker(&marker); - let _path = PathPrepend::new(fake.path()); - let _service_env = EnvOverride::remove(FASTLY_SERVICE_ID_ENV); - let context = AdapterDeployContext { - adapter_manifest_path: Some(manifest), - ..AdapterDeployContext::default() - }; - - let error = FastlyCliAdapter - .deploy(&context, &[]) - .expect_err("direct adapter deploy must reject invalid service id"); - assert_service_id_error(&error); - assert_provider_not_invoked(&marker); - } - - #[cfg(unix)] - #[test] - fn adapter_deploy_discovers_and_rejects_invalid_service_id_before_provider_cli() { - const CHILD_ENV: &str = "EDGEZERO_FASTLY_DISCOVERY_TEST_CHILD"; - const MARKER_ENV: &str = "EDGEZERO_FASTLY_DISCOVERY_TEST_MARKER"; - - if env::var_os(CHILD_ENV).is_some() { - let marker = PathBuf::from(env::var_os(MARKER_ENV).expect("child marker path")); - let error = FastlyCliAdapter - .deploy(&AdapterDeployContext::default(), &[]) - .expect_err("discovered invalid service id must fail direct adapter deploy"); - assert_service_id_error(&error); - assert_provider_not_invoked(&marker); - return; - } - - let dir = tempdir().expect("tempdir"); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"app\"\n") - .expect("Cargo manifest"); - fs::write( - dir.path().join("fastly.toml"), - "name = \"app\"\nservice_id = \"SVC-1\"\n", - ) - .expect("manifest"); - let marker = dir.path().join("provider.log"); - let fake = fake_provider_invocation_marker(&marker); - let path = env::join_paths( - once(fake.path().to_path_buf()) - .chain(env::split_paths(&env::var_os("PATH").unwrap_or_default())), - ) - .expect("test PATH"); - let output = Command::new(env::current_exe().expect("current test binary")) - .args([ - "--exact", - "cli::tests::adapter_deploy_discovers_and_rejects_invalid_service_id_before_provider_cli", - "--nocapture", - ]) - .current_dir(dir.path()) - .env(CHILD_ENV, "1") - .env(MARKER_ENV, &marker) - .env("PATH", path) - .env_remove(FASTLY_SERVICE_ID_ENV) - .output() - .expect("run isolated discovery regression"); - - assert!( - output.status.success(), - "isolated discovery regression failed:\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert_provider_not_invoked(&marker); - } - - #[test] - fn validate_version_str_accepts_integer_rejects_junk() { - assert_eq!(validate_version_str("42"), Ok(42)); - assert_eq!(validate_version_str("0"), Ok(0)); - validate_version_str("-1").expect_err("negative"); - validate_version_str("4.2").expect_err("float"); - validate_version_str("42\nurl = \"x\"").expect_err("newline injection"); - validate_version_str("").expect_err("empty"); - } - - #[test] - fn validate_domain_accepts_hostnames_rejects_injection() { - validate_domain("example.com").expect("bare hostname"); - validate_domain("staging.example.co.uk").expect("multi-label hostname"); - validate_domain("host-1.example.com").expect("hostname with dash"); - validate_domain("").expect_err("empty"); - validate_domain(".example.com").expect_err("leading dot"); - validate_domain("example.com.").expect_err("trailing dot"); - validate_domain("exa..mple.com").expect_err("empty label"); - validate_domain("example.com/evil").expect_err("slash"); - validate_domain("example.com\nurl = \"x\"").expect_err("newline injection"); - validate_domain("has space.com").expect_err("space"); - } - - #[test] - fn version_active_verdict_enforces_the_production_version_contract() { - // The requested version is the active one: healthy. - version_active_verdict(Some(7), 7, "SVC1", "before probing").expect("match is ok"); - // A different active version (a concurrent deploy) must fail closed and name - // BOTH versions so the mismatch is diagnosable. - let err = version_active_verdict(Some(9), 7, "SVC1", "after probing") - .expect_err("a newer active version must fail the version contract"); - assert!(err.contains('7') && err.contains('9'), "{err}"); - // No active version at all is not a healthy version-7 report either. - version_active_verdict(None, 7, "SVC1", "before probing") - .expect_err("no active version must fail the contract"); - } - - #[test] - fn is_healthy_status_covers_2xx_only() { - assert!(is_healthy_status(200)); - assert!(is_healthy_status(204)); - assert!(is_healthy_status(299)); - // 3xx is NOT healthy: the probe does not follow redirects, so a 301 to an - // error page must not pass a gate that suppresses an automatic rollback. - assert!(!is_healthy_status(301)); - assert!(!is_healthy_status(399)); - assert!(!is_healthy_status(400)); - assert!(!is_healthy_status(500)); - assert!(!is_healthy_status(199)); - } - - #[test] - fn parse_fastly_version_handles_the_shapes_fastly_emits() { - // The Fastly CLI's own success lines. Go format strings: - // "Updated package (service %s, version %v)" (compute update) - // "Deployed package (service %s, version %v)" (compute deploy) - assert_eq!( - parse_fastly_version("SUCCESS: Deployed package (service abc, version 7)"), - Some(7) - ); - assert_eq!( - parse_fastly_version("\nSUCCESS: Updated package (service SU1Z0, version 42)\n"), - Some(42) - ); - // Our canonical contract line. - assert_eq!(parse_fastly_version("version=12"), Some(12)); - // The --autoclone notice, when no success line is present. - assert_eq!( - parse_fastly_version( - "Service version 3 is not editable, so it was automatically cloned because \ - --autoclone is enabled. Now operating on version 4." - ), - Some(4) - ); - // Full autoclone + success output: the SUCCESS line wins, and the - // PRE-clone version (3) never does — even though stdout/stderr are - // concatenated and their relative order is not guaranteed. - let combined = "SUCCESS: \nUpdated package (service abc, version 4)\n\ - Service version 3 is not editable, so it was automatically cloned. \ - Now operating on version 4."; - assert_eq!(parse_fastly_version(combined), Some(4)); - assert_eq!(parse_fastly_version("no numbers here"), None); - } - - #[test] - fn parse_fastly_version_rejects_confusable_lines() { - // The old parser took ANY digits after the word "version", so each - // of these silently produced a WRONG service version. They must now - // all be `None`, which makes managed deployment fail closed. - assert_eq!( - parse_fastly_version("Uploaded package to service 12345, version unchanged"), - None - ); - // The CLI's own semver must not be mistaken for a service version. - assert_eq!(parse_fastly_version("Fastly CLI version 15.2.0"), None); - assert_eq!( - parse_fastly_version("Checking version compatibility for service 99"), - None - ); - // A bare `version ` mention with no success-line context is not - // trusted either. - assert_eq!(parse_fastly_version("cloning version 3"), None); - // `--version=active` echoed in a command line is not a contract line. - assert_eq!( - parse_fastly_version("running: fastly compute update --version=active"), - None - ); - } - - #[test] - fn cloned_version_requires_a_new_version_for_the_exact_service() { - assert_eq!( - parse_cloned_version(r#"{"service_id":"svc","number":42}"#, "svc", 40), - Ok(42) - ); - for invalid in [ - r#"{"service_id":"other","number":42}"#, - r#"{"service_id":"svc","number":40}"#, - r#"{"service_id":"svc","number":"42"}"#, - "[]", - "not json", - ] { - parse_cloned_version(invalid, "svc", 40) - .expect_err("an ambiguous clone response must fail closed"); - } - } - - #[test] - fn parse_active_version_finds_active_entry() { - let json = r#"[ - {"number":1,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[]}, - {"number":2,"active":true,"locked":true,"staging":false,"deployed":true,"environments":[]}, - {"number":3,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]} - ]"#; - assert_eq!(resolve_active_version(json), Ok(Some(2))); - } - - #[test] - fn deploy_plan_version_source_parses_state_and_selects_active() { - let versions = parse_service_versions( - r#"[ - {"number":1,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[{"active_version":2,"name":"production","service_id":"SVC1"}]}, - {"number":2,"active":true,"locked":true,"staging":false,"deployed":true,"environments":[{"active_version":2,"name":"production","service_id":"SVC1"}]} - ]"#, - ) - .expect("typed version list"); - assert_eq!(versions[1].number, 2); - assert!(versions[1].active); - assert!(versions[1].locked); - assert_eq!(versions[1].environments[0].name, "production"); - assert_eq!( - select_version_source(&versions), - Ok(VersionSource::Active(2)) - ); - } - - #[test] - fn deploy_plan_version_source_parses_complete_fastly_api_records() { - let versions = parse_service_versions( - r#"[{ - "active":true, - "comment":"publisher release", - "created_at":"2026-09-16T00:00:00Z", - "deployed":true, - "environments":[{ - "active_version":7, - "name":"production", - "service_id":"SVC1" - }], - "locked":true, - "number":7, - "service_id":"SVC1", - "staging":false, - "testing":false, - "updated_at":"2026-09-16T00:00:00Z" - }]"#, - ) - .expect("authoritative Fastly API version list"); - assert_eq!(versions[0].environments[0].active_version, 7); - assert_eq!(versions[0].environments[0].name, "production"); - assert_eq!(versions[0].environments[0].service_id, "SVC1"); - assert_eq!( - select_version_source(&versions), - Ok(VersionSource::Active(7)) - ); - } - - #[test] - fn version_source_parses_fastly_cli_15_1_capitalized_records() { - let versions = parse_service_versions( - r#"[{ - "Active":true, - "Comment":"publisher release", - "Deployed":true, - "Environments":[{ - "ServiceVersion":7, - "Name":"production", - "ServiceID":"SVC1" - }], - "Locked":true, - "Number":7, - "ServiceID":"SVC1", - "Staging":false, - "Testing":false - }]"#, - ) - .expect("Fastly CLI 15.1 version JSON"); - assert_eq!(versions[0].environments[0].name, "production"); - assert_eq!( - select_version_source(&versions), - Ok(VersionSource::Active(7)) - ); - } - - #[test] - fn deploy_plan_version_source_rejects_every_missing_authoritative_field() { - const VERSION_NUMBER: u64 = 1; - - let complete = serde_json::json!({ - "active": false, - "deployed": false, - "environments": [], - "locked": false, - "number": VERSION_NUMBER, - "staging": false - }); - for field in ["active", "environments", "locked", "number"] { - let mut record = complete.clone(); - record - .as_object_mut() - .expect("version object") - .remove(field); - let raw = serde_json::json!([record]).to_string(); - parse_service_versions(&raw).expect_err(&format!("missing `{field}` must fail closed")); - } - } - - #[test] - fn deploy_plan_version_source_rejects_orphan_draft_beside_retired_source() { - let versions = parse_service_versions( - r#"[ - {"number":1,"active":false,"locked":true,"staging":false,"deployed":false,"environments":[]}, - {"number":2,"active":false,"locked":false,"staging":true,"deployed":true,"environments":[]} - ]"#, - ) - .expect("typed version list"); - let error = select_version_source(&versions) - .expect_err("an editable draft beside a retired source has no provable owner"); - assert!( - error.contains("editable Fastly draft 2") - && error.contains("retired version") - && error.contains("cannot prove"), - "error must identify the orphan draft and retired source: {error}" - ); - } - - #[test] - fn deploy_plan_version_source_selects_unique_shadow_staging_source_without_active() { - let versions = parse_service_versions( - r#"[{"number":3,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[{"active_version":3,"name":"staging","service_id":"shadow-staging-service"}]}]"#, - ) - .expect("staged version list"); - assert_eq!( - select_version_source(&versions), - Ok(VersionSource::Staging(3)) - ); - } - - #[test] - fn deploy_plan_version_source_recovers_first_staging_deactivation() { - let versions = parse_service_versions( - r#"[{"number":1,"active":false,"locked":true,"staging":false,"deployed":false,"environments":[]}]"#, - ) - .expect("retired first staging version"); - assert_eq!( - select_version_source(&versions), - Ok(VersionSource::Retired(1)) - ); - } - - #[test] - fn deploy_plan_version_source_rejects_retry_draft_beside_staging() { - let versions = parse_service_versions( - r#"[ - {"number":1,"active":false,"locked":true,"environments":[{"active_version":1,"name":"staging","service_id":"shadow-staging-service"}]}, - {"number":2,"active":false,"locked":false,"environments":[]} - ]"#, - ) - .expect("staged source and one retry draft"); - let error = select_version_source(&versions) - .expect_err("a retry draft must not replace the real staged source"); - assert!( - error.contains("editable Fastly draft 2") - && error.contains("staged version 1") - && error.contains("fastly service version lock --version=2") - && !error.contains("first deployment"), - "error must identify both versions and the fail-closed recovery: {error}" - ); - } - - #[test] - fn deploy_plan_version_source_rejects_missing_duplicate_or_ambiguous_staging_source() { - for invalid in [ - r#"[{"number":2,"active":false,"locked":true,"staging":false,"deployed":false,"environments":[{"active_version":2,"name":"staging","service_id":"shadow-staging-service"}]},{"number":3,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[{"active_version":3,"name":"staging","service_id":"shadow-staging-service"}]}]"#, - r#"[{"number":3,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[{"active_version":2,"name":"staging","service_id":"shadow-staging-service"}]}]"#, - r#"[{"number":3,"active":false,"locked":true,"staging":true,"deployed":true,"environments":[{"active_version":3,"name":"production","service_id":"SVC1"}]}]"#, - ] { - let versions = parse_service_versions(invalid).expect("well-formed version list"); - assert!( - select_version_source(&versions).is_err(), - "unsafe staged source must fail closed: {invalid}" - ); - } - } - - #[test] - fn deploy_plan_version_source_rejects_duplicate_global_staging_records() { - let versions = parse_service_versions( - r#"[ - {"number":3,"active":false,"locked":true,"environments":[{"active_version":3,"name":"staging","service_id":"shadow-staging-service"}]}, - {"number":4,"active":false,"locked":true,"environments":[{"active_version":3,"name":"staging","service_id":"shadow-staging-service"}]} - ]"#, - ) - .expect("well-formed but ambiguous version list"); - - let error = select_version_source(&versions) - .expect_err("more than one staging environment record must fail closed"); - assert!( - error.contains("more than one staging environment record"), - "error must identify the provider-state ambiguity: {error}" - ); - } - - #[test] - fn deploy_plan_version_source_rejects_missing_duplicate_and_malformed_versions() { - for invalid in [ - "[]", - r#"[{"number":1},{"number":1}]"#, - r#"[{"number":"1"}]"#, - r#"[{"number":1,"active":"false"}]"#, - r#"[{"number":1,"locked":"false"}]"#, - r#"[{"number":1,"environments":"staging"}]"#, - ] { - assert!( - parse_service_versions(invalid).is_err(), - "invalid version list must fail: {invalid}" - ); - } - } - - #[test] - fn deploy_plan_version_source_ignores_unused_deployed_and_staging_fields() { - let versions = parse_service_versions( - r#"[{"number":1,"active":false,"locked":false,"staging":"unused","deployed":{"unused":true},"environments":[]}]"#, - ) - .expect("unused provider fields must not control version state"); - assert_eq!( - select_version_source(&versions), - Ok(VersionSource::InitialDraft(1)) - ); - } - - #[test] - fn deploy_plan_version_source_rejects_unsafe_or_ambiguous_first_drafts() { - for invalid in [ - r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]},{"number":2,"active":false,"locked":true,"staging":false,"deployed":false,"environments":[]}]"#, - r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]},{"number":2,"active":false,"locked":false,"staging":true,"deployed":false,"environments":[]}]"#, - r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]},{"number":2,"active":false,"locked":false,"staging":false,"deployed":true,"environments":[]}]"#, - r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]},{"number":2,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[{"active_version":2,"name":"staging","service_id":"SVC1"}]}]"#, - r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]},{"number":2,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]}]"#, - ] { - let versions = parse_service_versions(invalid).expect("well-formed versions"); - assert!( - select_version_source(&versions).is_err(), - "unsafe first-deploy source must fail: {invalid}" - ); - } - } - - #[test] - fn publication_barrier_rejects_draft_that_is_no_longer_editable() { - let versions = parse_service_versions( - r#"[{"number":7,"active":false,"locked":true,"environments":[]}]"#, - ) - .expect("locked draft record"); - - let error = require_managed_draft_state(VersionSource::InitialDraft(7), &versions, 7) - .expect_err("publication must reject a locked target"); - assert!(error.contains("no longer an unpublished editable draft")); - } - - #[test] - fn publication_barrier_rejects_active_version_appearing_for_initial_draft() { - let versions = parse_service_versions( - r#"[ - {"number":6,"active":true,"locked":true,"environments":[{"active_version":6,"name":"production","service_id":"production-service"}]}, - {"number":7,"active":false,"locked":false,"environments":[]} - ]"#, - ) - .expect("draft plus foreign active version"); - - let error = require_managed_draft_state(VersionSource::InitialDraft(7), &versions, 7) - .expect_err("publication must reject a newly active version"); - assert!(error.contains("active version appeared before publication")); - } - - #[test] - fn publication_barrier_excludes_the_new_clone_when_revalidating_its_source() { - let versions = parse_service_versions( - r#"[ - {"number":3,"active":false,"locked":true,"environments":[{"active_version":3,"name":"staging","service_id":"shadow-staging-service"}]}, - {"number":4,"active":false,"locked":false,"environments":[]} - ]"#, - ) - .expect("staged source plus cloned target"); - - let source_versions = source_versions_excluding(&versions, Some(4)); - assert_eq!( - select_version_source(&source_versions), - Ok(VersionSource::Staging(3)) - ); - assert!( - select_version_source(&versions).is_err(), - "including the target would misclassify the provider state as ambiguous" - ); - } - - #[test] - fn publication_barrier_rejects_package_identity_mismatch() { - let error = require_package_identity("expected-hash", "foreign-hash", 42) - .expect_err("publication must reject a replaced package"); - assert!(error.contains("version 42 package identity changed")); - } - - #[test] - fn publication_barrier_rejects_protected_configuration_mismatch() { - fn snapshot(domain: &str) -> VersionConfigurationSnapshot { - VersionConfigurationSnapshot { - backends: serde_json::json!([]), - domains: serde_json::json!([{ "name": domain }]), - healthchecks: serde_json::json!([]), - logging: Vec::new(), - settings: serde_json::json!({}), - } - } - - let error = require_draft_configuration( - &snapshot("expected.example"), - &snapshot("foreign.example"), - 42, - ) - .expect_err("publication must reject protected configuration drift"); - assert!(error.contains("protected Compute configuration changed")); - } - - fn deploy_plan_inventories() -> ResourceInventories { - ResourceInventories::from_json( - r#"[ - {"id":"CONFIG_A","name":"config-a"}, - {"id":"CONFIG_B","name":"config-b"} - ]"#, - r#"[{"id":"KV_A","name":"kv-a"},{"id":"KV_B","name":"kv-b"}]"#, - r#"[{"id":"SECRET_A","name":"secret-a"}]"#, - ) - .expect("valid inventories") - } - - fn desired_link( - kind: ResourceKind, - alias: &str, - selected_name: &str, - resource_id: &str, - ) -> DesiredResourceLink { - DesiredResourceLink { - alias: alias.to_owned(), - kind, - resource_id: resource_id.to_owned(), - selected_name: selected_name.to_owned(), - } - } - - fn existing_link( - kind: ResourceKind, - alias: &str, - resource_id: &str, - link_id: &str, - ) -> ExistingResourceLink { - ExistingResourceLink { - alias: alias.to_owned(), - kind, - link_id: link_id.to_owned(), - resource_id: resource_id.to_owned(), - } - } - - #[test] - fn deploy_plan_uses_logical_aliases_and_selected_physical_resources() { - let environment = EnvConfig::from_vars([ - ("EDGEZERO__STORES__CONFIG__SHARED__NAME", "config-a"), - ("EDGEZERO__STORES__KV__SHARED__NAME", "kv-a"), - ("EDGEZERO__STORES__SECRETS__CREDENTIALS__NAME", "secret-a"), - ]); - let desired = desired_resource_links( - &RuntimeStoreIds { - config: vec!["shared".to_owned()], - kv: vec!["shared".to_owned()], - secrets: vec!["credentials".to_owned()], - }, - &environment, - &deploy_plan_inventories(), - ) - .expect("desired links"); - - assert_eq!( - desired, - vec![ - desired_link(ResourceKind::Config, "shared", "config-a", "CONFIG_A"), - desired_link(ResourceKind::Kv, "shared", "kv-a", "KV_A"), - desired_link(ResourceKind::Secret, "credentials", "secret-a", "SECRET_A",), - ] - ); - } - - #[test] - fn deploy_plan_rejects_present_invalid_store_name_before_inventory_lookup() { - for value in ["", " ", "bad\nname"] { - let environment = - EnvConfig::from_vars([("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME", value)]); - let error = desired_resource_links( - &RuntimeStoreIds { - config: vec!["app_config".to_owned()], - ..RuntimeStoreIds::default() - }, - &environment, - &deploy_plan_inventories(), - ) - .expect_err("present invalid selector must fail"); - assert!( - error.contains("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME"), - "invalid selector error must name its canonical variable" - ); - } - } - - #[test] - fn deploy_plan_replaces_declared_identity_and_preserves_undeclared_links() { - let desired = vec![desired_link( - ResourceKind::Config, - "app_config", - "config-b", - "CONFIG_B", - )]; - let existing = vec![ - existing_link(ResourceKind::Config, "app_config", "CONFIG_A", "OLD_CONFIG"), - existing_link(ResourceKind::Kv, "sessions", "KV_A", "KEEP_KV"), - ]; - - let plan = plan_link_reconciliation(&desired, &existing, &deploy_plan_inventories()) - .expect("reconciliation"); - assert_eq!(plan.delete_link_ids, vec!["OLD_CONFIG"]); - assert_eq!(plan.create, desired); - } - - #[test] - fn deploy_plan_preserves_undeclared_link_absent_from_visible_inventories() { - let existing = vec![existing_link( - ResourceKind::Secret, - "shared_by_another_app", - "INACCESSIBLE_SECRET", - "KEEP_SECRET", - )]; - - let plan = plan_link_reconciliation(&[], &existing, &deploy_plan_inventories()) - .expect("an undeclared inherited link does not require inventory visibility"); - assert!(plan.delete_link_ids.is_empty()); - assert!(plan.create.is_empty()); - } - - #[test] - fn deploy_plan_keeps_same_alias_isolated_by_resource_kind() { - let desired = vec![ - desired_link(ResourceKind::Config, "shared", "config-a", "CONFIG_A"), - desired_link(ResourceKind::Kv, "shared", "kv-b", "KV_B"), - ]; - let existing = vec![ - existing_link(ResourceKind::Config, "shared", "CONFIG_A", "CONFIG_LINK"), - existing_link(ResourceKind::Kv, "shared", "KV_A", "KV_LINK"), - ]; - - let plan = plan_link_reconciliation(&desired, &existing, &deploy_plan_inventories()) - .expect("kind-isolated reconciliation"); - assert_eq!(plan.delete_link_ids, vec!["KV_LINK"]); - assert_eq!( - plan.create, - vec![desired_link(ResourceKind::Kv, "shared", "kv-b", "KV_B")] - ); - } - - #[test] - fn deploy_plan_rejects_reported_kind_that_conflicts_with_inventory() { - let existing = vec![existing_link( - ResourceKind::Secret, - "credentials", - "CONFIG_A", - "BAD_KIND", - )]; - let error = plan_link_reconciliation(&[], &existing, &deploy_plan_inventories()) - .expect_err("kind conflict must fail"); - assert!(error.contains("reports Secret Store"), "{error}"); - assert!(error.contains("belongs to Config Store"), "{error}"); - } - - #[test] - fn resource_link_parser_requires_known_resource_type() { - let raw = r#"[ - {"id":"CONFIG_LINK","name":"shared","resource_id":"CONFIG_A","resource_type":"config"}, - {"id":"KV_LINK","name":"shared","resource_id":"KV_A","resource_type":"kv-store"}, - {"id":"SECRET_LINK","name":"credentials","resource_id":"SECRET_A","resource_type":"secret-store"} - ]"#; - let (links, _) = parse_resource_links(raw).expect("typed links"); - assert_eq!(links[0].kind, ResourceKind::Config); - assert_eq!(links[1].kind, ResourceKind::Kv); - assert_eq!(links[2].kind, ResourceKind::Secret); - - let unknown = - r#"[{"id":"LINK","name":"x","resource_id":"X","resource_type":"dictionary"}]"#; - let error = parse_resource_links(unknown).expect_err("unknown type must fail"); - assert!(error.contains("unknown `resource_type`"), "{error}"); - } - - #[test] - fn version_configuration_snapshot_normalizes_clone_metadata_and_order() { - assert_eq!( - parse_snapshot_array( - "backends", - r#"[ - {"name":"origin-b","hostname":"b.example","service_id":"SVC","version":40,"created_at":"old"}, - {"name":"origin-a","hostname":"a.example","locked":true,"updated_at":"old"} - ]"#, - ) - .expect("source snapshot"), - parse_snapshot_array( - "backends", - r#"[ - {"name":"origin-a","hostname":"a.example","locked":false,"updated_at":"new"}, - {"name":"origin-b","hostname":"b.example","service_id":"SVC","version":42,"created_at":"new"} - ]"#, - ) - .expect("clone snapshot") - ); - - parse_snapshot_array("backends", r#"[{"name":"origin"}, null]"#) - .expect_err("non-object collection entries must fail closed"); - parse_snapshot_array("backends", "{}").expect_err("objects are not collections"); - parse_snapshot_object("settings", "[]").expect_err("arrays are not settings objects"); - assert_eq!( - parse_snapshot_object( - "settings", - r#"{"general.default_ttl":3600,"service_id":"SVC","version":40}"#, - ), - parse_snapshot_object( - "settings", - r#"{"general.default_ttl":3600,"service_id":"SVC","version":42}"#, - ) - ); - } - - #[test] - fn version_configuration_snapshot_ignores_json_object_key_order() { - let source = parse_snapshot_array( - "backends", - r#"[ - {"name":"origin-a","hostname":"z.example","tls":{"check":true,"sni":"z.example"}}, - {"name":"origin-b","hostname":"a.example","tls":{"check":false,"sni":"a.example"}} - ]"#, - ) - .expect("source snapshot"); - let reread = parse_snapshot_array( - "backends", - r#"[ - {"tls":{"sni":"a.example","check":false},"hostname":"a.example","name":"origin-b"}, - {"tls":{"sni":"z.example","check":true},"hostname":"z.example","name":"origin-a"} - ]"#, - ) - .expect("reordered snapshot"); - - assert_eq!(source, reread); - } - - #[test] - fn logging_snapshot_uses_fastly_api_provider_paths() { - assert!(FASTLY_LOGGING_PROVIDER_KINDS.contains(&"pubsub")); - assert!(FASTLY_LOGGING_PROVIDER_KINDS.contains(&"logentries")); - assert!(FASTLY_LOGGING_PROVIDER_KINDS.contains(&"s3")); - assert!(!FASTLY_LOGGING_PROVIDER_KINDS.contains(&"googlepubsub")); - } - - #[test] - fn fastly_config_keys_use_the_logical_id_for_every_target() { - validate_fastly_config_key("app_config", "app_config", false, false) - .expect("production key"); - validate_fastly_config_key("app_config", "app_config", true, false) - .expect("staging target key"); - validate_fastly_config_key("app_config", "app_config", false, true).expect("local key"); - - for (key, staging, local) in [ - ("custom", false, false), - ("alternate", true, false), - ("alternate", false, true), - ] { - let error = validate_fastly_config_key("app_config", key, staging, local) - .expect_err("conflicting key must fail"); - assert!(error.contains("logical config key"), "{error}"); - } - } - - #[test] - fn parse_active_version_none_when_no_active() { - // A parsed list with no active version is `Ok(None)` — confirmed - // no active version (first deploy), NOT an operational failure. - let json = r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]}]"#; - assert_eq!(resolve_active_version(json), Ok(None)); - } - - #[test] - fn resolve_active_version_errors_on_unparseable_payload() { - // A truncated / non-array body is an operational failure, distinct from - // "no active version" — the caller must fail closed, not record empty. - resolve_active_version("not json").expect_err("non-JSON must be an operational error"); - resolve_active_version(r#"{"error":"unauthorized"}"#) - .expect_err("a non-array body must be an operational error"); - } - - #[test] - fn resolve_active_version_errors_on_malformed_active_entries() { - // A garbled ACTIVE entry must fail closed, not read as "no active - // version" — otherwise a production deploy proceeds with no rollback - // target. Each of these is malformed and must be an operational error. - resolve_active_version(r#"[{"active":true}]"#) - .expect_err("active entry with no `number` must error"); - resolve_active_version(r#"[{"active":true,"number":"7"}]"#) - .expect_err("active entry with a string `number` must error"); - resolve_active_version(r#"[{"active":"true","number":7}]"#) - .expect_err("a non-boolean `active` must error"); - // A non-boolean `active` ANYWHERE is schema drift — the whole list is - // scanned, so it is caught even AFTER a valid active entry (a naive - // first-match parser would miss this one). - resolve_active_version(r#"[{"active":"false"},{"active":true,"number":9}]"#) - .expect_err("a non-boolean `active` before the active entry is schema drift"); - resolve_active_version(r#"[{"active":true,"number":9},{"active":"nope"}]"#) - .expect_err("a non-boolean `active` AFTER the active entry is still schema drift"); - // More than one active version is ambiguous — refuse rather than pick one. - resolve_active_version(r#"[{"active":true,"number":9,"locked":true,"staging":false,"deployed":true,"environments":[]},{"active":true,"number":10,"locked":true,"staging":false,"deployed":true,"environments":[]}]"#) - .expect_err("two active versions must error as ambiguous"); - // EVERY element must be a version object with a numeric `number` — a - // garbled entry must fail closed, not be skipped as "not active". - resolve_active_version("[]").expect_err("an empty version list is an invalid response"); - resolve_active_version("[null]").expect_err("a null element must error"); - resolve_active_version("[{}]").expect_err("an entry with no `number` must error"); - resolve_active_version(r#"[{"number":"invalid"}]"#) - .expect_err("a non-numeric `number` must error"); - // Every safety field is mandatory; omission is schema drift. - resolve_active_version( - r#"[{"number":42,"locked":false,"staging":false,"deployed":false,"environments":[]}]"#, - ) - .expect_err("an omitted active field must fail closed"); - // Sanity: a well-formed list still resolves. - assert_eq!( - resolve_active_version( - r#"[{"active":false,"number":1,"locked":true,"staging":false,"deployed":true,"environments":[]},{"active":true,"number":2,"locked":true,"staging":false,"deployed":true,"environments":[]}]"# - ), - Ok(Some(2)) - ); - } - - #[test] - fn ensure_rollback_from_is_active_blocks_racing_deploys() { - // The version being rolled back FROM is still active → proceed. - assert_eq!(ensure_rollback_from_is_active(Some(7), 7, "svc"), Ok(())); - // A NEWER version is active (a deploy raced the rollback) → refuse, so - // the newer deploy is not clobbered. - ensure_rollback_from_is_active(Some(9), 7, "svc") - .expect_err("a newer active version must block the rollback"); - // No active version at all → refuse. - ensure_rollback_from_is_active(None, 7, "svc") - .expect_err("no active version must block the rollback"); - } - - #[test] - fn staging_rollback_after_failure_before_stage_is_a_noop() { - let versions = parse_service_versions( - r#"[{"active":false,"number":7,"locked":false,"staging":true,"deployed":true,"environments":[]}]"#, - ) - .expect("version list"); - assert_eq!( - staging_rollback_decision(&versions, 7, "svc"), - Ok(StagingRollbackDecision::NoopDraft) - ); - } - - #[test] - fn staging_rollback_after_failure_after_stage_deactivates_exact_version() { - let versions = parse_service_versions( - r#"[{"active":false,"number":7,"locked":false,"staging":false,"deployed":false,"environments":[{"active_version":7,"name":"staging","service_id":"shadow-staging-service"}]}]"#, - ) - .expect("version list"); - assert_eq!( - staging_rollback_decision(&versions, 7, "svc"), - Ok(StagingRollbackDecision::Deactivate) - ); - staging_rollback_decision(&versions, 8, "svc") - .expect_err("an absent version must fail closed"); - } - - #[test] - fn staging_rollback_uses_the_exact_staging_environment_record() { - let versions = parse_service_versions( - r#"[{"active":true,"number":7,"locked":true,"staging":false,"deployed":false,"environments":[{"active_version":7,"name":"production","service_id":"production-service"},{"active_version":7,"name":"staging","service_id":"shadow-staging-service"}]}]"#, - ) - .expect("version list"); - assert_eq!( - staging_rollback_decision(&versions, 7, "svc"), - Ok(StagingRollbackDecision::Deactivate) - ); - - let duplicate = parse_service_versions( - r#"[{"active":false,"number":7,"locked":true,"environments":[{"active_version":7,"name":"staging","service_id":"shadow-staging-service"},{"active_version":7,"name":"staging","service_id":"shadow-staging-service"}]}]"#, - ) - .expect("version list"); - staging_rollback_decision(&duplicate, 7, "svc") - .expect_err("duplicate staging environment records must fail closed"); - - let cross_version_duplicate = parse_service_versions( - r#"[{"active":false,"number":7,"locked":true,"environments":[{"active_version":7,"name":"staging","service_id":"shadow-staging-service"}]},{"active":false,"number":8,"locked":true,"environments":[{"active_version":8,"name":"staging","service_id":"shadow-staging-service"}]}]"#, - ) - .expect("version list"); - staging_rollback_decision(&cross_version_duplicate, 7, "svc") - .expect_err("staging records on multiple versions must fail closed"); - } - - #[test] - fn active_version_or_require_enforces_require_active() { - let active = r#"[{"active":true,"number":5,"locked":true,"staging":false,"deployed":true,"environments":[]}]"#; - let none = r#"[{"active":false,"number":5,"locked":false,"staging":false,"deployed":false,"environments":[]}]"#; - - // A resolvable active version is returned regardless of the flag. - assert_eq!(active_version_or_require(active, false, "svc"), Ok(Some(5))); - assert_eq!(active_version_or_require(active, true, "svc"), Ok(Some(5))); - - // No active version: tolerated for `active-version` (first deploy), but an - // ERROR for the production-deploy fallback (`--require-active`), which - // must never report a deploy with no resolvable version. - assert_eq!(active_version_or_require(none, false, "svc"), Ok(None)); - active_version_or_require(none, true, "svc") - .expect_err("require-active with no active version must fail closed"); - - // A malformed response is an error either way. - active_version_or_require("not json", false, "svc").expect_err("malformed must error"); - } - - #[test] - fn parse_staging_ip_reads_the_singular_staging_ip_field() { - // The REAL Fastly response shape for - // `GET /service//version//domain?include=staging_ips`: - // an array of domain objects, each with a SINGULAR `staging_ip` - // STRING. Body copied from go-fastly's recorded API fixture - // `fastly/fixtures/domains/list_with_staging_ips.yaml`, matching - // its `StagingIP *string `mapstructure:"staging_ip"`` field. - // (`staging_ips` is only the `include=` query value, never a - // field name — the previous parser looked for it as an array and - // therefore NEVER found a staging IP.) - let json = r#"[ - { - "created_at": "2022-11-04T17:36:56Z", - "service_id": "kKJb5bOFI47uHeBVluGfX1", - "name": "integ-test-20221104.go-fastly-1.com", - "version": 73, - "comment": "comment", - "deleted_at": null, - "staging_ip": "167.82.81.194" - } - ]"#; - assert_eq!( - parse_staging_ip(json, "integ-test-20221104.go-fastly-1.com"), - Ok("167.82.81.194".to_owned()) - ); - } - - #[test] - fn parse_staging_ip_selects_the_requested_domain() { - let json = r#"[ - {"name":"other.example.com","staging_ip":"151.101.1.10"}, - {"name":"example.com","staging_ip":"151.101.2.10"} - ]"#; - assert_eq!( - parse_staging_ip(json, "example.com"), - Ok("151.101.2.10".to_owned()) - ); - } - - #[test] - fn parse_staging_ip_rejects_missing_duplicate_or_malformed_domain_records() { - for json in [ - r#"[{"name":"other.example.com","staging_ip":"151.101.1.10"}]"#, - r#"[{"name":"example.com","staging_ip":null}]"#, - r#"[{"name":"example.com","staging_ips":["151.101.2.10"]}]"#, - r#"[{"name":"example.com","staging_ip":"151.101.2.10"},{"name":"example.com","staging_ip":"151.101.2.11"}]"#, - r#"{"name":"example.com","staging_ip":"151.101.2.10"}"#, - ] { - parse_staging_ip(json, "example.com") - .expect_err("ambiguous or malformed domain inventory must fail closed"); - } - } - - #[test] - fn build_curl_probe_args_production_has_no_connect_to() { - let args = build_curl_probe_args("example.com", "/", None, 10); - assert!(!args.iter().any(|arg| arg == "--connect-to")); - assert!(args.contains(&"https://example.com/".to_owned())); - assert!(args.contains(&"--max-time".to_owned())); - assert!(args.contains(&"10".to_owned())); - // Globbing must be off so bracket/brace characters in a path are literal. - assert!(args.contains(&"--globoff".to_owned())); - } - - #[test] - fn build_curl_probe_args_path_with_glob_chars_is_literal() { - // A path with `[` `]` would be a curl glob without --globoff; here it must - // appear verbatim in the single URL argument, with globbing disabled. - let args = build_curl_probe_args("example.com", "/health?ids[0]=1", None, 10); - assert!(args.contains(&"--globoff".to_owned())); - assert!(args.contains(&"https://example.com/health?ids[0]=1".to_owned())); - } - - #[test] - fn build_curl_probe_args_staging_reroutes_to_ip() { - let args = build_curl_probe_args("staging.example.com", "/", Some("151.101.2.10"), 15); - let idx = args - .iter() - .position(|arg| arg == "--connect-to") - .expect("--connect-to present for staging"); - assert_eq!(args[idx + 1], "::151.101.2.10:443"); - assert!(args.contains(&"https://staging.example.com/".to_owned())); - } - - #[test] - fn build_curl_probe_args_leads_with_q_to_ignore_curlrc() { - // `-q` must be the FIRST argument or curl merges `~/.curlrc` before it. - let args = build_curl_probe_args("example.com", "/", None, 10); - assert_eq!(args.first().map(String::as_str), Some("-q")); - } - - #[test] - fn build_curl_probe_args_brackets_ipv6_connect_to() { - let args = build_curl_probe_args("staging.example.com", "/", Some("2001:db8::1"), 10); - let idx = args - .iter() - .position(|arg| arg == "--connect-to") - .expect("--connect-to present for staging"); - // An IPv6 literal must be bracketed so curl does not misparse the colons. - assert_eq!(args[idx + 1], "::[2001:db8::1]:443"); - } - - #[test] - fn build_curl_probe_args_honors_path_on_production_and_staging() { - // Production: the path is appended to the domain URL. - let prod = build_curl_probe_args("example.com", "/health", None, 10); - assert!(prod.contains(&"https://example.com/health".to_owned())); - // Staging: same URL (with the path), rerouted to the staging IP. - let staging = - build_curl_probe_args("staging.example.com", "/health", Some("151.101.2.10"), 10); - assert!(staging.contains(&"https://staging.example.com/health".to_owned())); - let idx = staging - .iter() - .position(|arg| arg == "--connect-to") - .expect("--connect-to present for staging"); - assert_eq!(staging[idx + 1], "::151.101.2.10:443"); - } - - #[test] - fn validate_probe_path_requires_leading_slash_and_no_whitespace() { - validate_probe_path("/").expect("root"); - validate_probe_path("/health").expect("simple path"); - validate_probe_path("/api/v1/status?ready=1").expect("path with query"); - validate_probe_path("health").expect_err("no leading slash"); - validate_probe_path("").expect_err("empty"); - validate_probe_path("/ with space").expect_err("whitespace"); - validate_probe_path("/inject\nHost: evil").expect_err("newline injection"); - } - - #[test] - fn healthcheck_rejects_zero_timeout() { - // A zero timeout becomes curl `--max-time 0` (no limit); reject it before - // any probe. The other required args are valid so we reach the check. - let args = [ - "--adapter", - "fastly", - "--domain", - "example.com", - "--service-id", - "svc123", - "--version", - "1", - "--timeout", - "0", - ] - .map(str::to_owned) - .to_vec(); - let err = healthcheck(&args).expect_err("zero timeout must be rejected"); - assert!(err.contains("timeout"), "unexpected error: {err}"); - } - - #[test] - fn probe_with_retries_returns_first_healthy() { - let mut calls: i32 = 0; - let mut between: i32 = 0; - let result = probe_with_retries( - 5, - || { - calls += 1_i32; - Ok(200) - }, - || between += 1_i32, - ); - assert_eq!(result, Ok(200)); - assert_eq!(calls, 1_i32, "should stop after first healthy probe"); - assert_eq!(between, 0_i32, "no delay before the first attempt"); - } - - #[test] - fn probe_with_retries_succeeds_after_unhealthy_attempts() { - let mut calls: i32 = 0; - let mut between: i32 = 0; - let result = probe_with_retries( - 5, - || { - calls += 1_i32; - if calls < 3_i32 { Ok(503) } else { Ok(200) } - }, - || between += 1_i32, - ); - assert_eq!(result, Ok(200)); - assert_eq!(calls, 3_i32); - assert_eq!( - between, 2_i32, - "delay runs between each of the first 3 attempts" - ); - } - - #[test] - fn probe_with_retries_exhausts_and_reports_last_code() { - let mut between: i32 = 0; - let result = probe_with_retries(3, || Ok(500), || between += 1_i32); - assert_eq!( - result, - Err((Some(500), "unhealthy HTTP status 500".to_owned())) - ); - assert_eq!( - between, 2_i32, - "delay runs between attempts, not after the last" - ); - } - - #[test] - fn probe_with_retries_reports_transport_error() { - let result: Result, String)> = - probe_with_retries(1, || Err("connection refused".to_owned()), || {}); - assert_eq!(result, Err((None, "connection refused".to_owned()))); - } - - #[test] - fn probe_with_retries_treats_zero_retry_as_one_attempt() { - let mut calls: i32 = 0; - let result = probe_with_retries( - 0, - || { - calls += 1_i32; - Ok(500) - }, - || {}, - ); - assert_eq!( - result, - Err((Some(500), "unhealthy HTTP status 500".to_owned())) - ); - assert_eq!(calls, 1_i32); - } - - #[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() { - // `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}" - ); - // 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\"\nservice_id = \"SVC1\"\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"); - assert_eq!(out.len(), 3, "dry-run rows: {out:?}"); - 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`")); - // Manifest untouched. - let after = fs::read_to_string(&path).expect("read"); - assert_eq!( - after, "name = \"demo\"\nservice_id = \"SVC1\"\n", - "dry-run mutated fastly.toml" - ); - } - - #[cfg(unix)] - #[test] - fn provision_creates_declared_store_in_fastly_manifest_directory() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let adapter_dir = dir.path().join("adapters/fastly"); - fs::create_dir_all(&adapter_dir).expect("adapter dir"); - let path = adapter_dir.join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write"); - let kv = vec![ResolvedStoreId::from_logical("sessions")]; - let stores = ProvisionStores { - config: &[], - kv: &kv, - secrets: &[], - }; - let oplog = dir.path().join("oplog.txt"); - let fake = fake_fastly_runtime_mapping(&[], &oplog); - let _path = PathPrepend::new(fake.path()); - - FastlyCliAdapter - .provision( - dir.path(), - Some("adapters/fastly/fastly.toml"), - None, - &stores, - false, - ) - .expect("provision succeeds"); - - let log = fs::read_to_string(&oplog).expect("oplog"); - let manifest_dir = fs::canonicalize(&adapter_dir).expect("canonical manifest dir"); - assert!( - log.contains(&format!( - "kv-store-create name=--name=sessions cwd={}", - manifest_dir.display() - )), - "declared store creation uses the Fastly manifest directory: {log}" - ); - assert!( - fs::read_to_string(path) - .expect("manifest") - .contains("[setup.kv_stores.sessions]"), - "declared store setup block is written" - ); - } - - #[cfg(unix)] - #[test] - fn provision_rejects_non_default_mapping_without_service_before_mutation() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - let original = "name = \"demo\"\n"; - fs::write(&path, original).expect("write"); - let kv = vec![ResolvedStoreId::new("sessions", "shared_sessions")]; - let stores = ProvisionStores { - config: &[], - kv: &kv, - secrets: &[], - }; - let oplog = dir.path().join("oplog.txt"); - let fake = fake_fastly_runtime_mapping(&[], &oplog); - let _path = PathPrepend::new(fake.path()); - - let error = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect_err("a distinct physical name needs a selected service"); - - assert!( - error.contains("shared_sessions") - && error.contains("sessions") - && error.contains("service_id"), - "error must explain the unsupported mapping and recovery: {error}" - ); - assert!( - !oplog.exists(), - "provider must not be called before rejection" - ); - assert_eq!( - fs::read_to_string(path).expect("read manifest"), - original, - "preflight rejection must not mutate fastly.toml" - ); - } - - #[cfg(unix)] - #[test] - fn provision_existing_service_links_physical_store_under_logical_alias() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\nservice_id = \"abc123svc\"\n").expect("write"); - let kv = vec![ResolvedStoreId::new("sessions", "shared_sessions")]; - let stores = ProvisionStores { - config: &[], - kv: &kv, - secrets: &[], - }; - let oplog = dir.path().join("oplog.txt"); - let fake = fake_fastly_runtime_mapping(&[], &oplog); - let _path = PathPrepend::new(fake.path()); - - let lines = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect("selected service can use a distinct physical store"); - - let log = fs::read_to_string(&oplog).expect("provider log"); - assert!( - log.contains("kv-store-create name=--name=shared_sessions"), - "provider resource must use the selected physical name: {log}" - ); - let manifest = fs::read_to_string(path).expect("read manifest"); - assert!( - !manifest.contains("[setup.kv_stores"), - "Fastly setup cannot represent physical-to-logical mappings: {manifest}" - ); - let message = lines.join("\n"); - assert!( - message.contains("name=`shared_sessions`") - && message.contains("--name=sessions") - && !message.contains("--name=shared_sessions"), - "lookup must use the physical name and the link must use the logical alias: {message}" - ); - } - - #[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"); - fs::write(&path, "name = \"demo\"\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"]); - } - - #[cfg(unix)] - #[test] - fn provision_skips_store_creation_when_setup_block_already_present() { - // Re-running provision skips resource creation. - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write( - &path, - "service_id = \"SVC1\"\n\ - [setup.kv_stores.sessions]\n[local_server.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 oplog = dir.path().join("oplog.txt"); - let fake = fake_fastly_runtime_mapping(&[], &oplog); - let _path = PathPrepend::new(fake.path()); - - let out = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect("skip path succeeds"); - assert_eq!(out.len(), 1); - assert!(out[0].contains("already declared"), "got: {out:?}"); - assert!( - !oplog.exists(), - "provision must not inspect or mutate provider state" - ); - } - - /// When `fastly.toml` declares `service_id`, the next - /// `fastly compute deploy` skips `[setup]` entirely. provision - /// must emit the `fastly service resource-link create` remediation for - /// every declared store it creates. - #[test] - fn provision_emits_resource_link_note_for_declared_store_on_existing_service() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\nservice_id = \"abc123svc\"\n").expect("write"); - let selected = select_fastly_service_id(Some("abc123svc".to_owned()), None) - .expect("select service id"); - let note = resource_link_note(selected.as_ref(), "config", "shared_config", "app_config") - .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=`shared_config`"), - "note identifies the selected physical store: {note}" - ); - assert!( - note.contains( - "fastly service resource-link create --service-id=abc123svc --resource-id= --version=latest --autoclone --name=app_config" - ), - "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 note = resource_link_note(None, "config", "shared_config", "app_config"); - assert!( - note.is_none(), - "no service_id => no resource-link prompt: {note:?}" - ); - } - - #[test] - fn scaffold_empty_service_id_is_treated_as_unselected() { - let dir = tempdir().expect("tempdir"); - let manifest = dir.path().join("fastly.toml"); - fs::write(&manifest, "name = \"demo\"\nservice_id = \"\"\n").expect("write manifest"); - - assert!( - effective_fastly_service_id(&manifest) - .expect("read scaffold manifest") - .is_none() - ); - } - - #[test] - fn provision_uses_fastly_service_id_environment_fallback_for_link_note() { - let selected = select_fastly_service_id(None, Some("envservice".to_owned())) - .expect("select environment service id"); - let note = resource_link_note( - selected.as_ref(), - "secret", - "physical_credentials", - "credentials", - ) - .expect("environment service produces a link note"); - assert!( - note.contains("`FASTLY_SERVICE_ID` selects service `envservice`") - && note.contains("--service-id=envservice") - && note.contains("secret-store list --json"), - "environment-selected service is used consistently: {note}" - ); - } - - #[test] - fn provision_rejects_conflicting_manifest_and_environment_service_ids() { - let err = select_fastly_service_id( - Some("manifestservice".to_owned()), - Some("environmentservice".to_owned()), - ) - .expect_err("conflicting service ids must fail before provisioning"); - assert!( - err.contains("conflicts with FASTLY_SERVICE_ID"), - "conflict names both selectors: {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:?}" - ); - } - - #[test] - fn find_config_store_id_flags_schema_drift_when_any_entry_is_malformed() { - // A well-formed, non-matching entry must NOT mask a malformed one: the - // malformed entry could be the store we're looking for (hidden behind a - // missing name/id). Deciding NotFound here would fail OPEN — staging - // would then mirror no production overrides. Any malformed entry is drift. - let stdout = r#"[ - {"id": "abc123", "name": "some_other_store"}, - {"id": "def456"} - ]"#; - let drift = find_config_store_id(stdout, "app_config"); - assert!( - matches!(drift, ConfigStoreLookup::SchemaDrift(_)), - "a malformed entry alongside a well-formed one must be schema drift, got {drift:?}" - ); - } - - #[test] - fn find_config_store_id_scans_past_a_match() { - // The full list is scanned: a malformed entry AFTER the match must still - // be caught (no short-circuit on the first Found). - let stdout = r#"[ - {"id": "abc123", "name": "app_config"}, - {"name": "broken"} - ]"#; - let drift = find_config_store_id(stdout, "app_config"); - assert!( - matches!(drift, ConfigStoreLookup::SchemaDrift(_)), - "a malformed entry after the match must be schema drift, got {drift:?}" - ); - } - - #[test] - fn find_config_store_id_flags_duplicate_names_as_ambiguous() { - let stdout = r#"[ - {"id": "abc123", "name": "app_config"}, - {"id": "def456", "name": "app_config"} - ]"#; - let drift = find_config_store_id(stdout, "app_config"); - assert!( - matches!(drift, ConfigStoreLookup::SchemaDrift(_)), - "two stores with the same name must be ambiguous drift, 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 fake `fastly` for live runtime store-name reconciliation. - /// The current runtime-env entries are listed verbatim and every update or - /// delete is recorded in `oplog`. - #[cfg(unix)] - fn fake_fastly_runtime_mapping( - current: &[(String, String)], - oplog: &Path, - ) -> tempfile::TempDir { - fake_fastly_runtime_mapping_with_update_exit(current, oplog, 0) - } - - #[cfg(unix)] - fn fake_fastly_runtime_mapping_with_update_exit( - current: &[(String, String)], - oplog: &Path, - update_exit: i32, - ) -> tempfile::TempDir { - fake_fastly_runtime_mapping_with_exits(current, oplog, update_exit, 0) - } - - #[cfg(unix)] - fn fake_fastly_runtime_mapping_with_exits( - current: &[(String, String)], - oplog: &Path, - update_exit: i32, - delete_exit: i32, - ) -> tempfile::TempDir { - use std::os::unix::fs::PermissionsExt as _; - - let dir = tempdir().expect("tempdir"); - let store_list = dir.path().join("stores.json"); - let entry_list = dir.path().join("entries.json"); - fs::write( - &store_list, - format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#), - ) - .expect("store list"); - let entries = current - .iter() - .map(|(key, value)| { - serde_json::json!({ - "item_key": key, - "item_value": value, - }) - }) - .collect::>(); - fs::write( - &entry_list, - serde_json::to_string(&entries).expect("entry list json"), - ) - .expect("entry list"); - - let script = format!( - r#"#!/bin/sh -if [ "$1" = "compute" ] && [ "$2" = "deploy" ]; then printf 'compute-deploy cwd=%s\n' "$PWD" >> '{oplog}'; exit 0; fi -if [ "$1" = "config-store" ] && [ "$2" = "create" ]; then printf 'store-create cwd=%s\n' "$PWD" >> '{oplog}'; exit 0; fi -if [ "$1" = "kv-store" ] && [ "$2" = "create" ]; then printf 'kv-store-create name=%s cwd=%s\n' "$3" "$PWD" >> '{oplog}'; exit 0; fi -if [ "$1" = "config-store" ]; then printf 'store-list cwd=%s\n' "$PWD" >> '{oplog}'; cat '{stores}'; 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 cwd=%s\n' "$PWD" >> '{oplog}'; cat '{entries}'; exit 0; fi -if [ "$sub" = "update" ]; then value=$(cat); printf 'update %s=%s cwd=%s\n' "$key" "$value" "$PWD" >> '{oplog}'; exit {update_exit}; fi -if [ "$sub" = "delete" ]; then printf 'delete %s cwd=%s\n' "$key" "$PWD" >> '{oplog}'; exit {delete_exit}; fi -echo 'unexpected fastly invocation' >&2 -exit 1 -"#, - stores = store_list.display(), - entries = entry_list.display(), - oplog = oplog.display(), - ); - 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 - } - - /// 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}" - ); - } - - /// Writing two blobs under different root keys must leave both keys - /// readable from the local fastly.toml. 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, - &[("other_config".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 sibling = contents - .get("other_config") - .and_then(toml_edit::Item::as_str) - .expect("sibling key must be present"); - assert_eq!(sibling, "{\"envelope\":\"B\"}", "sibling 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. - /// - /// Writing a sibling root leaves `app_config` intact. 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}" - ); - } - } - - /// Fake `fastly` on `$PATH` that records `\t` for every - /// invocation. Used to prove the production deploy runs in the - /// manifest-selected app directory. - #[cfg(unix)] - fn fake_fastly_cwd_recorder() -> (tempfile::TempDir, PathBuf) { - use std::os::unix::fs::PermissionsExt as _; - - let dir = tempdir().expect("tempdir"); - let record = dir.path().join("argv.log"); - let script_path = dir.path().join("fastly"); - let script = format!( - "#!/bin/sh\nprintf '%s\\t%s\\n' \"$PWD\" \"$*\" >> '{}'\nexit 0\n", - record.display(), - ); - fs::write(&script_path, script).expect("write fake fastly"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod +x"); - (dir, record) - } - - #[cfg(unix)] - #[test] - fn deploy_honours_threaded_manifest_path_and_strips_it_from_the_fastly_argv() { - // Production deploys used to ignore the CLI-threaded - // `--manifest-path` and fall back to `find_fastly_manifest(cwd)`, - // which in a monorepo picks the CLOSEST fastly.toml — the wrong - // app. The threaded path must select the app directory, and must - // be STRIPPED from the argv (`fastly compute deploy` has no such - // flag and would exit non-zero). - let _lock = path_mutation_guard().lock().expect("guard"); - let (fake, record) = fake_fastly_cwd_recorder(); - let _path = PathPrepend::new(fake.path()); - - let app = tempdir().expect("app dir"); - let manifest = app.path().join("fastly.toml"); - fs::write(&manifest, "name = \"app\"\n").expect("write fastly.toml"); - - let args = vec![ - "--manifest-path".to_owned(), - manifest.display().to_string(), - "--service-id".to_owned(), - "SVC1".to_owned(), - ]; - deploy(&args).expect("deploy must run against the threaded manifest"); - - let recorded = fs::read_to_string(&record).expect("fastly was invoked"); - let (cwd, recorded_argv) = recorded - .trim_end() - .split_once('\t') - .expect("recorded `\\t`"); - assert_eq!( - fs::canonicalize(cwd).expect("cwd"), - fs::canonicalize(app.path()).expect("app dir"), - "deploy must run in the manifest-selected app directory" - ); - assert_eq!( - recorded_argv, - "compute deploy --service-id SVC1 --non-interactive" - ); - } - - #[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(), "other_config".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 `other_config`")); - // 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_archive.__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_archive", make("archive")); - let sibling_chunks = chunk_keys_of("app_config_archive", &make("archive")); - 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 &sibling_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()), - ("other_config".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 000000000..5fb04407b --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/gc.rs @@ -0,0 +1,2546 @@ +//! `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::*; + #[cfg(unix)] + 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/local_vendor.rs b/crates/edgezero-adapter-fastly/src/cli/local_vendor.rs new file mode 100644 index 000000000..bacb5a7d7 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/local_vendor.rs @@ -0,0 +1,320 @@ +//! Isolate native CLI manifest rewrites from the operator's source files. +use super::{ManifestLock, atomically_replace_file}; +use std::fs; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::{NamedTempFile, TempDir}; +use toml_edit::{DocumentMut, value}; + +pub(super) struct PrivateManifest { + startup: TempDir, + selected: NamedTempFile, + source: PathBuf, + snapshot_id: Option, + environment: String, +} + +impl PrivateManifest { + pub(super) fn new(requested_source: &Path) -> Result { + let (source, snapshot) = { + let lock = ManifestLock::acquire(requested_source)?; + let snapshot = fs::read_to_string(lock.target()).map_err(|err| err.to_string())?; + (lock.target().to_path_buf(), snapshot) + }; + let snapshot_id = manifest_id(&snapshot)?; + let startup = tempfile::tempdir().map_err(|err| err.to_string())?; + // Fastly reads this before parsing --dir/--env and may upgrade it. + fs::write(startup.path().join("fastly.toml"), &snapshot).map_err(|err| err.to_string())?; + let directory = source.parent().ok_or("Fastly manifest has no parent")?; + let mut selected = tempfile::Builder::new() + .prefix("fastly.edgezero-") + .suffix(".toml") + .tempfile_in(directory) + .map_err(|err| err.to_string())?; + selected + .write_all(snapshot.as_bytes()) + .map_err(|err| err.to_string())?; + let filename = selected + .path() + .file_name() + .and_then(|name| name.to_str()) + .ok_or("private Fastly manifest filename is not UTF-8")?; + let environment = filename + .strip_prefix("fastly.") + .and_then(|name| name.strip_suffix(".toml")) + .ok_or("invalid private Fastly manifest filename")? + .to_owned(); + Ok(Self { + startup, + selected, + source, + snapshot_id, + environment, + }) + } + + pub(super) fn configure(&self, command: &mut Command) { + command + .current_dir(self.startup.path()) + .arg("--dir") + .arg(self.source.parent().unwrap_or_else(|| Path::new("."))) + .arg("--env") + .arg(&self.environment); + } + + pub(super) fn merge_service_id(&self) -> Result<(), String> { + let vendor = fs::read_to_string(self.selected.path()).map_err(|err| err.to_string())?; + let vendor_state = manifest_id(&vendor)?; + if vendor_state == self.snapshot_id { + return Ok(()); + } + let Some(vendor_id) = vendor_state else { + return Err( + "Fastly removed service_id from its private manifest; source was preserved" + .to_owned(), + ); + }; + super::staging::validate_service_id(&vendor_id)?; + let lock = ManifestLock::acquire(&self.source)?; + let current = fs::read_to_string(lock.target()).map_err(|err| err.to_string())?; + let current_id = manifest_id(¤t)?; + if current_id != self.snapshot_id && current_id.as_deref() != Some(&vendor_id) { + return Err("Fastly service_id changed concurrently; refusing vendor writeback and preserving the source manifest".to_owned()); + } + let mut document: DocumentMut = current + .parse() + .map_err(|_err| "invalid Fastly manifest (parse details redacted)".to_owned())?; + document.insert("service_id", value(vendor_id)); + atomically_replace_file(lock.target(), ¤t, &document.to_string()) + } +} + +fn manifest_id(body: &str) -> Result, String> { + let document: DocumentMut = body + .parse() + .map_err(|_err| "invalid Fastly manifest (parse details redacted)".to_owned())?; + document + .get("service_id") + .map(|item| { + item.as_str() + .map(str::to_owned) + .ok_or_else(|| "Fastly service_id must be a string".to_owned()) + }) + .transpose() + .map(|optional_id| optional_id.filter(|id| !id.is_empty())) +} + +pub(super) fn validate_native_layout_args(args: &[String]) -> Result<(), String> { + for arg in args { + if arg == "--dir" + || arg.starts_with("--dir=") + || arg.starts_with("-C") + || arg == "--env" + || arg.starts_with("--env=") + { + return Err(format!( + "Fastly argument {arg:?} overrides the adapter-owned manifest selection; select the manifest in edgezero.toml" + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(unix)] + use std::iter::once; + #[cfg(unix)] + use std::time::{Duration, Instant}; + #[cfg(unix)] + use std::{env, thread}; + + #[cfg(unix)] + fn write_vendor_shim(directory: &Path) { + use std::os::unix::fs::PermissionsExt as _; + let shim = directory.join("fastly"); + fs::write( + &shim, + r#"#!/bin/sh +# Model Fastly's manifest upgrade before global flag parsing. +printf 'startup_upgraded = true\n' > fastly.toml +selected_dir='.' +selected_env='' +while [ "$#" -gt 0 ]; do + case "$1" in + --dir) selected_dir="$2"; shift ;; + --env) selected_env="$2"; shift ;; + esac + shift +done +if [ -n "$EDGEZERO_VENDOR_BARRIER" ]; then + touch "$EDGEZERO_VENDOR_BARRIER/ready" + while [ ! -e "$EDGEZERO_VENDOR_BARRIER/release" ]; do sleep 0.01; done +fi +printf 'name = "vendor"\nservice_id = "NewService"\n' > "$selected_dir/fastly.$selected_env.toml" +if [ -n "$EDGEZERO_VENDOR_BARRIER" ]; then touch "$EDGEZERO_VENDOR_BARRIER/done"; fi +"#, + ) + .unwrap(); + fs::set_permissions(shim, fs::Permissions::from_mode(0o755)).unwrap(); + } + + #[cfg(unix)] + #[test] + fn native_deploy_isolates_startup_and_selected_manifest_writes() { + use edgezero_adapter::registry::AdapterExecContext; + use edgezero_core::test_env::{EnvOverride, PathPrepend}; + let _lock = super::super::path_mutation_guard().lock().unwrap(); + let _no_service = EnvOverride::remove("FASTLY_SERVICE_ID"); + let dir = tempfile::tempdir().unwrap(); + write_vendor_shim(dir.path()); + let _path = PathPrepend::new(dir.path()); + let source = dir.path().join("custom.toml"); + let sibling = dir.path().join("fastly.toml"); + fs::write(&source, "# source\nname = 'original'\n").unwrap(); + fs::write(&sibling, "# unrelated\nname = 'sibling'\n").unwrap(); + super::super::run::deploy( + &[], + &AdapterExecContext::new().with_adapter_manifest(&source), + ) + .unwrap(); + let actual = fs::read_to_string(&source).unwrap(); + assert!( + actual.contains("# source") + && actual.contains("'original'") + && actual.contains("NewService"), + "{actual}" + ); + assert_eq!( + fs::read_to_string(sibling).unwrap(), + "# unrelated\nname = 'sibling'\n" + ); + assert!(!fs::read_dir(dir.path()).unwrap().any(|entry| { + entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with("fastly.edgezero-") + })); + } + + #[cfg(unix)] + #[test] + fn cancelled_parent_cannot_leave_vendor_writing_source_manifests() { + use edgezero_adapter::registry::{AdapterDeployContext, AdapterExecContext}; + use std::process::{Command, Stdio}; + const CHILD_ENV: &str = "EDGEZERO_VENDOR_CANCEL_CHILD"; + if let Some(root) = env::var_os(CHILD_ENV) { + let source = Path::new(&root).join("custom.toml"); + let environment = vec![( + "EDGEZERO_VENDOR_BARRIER".to_owned(), + root.to_string_lossy().into_owned(), + )]; + let context = AdapterDeployContext { + adapter_manifest_path: Some(source), + ..Default::default() + }; + super::super::run::deploy_with_context( + &context, + &[], + &AdapterExecContext::new().with_env(&environment), + ) + .unwrap(); + return; + } + let dir = tempfile::tempdir().unwrap(); + write_vendor_shim(dir.path()); + let source = dir.path().join("custom.toml"); + let sibling = dir.path().join("fastly.toml"); + fs::write(&source, "name = 'original'\n").unwrap(); + fs::write(&sibling, "name = 'sibling'\n").unwrap(); + let path = env::join_paths( + once(dir.path().to_path_buf()) + .chain(env::split_paths(&env::var_os("PATH").unwrap_or_default())), + ) + .unwrap(); + let mut parent = Command::new(env::current_exe().unwrap()) + .args(["--exact", "cli::local_vendor::tests::cancelled_parent_cannot_leave_vendor_writing_source_manifests", "--nocapture"]) + .env(CHILD_ENV, dir.path()).env("PATH", path).env_remove("FASTLY_SERVICE_ID") + .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); + wait_for_barrier(&dir.path().join("ready")); + parent.kill().unwrap(); + parent.wait().unwrap(); + let edited = "# concurrent writer\nname = 'changed'\nservice_id = 'Concurrent'\n"; + fs::write(&source, edited).unwrap(); + fs::write(dir.path().join("release"), "").unwrap(); + wait_for_barrier(&dir.path().join("done")); + assert_eq!(fs::read_to_string(source).unwrap(), edited); + assert_eq!(fs::read_to_string(sibling).unwrap(), "name = 'sibling'\n"); + } + + #[cfg(unix)] + fn wait_for_barrier(path: &Path) { + let started = Instant::now(); + while !path.exists() { + assert!( + started.elapsed() < Duration::from_secs(10), + "vendor did not reach {}", + path.display() + ); + thread::sleep(Duration::from_millis(10)); + } + } + + #[test] + fn writeback_preserves_concurrent_unrelated_edits() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("custom.toml"); + fs::write(&source, "name = 'original'\n").unwrap(); + let private = PrivateManifest::new(&source).unwrap(); + fs::write( + private.selected.path(), + "name = 'vendor'\nservice_id = 'NewService'\n", + ) + .unwrap(); + fs::write(&source, "# operator\nname = 'edited'\n").unwrap(); + private.merge_service_id().unwrap(); + let actual = fs::read_to_string(source).unwrap(); + assert!( + actual.contains("# operator") + && actual.contains("'edited'") + && actual.contains("NewService"), + "{actual}" + ); + assert!(!actual.contains("vendor")); + } + + #[test] + fn writeback_refuses_a_concurrent_service_change() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("fastly.toml"); + fs::write(&source, "service_id = 'Original'\n").unwrap(); + let private = PrivateManifest::new(&source).unwrap(); + fs::write(private.selected.path(), "service_id = 'Vendor'\n").unwrap(); + let edited = "service_id = 'Concurrent'\n"; + fs::write(&source, edited).unwrap(); + assert!( + private + .merge_service_id() + .unwrap_err() + .contains("concurrently") + ); + assert_eq!(fs::read_to_string(source).unwrap(), edited); + } + + #[test] + fn native_directory_and_environment_overrides_are_adapter_owned() { + for arg in [ + "--dir", + "--dir=x", + "-C", + "-Cpath", + "--env", + "--env=production", + ] { + assert!(validate_native_layout_args(&[arg.to_owned()]).is_err()); + } + } +} 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 000000000..395cbc4c8 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/mod.rs @@ -0,0 +1,1429 @@ +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::local::{AdapterLocalContext, AdapterLocalLayout, LocalArtifactKind}; +use edgezero_adapter::registry::{ + Adapter, AdapterAction, AdapterDeployContext, AdapterDeployedState, AdapterExecContext, + AdapterPushContext, DeployOwnership, 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 local_vendor; +mod provision_cloud; +mod provision_local; +mod push_cloud; +mod push_local; +mod run; +mod staging; +use staging::{ + deploy_managed_with_context, owns_managed_deploy, parse_fastly_version, + parse_release_managed_deploy_args, scan_reserved_deploy_args, + validate_effective_deploy_service_id, validate_fastly_config_key, validate_service_id, + verify_version_active, +}; +#[cfg(test)] +mod test_support; + +static FASTLY_ADAPTER: FastlyCliAdapter = FastlyCliAdapter; + +static FASTLY_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { + local_ignore_patterns: &[ + "fastly.toml", + "fastly.edgezero-*.toml", + ".*.edgezero-lock", + ".*.edgezero-*.tmp", + ], + tool_pins: &["fastly 16.0.0", "viceroy 0.21.0"], + install_notes: &[], + 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("{project_mod}_log"), + 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://github.com/IABTechLab/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://github.com/IABTechLab/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://github.com/IABTechLab/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 preflight_deploy( + &self, + context: &AdapterDeployContext, + args: &[String], + exec: &AdapterExecContext<'_>, + ) -> Result { + validate_effective_deploy_service_id(context, exec)?; + if owns_managed_deploy(context) { + parse_release_managed_deploy_args(args)?; + Ok(DeployOwnership::AdapterManaged) + } else { + scan_reserved_deploy_args(args)?; + Ok(DeployOwnership::ManifestCommand) + } + } + + fn validate_config_key_for_target( + &self, + logical_store_id: &str, + key: &str, + staging: bool, + local: bool, + ) -> Result<(), String> { + validate_fastly_config_key(logical_store_id, key, staging, local) + } + + fn finalize_deploy( + &self, + context: &AdapterDeployContext, + command_output: Option<&str>, + exec: &AdapterExecContext<'_>, + ) -> Result<(), String> { + if context.staging { + return Ok(()); + } + + let Some(service_id) = context.service_id.as_deref() else { + return Ok(()); + }; + validate_service_id(service_id)?; + if let Some(version) = command_output.and_then(parse_fastly_version) { + let token = staging::require_token_with_exec(exec)?; + verify_version_active( + service_id, + version, + &token, + "after manifest-command deployment", + )?; + log::info!("version={version}"); + return Ok(()); + } + staging::emit_active_version_with_token(service_id, true, &staging::require_token_with_exec(exec)?).map_err(|error| { + format!( + "deploy succeeded but the activated version could not be resolved from the deploy output or Fastly API: {error}" + ) + }) + } + + fn deploy( + &self, + context: &AdapterDeployContext, + args: &[String], + exec: &AdapterExecContext<'_>, + ) -> Result<(), String> { + if owns_managed_deploy(context) { + deploy_managed_with_context(context, args, exec) + } else { + validate_effective_deploy_service_id(context, exec)?; + scan_reserved_deploy_args(args)?; + run::deploy_with_context(context, args, exec) + } + } + + 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), + AdapterAction::DeployStaging => Err( + "Fastly staging requires typed deploy context and --application-release".to_owned(), + ), + AdapterAction::EmitVersion => staging::emit_active_version(args), + AdapterAction::Healthcheck => staging::healthcheck(args), + AdapterAction::Rollback => staging::rollback(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::gc_fastly_config_store(store.platform.as_str(), older_than_secs, dry_run) + } + + fn local_layout( + &self, + context: &AdapterLocalContext<'_>, + ) -> Result { + let manifest = context.manifest().unwrap_or("fastly.toml"); + Ok(AdapterLocalLayout::new_with_kind( + manifest, + LocalArtifactKind::SensitiveToml, + )) + } + + 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> { + 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> { + 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 { + 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 { + 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; + } +} + +#[expect( + clippy::missing_trait_methods, + reason = "Drop::pin_drop has a default; this guard uses ordinary drop" +)] +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; + #[cfg(unix)] + use tempfile::tempdir; + + #[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 000000000..7128dab2a --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/provision_cloud.rs @@ -0,0 +1,1597 @@ +impl SelectedFastlyService { + pub(super) fn into_id(self) -> String { + self.id + } +} + +use super::staging::FASTLY_SERVICE_ID_ENV; +#[derive(Clone, Copy, Debug)] +pub(super) enum FastlyServiceIdSource { + Environment, + Manifest, +} + +#[derive(Debug)] +pub(super) struct SelectedFastlyService { + id: String, + source: FastlyServiceIdSource, +} +use super::staging::validate_service_id; +use std::env; +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 when physical and logical names match. +/// Distinct physical names require an existing service and an explicit link. +/// +/// 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 { + // 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 tracked_or_local = reconcile_service_id(&fastly_path, deployed)?; + let environment_or_local = effective_fastly_service_id(&fastly_path)?.map(|service| service.id); + let selected_service = select_fastly_service_id(tracked_or_local, environment_or_local)?; + if selected_service.is_none() { + for store in stores.kv.iter().chain(stores.config).chain(stores.secrets) { + if store.logical != store.platform { + return Err(format!( + "Fastly store `{}` is selected for logical id `{}`, but no service is selected. Fastly setup cannot bind a physical store under a different logical alias. Set service_id or FASTLY_SERVICE_ID before provisioning this mapping.", + store.platform, store.logical + )); + } + } + } + + // 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 { + // Cloud creation selects physical resources; resource links keep + // the logical aliases compiled into the application. + 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". + let setup_can_represent_mapping = logical == name; + if setup_can_represent_mapping && setup_block_present(&fastly_path, kind, logical)? { + 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(selected_service.as_ref(), kind, name, logical) + { + line.push('\n'); + line.push_str(¬e); + } + out.push(line); + continue; + } + if dry_run && !setup_can_represent_mapping { + out.push(format!("would run `fastly {kind}-store create --name={name}` for logical id `{logical}`; the selected service requires an explicit resource link under alias `{logical}`")); + 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_in(kind, name, fastly_path.parent().unwrap_or(manifest_root))?; + // 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. + if setup_can_represent_mapping { + append_fastly_setup(&fastly_path, kind, logical).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(selected_service.as_ref(), kind, name, logical); + let mut line = if setup_can_represent_mapping { + format!( + "created fastly {kind}-store `{name}` (logical id `{logical}`); appended setup tables to {}", + fastly_path.display() + ) + } else { + format!( + "created fastly {kind}-store `{name}` for logical id `{logical}`; fastly.toml was not changed because [setup] cannot express a distinct physical resource name" + ) + }; + 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 +/// has already resolved and validated the selector. A provider response saying +/// that the same-kind store already exists is treated as idempotent success; +/// other failures retain the CLI diagnostic. +/// +/// # 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_in(kind: &str, name: &str, cwd: &Path) -> Result<(), String> { + let subcommand = format!("{kind}-store"); + let name_arg = format!("--name={name}"); + let mut command = Command::new("fastly"); + command + .args([subcommand.as_str(), "create", name_arg.as_str()]) + .current_dir(cwd); + let output = command.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. +pub(super) 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. 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). +pub(super) 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(|service_id| !service_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 is selected through fastly.toml or `FASTLY_SERVICE_ID`, the +/// next `fastly compute deploy` targets an existing service and skips `[setup]`. +/// Any store created by provision then needs a separate resource link. +fn resource_link_note( + selected: Option<&SelectedFastlyService>, + kind: &str, + physical_name: &str, + logical_alias: &str, +) -> Option { + selected.map(|service| { + let svc_id = &service.id; + let selection = match service.source { + FastlyServiceIdSource::Manifest => { + format!("fastly.toml declares `service_id = \"{svc_id}\"`") + } + FastlyServiceIdSource::Environment => { + format!("`{FASTLY_SERVICE_ID_ENV}` selects service `{svc_id}`") + } + }; + format!( + " {selection}, 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=`{physical_name}`), then run:\n fastly service resource-link create --service-id={svc_id} --resource-id= --version=latest --autoclone --name={logical_alias}\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(()) +} + +pub(super) fn select_fastly_service_id( + manifest_id: Option, + environment_id: Option, +) -> Result, String> { + if let Some(manifest) = manifest_id.as_deref() { + validate_service_id(manifest)?; + } + if let Some(environment) = environment_id.as_deref() { + validate_service_id(environment)?; + } + if let (Some(manifest), Some(environment)) = (&manifest_id, &environment_id) + && manifest != environment + { + return Err(format!( + "fastly.toml service_id `{manifest}` conflicts with {FASTLY_SERVICE_ID_ENV} `{environment}`; make them agree before provisioning" + )); + } + + let selected = match (manifest_id, environment_id) { + (Some(id), _) => Some(SelectedFastlyService { + id, + source: FastlyServiceIdSource::Manifest, + }), + (None, Some(id)) => Some(SelectedFastlyService { + id, + source: FastlyServiceIdSource::Environment, + }), + (None, None) => None, + }; + Ok(selected) +} + +pub(super) fn effective_fastly_service_id( + path: &Path, +) -> Result, String> { + let manifest_id = read_fastly_service_id(path)?; + let environment_id = match env::var(FASTLY_SERVICE_ID_ENV) { + Ok(id) => Some(id), + Err(env::VarError::NotPresent) => None, + Err(env::VarError::NotUnicode(_)) => { + return Err(format!( + "invalid service id from {FASTLY_SERVICE_ID_ENV}: expected ASCII letters and digits only" + )); + } + }; + select_fastly_service_id(manifest_id, environment_id) +} + +#[cfg(test)] +mod tests { + use super::super::FastlyCliAdapter; + #[cfg(unix)] + use super::super::path_mutation_guard; + #[cfg(unix)] + use super::super::provision_local::write_fastly_local_config_store; + use super::super::run::synthesise_fastly_toml; + #[cfg(unix)] + use super::super::test_support::fake_fastly_runtime_mapping; + use super::*; + use edgezero_adapter::registry::{ + Adapter as _, AdapterDeployedState, ProvisionMode, ResolvedStoreId, TypedSecretEntry, + }; + #[cfg(unix)] + use edgezero_core::test_env::PathPrepend; + 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 ---------- + + #[cfg(unix)] + #[cfg(unix)] + #[cfg(unix)] + #[test] + fn provision_rejects_conflicting_manifest_and_environment_service_ids() { + let err = select_fastly_service_id( + Some("manifestservice".to_owned()), + Some("environmentservice".to_owned()), + ) + .expect_err("conflicting service ids must fail before provisioning"); + assert!( + err.contains("conflicts with FASTLY_SERVICE_ID"), + "conflict names both selectors: {err}" + ); + } + + #[test] + fn provision_uses_fastly_service_id_environment_fallback_for_link_note() { + let selected = select_fastly_service_id(None, Some("envservice".to_owned())) + .expect("select environment service id"); + let note = resource_link_note( + selected.as_ref(), + "secret", + "physical_credentials", + "credentials", + ) + .expect("environment service produces a link note"); + assert!( + note.contains("`FASTLY_SERVICE_ID` selects service `envservice`") + && note.contains("--service-id=envservice") + && note.contains("secret-store list --json"), + "environment-selected service is used consistently: {note}" + ); + } + + #[test] + fn scaffold_empty_service_id_is_treated_as_unselected() { + let dir = tempdir().expect("tempdir"); + let manifest = dir.path().join("fastly.toml"); + fs::write(&manifest, "name = \"demo\"\nservice_id = \"\"\n").expect("write manifest"); + + assert!( + effective_fastly_service_id(&manifest) + .expect("read scaffold manifest") + .is_none() + ); + } + + #[cfg(unix)] + #[test] + fn provision_existing_service_links_physical_store_under_logical_alias() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\nservice_id = \"abc123svc\"\n").expect("write"); + let kv = vec![ResolvedStoreId::new("sessions", "shared_sessions")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, + secrets: &[], + }; + let oplog = dir.path().join("oplog.txt"); + let fake = fake_fastly_runtime_mapping(&[], &oplog); + let _path = PathPrepend::new(fake.path()); + + let lines = FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) + .expect("selected service can use a distinct physical store"); + + let log = fs::read_to_string(&oplog).expect("provider log"); + assert!( + log.contains("kv-store-create name=--name=shared_sessions"), + "provider resource must use the selected physical name: {log}" + ); + let manifest = fs::read_to_string(path).expect("read manifest"); + assert!( + !manifest.contains("[setup.kv_stores"), + "Fastly setup cannot represent physical-to-logical mappings: {manifest}" + ); + let message = lines.status_lines.join("\n"); + assert!( + message.contains("name=`shared_sessions`") + && message.contains("--name=sessions") + && !message.contains("--name=shared_sessions"), + "lookup must use the physical name and the link must use the logical alias: {message}" + ); + } + + #[cfg(unix)] + #[test] + fn provision_rejects_non_default_mapping_without_service_before_mutation() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + let original = "name = \"demo\"\n"; + fs::write(&path, original).expect("write"); + let kv = vec![ResolvedStoreId::new("sessions", "shared_sessions")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, + secrets: &[], + }; + let oplog = dir.path().join("oplog.txt"); + let fake = fake_fastly_runtime_mapping(&[], &oplog); + let _path = PathPrepend::new(fake.path()); + + let error = FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) + .expect_err("a distinct physical name needs a selected service"); + + assert!( + error.contains("shared_sessions") + && error.contains("sessions") + && error.contains("service_id"), + "error must explain the unsupported mapping and recovery: {error}" + ); + assert!( + !oplog.exists(), + "provider must not be called before rejection" + ); + assert_eq!( + fs::read_to_string(path).expect("read manifest"), + original, + "preflight rejection must not mutate fastly.toml" + ); + } + + #[cfg(unix)] + #[test] + fn provision_creates_declared_store_in_fastly_manifest_directory() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let adapter_dir = dir.path().join("adapters/fastly"); + fs::create_dir_all(&adapter_dir).expect("adapter dir"); + let path = adapter_dir.join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + let kv = vec![ResolvedStoreId::from_logical("sessions")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, + secrets: &[], + }; + let oplog = dir.path().join("oplog.txt"); + let fake = fake_fastly_runtime_mapping(&[], &oplog); + let _path = PathPrepend::new(fake.path()); + + FastlyCliAdapter + .provision( + dir.path(), + Some("adapters/fastly/fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) + .expect("provision succeeds"); + + let log = fs::read_to_string(&oplog).expect("oplog"); + let manifest_dir = fs::canonicalize(&adapter_dir).expect("canonical manifest dir"); + assert!( + log.contains(&format!( + "kv-store-create name=--name=sessions cwd={}", + manifest_dir.display() + )), + "declared store creation uses the Fastly manifest directory: {log}" + ); + assert!( + fs::read_to_string(path) + .expect("manifest") + .contains("[setup.kv_stores.sessions]"), + "declared store setup block is written" + ); + } + + #[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 create rows, then the + // convergence rows: one runtime store-name mapping PER declared store + // (written unconditionally now, even when platform == logical) = 7. + assert_eq!(out.status_lines.len(), 3); + 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`") + ); + // Manifest untouched. + let after = fs::read_to_string(&path).expect("read"); + assert_eq!(after, "name = \"demo\"\n", "dry-run mutated fastly.toml"); + } + + // ---------- populate_runtime_name_mappings (convergence) ---------- + + /// 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 = \"SVCALREADYDEPLOYED\"\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(), "SVCALREADYDEPLOYED".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 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(&SelectedFastlyService { + id: resolved, + source: FastlyServiceIdSource::Manifest, + }), + "kv", + "sessions", + "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 not re-create + // it (no `fastly *-store create` shell-out). We drive this in dry-run: + // the loop's skip-check runs BEFORE the dry-run branch, so an + // already-present block yields the same "already declared; skipping" + // line the real run emits, while never invoking the CLI. (A real run + // now still shells out at the convergence step to reconcile the + // runtime store-name mappings, so it can't run without `fastly`.) + 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, + true, + ) + .expect("skip path succeeds without invoking fastly"); + // The declared store is skipped (not re-created); the convergence step + // still models its runtime store-name mapping. + assert_eq!(out.status_lines.len(), 1, "got: {out:?}"); + assert!( + out.status_lines[0].contains("already declared") + && !out.status_lines[0].contains("would run"), + "declared store skipped, not re-created: {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:?}" + ); + } + + /// 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 000000000..78bd79444 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/provision_local.rs @@ -0,0 +1,1978 @@ +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 logical store aliases. 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). +/// +/// Store-block mutations 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; + + // Portable physical-store selectors uppercase logical ids; reject ids + // that would collide in that selector namespace. + 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. + + 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 lock = (!dry_run) + .then(|| ManifestLock::acquire(&fastly_path)) + .transpose()?; + let target = lock + .as_ref() + .map_or(fastly_path.as_path(), ManifestLock::target); + let raw = fs::read_to_string(target) + .map_err(|err| format!("failed to read {}: {err}", fastly_path.display()))?; + let mut doc: DocumentMut = raw.parse().map_err(|_err| { + format!( + "failed to parse {} (parse details redacted)", + 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.logical)?; + status_lines.push(format!( + "fastly: wrote local kv_store `{}` (logical id `{}`) in {path_display}", + store.logical, 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.logical)?; + status_lines.push(format!( + "fastly: wrote local config_store `{}` (logical id `{}`) in {path_display}", + store.logical, store.logical + )); + } + + if !dry_run { + atomically_replace_file(target, &raw, &doc.to_string())?; + } + + 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 lock = (!dry_run) + .then(|| ManifestLock::acquire(&fastly_path)) + .transpose()?; + let target = lock + .as_ref() + .map_or(fastly_path.as_path(), ManifestLock::target); + let raw = fs::read_to_string(target) + .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 {} (parse details redacted)", + 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 { + // Viceroy exposes the same logical alias as a production resource link. + let added = upsert_secret_store_entry(&mut doc, entry.store_id, 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 { + atomically_replace_file(target, &raw, &doc.to_string())?; + } + + 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(()) +} + +/// 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() + ) +} + +/// 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, + }; + #[cfg(unix)] + 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"; + + #[test] + fn local_provision_parse_errors_redact_values() { + let dir = tempdir().unwrap(); + let path = dir.path().join("fastly.toml"); + let original = "name = 'demo'\nvalue = 'PRIVATE_PARSE_SENTINEL' broken\n"; + fs::write(&path, original).unwrap(); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + for result in [ + provision(dir.path(), Some("fastly.toml"), &stores, None, false), + provision_typed(dir.path(), Some("fastly.toml"), &[], false), + ] { + let error = result.unwrap_err(); + assert!(error.contains("parse"), "{error}"); + assert!(!error.contains("PRIVATE_PARSE_SENTINEL"), "{error}"); + } + assert_eq!(fs::read_to_string(path).unwrap(), original); + } + + #[cfg(unix)] + #[test] + fn local_provision_preserves_modes_and_dry_run_creates_no_lock() { + use std::os::unix::fs::PermissionsExt as _; + for typed in [false, true] { + let dir = tempdir().unwrap(); + let path = dir.path().join("custom.toml"); + let original = "name='demo'\n"; + fs::write(&path, original).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + let config = [ResolvedStoreId::from_logical("settings")]; + let stores = ProvisionStores { + config: &config, + kv: &[], + secrets: &[], + }; + for dry_run in [true, false] { + if typed { + provision_typed( + dir.path(), + Some("custom.toml"), + &[TypedSecretEntry::new("vault", "token", "api_token")], + dry_run, + ) + .unwrap(); + } else { + provision(dir.path(), Some("custom.toml"), &stores, None, dry_run).unwrap(); + } + if dry_run { + assert_eq!(fs::read_to_string(&path).unwrap(), original); + assert!(!dir.path().join(".custom.toml.edgezero-lock").exists()); + } + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + } + + #[cfg(unix)] + #[test] + fn local_provision_waits_for_manifest_lock_before_reading() { + use std::env; + use std::process::{Command, Stdio}; + use std::sync::mpsc; + use std::thread; + use std::time::{Duration, Instant}; + const CHILD: &str = "EDGEZERO_LOCAL_PROVISION_LOCK_CHILD"; + if let Some(child_root) = env::var_os(CHILD) { + let root = Path::new(&child_root); + fs::write(root.join("ready"), "").unwrap(); + if env::var_os("EDGEZERO_LOCAL_PROVISION_TYPED").is_some() { + provision_typed( + root, + Some("custom.toml"), + &[TypedSecretEntry::new("vault", "token", "api_token")], + false, + ) + .unwrap(); + } else { + let config = [ResolvedStoreId::from_logical("settings")]; + let stores = ProvisionStores { + config: &config, + kv: &[], + secrets: &[], + }; + provision(root, Some("custom.toml"), &stores, None, false).unwrap(); + } + return; + } + for typed in [false, true] { + let dir = tempdir().unwrap(); + let path = dir.path().join("custom.toml"); + let before = "name = 'demo'\n"; + fs::write(&path, before).unwrap(); + let guard = ManifestLock::acquire(&path).unwrap(); + let mut command = Command::new(env::current_exe().unwrap()); + command.args(["--exact", "cli::provision_local::tests::local_provision_waits_for_manifest_lock_before_reading", "--nocapture"]) + .env(CHILD, dir.path()).env_remove("EDGEZERO_LOCAL_PROVISION_TYPED") + .stdout(Stdio::null()); + if typed { + command.env("EDGEZERO_LOCAL_PROVISION_TYPED", "1"); + } + let mut child = command.spawn().unwrap(); + let started = Instant::now(); + while !dir.path().join("ready").exists() { + assert!( + started.elapsed() < Duration::from_secs(10), + "child did not start" + ); + assert!( + child.try_wait().unwrap().is_none(), + "child exited before readiness" + ); + thread::sleep(Duration::from_millis(5)); + } + let (send, receive) = mpsc::channel(); + let waiter = thread::spawn(move || { + let _sent = send.send(child.wait().unwrap()); + }); + let early = receive.recv_timeout(Duration::from_millis(200)); + let blocked = matches!(early, Err(mpsc::RecvTimeoutError::Timeout)); + let current = fs::read_to_string(guard.target()).unwrap(); + atomically_replace_file( + guard.target(), + ¤t, + "name = 'demo'\nservice_id = 'NewService'\n", + ) + .unwrap(); + drop(guard); + let status = + early.unwrap_or_else(|_| receive.recv_timeout(Duration::from_secs(10)).unwrap()); + waiter.join().unwrap(); + assert!(status.success()); + assert!( + blocked, + "base/typed local provision bypassed the manifest lock" + ); + let after = fs::read_to_string(&path).unwrap(); + let doc: toml_edit::DocumentMut = after.parse().unwrap(); + assert_eq!(doc["service_id"].as_str(), Some("NewService")); + assert!(after.contains(if typed { "api_token" } else { "settings" })); + } + } + + /// 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) ---------- + + #[cfg(unix)] + #[test] + fn local_provision_uses_logical_runtime_bindings_with_physical_selectors() { + let dir = tempfile::tempdir().expect("temporary project"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "manifest_version = 3\nname = \"demo\"\n").expect("manifest"); + let config = [edgezero_adapter::ResolvedStoreId::new( + "app_config", + "config-production", + )]; + let kv = [edgezero_adapter::ResolvedStoreId::new( + "sessions", + "sessions-production", + )]; + let secrets = [edgezero_adapter::ResolvedStoreId::new( + "vault", + "vault-production", + )]; + let stores = ProvisionStores { + config: &config, + kv: &kv, + secrets: &secrets, + }; + provision(dir.path(), Some("fastly.toml"), &stores, None, false).expect("provision stores"); + let typed = [ + edgezero_adapter::TypedSecretEntry::new("vault", "token", "api_token") + .with_platform("vault-production"), + ]; + provision_typed(dir.path(), Some("fastly.toml"), &typed, false).expect("provision secrets"); + let raw = fs::read_to_string(path).expect("manifest after provision"); + let doc = raw.parse::().expect("valid TOML"); + assert!( + doc["local_server"]["config_stores"] + .get("app_config") + .is_some(), + "config binding must use logical alias: {raw}" + ); + assert!( + doc["local_server"]["kv_stores"].get("sessions").is_some(), + "KV binding must use logical alias: {raw}" + ); + assert!( + doc["local_server"]["secret_stores"].get("vault").is_some(), + "secret binding must use logical alias: {raw}" + ); + assert!( + doc["local_server"]["config_stores"] + .get("edgezero_runtime_env") + .is_none(), + "runtime no longer reads the old override store" + ); + } + + #[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)" + ); + } + + /// 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}" + ); + assert!( + !after.contains("edgezero_runtime_env"), + "no obsolete runtime override store" + ); + } + + /// 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" + ); + } + + #[test] + fn provision_typed_seeds_secret_store_under_logical_alias() { + 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("vault"), + "Viceroy store must be keyed by the logical alias: {after}" + ); + assert!( + !stores.contains_key("prod_vault"), + "physical selectors must not rename runtime aliases: {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 000000000..2ff050318 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/push_cloud.rs @@ -0,0 +1,1679 @@ +use std::cell::{Cell, RefCell}; +use std::collections::HashSet; +use std::io::{ErrorKind, Write as _}; +use std::path::Path; +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). +pub(super) 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. +pub(super) 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`. 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. +pub(super) fn resolve_remote_config_store_id(name: &str) -> Result, String> { + match classify_remote_config_store(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/IABTechLab/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" + ) +} + +/// Look a config store up by name and return the raw [`ConfigStoreLookup`], so +/// callers can tell "the account has no such store" (`NotFound`) apart from "the +/// lookup itself failed" (`Err` — CLI missing / non-zero exit — or +/// `SchemaDrift`). A staged deploy relies on that distinction to decide whether +/// to skip config isolation (genuinely no store) or fail closed (couldn't tell). +/// +/// `Err` is only for a failure to OBTAIN an answer; a successful listing that +/// simply doesn't contain `name` is `Ok(ConfigStoreLookup::NotFound)`. +pub(super) fn classify_remote_config_store(name: &str) -> Result { + classify_remote_config_store_with_cwd(name, None) +} + +pub(super) fn classify_remote_config_store_with_cwd( + name: &str, + cwd: Option<&Path>, +) -> Result { + let mut command = Command::new("fastly"); + command.args(["config-store", "list", "--json"]); + if let Some(command_cwd) = cwd { + command.current_dir(command_cwd); + } + let output = command.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() + )); + } + // Adopt main's strict UTF-8 gate (fail closed on undecodable stdout) but + // return the raw lookup so staging callers keep the 3-way verdict; the + // SchemaDrift -> Err mapping lives in resolve_remote_config_store_id. + let stdout = strict_stdout(output.stdout, "config-store list --json")?; + Ok(find_config_store_id(&stdout, name)) +} + +#[cfg(test)] +mod tests { + use super::super::FastlyCliAdapter; + #[cfg(unix)] + use super::super::path_mutation_guard; + use super::*; + #[cfg(unix)] + 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 000000000..960a3d687 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/push_local.rs @@ -0,0 +1,2281 @@ +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::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.logical.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 { + // Per the chunk-GC dry-run contract (spec 2026-07-07 §"Error + // semantics"), a dry-run MUST NOT newly fail: it previews the edit and + // DEGRADES the orphan count (absent prior -> 0; unreadable / malformed + // / non-string prior -> "unknown") rather than rejecting. The REAL + // push still fails fatally at the writer on malformed / not-yet- + // provisioned state; only this preview count degrades. Read-only. + 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.logical.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; + #[cfg(unix)] + 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}" + ); + } + + #[test] + fn push_config_entries_local_dry_run_continues_over_unprovisioned_store() { + // Per the chunk-GC dry-run contract (spec 2026-07-07 §"Error + // semantics"), a dry-run MUST NOT fail on absent / unprovisioned prior + // state: it PREVIEWS the edit and degrades the orphan count (absent + // prior -> 0). The REAL push still refuses at the writer -- that path + // is unchanged. + 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 out = 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("dry-run must NOT fail on an unprovisioned store"); + assert!( + out.iter().any(|line| line.contains("would set `greeting`")), + "dry-run previews the edit: {out:?}" + ); + // Read-only: the probe must leave the file byte-identical. + let after = fs::read_to_string(&fastly_toml).expect("read back"); + assert_eq!(after, original, "dry-run must not edit fastly.toml"); + } + + #[test] + fn push_config_entries_local_dry_run_degrades_over_malformed_prior() { + // Malformed prior TOML -> the orphan count degrades to "unknown" and + // the dry-run CONTINUES (does not error, does not leak the raw value). + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + let original = "this is not = = valid toml with SECRET_abc in it\n"; + fs::write(&fastly_toml, original).expect("write"); + + let out = 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("dry-run must degrade over malformed prior, not fail"); + let joined = out.join("\n"); + assert!( + joined.contains("unknown number of orphan chunks") && !joined.contains("SECRET_abc"), + "count degrades to unknown without leaking the raw value: {joined}" + ); + } + + // ---------- 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 000000000..49b9d37a6 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/run.rs @@ -0,0 +1,503 @@ +use super::staging::{ + args_without_flag_value, resolve_deploy_manifest_path, scan_reserved_deploy_args, + validate_deploy_service_id_for_manifest, +}; +use edgezero_adapter::registry::AdapterDeployContext; +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) +} + +pub(super) fn deploy(extra_args: &[String], exec: &AdapterExecContext<'_>) -> Result<(), String> { + let mut context = super::staging::legacy_deploy_context(extra_args, false); + if context.adapter_manifest_path.is_none() { + context.adapter_manifest_path = exec.adapter_manifest().map(Path::to_path_buf); + } + deploy_with_context(&context, extra_args, exec) +} + +/// # 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()) + })?; + if manifest.file_name().and_then(|name| name.to_str()) != Some("fastly.toml") { + return Err("Fastly compute serve builds from fastly.toml; select a manifest named fastly.toml in edgezero.toml for serving".to_owned()); + } + super::local_vendor::validate_native_layout_args(extra_args)?; + let manifest_dir = manifest.parent().ok_or("Fastly manifest has no parent")?; + 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(()) +} + +pub(super) 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() +} + +pub(super) fn deploy_with_context( + context: &AdapterDeployContext, + extra_args: &[String], + exec: &AdapterExecContext<'_>, +) -> Result<(), String> { + let manifest_path = resolve_deploy_manifest_path(context)?; + validate_deploy_service_id_for_manifest(context, &manifest_path, exec)?; + let without_manifest = args_without_flag_value(extra_args, "--manifest-path"); + let mut forwarded = args_without_flag_value(&without_manifest, "--service-id"); + scan_reserved_deploy_args(&forwarded)?; + if let Some(service_id) = context.service_id.as_deref() { + forwarded.extend(["--service-id".to_owned(), service_id.to_owned()]); + } + + super::local_vendor::validate_native_layout_args(&forwarded)?; + let private = super::local_vendor::PrivateManifest::new(&manifest_path)?; + let mut command = Command::new("fastly"); + command + .args(build_compute_deploy_args(&forwarded)) + .envs(exec.env().iter().map(|(key, value)| (key, value))); + private.configure(&mut command); + 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}")); + } + + private.merge_service_id() +} + +/// Build the argv for `fastly compute deploy`, appending +/// `--non-interactive` (a Fastly CLI *global* flag, supported by +/// `compute deploy`) unless the caller already passed it. Without it a +/// production deploy can block on an interactive prompt in CI. +pub(super) fn build_compute_deploy_args(extra_args: &[String]) -> Vec { + let mut argv = vec!["compute".to_owned(), "deploy".to_owned()]; + argv.extend_from_slice(extra_args); + if !has_non_interactive(extra_args) { + argv.push("--non-interactive".to_owned()); + } + argv +} + +/// Whether `args` already carries the Fastly CLI's non-interactive +/// switch, in either its long (`--non-interactive`) or short (`-i`) +/// form. Used to avoid passing the flag twice when a caller already +/// supplied it via `deploy-args` passthrough. +pub(super) fn has_non_interactive(args: &[String]) -> bool { + args.iter() + .any(|arg| arg == "--non-interactive" || arg == "-i") +} + +#[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/staging.rs b/crates/edgezero-adapter-fastly/src/cli/staging.rs new file mode 100644 index 000000000..958b6d917 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/staging.rs @@ -0,0 +1,5324 @@ +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "the managed deployment planning and execution state machine is kept as one cohesive block" +)] + +const FASTLY_API_TOKEN_ENV: &str = "FASTLY_API_TOKEN"; +/// Env var carrying the default Fastly service id, used when +/// `--service-id` is not passed explicitly. +pub(super) const FASTLY_SERVICE_ID_ENV: &str = "FASTLY_SERVICE_ID"; + +/// Bound every Fastly API call so an outage or a stalled connection cannot hang the +/// job — potentially until the surrounding workflow timeout, hours later — during a +/// time-sensitive operation like a rollback. `curl` exits 28 when either limit is +/// hit, which `curl_config_capture` turns into an explicit timeout error. +const FASTLY_API_CONNECT_TIMEOUT_SECS: u64 = 10; +const FASTLY_API_MAX_TIME_SECS: u64 = 30; +/// curl's exit code for an operation that exceeded `--connect-timeout`/`--max-time`. +const CURL_EXIT_TIMEOUT: i32 = 28; + +use super::FASTLY_INSTALL_HINT; +use super::provision_cloud::SelectedFastlyService; +use super::push_cloud::{redact_stderr, strict_stdout}; +use super::run::{find_fastly_manifest, has_non_interactive}; +use edgezero_adapter::registry::{AdapterDeployContext, AdapterExecContext, DeployStoreIds}; +use edgezero_adapter::release::{VerifiedApplicationRelease, verify_application_release}; +use edgezero_core::env_config::{EnvConfig, merge_env_defaults}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::env; +use std::fmt::{self, Write as _}; +use std::fs; +use std::io::{ErrorKind, Write as _}; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +use std::process::{ChildStdin, Command, Stdio}; +use std::thread; +use std::time::Duration; + +/// Non-targeting Fastly CLI global booleans accepted by the managed deploy +/// path. Lifecycle-owned `--autoclone` is deliberately absent. +const MANAGED_DEPLOY_GLOBAL_BOOL_FLAGS: &[&str] = &[ + "--accept-defaults", + "-d", + "--auto-yes", + "-y", + "--debug-mode", + "--non-interactive", + "-i", + "--quiet", + "-q", + "--verbose", + "-v", +]; + +/// Version-scoped logging endpoint collections exposed by the Fastly API. +/// `service logging debug` streams endpoint errors and is not a collection. +const FASTLY_LOGGING_PROVIDER_KINDS: &[&str] = &[ + "azureblob", + "bigquery", + "cloudfiles", + "datadog", + "digitalocean", + "elasticsearch", + "ftp", + "gcs", + "pubsub", + "grafanacloudlogs", + "heroku", + "honeycomb", + "https", + "kafka", + "kinesis", + "logentries", + "loggly", + "logshuttle", + "newrelic", + "newrelicotlp", + "openstack", + "papertrail", + "s3", + "scalyr", + "sftp", + "splunk", + "sumologic", + "syslog", +]; + +#[derive(Debug, Eq, PartialEq)] +pub(super) struct ReleaseManagedDeployArgs { + comment: Option, + globals: Vec, +} + +pub(super) struct FastlyApiToken { + value: String, + env: Vec<(String, String)>, +} + +impl fmt::Debug for FastlyApiToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("FastlyApiToken([REDACTED])") + } +} + +impl FastlyApiToken { + fn as_str(&self) -> &str { + &self.value + } +} + +#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq)] +struct ServiceVersionRecord { + #[serde(alias = "Active")] + active: bool, + #[serde(alias = "Environments")] + environments: Vec, + #[serde(alias = "Locked")] + locked: bool, + #[serde(alias = "Number")] + number: u64, +} + +#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq)] +struct ServiceEnvironmentRecord { + #[serde(alias = "ServiceVersion")] + active_version: u64, + #[serde(alias = "Name")] + name: String, + #[serde(alias = "ServiceID")] + service_id: String, +} + +#[derive(Debug, serde::Deserialize)] +struct ComputePackageRecord { + metadata: ComputePackageMetadata, + service_id: String, + version: u64, +} + +#[derive(Debug, serde::Deserialize)] +struct ComputePackageMetadata { + files_hash: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum VersionSource { + Active(u64), + InitialDraft(u64), + Retired(u64), + Staging(u64), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PublishTarget { + Production, + Staging, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StagingRollbackDecision { + Deactivate, + NoopDraft, +} + +#[derive(Debug)] +struct InitialDraftSnapshot { + configuration: VersionConfigurationSnapshot, + links: serde_json::Value, + metadata: serde_json::Value, + version: ServiceVersionRecord, +} + +#[derive(Debug)] +struct InactiveSourceSnapshot { + links: serde_json::Value, + metadata: serde_json::Value, + version: ServiceVersionRecord, +} + +#[derive(Clone, Debug, PartialEq)] +struct LoggingProviderSnapshot { + endpoints: serde_json::Value, + kind: &'static str, +} + +#[derive(Clone, Debug, PartialEq)] +struct VersionConfigurationSnapshot { + backends: serde_json::Value, + domains: serde_json::Value, + healthchecks: serde_json::Value, + logging: Vec, + settings: serde_json::Value, +} + +#[derive(Debug)] +enum EditableVersionSource { + CloneActive { active_version: u64 }, + CloneRetired(Box), + CloneStaging(Box), + InitialDraft(Box), +} + +#[derive(Debug)] +struct ManagedDeployPlan { + arguments: ReleaseManagedDeployArgs, + child_env: Vec<(String, String)>, + command_dir: tempfile::TempDir, + links: LinkReconciliation, + package_files_hash: String, + package_sha256: String, + release: VerifiedApplicationRelease, + service_id: String, + source_configuration: VersionConfigurationSnapshot, + source_links: Vec, + target: PublishTarget, + token: FastlyApiToken, + version_source: EditableVersionSource, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum ResourceKind { + Config, + Kv, + Secret, +} + +impl ResourceKind { + fn display_name(self) -> &'static str { + match self { + Self::Config => "Config Store", + Self::Kv => "KV Store", + Self::Secret => "Secret Store", + } + } + + fn selector_kind(self) -> &'static str { + match self { + Self::Config => "config", + Self::Kv => "kv", + Self::Secret => "secrets", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct DesiredResourceLink { + alias: String, + kind: ResourceKind, + resource_id: String, + selected_name: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ExistingResourceLink { + alias: String, + kind: ResourceKind, + link_id: String, + resource_id: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct LinkReconciliation { + create: Vec, + delete_link_ids: Vec, +} + +#[derive(Clone, Debug)] +struct StoreInventory { + by_name: BTreeMap, +} + +#[derive(serde::Deserialize)] +struct PaginatedStoreInventoryPage { + #[serde(alias = "Data")] + data: Vec, + #[serde(alias = "Meta")] + meta: PaginatedStoreInventoryMeta, +} + +#[derive(serde::Deserialize)] +struct PaginatedStoreInventoryRecord { + #[serde(alias = "StoreID")] + id: String, + #[serde(alias = "Name")] + name: String, +} + +#[derive(serde::Deserialize)] +struct PaginatedStoreInventoryMeta { + next_cursor: Option, +} + +#[derive(Clone, Debug)] +struct ResourceInventories { + config: StoreInventory, + kind_by_resource_id: BTreeMap, + kv: StoreInventory, + secret: StoreInventory, +} + +impl ResourceInventories { + fn for_kind(&self, kind: ResourceKind) -> &StoreInventory { + match kind { + ResourceKind::Config => &self.config, + ResourceKind::Kv => &self.kv, + ResourceKind::Secret => &self.secret, + } + } + + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "the pure JSON constructor is retained for clean-cutover planner tests" + ) + )] + fn from_json(raw_config: &str, raw_kv: &str, raw_secret: &str) -> Result { + let config = parse_store_inventory(ResourceKind::Config, raw_config)?; + let kv = parse_store_inventory(ResourceKind::Kv, raw_kv)?; + let secret = parse_store_inventory(ResourceKind::Secret, raw_secret)?; + Self::from_inventories(config, kv, secret) + } + + fn from_inventories( + config: StoreInventory, + kv: StoreInventory, + secret: StoreInventory, + ) -> Result { + let mut kind_by_resource_id = BTreeMap::new(); + for (kind, inventory) in [ + (ResourceKind::Config, &config), + (ResourceKind::Kv, &kv), + (ResourceKind::Secret, &secret), + ] { + for resource_id in inventory.by_name.values() { + if let Some(prior_kind) = kind_by_resource_id.insert(resource_id.clone(), kind) { + return Err(format!( + "Fastly resource id `{resource_id}` appears in both {} and {} inventories; resource kind is ambiguous", + prior_kind.display_name(), + kind.display_name() + )); + } + } + } + Ok(Self { + config, + kind_by_resource_id, + kv, + secret, + }) + } + + fn resolve(&self, kind: ResourceKind, name: &str) -> Result { + self.for_kind(kind) + .by_name + .get(name) + .cloned() + .ok_or_else(|| { + format!( + "selected Fastly {} `{name}` does not exist in the complete provider inventory", + kind.display_name() + ) + }) + } +} + +fn parse_store_inventory(kind: ResourceKind, raw: &str) -> Result { + let parsed: serde_json::Value = serde_json::from_str(raw).map_err(|error| { + format!( + "failed to parse Fastly {} inventory as JSON: {error}", + kind.display_name() + ) + })?; + let rows = parsed.as_array().ok_or_else(|| { + format!( + "Fastly {} inventory must be one complete bare JSON array; paginated or enveloped results are not accepted", + kind.display_name() + ) + })?; + let mut records = Vec::with_capacity(rows.len()); + for (index, row) in rows.iter().enumerate() { + let object = row.as_object().ok_or_else(|| { + format!( + "Fastly {} inventory record #{index} is not an object", + kind.display_name() + ) + })?; + let field = |name| { + object + .get(name) + .and_then(serde_json::Value::as_str) + .filter(|field_value| !field_value.is_empty()) + }; + let (Some(name), Some(id)) = (field("name"), field("id")) else { + return Err(format!( + "Fastly {} inventory record #{index} requires non-empty string `name` and `id` fields", + kind.display_name() + )); + }; + records.push((name.to_owned(), id.to_owned())); + } + store_inventory_from_records(kind, records) +} + +fn store_inventory_from_records( + kind: ResourceKind, + records: impl IntoIterator, +) -> Result { + let mut by_name = BTreeMap::new(); + let mut ids = BTreeSet::new(); + for (index, (name, id)) in records.into_iter().enumerate() { + if id.is_empty() + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(format!( + "Fastly {} inventory record #{index} has invalid resource id", + kind.display_name() + )); + } + if name.is_empty() + || name.chars().all(char::is_whitespace) + || name.chars().any(char::is_control) + { + return Err(format!( + "Fastly {} inventory record #{index} has invalid store name", + kind.display_name() + )); + } + if by_name.insert(name.clone(), id.clone()).is_some() { + return Err(format!( + "Fastly {} inventory contains duplicate name `{name}`", + kind.display_name() + )); + } + if !ids.insert(id.clone()) { + return Err(format!( + "Fastly {} inventory contains duplicate resource id `{id}`", + kind.display_name() + )); + } + } + Ok(StoreInventory { by_name }) +} + +fn percent_encode_query_value(value: &str) -> Result { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + write!(encoded, "%{byte:02X}") + .map_err(|error| format!("failed to encode Fastly pagination cursor: {error}"))?; + } + } + Ok(encoded) +} + +fn collect_paginated_store_inventory( + kind: ResourceKind, + first_path: &str, + mut fetch: impl FnMut(&str) -> Result, +) -> Result { + let mut path = first_path.to_owned(); + let mut seen_cursors = BTreeSet::new(); + let mut records = Vec::new(); + loop { + let raw = fetch(&path)?; + let page: PaginatedStoreInventoryPage = serde_json::from_str(&raw).map_err(|_error| { + format!( + "Fastly {} inventory page has an invalid paginated response shape (payload redacted)", + kind.display_name() + ) + })?; + records.extend(page.data.into_iter().map(|record| (record.name, record.id))); + let Some(cursor) = page.meta.next_cursor.filter(|cursor| !cursor.is_empty()) else { + break; + }; + if !seen_cursors.insert(cursor.clone()) { + return Err(format!( + "Fastly {} inventory repeated a pagination cursor; completeness cannot be proven", + kind.display_name() + )); + } + path = format!( + "{first_path}&cursor={}", + percent_encode_query_value(&cursor)? + ); + } + store_inventory_from_records(kind, records) +} + +fn fetch_complete_paginated_store_inventory( + kind: ResourceKind, + endpoint: &str, + token: &FastlyApiToken, +) -> Result { + let first_path = format!("{endpoint}?limit=100"); + collect_paginated_store_inventory(kind, &first_path, |path| fastly_api_get(path, token)) +} + +fn desired_resource_links( + stores: &RuntimeStoreIds, + environment: &EnvConfig, + inventories: &ResourceInventories, +) -> Result, String> { + let mut desired_by_identity = BTreeMap::<(ResourceKind, String), DesiredResourceLink>::new(); + + for (kind, logical_ids) in [ + (ResourceKind::Config, &stores.config), + (ResourceKind::Kv, &stores.kv), + (ResourceKind::Secret, &stores.secrets), + ] { + for logical_id in logical_ids { + let selected_name = environment + .store_name_checked(kind.selector_kind(), logical_id) + .map_err(|error| format!("invalid Fastly deploy environment: {error}"))?; + let resource_id = inventories.resolve(kind, &selected_name)?; + let desired = DesiredResourceLink { + kind, + alias: logical_id.clone(), + selected_name: selected_name.clone(), + resource_id, + }; + let identity = (kind, logical_id.clone()); + if desired_by_identity.insert(identity, desired).is_some() { + return Err(format!( + "Fastly {} logical store id `{logical_id}` is declared more than once", + kind.display_name() + )); + } + } + } + Ok(desired_by_identity.into_values().collect()) +} + +fn plan_link_reconciliation( + desired: &[DesiredResourceLink], + existing: &[ExistingResourceLink], + inventories: &ResourceInventories, +) -> Result { + // Fastly exposes Config, KV, and Secret Stores through separate typed + // runtime APIs, so a resource-link identity is `(kind, alias)`. Reusing an + // alias across kinds is valid; two links with the same kind and alias are + // ambiguous to EdgeZero and fail closed below. + let mut desired_identities = BTreeSet::new(); + for link in desired { + let identity = (link.kind, link.alias.as_str()); + if !desired_identities.insert(identity) { + return Err(format!( + "desired Fastly {} resource-link alias `{}` is duplicated", + link.kind.display_name(), + link.alias + )); + } + } + let mut existing_by_identity = BTreeMap::new(); + let mut existing_ids = BTreeSet::new(); + for link in existing { + if !existing_ids.insert(link.link_id.as_str()) { + return Err(format!( + "Fastly resource-link inventory contains duplicate link id `{}`", + link.link_id + )); + } + match inventories.kind_by_resource_id.get(&link.resource_id) { + Some(kind) if *kind == link.kind => {} + Some(kind) => { + return Err(format!( + "Fastly resource link `{}` reports {} but resource `{}` belongs to {}", + link.alias, + link.kind.display_name(), + link.resource_id, + kind.display_name() + )); + } + // Account inventory visibility can be narrower than the resource + // links inherited by this service version. The link response is + // authoritative for an undeclared inherited link, which must + // survive reconciliation even when the token cannot list its + // physical resource. + None => {} + } + let identity = (link.kind, link.alias.as_str()); + if existing_by_identity.insert(identity, link).is_some() { + return Err(format!( + "Fastly resource-link inventory contains duplicate {} alias `{}`", + link.kind.display_name(), + link.alias + )); + } + } + + let mut create = Vec::new(); + let mut delete_link_ids = Vec::new(); + for desired_link in desired { + let identity = (desired_link.kind, desired_link.alias.as_str()); + match existing_by_identity.get(&identity) { + Some(existing_link) if existing_link.resource_id == desired_link.resource_id => {} + Some(existing_link) => { + delete_link_ids.push(existing_link.link_id.clone()); + create.push(desired_link.clone()); + } + None => create.push(desired_link.clone()), + } + } + + delete_link_ids.sort(); + delete_link_ids.dedup(); + create.sort_by(|left, right| (left.kind, &left.alias).cmp(&(right.kind, &right.alias))); + Ok(LinkReconciliation { + create, + delete_link_ids, + }) +} + +fn managed_startup_directory(root: &Path, manifest: &Path) -> Result { + let command_dir = tempfile::tempdir().map_err(|error| error.to_string())?; + let canonical_root = root.canonicalize().map_err(|error| error.to_string())?; + if command_dir + .path() + .canonicalize() + .map_err(|error| error.to_string())? + .starts_with(canonical_root) + { + return Err("TMPDIR must be outside the immutable application release".to_owned()); + } + fs::copy(manifest, command_dir.path().join("fastly.toml")) + .map_err(|error| format!("prepare private Fastly startup manifest: {error}"))?; + Ok(command_dir) +} + +struct FastlyCommandContext<'ctx> { + cwd: &'ctx Path, + env: &'ctx [(String, String)], +} + +#[expect( + clippy::too_many_lines, + reason = "the read-only checkpoint intentionally assembles every validated input into one reviewable plan" +)] +fn build_managed_deploy_plan( + context: &AdapterDeployContext, + args: &[String], + exec: &AdapterExecContext<'_>, +) -> Result { + let arguments = parse_release_managed_deploy_args(args)?; + let target = if context.staging { + PublishTarget::Staging + } else { + PublishTarget::Production + }; + let environment = effective_deploy_environment(context, exec)?; + let release_root = context + .application_release_root + .as_deref() + .ok_or_else(|| "managed Fastly deployment requires --application-release".to_owned())?; + let application_manifest = context + .application_manifest_path + .as_deref() + .ok_or_else(|| { + "managed Fastly deployment requires the exact loaded application manifest path" + .to_owned() + })?; + let adapter_manifest = context.adapter_manifest_path.as_deref().ok_or_else(|| { + "managed Fastly deployment requires the exact referenced Fastly manifest path".to_owned() + })?; + let release = verify_application_release( + release_root, + application_manifest, + adapter_manifest, + "fastly", + 1, + )?; + let service_id = resolve_managed_plan_service_id(context, &release, exec)?; + let token = require_token_with_exec(exec)?; + if token.as_str().is_empty() { + return Err(format!( + "{FASTLY_API_TOKEN_ENV} must be non-empty in the environment" + )); + } + + let stores = RuntimeStoreIds::from(&context.stores); + for logical_id in &stores.config { + let key = environment + .store_key_checked(ResourceKind::Config.selector_kind(), logical_id) + .map_err(|error| format!("invalid Fastly deploy environment: {error}"))?; + validate_fastly_config_key(logical_id, &key, target == PublishTarget::Staging, false)?; + } + let command_dir = managed_startup_directory(release_root, release.adapter_manifest())?; + let command_context = FastlyCommandContext { + cwd: command_dir.path(), + env: exec.env(), + }; + let cwd = &command_context; + let package_files_hash = compute_package_files_hash(release.package(), cwd, &token)?; + + let raw_config_inventory = run_fastly_json_capture(&["config-store", "list", "--json"], cwd)?; + let config_inventory = parse_store_inventory(ResourceKind::Config, &raw_config_inventory)?; + let kv_inventory = + fetch_complete_paginated_store_inventory(ResourceKind::Kv, "/resources/stores/kv", &token)?; + let secret_inventory = fetch_complete_paginated_store_inventory( + ResourceKind::Secret, + "/resources/stores/secret", + &token, + )?; + let inventories = + ResourceInventories::from_inventories(config_inventory, kv_inventory, secret_inventory)?; + let desired_links = desired_resource_links(&stores, &environment, &inventories)?; + + let versions_raw = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; + let versions = parse_service_versions(&versions_raw)?; + let selected_source = select_version_source(&versions)?; + let source_version = match selected_source { + VersionSource::Active(version) + | VersionSource::InitialDraft(version) + | VersionSource::Retired(version) + | VersionSource::Staging(version) => version, + }; + let links_raw = run_fastly_json_capture( + &[ + "service", + "resource-link", + "list", + &format!("--service-id={service_id}"), + &format!("--version={source_version}"), + "--json", + ], + cwd, + )?; + let (source_links, links_snapshot) = parse_resource_links(&links_raw)?; + let source_configuration = + read_version_configuration_snapshot_for(&service_id, &token, source_version)?; + + let links = plan_link_reconciliation(&desired_links, &source_links, &inventories)?; + + let version_source = match selected_source { + VersionSource::Active(active_version) => { + EditableVersionSource::CloneActive { active_version } + } + VersionSource::InitialDraft(draft_version) => { + let version = versions + .iter() + .find(|version| version.number == draft_version) + .cloned() + .ok_or_else(|| { + format!("selected initial draft version {draft_version} disappeared") + })?; + let metadata = exact_version_metadata(&versions_raw, draft_version)?; + EditableVersionSource::InitialDraft(Box::new(snapshot_initial_draft( + version, + metadata, + links_snapshot, + source_configuration.clone(), + ))) + } + VersionSource::Retired(inactive_version) | VersionSource::Staging(inactive_version) => { + let version = versions + .iter() + .find(|version| version.number == inactive_version) + .cloned() + .ok_or_else(|| { + format!("selected inactive version {inactive_version} disappeared") + })?; + let metadata = exact_version_metadata(&versions_raw, inactive_version)?; + let snapshot = Box::new(InactiveSourceSnapshot { + links: links_snapshot, + metadata, + version, + }); + if matches!(selected_source, VersionSource::Retired(_)) { + EditableVersionSource::CloneRetired(snapshot) + } else { + EditableVersionSource::CloneStaging(snapshot) + } + } + }; + let package_sha256 = release.package_sha256().to_owned(); + + Ok(ManagedDeployPlan { + command_dir, + child_env: exec.env().to_vec(), + arguments, + links, + package_files_hash, + package_sha256, + release, + service_id, + source_configuration, + source_links, + target, + token, + version_source, + }) +} + +pub(super) fn deploy_managed_with_context( + context: &AdapterDeployContext, + args: &[String], + exec: &AdapterExecContext<'_>, +) -> Result<(), String> { + let plan = build_managed_deploy_plan(context, args, exec)?; + execute_managed_deploy_plan(&plan) +} + +fn execute_managed_deploy_plan(plan: &ManagedDeployPlan) -> Result<(), String> { + execute_managed_deploy_plan_with_emit(plan, &mut |line| log::info!("{line}")) +} + +fn execute_managed_deploy_plan_with_emit( + plan: &ManagedDeployPlan, + emit: &mut dyn FnMut(&str), +) -> Result<(), String> { + let command_context = FastlyCommandContext { + cwd: plan.command_dir.path(), + env: &plan.child_env, + }; + let cwd = &command_context; + emit(&format!("package-sha256={}", plan.package_sha256)); + let version = prepare_managed_version(plan, cwd, emit)?; + + if let Some(comment) = plan.arguments.comment.as_deref() { + run_fastly_status( + &[ + "service".to_owned(), + "version".to_owned(), + "update".to_owned(), + format!("--service-id={}", plan.service_id), + format!("--version={version}"), + format!("--comment={comment}"), + ], + cwd, + )?; + } + + let inherited = read_version_links(&plan.service_id, version, cwd)?; + require_same_link_resources(&plan.source_links, &inherited, "inherited draft")?; + let delete_identities = planned_delete_identities(plan)?; + let expected_links = expected_final_link_resources(plan, &delete_identities)?; + let inherited_by_identity = links_by_identity(&inherited)?; + for identity in &delete_identities { + let link = inherited_by_identity.get(identity).ok_or_else(|| { + format!( + "planned stale Fastly {} resource link `{}` is absent from the draft", + identity.0.display_name(), + identity.1 + ) + })?; + run_fastly_status( + &[ + "service".to_owned(), + "resource-link".to_owned(), + "delete".to_owned(), + format!("--service-id={}", plan.service_id), + format!("--version={version}"), + format!("--id={}", link.link_id), + ], + cwd, + )?; + } + for link in &plan.links.create { + run_fastly_status( + &[ + "service".to_owned(), + "resource-link".to_owned(), + "create".to_owned(), + format!("--service-id={}", plan.service_id), + format!("--version={version}"), + format!("--resource-id={}", link.resource_id), + format!("--name={}", link.alias), + ], + cwd, + )?; + } + let reconciled = read_version_links(&plan.service_id, version, cwd)?; + require_exact_link_resources(&expected_links, &reconciled, "reconciled draft")?; + + // Capture the versioned Compute configuration only after EdgeZero has + // finished every intended mutation, then compare it again in the immediate + // publication barrier below. Package identity and resource links have + // dedicated checks alongside this snapshot. + let expected_configuration = read_version_configuration_snapshot(plan, version)?; + if expected_configuration != plan.source_configuration { + return Err(format!( + "Fastly version {version} protected Compute configuration no longer matches its source" + )); + } + + revalidate_managed_draft(plan, version, &expected_links, &expected_configuration, cwd)?; + + match plan.target { + PublishTarget::Staging => run_fastly_status( + &[ + "service".to_owned(), + "version".to_owned(), + "stage".to_owned(), + format!("--service-id={}", plan.service_id), + format!("--version={version}"), + ], + cwd, + ), + PublishTarget::Production => fastly_api_put( + &format!("/service/{}/version/{version}/activate", plan.service_id), + &plan.token, + ) + .map(|_status| ()), + } +} + +struct CapturedFastlyCommand { + combined: String, + status: String, + success: bool, +} + +fn prepare_managed_version( + plan: &ManagedDeployPlan, + cwd: &FastlyCommandContext<'_>, + emit: &mut dyn FnMut(&str), +) -> Result { + let package = plan.release.package().to_str().ok_or_else(|| { + "verified Fastly package path is not valid UTF-8 and cannot be passed to the Fastly CLI" + .to_owned() + })?; + let version = match &plan.version_source { + EditableVersionSource::CloneActive { active_version } => { + revalidate_active_source(plan, *active_version, cwd, None)?; + clone_managed_source(plan, *active_version, cwd, emit)? + } + EditableVersionSource::CloneRetired(snapshot) + | EditableVersionSource::CloneStaging(snapshot) => { + revalidate_inactive_source(plan, snapshot, cwd, None)?; + require_source_configuration(plan, snapshot.version.number)?; + clone_managed_source(plan, snapshot.version.number, cwd, emit)? + } + EditableVersionSource::InitialDraft(snapshot) => { + revalidate_initial_draft_before_update(plan, snapshot, cwd)?; + let version = snapshot.version.number; + emit(&format!("version={version}")); + version + } + }; + let mut update = vec![ + "compute".to_owned(), + "update".to_owned(), + format!("--service-id={}", plan.service_id), + format!("--version={version}"), + ]; + update.push(format!("--package={package}")); + update.extend(plan.arguments.globals.iter().cloned()); + if !has_non_interactive(&plan.arguments.globals) { + update.push("--non-interactive".to_owned()); + } + + let outcome = run_fastly_capture_outcome(&update, cwd)?; + let reported_version = parse_fastly_version(&outcome.combined); + if !outcome.success { + let redacted_output = outcome.combined.replace(plan.token.as_str(), "[REDACTED]"); + return Err(format!( + "`fastly {}` exited with status {}\n{}", + update.join(" "), + outcome.status, + redacted_output.trim() + )); + } + + if let Some(reported) = reported_version + && reported != version + { + return Err(format!( + "Fastly updated version {reported}, but the verified draft was version {version}" + )); + } + Ok(version) +} + +fn clone_managed_source( + plan: &ManagedDeployPlan, + source_version: u64, + cwd: &FastlyCommandContext<'_>, + emit: &mut dyn FnMut(&str), +) -> Result { + let raw = fastly_api_put_capture( + &format!( + "/service/{}/version/{source_version}/clone", + plan.service_id + ), + &plan.token, + )?; + let version = parse_cloned_version(&raw, &plan.service_id, source_version)?; + emit(&format!("version={version}")); + + match &plan.version_source { + EditableVersionSource::CloneActive { active_version } => { + revalidate_active_source(plan, *active_version, cwd, Some(version))?; + } + EditableVersionSource::CloneRetired(snapshot) + | EditableVersionSource::CloneStaging(snapshot) => { + revalidate_inactive_source(plan, snapshot, cwd, Some(version))?; + require_source_configuration(plan, snapshot.version.number)?; + } + EditableVersionSource::InitialDraft(_) => { + return Err("internal managed deploy clone source mismatch".to_owned()); + } + } + + let versions_raw = fastly_api_get( + &format!("/service/{}/version", plan.service_id), + &plan.token, + )?; + let versions = parse_service_versions(&versions_raw)?; + let draft = versions + .iter() + .find(|candidate| candidate.number == version) + .ok_or_else(|| format!("cloned Fastly draft version {version} is absent"))?; + if draft.active || draft.locked || !draft.environments.is_empty() { + return Err(format!( + "cloned Fastly version {version} is not an unpublished editable draft" + )); + } + let cloned_links = read_version_links(&plan.service_id, version, cwd)?; + require_same_link_resources(&plan.source_links, &cloned_links, "fresh clone")?; + let cloned_configuration = read_version_configuration_snapshot(plan, version)?; + if cloned_configuration != plan.source_configuration { + return Err(format!( + "cloned Fastly version {version} does not match the preflight source configuration" + )); + } + Ok(version) +} + +fn parse_cloned_version(raw: &str, service_id: &str, source_version: u64) -> Result { + let value: serde_json::Value = serde_json::from_str(raw) + .map_err(|_error| "Fastly clone response is malformed".to_owned())?; + let object = value + .as_object() + .ok_or_else(|| "Fastly clone response must be one JSON object".to_owned())?; + let cloned_service = object + .get("service_id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Fastly clone response has no service_id".to_owned())?; + let version = object + .get("number") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| "Fastly clone response has no numeric version".to_owned())?; + if cloned_service != service_id || version == source_version { + return Err( + "Fastly clone response does not identify a new version for the requested service" + .to_owned(), + ); + } + Ok(version) +} + +fn run_fastly_capture_outcome( + fastly_args: &[String], + cwd: &FastlyCommandContext<'_>, +) -> Result { + let output = Command::new("fastly") + .args(fastly_args) + .current_dir(cwd.cwd) + .envs(cwd.env.iter().map(|(key, value)| (key, value))) + .output() + .map_err(|error| { + if error.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to run fastly CLI: {error}") + } + })?; + let mut combined = String::from_utf8_lossy(&output.stdout).into_owned(); + combined.push_str(&String::from_utf8_lossy(&output.stderr)); + Ok(CapturedFastlyCommand { + combined, + status: output.status.to_string(), + success: output.status.success(), + }) +} + +fn is_canonical_sha512_hex(value: &str) -> bool { + value.len() == 128 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn parse_package_files_hash_output(output: &str) -> Result { + let hashes = output + .lines() + .map(str::trim) + .filter(|line| is_canonical_sha512_hex(line)) + .collect::>(); + if hashes.len() != 1 { + return Err( + "Fastly package hash command did not return one unambiguous SHA-512 files hash" + .to_owned(), + ); + } + hashes.into_iter().next().map(str::to_owned).ok_or_else(|| { + "Fastly package hash command did not return one unambiguous SHA-512 files hash".to_owned() + }) +} + +fn compute_package_files_hash( + package: &Path, + cwd: &FastlyCommandContext<'_>, + token: &FastlyApiToken, +) -> Result { + let package_path = package.to_str().ok_or_else(|| { + "verified Fastly package path is not valid UTF-8 and cannot be hashed by the Fastly CLI" + .to_owned() + })?; + let outcome = run_fastly_capture_outcome( + &[ + "compute".to_owned(), + "hash-files".to_owned(), + format!("--package={package_path}"), + "--skip-build".to_owned(), + "--non-interactive".to_owned(), + "--quiet".to_owned(), + ], + cwd, + )?; + if !outcome.success { + let redacted = outcome.combined.replace(token.as_str(), "[REDACTED]"); + return Err(format!( + "Fastly package hash command exited with status {}\n{}", + outcome.status, + redacted.trim() + )); + } + parse_package_files_hash_output(&outcome.combined) +} + +fn parse_package_metadata_files_hash( + raw: &str, + expected_service_id: &str, + expected_version: u64, +) -> Result { + let package: ComputePackageRecord = serde_json::from_str(raw) + .map_err(|_error| "Fastly package metadata response is malformed".to_owned())?; + if package.service_id != expected_service_id || package.version != expected_version { + return Err( + "Fastly package metadata does not identify the requested service version".to_owned(), + ); + } + if !is_canonical_sha512_hex(&package.metadata.files_hash) { + return Err("Fastly package metadata contains an invalid files hash".to_owned()); + } + Ok(package.metadata.files_hash) +} + +fn read_version_links( + service_id: &str, + version: u64, + cwd: &FastlyCommandContext<'_>, +) -> Result, String> { + read_version_links_with_snapshot(service_id, version, cwd).map(|(links, _snapshot)| links) +} + +fn read_version_links_with_snapshot( + service_id: &str, + version: u64, + cwd: &FastlyCommandContext<'_>, +) -> Result<(Vec, serde_json::Value), String> { + let service_arg = format!("--service-id={service_id}"); + let version_arg = format!("--version={version}"); + let raw = run_fastly_json_capture( + &[ + "service", + "resource-link", + "list", + &service_arg, + &version_arg, + "--json", + ], + cwd, + )?; + parse_resource_links(&raw) +} + +type ResourceLinkIdentity = (ResourceKind, String); + +fn links_by_identity( + links: &[ExistingResourceLink], +) -> Result, String> { + let mut by_identity = BTreeMap::new(); + let mut ids = BTreeSet::new(); + for link in links { + if !ids.insert(link.link_id.as_str()) { + return Err(format!( + "Fastly resource-link inventory contains duplicate link id `{}`", + link.link_id + )); + } + let identity = (link.kind, link.alias.clone()); + if by_identity.insert(identity, link.clone()).is_some() { + return Err(format!( + "Fastly resource-link inventory contains duplicate {} alias `{}`", + link.kind.display_name(), + link.alias + )); + } + } + Ok(by_identity) +} + +fn link_resource_map( + links: &[ExistingResourceLink], +) -> Result, String> { + Ok(links_by_identity(links)? + .into_iter() + .map(|(identity, link)| (identity, link.resource_id)) + .collect()) +} + +fn require_same_link_resources( + expected: &[ExistingResourceLink], + actual: &[ExistingResourceLink], + label: &str, +) -> Result<(), String> { + let expected_resources = link_resource_map(expected)?; + require_exact_link_resources(&expected_resources, actual, label) +} + +fn require_exact_link_resources( + expected: &BTreeMap, + actual: &[ExistingResourceLink], + label: &str, +) -> Result<(), String> { + let actual_resources = link_resource_map(actual)?; + if actual_resources == *expected { + Ok(()) + } else { + Err(format!( + "Fastly {label} resource links changed unexpectedly; refusing to publish" + )) + } +} + +fn planned_delete_identities( + plan: &ManagedDeployPlan, +) -> Result, String> { + let by_id = plan + .source_links + .iter() + .map(|link| (link.link_id.as_str(), (link.kind, link.alias.as_str()))) + .collect::>(); + plan.links + .delete_link_ids + .iter() + .map(|id| { + by_id + .get(id.as_str()) + .map(|(kind, alias)| (*kind, (*alias).to_owned())) + .ok_or_else(|| { + format!("planned Fastly resource-link deletion `{id}` has no source link") + }) + }) + .collect() +} + +fn expected_final_link_resources( + plan: &ManagedDeployPlan, + delete_identities: &BTreeSet, +) -> Result, String> { + let mut expected = link_resource_map(&plan.source_links)?; + for identity in delete_identities { + if expected.remove(identity).is_none() { + return Err(format!( + "planned stale Fastly {} resource-link alias `{}` has no source link", + identity.0.display_name(), + identity.1 + )); + } + } + for link in &plan.links.create { + let identity = (link.kind, link.alias.clone()); + if expected + .insert(identity, link.resource_id.clone()) + .is_some() + { + return Err(format!( + "planned Fastly {} resource-link creation `{}` collides with an inherited identity", + link.kind.display_name(), + link.alias + )); + } + } + Ok(expected) +} + +fn revalidate_initial_draft_before_update( + plan: &ManagedDeployPlan, + snapshot: &InitialDraftSnapshot, + cwd: &FastlyCommandContext<'_>, +) -> Result<(), String> { + let versions_raw = fastly_api_get( + &format!("/service/{}/version", plan.service_id), + &plan.token, + )?; + let versions = parse_service_versions(&versions_raw)?; + if select_version_source(&versions)? != VersionSource::InitialDraft(snapshot.version.number) { + return Err("Fastly initial draft selection changed after preflight".to_owned()); + } + let current = versions + .iter() + .find(|version| version.number == snapshot.version.number) + .ok_or_else(|| "Fastly initial draft disappeared after preflight".to_owned())?; + if current != &snapshot.version + || exact_version_metadata(&versions_raw, snapshot.version.number)? != snapshot.metadata + { + return Err("Fastly initial draft metadata changed after preflight".to_owned()); + } + let service_id = &plan.service_id; + let version = snapshot.version.number; + let (_current_links, links_value) = read_version_links_with_snapshot(service_id, version, cwd)?; + if links_value != snapshot.links { + return Err("Fastly initial draft links changed after preflight".to_owned()); + } + require_protected_initial_snapshot(plan, snapshot)?; + Ok(()) +} + +fn revalidate_active_source( + plan: &ManagedDeployPlan, + active_version: u64, + cwd: &FastlyCommandContext<'_>, + excluded_target: Option, +) -> Result<(), String> { + let versions_raw = fastly_api_get( + &format!("/service/{}/version", plan.service_id), + &plan.token, + )?; + let versions = parse_service_versions(&versions_raw)?; + let source_versions = source_versions_excluding(&versions, excluded_target); + if select_version_source(&source_versions)? != VersionSource::Active(active_version) { + return Err("Fastly active source selection changed after preflight".to_owned()); + } + let source = source_versions + .iter() + .find(|version| version.number == active_version) + .ok_or_else(|| "Fastly active source disappeared after preflight".to_owned())?; + if !source.locked { + return Err("Fastly active source is unexpectedly editable".to_owned()); + } + let current_links = read_version_links(&plan.service_id, active_version, cwd)?; + require_same_link_resources(&plan.source_links, ¤t_links, "active source")?; + require_source_configuration(plan, active_version) +} + +fn require_source_configuration( + plan: &ManagedDeployPlan, + source_version: u64, +) -> Result<(), String> { + let current = read_version_configuration_snapshot(plan, source_version)?; + if current == plan.source_configuration { + Ok(()) + } else { + Err(format!( + "Fastly source version {source_version} protected Compute configuration changed after preflight" + )) + } +} + +fn revalidate_inactive_source( + plan: &ManagedDeployPlan, + snapshot: &InactiveSourceSnapshot, + cwd: &FastlyCommandContext<'_>, + excluded_target: Option, +) -> Result<(), String> { + let versions_raw = fastly_api_get( + &format!("/service/{}/version", plan.service_id), + &plan.token, + )?; + let versions = parse_service_versions(&versions_raw)?; + let source_versions = source_versions_excluding(&versions, excluded_target); + let expected = match &plan.version_source { + EditableVersionSource::CloneRetired(_) => VersionSource::Retired(snapshot.version.number), + EditableVersionSource::CloneStaging(_) => VersionSource::Staging(snapshot.version.number), + EditableVersionSource::CloneActive { .. } | EditableVersionSource::InitialDraft(_) => { + return Err("internal managed deploy inactive source mismatch".to_owned()); + } + }; + if select_version_source(&source_versions)? != expected { + return Err("Fastly inactive source selection changed after preflight".to_owned()); + } + let current = versions + .iter() + .find(|version| version.number == snapshot.version.number) + .ok_or_else(|| "Fastly inactive source disappeared after preflight".to_owned())?; + if current != &snapshot.version + || exact_version_metadata(&versions_raw, snapshot.version.number)? != snapshot.metadata + { + return Err("Fastly inactive source metadata changed after preflight".to_owned()); + } + let (_current_links, links_value) = + read_version_links_with_snapshot(&plan.service_id, snapshot.version.number, cwd)?; + if links_value != snapshot.links { + return Err("Fastly inactive source links changed after preflight".to_owned()); + } + Ok(()) +} + +fn require_protected_initial_snapshot( + plan: &ManagedDeployPlan, + snapshot: &InitialDraftSnapshot, +) -> Result<(), String> { + let current = read_version_configuration_snapshot(plan, snapshot.version.number)?; + if current == snapshot.configuration { + Ok(()) + } else { + Err("Fastly initial draft protected configuration changed after preflight".to_owned()) + } +} + +fn revalidate_managed_draft( + plan: &ManagedDeployPlan, + version: u64, + expected_links: &BTreeMap, + expected_configuration: &VersionConfigurationSnapshot, + cwd: &FastlyCommandContext<'_>, +) -> Result<(), String> { + let versions_raw = fastly_api_get( + &format!("/service/{}/version", plan.service_id), + &plan.token, + )?; + let versions = parse_service_versions(&versions_raw)?; + let source_state = match &plan.version_source { + EditableVersionSource::CloneActive { active_version } => { + VersionSource::Active(*active_version) + } + EditableVersionSource::CloneRetired(snapshot) => { + VersionSource::Retired(snapshot.version.number) + } + EditableVersionSource::CloneStaging(snapshot) => { + VersionSource::Staging(snapshot.version.number) + } + EditableVersionSource::InitialDraft(snapshot) => { + VersionSource::InitialDraft(snapshot.version.number) + } + }; + require_managed_draft_state(source_state, &versions, version)?; + match &plan.version_source { + EditableVersionSource::CloneRetired(snapshot) + | EditableVersionSource::CloneStaging(snapshot) => { + revalidate_inactive_source(plan, snapshot, cwd, Some(version))?; + } + EditableVersionSource::CloneActive { .. } | EditableVersionSource::InitialDraft(_) => {} + } + let links = read_version_links(&plan.service_id, version, cwd)?; + require_exact_link_resources(expected_links, &links, "final draft")?; + let package_raw = fastly_api_get( + &format!("/service/{}/version/{version}/package", plan.service_id), + &plan.token, + )?; + let package_files_hash = + parse_package_metadata_files_hash(&package_raw, &plan.service_id, version)?; + require_package_identity(&plan.package_files_hash, &package_files_hash, version)?; + let current_configuration = read_version_configuration_snapshot(plan, version)?; + require_draft_configuration(expected_configuration, ¤t_configuration, version) +} + +fn source_versions_excluding( + versions: &[ServiceVersionRecord], + excluded_target: Option, +) -> Vec { + versions + .iter() + .filter(|version| Some(version.number) != excluded_target) + .cloned() + .collect() +} + +fn require_managed_draft_state( + source: VersionSource, + versions: &[ServiceVersionRecord], + version: u64, +) -> Result<(), String> { + let draft = versions + .iter() + .find(|candidate| candidate.number == version) + .ok_or_else(|| format!("Fastly draft version {version} disappeared before publication"))?; + if draft.active || draft.locked || !draft.environments.is_empty() { + return Err(format!( + "Fastly version {version} is no longer an unpublished editable draft" + )); + } + + let active_versions = versions + .iter() + .filter(|candidate| candidate.active) + .map(|candidate| candidate.number) + .collect::>(); + match source { + VersionSource::Active(active_version) if active_versions != [active_version] => { + return Err("Fastly active version changed before publication".to_owned()); + } + VersionSource::Active(_) => {} + _ if !active_versions.is_empty() => { + return Err("Fastly active version appeared before publication".to_owned()); + } + VersionSource::Retired(source_version) if version == source_version => { + return Err("managed Fastly retired clone version did not advance".to_owned()); + } + VersionSource::Staging(source_version) if version == source_version => { + return Err("managed Fastly staged clone version did not advance".to_owned()); + } + VersionSource::InitialDraft(source_version) if version != source_version => { + return Err("managed Fastly initial draft version changed".to_owned()); + } + VersionSource::Retired(_) | VersionSource::Staging(_) | VersionSource::InitialDraft(_) => {} + } + Ok(()) +} + +fn require_package_identity(expected: &str, actual: &str, version: u64) -> Result<(), String> { + if actual == expected { + Ok(()) + } else { + Err(format!( + "Fastly version {version} package identity changed before publication" + )) + } +} + +fn require_draft_configuration( + expected: &VersionConfigurationSnapshot, + actual: &VersionConfigurationSnapshot, + version: u64, +) -> Result<(), String> { + if actual == expected { + Ok(()) + } else { + Err(format!( + "Fastly version {version} protected Compute configuration changed before publication" + )) + } +} + +fn read_version_configuration_snapshot( + plan: &ManagedDeployPlan, + version: u64, +) -> Result { + read_version_configuration_snapshot_for(&plan.service_id, &plan.token, version) +} + +fn read_version_configuration_snapshot_for( + service_id: &str, + token: &FastlyApiToken, + version: u64, +) -> Result { + let base = format!("/service/{service_id}/version/{version}"); + let domains = parse_snapshot_array( + "domains", + &fastly_api_get(&format!("{base}/domain"), token)?, + )?; + let backends = parse_snapshot_array( + "backends", + &fastly_api_get(&format!("{base}/backend"), token)?, + )?; + let healthchecks = parse_snapshot_array( + "health checks", + &fastly_api_get(&format!("{base}/healthcheck"), token)?, + )?; + let logging = snapshot_logging_providers(&base, token)?; + let settings = parse_snapshot_object( + "settings", + &fastly_api_get(&format!("{base}/settings"), token)?, + )?; + Ok(VersionConfigurationSnapshot { + backends, + domains, + healthchecks, + logging, + settings, + }) +} + +fn resolve_managed_plan_service_id( + context: &AdapterDeployContext, + release: &VerifiedApplicationRelease, + exec: &AdapterExecContext<'_>, +) -> Result { + if let Some(service_id) = context.service_id.as_deref() { + validate_service_id(service_id)?; + return Ok(service_id.to_owned()); + } + effective_fastly_service_id_with_exec(release.adapter_manifest(), exec)? + .map(SelectedFastlyService::into_id) + .ok_or_else(|| { + format!( + "managed Fastly deployment requires a service id in typed context, the verified Fastly manifest, or {FASTLY_SERVICE_ID_ENV}" + ) + }) +} + +fn run_fastly_json_capture( + args: &[&str], + cwd: &FastlyCommandContext<'_>, +) -> Result { + let output = Command::new("fastly") + .args(args) + .current_dir(cwd.cwd) + .envs(cwd.env.iter().map(|(key, value)| (key, value))) + .output() + .map_err(|error| { + if error.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to run read-only Fastly command: {error}") + } + })?; + if !output.status.success() { + return Err(format!( + "read-only `fastly {}` exited with status {}\nstderr: {}", + args.join(" "), + output.status, + redact_stderr(&String::from_utf8_lossy(&output.stderr)) + )); + } + strict_stdout(output.stdout, "read-only Fastly JSON command") +} + +fn parse_resource_links( + raw: &str, +) -> Result<(Vec, serde_json::Value), String> { + let snapshot: serde_json::Value = serde_json::from_str(raw) + .map_err(|error| format!("failed to parse Fastly resource-link inventory: {error}"))?; + let rows = snapshot.as_array().ok_or_else(|| { + "Fastly resource-link inventory must be one complete bare JSON array".to_owned() + })?; + let mut links = Vec::with_capacity(rows.len()); + for (index, row) in rows.iter().enumerate() { + let object = row.as_object().ok_or_else(|| { + format!("Fastly resource-link inventory record #{index} is not an object") + })?; + let required = |field: &str| { + object + .get(field) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| { + format!( + "Fastly resource-link inventory record #{index} requires non-empty string `{field}`" + ) + }) + }; + let resource_type = required("resource_type")?; + let kind = match resource_type.as_str() { + "config" => ResourceKind::Config, + "kv-store" => ResourceKind::Kv, + "secret-store" => ResourceKind::Secret, + _ => { + return Err(format!( + "Fastly resource-link inventory record #{index} has unknown `resource_type` `{resource_type}`" + )); + } + }; + links.push(ExistingResourceLink { + link_id: required("id")?, + alias: required("name")?, + kind, + resource_id: required("resource_id")?, + }); + } + Ok((links, snapshot)) +} + +fn snapshot_initial_draft( + version: ServiceVersionRecord, + metadata: serde_json::Value, + links: serde_json::Value, + configuration: VersionConfigurationSnapshot, +) -> InitialDraftSnapshot { + InitialDraftSnapshot { + configuration, + links, + metadata, + version, + } +} + +fn snapshot_logging_providers( + version_base: &str, + token: &FastlyApiToken, +) -> Result, String> { + FASTLY_LOGGING_PROVIDER_KINDS + .iter() + .map(|&kind| { + let endpoints = parse_snapshot_array( + &format!("logging/{kind}"), + &fastly_api_get(&format!("{version_base}/logging/{kind}"), token)?, + )?; + Ok(LoggingProviderSnapshot { endpoints, kind }) + }) + .collect() +} + +fn exact_version_metadata(raw: &str, number: u64) -> Result { + let value: serde_json::Value = serde_json::from_str(raw) + .map_err(|error| format!("failed to preserve Fastly version metadata: {error}"))?; + value + .as_array() + .and_then(|versions| { + versions.iter().find(|version| { + version.get("number").and_then(serde_json::Value::as_u64) == Some(number) + }) + }) + .cloned() + .ok_or_else(|| format!("selected Fastly version {number} has no exact metadata record")) +} + +fn parse_snapshot_array(label: &str, raw: &str) -> Result { + let mut value: serde_json::Value = serde_json::from_str(raw) + .map_err(|error| format!("failed to parse Fastly {label} snapshot: {error}"))?; + let rows = value + .as_array_mut() + .ok_or_else(|| format!("Fastly {label} snapshot must be a complete JSON array"))?; + for (index, row) in rows.iter_mut().enumerate() { + let object = row + .as_object_mut() + .ok_or_else(|| format!("Fastly {label} snapshot record #{index} must be an object"))?; + remove_snapshot_metadata(object); + row.sort_all_objects(); + } + rows.sort_by_cached_key(ToString::to_string); + Ok(value) +} + +fn remove_snapshot_metadata(object: &mut serde_json::Map) { + for field in [ + "created_at", + "locked", + "service_id", + "updated_at", + "version", + ] { + object.remove(field); + } +} + +fn parse_snapshot_object(label: &str, raw: &str) -> Result { + let mut value: serde_json::Value = serde_json::from_str(raw) + .map_err(|error| format!("failed to parse Fastly {label} snapshot: {error}"))?; + let object = value + .as_object_mut() + .ok_or_else(|| format!("Fastly {label} snapshot must be a complete JSON object"))?; + remove_snapshot_metadata(object); + value.sort_all_objects(); + Ok(value) +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct RuntimeStoreIds { + config: Vec, + kv: Vec, + secrets: Vec, +} + +impl From<&DeployStoreIds> for RuntimeStoreIds { + fn from(stores: &DeployStoreIds) -> Self { + Self { + config: stores.config.clone(), + kv: stores.kv.clone(), + secrets: stores.secrets.clone(), + } + } +} + +pub(super) fn owns_managed_deploy(context: &AdapterDeployContext) -> bool { + context.application_release_root.is_some() || context.staging || !context.stores.is_empty() +} + +// =================================================================== +// Fastly lifecycle +// =================================================================== +// +// The adapter-managed deployment path verifies the immutable application release, +// uploads its recorded package with `compute update` to an exact unreachable draft, +// reconciles and reads back exact logical resource links and the package identity, and +// stages or activates only after verification. It emits `version=` and +// `package-sha256=`. The bare store-free production manifest command is a +// compatibility path outside this managed lifecycle. +// +// These entry points also back the `healthcheck` and `rollback` app-CLI subcommands: +// +// * healthcheck → curl the domain (production) or the version's +// resolved staging IP (`--staging`); non-zero exit when unhealthy. +// * rollback → activate the explicit `--rollback-to` version +// (production) or deactivate `` (staging) via the Fastly API. +// Rollback prints `rolled-back-to=`. +// +// Provider HTTP calls shell out to `curl` (matching the lifecycle action +// conventions and avoiding a WASM-incompatible HTTP client +// in the adapter). The `FASTLY_API_TOKEN` is passed to `curl` via a +// `--config -` stdin file rather than on argv, so it never appears in +// `ps` / `/proc//cmdline` (same discipline as +// `create_config_store_entry`'s `--stdin`). + +/// Value that follows `flag` in a `--flag value` arg slice, if present. +fn arg_value<'args>(args: &'args [String], flag: &str) -> Option<&'args str> { + args.iter() + .position(|arg| arg == flag) + .and_then(|idx| idx.checked_add(1)) + .and_then(|idx| args.get(idx)) + .map(String::as_str) +} + +/// Whether a boolean `flag` (e.g. `--staging`) is present in `args`. +fn arg_flag(args: &[String], flag: &str) -> bool { + args.iter().any(|arg| arg == flag) +} + +/// Copy of `args` with `--flag value` removed (both tokens). Used to +/// forward operator passthrough (e.g. `--comment`) to `fastly compute +/// update` without re-passing `--service-id`, which is threaded +/// explicitly. +pub(super) fn args_without_flag_value(args: &[String], flag: &str) -> Vec { + let mut out = Vec::with_capacity(args.len()); + let mut skip = false; + for arg in args { + if skip { + skip = false; + continue; + } + if arg == flag { + skip = true; + continue; + } + out.push(arg.clone()); + } + out +} + +pub(super) fn scan_reserved_deploy_args(args: &[String]) -> Result<(), String> { + for arg in args { + let reserved_flag = match arg.as_str() { + "--service-id" | "-s" | "--service-name" | "--version" | "--autoclone" | "--token" + | "-t" => Some(arg.as_str()), + value if value.starts_with("--service-id=") => Some("--service-id"), + value if value.starts_with("--service-name=") => Some("--service-name"), + value if value.starts_with("--version=") => Some("--version"), + value if value.starts_with("--autoclone=") => Some("--autoclone"), + value if value.starts_with("--token=") => Some("--token"), + value if value.starts_with("-s") && !value.starts_with("--") && value.len() > 2 => { + Some("-s") + } + value if value.starts_with("-t") && !value.starts_with("--") && value.len() > 2 => { + Some("-t") + } + _ => None, + }; + if let Some(flag) = reserved_flag { + return Err(format!( + "Fastly deploy argument `{flag}` is reserved for the EdgeZero deployment lifecycle" + )); + } + } + Ok(()) +} + +fn set_managed_deploy_value( + slot: &mut Option, + flag: &str, + value: &str, +) -> Result<(), String> { + if value.is_empty() { + return Err(format!( + "Fastly deploy argument `{flag}` requires a non-empty value" + )); + } + if slot.is_some() { + return Err(format!( + "Fastly deploy argument `{flag}` may be provided only once" + )); + } + *slot = Some(value.to_owned()); + Ok(()) +} + +fn detached_managed_deploy_value<'args>( + args: &'args [String], + index: usize, + flag: &str, +) -> Result<&'args str, String> { + let value = args + .get(index.saturating_add(1)) + .ok_or_else(|| format!("Fastly deploy argument `{flag}` requires a value"))?; + if value.is_empty() || value.starts_with('-') { + return Err(format!( + "Fastly deploy argument `{flag}` requires a non-empty value" + )); + } + Ok(value) +} + +pub(super) fn parse_release_managed_deploy_args( + args: &[String], +) -> Result { + scan_reserved_deploy_args(args)?; + let mut parsed = ReleaseManagedDeployArgs { + comment: None, + globals: Vec::new(), + }; + let mut index = 0; + while let Some(arg) = args.get(index) { + if arg == "--comment" { + let value = detached_managed_deploy_value(args, index, "--comment")?; + set_managed_deploy_value(&mut parsed.comment, "--comment", value)?; + index = index.saturating_add(2); + } else if let Some(value) = arg.strip_prefix("--comment=") { + set_managed_deploy_value(&mut parsed.comment, "--comment", value)?; + index = index.saturating_add(1); + } else if arg == "--package" + || arg == "-p" + || arg.starts_with("--package=") + || (arg.starts_with("-p") && arg.len() > 2) + { + return Err( + "Fastly deploy argument `--package/-p` is owned by the immutable application release" + .to_owned(), + ); + } else if MANAGED_DEPLOY_GLOBAL_BOOL_FLAGS.contains(&arg.as_str()) { + parsed.globals.push(arg.clone()); + index = index.saturating_add(1); + } else if let Some((flag, _)) = arg.split_once('=') + && MANAGED_DEPLOY_GLOBAL_BOOL_FLAGS.contains(&flag) + { + return Err(format!( + "Fastly boolean deploy argument `{flag}` does not accept a value" + )); + } else if arg.starts_with('-') { + return Err(format!("unsupported Fastly deploy argument {arg:?}")); + } else { + return Err(format!( + "unexpected positional Fastly deploy argument {arg:?}" + )); + } + } + Ok(parsed) +} + +/// Resolve the target service id from `--service-id` or, failing that, +/// `FASTLY_SERVICE_ID`. +fn resolve_service_id(args: &[String]) -> Result { + if let Some(value) = arg_value(args, "--service-id") { + return Ok(value.to_owned()); + } + env::var(FASTLY_SERVICE_ID_ENV).map_err(|_err| { + format!("no service id: pass `--service-id ` or set {FASTLY_SERVICE_ID_ENV}") + }) +} + +pub(super) fn validate_effective_deploy_service_id( + context: &AdapterDeployContext, + exec: &AdapterExecContext<'_>, +) -> Result<(), String> { + if let Some(service_id) = context.service_id.as_deref() { + return validate_service_id(service_id); + } + if let Some(manifest_path) = context.adapter_manifest_path.as_deref() { + return validate_deploy_service_id_for_manifest(context, manifest_path, exec); + } + if let Some((_, id)) = exec + .env() + .iter() + .rev() + .find(|(key, _)| key == FASTLY_SERVICE_ID_ENV) + { + return validate_service_id(id); + } + match env::var(FASTLY_SERVICE_ID_ENV) { + Ok(service_id) => validate_service_id(&service_id), + Err(env::VarError::NotPresent) => Ok(()), + Err(env::VarError::NotUnicode(_)) => Err(format!( + "invalid service id from {FASTLY_SERVICE_ID_ENV}: expected ASCII letters and digits only" + )), + } +} + +pub(super) fn validate_deploy_service_id_for_manifest( + context: &AdapterDeployContext, + manifest_path: &Path, + exec: &AdapterExecContext<'_>, +) -> Result<(), String> { + if let Some(service_id) = context.service_id.as_deref() { + return validate_service_id(service_id); + } + effective_fastly_service_id_with_exec(manifest_path, exec)?; + Ok(()) +} + +fn effective_deploy_environment( + context: &AdapterDeployContext, + exec: &AdapterExecContext<'_>, +) -> Result { + let mut resolved: BTreeMap = env::vars().collect(); + resolved.extend(exec.env().iter().cloned()); + let variables = merge_env_defaults(context.variable_defaults.iter(), resolved); + let environment = EnvConfig::from_vars(variables); + for (kind, logical_ids) in [ + (ResourceKind::Config, &context.stores.config), + (ResourceKind::Kv, &context.stores.kv), + (ResourceKind::Secret, &context.stores.secrets), + ] { + for logical_id in logical_ids { + environment + .store_name_checked(kind.selector_kind(), logical_id) + .map_err(|error| format!("invalid Fastly deploy environment: {error}"))?; + if kind == ResourceKind::Config { + environment + .store_key_checked(kind.selector_kind(), logical_id) + .map_err(|error| format!("invalid Fastly deploy environment: {error}"))?; + } + } + } + Ok(environment) +} + +pub(super) fn validate_fastly_config_key( + logical_store_id: &str, + key: &str, + _staging: bool, + _local: bool, +) -> Result<(), String> { + let expected = logical_store_id; + if key == expected { + Ok(()) + } else { + Err(format!( + "Fastly uses logical config key `{expected}` for every target; remove the conflicting --key or EDGEZERO__STORES__CONFIG__{}__KEY override and select the environment's physical store with __NAME", + logical_store_id.to_ascii_uppercase() + )) + } +} + +fn effective_fastly_service_id_with_exec( + path: &Path, + exec: &AdapterExecContext<'_>, +) -> Result, String> { + let Some((_, environment_id)) = exec + .env() + .iter() + .rev() + .find(|(key, _)| key == FASTLY_SERVICE_ID_ENV) + else { + return super::provision_cloud::effective_fastly_service_id(path); + }; + let manifest_id = super::provision_cloud::read_fastly_service_id(path)?; + super::provision_cloud::select_fastly_service_id(manifest_id, Some(environment_id.clone())) +} + +pub(super) fn require_token_with_exec( + exec: &AdapterExecContext<'_>, +) -> Result { + let value = exec + .env() + .iter() + .rev() + .find(|(key, _)| key == FASTLY_API_TOKEN_ENV) + .map(|(_, value)| value.clone()) + .map_or_else( + || { + env::var(FASTLY_API_TOKEN_ENV).map_err(|_error| { + format!("{FASTLY_API_TOKEN_ENV} must be set in the environment") + }) + }, + Ok, + )?; + Ok(FastlyApiToken { + value, + env: exec.env().to_vec(), + }) +} + +/// Read ambient credentials for standalone API actions. +pub(super) fn require_token() -> Result { + require_token_with_exec(&AdapterExecContext::new()) +} + +/// Whether an HTTP status counts as healthy (2xx only). +/// +/// A passing probe gates against an automatic rollback, so a 3xx is deliberately +/// NOT healthy: a staged version answering `301` to an error page (the probe does +/// not follow redirects) would otherwise mask a bad deploy as healthy. +fn is_healthy_status(code: u16) -> bool { + (200..300).contains(&code) +} + +/// Digits immediately following `marker` in `lower` (a lowercased +/// haystack), for the LAST occurrence of `marker`. The number must be +/// terminated by `terminator` — so a partial/confusable match (e.g. a +/// semver `15.2.0`) yields `None` rather than a bogus version. +fn last_version_after(lower: &str, marker: &str, terminator: char) -> Option { + let mut result = None; + for (idx, _) in lower.match_indices(marker) { + let after = idx.saturating_add(marker.len()); + let Some(rest) = lower.get(after..) else { + continue; + }; + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); + if digits.is_empty() || rest.chars().nth(digits.len()) != Some(terminator) { + continue; + } + if let Ok(parsed) = digits.parse::() { + result = Some(parsed); + } + } + result +} + +/// Parse a Fastly service version out of Fastly CLI output, accepting +/// ONLY the shapes the CLI actually emits, in precedence order: +/// +/// 1. Our canonical `version=` contract line. +/// 2. The CLI's success line, whose Go format string is +/// `"Updated package (service %s, version %v)"` (and +/// `"Deployed package (...)"` for `compute deploy`) — matched as +/// `, version )`. This names the version the package landed on, +/// so it wins over (3). +/// 3. The `--autoclone` notice, `"... Now operating on version %d."` — +/// the freshly-cloned draft, used when the success line is absent. +/// +/// Everything else yields `None` and the caller FAILS CLOSED. +/// +/// Deliberately strict. The previous implementation took ANY digits +/// appearing after the word "version" and let the last match win, so: +/// * `Uploaded package to service 12345, version unchanged` parsed as +/// version 12345, and +/// * the autoclone notice's *pre-clone* version +/// (`Service version 3 is not editable...`) could beat the real one, +/// since stdout and stderr are concatenated and their relative order +/// is not guaranteed. +/// +/// A misparse here stages, comments, or rolls back the WRONG service +/// version, so ambiguity must be an error, not a guess. +pub(super) fn parse_fastly_version(text: &str) -> Option { + let lower = text.to_ascii_lowercase(); + parse_canonical_version_line(&lower) + .or_else(|| last_version_after(&lower, ", version ", ')')) + .or_else(|| last_version_after(&lower, "now operating on version ", '.')) +} + +/// Last standalone `version=` line (the whole trimmed line must be +/// exactly that, so a `--version=active` flag echoed in a command line +/// cannot masquerade as one). +fn parse_canonical_version_line(lower: &str) -> Option { + lower.lines().rev().find_map(|line| { + let digits = line.trim().strip_prefix("version=")?; + (!digits.is_empty() && digits.chars().all(|ch| ch.is_ascii_digit())) + .then(|| digits.parse().ok()) + .flatten() + }) +} + +/// Parse `fastly service version list --json` (or the Fastly API +/// `/service//version` array) for the `number` of the `active` +/// version. +/// Resolve the active version from a Fastly version-list JSON. +/// +/// `Ok(Some(n))` — exactly one version is active. `Ok(None)` — the list parsed +/// but NO version is active (a first-ever deploy; the caller records an empty +/// rollback target and proceeds). `Err(_)` — the payload could not be parsed as +/// a version list, OR it is MALFORMED (a selection/safety field is missing or +/// has the wrong type, or MORE THAN ONE active version exists). All are +/// OPERATIONAL failures the caller must +/// NOT silently treat as "no active version" — otherwise a garbled or ambiguous +/// response would fail open and let a production deploy proceed with no rollback +/// target. +/// +/// The ENTIRE list is scanned (not short-circuited at the first active entry) so +/// that a malformed field or a second active version anywhere in the response +/// is caught rather than ignored. +fn resolve_active_version(json: &str) -> Result, String> { + let versions = parse_service_versions(json)?; + let mut active_version: Option = None; + for version in versions { + if version.active { + if active_version.is_some() { + return Err(format!( + "the Fastly version list reports more than one active version ({} and {}); the response is ambiguous, refusing to pick one", + active_version.unwrap_or_default(), + version.number, + )); + } + active_version = Some(version.number); + } + } + Ok(active_version) +} + +fn parse_service_versions(json: &str) -> Result, String> { + let versions: Vec = serde_json::from_str(json) + .map_err(|error| format!("failed to parse the Fastly version list as JSON: {error}"))?; + if versions.is_empty() { + return Err( + "the Fastly version list is empty; a service always has at least an initial version, so this response cannot be trusted".to_owned(), + ); + } + let mut numbers = HashSet::with_capacity(versions.len()); + for version in &versions { + if !numbers.insert(version.number) { + return Err(format!( + "the Fastly version list contains duplicate version number {}; refusing an ambiguous response", + version.number + )); + } + for environment in &version.environments { + if environment.name.is_empty() || environment.service_id.is_empty() { + return Err(format!( + "Fastly version {} contains an incomplete environment record", + version.number + )); + } + } + } + Ok(versions) +} + +fn select_version_source(versions: &[ServiceVersionRecord]) -> Result { + let staging_record_count = versions + .iter() + .flat_map(|version| &version.environments) + .filter(|environment| environment.name == "staging") + .count(); + if staging_record_count > 1 { + return Err(format!( + "the Fastly version list reports more than one staging environment record ({staging_record_count}); the response is ambiguous" + )); + } + + let active_versions = versions + .iter() + .filter(|version| version.active) + .collect::>(); + if active_versions.len() > 1 { + return Err("the Fastly version list reports more than one active version".to_owned()); + } + if let Some(active_version) = active_versions.first() { + return Ok(VersionSource::Active(active_version.number)); + } + + let highest = versions + .iter() + .map(|version| version.number) + .max() + .ok_or_else(|| "the Fastly version list is empty".to_owned())?; + let drafts = versions + .iter() + .filter(|version| !version.active && !version.locked && version.environments.is_empty()) + .collect::>(); + let staging_versions = versions + .iter() + .filter(|version| { + !version.active + && version.environments.len() == 1 + && version.environments.iter().all(|environment| { + environment.name == "staging" && environment.active_version == version.number + }) + }) + .collect::>(); + let retired = versions + .iter() + .filter(|version| !version.active && version.locked && version.environments.is_empty()) + .collect::>(); + match (drafts.as_slice(), staging_versions.as_slice()) { + ([draft], []) if draft.number == highest && retired.is_empty() => { + return Ok(VersionSource::InitialDraft(draft.number)); + } + ([draft], []) if !retired.is_empty() => { + return Err(format!( + "editable Fastly draft {} exists beside retired version(s); EdgeZero cannot prove the draft belongs to a retired source. Restore a single unambiguous source version before retrying", + draft.number + )); + } + ([], [staging_version]) => return Ok(VersionSource::Staging(staging_version.number)), + ([draft], [staging_version]) => { + return Err(format!( + "editable Fastly draft {draft_version} exists beside staged version {staging_version}; EdgeZero cannot prove the draft belongs to the staged source. Lock the orphan with `fastly service version lock --version={draft_version}` (and pass `--service-id` when fastly.toml does not select the service), then retry", + draft_version = draft.number, + staging_version = staging_version.number, + )); + } + _ => {} + } + if drafts.is_empty() + && staging_versions.is_empty() + && let Some(retired_version) = retired.iter().find(|version| version.number == highest) + { + return Ok(VersionSource::Retired(retired_version.number)); + } + if drafts.len() == 1 && staging_versions.len() <= 1 { + return Err(format!( + "first deployment requires the highest service version ({highest}) to be the initialized editable draft" + )); + } + if staging_versions.len() > 1 || drafts.len() > 1 { + return Err(format!( + "deployment without an active version requires one highest editable draft, staged source, or retired source; found {} drafts, {} staged versions, and {} retired versions", + drafts.len(), + staging_versions.len(), + retired.len() + )); + } + Err(format!( + "deployment without an active version requires one highest editable draft, staged source, or retired source; found {} drafts, {} staged versions, and {} retired versions", + drafts.len(), + staging_versions.len(), + retired.len() + )) +} + +/// Best-effort staleness guard for a production rollback: the version being +/// rolled back FROM (`from_version`, the caller's `--version`) must still be the +/// ACTIVE version. A rollback can run long after its deploy; if a newer version +/// was activated since, activating the old target would clobber it — so refuse. +/// +/// This narrows but does NOT close the race: the caller reads the active version +/// and activates in two separate requests, and Fastly's activate endpoint has no +/// precondition, so a deploy landing between them can still be clobbered. +/// Service-scoped serialization is required to eliminate it. +fn ensure_rollback_from_is_active( + active: Option, + from_version: u64, + service_id: &str, +) -> Result<(), String> { + match active { + Some(active_version) if active_version == from_version => Ok(()), + Some(active_version) => Err(format!( + "refusing to roll back service {service_id}: the active version is now {active_version}, not the {from_version} being rolled back from -- a newer deploy is live and rolling back would clobber it" + )), + None => Err(format!( + "refusing to roll back service {service_id}: it has no active version" + )), + } +} + +fn staging_rollback_decision( + versions: &[ServiceVersionRecord], + requested_version: u64, + service_id: &str, +) -> Result { + let staging_records = versions + .iter() + .flat_map(|version| { + version + .environments + .iter() + .filter(|environment| environment.name == "staging") + .map(move |environment| (version.number, environment)) + }) + .collect::>(); + if staging_records.len() > 1 { + return Err(format!( + "Fastly service {service_id} reports more than one staging environment record; refusing staging rollback" + )); + } + let version = versions + .iter() + .find(|candidate| candidate.number == requested_version) + .ok_or_else(|| { + format!( + "Fastly version {requested_version} is absent from service {service_id}; refusing staging rollback" + ) + })?; + let is_staging_version = matches!( + staging_records.as_slice(), + [(record_version, environment)] + if *record_version == requested_version + && environment.active_version == requested_version + ); + if is_staging_version { + return Ok(StagingRollbackDecision::Deactivate); + } + let unpublished_draft = !version.active && !version.locked && version.environments.is_empty(); + if unpublished_draft { + return Ok(StagingRollbackDecision::NoopDraft); + } + Err(format!( + "Fastly version {requested_version} is neither the exact staged version nor an unpublished editable draft; refusing staging rollback" + )) +} + +/// Staging IP for one exact domain in a Fastly +/// `GET /service//version//domain?include=staging_ips` response. +/// +/// The response is an ARRAY of domain objects, and the staging address +/// is a SINGULAR, nullable STRING field named `staging_ip` on each +/// domain (`staging_ips` is only the `include=` query-param value, never +/// a field name). Verified against the go-fastly `Domain` model, whose +/// field is `StagingIP` with the mapstructure tag `staging_ip`, and its +/// recorded API fixture `fixtures/domains/list_with_staging_ips.yaml`, +/// plus Fastly's "working with staging" guide. +fn parse_staging_ip(json: &str, domain: &str) -> Result { + #[derive(serde::Deserialize)] + struct DomainRecord { + name: String, + staging_ip: Option, + } + + let records: Vec = serde_json::from_str(json).map_err(|error| { + format!( + "Fastly staging domain inventory has an invalid response shape (payload redacted): {error}" + ) + })?; + let mut matches = records.iter().filter(|record| record.name == domain); + let record = matches.next().ok_or_else(|| { + format!("Fastly staging domain inventory does not contain domain `{domain}`") + })?; + if matches.next().is_some() { + return Err(format!( + "Fastly staging domain inventory contains duplicate domain `{domain}`" + )); + } + record + .staging_ip + .as_deref() + .filter(|ip| !ip.is_empty()) + .map(str::to_owned) + .ok_or_else(|| format!("Fastly staging domain `{domain}` has no staging IP")) +} + +/// Build the `curl` argv for a health probe. Production probes the +/// domain directly; staging reroutes the TLS connection to the +/// resolved staging IP via `--connect-to :::443`. `path` is the +/// URL path (always begins with '/'), applied identically to both. +fn build_curl_probe_args( + domain: &str, + path: &str, + staging_ip: Option<&str>, + timeout_secs: u64, +) -> Vec { + let mut args = vec![ + // `-q` first so curl never merges `~/.curlrc` into a probe (a planted + // `proxy`/`output` there could otherwise redirect or corrupt the check). + "-q".to_owned(), + "-sS".to_owned(), + // Disable curl's URL globbing: a valid probe path may contain `[` `]` `{` + // `}` (e.g. `/health?ids[0]=1`), which curl would otherwise treat as a + // glob — failing with exit 3 or firing multiple requests, and so + // mis-reporting a healthy deployment as unhealthy. + "--globoff".to_owned(), + "-o".to_owned(), + "/dev/null".to_owned(), + "-w".to_owned(), + "%{http_code}".to_owned(), + "--max-time".to_owned(), + timeout_secs.to_string(), + ]; + if let Some(ip) = staging_ip { + // `--connect-to ::HOST:PORT` reroutes the TLS connection to the staging + // IP. An IPv6 literal must be bracketed or curl mis-parses the colons; + // the caller has already validated `ip` parses as an `IpAddr`. + let target = if ip.contains(':') { + format!("::[{ip}]:443") + } else { + format!("::{ip}:443") + }; + args.push("--connect-to".to_owned()); + args.push(target); + } + args.push(format!("https://{domain}{path}")); + args +} + +/// Validate a caller-supplied probe path. It is appended to +/// `https://{domain}` to form one curl argument, so it must begin with +/// '/' and carry no whitespace or control characters that would break +/// the URL or smuggle a second token. +fn validate_probe_path(path: &str) -> Result<(), String> { + if !path.starts_with('/') { + return Err(format!("healthcheck --path must begin with '/': '{path}'")); + } + if path.chars().any(|ch| ch.is_whitespace() || ch.is_control()) { + return Err(format!( + "healthcheck --path must not contain whitespace or control characters: '{path}'" + )); + } + Ok(()) +} + +/// Retry a health probe. Returns `Ok(code)` on the first healthy +/// status, or `Err((last_code, message))` after exhausting attempts. +/// `between` runs between attempts (not after the last) so it can be a +/// no-op in tests. +fn probe_with_retries( + retry: u32, + mut prober: P, + mut between: S, +) -> Result, String)> +where + P: FnMut() -> Result, + S: FnMut(), +{ + let attempts = retry.max(1); + let mut last_code = None; + let mut last_msg = "no probe attempts were made".to_owned(); + for attempt in 0..attempts { + match prober() { + Ok(code) if is_healthy_status(code) => return Ok(code), + Ok(code) => { + last_code = Some(code); + last_msg = format!("unhealthy HTTP status {code}"); + } + Err(err) => last_msg = err, + } + if attempt.saturating_add(1) < attempts { + between(); + } + } + Err((last_code, last_msg)) +} + +/// Run `fastly ` in `cwd`, inheriting stdio, and map a non-zero +/// exit to an error. +fn run_fastly_status(fastly_args: &[String], cwd: &FastlyCommandContext<'_>) -> Result<(), String> { + let status = Command::new("fastly") + .args(fastly_args) + .current_dir(cwd.cwd) + .envs(cwd.env.iter().map(|(key, value)| (key, value))) + .status() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to run fastly CLI: {err}") + } + })?; + if status.success() { + Ok(()) + } else { + Err(format!( + "`fastly {}` exited with status {status}", + fastly_args.join(" ") + )) + } +} + +/// Run `fastly ` in `cwd` capturing stdout+stderr (combined) for +/// version parsing. Errors on a non-zero exit. +/// Run `curl -q -sS --config -`, piping `config` (which carries the +/// `Fastly-Key` header + url) through stdin so the token never touches +/// argv. Returns stdout on a zero exit. +/// +/// `-q` MUST be the first argument: without it curl reads `~/.curlrc` +/// (or `$CURL_HOME/.curlrc`) and merges it into this token-bearing +/// config, so a `proxy = …` directive planted by an earlier same-job +/// build step could exfiltrate the `Fastly-Key` header. `--connect-timeout` +/// / `--max-time` bound the call. +fn curl_config_capture(config: &str, environment: &[(String, String)]) -> Result { + let connect_timeout = FASTLY_API_CONNECT_TIMEOUT_SECS.to_string(); + let max_time = FASTLY_API_MAX_TIME_SECS.to_string(); + let mut child = Command::new("curl") + .envs(environment.iter().map(|(key, value)| (key, value))) + .args([ + "-q", + "-sS", + "--connect-timeout", + &connect_timeout, + "--max-time", + &max_time, + "--config", + "-", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + "`curl` not found on PATH; install curl and retry".to_owned() + } else { + format!("failed to spawn `curl`: {err}") + } + })?; + // Take stdin OUT of the child and hand it to a helper BY VALUE, so it drops at + // that helper's scope end — a natural drop rather than an explicit `drop(stdin)`, + // which trips `clippy::drop_non_drop` on wasm targets where `ChildStdin` is not + // `Drop`. The drop must precede `wait_with_output` so curl sees EOF (same pattern + // as `write_value_to_fastly_stdin` on the fastly path). + let stdin = child + .stdin + .take() + .ok_or_else(|| "failed to open stdin pipe to `curl`".to_owned())?; + write_config_to_curl_stdin(stdin, config)?; + let output = child + .wait_with_output() + .map_err(|err| format!("failed to wait on `curl`: {err}"))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else if output.status.code() == Some(CURL_EXIT_TIMEOUT) { + Err(format!( + "`curl` timed out after connect-timeout {FASTLY_API_CONNECT_TIMEOUT_SECS}s / max-time {FASTLY_API_MAX_TIME_SECS}s: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) + } else { + Err(format!( + "`curl` exited with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +/// Write `config` to curl's stdin, taking the handle BY VALUE so it drops at this +/// function's scope end. That natural drop closes the pipe (curl sees EOF) without +/// an explicit `drop(stdin)`, which trips `clippy::drop_non_drop` on wasm targets +/// where `ChildStdin` is not `Drop` (mirrors `write_value_to_fastly_stdin`). +fn write_config_to_curl_stdin(mut stdin: ChildStdin, config: &str) -> Result<(), String> { + stdin + .write_all(config.as_bytes()) + .map_err(|err| format!("failed to write curl config to stdin: {err}")) +} + +/// Wrap `value` in a curl-config double-quoted string, escaping the +/// characters that would otherwise let a value terminate its quote and +/// inject additional curl options. Within a curl `--config` file a +/// double-quoted value only honours the escapes `\\`, `\"`, `\n`, `\r`, +/// `\t` (and the config is parsed line-by-line, so a raw newline ends +/// the directive regardless of quoting). We escape backslash and quote +/// so the value cannot break out of the quotes, and map raw control +/// characters to their escape form so NO raw newline (or CR/tab) is +/// ever written into the config file. This is the second half of the +/// injection defence: untrusted identifiers are also validated (see +/// `validate_service_id` / `validate_version_str` / `validate_domain`), +/// but the token is a secret we cannot constrain to a charset, so it +/// relies on this escaping alone. +fn curl_quote(value: &str) -> String { + let mut out = String::with_capacity(value.len().saturating_add(2)); + out.push('"'); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + other => out.push(other), + } + } + out.push('"'); + out +} + +/// Validate an operator-supplied Fastly service id before it is +/// interpolated into an API URL. Fastly service ids contain only ASCII +/// letters and digits. +/// Values carrying a quote, newline, or space could inject curl options via +/// the `--config` file. +pub(super) fn validate_service_id(id: &str) -> Result<(), String> { + if !id.is_empty() && id.chars().all(|ch| ch.is_ascii_alphanumeric()) { + Ok(()) + } else { + Err(format!( + "invalid service id {id:?}: expected ASCII letters and digits only" + )) + } +} + +/// Validate a service-version string is a plain non-negative integer +/// before it is interpolated into an API URL. Returns the parsed value +/// so callers can reuse it. +fn validate_version_str(version: &str) -> Result { + version.parse::().map_err(|err| { + format!("invalid version {version:?}: expected a non-negative integer: {err}") + }) +} + +/// Validate a domain is a plausible hostname before it is placed into a +/// `curl` URL. Rejects anything outside the DNS label charset +/// (`[A-Za-z0-9-.]`), empty / over-long values, leading/trailing dots, +/// and empty labels so an injected quote / slash / space / newline +/// cannot smuggle curl options or a second URL. +fn validate_domain(domain: &str) -> Result<(), String> { + let charset_ok = domain + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '.'); + let shape_ok = !domain.is_empty() + && domain.len() <= 253 + && !domain.starts_with('.') + && !domain.ends_with('.') + && !domain.contains(".."); + if charset_ok && shape_ok { + Ok(()) + } else { + Err(format!( + "invalid domain {domain:?}: expected a hostname like `example.com`" + )) + } +} + +/// `GET https://api.fastly.com` with the `Fastly-Key` header; +/// returns the response body ONLY on a 2xx status. Both the header (carrying the +/// secret token) and the URL are written through `curl_quote` so neither can +/// inject curl options into the `--config` document. +/// +/// The HTTP status is captured explicitly via `write-out` (as the PUT helper +/// does) and required to be 2xx before the body is trusted. `--fail` alone would +/// reject 4xx/5xx but still accept a 3xx — whose (array-shaped) body could +/// otherwise be parsed as version data. No `location` directive is set, so a +/// redirect is never followed. +fn fastly_api_get(path: &str, token: &FastlyApiToken) -> Result { + let header = curl_quote(&format!("Fastly-Key: {}", token.as_str())); + let url = curl_quote(&format!("https://api.fastly.com{path}")); + // `write-out` appends the status on its own trailing line AFTER the body. + let config = format!("header = {header}\nurl = {url}\nwrite-out = \"\\n%{{http_code}}\"\n"); + let out = curl_config_capture(&config, &token.env) + .map_err(|err| format!("Fastly API GET {path} failed: {err}"))?; + let (body, status_line) = out + .rsplit_once('\n') + .ok_or_else(|| format!("Fastly API GET {path}: no HTTP status in the curl output"))?; + let status: u16 = status_line.trim().parse().map_err(|err| { + format!( + "Fastly API GET {path}: could not parse the HTTP status {:?}: {err}", + status_line.trim() + ) + })?; + if !(200..300).contains(&status) { + return Err(format!("Fastly API GET {path} returned HTTP {status}")); + } + Ok(body.to_owned()) +} + +/// `PUT https://api.fastly.com` with the `Fastly-Key` header; +/// returns the HTTP status, erroring on non-2xx. Fastly's version +/// activate/deactivate endpoints require `PUT` (not `POST`). Header and +/// URL are escaped via `curl_quote`; the literal `request`, `output`, +/// and `write-out` directives are fixed constants. +fn fastly_api_put(path: &str, token: &FastlyApiToken) -> Result { + let header = curl_quote(&format!("Fastly-Key: {}", token.as_str())); + let url = curl_quote(&format!("https://api.fastly.com{path}")); + let config = format!( + "request = \"PUT\"\nheader = {header}\nurl = {url}\noutput = \"/dev/null\"\nwrite-out = \"%{{http_code}}\"\n" + ); + let out = curl_config_capture(&config, &token.env)?; + let code: u16 = out.trim().parse().map_err(|err| { + format!( + "could not parse HTTP status from curl output {:?}: {err}", + out.trim() + ) + })?; + if (200..300).contains(&code) { + Ok(code) + } else { + Err(format!("Fastly API PUT {path} returned HTTP {code}")) + } +} + +/// `PUT https://api.fastly.com` and return a non-empty response body. +/// This is used for mutations, such as cloning a version, whose response +/// identifies the newly-created provider object needed for recovery. +fn fastly_api_put_capture(path: &str, token: &FastlyApiToken) -> Result { + let header = curl_quote(&format!("Fastly-Key: {}", token.as_str())); + let url = curl_quote(&format!("https://api.fastly.com{path}")); + let config = format!( + "request = \"PUT\"\nheader = {header}\nurl = {url}\nwrite-out = \"\\n%{{http_code}}\"\n" + ); + let out = curl_config_capture(&config, &token.env)?; + let (body, status_line) = out + .rsplit_once('\n') + .ok_or_else(|| format!("Fastly API PUT {path}: no HTTP status in the curl output"))?; + let code: u16 = status_line.trim().parse().map_err(|error| { + format!( + "Fastly API PUT {path}: could not parse the HTTP status {:?}: {error}", + status_line.trim() + ) + })?; + if !(200..300).contains(&code) { + return Err(format!("Fastly API PUT {path} returned HTTP {code}")); + } + if body.trim().is_empty() { + return Err(format!("Fastly API PUT {path} returned an empty response")); + } + Ok(body.to_owned()) +} + +pub(super) fn legacy_deploy_context(args: &[String], staging: bool) -> AdapterDeployContext { + AdapterDeployContext { + adapter_manifest_path: arg_value(args, "--manifest-path").map(PathBuf::from), + application_manifest_path: None, + application_release_root: None, + service_id: arg_value(args, "--service-id").map(str::to_owned), + stores: DeployStoreIds::default(), + staging, + variable_defaults: BTreeMap::default(), + } +} + +/// Resolve the directory containing the Fastly manifest selected by the +/// application manifest. Fall back to discovery for direct adapter callers. +pub(super) fn resolve_deploy_manifest_path( + context: &AdapterDeployContext, +) -> Result { + if let Some(path) = context.adapter_manifest_path.as_deref() { + return Ok(path.to_path_buf()); + } + find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path()) +} + +/// Production companion to `deploy`: resolve the active service version via the +/// Fastly API and emit it as a `version=` line. +/// +/// Distinguishes "confirmed no active version" from an operational failure: a +/// service with no active version yet (a first-ever deploy) is NOT an error — it +/// emits an empty `version=` line and succeeds, so the caller records an empty +/// rollback target. Only a real failure (API/auth error, or a version list that +/// cannot be parsed) returns `Err`, so the caller can fail closed instead of +/// silently proceeding without a rollback target. +/// +/// `--require-active` flips the no-active-version case to an error: it is passed +/// by the production-`deploy` version fallback, where a version was JUST +/// activated, so "no active version" is not a valid first-deploy state but an +/// operational failure the CLI must not report as success. +pub(super) fn emit_active_version(args: &[String]) -> Result<(), String> { + let service_id = resolve_service_id(args)?; + validate_service_id(&service_id)?; + emit_active_version_for(&service_id, arg_flag(args, "--require-active")) +} + +pub(super) fn emit_active_version_for( + service_id: &str, + require_active: bool, +) -> Result<(), String> { + emit_active_version_with_token(service_id, require_active, &require_token()?) +} + +pub(super) fn emit_active_version_with_token( + service_id: &str, + require_active: bool, + token: &FastlyApiToken, +) -> Result<(), String> { + let json = fastly_api_get(&format!("/service/{service_id}/version"), token)?; + if let Some(version) = active_version_or_require(&json, require_active, service_id)? { + log::info!("version={version}"); + } else { + // Confirmed no active version (first-ever deploy), and it was not + // required. Emit an explicit empty line so the caller records an empty + // rollback target and succeeds — distinct from a failure (`Err`). + log::info!("version="); + log::info!( + "service {service_id} has no active version yet; emitting an empty rollback target" + ); + } + Ok(()) +} + +/// Resolve the active version and apply the `--require-active` policy. +/// +/// `Ok(Some(n))` — a version is active. `Ok(None)` — no active version and +/// `require_active` is false (a first-ever `active-version` call; the caller +/// records an empty rollback target). `Err` — the response was malformed +/// ([`resolve_active_version`]), OR no version is active while `require_active` +/// is true. The latter is the production-`deploy` fallback: a version was JUST +/// activated, so "no active version" is an error, not a valid empty result. +fn active_version_or_require( + json: &str, + require_active: bool, + service_id: &str, +) -> Result, String> { + match resolve_active_version(json)? { + Some(version) => Ok(Some(version)), + None if require_active => Err(format!( + "the deploy reported success but the Fastly API returns no active version for service {service_id}; refusing to report a deploy with no resolvable version" + )), + None => Ok(None), + } +} + +/// Require `version` to be the currently ACTIVE service version — the +/// production healthcheck's version contract. +/// +/// The production probe hits the live domain, which serves whatever version is +/// active, so "healthcheck version N" is only a true statement about N while N is +/// active. `phase` (`before probing` / `after probing`) names when the check ran, +/// so a version activated by a concurrent deploy is reported clearly rather than +/// masquerading as a healthy `version`. +pub(super) fn verify_version_active( + service_id: &str, + version: u64, + token: &FastlyApiToken, + phase: &str, +) -> Result<(), String> { + let json = fastly_api_get(&format!("/service/{service_id}/version"), token)?; + version_active_verdict(resolve_active_version(&json)?, version, service_id, phase) +} + +/// The pure decision behind [`verify_version_active`], split out so the version +/// contract is unit-testable without a live Fastly API. +fn version_active_verdict( + active: Option, + version: u64, + service_id: &str, + phase: &str, +) -> Result<(), String> { + match active { + Some(active_version) if active_version == version => Ok(()), + Some(active_version) => Err(format!( + "production healthcheck version {version} is not active {phase}: service {service_id} currently has version {active_version} active, so the live-domain probe reflects version {active_version}, not {version}" + )), + None => Err(format!( + "production healthcheck version {version} could not be confirmed active {phase}: service {service_id} has no active version" + )), + } +} + +/// `healthcheck --adapter fastly ...`: probe the domain +/// (production) or the version's staging IP (`--staging`), retrying up +/// to `--retry` times. Emits `status-code` / `healthy` and returns +/// `Err` (non-zero exit) when unhealthy after retries. +/// +/// `--domain`, `--service-id` and `--version` are REQUIRED and validated +/// on BOTH the production and the staging path. GitHub Actions' `required: +/// true` does not actually fail a workflow when an input is omitted or +/// empty, so this is the real guard: a production healthcheck must never +/// probe on behalf of an absent/empty version it never verified — the +/// caller chains that same version into rollback. +/// +/// On the PRODUCTION path the probe reaches whatever version is live, so when a +/// token is available `version` is verified ACTIVE before and after the probe +/// (see [`verify_version_active`]); without a token the check is service-level. +pub(super) fn healthcheck(args: &[String]) -> Result<(), String> { + let domain = + arg_value(args, "--domain").ok_or_else(|| "healthcheck requires --domain".to_owned())?; + validate_domain(domain)?; + let service_id = resolve_service_id(args)?; + validate_service_id(&service_id)?; + let version_str = + arg_value(args, "--version").ok_or_else(|| "healthcheck requires --version".to_owned())?; + let version = validate_version_str(version_str)?; + let path = arg_value(args, "--path").unwrap_or("/"); + validate_probe_path(path)?; + let retry = arg_value(args, "--retry") + .and_then(|value| value.parse().ok()) + .unwrap_or(3_u32); + let retry_delay = arg_value(args, "--retry-delay") + .and_then(|value| value.parse().ok()) + .unwrap_or(5_u64); + let timeout = arg_value(args, "--timeout") + .and_then(|value| value.parse().ok()) + .unwrap_or(10_u64); + // curl reads `--max-time 0` as "no limit", so a zero timeout lets a single + // probe run indefinitely. Require a positive value. + if timeout == 0 { + return Err("healthcheck --timeout must be a positive number of seconds".to_owned()); + } + + let is_staging = arg_flag(args, "--staging"); + let staging_ip = if is_staging { + let token = require_token()?; + let json = fastly_api_get( + &format!("/service/{service_id}/version/{version}/domain?include=staging_ips"), + &token, + )?; + let ip = parse_staging_ip(&json, domain)?; + // Require a real `IpAddr` before it reaches curl's `--connect-to`, which + // also settles IPv4-vs-IPv6 formatting. + ip.parse::().map_err(|err| { + format!("resolved staging IP {ip:?} is not a valid IP address: {err}") + })?; + Some(ip) + } else { + None + }; + + // Production version contract: the probe hits the live domain, which serves + // whatever version is ACTIVE — not necessarily `version`. When a token is + // available, require `version` to be active both BEFORE and AFTER the probe, so + // a version activated concurrently (by another deploy) cannot be reported as a + // healthy `version`. Without a token the production check is inherently + // service-level — say so rather than imply a version-specific guarantee. The + // staging path already targets the specific version's staging IP, so it needs + // no such check. + let production_token = if is_staging { + None + } else { + match env::var(FASTLY_API_TOKEN_ENV) { + Ok(value) if !value.is_empty() => Some(FastlyApiToken { + value, + env: Vec::new(), + }), + _ => { + log::info!( + "no {FASTLY_API_TOKEN_ENV} available; production healthcheck is service-level (probes the live domain for service {service_id}, not specifically version {version})" + ); + None + } + } + }; + if let Some(token) = production_token.as_ref() { + verify_version_active(&service_id, version, token, "before probing")?; + } + + let curl_args = build_curl_probe_args(domain, path, staging_ip.as_deref(), timeout); + let delay = Duration::from_secs(retry_delay); + let outcome = probe_with_retries(retry, || curl_status(&curl_args), || thread::sleep(delay)); + match outcome { + Ok(code) => { + // Confirm `version` is STILL active, so a deploy that activated a newer + // version during the probe+retries is not reported as a healthy `version`. + if let Some(token) = production_token.as_ref() { + verify_version_active(&service_id, version, token, "after probing")?; + } + log::info!("status-code={code}"); + log::info!("healthy=true"); + Ok(()) + } + Err((last_code, msg)) => { + if let Some(code) = last_code { + log::info!("status-code={code}"); + } + log::info!("healthy=false"); + Err(format!( + "healthcheck for {domain} failed after {} attempt(s): {msg}", + retry.max(1) + )) + } + } +} + +/// Run a single `curl` health probe, returning the HTTP status. A +/// transport failure (timeout, DNS, refused) surfaces as `Err` so the +/// retry loop treats it as an unhealthy attempt. +fn curl_status(args: &[String]) -> Result { + let output = Command::new("curl").args(args).output().map_err(|err| { + if err.kind() == ErrorKind::NotFound { + "`curl` not found on PATH; install curl and retry".to_owned() + } else { + format!("failed to spawn `curl`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "curl transport failure (status {}): {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.trim().parse::().map_err(|err| { + format!( + "could not parse HTTP status from curl output {:?}: {err}", + stdout.trim() + ) + }) +} + +/// `rollback --adapter fastly ...`: production activates the explicit +/// `--rollback-to` version (Fastly cannot infer a previous version); +/// staging deactivates ``. +pub(super) fn rollback(args: &[String]) -> Result<(), String> { + let service_id = resolve_service_id(args)?; + validate_service_id(&service_id)?; + let version_str = + arg_value(args, "--version").ok_or_else(|| "rollback requires --version".to_owned())?; + let version = validate_version_str(version_str)?; + let token = require_token()?; + + if arg_flag(args, "--staging") { + let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; + let versions = parse_service_versions(&json)?; + match staging_rollback_decision(&versions, version, &service_id)? { + StagingRollbackDecision::Deactivate => { + // Fastly's environment-scoped deactivate is + // `PUT .../deactivate/staging`; a plain `.../deactivate` + // targets production activation. + fastly_api_put( + &format!("/service/{service_id}/version/{version}/deactivate/staging"), + &token, + )?; + log::info!( + "[edgezero] deactivated staged version {version} on Fastly service {service_id}" + ); + } + StagingRollbackDecision::NoopDraft => log::info!( + "[edgezero] Fastly version {version} is an unpublished draft; staging rollback has nothing to deactivate" + ), + } + } else { + // Production rollback re-activates an EXPLICIT target. Fastly's version + // list has no field distinguishing a previously-live version from a + // staged one (`staging`/`deployed` are documented "Unused"; `locked` + // only means "not editable"), so the target cannot be inferred — it is + // captured before the superseding deploy and passed in as --rollback-to. + let previous = arg_value(args, "--rollback-to") + .and_then(|raw| validate_version_str(raw).ok()) + .ok_or_else(|| { + "production rollback requires a valid --rollback-to version".to_owned() + })?; + // Best-effort staleness check: the version being rolled back FROM + // (`--version`) must STILL be the active version. A rollback workflow can + // run long after its deploy — if a newer version was activated meanwhile, + // activating the old target would clobber that newer deploy, so refuse. + // + // This is NOT atomic: Fastly's activate endpoint has no precondition, so + // a deploy that lands BETWEEN this read and the activate below can still + // be clobbered. It narrows the window (catching the common much-later + // rollback) but does not close it — serialise deploys and rollbacks per + // SERVICE (a service-scoped concurrency group) to eliminate the race. + let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; + ensure_rollback_from_is_active(resolve_active_version(&json)?, version, &service_id)?; + // Fastly's activate endpoint requires `PUT` (not `POST`). + fastly_api_put( + &format!("/service/{service_id}/version/{previous}/activate"), + &token, + )?; + log::info!("rolled-back-to={previous}"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::super::FastlyCliAdapter; + #[cfg(unix)] + use super::super::path_mutation_guard; + use super::super::run::build_compute_deploy_args; + #[cfg(unix)] + use super::super::run::deploy; + use super::*; + use edgezero_adapter::registry::{Adapter as _, DeployOwnership}; + #[cfg(unix)] + use edgezero_adapter::registry::{ProvisionMode, ProvisionStores, ResolvedStoreId}; + #[cfg(unix)] + use edgezero_core::test_env::{EnvOverride, PathPrepend}; + #[cfg(unix)] + use std::fs; + #[cfg(unix)] + use std::iter::once; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt as _; + #[cfg(unix)] + use tempfile::tempdir; + #[cfg(unix)] + #[test] + fn api_transport_uses_execution_path_marker_and_token() { + let _guard = super::super::path_mutation_guard().lock().unwrap(); + let fallback = tempdir().unwrap(); + fs::write(fallback.path().join("curl"), "#!/bin/sh\nexit 93\n").unwrap(); + fs::set_permissions( + fallback.path().join("curl"), + fs::Permissions::from_mode(0o755), + ) + .unwrap(); + let _path = EnvOverride::set("PATH", fallback.path()); + let selected = tempdir().unwrap(); + fs::write( + selected.path().join("curl"), + r#"#!/bin/sh +[ "$EDGEZERO_EXPLICIT_CURL_MARKER" = "selected" ] || exit 91 +config=$(/bin/cat) +case "$config" in *'Fastly-Key: explicit-context-token'*) ;; *) exit 92 ;; esac +printf '[]\n200' +"#, + ) + .unwrap(); + fs::set_permissions( + selected.path().join("curl"), + fs::Permissions::from_mode(0o755), + ) + .unwrap(); + let variables = vec![ + ( + "PATH".to_owned(), + selected.path().to_string_lossy().into_owned(), + ), + ( + "FASTLY_API_TOKEN".to_owned(), + "explicit-context-token".to_owned(), + ), + ( + "EDGEZERO_EXPLICIT_CURL_MARKER".to_owned(), + "selected".to_owned(), + ), + ]; + let exec = AdapterExecContext::new().with_env(&variables); + assert_eq!( + fastly_api_get( + "/service/Selected/version", + &require_token_with_exec(&exec).unwrap() + ) + .unwrap(), + "[]" + ); + } + + #[cfg(unix)] + #[test] + fn managed_native_startup_updates_only_the_external_copy() { + let root = tempdir().unwrap(); + let manifest = root.path().join("fastly.toml"); + let original = "name = 'demo'\n"; + fs::write(&manifest, original).unwrap(); + let startup = managed_startup_directory(root.path(), &manifest).unwrap(); + let tools = tempdir().unwrap(); + fs::write( + tools.path().join("fastly"), + "#!/bin/sh\nprintf 'manifest_version = 3\\n' >> fastly.toml\n", + ) + .unwrap(); + fs::set_permissions( + tools.path().join("fastly"), + fs::Permissions::from_mode(0o755), + ) + .unwrap(); + let variables = vec![( + "PATH".to_owned(), + tools.path().to_string_lossy().into_owned(), + )]; + let command_context = FastlyCommandContext { + cwd: startup.path(), + env: &variables, + }; + assert!( + run_fastly_capture_outcome(&[], &command_context) + .unwrap() + .success + ); + assert_eq!(fs::read_to_string(&manifest).unwrap(), original); + assert!( + fs::read_to_string(startup.path().join("fastly.toml")) + .unwrap() + .contains("manifest_version") + ); + assert_eq!(fs::read_dir(root.path()).unwrap().count(), 1); + } + + #[cfg(unix)] + #[test] + fn managed_native_commands_and_selection_use_explicit_execution_environment() { + let _guard = super::super::path_mutation_guard().lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let manifest = dir.path().join("fastly.toml"); + fs::write(&manifest, "name = 'demo'\n").unwrap(); + let key = "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME"; + let variables = vec![ + ( + "FASTLY_API_TOKEN".to_owned(), + "explicit-context-token".to_owned(), + ), + ("FASTLY_SERVICE_ID".to_owned(), "ContextService".to_owned()), + (key.to_owned(), "context_config".to_owned()), + ]; + let exec = AdapterExecContext::new().with_env(&variables); + assert_eq!( + require_token_with_exec(&exec).unwrap().as_str(), + "explicit-context-token" + ); + assert_eq!( + effective_fastly_service_id_with_exec(&manifest, &exec) + .unwrap() + .unwrap() + .into_id(), + "ContextService" + ); + let context = AdapterDeployContext { + stores: DeployStoreIds { + config: vec!["app_config".to_owned()], + ..Default::default() + }, + ..Default::default() + }; + assert_eq!( + effective_deploy_environment(&context, &exec) + .unwrap() + .store_name("config", "app_config"), + "context_config" + ); + let cwd = FastlyCommandContext { + cwd: dir.path(), + env: exec.env(), + }; + // The command context keeps the provider-selected directory independent of the CLI root. + assert_eq!(cwd.cwd, dir.path()); + assert_eq!(cwd.env, variables); + } + + #[test] + fn probe_with_retries_treats_zero_retry_as_one_attempt() { + let mut calls: i32 = 0; + let result = probe_with_retries( + 0, + || { + calls += 1_i32; + Ok(500) + }, + || {}, + ); + assert_eq!( + result, + Err((Some(500), "unhealthy HTTP status 500".to_owned())) + ); + assert_eq!(calls, 1_i32); + } + + #[test] + fn probe_with_retries_reports_transport_error() { + let result: Result, String)> = + probe_with_retries(1, || Err("connection refused".to_owned()), || {}); + assert_eq!(result, Err((None, "connection refused".to_owned()))); + } + + #[test] + fn probe_with_retries_exhausts_and_reports_last_code() { + let mut between: i32 = 0; + let result = probe_with_retries(3, || Ok(500), || between += 1_i32); + assert_eq!( + result, + Err((Some(500), "unhealthy HTTP status 500".to_owned())) + ); + assert_eq!( + between, 2_i32, + "delay runs between attempts, not after the last" + ); + } + + #[test] + fn probe_with_retries_succeeds_after_unhealthy_attempts() { + let mut calls: i32 = 0; + let mut between: i32 = 0; + let result = probe_with_retries( + 5, + || { + calls += 1_i32; + if calls < 3_i32 { Ok(503) } else { Ok(200) } + }, + || between += 1_i32, + ); + assert_eq!(result, Ok(200)); + assert_eq!(calls, 3_i32); + assert_eq!( + between, 2_i32, + "delay runs between each of the first 3 attempts" + ); + } + + #[test] + fn probe_with_retries_returns_first_healthy() { + let mut calls: i32 = 0; + let mut between: i32 = 0; + let result = probe_with_retries( + 5, + || { + calls += 1_i32; + Ok(200) + }, + || between += 1_i32, + ); + assert_eq!(result, Ok(200)); + assert_eq!(calls, 1_i32, "should stop after first healthy probe"); + assert_eq!(between, 0_i32, "no delay before the first attempt"); + } + + #[test] + fn healthcheck_rejects_zero_timeout() { + // A zero timeout becomes curl `--max-time 0` (no limit); reject it before + // any probe. The other required args are valid so we reach the check. + let args = [ + "--adapter", + "fastly", + "--domain", + "example.com", + "--service-id", + "svc123", + "--version", + "1", + "--timeout", + "0", + ] + .map(str::to_owned) + .to_vec(); + let err = healthcheck(&args).expect_err("zero timeout must be rejected"); + assert!(err.contains("timeout"), "unexpected error: {err}"); + } + + #[test] + fn validate_probe_path_requires_leading_slash_and_no_whitespace() { + validate_probe_path("/").expect("root"); + validate_probe_path("/health").expect("simple path"); + validate_probe_path("/api/v1/status?ready=1").expect("path with query"); + validate_probe_path("health").expect_err("no leading slash"); + validate_probe_path("").expect_err("empty"); + validate_probe_path("/ with space").expect_err("whitespace"); + validate_probe_path("/inject\nHost: evil").expect_err("newline injection"); + } + + #[test] + fn build_curl_probe_args_honors_path_on_production_and_staging() { + // Production: the path is appended to the domain URL. + let prod = build_curl_probe_args("example.com", "/health", None, 10); + assert!(prod.contains(&"https://example.com/health".to_owned())); + // Staging: same URL (with the path), rerouted to the staging IP. + let staging = + build_curl_probe_args("staging.example.com", "/health", Some("151.101.2.10"), 10); + assert!(staging.contains(&"https://staging.example.com/health".to_owned())); + let idx = staging + .iter() + .position(|arg| arg == "--connect-to") + .expect("--connect-to present for staging"); + assert_eq!(staging[idx + 1], "::151.101.2.10:443"); + } + + #[test] + fn build_curl_probe_args_brackets_ipv6_connect_to() { + let args = build_curl_probe_args("staging.example.com", "/", Some("2001:db8::1"), 10); + let idx = args + .iter() + .position(|arg| arg == "--connect-to") + .expect("--connect-to present for staging"); + // An IPv6 literal must be bracketed so curl does not misparse the colons. + assert_eq!(args[idx + 1], "::[2001:db8::1]:443"); + } + + #[test] + fn build_curl_probe_args_leads_with_q_to_ignore_curlrc() { + // `-q` must be the FIRST argument or curl merges `~/.curlrc` before it. + let args = build_curl_probe_args("example.com", "/", None, 10); + assert_eq!(args.first().map(String::as_str), Some("-q")); + } + + #[test] + fn build_curl_probe_args_staging_reroutes_to_ip() { + let args = build_curl_probe_args("staging.example.com", "/", Some("151.101.2.10"), 15); + let idx = args + .iter() + .position(|arg| arg == "--connect-to") + .expect("--connect-to present for staging"); + assert_eq!(args[idx + 1], "::151.101.2.10:443"); + assert!(args.contains(&"https://staging.example.com/".to_owned())); + } + + #[test] + fn build_curl_probe_args_path_with_glob_chars_is_literal() { + // A path with `[` `]` would be a curl glob without --globoff; here it must + // appear verbatim in the single URL argument, with globbing disabled. + let args = build_curl_probe_args("example.com", "/health?ids[0]=1", None, 10); + assert!(args.contains(&"--globoff".to_owned())); + assert!(args.contains(&"https://example.com/health?ids[0]=1".to_owned())); + } + + #[test] + fn build_curl_probe_args_production_has_no_connect_to() { + let args = build_curl_probe_args("example.com", "/", None, 10); + assert!(!args.iter().any(|arg| arg == "--connect-to")); + assert!(args.contains(&"https://example.com/".to_owned())); + assert!(args.contains(&"--max-time".to_owned())); + assert!(args.contains(&"10".to_owned())); + // Globbing must be off so bracket/brace characters in a path are literal. + assert!(args.contains(&"--globoff".to_owned())); + } + + #[test] + fn arg_value_reads_flag_value() { + let args = vec![ + "--service-id".to_owned(), + "SVC1".to_owned(), + "--version".to_owned(), + "42".to_owned(), + ]; + assert_eq!(arg_value(&args, "--service-id"), Some("SVC1")); + assert_eq!(arg_value(&args, "--version"), Some("42")); + assert_eq!(arg_value(&args, "--missing"), None); + } + + #[test] + fn arg_value_none_when_flag_is_last() { + let args = vec!["--version".to_owned()]; + assert_eq!(arg_value(&args, "--version"), None); + } + + #[test] + fn arg_flag_detects_presence() { + let args = vec!["--staging".to_owned()]; + assert!(arg_flag(&args, "--staging")); + assert!(!arg_flag(&args, "--nope")); + } + + #[test] + fn args_without_flag_value_strips_pair() { + let args = vec![ + "--service-id".to_owned(), + "SVC1".to_owned(), + "--comment".to_owned(), + "ci".to_owned(), + ]; + assert_eq!( + args_without_flag_value(&args, "--service-id"), + vec!["--comment".to_owned(), "ci".to_owned()] + ); + } + + #[test] + fn resolve_service_id_prefers_flag() { + let args = vec!["--service-id".to_owned(), "SVCFROMARG".to_owned()]; + assert_eq!(resolve_service_id(&args).unwrap(), "SVCFROMARG"); + } + + fn owned(args: &[&str]) -> Vec { + args.iter().map(|arg| (*arg).to_owned()).collect() + } + + #[cfg(unix)] + fn fake_provider_invocation_marker(marker: &Path) -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempdir().expect("provider fake dir"); + for binary in ["fastly", "curl"] { + let script_path = dir.path().join(binary); + fs::write( + &script_path, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$0 $*\" >> '{}'\nexit 0\n", + marker.display() + ), + ) + .expect("write provider fake"); + let mut permissions = fs::metadata(&script_path) + .expect("provider fake metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&script_path, permissions).expect("chmod provider fake"); + } + dir + } + + #[cfg(unix)] + fn assert_provider_not_invoked(marker: &Path) { + assert!( + !marker.exists() + || fs::read_to_string(marker) + .expect("provider marker") + .is_empty(), + "invalid input must be rejected before the provider fake is invoked: {}", + fs::read_to_string(marker).unwrap_or_default() + ); + } + + fn assert_service_id_error(error: &str) { + assert!( + error.contains("ASCII letters and digits only"), + "service-id error must state the exact accepted alphabet: {error}" + ); + } + + #[test] + fn deploy_arg_scan_rejects_every_reserved_spelling() { + for args in [ + owned(&["--service-id", "service1"]), + owned(&["--service-id=service1"]), + owned(&["-s", "service1"]), + owned(&["-s=service1"]), + owned(&["-sservice1"]), + owned(&["--service-name", "demo"]), + owned(&["--service-name=demo"]), + owned(&["--version", "active"]), + owned(&["--version=active"]), + owned(&["--autoclone"]), + owned(&["--token", "secret"]), + owned(&["--token=secret"]), + owned(&["-t", "secret"]), + owned(&["-t=secret"]), + owned(&["-tsecret"]), + ] { + let error = scan_reserved_deploy_args(&args) + .expect_err("lifecycle-owned deploy argument must be rejected"); + assert!( + error.contains("reserved") || error.contains("lifecycle"), + "reserved argument error must explain ownership for {args:?}: {error}" + ); + } + } + + #[test] + fn deploy_arg_scan_redacts_inline_token_values() { + const SENTINEL: &str = "SUPER_SECRET_TOKEN_SENTINEL"; + for (arg, expected_flag) in [ + (format!("--token={SENTINEL}"), "--token"), + (format!("-t={SENTINEL}"), "-t"), + (format!("-t{SENTINEL}"), "-t"), + ] { + let error = scan_reserved_deploy_args(&[arg]) + .expect_err("inline credential arguments must be reserved"); + assert!( + !error.contains(SENTINEL), + "reserved-argument error leaked a credential: {error}" + ); + assert!( + error.contains(expected_flag), + "reserved-argument error must identify {expected_flag}: {error}" + ); + } + } + + #[test] + fn deploy_arg_scan_allows_unrelated_long_flags_beginning_with_s_or_t() { + scan_reserved_deploy_args(&owned(&[ + "--skip-build", + "--status", + "--timeout=30", + "--trace", + ])) + .expect("long flags must not be mistaken for attached -s or -t values"); + } + + #[test] + fn package_files_hash_parsers_require_exact_provider_identity() { + let hash = "a".repeat(128); + assert_eq!( + parse_package_files_hash_output(&format!("notice\n{hash}\n")) + .expect("canonical Fastly CLI hash"), + hash + ); + parse_package_files_hash_output("abc").expect_err("short hash must fail"); + parse_package_files_hash_output(&format!("{}\n{}", "a".repeat(128), "b".repeat(128))) + .expect_err("conflicting hashes must fail"); + + let response = serde_json::json!({ + "service_id": "SVC1", + "version": 8_u64, + "metadata": { "files_hash": "a".repeat(128) } + }) + .to_string(); + assert_eq!( + parse_package_metadata_files_hash(&response, "SVC1", 8) + .expect("matching package metadata"), + "a".repeat(128) + ); + parse_package_metadata_files_hash(&response, "OTHER", 8) + .expect_err("wrong service must fail"); + parse_package_metadata_files_hash(&response, "SVC1", 9) + .expect_err("wrong version must fail"); + } + + #[test] + fn deploy_arg_preflight_scans_store_free_manifest_deploys() { + let context = AdapterDeployContext { + service_id: Some("SVC1".to_owned()), + ..AdapterDeployContext::default() + }; + assert_eq!( + FastlyCliAdapter + .preflight_deploy( + &context, + &owned(&["--comment", "release"]), + &edgezero_adapter::registry::AdapterExecContext::new() + ) + .expect("safe manifest-command argument"), + DeployOwnership::ManifestCommand + ); + FastlyCliAdapter + .preflight_deploy( + &context, + &owned(&["-tsecret"]), + &edgezero_adapter::registry::AdapterExecContext::new(), + ) + .expect_err("reserved scan must run before store-free manifest dispatch"); + assert_eq!( + FastlyCliAdapter.preflight_deploy( + &context, + &owned(&["--package", "manifest-package.tar.gz"]), + &edgezero_adapter::registry::AdapterExecContext::new() + ), + Ok(DeployOwnership::ManifestCommand), + "store-free manifest commands retain provider package passthrough" + ); + } + + #[test] + fn managed_deploy_preflight_rejects_release_owned_package_spellings() { + let context = AdapterDeployContext { + service_id: Some("SVC1".to_owned()), + application_release_root: Some(PathBuf::from("release")), + ..AdapterDeployContext::default() + }; + for args in [ + owned(&["--package", "app.tar.gz"]), + owned(&["--package=app.tar.gz"]), + owned(&["-p", "app.tar.gz"]), + owned(&["-p=app.tar.gz"]), + owned(&["-papp.tar.gz"]), + ] { + let error = FastlyCliAdapter + .preflight_deploy( + &context, + &args, + &edgezero_adapter::registry::AdapterExecContext::new(), + ) + .expect_err("the immutable release owns the package path"); + assert!(error.contains("--package/-p"), "{args:?}: {error}"); + } + } + + #[test] + fn managed_deploy_preflight_owns_release_staging_and_each_store_kind() { + let direct = AdapterDeployContext { + service_id: Some("SVC1".to_owned()), + application_manifest_path: Some(PathBuf::from("edgezero.toml")), + ..AdapterDeployContext::default() + }; + assert_eq!( + FastlyCliAdapter.preflight_deploy( + &direct, + &[], + &edgezero_adapter::registry::AdapterExecContext::new() + ), + Ok(DeployOwnership::ManifestCommand) + ); + + let mut managed = direct.clone(); + managed.application_release_root = Some(PathBuf::from("release")); + assert_eq!( + FastlyCliAdapter.preflight_deploy( + &managed, + &[], + &edgezero_adapter::registry::AdapterExecContext::new() + ), + Ok(DeployOwnership::AdapterManaged) + ); + + managed = direct.clone(); + managed.staging = true; + assert_eq!( + FastlyCliAdapter.preflight_deploy( + &managed, + &[], + &edgezero_adapter::registry::AdapterExecContext::new() + ), + Ok(DeployOwnership::AdapterManaged) + ); + + for stores in [ + DeployStoreIds { + config: vec!["config".to_owned()], + ..DeployStoreIds::default() + }, + DeployStoreIds { + kv: vec!["kv".to_owned()], + ..DeployStoreIds::default() + }, + DeployStoreIds { + secrets: vec!["secret".to_owned()], + ..DeployStoreIds::default() + }, + ] { + managed = direct.clone(); + managed.stores = stores; + assert_eq!( + FastlyCliAdapter.preflight_deploy( + &managed, + &[], + &edgezero_adapter::registry::AdapterExecContext::new() + ), + Ok(DeployOwnership::AdapterManaged) + ); + } + } + + #[cfg(unix)] + #[test] + fn managed_deploy_requires_application_release_before_provider_mutation() { + let _lock = path_mutation_guard().lock().expect("guard"); + let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "test-token"); + let context = AdapterDeployContext { + service_id: Some("SVC1".to_owned()), + staging: true, + ..AdapterDeployContext::default() + }; + + let error = FastlyCliAdapter + .deploy( + &context, + &[], + &edgezero_adapter::registry::AdapterExecContext::new(), + ) + .expect_err("managed staging deploy requires an immutable release"); + assert!(error.contains("--application-release"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn manifest_command_finalization_binds_reported_version_to_active_service_version() { + let _lock = path_mutation_guard().lock().expect("guard"); + + let fake = tempdir().expect("provider fake"); + let marker = fake.path().join("provider.log"); + let curl = fake.path().join("curl"); + fs::write( + &curl, + format!( + "#!/bin/sh\ncat >/dev/null\nprintf 'called\\n' >> '{}'\nprintf '[{{\"number\":8,\"active\":true,\"locked\":true,\"staging\":false,\"deployed\":true,\"environments\":[{{\"active_version\":8,\"name\":\"production\",\"service_id\":\"SVC1\"}}]}}]\\n200'\n", + marker.display() + ), + ) + .expect("curl fake"); + let mut permissions = fs::metadata(&curl).expect("curl metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&curl, permissions).expect("curl executable"); + let _path = PathPrepend::new(fake.path()); + let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "test-token"); + let context = AdapterDeployContext { + service_id: Some("SVC1".to_owned()), + ..AdapterDeployContext::default() + }; + + FastlyCliAdapter + .finalize_deploy( + &context, + Some("version=8"), + &edgezero_adapter::registry::AdapterExecContext::new(), + ) + .expect("active version finalization"); + assert!( + marker.exists(), + "finalization must consult the exact service" + ); + + let error = FastlyCliAdapter + .finalize_deploy( + &context, + Some("version=9"), + &edgezero_adapter::registry::AdapterExecContext::new(), + ) + .expect_err("a version from another service or deployment must fail closed"); + assert!(error.contains('8') && error.contains('9'), "{error}"); + } + + #[test] + fn deploy_plan_final_argument_parser_accepts_comment_and_global_booleans() { + let parsed = parse_release_managed_deploy_args(&owned(&[ + "--comment=publisher deployment", + "--accept-defaults", + "-d", + "--auto-yes", + "-y", + "--debug-mode", + "--non-interactive", + "-i", + "--quiet", + "-q", + "--verbose", + "-v", + ])) + .expect("final managed argument forms"); + assert_eq!(parsed.comment.as_deref(), Some("publisher deployment")); + assert_eq!( + parsed.globals, + owned(&[ + "--accept-defaults", + "-d", + "--auto-yes", + "-y", + "--debug-mode", + "--non-interactive", + "-i", + "--quiet", + "-q", + "--verbose", + "-v" + ]) + ); + + let detached = + parse_release_managed_deploy_args(&owned(&["--comment", "publisher deployment"])) + .expect("detached comment form"); + assert_eq!(detached.comment.as_deref(), Some("publisher deployment")); + + let option_like = parse_release_managed_deploy_args(&owned(&["--comment=--json"])) + .expect("an attached option-like comment remains a literal value"); + assert_eq!(option_like.comment.as_deref(), Some("--json")); + } + + #[test] + fn deploy_plan_final_argument_parser_rejects_every_package_spelling() { + for args in [ + owned(&["--package", "app.tar.gz"]), + owned(&["--package=app.tar.gz"]), + owned(&["-p", "app.tar.gz"]), + owned(&["-p=app.tar.gz"]), + owned(&["-papp.tar.gz"]), + ] { + let error = parse_release_managed_deploy_args(&args) + .expect_err("the immutable release owns the package path"); + assert!(error.contains("--package/-p"), "{args:?}: {error}"); + } + } + + #[cfg(unix)] + #[test] + fn deploy_environment_parent_overrides_defaults_then_falls_back_to_logical_ids() { + let _lock = path_mutation_guard().lock().expect("guard"); + let config_name = "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME"; + let manifest_config_name = "EDGEZERO__stores__config__app_config__name"; + let kv_name = "EDGEZERO__STORES__KV__SESSIONS__NAME"; + let config_key = "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY"; + let _parent = EnvOverride::set(config_name, "parent_config"); + let _no_parent_kv = EnvOverride::remove(kv_name); + let _parent_key = EnvOverride::set(config_key, "parent_key"); + let context = AdapterDeployContext { + stores: DeployStoreIds { + config: vec!["app_config".to_owned(), "feature_flags".to_owned()], + kv: vec!["sessions".to_owned()], + secrets: vec!["default".to_owned()], + }, + variable_defaults: BTreeMap::from([ + ( + manifest_config_name.to_owned(), + "manifest_config".to_owned(), + ), + (config_key.to_owned(), "manifest_key".to_owned()), + (kv_name.to_owned(), "manifest_sessions".to_owned()), + ]), + ..AdapterDeployContext::default() + }; + + let environment = effective_deploy_environment(&context, &AdapterExecContext::new()) + .expect("valid effective environment"); + assert_eq!( + environment.store_name("config", "app_config"), + "parent_config", + "the parent process must override the manifest default" + ); + assert_eq!( + environment.store_name("kv", "sessions"), + "manifest_sessions", + "the manifest default must fill an absent parent value" + ); + assert_eq!( + environment.store_key("config", "app_config"), + "parent_key", + "the parent config key must override the manifest default" + ); + assert_eq!( + environment.store_name("config", "feature_flags"), + "feature_flags", + "an absent selector must fall back to the logical ID" + ); + assert_eq!( + environment.store_name("secrets", "default"), + "default", + "the logical id must fill an absent optional Secret Store selector" + ); + } + + #[cfg(unix)] + #[test] + fn deploy_environment_rejects_invalid_present_store_selectors() { + let _lock = path_mutation_guard().lock().expect("guard"); + let selector = "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME"; + let _no_parent_selector = EnvOverride::remove(selector); + let base = AdapterDeployContext { + stores: DeployStoreIds { + config: vec!["app_config".to_owned()], + ..DeployStoreIds::default() + }, + ..AdapterDeployContext::default() + }; + + let mut invalid_selector = base.clone(); + invalid_selector + .variable_defaults + .insert(selector.to_owned(), String::new()); + let selector_error = + effective_deploy_environment(&invalid_selector, &AdapterExecContext::new()) + .expect_err("a present empty selector must not fall back to the logical id"); + assert!( + selector_error.contains(selector), + "error names invalid selector: {selector_error}" + ); + + let _invalid_parent_selector = EnvOverride::set(selector, "bad\nselector"); + let mut invalid_parent = base.clone(); + invalid_parent + .variable_defaults + .insert(selector.to_owned(), "valid_manifest_selector".to_owned()); + let parent_error = + effective_deploy_environment(&invalid_parent, &AdapterExecContext::new()) + .expect_err("an invalid parent selector must not fall back to a valid default"); + assert!( + parent_error.contains(selector), + "error names parent selector: {parent_error}" + ); + } + + #[cfg(unix)] + #[test] + fn deploy_environment_rejects_invalid_value_before_provider_cli() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let manifest = dir.path().join("fastly.toml"); + fs::write(&manifest, "name = \"app\"\n").expect("manifest"); + let marker = dir.path().join("provider.log"); + let fake = fake_provider_invocation_marker(&marker); + let _path = PathPrepend::new(fake.path()); + let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "token"); + let selector = "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME"; + let _no_parent_selector = EnvOverride::remove(selector); + let context = AdapterDeployContext { + adapter_manifest_path: Some(manifest), + application_manifest_path: None, + application_release_root: None, + service_id: Some("SVC1".to_owned()), + staging: true, + stores: DeployStoreIds { + config: vec!["app_config".to_owned()], + ..DeployStoreIds::default() + }, + variable_defaults: BTreeMap::from([(selector.to_owned(), String::new())]), + }; + + let error = FastlyCliAdapter + .deploy( + &context, + &[], + &edgezero_adapter::registry::AdapterExecContext::new(), + ) + .expect_err("invalid runtime environment must fail managed deploy"); + assert!(error.contains(selector), "error names selector: {error}"); + assert_provider_not_invoked(&marker); + } + + #[test] + fn build_compute_deploy_args_is_non_interactive() { + // Without this a production deploy can block on an interactive + // prompt in CI. + let argv = build_compute_deploy_args(&owned(&["--service-id", "SVC1"])); + assert_eq!( + argv, + owned(&[ + "compute", + "deploy", + "--service-id", + "SVC1", + "--non-interactive" + ]) + ); + } + + #[test] + fn build_compute_deploy_args_does_not_duplicate_caller_flag() { + for flag in ["--non-interactive", "-i"] { + let argv = build_compute_deploy_args(&owned(&[flag])); + assert_eq!( + argv.iter() + .filter(|arg| *arg == "--non-interactive" || *arg == "-i") + .count(), + 1, + "must not pass the non-interactive switch twice ({flag})" + ); + } + } + + #[test] + fn healthcheck_rejects_missing_or_empty_required_values_on_production() { + for (args, needle) in [ + ( + owned(&["--domain", "example.com", "--service-id", "SVC1"]), + "--version", + ), + ( + owned(&[ + "--domain", + "example.com", + "--service-id", + "SVC1", + "--version", + "", + ]), + "invalid version", + ), + ( + owned(&[ + "--domain", + "example.com", + "--service-id", + "SVC1", + "--version", + "15.2.0", + ]), + "invalid version", + ), + ( + owned(&[ + "--domain", + "example.com", + "--service-id", + "", + "--version", + "7", + ]), + "invalid service id", + ), + ( + owned(&["--domain", "", "--service-id", "SVC1", "--version", "7"]), + "invalid domain", + ), + ( + owned(&["--service-id", "SVC1", "--version", "7"]), + "--domain", + ), + ] { + let err = healthcheck(&args).expect_err("must reject absent/empty required value"); + assert!( + err.contains(needle), + "expected {needle:?} in error for {args:?}, got: {err}" + ); + } + } + + #[test] + fn healthcheck_rejects_empty_required_values_on_staging() { + for args in [ + owned(&[ + "--staging", + "--domain", + "example.com", + "--service-id", + "", + "--version", + "7", + ]), + owned(&[ + "--staging", + "--domain", + "example.com", + "--service-id", + "SVC1", + "--version", + "", + ]), + ] { + healthcheck(&args).expect_err("staging must reject empty required values"); + } + } + + #[test] + fn rollback_rejects_missing_or_invalid_required_values() { + for staging in [&[][..], &["--staging".to_owned()][..]] { + for bad in [ + owned(&["--service-id", "SVC1"]), + owned(&["--service-id", "SVC1", "--version", ""]), + owned(&["--service-id", "SVC1", "--version", "12abc"]), + owned(&["--service-id", "", "--version", "7"]), + ] { + let mut args = bad.clone(); + args.extend_from_slice(staging); + rollback(&args).expect_err("rollback must reject invalid required values"); + } + } + } + + #[test] + fn curl_quote_escapes_quotes_and_backslashes() { + assert_eq!(curl_quote("plain"), "\"plain\""); + assert_eq!(curl_quote("a\"b"), "\"a\\\"b\""); + assert_eq!(curl_quote("a\\b"), "\"a\\\\b\""); + } + + #[test] + fn curl_quote_never_emits_raw_control_characters() { + // A token carrying a `"` and a newline must not be able to + // terminate its quoted value and inject a second `url = "..."` + // directive. The `"` is escaped and the newline is folded to a + // `\n` escape so NO raw newline reaches the curl config file. + let token = "tok\"en\nurl = \"https://evil.example\""; + let quoted = curl_quote(token); + assert!(quoted.starts_with('"') && quoted.ends_with('"')); + assert!(!quoted.contains('\n'), "no raw newline: {quoted}"); + assert!(!quoted.contains('\r')); + // The only unescaped `"` are the wrapping pair; every interior + // quote is preceded by a backslash. + assert_eq!(quoted, "\"tok\\\"en\\nurl = \\\"https://evil.example\\\"\""); + // A tab folds too. + assert_eq!(curl_quote("a\tb"), "\"a\\tb\""); + } + + #[test] + fn validate_service_id_accepts_fastly_handle() { + validate_service_id("SU1Z0isxPaozGVKXdv0eY").expect("alphanumeric handle"); + } + + #[test] + fn validate_service_id_rejects_punctuation() { + for invalid in ["SVC1_", "SVC-1", "SVC__OTHER"] { + let error = validate_service_id(invalid).expect_err("punctuation is not valid"); + assert_service_id_error(&error); + } + } + + #[test] + fn validate_service_id_rejects_injection_and_empty() { + // The canonical attack: a service id that closes the url value + // and appends a second url directive. + validate_service_id("abc\nurl = \"http://evil\"").expect_err("newline injection"); + validate_service_id("abc\"def").expect_err("quote"); + validate_service_id("has space").expect_err("space"); + validate_service_id("has/slash").expect_err("slash"); + validate_service_id("").expect_err("empty"); + } + + #[cfg(unix)] + #[test] + fn deploy_rejects_invalid_service_id_before_provider_cli() { + let _lock = path_mutation_guard().lock().expect("guard"); + let app = tempdir().expect("app dir"); + let manifest = app.path().join("fastly.toml"); + fs::write(&manifest, "name = \"app\"\n").expect("manifest"); + let marker = app.path().join("provider.log"); + let fake = fake_provider_invocation_marker(&marker); + let _path = PathPrepend::new(fake.path()); + let args = owned(&[ + "--manifest-path", + manifest.to_str().expect("utf8 manifest"), + "--service-id", + "SVC-1", + ]); + + let error = deploy( + &args, + &edgezero_adapter::registry::AdapterExecContext::new(), + ) + .expect_err("invalid service id must fail deploy"); + assert_service_id_error(&error); + assert_provider_not_invoked(&marker); + } + + #[cfg(unix)] + #[test] + fn active_version_rejects_invalid_service_id_before_provider_api() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let marker = dir.path().join("provider.log"); + let fake = fake_provider_invocation_marker(&marker); + let _path = PathPrepend::new(fake.path()); + let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "token"); + + let error = emit_active_version(&owned(&["--service-id", "SVC_1"])) + .expect_err("invalid service id must fail active-version capture"); + assert_service_id_error(&error); + assert_provider_not_invoked(&marker); + } + + #[cfg(unix)] + #[test] + fn healthcheck_rejects_invalid_service_id_before_provider_api() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let marker = dir.path().join("provider.log"); + let fake = fake_provider_invocation_marker(&marker); + let _path = PathPrepend::new(fake.path()); + let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "token"); + let args = owned(&[ + "--domain", + "example.com", + "--service-id", + "SVC-1", + "--version", + "7", + ]); + + let error = healthcheck(&args).expect_err("invalid service id must fail healthcheck"); + assert_service_id_error(&error); + assert_provider_not_invoked(&marker); + } + + #[cfg(unix)] + #[test] + fn rollback_rejects_invalid_service_id_before_provider_api() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let marker = dir.path().join("provider.log"); + let fake = fake_provider_invocation_marker(&marker); + let _path = PathPrepend::new(fake.path()); + let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "token"); + let args = owned(&[ + "--service-id", + "SVC_1", + "--version", + "7", + "--rollback-to", + "6", + ]); + + let error = rollback(&args).expect_err("invalid service id must fail rollback"); + assert_service_id_error(&error); + assert_provider_not_invoked(&marker); + } + + #[cfg(unix)] + #[test] + fn provision_rejects_invalid_service_id_before_provider_cli() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + fs::write( + dir.path().join("fastly.toml"), + "name = \"app\"\nservice_id = \"SVC-1\"\n", + ) + .expect("manifest"); + let marker = dir.path().join("provider.log"); + let fake = fake_provider_invocation_marker(&marker); + let _path = PathPrepend::new(fake.path()); + let _service_env = EnvOverride::remove(FASTLY_SERVICE_ID_ENV); + let kv = vec![ResolvedStoreId::from_logical("sessions")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, + secrets: &[], + }; + + let error = FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) + .expect_err("invalid manifest service id must fail provision"); + assert_service_id_error(&error); + assert_provider_not_invoked(&marker); + } + + #[cfg(unix)] + #[test] + fn adapter_deploy_rejects_invalid_service_id_before_provider_cli() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let manifest = dir.path().join("fastly.toml"); + fs::write(&manifest, "name = \"app\"\nservice_id = \"SVC_1\"\n").expect("manifest"); + let marker = dir.path().join("provider.log"); + let fake = fake_provider_invocation_marker(&marker); + let _path = PathPrepend::new(fake.path()); + let _service_env = EnvOverride::remove(FASTLY_SERVICE_ID_ENV); + let context = AdapterDeployContext { + adapter_manifest_path: Some(manifest), + ..AdapterDeployContext::default() + }; + + let error = FastlyCliAdapter + .deploy( + &context, + &[], + &edgezero_adapter::registry::AdapterExecContext::new(), + ) + .expect_err("direct adapter deploy must reject invalid service id"); + assert_service_id_error(&error); + assert_provider_not_invoked(&marker); + } + + #[cfg(unix)] + #[test] + fn adapter_deploy_discovers_and_rejects_invalid_service_id_before_provider_cli() { + const CHILD_ENV: &str = "EDGEZERO_FASTLY_DISCOVERY_TEST_CHILD"; + const MARKER_ENV: &str = "EDGEZERO_FASTLY_DISCOVERY_TEST_MARKER"; + + if env::var_os(CHILD_ENV).is_some() { + let marker = PathBuf::from(env::var_os(MARKER_ENV).expect("child marker path")); + let error = FastlyCliAdapter + .deploy( + &AdapterDeployContext::default(), + &[], + &edgezero_adapter::registry::AdapterExecContext::new(), + ) + .expect_err("discovered invalid service id must fail direct adapter deploy"); + assert_service_id_error(&error); + assert_provider_not_invoked(&marker); + return; + } + + let dir = tempdir().expect("tempdir"); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"app\"\n") + .expect("Cargo manifest"); + fs::write( + dir.path().join("fastly.toml"), + "name = \"app\"\nservice_id = \"SVC-1\"\n", + ) + .expect("manifest"); + let marker = dir.path().join("provider.log"); + let fake = fake_provider_invocation_marker(&marker); + let path = env::join_paths( + once(fake.path().to_path_buf()) + .chain(env::split_paths(&env::var_os("PATH").unwrap_or_default())), + ) + .expect("test PATH"); + let output = Command::new(env::current_exe().expect("current test binary")) + .args([ + "--exact", + "cli::tests::adapter_deploy_discovers_and_rejects_invalid_service_id_before_provider_cli", + "--nocapture", + ]) + .current_dir(dir.path()) + .env(CHILD_ENV, "1") + .env(MARKER_ENV, &marker) + .env("PATH", path) + .env_remove(FASTLY_SERVICE_ID_ENV) + .output() + .expect("run isolated discovery regression"); + + assert!( + output.status.success(), + "isolated discovery regression failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_provider_not_invoked(&marker); + } + + #[test] + fn validate_version_str_accepts_integer_rejects_junk() { + assert_eq!(validate_version_str("42"), Ok(42)); + assert_eq!(validate_version_str("0"), Ok(0)); + validate_version_str("-1").expect_err("negative"); + validate_version_str("4.2").expect_err("float"); + validate_version_str("42\nurl = \"x\"").expect_err("newline injection"); + validate_version_str("").expect_err("empty"); + } + + #[test] + fn validate_domain_accepts_hostnames_rejects_injection() { + validate_domain("example.com").expect("bare hostname"); + validate_domain("staging.example.co.uk").expect("multi-label hostname"); + validate_domain("host-1.example.com").expect("hostname with dash"); + validate_domain("").expect_err("empty"); + validate_domain(".example.com").expect_err("leading dot"); + validate_domain("example.com.").expect_err("trailing dot"); + validate_domain("exa..mple.com").expect_err("empty label"); + validate_domain("example.com/evil").expect_err("slash"); + validate_domain("example.com\nurl = \"x\"").expect_err("newline injection"); + validate_domain("has space.com").expect_err("space"); + } + + #[test] + fn version_active_verdict_enforces_the_production_version_contract() { + // The requested version is the active one: healthy. + version_active_verdict(Some(7), 7, "SVC1", "before probing").expect("match is ok"); + // A different active version (a concurrent deploy) must fail closed and name + // BOTH versions so the mismatch is diagnosable. + let err = version_active_verdict(Some(9), 7, "SVC1", "after probing") + .expect_err("a newer active version must fail the version contract"); + assert!(err.contains('7') && err.contains('9'), "{err}"); + // No active version at all is not a healthy version-7 report either. + version_active_verdict(None, 7, "SVC1", "before probing") + .expect_err("no active version must fail the contract"); + } + + #[test] + fn is_healthy_status_covers_2xx_only() { + assert!(is_healthy_status(200)); + assert!(is_healthy_status(204)); + assert!(is_healthy_status(299)); + // 3xx is NOT healthy: the probe does not follow redirects, so a 301 to an + // error page must not pass a gate that suppresses an automatic rollback. + assert!(!is_healthy_status(301)); + assert!(!is_healthy_status(399)); + assert!(!is_healthy_status(400)); + assert!(!is_healthy_status(500)); + assert!(!is_healthy_status(199)); + } + + #[test] + fn parse_fastly_version_handles_the_shapes_fastly_emits() { + // The Fastly CLI's own success lines. Go format strings: + // "Updated package (service %s, version %v)" (compute update) + // "Deployed package (service %s, version %v)" (compute deploy) + assert_eq!( + parse_fastly_version("SUCCESS: Deployed package (service abc, version 7)"), + Some(7) + ); + assert_eq!( + parse_fastly_version("\nSUCCESS: Updated package (service SU1Z0, version 42)\n"), + Some(42) + ); + // Our canonical contract line. + assert_eq!(parse_fastly_version("version=12"), Some(12)); + // The --autoclone notice, when no success line is present. + assert_eq!( + parse_fastly_version( + "Service version 3 is not editable, so it was automatically cloned because \ + --autoclone is enabled. Now operating on version 4." + ), + Some(4) + ); + // Full autoclone + success output: the SUCCESS line wins, and the + // PRE-clone version (3) never does — even though stdout/stderr are + // concatenated and their relative order is not guaranteed. + let combined = "SUCCESS: \nUpdated package (service abc, version 4)\n\ + Service version 3 is not editable, so it was automatically cloned. \ + Now operating on version 4."; + assert_eq!(parse_fastly_version(combined), Some(4)); + assert_eq!(parse_fastly_version("no numbers here"), None); + } + + #[test] + fn parse_fastly_version_rejects_confusable_lines() { + // The old parser took ANY digits after the word "version", so each + // of these silently produced a WRONG service version. They must now + // all be `None`, which makes managed deployment fail closed. + assert_eq!( + parse_fastly_version("Uploaded package to service 12345, version unchanged"), + None + ); + // The CLI's own semver must not be mistaken for a service version. + assert_eq!(parse_fastly_version("Fastly CLI version 15.2.0"), None); + assert_eq!( + parse_fastly_version("Checking version compatibility for service 99"), + None + ); + // A bare `version ` mention with no success-line context is not + // trusted either. + assert_eq!(parse_fastly_version("cloning version 3"), None); + // `--version=active` echoed in a command line is not a contract line. + assert_eq!( + parse_fastly_version("running: fastly compute update --version=active"), + None + ); + } + + #[test] + fn cloned_version_requires_a_new_version_for_the_exact_service() { + assert_eq!( + parse_cloned_version(r#"{"service_id":"svc","number":42}"#, "svc", 40), + Ok(42) + ); + for invalid in [ + r#"{"service_id":"other","number":42}"#, + r#"{"service_id":"svc","number":40}"#, + r#"{"service_id":"svc","number":"42"}"#, + "[]", + "not json", + ] { + parse_cloned_version(invalid, "svc", 40) + .expect_err("an ambiguous clone response must fail closed"); + } + } + + #[test] + fn parse_active_version_finds_active_entry() { + let json = r#"[ + {"number":1,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[]}, + {"number":2,"active":true,"locked":true,"staging":false,"deployed":true,"environments":[]}, + {"number":3,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]} + ]"#; + assert_eq!(resolve_active_version(json), Ok(Some(2))); + } + + #[test] + fn deploy_plan_version_source_parses_state_and_selects_active() { + let versions = parse_service_versions( + r#"[ + {"number":1,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[{"active_version":2,"name":"production","service_id":"SVC1"}]}, + {"number":2,"active":true,"locked":true,"staging":false,"deployed":true,"environments":[{"active_version":2,"name":"production","service_id":"SVC1"}]} + ]"#, + ) + .expect("typed version list"); + assert_eq!(versions[1].number, 2); + assert!(versions[1].active); + assert!(versions[1].locked); + assert_eq!(versions[1].environments[0].name, "production"); + assert_eq!( + select_version_source(&versions), + Ok(VersionSource::Active(2)) + ); + } + + #[test] + fn deploy_plan_version_source_parses_complete_fastly_api_records() { + let versions = parse_service_versions( + r#"[{ + "active":true, + "comment":"publisher release", + "created_at":"2026-09-16T00:00:00Z", + "deployed":true, + "environments":[{ + "active_version":7, + "name":"production", + "service_id":"SVC1" + }], + "locked":true, + "number":7, + "service_id":"SVC1", + "staging":false, + "testing":false, + "updated_at":"2026-09-16T00:00:00Z" + }]"#, + ) + .expect("authoritative Fastly API version list"); + assert_eq!(versions[0].environments[0].active_version, 7); + assert_eq!(versions[0].environments[0].name, "production"); + assert_eq!(versions[0].environments[0].service_id, "SVC1"); + assert_eq!( + select_version_source(&versions), + Ok(VersionSource::Active(7)) + ); + } + + #[test] + fn version_source_parses_fastly_cli_15_1_capitalized_records() { + let versions = parse_service_versions( + r#"[{ + "Active":true, + "Comment":"publisher release", + "Deployed":true, + "Environments":[{ + "ServiceVersion":7, + "Name":"production", + "ServiceID":"SVC1" + }], + "Locked":true, + "Number":7, + "ServiceID":"SVC1", + "Staging":false, + "Testing":false + }]"#, + ) + .expect("Fastly CLI 15.1 version JSON"); + assert_eq!(versions[0].environments[0].name, "production"); + assert_eq!( + select_version_source(&versions), + Ok(VersionSource::Active(7)) + ); + } + + #[test] + fn deploy_plan_version_source_rejects_every_missing_authoritative_field() { + const VERSION_NUMBER: u64 = 1; + + let complete = serde_json::json!({ + "active": false, + "deployed": false, + "environments": [], + "locked": false, + "number": VERSION_NUMBER, + "staging": false + }); + for field in ["active", "environments", "locked", "number"] { + let mut record = complete.clone(); + record + .as_object_mut() + .expect("version object") + .remove(field); + let raw = serde_json::json!([record]).to_string(); + parse_service_versions(&raw).expect_err(&format!("missing `{field}` must fail closed")); + } + } + + #[test] + fn deploy_plan_version_source_rejects_orphan_draft_beside_retired_source() { + let versions = parse_service_versions( + r#"[ + {"number":1,"active":false,"locked":true,"staging":false,"deployed":false,"environments":[]}, + {"number":2,"active":false,"locked":false,"staging":true,"deployed":true,"environments":[]} + ]"#, + ) + .expect("typed version list"); + let error = select_version_source(&versions) + .expect_err("an editable draft beside a retired source has no provable owner"); + assert!( + error.contains("editable Fastly draft 2") + && error.contains("retired version") + && error.contains("cannot prove"), + "error must identify the orphan draft and retired source: {error}" + ); + } + + #[test] + fn deploy_plan_version_source_selects_unique_shadow_staging_source_without_active() { + let versions = parse_service_versions( + r#"[{"number":3,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[{"active_version":3,"name":"staging","service_id":"shadow-staging-service"}]}]"#, + ) + .expect("staged version list"); + assert_eq!( + select_version_source(&versions), + Ok(VersionSource::Staging(3)) + ); + } + + #[test] + fn deploy_plan_version_source_recovers_first_staging_deactivation() { + let versions = parse_service_versions( + r#"[{"number":1,"active":false,"locked":true,"staging":false,"deployed":false,"environments":[]}]"#, + ) + .expect("retired first staging version"); + assert_eq!( + select_version_source(&versions), + Ok(VersionSource::Retired(1)) + ); + } + + #[test] + fn deploy_plan_version_source_rejects_retry_draft_beside_staging() { + let versions = parse_service_versions( + r#"[ + {"number":1,"active":false,"locked":true,"environments":[{"active_version":1,"name":"staging","service_id":"shadow-staging-service"}]}, + {"number":2,"active":false,"locked":false,"environments":[]} + ]"#, + ) + .expect("staged source and one retry draft"); + let error = select_version_source(&versions) + .expect_err("a retry draft must not replace the real staged source"); + assert!( + error.contains("editable Fastly draft 2") + && error.contains("staged version 1") + && error.contains("fastly service version lock --version=2") + && !error.contains("first deployment"), + "error must identify both versions and the fail-closed recovery: {error}" + ); + } + + #[test] + fn deploy_plan_version_source_rejects_missing_duplicate_or_ambiguous_staging_source() { + for invalid in [ + r#"[{"number":2,"active":false,"locked":true,"staging":false,"deployed":false,"environments":[{"active_version":2,"name":"staging","service_id":"shadow-staging-service"}]},{"number":3,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[{"active_version":3,"name":"staging","service_id":"shadow-staging-service"}]}]"#, + r#"[{"number":3,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[{"active_version":2,"name":"staging","service_id":"shadow-staging-service"}]}]"#, + r#"[{"number":3,"active":false,"locked":true,"staging":true,"deployed":true,"environments":[{"active_version":3,"name":"production","service_id":"SVC1"}]}]"#, + ] { + let versions = parse_service_versions(invalid).expect("well-formed version list"); + assert!( + select_version_source(&versions).is_err(), + "unsafe staged source must fail closed: {invalid}" + ); + } + } + + #[test] + fn deploy_plan_version_source_rejects_duplicate_global_staging_records() { + let versions = parse_service_versions( + r#"[ + {"number":3,"active":false,"locked":true,"environments":[{"active_version":3,"name":"staging","service_id":"shadow-staging-service"}]}, + {"number":4,"active":false,"locked":true,"environments":[{"active_version":3,"name":"staging","service_id":"shadow-staging-service"}]} + ]"#, + ) + .expect("well-formed but ambiguous version list"); + + let error = select_version_source(&versions) + .expect_err("more than one staging environment record must fail closed"); + assert!( + error.contains("more than one staging environment record"), + "error must identify the provider-state ambiguity: {error}" + ); + } + + #[test] + fn deploy_plan_version_source_rejects_missing_duplicate_and_malformed_versions() { + for invalid in [ + "[]", + r#"[{"number":1},{"number":1}]"#, + r#"[{"number":"1"}]"#, + r#"[{"number":1,"active":"false"}]"#, + r#"[{"number":1,"locked":"false"}]"#, + r#"[{"number":1,"environments":"staging"}]"#, + ] { + assert!( + parse_service_versions(invalid).is_err(), + "invalid version list must fail: {invalid}" + ); + } + } + + #[test] + fn deploy_plan_version_source_ignores_unused_deployed_and_staging_fields() { + let versions = parse_service_versions( + r#"[{"number":1,"active":false,"locked":false,"staging":"unused","deployed":{"unused":true},"environments":[]}]"#, + ) + .expect("unused provider fields must not control version state"); + assert_eq!( + select_version_source(&versions), + Ok(VersionSource::InitialDraft(1)) + ); + } + + #[test] + fn deploy_plan_version_source_rejects_unsafe_or_ambiguous_first_drafts() { + for invalid in [ + r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]},{"number":2,"active":false,"locked":true,"staging":false,"deployed":false,"environments":[]}]"#, + r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]},{"number":2,"active":false,"locked":false,"staging":true,"deployed":false,"environments":[]}]"#, + r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]},{"number":2,"active":false,"locked":false,"staging":false,"deployed":true,"environments":[]}]"#, + r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]},{"number":2,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[{"active_version":2,"name":"staging","service_id":"SVC1"}]}]"#, + r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]},{"number":2,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]}]"#, + ] { + let versions = parse_service_versions(invalid).expect("well-formed versions"); + assert!( + select_version_source(&versions).is_err(), + "unsafe first-deploy source must fail: {invalid}" + ); + } + } + + #[test] + fn publication_barrier_rejects_draft_that_is_no_longer_editable() { + let versions = parse_service_versions( + r#"[{"number":7,"active":false,"locked":true,"environments":[]}]"#, + ) + .expect("locked draft record"); + + let error = require_managed_draft_state(VersionSource::InitialDraft(7), &versions, 7) + .expect_err("publication must reject a locked target"); + assert!(error.contains("no longer an unpublished editable draft")); + } + + #[test] + fn publication_barrier_rejects_active_version_appearing_for_initial_draft() { + let versions = parse_service_versions( + r#"[ + {"number":6,"active":true,"locked":true,"environments":[{"active_version":6,"name":"production","service_id":"production-service"}]}, + {"number":7,"active":false,"locked":false,"environments":[]} + ]"#, + ) + .expect("draft plus foreign active version"); + + let error = require_managed_draft_state(VersionSource::InitialDraft(7), &versions, 7) + .expect_err("publication must reject a newly active version"); + assert!(error.contains("active version appeared before publication")); + } + + #[test] + fn publication_barrier_excludes_the_new_clone_when_revalidating_its_source() { + let versions = parse_service_versions( + r#"[ + {"number":3,"active":false,"locked":true,"environments":[{"active_version":3,"name":"staging","service_id":"shadow-staging-service"}]}, + {"number":4,"active":false,"locked":false,"environments":[]} + ]"#, + ) + .expect("staged source plus cloned target"); + + let source_versions = source_versions_excluding(&versions, Some(4)); + assert_eq!( + select_version_source(&source_versions), + Ok(VersionSource::Staging(3)) + ); + assert!( + select_version_source(&versions).is_err(), + "including the target would misclassify the provider state as ambiguous" + ); + } + + #[test] + fn publication_barrier_rejects_package_identity_mismatch() { + let error = require_package_identity("expected-hash", "foreign-hash", 42) + .expect_err("publication must reject a replaced package"); + assert!(error.contains("version 42 package identity changed")); + } + + #[test] + fn publication_barrier_rejects_protected_configuration_mismatch() { + fn snapshot(domain: &str) -> VersionConfigurationSnapshot { + VersionConfigurationSnapshot { + backends: serde_json::json!([]), + domains: serde_json::json!([{ "name": domain }]), + healthchecks: serde_json::json!([]), + logging: Vec::new(), + settings: serde_json::json!({}), + } + } + + let error = require_draft_configuration( + &snapshot("expected.example"), + &snapshot("foreign.example"), + 42, + ) + .expect_err("publication must reject protected configuration drift"); + assert!(error.contains("protected Compute configuration changed")); + } + + fn deploy_plan_inventories() -> ResourceInventories { + ResourceInventories::from_json( + r#"[ + {"id":"CONFIG_A","name":"config-a"}, + {"id":"CONFIG_B","name":"config-b"} + ]"#, + r#"[{"id":"KV_A","name":"kv-a"},{"id":"KV_B","name":"kv-b"}]"#, + r#"[{"id":"SECRET_A","name":"secret-a"}]"#, + ) + .expect("valid inventories") + } + + fn desired_link( + kind: ResourceKind, + alias: &str, + selected_name: &str, + resource_id: &str, + ) -> DesiredResourceLink { + DesiredResourceLink { + alias: alias.to_owned(), + kind, + resource_id: resource_id.to_owned(), + selected_name: selected_name.to_owned(), + } + } + + fn existing_link( + kind: ResourceKind, + alias: &str, + resource_id: &str, + link_id: &str, + ) -> ExistingResourceLink { + ExistingResourceLink { + alias: alias.to_owned(), + kind, + link_id: link_id.to_owned(), + resource_id: resource_id.to_owned(), + } + } + + #[test] + fn deploy_plan_uses_logical_aliases_and_selected_physical_resources() { + let environment = EnvConfig::from_vars([ + ("EDGEZERO__STORES__CONFIG__SHARED__NAME", "config-a"), + ("EDGEZERO__STORES__KV__SHARED__NAME", "kv-a"), + ("EDGEZERO__STORES__SECRETS__CREDENTIALS__NAME", "secret-a"), + ]); + let desired = desired_resource_links( + &RuntimeStoreIds { + config: vec!["shared".to_owned()], + kv: vec!["shared".to_owned()], + secrets: vec!["credentials".to_owned()], + }, + &environment, + &deploy_plan_inventories(), + ) + .expect("desired links"); + + assert_eq!( + desired, + vec![ + desired_link(ResourceKind::Config, "shared", "config-a", "CONFIG_A"), + desired_link(ResourceKind::Kv, "shared", "kv-a", "KV_A"), + desired_link(ResourceKind::Secret, "credentials", "secret-a", "SECRET_A",), + ] + ); + } + + #[test] + fn deploy_plan_rejects_present_invalid_store_name_before_inventory_lookup() { + for value in ["", " ", "bad\nname"] { + let environment = + EnvConfig::from_vars([("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME", value)]); + let error = desired_resource_links( + &RuntimeStoreIds { + config: vec!["app_config".to_owned()], + ..RuntimeStoreIds::default() + }, + &environment, + &deploy_plan_inventories(), + ) + .expect_err("present invalid selector must fail"); + assert!( + error.contains("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME"), + "invalid selector error must name its canonical variable" + ); + } + } + + #[test] + fn deploy_plan_replaces_declared_identity_and_preserves_undeclared_links() { + let desired = vec![desired_link( + ResourceKind::Config, + "app_config", + "config-b", + "CONFIG_B", + )]; + let existing = vec![ + existing_link(ResourceKind::Config, "app_config", "CONFIG_A", "OLD_CONFIG"), + existing_link(ResourceKind::Kv, "sessions", "KV_A", "KEEP_KV"), + ]; + + let plan = plan_link_reconciliation(&desired, &existing, &deploy_plan_inventories()) + .expect("reconciliation"); + assert_eq!(plan.delete_link_ids, vec!["OLD_CONFIG"]); + assert_eq!(plan.create, desired); + } + + #[test] + fn deploy_plan_preserves_undeclared_link_absent_from_visible_inventories() { + let existing = vec![existing_link( + ResourceKind::Secret, + "shared_by_another_app", + "INACCESSIBLE_SECRET", + "KEEP_SECRET", + )]; + + let plan = plan_link_reconciliation(&[], &existing, &deploy_plan_inventories()) + .expect("an undeclared inherited link does not require inventory visibility"); + assert!(plan.delete_link_ids.is_empty()); + assert!(plan.create.is_empty()); + } + + #[test] + fn deploy_plan_keeps_same_alias_isolated_by_resource_kind() { + let desired = vec![ + desired_link(ResourceKind::Config, "shared", "config-a", "CONFIG_A"), + desired_link(ResourceKind::Kv, "shared", "kv-b", "KV_B"), + ]; + let existing = vec![ + existing_link(ResourceKind::Config, "shared", "CONFIG_A", "CONFIG_LINK"), + existing_link(ResourceKind::Kv, "shared", "KV_A", "KV_LINK"), + ]; + + let plan = plan_link_reconciliation(&desired, &existing, &deploy_plan_inventories()) + .expect("kind-isolated reconciliation"); + assert_eq!(plan.delete_link_ids, vec!["KV_LINK"]); + assert_eq!( + plan.create, + vec![desired_link(ResourceKind::Kv, "shared", "kv-b", "KV_B")] + ); + } + + #[test] + fn deploy_plan_rejects_reported_kind_that_conflicts_with_inventory() { + let existing = vec![existing_link( + ResourceKind::Secret, + "credentials", + "CONFIG_A", + "BAD_KIND", + )]; + let error = plan_link_reconciliation(&[], &existing, &deploy_plan_inventories()) + .expect_err("kind conflict must fail"); + assert!(error.contains("reports Secret Store"), "{error}"); + assert!(error.contains("belongs to Config Store"), "{error}"); + } + + #[test] + fn resource_link_parser_requires_known_resource_type() { + let raw = r#"[ + {"id":"CONFIG_LINK","name":"shared","resource_id":"CONFIG_A","resource_type":"config"}, + {"id":"KV_LINK","name":"shared","resource_id":"KV_A","resource_type":"kv-store"}, + {"id":"SECRET_LINK","name":"credentials","resource_id":"SECRET_A","resource_type":"secret-store"} + ]"#; + let (links, _) = parse_resource_links(raw).expect("typed links"); + assert_eq!(links[0].kind, ResourceKind::Config); + assert_eq!(links[1].kind, ResourceKind::Kv); + assert_eq!(links[2].kind, ResourceKind::Secret); + + let unknown = + r#"[{"id":"LINK","name":"x","resource_id":"X","resource_type":"dictionary"}]"#; + let error = parse_resource_links(unknown).expect_err("unknown type must fail"); + assert!(error.contains("unknown `resource_type`"), "{error}"); + } + + #[test] + fn version_configuration_snapshot_normalizes_clone_metadata_and_order() { + assert_eq!( + parse_snapshot_array( + "backends", + r#"[ + {"name":"origin-b","hostname":"b.example","service_id":"SVC","version":40,"created_at":"old"}, + {"name":"origin-a","hostname":"a.example","locked":true,"updated_at":"old"} + ]"#, + ) + .expect("source snapshot"), + parse_snapshot_array( + "backends", + r#"[ + {"name":"origin-a","hostname":"a.example","locked":false,"updated_at":"new"}, + {"name":"origin-b","hostname":"b.example","service_id":"SVC","version":42,"created_at":"new"} + ]"#, + ) + .expect("clone snapshot") + ); + + parse_snapshot_array("backends", r#"[{"name":"origin"}, null]"#) + .expect_err("non-object collection entries must fail closed"); + parse_snapshot_array("backends", "{}").expect_err("objects are not collections"); + parse_snapshot_object("settings", "[]").expect_err("arrays are not settings objects"); + assert_eq!( + parse_snapshot_object( + "settings", + r#"{"general.default_ttl":3600,"service_id":"SVC","version":40}"#, + ), + parse_snapshot_object( + "settings", + r#"{"general.default_ttl":3600,"service_id":"SVC","version":42}"#, + ) + ); + } + + #[test] + fn version_configuration_snapshot_ignores_json_object_key_order() { + let source = parse_snapshot_array( + "backends", + r#"[ + {"name":"origin-a","hostname":"z.example","tls":{"check":true,"sni":"z.example"}}, + {"name":"origin-b","hostname":"a.example","tls":{"check":false,"sni":"a.example"}} + ]"#, + ) + .expect("source snapshot"); + let reread = parse_snapshot_array( + "backends", + r#"[ + {"tls":{"sni":"a.example","check":false},"hostname":"a.example","name":"origin-b"}, + {"tls":{"sni":"z.example","check":true},"hostname":"z.example","name":"origin-a"} + ]"#, + ) + .expect("reordered snapshot"); + + assert_eq!(source, reread); + } + + #[test] + fn logging_snapshot_uses_fastly_api_provider_paths() { + assert!(FASTLY_LOGGING_PROVIDER_KINDS.contains(&"pubsub")); + assert!(FASTLY_LOGGING_PROVIDER_KINDS.contains(&"logentries")); + assert!(FASTLY_LOGGING_PROVIDER_KINDS.contains(&"s3")); + assert!(!FASTLY_LOGGING_PROVIDER_KINDS.contains(&"googlepubsub")); + } + + #[test] + fn fastly_config_keys_use_the_logical_id_for_every_target() { + validate_fastly_config_key("app_config", "app_config", false, false) + .expect("production key"); + validate_fastly_config_key("app_config", "app_config", true, false) + .expect("staging target key"); + validate_fastly_config_key("app_config", "app_config", false, true).expect("local key"); + + for (key, staging, local) in [ + ("custom", false, false), + ("alternate", true, false), + ("alternate", false, true), + ] { + let error = validate_fastly_config_key("app_config", key, staging, local) + .expect_err("conflicting key must fail"); + assert!(error.contains("logical config key"), "{error}"); + } + } + + #[test] + fn parse_active_version_none_when_no_active() { + // A parsed list with no active version is `Ok(None)` — confirmed + // no active version (first deploy), NOT an operational failure. + let json = r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]}]"#; + assert_eq!(resolve_active_version(json), Ok(None)); + } + + #[test] + fn resolve_active_version_errors_on_unparseable_payload() { + // A truncated / non-array body is an operational failure, distinct from + // "no active version" — the caller must fail closed, not record empty. + resolve_active_version("not json").expect_err("non-JSON must be an operational error"); + resolve_active_version(r#"{"error":"unauthorized"}"#) + .expect_err("a non-array body must be an operational error"); + } + + #[test] + fn resolve_active_version_errors_on_malformed_active_entries() { + // A garbled ACTIVE entry must fail closed, not read as "no active + // version" — otherwise a production deploy proceeds with no rollback + // target. Each of these is malformed and must be an operational error. + resolve_active_version(r#"[{"active":true}]"#) + .expect_err("active entry with no `number` must error"); + resolve_active_version(r#"[{"active":true,"number":"7"}]"#) + .expect_err("active entry with a string `number` must error"); + resolve_active_version(r#"[{"active":"true","number":7}]"#) + .expect_err("a non-boolean `active` must error"); + // A non-boolean `active` ANYWHERE is schema drift — the whole list is + // scanned, so it is caught even AFTER a valid active entry (a naive + // first-match parser would miss this one). + resolve_active_version(r#"[{"active":"false"},{"active":true,"number":9}]"#) + .expect_err("a non-boolean `active` before the active entry is schema drift"); + resolve_active_version(r#"[{"active":true,"number":9},{"active":"nope"}]"#) + .expect_err("a non-boolean `active` AFTER the active entry is still schema drift"); + // More than one active version is ambiguous — refuse rather than pick one. + resolve_active_version(r#"[{"active":true,"number":9,"locked":true,"staging":false,"deployed":true,"environments":[]},{"active":true,"number":10,"locked":true,"staging":false,"deployed":true,"environments":[]}]"#) + .expect_err("two active versions must error as ambiguous"); + // EVERY element must be a version object with a numeric `number` — a + // garbled entry must fail closed, not be skipped as "not active". + resolve_active_version("[]").expect_err("an empty version list is an invalid response"); + resolve_active_version("[null]").expect_err("a null element must error"); + resolve_active_version("[{}]").expect_err("an entry with no `number` must error"); + resolve_active_version(r#"[{"number":"invalid"}]"#) + .expect_err("a non-numeric `number` must error"); + // Every safety field is mandatory; omission is schema drift. + resolve_active_version( + r#"[{"number":42,"locked":false,"staging":false,"deployed":false,"environments":[]}]"#, + ) + .expect_err("an omitted active field must fail closed"); + // Sanity: a well-formed list still resolves. + assert_eq!( + resolve_active_version( + r#"[{"active":false,"number":1,"locked":true,"staging":false,"deployed":true,"environments":[]},{"active":true,"number":2,"locked":true,"staging":false,"deployed":true,"environments":[]}]"# + ), + Ok(Some(2)) + ); + } + + #[test] + fn ensure_rollback_from_is_active_blocks_racing_deploys() { + // The version being rolled back FROM is still active → proceed. + assert_eq!(ensure_rollback_from_is_active(Some(7), 7, "svc"), Ok(())); + // A NEWER version is active (a deploy raced the rollback) → refuse, so + // the newer deploy is not clobbered. + ensure_rollback_from_is_active(Some(9), 7, "svc") + .expect_err("a newer active version must block the rollback"); + // No active version at all → refuse. + ensure_rollback_from_is_active(None, 7, "svc") + .expect_err("no active version must block the rollback"); + } + + #[test] + fn staging_rollback_after_failure_before_stage_is_a_noop() { + let versions = parse_service_versions( + r#"[{"active":false,"number":7,"locked":false,"staging":true,"deployed":true,"environments":[]}]"#, + ) + .expect("version list"); + assert_eq!( + staging_rollback_decision(&versions, 7, "svc"), + Ok(StagingRollbackDecision::NoopDraft) + ); + } + + #[test] + fn staging_rollback_after_failure_after_stage_deactivates_exact_version() { + let versions = parse_service_versions( + r#"[{"active":false,"number":7,"locked":false,"staging":false,"deployed":false,"environments":[{"active_version":7,"name":"staging","service_id":"shadow-staging-service"}]}]"#, + ) + .expect("version list"); + assert_eq!( + staging_rollback_decision(&versions, 7, "svc"), + Ok(StagingRollbackDecision::Deactivate) + ); + staging_rollback_decision(&versions, 8, "svc") + .expect_err("an absent version must fail closed"); + } + + #[test] + fn staging_rollback_uses_the_exact_staging_environment_record() { + let versions = parse_service_versions( + r#"[{"active":true,"number":7,"locked":true,"staging":false,"deployed":false,"environments":[{"active_version":7,"name":"production","service_id":"production-service"},{"active_version":7,"name":"staging","service_id":"shadow-staging-service"}]}]"#, + ) + .expect("version list"); + assert_eq!( + staging_rollback_decision(&versions, 7, "svc"), + Ok(StagingRollbackDecision::Deactivate) + ); + + let duplicate = parse_service_versions( + r#"[{"active":false,"number":7,"locked":true,"environments":[{"active_version":7,"name":"staging","service_id":"shadow-staging-service"},{"active_version":7,"name":"staging","service_id":"shadow-staging-service"}]}]"#, + ) + .expect("version list"); + staging_rollback_decision(&duplicate, 7, "svc") + .expect_err("duplicate staging environment records must fail closed"); + + let cross_version_duplicate = parse_service_versions( + r#"[{"active":false,"number":7,"locked":true,"environments":[{"active_version":7,"name":"staging","service_id":"shadow-staging-service"}]},{"active":false,"number":8,"locked":true,"environments":[{"active_version":8,"name":"staging","service_id":"shadow-staging-service"}]}]"#, + ) + .expect("version list"); + staging_rollback_decision(&cross_version_duplicate, 7, "svc") + .expect_err("staging records on multiple versions must fail closed"); + } + + #[test] + fn active_version_or_require_enforces_require_active() { + let active = r#"[{"active":true,"number":5,"locked":true,"staging":false,"deployed":true,"environments":[]}]"#; + let none = r#"[{"active":false,"number":5,"locked":false,"staging":false,"deployed":false,"environments":[]}]"#; + + // A resolvable active version is returned regardless of the flag. + assert_eq!(active_version_or_require(active, false, "svc"), Ok(Some(5))); + assert_eq!(active_version_or_require(active, true, "svc"), Ok(Some(5))); + + // No active version: tolerated for `active-version` (first deploy), but an + // ERROR for the production-deploy fallback (`--require-active`), which + // must never report a deploy with no resolvable version. + assert_eq!(active_version_or_require(none, false, "svc"), Ok(None)); + active_version_or_require(none, true, "svc") + .expect_err("require-active with no active version must fail closed"); + + // A malformed response is an error either way. + active_version_or_require("not json", false, "svc").expect_err("malformed must error"); + } + + #[test] + fn parse_staging_ip_reads_the_singular_staging_ip_field() { + // The REAL Fastly response shape for + // `GET /service//version//domain?include=staging_ips`: + // an array of domain objects, each with a SINGULAR `staging_ip` + // STRING. Body copied from go-fastly's recorded API fixture + // `fastly/fixtures/domains/list_with_staging_ips.yaml`, matching + // its `StagingIP *string `mapstructure:"staging_ip"`` field. + // (`staging_ips` is only the `include=` query value, never a + // field name — the previous parser looked for it as an array and + // therefore NEVER found a staging IP.) + let json = r#"[ + { + "created_at": "2022-11-04T17:36:56Z", + "service_id": "kKJb5bOFI47uHeBVluGfX1", + "name": "integ-test-20221104.go-fastly-1.com", + "version": 73, + "comment": "comment", + "deleted_at": null, + "staging_ip": "167.82.81.194" + } + ]"#; + assert_eq!( + parse_staging_ip(json, "integ-test-20221104.go-fastly-1.com"), + Ok("167.82.81.194".to_owned()) + ); + } + + #[test] + fn parse_staging_ip_selects_the_requested_domain() { + let json = r#"[ + {"name":"other.example.com","staging_ip":"151.101.1.10"}, + {"name":"example.com","staging_ip":"151.101.2.10"} + ]"#; + assert_eq!( + parse_staging_ip(json, "example.com"), + Ok("151.101.2.10".to_owned()) + ); + } + + #[test] + fn parse_staging_ip_rejects_missing_duplicate_or_malformed_domain_records() { + for json in [ + r#"[{"name":"other.example.com","staging_ip":"151.101.1.10"}]"#, + r#"[{"name":"example.com","staging_ip":null}]"#, + r#"[{"name":"example.com","staging_ips":["151.101.2.10"]}]"#, + r#"[{"name":"example.com","staging_ip":"151.101.2.10"},{"name":"example.com","staging_ip":"151.101.2.11"}]"#, + r#"{"name":"example.com","staging_ip":"151.101.2.10"}"#, + ] { + parse_staging_ip(json, "example.com") + .expect_err("ambiguous or malformed domain inventory must fail closed"); + } + } +} 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 000000000..68280a192 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/test_support.rs @@ -0,0 +1,494 @@ +//! 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}; +#[cfg(unix)] +use std::path::Path; +#[cfg(unix)] +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) +} + +/// Build a fake `fastly` for live runtime store-name reconciliation. +/// The current runtime-env entries are listed verbatim and every update or +/// delete is recorded in `oplog`. +#[cfg(unix)] +pub(crate) fn fake_fastly_runtime_mapping( + current: &[(String, String)], + oplog: &Path, +) -> tempfile::TempDir { + fake_fastly_runtime_mapping_with_update_exit(current, oplog, 0) +} + +#[cfg(unix)] +fn fake_fastly_runtime_mapping_with_update_exit( + current: &[(String, String)], + oplog: &Path, + update_exit: i32, +) -> tempfile::TempDir { + fake_fastly_runtime_mapping_with_exits(current, oplog, update_exit, 0) +} + +#[cfg(unix)] +fn fake_fastly_runtime_mapping_with_exits( + current: &[(String, String)], + oplog: &Path, + update_exit: i32, + delete_exit: i32, +) -> tempfile::TempDir { + use std::fs; + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempdir().expect("tempdir"); + let store_list = dir.path().join("stores.json"); + let entry_list = dir.path().join("entries.json"); + fs::write( + &store_list, + format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#), + ) + .expect("store list"); + let entries = current + .iter() + .map(|(key, value)| { + serde_json::json!({ + "item_key": key, + "item_value": value, + }) + }) + .collect::>(); + fs::write( + &entry_list, + serde_json::to_string(&entries).expect("entry list json"), + ) + .expect("entry list"); + + let script = format!( + r#"#!/bin/sh +if [ "$1" = "compute" ] && [ "$2" = "deploy" ]; then printf 'compute-deploy cwd=%s\n' "$PWD" >> '{oplog}'; exit 0; fi +if [ "$1" = "config-store" ] && [ "$2" = "create" ]; then printf 'store-create cwd=%s\n' "$PWD" >> '{oplog}'; exit 0; fi +if [ "$1" = "kv-store" ] && [ "$2" = "create" ]; then printf 'kv-store-create name=%s cwd=%s\n' "$3" "$PWD" >> '{oplog}'; exit 0; fi +if [ "$1" = "config-store" ]; then printf 'store-list cwd=%s\n' "$PWD" >> '{oplog}'; cat '{stores}'; 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 cwd=%s\n' "$PWD" >> '{oplog}'; cat '{entries}'; exit 0; fi +if [ "$sub" = "update" ]; then value=$(cat); printf 'update %s=%s cwd=%s\n' "$key" "$value" "$PWD" >> '{oplog}'; exit {update_exit}; fi +if [ "$sub" = "delete" ]; then printf 'delete %s cwd=%s\n' "$key" "$PWD" >> '{oplog}'; exit {delete_exit}; fi +echo 'unexpected fastly invocation' >&2 +exit 1 +"#, + stores = store_list.display(), + entries = entry_list.display(), + oplog = oplog.display(), + ); + 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 +} diff --git a/crates/edgezero-adapter-fastly/src/config_store.rs b/crates/edgezero-adapter-fastly/src/config_store.rs index bad34170b..8c1847fae 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 e3ccb4414..000000000 --- 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 4e14c8322..000000000 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ /dev/null @@ -1,2751 +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://github.com/IABTechLab/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://github.com/IABTechLab/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://github.com/IABTechLab/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 = "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), - // The Fastly staging lifecycle is Fastly-only. - AdapterAction::DeployStaging - | AdapterAction::EmitVersion - | AdapterAction::Healthcheck - | AdapterAction::Rollback => Err(format!( - "spin adapter does not support the Fastly staging lifecycle action {action:?}" - )), - 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), 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. 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). - // 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; - // KV-backed config no longer shares Spin's flat - // variable namespace, so config keys are NOT considered here - // (and the trait dropped the parameter) — 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}` does not reference a valid Spin variable name. {reason}. Pick a secret reference that conforms; its value is redacted.", - field = entry.field_name, - )); - } - if let Some(prev_field) = seen.insert(spin_var.clone(), entry.field_name.as_str()) { - return Err(format!( - "`#[secret]` fields `{prev_field}` and `{this_field}` reference the same Spin variable after lowercasing; Spin's flat variable namespace cannot disambiguate them. Pick distinct secret references; their values are redacted.", - 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