Skip to content

feat: provision --local + gitignored adapter manifests + hardened path safety - #287

Open
aram356 wants to merge 120 commits into
mainfrom
feature/provision-local-impl
Open

feat: provision --local + gitignored adapter manifests + hardened path safety#287
aram356 wants to merge 120 commits into
mainfrom
feature/provision-local-impl

Conversation

@aram356

@aram356 aram356 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Adds edgezero provision --local, folds all five adapter manifests into the gitignored-generated model, and hardens the surrounding CLI (path safety, env redaction, adapter-scoped env-file load) across provision, config push, config diff, and serve.

Source artifacts

The 9 plan sections all landed on this branch (rather than as separate sub-PRs merging in); each closes on merge below.

What ships

provision --local. New ProvisionMode::Local arm threads through Adapter::provision. Local mode:

  • Synthesises minimal per-adapter manifests on a clean clone via toml_edit::DocumentMut (CLI-owned bootstrap runs before validation).
  • Merges per-store bindings + env-label lines (EDGEZERO__STORES__<KIND>__<ID>__NAME=…) so the runtime resolves stores at startup.
  • Writes adapter-specific env files (.edgezero/.env, .dev.vars, <spin_crate>/.env).
  • Never shells out to cloud CLIs.

Dry-run stages a real fs::copy into a tempfile::TempDir and diffs the result back. The tree stays byte-identical either way.

Typed provision (run_provision_typed::<C>). Generated <app>-cli binaries dispatch the typed variant, which runs the base flow then additionally emits per-secret placeholder lines derived from AppConfig's #[secret] / #[secret(store_ref)] fields (Axum: <key>=; Cloudflare: <key>="" in .dev.vars; Fastly: [[local_server.secret_stores.<store_id>]]; Spin: lowercased [variables] + SPIN_VARIABLE_<NAME>= in .env). The bundled edgezero binary intentionally does not emit placeholders — it has no downstream AppConfig type.

All five adapter manifests are provision-generated and gitignored. axum.toml joined wrangler.toml / fastly.toml / spin.toml / runtime-config.toml in the 2026-07 amendment; the scaffold .hbs template for axum.toml was removed so scaffold-time provision is the single writer. A CI grep gate enforces the whole set.

Path safety. New path_safety::{assert_provision_paths_safe, assert_provision_paths_contained} helpers guard every CLI entry point that joins manifest_root with an operator-declared adapter path. Cloud dispatch runs the "safe" variant (absolute-path rejection + .. traversal rejection); --local runs the stricter "contained" variant that additionally requires BOTH [adapters.<name>.adapter].manifest AND [adapters.<name>.adapter].crate to be declared, with the manifest resolving inside the crate dir. Wired into run_provision, run_provision_typed, run_config_push_typed, run_config_diff_typed, and run_serve.

Renamed / nested adapter crate support. cli_support::read_adapter_crate_name walks upward from the manifest's parent to the first Cargo.toml inside manifest_root and reads [package].name. All four bundled adapter synthesisers use this to name generated manifests correctly under operator renames ([adapters.axum.adapter].crate = "crates/server"crate = "server" in axum.toml; Cargo package spin-server → wasm path spin_server.wasm). Nested manifests like crates/server/config/spin.toml resolve correctly too.

Spin component / crate decoupling. synthesise_spin_toml now takes two distinct identities — crate_name (from Cargo.toml, drives [application].name and the wasm source basename) and component (from [adapters.spin.adapter].component, drives [[trigger.http]].component and the [component.<id>] table key). Fixes the pre-2026-07 conflation where setting a component selector would silently mispoint the wasm artifact.

Env-value redaction in dry-run. .env and .dev.vars bodies are rewritten as KEY=<redacted> before diffing so operator secrets never surface in dry-run output. Commented KEY=value lines (adapter provisioners emit these as # EDGEZERO__STORES__…__KEY= placeholders, and operators stash real values behind # for later use) get the same treatment; pure comments and blank lines pass through so structural drift stays readable.

Adapter-scoped env-file load in run_serve. Axum reads <manifest_root>/.edgezero/.env; Spin reads .env next to the resolved spin.toml (matches where provision writes it — a nested manifest = "crates/spin/config/spin.toml" correctly resolves to crates/spin/config/.env). Both run through the path-safety guard before the read. Contract test spawns a real serve child, inspects its inherited environment via a file the child writes, and asserts the marker propagates.

Section breakdown

Each section closes on merge:

Post-plan additions (from external review iterations)

Multiple review passes drove refinements past the original plan:

  • Axum folded into gitignored-generated set (v4 review) — scaffold template removed; .gitignore + CI gate + spec-plan-guide docs updated.
  • read_adapter_crate_name upward walk added to support nested manifests (v5 review).
  • Spin [application].name and wasm basename decoupled from the component selector (v5 review).
  • Path safety extended: strict-local requires both .manifest AND .crate; run_config_diff_typed gets the guard (previously read-only justified skipping); run_serve gets the guard on the Spin env-file load.
  • Env-value redaction added to dry-run diff output, including commented # KEY=value lines (v6/v7 reviews).
  • Typed dry-run runs the full run_manifest_shape_gates (adds validate_deployed_field_ownership alongside capability + handler-path checks).
  • Spin run_serve derives .env path from [adapters.spin.adapter].manifest.parent() (was .crate — broke on nested manifests). Contract test observes the spawned child's env (v8 review).
  • Non-test #[expect(...)] restructure sweep across edgezero-cli, edgezero-core/manifest.rs (Deserialize impls), and adapter cli/mod.rs files (removed via explicit trait method overrides, ConfigPushSuppressions sub-struct, stream module, structural code fixes rather than workspace-level allows).

CI gates (all pass locally)

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo test --workspace --all-targets
  • cargo check --workspace --all-targets --features "fastly cloudflare spin"
  • cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin
  • examples/app-demo workspace tests
  • Generated-project ignored compile test (cargo test -p edgezero-cli --test generated_project_builds -- --ignored)

Test plan

  • All five CI gates pass on the merged branch
  • Per-adapter contract tests cover the four provision_local_* cases + Spin's env-label alignment quartet (Section 9)
  • Renamed / nested adapter crate regression tests across all four adapters
  • Spin nested-manifest serve env contract test (parent env + child-observed env)
  • Dry-run env / secret file redaction regression tests (raw values, commented KEY=value shapes, structural drift)
  • Path-safety negative tests for run_serve, run_config_diff_typed, run_config_push_typed, run_provision
  • Worktree is clean after workspace + examples/app-demo tests

Migration notes

  • Local development requires re-running <app>-cli provision --adapter <name> --local after cloning to regenerate the five adapter manifests (.gitignore covers all of them plus .dev.vars).
  • [adapters.<name>.adapter].manifest and [adapters.<name>.adapter].crate are now both required for provision --local and config push/diff --local. Every scaffolded project already sets both. Cloud dispatch remains permissive.
  • Downstream CLIs generated from the scaffold now dispatch via run_provision_typed::<AppConfig> (not the untyped run_provision) so #[secret] fields reach the adapters' provision_typed impls.

Empty tracking commit for the implementation work tracked in:
  docs/superpowers/plans/2026-06-27-provision-local.md

Issues:
  - Epic: <epic-issue-url>
  - Per-section sub-issues linked from the epic.

This PR opens as a DRAFT and stays draft until Section 1 lands its
first real commit. Each section opens as its own follow-up PR
that lands here before the umbrella merges to main.
@aram356 aram356 self-assigned this Jun 30, 2026
aram356 added 14 commits June 30, 2026 00:46
run_shared_checks iterates every declared adapter and dispatches
validate_adapter_manifest, which for Spin does
fs::read_to_string(manifest_root.join(rel)). With the containment
guard sitting after run_shared_checks, a manifest declaring
[adapters.spin.adapter].manifest = "../outside/spin.toml" could
trigger a filesystem read outside the project root before the
guard rejected it — a spec violation of §"Path containment (MUST)"
which requires the helper run BEFORE any manifest-path use.

Fix by relocating the check to fire immediately after
load_push_context, and looping over every declared adapter (not
just ctx.adapter) since run_shared_checks reads all of them.
Also close Task 7's Minor about the duplicate adapter_entry call
by removing the now-redundant per-adapter guard block.

Regression test: config_push_local_rejects_parent_traversal_in_
sibling_spin_adapter declares a poisoned Spin adapter alongside
the pushed axum adapter, and asserts the error names the
containment violation (not Spin's "failed to read spin manifest"
message that would surface under the old ordering).

Also tighten copy_tree's else-branch to explicitly gate on
is_regular_file() rather than "everything non-dir non-symlink",
add a Unix symlink-skip test, and drop a stale
#[expect(dead_code)] on ValidationContext::manifest_path that
now has real callers.
The bare-cwd variant of the accept-test (--manifest edgezero.toml)
previously wrote the manifest to a tempdir and set EDGEZERO_MANIFEST,
but run_provision reads args.manifest directly (no env fallback).
The test therefore failed on manifest load ("failed to load
edgezero.toml") and its negative !contains(path-safety-markers)
assertion vacuously passed — no actual coverage of the
`args.manifest.parent() == ""` fallback.

Fix by adding a CwdGuard RAII helper that chdirs into the tempdir
under the manifest_guard() serialisation lock and restores the
previous cwd on drop. Both accept-tests now also assert positively
that the error is the (true, true) dispatch stub ("local dry-run
staging lands in Task 10/11"), proving the manifest loaded AND
path-safety passed AND we reached the dispatch matrix. Drop the
now-unnecessary EnvOverride from both tests.

Reviewer: reviewer of Task 9 pushed this as a Low ahead of Task 10
because run_with_staging depends on manifest-root/cwd correctness.
@aram356
aram356 marked this pull request as ready for review July 8, 2026 22:09
@aram356 aram356 changed the title feat: provision --local (umbrella PR) feat: provision --local + gitignored adapter manifests + hardened path safety Jul 8, 2026

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 ## PR Review

Summary

This is a thorough implementation with strong adapter-specific coverage, dry-run fidelity tests, and clear ownership boundaries. I found three blocking safety gaps: lexical containment can be bypassed through symlinks, run_serve mutates the process environment, and config validate remains outside the new path-safety perimeter.

😃 Praise

  • 😃 The dry-run renderer redacts both sides before diffing and handles commented assignments plus value-only changes without leaking secret values.

Findings

Blocking

  • 🔧 Apply path safety to config validate: Both run_config_validate and run_config_validate_typed call run_shared_checks, which reaches run_adapter_shared_checks and invokes validate_adapter_manifest without first applying assert_provision_paths_safe. Spin's validator then reads manifest_root.join(adapter_manifest_path), so an absolute or traversing path rejected by provision/push/diff remains readable through validation (crates/edgezero-cli/src/config.rs:1421). Centralize the safe variant immediately before each adapter validator; local push can retain its stricter contained check.

Non-blocking

  • 🤔 Document Axum's generated-manifest workflow: docs/guide/adapters/axum.md still presents axum.toml as an ordinary project file without explaining that it is now gitignored and absent after a fresh clone. Add the regeneration command (<app>-cli provision --adapter axum --local) near the manifest description (docs/guide/adapters/axum.md:201).

CI Status

  • 📝 fmt: PASS
  • 📝 clippy: PASS
  • 📝 workspace tests: PASS
  • 📝 feature compilation: PASS
  • 📝 Spin wasm32-wasip2 check: PASS
  • 📝 GitHub checks: PASS

("[adapters.<name>.adapter].crate", adapter_crate_path),
] {
let Some(raw) = maybe_raw else { continue };
let candidate = Path::new(raw);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 Symlinks bypass the containment guarantee: these checks are purely lexical. For example, crate = "crates/worker" passes when crates/worker is a symlink to a directory outside the project; the subsequent adapter fs::write calls follow that symlink and mutate the external target. Dry-run makes this harder to notice because copy_dir_recursive deliberately skips symlinks, so its staged result differs from the real write path.

Please canonicalize and verify each existing path component (or reject symlink components) before writing. For complete TOCTOU protection, perform directory-relative opens/writes beneath a trusted root rather than checking a path and reopening it by name.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 44c9a2d (crates/edgezero-cli/src/path_safety.rs).

assert_provision_paths_impl now walks each component of the resolved path from project_root inward via new reject_symlink_components helper. Any existing intermediate whose symlink_metadata reports is_symlink() == true is rejected before dispatch. Broken/dangling symlinks are caught too (they still report is_symlink() == true). Applies to both the safe and strict-local variants — symlink following would let cloud dispatch read from outside the project as well.

Four regression tests (Unix, std::os::unix::fs::symlink): rejects_symlinked_adapter_crate_directory, rejects_symlink_in_manifest_intermediate_component, safe_variant_also_rejects_symlinked_paths, accepts_regular_directory_and_first_run_missing_paths (proves NotFound intermediates for first-run scaffold still pass).

Documented as best-effort against current filesystem state, not full TOCTOU protection — openat + O_NOFOLLOW threaded through every adapter is flagged as follow-up scope in the code comment.

Comment thread crates/edgezero-cli/src/env_file.rs Outdated
.strip_prefix('"')
.and_then(|stripped| stripped.strip_suffix('"'))
.unwrap_or(trimmed_val);
env::set_var(key, value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 Do not mutate the process environment from this library path: on Unix, setenv/getenv access is not thread-safe, and this crate cannot guarantee that no other thread is reading the environment. The repository's own shared_test_guards.rs explicitly documents this constraint, while run_serve can be called from a multithreaded downstream process.

Parse the file into an owned env overlay and apply it only to the spawned child with Command::envs. The existing adapter::run_shell path already builds a Command; registered-adapter execution should receive the same overlay through an execution context rather than process-global mutation. Validate keys and values while parsing so malformed NUL/= input returns an error instead of panicking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in c38cb547d6d886e0688aa (crates/edgezero-cli/src/env_file.rs, adapter.rs, lib.rs).

env_file::load_into_process_env (which called std::env::set_var) removed. Replaced by env_file::parse_env_overlay which returns an owned Vec<(String, String)> — no process-env mutation, no shared environ access, safe from any multithreaded caller. The parser also rejects lines whose key contains NUL or =, and values that contain NUL (both are Command::env contract violations that would otherwise panic or misroute downstream).

run_serve threads the overlay into the spawned child via new adapter::execute_with_env_overlayCommand::env per entry. Each Command::env call stores the entry in the Commands private map — no shared state, no setenv. Existing-env-wins preserved by checking std::env::var_os before each set (mirrors the manifest [environment.variables] block and the adapter bind hint).

Three parser tests + three integration tests:

  • env_file::tests::parses_key_value_lines_into_ordered_overlay (pure parser)
  • env_file::tests::rejects_nul_byte_in_key / rejects_nul_byte_in_value (Command::env contract)
  • env_file::tests::missing_file_yields_readable_error
  • run_serve_does_not_mutate_parent_process_env_yet_child_sees_env_file_value (the load-bearing thread-safety assertion — child observes the marker via a file it writes; parent env asserted to be None after run_serve returns)
  • run_serve_existing_parent_env_wins_over_env_file (opposite contract — parents KEY wins over the .env)

// through as-is so structural drift is still visible.
let is_env_like = is_env_secret_carriage_path(proj_path);
let old_render = if is_env_like {
redact_env_body_for_diff(&old)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😃 Good secret-handling boundary: redacting both the old and staged bodies before constructing the diff prevents unchanged context lines from leaking operator values. The separate value-only-change marker also preserves useful signal without exposing keys or values.

@ChristianPavilonis ChristianPavilonis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Requesting changes. The implementation has strong coverage and all current checks pass, but the inline findings identify three high-impact correctness/security regressions plus dry-run, nested-layout, and public-API compatibility gaps. I have not repeated the symlink-containment, process-global environment, validation-ordering, or Axum-documentation findings from the existing review.

Comment thread crates/edgezero-cli/src/path_safety.rs
Comment thread crates/edgezero-adapter-cloudflare/src/cli/provision_cloud.rs Outdated
Comment thread crates/edgezero-adapter-spin/src/cli/run.rs Outdated
Comment thread crates/edgezero-cli/src/provision.rs
Comment thread crates/edgezero-adapter-axum/src/cli/run.rs Outdated
Comment thread crates/edgezero-cli/src/args.rs Outdated
aram356 added 3 commits July 9, 2026 14:55
…verlay via Command::env, axum docs

All four findings from #287
review (@prk-Jr) addressed.

## Blocking #1 — config validate outside path-safety perimeter

`run_config_validate` / `run_config_validate_typed` called
`run_shared_checks` -> `run_adapter_shared_checks` which invoked
`adapter.validate_adapter_manifest(manifest_root, path, ...)`
without `assert_provision_paths_safe`. Spin's validator then
reads `manifest_root.join(adapter_manifest_path)`, so a poisoned
`manifest = "/etc/spin.toml"` would surface that file's contents
as the validation error message.

Fix: call `assert_provision_paths_safe` per-adapter INSIDE
`run_adapter_shared_checks`, before each adapter's
`validate_adapter_manifest`. This makes validate share the same
minimum-bar guard push/diff/provision already run; local push/diff
still layer their stricter `contained` variant on top.

Regression tests:
- `raw_rejects_absolute_adapter_manifest_path_before_adapter_validators_run`
- `raw_rejects_parent_traversal_adapter_manifest_before_adapter_validators_run`

## Blocking #2 — Symlink bypass in lexical containment

The lexical guard let `crate = "crates/worker"` pass when
`crates/worker` is a symlink to a directory outside the project;
subsequent adapter `fs::write` calls followed the symlink and
mutated the external target. Dry-run `copy_dir_recursive`
deliberately skips symlinks, so its staged result differed from
the real write path — the operator wouldn't notice.

Fix: new `reject_symlink_components(start, candidate, label)`
helper walks each component of the resolved path and rejects any
existing intermediate that `symlink_metadata` reports as
`is_symlink()`. `NotFound` intermediates (first-run scaffold
where the adapter crate isn't materialised yet) are fine — the
adapter creates them from EdgeZero-owned code. Missing directories
along the way are ok; a broken symlink still reports
`is_symlink() == true`, so dangling links get caught too.

Fires on BOTH the safe and strict-local variants; symlink
following would let cloud dispatch read from outside the project
too.

Note: this closes the ambient-file surface but is not a full
TOCTOU guard. A concurrent attacker who plants the symlink
between check and adapter write can still race. Fully closing
that requires directory-relative opens (`openat` + `O_NOFOLLOW`)
threaded through every adapter — flagged as follow-up scope.

Regression tests (Unix-only, use `std::os::unix::fs::symlink`):
- `rejects_symlinked_adapter_crate_directory`
- `rejects_symlink_in_manifest_intermediate_component`
- `safe_variant_also_rejects_symlinked_paths`
- `accepts_regular_directory_and_first_run_missing_paths`

## Blocking #3 — run_serve mutated process env

`env_file::load_into_process_env` wrote every KEY=VALUE via
`std::env::set_var`. On Unix, `setenv`/`getenv` operate on the
shared `environ` array without synchronisation — the C standard
flags concurrent access as UB. A multithreaded downstream process
calling `edgezero_cli::run_serve` from one thread while another
thread reads `std::env::var` observes a torn read.

Fix:
- `env_file::load_into_process_env` -> `parse_env_overlay`, which
  returns `Vec<(String, String)>`. No process-env mutation.
- Parser rejects lines with NUL bytes in the key or value
  (`Command::env` contract violations) rather than panicking or
  silently misrouting downstream.
- New `adapter::execute_with_env_overlay` accepts the owned
  overlay; `run_shell` applies it via `Command::env` (per-Command
  private map — no shared state, no setenv). Existing-env-wins
  preserved by checking `env::var_os` before each set, same rule
  as the manifest `[environment.variables]` block.
- `run_serve` swaps the load path for
  `parse_env_overlay` + `execute_with_env_overlay`.

Regression tests:
- `env_file::tests::parses_key_value_lines_into_ordered_overlay`
  (pure parser: comments, quotes, indented lines, malformed lines
  skipped).
- `env_file::tests::rejects_nul_byte_in_key` /
  `rejects_nul_byte_in_value` (Command::env contract).
- `env_file::tests::missing_file_yields_readable_error`.
- `run_serve_does_not_mutate_parent_process_env_yet_child_sees_env_file_value`:
  child observes the overlay via a file it writes; the PARENT's
  `env::var(marker)` is asserted to be None afterward. The
  load-bearing thread-safety assertion.
- `run_serve_existing_parent_env_wins_over_env_file`: opposite
  contract — parent's KEY wins over the .env's KEY.
- `run_serve_loads_env_file_into_process_env_before_spawning_child`
  (existing) updated: dropped the parent-env-observed assertion
  (which is now inverted from the new contract); kept the
  child-observed nested-manifest-parent assertion.

The old `load_into_process_env_*` tests in lib.rs are deleted —
parser behaviour is now covered in env_file.rs's own module tests,
and the process-env observation was the exact pattern this fix
removed.

## Non-blocking — Axum docs

`docs/guide/adapters/axum.md` presented `axum.toml` as an
ordinary project file. Added a warning block under the
Configuration section documenting the 2026-07 amendment: it is
provision-generated + gitignored, teammates regenerate via
`<app>-cli provision --adapter axum --local`, operator edits
survive re-run, and the Axum blueprint has no scaffold `.hbs`
template so scaffold and clean-clone produce byte-identical
output.

All CI gates locally clean: fmt, clippy --all-features -D warnings,
workspace tests (1521 pass — 4 new symlink + 4 new env-file parser
+ 2 new run_serve overlay + 2 new config-validate path-guard tests
minus 2 obsolete load_into_process_env tests), Spin
wasm32-wasip2 check, docs prettier.
…al-impl

# Conflicts:
#	crates/edgezero-adapter-spin/src/cli.rs
#	crates/edgezero-cli/src/bin/check_no_nested_app_config.rs
#	crates/edgezero-cli/src/config.rs
…, dry-run diff, ConfigPushArgs source-compat

All six findings from the second review pass (@ChristianPavilonis)
addressed.

## P1a — Windows path shapes bypass containment

`Path::is_absolute()` only catches drive-absolute (`C:\foo`) on
Windows and Unix-absolute (`/foo`) on Unix. Two shapes slipped
through on every host, regardless of authoring platform:

- `\outside\spin.toml` — Windows rooted-without-drive; on Unix
  it looked like a relative component with embedded backslashes.
- `D:outside\spin.toml` — Windows drive-relative
  (`D:`'s cwd); `Component::Prefix` only fires on Windows.

Path safety now rejects any raw string that starts with `\` or
`/` and any string matching a byte-level Windows drive prefix
(`[A-Za-z]:`), regardless of host OS. Belt-and-braces:
`Component::Prefix(_)` is also rejected. 5 new regression tests
covering rooted-no-drive, forward-slash rooted, drive-relative,
drive-absolute, and a legitimate mid-string-backslash accept case.

## P1b — Cloudflare retry loses existing namespace IDs

If attempt 1 creates namespace A then fails on B, the outcome is
discarded. On retry, A is skipped (already present in
wrangler.toml) but was NOT recorded in `created_kv_ns`, so
`ProvisionOutcome.deployed` only carries B. That permanently
drops A from `[adapters.cloudflare.deployed].kv_namespaces` in
edgezero.toml; teammates cloning fresh silently point at a
missing namespace.

Fix: when skipping an already-provisioned namespace, ALSO record
its id in `created_kv_ns` so the outcome surfaces the full set
(existing + newly-created). Regression test
`skipped_existing_namespace_ids_still_surface_in_deployed_outcome`
seeds a real id and asserts `outcome.deployed.sub_tables
["kv_namespaces"][logical]` matches.

## P1c — Nested Spin manifest emits wrong wasm relative path

`synthesise_spin_toml` hard-coded `../../target/...` — correct
for the 2-deep scaffold convention `crates/<crate>/spin.toml` but
WRONG for nested layouts like `crates/spin-server/config/spin.toml`
(needs `../../../target/...`; `../../target/` resolves to
`crates/target/`, not the workspace's `target/`).

New `workspace_relative_target_prefix(manifest_rel)` computes the
prefix from the depth of `manifest_rel.parent()`. The synth
signature grows a `manifest_rel: &Path` arg; the caller in
`cli/mod.rs` passes `spin_rel` through. Existing tests updated
to pass `Path::new("crates/x/spin.toml")` (or the seeded manifest
path) to keep the same emitted prefix. Nested-manifest regression
test tightened to assert the exact 3-deep `../../../target/...`
prefix (previously only checked the basename, masking the bug).

## P2a — Cloud dry-run suppressed the edgezero.toml writeback diff

`merge_deployed_into_manifest` returned silently in dry-run.
Fastly's cloud dry-run can discover an existing `service_id` via
a read-only `fastly service describe`, and the corresponding
real run mutates tracked `edgezero.toml` without any preview.

Dry-run now renders the original-vs-updated unified diff via
`similar::TextDiff` when the doc changes, or logs a "no change"
line otherwise. Existing dry-run test still passes (still no
file mutation), just now with a log line the operator can read.

## P2b — Nested Axum synth emits crate_dir="." (loader breaks)

For `crates/server/config/axum.toml`, the synthesiser found the
crate name via the upward walk but still emitted `crate_dir = "."` —
which points the axum loader at `crates/server/config/Cargo.toml`
(manifest parent), not `crates/server/Cargo.toml` (crate root).
`edgezero serve --adapter axum` then errored out with
"expected Cargo.toml next to the manifest".

New `cli_support::read_adapter_crate_root` returns the DIRECTORY
holding the discovered Cargo.toml (sibling of the existing
`read_adapter_crate_name`). `derive_axum_crate_dir` in
`edgezero-adapter-axum/src/cli/mod.rs` computes the relative
path from the manifest's parent to the crate root — `"."` for
scaffold-convention, `".."` (or deeper) for nested. The synth
gains a `crate_dir: &str` arg. Two regression tests: nested
manifest emits `crate_dir = ".."`; scaffold-convention manifest
still emits `crate_dir = "."`.

## P2c — ConfigPushArgs source break (`no_diff` / `no_env`)

Prior commit hoisted `no_diff` / `no_env` under a nested
`ConfigPushSuppressions` sub-struct to satisfy
`clippy::struct_excessive_bools`, breaking every downstream CLI
that constructs `ConfigPushArgs` via `Default::default()` +
field assignment (`args.no_diff = true`) with E0609.
`#[non_exhaustive]` does not insulate against moving or
removing existing public fields.

Fields restored as top-level on `ConfigPushArgs`. Total bool
count is now 5, so the struct gets a per-item
`#[expect(clippy::struct_excessive_bools)]` with a reason line
pinning the source-compat rationale. `ConfigPushSuppressions`
kept as an empty compat-stub for any out-of-tree code that
reached for it. Call sites (config.rs, args.rs tests, app-demo
test) updated back to `args.no_diff` / `args.no_env`.

All CI gates locally clean: fmt, clippy `--workspace
--all-targets --all-features -D warnings`, workspace tests (1622
pass), Spin wasm32-wasip2 check, examples/app-demo workspace
tests.
@aram356

aram356 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Review responses — first-pass findings

The two summary-body findings without inline threads:

Blocking — Apply path safety to config validate. Resolved in 7a92e57 (crates/edgezero-cli/src/config.rs, run_adapter_shared_checks). Added assert_provision_paths_safe(manifest_root, adapter.manifest, adapter.crate) per-adapter inside run_adapter_shared_checks BEFORE each adapter's validate_adapter_manifest runs — same guard push/diff/provision already use. run_config_validate and run_config_validate_typed now share the minimum-bar containment gate. Two regression tests: raw_rejects_absolute_adapter_manifest_path_before_adapter_validators_run, raw_rejects_parent_traversal_adapter_manifest_before_adapter_validators_run.

Non-blocking — Document Axum's generated-manifest workflow. Resolved in 7d6d886 (docs/guide/adapters/axum.md). Added a warning block after the manifest description explaining that axum.toml is provision-generated and gitignored since the 2026-07 amendment, with the regeneration command <app>-cli provision --adapter axum --local and a note that the Axum blueprint has no scaffold .hbs template so scaffold and clean-clone produce byte-identical output.

Individual replies posted on each of the six second-review inline threads (P1a Windows paths, P1b CF retry, P1c Spin nested wasm, P2a dry-run diff, P2b Axum crate_dir, P2c ConfigPushArgs source break). All CI gates locally clean; branch at e0688aa on top of the main merge 3f340ef.

@prk-Jr
prk-Jr self-requested a review July 14, 2026 06:21

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 LGTM

aram356 added 17 commits July 16, 2026 08:15
…al-impl

# Conflicts:
#	crates/edgezero-adapter-axum/src/cli/run.rs
#	crates/edgezero-adapter-cloudflare/src/cli.rs
#	crates/edgezero-adapter-fastly/src/cli.rs
#	crates/edgezero-adapter-spin/src/cli.rs
#	crates/edgezero-adapter-spin/src/cli/push_sqlite.rs
#	crates/edgezero-cli/Cargo.toml
#	crates/edgezero-cli/src/adapter.rs
#	crates/edgezero-cli/src/config.rs
#	crates/edgezero-cli/src/generator.rs
#	crates/edgezero-cli/src/provision.rs
`push_config_entries` built its starting map with a `_ => BTreeMap::new()`
catch-all over `fs::read_to_string`, so every read failure -- not just
NotFound -- silently produced an empty map. The `fs::write` that follows
then replaced the file with only the current push's entries, dropping
every sibling blob the operator had already pushed (e.g. losing
`app_config` when pushing `app_config_staging`).

Only two cases legitimately start empty: the file does not exist, or it
exists and is blank. Invalid UTF-8, permission-denied and transient I/O
errors now propagate with an error that names the file and says the push
was refused rather than overwriting it.

Regression test seeds an unreadable (invalid UTF-8) local-config file and
asserts the push errors AND leaves the file byte-identical.
Blocking #1 -- symlink escapes in gitignored local state.

`.edgezero/` and the env files are gitignored, so a symlink planted in
either never surfaces in review, and both write paths followed it:

- `env_file::append_lines_dedup_with_header` used `path.exists()` +
  `fs::write`, which follow a symlinked final component. A planted
  `.env -> ~/.ssh/authorized_keys` had provision write attacker-chosen
  `KEY=value` lines into the target, and `set_restrictive_mode` then
  chmod 0600'd the victim's file. A DANGLING link is worse: `fs::write`
  creates the target. Now rejected via `symlink_metadata`, which does
  not follow links.
- `ProvisionLock::acquire` ran `create_dir_all` + `OpenOptions::create`
  on `.edgezero/provision.lock`, taking an flock on -- and holding a
  writable descriptor to -- a file outside the tree.
- The dry-run staging copy vetted symlinked entries *inside* the tree it
  walked but not the root it was handed, so a symlinked `.edgezero`
  would have it copy e.g. `~/.aws` into the operator-visible staging dir.

`path_safety::reject_symlink_components` already implemented the bounded
component walk for manifest-declared paths; it is now pub(crate) with a
field-agnostic message and reused by the two CLI sites. `env_file` lives
in `edgezero-adapter` and has no project root to bound a walk against,
so it guards its final component only -- the parent chain remains the
resolving caller's job.

Blocking #5 -- lossy derivations collapsing two values onto one target.

Two independent axes, both silent and both serving the wrong value:

- Fastly derives each secret's Viceroy env var as
  `key.to_ascii_uppercase()`, and `upsert_secret_store_entry` dedups on
  the exact key -- so `api_token` and `API_TOKEN` produced two separate
  `fastly.toml` rows that BOTH read `$API_TOKEN`. Now caught by
  `validate_typed_secrets`, which was a no-op stub. Cloudflare's stub is
  correct and stays: it never derives anything from the secret key.
- Cloudflare AND Fastly both upper-case `store.logical` into
  `EDGEZERO__STORES__<KIND>__<LOGICAL>__NAME`. TOML keys are
  case-sensitive, so `[stores.kv.myStore]` and `[stores.kv.MYSTORE]` are
  two real stores emitting one variable, and env_file's dedup silently
  dropped the loser -- leaving that store pointed at the other's
  platform name. Guarded once in `ProvisionStores`, called by both.
  Kind is part of the variable name, so cross-kind id reuse still works.

Tests cover each escape and each collision, asserting the victim file is
byte-identical / the link target was never created, plus the negative
cases (exact duplicate ids, same id across kinds, one key in two stores)
so the guards don't over-reject.
`fastly.toml` is gitignored (per-machine); `edgezero.toml` is committed
(shared). The two provision paths move `service_id` in OPPOSITE
directions -- local pins the tracked id INTO fastly.toml, cloud captures
fastly.toml's id back OUT into the tracked block -- and nothing
arbitrated between them: the CLI hard-coded `deployed: None` on the
cloud arm, so the adapter could not see the tracked value, and
`merge_deployed_into_manifest` does a plain `insert`.

The read-back therefore always won. A developer whose gitignored
fastly.toml was left over from another service ran `edgezero provision`
and silently rewrote the id the whole team deploys against -- with no
diff to review, since the file that sourced it is not in git.

The read-back is not gratuitous, though: it is the intended bootstrap
(`fastly compute deploy` creates the service and writes its id into
fastly.toml, provision records it for the team), so "tracked always
wins" would break first-time capture. Thread the tracked state into the
cloud arm and arbitrate on it instead:

- tracked absent          -> capture (the bootstrap case, unchanged)
- tracked == fastly.toml  -> no writeback; returning the identical value
                             would report a no-op edit and render a
                             dry-run diff on every cloud run
- tracked != fastly.toml  -> error naming both ids and both remedies

The conflict arm errors rather than picking a side because neither is
trustworthy automatically: the local file may be stale, or the service
may have been legitimately re-created. The operator decides.

The two existing read-back tests already passed `deployed: None`, so
they keep passing and now pin the bootstrap case precisely; new tests
cover the match and conflict arms.
The CLI runs `build`/`deploy`/`serve`/`auth` two ways: if the manifest
sets `[adapters.<name>.commands].<action>` it spawns that shell command
with the project root as cwd and the resolved child env (bind hints,
`[environment.variables]`, the provision-written `.env` overlay); if the
command is unset it falls back to the registered adapter's `execute`.

The fallback received only the action and passthrough args, so
everything the shell path applies was dropped: a `serve` there started
the app with none of its `.env` secrets and resolved its manifest from
the process cwd instead of the project root (blocking #3). It is
reachable whenever a hand-written manifest omits `commands.serve`
(scaffolded projects always set it).

`Adapter::execute` now takes an `AdapterExecContext` carrying the
manifest root + fully-resolved child env. Each adapter's `run::{build,
deploy,serve}` seeds manifest discovery from `ctx.cwd()` (via the new
`cli_support::discovery_base`) and applies `ctx.env()` to the spawned
vendor CLI; the `auth` arms shell out to a globally-scoped login and
ignore it. An empty context is inert, so the shell path and every
existing call are unchanged.

Env precedence + the required-secrets assertion were the shell path's
private `apply_environment`; both moved into a shared `build_child_env`
so the two paths cannot drift. That move also fixes two adjacent bugs:

- #7: required `[environment.secrets]` were checked against the parent
  env BEFORE the `.env` overlay was applied, so a secret supplied only
  by the provision-written `.env` was falsely reported missing and
  `serve` refused to start. The check now sees the same resolved set the
  child will.
- #9 (Spin half): the fallback `spin up` omitted
  `--runtime-config-file`, which the shell path passes, so local KV
  bindings provision wrote were absent. `run::serve` now passes it when
  `runtime-config.toml` exists.

Trait doubles and call sites updated; tests cover the context apply, the
inert default, overlay-as-secret-source, and the precedence arms.
Spec-alignment (findings #1, #6, #7): synthesised manifests now match
the spec's normative baselines exactly, verified by per-adapter
exact-content tests.
- Cloudflare drops the `[build]` table; Fastly drops the empty
  `authors` array; Spin drops the `[component.<id>.build]` table.
- Spin `allowed_outbound_hosts` becomes opt-in via
  `[adapters.spin.adapter].allowed_outbound_hosts`, defaulting to
  Spin's deny-all baseline. Both `edgezero new` and clean-clone read
  the same manifest, so the emitted file stays byte-identical.
- Fastly cloud `provision` no longer auto-captures `service_id` from
  the gitignored, per-machine `fastly.toml`. Per the v1 contract the
  writeback is a documented one-time manual copy after `fastly compute
  deploy`; auto-capturing it let a stale local file overwrite the
  team's committed id.

Correctness (findings #2, #3, #4, #8):
- Cloudflare cloud `provision` reconciles namespace ids against the
  tracked `[adapters.cloudflare.deployed]` block: a local id that
  disagrees aborts with a conflict error instead of silently
  overwriting the committed one, and a fresh clone whose wrangler.toml
  lacks the id has it restored rather than creating a duplicate remote
  namespace.
- Fastly typed-secret provisioning seeds the Viceroy
  `[[local_server.secret_stores.<name>]]` table under the resolved
  PLATFORM name (`TypedSecretEntry.platform`), the name the runtime
  opens, instead of the raw logical id -- an env override no longer
  points the runtime at a store the seed never created.
- Symlink guards extended to the remaining provision/push write sites:
  `write_baseline_to_disk`, Axum's local-config JSON, and Spin's local
  KV SQLite db. `env_file::reject_symlinked_target` is now the shared
  final-component check.
- Deployed-field ownership is enforced in core `Manifest` validation
  (canonical adapter map), so reduced-feature builds and non-CLI
  readers reject a field placed under an adapter that does not own it,
  not just the CLI's registry-based check.

Also removes planning/review provenance markers (PR-round, Task, Stage,
Phase, review-priority) from code comments, and corrects the CHANGELOG
lock-path reference.
Closes the two remaining review mediums.

Push→provision coverage (spec §"Push after provision leaves provision
artifacts intact"): each adapter now has a concrete
`push_after_provision_preserves_*` test that seeds an operator secret
into the provision-written file, runs `config push --local`, and asserts
that file is byte-for-byte intact:
- Axum: `.edgezero/.env` (push writes local-config JSON).
- Cloudflare: `.dev.vars` (push shells `wrangler kv bulk put` via the
  fake-wrangler shim -- no network).
- Fastly: `[[local_server.secret_stores.<id>]]` in fastly.toml (push
  writes `[local_server.config_stores.*]`).
- Spin: the Spin-side `.env` (push writes the local KV SQLite db).

Migration docs: the generated README and getting-started/spin guides
told operators to run the not-yet-installed project CLI on a fresh clone
and to hand-edit the generated per-adapter binding. Both contradict the
workflow -- the CLI is built from source (`cargo run -p <cli> --`) and
provision owns the adapter binding. Corrected the README template, the
getting-started and spin adapter guides, and the cli-walkthrough
preamble, and fixed the remaining `.edgezero-provision.lock` ->
`.edgezero/provision.lock` reference. CHANGELOG updated to note the
coverage gap is closed.
The spec's rerun-to-refresh workflow says an operator who changes
`[adapters.spin.adapter].component` out of phase with an
already-synthesised `spin.toml` re-runs `provision --local` to refresh.
But provision rejected it: `resolve_component_id` errored when the
selector matched no existing component, and `validate_adapter_manifest`
(which provision runs first) rejected it before provision could update
anything -- so the manifest could never be refreshed.

For a single-component `spin.toml`, provision now renames the sole
`[component.<old>]` table to the selector and repoints
`[[trigger.http]].component` at it (Spin's loader rejects a trigger
that names a component with no matching block, so both edits happen
together). Validation allows the transient mismatch for the
single-component case so provision can proceed. A non-matching selector
against MULTIPLE components stays a hard error -- provision can't infer
which to rename.

Tests: base `provision` and `provision_typed` both rename the sole
component and repoint the trigger; validation allows the single-
component refresh but still rejects the ambiguous multi-component case;
the CLI `config validate` test is updated to match.
Round-11 review, blockers and mediums:

- Spin `read_sqlite_entry` opened the db read-write and ran `CREATE
  TABLE`, so a nominally read-only `config diff --local` / push preflight
  dirtied the file. It now opens READ_ONLY and runs no DDL; a schema-less
  db reads as `MissingStore`.
- Spin's local-KV path guarded only the final db component. A symlinked
  INTERMEDIATE (`.spin`) would let `create_dir_all` / open escape the
  project tree. New `env_file::reject_symlink_components` walks every
  component from the manifest root down; applied on both the write and
  the read resolution.
- Cloudflare cloud provision no longer promotes an unverified namespace
  id read from the gitignored per-machine `wrangler.toml` into tracked
  deployed state. Durable ids come from `wrangler kv namespace create`
  output only; a local id that matches tracked is a no-op, one that
  conflicts errors, and one with no tracked entry is reported, never
  silently made the team source of truth.
- `config validate` stays strict on a Spin component-selector mismatch
  (reports the inconsistency); the single-component refresh is now a
  provision-only transition via a new `allow_component_refresh` flag on
  `validate_adapter_manifest`, so provision can rename while the static
  check does not weaken.
- Fastly cloud dry-run checks the `[setup.*]` skip BEFORE reporting, so a
  dry-run models the real run instead of claiming "would create" for a
  store that already exists.
- Docs: Fastly/Cloudflare primitive snippets match the synthesised
  baselines; Fastly documents the one-time `service_id` copy; the
  migration guide gains the guarded `git rm --cached` untracking step.
…push tests

Round-11 review, remaining findings:

- The registry-fallback `execute` rediscovered each adapter's
  per-platform manifest by scanning the workspace, which can pick the
  wrong manifest in a nested / multi-app layout or follow a symlink off
  the validated tree. `AdapterExecContext` now carries the declared,
  root-resolved `[adapters.<name>.adapter].manifest`, and every
  adapter's `run::{build,deploy,serve}` uses it verbatim (via
  `cli_support::declared_or_discovered_manifest`) instead of scanning
  whenever a manifest was loaded.
- Managed TOML value updates used `Table::insert`, which replaces the
  whole item and drops a trailing inline comment on the line. Cloudflare
  `id`/`preview_id`, Fastly root `service_id`, and the
  `[adapters.<name>.deployed]` writeback now update the value in place
  and clone the existing decor, honouring the byte-preserving merge
  contract.
- The push-after-provision contract tests manually constructed the
  secret file, so a provision-output-shape regression could slip past
  them. All four (Axum/Cloudflare/Fastly/Spin) now run the real
  `provision_typed` to write the file, let the operator fill in a value,
  then push and assert it survives -- exercising the actual composition.
Blockers:

- Apply the adapter declared-path safety guard to `build` and `deploy`,
  not just `serve`, via a shared `assert_adapter_declared_paths_safe`.
- Anchor `wrangler kv namespace create` to the resolved wrangler.toml
  with `--config` so it can't act on an unrelated config.
- Stop losing namespace ids when a later store fails mid-provision:
  `ProvisionOutcome` now carries `error`, per-store work moved into
  `provision_one_kv_store`, and the id is recorded before the writeback
  so a create-success/upsert-failure still checkpoints. The CLI persists
  `deployed` first, then surfaces the error.
- Refresh the Spin component selector even when no KV/config stores are
  declared, so a secrets-only app doesn't leave a manifest that fails
  strict validate.
- Reject distinct (store, key) pairs that collide on the same uppercased
  Fastly env var, including the same key across two stores.
- Fix the migration untracking snippet: match adapter manifests and
  `.dev.vars` at the repo root as well as subdirectories (a `**/` pathspec
  skips root files), and keep the pipeline NUL-delimited.

Also:

- `config push --local` for Fastly now only upserts keys into a contents
  table provision already created, instead of fabricating the manifest
  structure it doesn't own.
- Refuse Fastly chunk keys that exceed the 256-character Config Store key
  limit rather than emitting keys the platform rejects.
- Treat a present-but-non-string `path`/`url`/`type` in Spin's
  runtime-config as malformed instead of silently falling back to the
  default backend, and preserve inline-comment decor when the component
  rename repoints a trigger.
- Drop the legacy `.crate`/root fallback when resolving Spin's serve
  `.env`; it contradicts the required-`.manifest` rule and could read the
  wrong directory.
- Assert the local dry-run actually succeeds for every adapter, and cover
  staged-tempdir path rewriting directly against `render_dry_run_report`.
- Seed the Fastly and Cloudflare config stores in the config smoke before
  booting the emulator, and correct the adapter-manifest baselines in the
  docs (the synthesised `name` is the adapter crate's package name) plus
  the fresh-clone CLI invocations.
Blockers:

- Refuse registry-fallback dispatch when a loaded manifest declares an
  adapter without `[adapters.<name>.adapter].manifest`. Previously the
  path guard was a no-op and the exec context carried no manifest, so
  every adapter fell back to a recursive, symlink-following workspace
  scan that could select an unrelated or out-of-tree project.
- Thread the resolved `.crate` root through `AdapterExecContext`. The
  Cloudflare, Fastly, and Spin build paths assumed `Cargo.toml` sat
  beside the platform manifest, so a nested `crates/server/config/
  spin.toml` resolved `config/Cargo.toml` and failed the build.
- Refuse Fastly cloud provisioning when fastly.toml is absent. On a
  clean clone it creates the remote stores first, then materialises a
  `[setup.*]`-only manifest that `fastly compute build` rejects, leaving
  the stores orphaned. Cloud dispatch also now receives the tracked
  deployed state, so the resource-link remediation still fires when only
  `[adapters.fastly.deployed].service_id` knows the service is deployed.
- Validate a chunk pointer's declared lengths before allocating.
  `envelope_len` is untrusted store data and reached
  `String::with_capacity` directly, so a corrupt pointer aborted the
  runtime instead of returning an integrity error.

Also:

- Preserve `ProvisionOutcome::error` across baseline prepending and the
  typed dry-run merge; both rebuilt the outcome from status lines and
  deployed state alone, reporting a failed local provision as success.
- Enforce the 256-character Config Store key limit on the direct-value
  path, not just on chunk keys.
- Make Fastly's dry-runs model the real operation: local push now probes
  the provision-owned contents table read-only, and cloud provisioning no
  longer claims it would create an `edgezero_runtime_env` store whose
  setup block already exists.
- Build `axum.toml` through `toml_edit` instead of raw interpolation, and
  reject typed secret keys that cannot round-trip through a `<key>=` line
  (`partner=token` previously emitted `partner=token=`).
- Point the config smoke at `/config/typed`, which reflects the single
  BlobEnvelope `config push` writes; the per-key assertions only passed
  against hand-seeded pre-cutover emulator state.
- Save and restore `.dev.vars` in the secret and key-override smokes
  rather than truncating and deleting it: provision writes only empty
  placeholders, so it is not regenerable.
- Drive clean-clone manifest regeneration through the typed provision
  entry point a generated CLI actually calls, and drop the unused
  `ConfigPushSuppressions` compatibility stub.
Blockers:

- Split the typed dry-run helper so `run_local_dry_run_typed` clears the
  workspace `too_many_lines` clippy lint (the `-D warnings` CI gate was
  failing), and thread the staged provision's partial-failure error out
  so a dry-run over a broken provision exits non-zero.
- Derive the axum nested `crate_dir` lexically instead of via
  `canonicalize()`: the old code required the manifest's parent dir to
  exist, so on a fresh clone it stayed relative while the crate root went
  absolute, `strip_prefix` failed, and it emitted "." where a nested
  manifest needs "..".
- Anchor the adapters' `cargo build` at the crate root (`current_dir`) so
  dispatching through an absolute `EDGEZERO_MANIFEST` from outside the
  project no longer picks up the caller's `.cargo/config.toml` or
  resolves relative args against the wrong directory.
- Make `resource_link_note` treat the tracked
  `[adapters.fastly.deployed].service_id` as the durable authority:
  prefer it over the gitignored fastly.toml and refuse on conflict,
  rather than recommending a link to whatever the local file names.

Smoke fixes:

- Config smoke: pass `--yes` on every `config push` (was prompting /
  failing without a TTY) and seed the mandatory `demo_api_token` secret
  per adapter so `/config/typed`'s secret walk resolves.
- Secrets smoke: inject the `SMOKE_SECRET` declaration into the generated
  fastly.toml / spin.toml before boot (provision only declares typed
  secrets), so the Fastly and Spin arms resolve it on a clean clone.
- Override smoke: stop appending a duplicate `demo_api_token` secret-store
  entry (warm-up already wrote it) and back up fastly.toml, which boot
  mutates in place.

Also:

- Refresh the app-demo Cargo.lock for axum's new `toml_edit` dep so
  `--locked` passes.
- Require `[adapters.<name>.adapter].crate` alongside `.manifest` on the
  registry-fallback path, and propagate the untyped dry-run's error.
- Emit the generated-CLI secret-placeholder follow-up after a bundled
  `provision --local`, and report Spin provisioning per touched file
  instead of one line naming only spin.toml.
- Correct the spec (no `.hbs` templates back the adapter manifests) and
  the plan (keep the `.dev.vars` backup; only manifest backups are
  obsolete).
Blockers:

- Reject absolute / `..` SQLite `path`s and symlinked runtime-config.toml
  in Spin local writes, so `--local` state can't escape the project tree.
- Refuse a Spin `--local` push whose label declares a non-SQLite backend
  (redis / azure_cosmos / unknown) instead of seeding a database `spin up`
  never reads.
- Edit inline-table deployed state via `TableLike`, so a valid
  `deployed = { kv_namespaces = { … } }` manifest can be checkpointed
  instead of failing writeback and stranding a created cloud resource.
- Reconcile the Fastly `service_id` (tracked vs local, with conflict
  refusal) in PREFLIGHT, before any store is created or `[setup]` written,
  and so dry-run surfaces the conflict too.
- Guard the axum serve `.env` chain with `reject_symlink_components`: a
  symlinked `.edgezero` / `.env` can no longer inject env into the child.
- Enforce adapter-declaration consistency on the manifest
  `commands.<action>` shell path too, so a half-declared adapter that
  provision and the registry fallback reject can't slip through
  build/deploy/serve.

Smoke:

- Back up fastly.toml before the oversized override section mutates it,
  and run `restore_backups` even when `stop_server` returns non-zero
  under `set -e` (it previously short-circuited the restore).
- Thread the authoritative `.crate` path into `synthesise_baseline_manifest`
  so a nested package can't be mis-selected: adapters now derive the
  crate name from the declared crate root, falling back to the ancestor
  Cargo.toml search only when it's undeclared.
- Resolve a relative `CARGO_TARGET_DIR` against the crate dir cargo built
  in (the build runs with `current_dir(crate_dir)`), not the CLI's
  process cwd, so Cloudflare/Fastly artifact discovery finds the wasm.
- Emit the commented `__KEY` hint for a CONFIG store added on Fastly's
  additive provision path, so incremental provisioning converges to the
  same shape a clean provision produces.
- Dedup batched env-file entries against each other, not just the file,
  so duplicate typed-secret keys can't leave a trailing blank-valued
  placeholder that wins at load.
- Repoint EVERY Spin trigger type on a component rename, not just
  `[[trigger.http]]`, so a redis/other trigger can't keep a stale ref.
- Narrow the config-push lock: release it across the interactive consent
  prompt (re-acquired for the write, which rechecks remote state) so a
  human at the prompt can't block peers; and take the lock in `run_deploy`
  since `fastly compute deploy` writes `service_id` into fastly.toml.
- Report provision writes by what actually landed: Axum and Spin no
  longer count deduped-away candidates as writes (`append_lines_dedup`
  now returns whether it wrote).
- CI: add `--locked` to the app-demo test, and assert the adapter
  manifests + `.dev.vars` are actually gitignored (`git check-ignore`),
  not merely currently untracked.
Blockers:

- Edit inline `adapters = { … }` state via TableLike at the TOP level
  too, so a fully-inline adapters tree can be checkpointed instead of
  rejected after remote creation.
- Preflight a missing wrangler.toml in Cloudflare cloud provision, before
  any `wrangler kv namespace create`, matching Fastly -- a clean clone no
  longer orphans namespaces behind a manifest that never declared them.
- Thread the manifest deploy command into `config diff`'s push context
  (as push already does) so a Fermyon Cloud Spin config reports the
  Cloud-unsupported signal instead of reading local SQLite.
- Reject a Spin `--local` SQLite db that resolves OUTSIDE the crate even
  when the `path` string is clean-relative: `--runtime-config` can point
  the anchoring directory off-tree (e.g. `/tmp`).
- Guard the axum serve `.edgezero` chain UNCONDITIONALLY, not only when
  `.env` exists -- the config store reads `local-config-*.json` there at
  request time, so a symlinked `.edgezero` must be refused with no `.env`.
- Back up fastly.toml (and `.dev.vars`) BEFORE the config smoke's warm-up
  and push mutate them, and install the cleanup trap first, so a run
  never leaves a developer's tree changed.

Also:

- Resolve `CARGO_TARGET_DIR` for artifact lookup from the same ctx env
  the build used (not just the process env), so a manifest-set custom
  target dir is found.
- Make Spin's local read/diff reject Redis/Azure/unknown backends via the
  same helper the write path uses, instead of diffing stale default
  SQLite.
- Stop taking the non-reentrant provision lock in `run_deploy`: the
  deploy command is arbitrary and may itself invoke provision/config
  push in a child process, which would self-deadlock.
- Anchor axum `crate_dir` on the declared `.crate` (like the crate name),
  so a nested package can't produce an internally inconsistent axum.toml.
- Align the spec gitignore example and the plan's axum step with the
  current contract: axum.toml is a provision-generated, gitignored
  manifest.
Provisioning and push:
- Fastly cloud provision preflights the entire [setup] writeback shape
  before any `*-store create`, so a malformed-but-valid manifest aborts
  before orphaning a remote store (and dry-run models the real outcome).
- Cloudflare cloud provision preflights every store's deterministic
  id-conflict and writeback-shape checks before the first `wrangler`
  call, so a later store's knowable conflict can't strand a namespace an
  earlier store already created.
- Spin build artifact discovery reads CARGO_TARGET_DIR from the ctx env
  the build actually used, not just the process env.
- A declared `[adapters.<name>.adapter].crate` with an unreadable
  Cargo.toml is now a hard error rather than a silent fallback to
  ancestor discovery.
- Idempotent env-file provisioning repairs loose (0644) permissions to
  0600 even when there is nothing to append; dry-run leaves mode alone.
- A missing `fastly` CLI is surfaced as an error instead of being
  misreported as a missing config store.
- Cloudflare config-push dry-run fails on a malformed wrangler.toml
  instead of masking it as an <unresolved> namespace; an absent or
  placeholder binding stays a lenient preview.

Axum:
- Fallback execution validates the resolved crate dir against the
  authoritative declared crate (or the workspace root), rejecting
  conflicts and symlink escapes.

Smoke scripts:
- Back up developer-local emulator state before warm-up provisioning
  mutates it, restore directories and files in reverse order, and
  distinguish an absent file from an existing empty one.

Docs:
- Reconcile the provision-local spec, scaffold README, and Spin guide
  with the hard-cutoff model: the synthesiser is the single writer for
  every adapter manifest, hand-edits are preserved on re-run, and
  `edgezero new` takes no --adapter flag.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment