diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 32040c79..9ecbff66 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -28,6 +28,7 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ + tests/fixtures/reusable-app/target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo- @@ -49,6 +50,12 @@ jobs: - name: Run cargo fmt run: cargo fmt --all -- --check + - name: Run cargo fmt (lifecycle fixtures) + run: cargo fmt --manifest-path tests/fixtures/reusable-app/Cargo.toml --all -- --check + + - name: Run cargo clippy (native lifecycle fixtures) + run: cargo clippy --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-harness -p fixture-core -p fixture-axum --all-targets -- -D warnings + - name: Run cargo clippy run: cargo clippy --workspace --all-targets --all-features -- -D warnings @@ -121,6 +128,7 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ + tests/fixtures/reusable-app/target/ key: ${{ runner.os }}-cargo-${{ matrix.adapter }}-clippy-${{ hashFiles('**/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo-${{ matrix.adapter }}-clippy- @@ -166,6 +174,18 @@ jobs: -D warnings done <<<"${FEATURE_SETS}" + - name: Clippy ${{ matrix.adapter }} lifecycle fixture + env: + ADAPTER: ${{ matrix.adapter }} + TARGET: ${{ matrix.target }} + run: | + target_args=() + if [[ "${TARGET}" != "native" ]]; then + target_args=(--target "${TARGET}") + fi + cargo clippy --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml \ + -p "fixture-${ADAPTER}" "${target_args[@]}" --all-targets -- -D warnings + format-docs: runs-on: ubuntu-latest defaults: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f5c6ac5..e0d5b4fd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,8 @@ on: push: branches: [main] pull_request: - branches: [main] + # Native-store PRs are currently stacked on the outbound HTTP specification. + branches: [main, docs/outbound-http-spec] permissions: contents: read @@ -29,6 +30,7 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ + tests/fixtures/reusable-app/target/ key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo-test- @@ -51,6 +53,7 @@ jobs: run: | cargo fetch --locked cargo fetch --locked --manifest-path examples/app-demo/Cargo.toml + cargo fetch --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml - name: No placeholder pins run: ./scripts/check_no_placeholder_pins.sh @@ -90,9 +93,42 @@ jobs: - name: Nested AppConfig checker tests run: cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config + - name: Lifecycle fixture host tests + run: | + cargo test --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-harness -p fixture-core --features fixture-core/qualification + node --test scripts/qualify_response_egress.test.mjs + + - name: Check Axum lifecycle fixture + run: cargo check --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-axum + + - name: Check opt-in lifecycle qualification fixtures + # These are compile-only identities, never deployed qualification evidence. + env: + EDGEZERO_QUALIFICATION_REVISION: "0000000000000000000000000000000000000000" + EDGEZERO_QUALIFICATION_ARTIFACT: "0000000000000000000000000000000000000000000000000000000000000000" + run: | + cargo check --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-axum --features qualification + cargo check --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-fastly --features qualification --bin fixture-fastly-qualification --target wasm32-wasip1 + cargo check --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-cloudflare --features qualification --target wasm32-unknown-unknown + cargo check --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-spin --features qualification --target wasm32-wasip2 + - name: Run workspace tests run: cargo test --workspace --all-targets + - name: Native store dependency isolation + run: bash scripts/check_native_store_dependency_contract.sh + + - name: Native AWS provider and startup contracts (loopback only) + run: | + cargo test --offline --locked -p edgezero-store-aws --no-default-features --features appconfig-agent --lib + cargo test --offline --locked -p edgezero-store-aws --no-default-features --features secrets-manager --lib + cargo test --offline --locked -p edgezero-store-aws --no-default-features --features dynamodb-kv --lib + cargo test --offline --locked -p edgezero-store-aws --all-features --lib + scripts/run_test_nonzero.sh production_runner_loads_envelope_and_real_sdk_secret_before_serving_mixed_stores cargo test --offline --locked -p edgezero-adapter-axum --features aws-stores,test-utils --test native_aws_startup + + - name: Bundled demo logging contract + run: scripts/run_test_nonzero.sh demo_respects_off_without_hiding_terminal_errors cargo test --offline --locked -p edgezero-cli --features demo-example --test demo_logging + - name: Run native outbound adapter contracts run: | scripts/run_test_nonzero.sh batch_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test contract @@ -123,6 +159,24 @@ jobs: bash scripts/check_adapter_feature_matrix.sh "${adapter}" native done + - name: Generated native AWS startup (loopback only) + env: + EDGEZERO_GENERATED_TEST_TARGET_DIR: ${{ github.workspace }}/target + run: scripts/run_test_nonzero.sh --ignored generated_cli_build_features_and_native_aws_startup_are_proven_together cargo test --offline --locked -p edgezero-cli --test native_store_generated_proof + + - name: Generated native DynamoDB KV (loopback only) + env: + EDGEZERO_GENERATED_TEST_TARGET_DIR: ${{ github.workspace }}/target + run: scripts/run_test_nonzero.sh --ignored generated_cli_default_off_and_enabled_dynamodb_crud_list_ttl_restart cargo test --offline --locked -p edgezero-cli --test dynamodb_generated_proof + + - name: Native AWS integration runner tests + run: node --test scripts/native_aws_test_admin.test.mjs scripts/run_native_aws_integration.test.mjs + + - name: Generated native AWS independent service contracts + env: + EDGEZERO_GENERATED_TEST_TARGET_DIR: ${{ github.workspace }}/target + run: node scripts/run_native_aws_integration.mjs + - name: Verify a generated project compiles run: scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test --offline --locked -p edgezero-cli --test generated_project_builds @@ -188,6 +242,7 @@ jobs: ~/.cargo/git/db/ ~/.wasmtime/bin/ target/ + tests/fixtures/reusable-app/target/ key: ${{ runner.os }}-cargo-${{ matrix.adapter }}-${{ hashFiles('**/Cargo.lock', '.tool-versions') }} restore-keys: | ${{ runner.os }}-cargo-${{ matrix.adapter }}- @@ -323,3 +378,19 @@ jobs: - name: Check ${{ matrix.adapter }} wasm feature matrix run: bash scripts/check_adapter_feature_matrix.sh ${{ matrix.adapter }} ${{ matrix.target }} + - name: Fastly reusable HTTP smoke + if: matrix.adapter == 'fastly' + run: ./scripts/smoke_test_reusable_app.sh --adapter fastly --suite smoke + + - name: Preserve Fastly lifecycle evidence + if: always() && matrix.adapter == 'fastly' + uses: actions/upload-artifact@v4 + with: + name: fastly-lifecycle-evidence + path: tests/fixtures/reusable-app/.runs/ + include-hidden-files: true + if-no-files-found: ignore + retention-days: 7 + + - name: Check ${{ matrix.adapter }} lifecycle fixture + run: cargo check --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-${{ matrix.adapter }} --target ${{ matrix.target }} --all-targets diff --git a/CHANGELOG.md b/CHANGELOG.md index 518b032a..0473fa46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,272 @@ ## Unreleased -### Outbound content encoding +### Portable HTTP And Request Lifecycles (PR #275) -`ContentEncoding::Passthrough` is now `Passthrough(PassthroughReason)`, and the public decoder -budget is now `max_decoder_bytes`. A single zlib-wrapped `deflate` response is decoded and its -compressed headers are removed, while stacked, unsupported, and malformed values retain their -typed passthrough reason. This is a hard API migration with no compatibility aliases. +The changes below cover [PR #275](https://github.com/stackpop/edgezero/pull/275), including its +implementation and review follow-ups. This is a hard API migration: retired APIs and enum variants +are removed rather than retained as compatibility aliases. Provider limitations are published +separately from the portable API and must be considered when declaring required capabilities. + +### Bounded Local Config Snapshots + +- Hard-cut `ConfigStore::{get, get_bounded}` and matching handle APIs from owned `String` + to shared `ConfigValue` UTF-8 payloads across all adapters and fixtures. Borrow via + `as_str()`/`as_ref()`; ownership copies are explicit. Debug output is redacted. +- Load Axum immutable local snapshots before listener binding, with validated finite + `EDGEZERO__ADAPTER__CONFIG_STORE__*` file/entry/key/value/resident/startup budgets. + Enforce actual reads and decoder/index/payload staging charges, reject duplicate keys, + fail startup on allocation-limit violations, and retain typed failed declared bindings. +- Make Axum `from_path`, `from_local_file` and `from_map` require `ConfigStoreLimits`; + `from_map` is now fallible. Publish `resident_allocation_bytes`; push updates require + server restart. Shared reads preserve independent byte caps and the application clock. +- Pin the decoder allocation audit to `serde_json 1.0.150`; stream canonical hashing + without full-output staging and release the extractor's raw handle before secret reads. +- Add native TCP proofs for suppressed-body completion ownership, stalled producer and + non-reading client deadlines, disconnect cleanup, exactly-once release and capacity + recovery. These are local evidence, not hosted provider qualification; broad config + allocation remains Unsupported and response egress remains BestEffort. + +### Bounded Provider Config Reads + +- Replace Cloudflare bounded KV text materialization with capped BYOB byte-stream reads, + reusable 16 KiB buffers and acquired-reader cancellation on overflow, timeout or drop. + Require valid UTF-8 without lossy expansion; raw `get` remains explicitly unbounded. + Cancel late-arriving streams after an abandoned acquisition, consume rejected cancellation + promises, and yield cooperatively between bounded groups of clock-checked reads. + Require runtime GC cleanup support before KV dispatch; demo/generated Workers enable + `enable_weak_ref` explicitly. GC retention is still excluded from allocation certification. +- Replace Fastly SDK auto-growing config reads with one caller-sized host-ABI buffer. + Reject over-cap chunk declarations before fetching or allocating reassembly; borrow + discriminator tokens rather than building a JSON tree. Bounded storage resolution checks + transport integrity; typed envelope verification remains behind the extractor's structural scan. +- Hard-cut `FastlyConfigStore::try_open` to return `ConfigStoreError` rather than the SDK's + error type. Reject duplicate pointer discriminators and preserve future-format overwrite guards. +- Publish narrow guest allocation charges and the remaining Spin/hosted evidence gaps. + Broad config allocation stays Unsupported and config deadlines stay BestEffort. +- Add opt-in, revision-pinned lifecycle qualification fixtures for all four standard entrypoints, + a bounded HTTPS probe runner and a fail-closed correlated ownership-evidence validator. + Compilation and local tests are not hosted qualification; no capability is promoted. + +### Outbound HTTP And Batches + +- Replace the proxy-client/service API with `HttpClient`, `OutboundHttpClient`, `OutboundRequest`, + and `OutboundResponse`. Standard adapters inject the client into request extensions; handlers + access it through `RequestContext::http_client()`. Missing manual wiring remains explicit. +- Add buffered and streamed uploads/responses, absolute deadlines, request-body bounds, explicit + cache policy, and HTTP authority override independent of URI routing and TLS identity where + supported. Automatic redirects and implicit provider response decoding are disabled. +- Request construction is fallible and validates absolute HTTP(S) targets. Dispatch accepts GET, + HEAD, POST, PUT, PATCH, DELETE, and OPTIONS; GET/HEAD reject nonempty bodies. Ordinary `Host` + headers are normalized away; use the explicit authority override instead. +- Default to a 1 MiB buffered response, an 8 MiB upload cap, a 32 MiB decoder budget, a 24-bit Brotli + window limit, and a 30-second exchange budget when no timeout/deadline is supplied. Response-mode + setters are last-write-wins; `stream_response()` opts into streamed delivery. Encoded, decoded, + header, and chunk limits are separate opt-in controls. +- Accept streamed upload sources under the request cap and deadline, but collect them before + provider dispatch on Axum and Cloudflare. Cloudflare no longer forwards upload chunks directly + to Fetch as the old proxy implementation did; this API does not promise wire-streamed uploads. +- Expose `OutboundCachePolicy::{PlatformDefault, Bypass}`. Bypass disables provider caching on + Cloudflare/Fastly and is a no-op on Axum/Spin, which do not install an outbound response cache. +- Add completion-driven fanout through `start_batch_until`, indexed `next`, explicit `cancel`, + ordered `collect`, and `send_all_until`. Batch slots require buffered requests and responses; + streaming exchanges use `send`. +- Return per-slot elapsed time and outcomes, preserving input indices while yielding observed + completion order. Timing uses one batch-entry snapshot and includes preflight, provider setup, + complete body collection, and guest-observation delays rather than claiming pure transport RTT. +- Define `observation_cutoff` as the point where batch observation stops, even if individual slots + retain request budget. Earlier cutoffs are valid partial-result operations, not request timeouts. +- Distinguish `Completed`, `Cutoff`, and typed batch failure. Only cutoff permits unresolved slots + in successful collection; `OutboundBatchFailure` retains both the precise error and previously + collected, index-aligned slots. Cancellation releases the driver and returns unresolved indices. +- Require clean driver EOF before `Completed`, including zero-slot drivers. Trailing failures, + duplicate/out-of-range indices, premature EOF, and cutoff after all slots resolve are rejected; + poisoned batches cannot resume driver polling. External adapters have a documented, public + `OutboundBatchDriverEvent` / `OutboundBatch::from_driver` construction protocol. +- Axum, Cloudflare, and Spin drive complete eligible exchanges concurrently. Fastly dispatches + eligible inputs before harvesting ready pending handles, preserves handle-to-slot identity, and + uses deterministic dynamic-backend identities that include exact timer budgets. An early + `BatchCutoff`-attributed provider phase timeout terminates only its slot unless the actual + observation cutoff has expired. +- Share the future-based batch driver through `OutboundBatch::from_futures` on Axum, Cloudflare, + and Spin. Preserve recorded terminal timestamps, ready-result retention, index validation, + and clean-EOF completion; Fastly retains its provider-handle driver. + +### Encoding, Limits, And Error Classification + +- Enforce independent encoded-transport, decoded-output, and final buffered-response caps. + Decoded caps apply to identity and EdgeZero-decoded bodies, not raw passthrough encodings. + Checks occur incrementally before appending excess output; response collection retains the + configured bounds and original absolute deadline. +- Share the response-body policy through `ResponseBodyPolicy` and `apply_response_body_policy` + across all four adapters. Declared lengths, encoded limits, decoding/header rewriting, decoded + limits, and rechunking retain their order; transport lifetimes remain adapter-owned. +- Add `into_bytes_bounded`, `into_bytes_bounded_until`, `json_bounded`, and `json_bounded_until` + for explicit response consumption. The `_until` helpers check the retained application clock + cooperatively; pending-stream cancellation still depends on the adapter's deadline wrapper. +- Add response-header byte/count caps with typed overflow origins. Accounting includes every + adapter-visible upstream field before normalization and the synthetic `x-edgezero-proxy` field. + Preserve repeated headers, including `Set-Cookie`, and strip hop-by-hop fields consistently. +- Decode single bare `gzip`, zlib-wrapped `deflate`, and `br` codings. Validate multi-member gzip + through transport EOF, reject trailing data after deflate/Brotli completion, and remove compressed + representation headers only when EdgeZero decodes. Request bodies retain their declared encoding + without implicit recompression. +- Replace bare `ContentEncoding::Passthrough` with `Passthrough(PassthroughReason)`, distinguishing + `Stacked`, `Unsupported(token)`, and `Malformed` while retaining encoded bodies and headers. +- Reject excessive Brotli window bits before decoder allocation. Publish pinned Brotli and flate + decoder memory-charge contracts and enforce the codec-independent `max_decoder_bytes` budget. + Add opt-in `max_chunk_bytes` rechunking as an emitted-item bound, not a receive-allocation bound. +- Add `Deadline`, `DispatchBudget`, the EdgeZero-owned `MonotonicInstant` alias, and injectable + `MonotonicClock`. Ingress, outbound clients, deferred response drains, config extraction, and + egress use one application clock domain. Equality expiry wins; far-future deadlines are clamped + and remaining dispatch budgets cannot grow beyond their entry duration. Backwards batch terminal + observations and Fastly dispatch samples fail closed. +- Add typed timeout provenance (`BudgetSource`, including `BatchCutoff`), gateway transport/decode + reasons, response-limit reasons, and store-extraction reasons. Distinguish initial unreachable + failures, midstream transport failures, protocol failures, and content-coding layers without + requiring message parsing where the provider exposes those distinctions. Opaque Cloudflare Fetch + failures retain `BadGatewayReason::Unspecified`. Preserve timeout causes through decoder error + conversion; upstream/response-limit failures use 502 and exhausted exchange budgets use 504. +- Use category-only public diagnostics for provider-backed failures so URLs, credentials, and + provider error details cannot escape in framework HTTP error bodies. + +### Ingress Admission And Routing + +- Resolve routes before admission and body polling on all four standard adapters. Publish stable + `RouteId`, `RouteMetadata`, and `RouteResolution::{Matched, MethodNotAllowed, NotFound}`, including + optional manifest-sourced route `class` metadata. +- Stamp request start with the application clock, expose absolute read deadlines, and retain an + opaque `IngressGrant` through admitted processing. Request contexts can consume the grant once. +- Replace eager unbounded inbound reads with bounded body consumption and deadline-aware wrappers. + Cloudflare and Spin race pending asynchronous reads with timers; Axum owns its connection-local + read future. Fastly retains its synchronous-hostcall limitation. +- Add cached, fallible `RequestContext::body_bytes(max)` and bounded JSON/form consumption. + Failed or cancelled drains poison the body cell so later readers cannot retry a partially consumed + source. Default extractors are bounded; `ValidatedJsonWithin` and + `ValidatedFormWithin` let handlers select their own compile-time caps. +- Add `ReadBodyBeforeFallback` for bounded discard before canonical 404/405. It carries the + application grant, byte cap, read deadline, and buffered `on_exceeded` / `on_timeout` responses. + Overflow or timeout takes precedence without invoking a route handler or middleware; refusal + remains the zero-read overload path. +- Add non-response `AdmissionDecision::Abort`. It starts no response lifecycle, invokes no handler, + middleware, or completion callback, and polls no body. Axum closes the owned HTTP/1 connection; + other adapters delegate the final transport effect to their providers. +- Add defensive normalized request-target/header/framing checks and typed accounting summaries. + These do not claim access to raw parser-boundary memory or ambiguous raw framing. +- Emit a sorted, deduplicated `Allow` header for canonical 405 responses, including successful + fallback drains. Add an application-controlled framework-error renderer while preserving status, + `Allow`, and the effective config `Retry-After` policy after custom rendering. Applications remain + responsible for bounding their renderer's output. + +### Bounded Axum HTTP/1 Ingress + +- Enforce finite pre-admission limits through the existing Axum server entrypoints: 256 live + connections, 64 KiB combined raw request heads, 100 raw fields, and a non-resetting 10-second + idle-plus-header deadline by default. Explicit invalid settings fail startup before application + configuration or listener binding. `AxumIngressConfig` publishes validated effective limits and + parser receive-allocation assumptions. +- Replace per-connection spawned tasks with one bounded connection-future owner. At capacity, + close the single transient accepted socket without a parser, HTTP refusal response, or waiter + queue. Keep-alive retains its slot; disconnect, failure, timeout, cancellation, and shutdown + release connection state. Preserve existing application body-read and response-write deadlines. +- Pin Hyper 1.12.0, bytes 1.12.1, and http 1.4.2 for the audited HTTP/1 resource boundary. Raw head and duplicate + field-count limits precede admission, but independent raw-target/header accounting and strict + ambiguous-framing rejection remain unsupported. HTTP/2 and socket upgrade ownership are not + enabled. Kernel buffers, allocator overhead, and application allocations are not RSS-certified. +- Bound and compact response headers before Hyper reuses their map for request parsing, preserving + duplicates and sensitive flags. Response fields share the configured head-byte budget and a + derived `max(100, request_field_limit)` count cap; overflow closes the connection. Custom Hyper + reason phrases are removed. Oversized spare capacity/backing buffers cannot persist in the + parser cache. +- **Breaking:** `AxumDevServerConfig` now requires its validated `ingress` field in complete struct + literals. Use `AxumIngressConfig::default()`, `from_env`, or `new`; generated entrypoints still + delegate to `run_app`. Axum CLI Cargo children receive selected manifest environment defaults, + respecting parent environment precedence and declaration order. The demo manifest and scaffold + document the ingress settings without duplicating runtime enforcement. +- Recognize case-insensitive `EDGEZERO__` prefixes on Windows, where child environment-key aliases + retain their first spelling. Unix key identity stays unchanged, and explicit invalid limits + cannot be bypassed through a Windows case alias. + +### Response Egress And Application Assembly + +- Carry every response-producing admission outcome through `ResponseEgressEnvelope` and one + `ResponseEgressAttempt`, retaining the application clock, policy, and response-scoped completion + resource until the exactly-once terminal transition. +- Add non-clone `ResponseEgressCompletion`, generic `late_bound` resource installation, and + left-to-right `join` composition. Completion runs before the global observer; abandonment and + competing terminal paths release resources without duplicate reports. +- Refusal/fallback responses retain their admission-decision-owned completion resources. Detached + normalized errors use `DetachedResponseEgressDecision::{Send { completion, deadline }, Abort}`; + the application factory can reserve resources or abort without creating a response. Raw parser + failures remain outside the application lifecycle. +- Support per-response absolute and egress-start-relative deadlines through + `ResponseEgressDeadline`. An absolute `at(deadline)` can include preceding application queue time; + `after(Duration)` resolves in `ResponseEgressEnvelope::begin` and excludes time before egress + starts. Preserve the selected deadline through policy, framing, conversion, and delivery. +- Replace response-object handoff and whole-stream buffering with adapter-owned response pumps: + Axum's Hyper connection, Cloudflare's writer/abort lifetime, Fastly's manual `stream_to_client`, + and Spin's owned WASI response/body/result writers. Account accepted payload prefixes and release + sources on timeout, disconnect, source failure, and terminal transport errors. +- Share response framing rules for HEAD, body-forbidden statuses, declared-length validation, + hop-by-hop stripping, unsupported upgrades, and bounded pre-commit error fallbacks. Fastly manual + framing is enabled only when a body is actually transmitted. +- Validate Axum exact-length stream EOF before handing Hyper the final frame; preceding frames + remain incremental. Validate transmitted zero-length streams before handoff under the original + deadline. Excess/late-error tails fail, pending/empty tails stay deadline-bounded, detached finite + fallbacks retain GET payloads, and HEAD remains bodyless. Registration expiry selects 504 rather + than a conversion 500. +- Make assembly core-owned and fallible through `App::build::(platform)` and + `Hooks::configure(&mut App) -> Result<(), EdgeError>`. Install target metadata before configuration, + propagate hook errors unchanged, and reject successful configuration that changes any platform + fact, provenance, or unknown reason. Add `app!(..., configure = ...)` with a fallible callback + whose errors propagate through `App::build`. Axum completes assembly before listener binding. +- Add Fastly's closed `run_app_with_hooks` lifecycle for raw-request preparation and routed-response + finalization while retaining adapter-owned delivery and returning routed hook state only after + terminal delivery. +- Integrate opt-in reusable application lifecycles with fallible assembly and canonical admission/ + egress. Cloudflare and Spin expose prebuilt `dispatch_app`; Fastly exposes `serve_app`, + `serve_app_with_hooks`, and successful-only `lifecycle::Sandbox` retention. Fastly custom serving + callbacks own delivery and return `Result<(), E>` without an SDK-generated second response. + Reuse no request handles, bodies, extensions, registries, or pending transport work. + +### Bounded Config And Secret Extraction + +- Add `ConfigExtractionLimits` and typed bounded config/secret reads with per-value, backend-byte, + and cumulative extraction caps under one extraction-start-relative timeout and application clock. + Fastly chunk reconstruction and secret hydration share the same cumulative accounting/deadline. +- Bound JSON depth and node count before materializing a config envelope as `serde_json::Value`, + so nested or oversized structural shapes are rejected before allocating the retained tree. +- Distinguish missing binding/value/secret, malformed envelope, unsupported envelope version, + integrity failure, deserialization, validation, overflow, deadline, and provider-read failures + through `StoreExtractionReason` and typed store errors rather than the previous coarse extraction + classifications. +- Require provider implementations of `ConfigStore::get_bounded` and + `SecretStore::get_bytes_bounded`; there is no default unbounded-read-then-check implementation. + Return `BoundedStoreRead` with explicit backend bytes and an optional value. +- Preserve declared Cloudflare/Fastly config bindings when opening fails by installing typed failing + handles. Optional missing-config policy cannot reinterpret a broken declared binding as absence. + Defer Spin config-store opening until bounded extraction, after admission, so unrelated routes + can still dispatch and opening participates in extraction timing. +- Race asynchronous Cloudflare/Spin reads with platform timers and recheck injected-clock expiry + around ready results. Synchronous Axum/Fastly provider calls remain non-preemptible; byte and + timeout controls do not certify provider allocation or finite cancellation latency. ### Platform resource metadata +- Add `Adapter::platform_metadata()` and application-visible `PlatformMetadata` with typed + `PlatformFact::{Known, Unknown}` values, provenance, and unknown reasons for memory ceilings, + concurrent live inbound population, and host ingress/framing memory accounting. +- Distinguish per-instance from per-execution ceilings and separate stack allowances. Publish + canonical Axum, Cloudflare, Fastly, generic Spin, and opt-in Akamai Functions profiles; generic + Spin remains runtime-configured rather than inheriting a hosted quota. +- Add checked, scope-aware `validate_memory_envelope` with `Fits`, `Exceeds`, and typed + `Indeterminate` results. Reject scope mismatch and arithmetic overflow; unknown platform facts + remain indeterminate rather than being replaced with guessed population or parser-memory bounds. + Applications and adapter implementations must migrate to the current platform-fact and response -construction APIs. No compatibility aliases are retained. +construction APIs. The migration record includes APIs from earlier PR revisions as well as the +pre-PR release surface, so consumers pinned to intermediate commits can update directly. No +compatibility aliases are retained. | Removed or changed API | Replacement | | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------- | @@ -24,3 +279,121 @@ construction APIs. No compatibility aliases are retained. | `Adapter::memory_ceiling()` | `Adapter::platform_metadata()` | | overridable `Hooks::build_app()` and infallible `configure` | `App::build::(platform)` plus fallible `Hooks::configure` | | four-argument `OutboundResponse::new` | five-argument constructor requiring the application `MonotonicClock` | + +### Logging Ownership And Boot Diagnostics + +`owns_logging = true` now explicitly owns backend installation, backend filters, and the facade +maximum before entering an adapter runner. `resolve_logging_level(&EnvConfig)` shares runtime +level resolution. Managed Axum/Fastly loggers retain `edgezero::boot` warnings and errors at +`Warn` even when ordinary logging is `Error` or `Off`. Cloudflare/Spin initializers remain no-ops. + +Fastly `runtime_env_config` now returns `FastlyRuntimeConfig` with an `env` field and bounded +deferred diagnostics. Custom callers must initialize logging and call `emit_boot_diagnostics()`; +the former direct `EnvConfig` return is removed, with no compatibility alias. Managed runners +initialize logging before application configuration. Axum now returns logger-setup errors instead +of ignoring them; preinstalled backends must use `owns_logging = true`. + +Axum's `run_app_with_preflight` supports checks after logger setup and before configuration or +listener binding. The bundled demo uses it without first installing the CLI logger. Terminal CLI +errors remain visible on stderr regardless of runtime logging level. + +### CLI, Capabilities, Demo, And Generator + +- Add typed manifest capabilities and `Adapter::capability` with `Native`, `BoundedCooperative`, + `BestEffort`, and `Unsupported`. Runtime-producing CLI operations validate required support + before side effects; optional support emits boot diagnostics without blocking execution. + Unknown names, duplicates, overlap, and malformed baked contracts fail closed. +- Add canonical outbound-host declarations, with an omitted declaration defaulting to HTTPS only + and explicit `"*"` enabling HTTP and HTTPS. These configure platform plumbing, not destination + authorization. Spin's selected component must match the canonical host contract. +- Pin CLI execution to the resolved application root, platform manifest, and component through + `AdapterExecutionTarget` / `execute_target`, including manifest-defined shell commands. Reject + ambiguous or escaping manifest selection instead of checking one project and executing another. + Fastly deployment honors the selected manifest path. +- Migrate checked-in demos and generated applications to the new client, buffered fanout, + passthrough classification, response limits, clocks, ingress policy, and fallible assembly. + Include explicit missing-client 501 behavior and partial-result/failure examples. +- Keep runtime setup in reusable adapter runners rather than copying transport or logging code + into templates. The demo illustrates completion composition and late-bound ownership; generated + scaffolds retain finite admission/egress policies with core-owned empty lifecycle tokens. +- Gate `handlebars` behind the existing `edgezero-cli/cli` feature so runtime-only library consumers + do not pull scaffolding or implicitly enable its `serde_json/preserve_order` dependency feature. + +### Additional Hard Migrations + +| Removed or changed API | Replacement | +| ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `ProxyClient`, `ProxyHandle`, `ProxyRequest`, `ProxyResponse`, `ProxyService`, adapter `*ProxyClient` | `OutboundHttpClient`, `HttpClient`, `OutboundRequest`, `OutboundResponse`, client forwarding, adapter `*OutboundClient` | +| Infallible proxy request constructors and tuple `into_parts()` | Fallible `OutboundRequest` constructors / `from_parts` and structured `OutboundRequestParts` | +| Proxy request extension access and `body_mut()` | Application-owned state and outbound request builders / structured parts | +| `Body::from_stream` with arbitrary external errors | Typed `EdgeError` items, or `Body::from_external_stream` for external errors | +| Synchronous/unbounded request-context body access | Async `body_bytes(max)`, `json_within(max)`, `form_within(max)`; fallible `take_body()` / `into_request()` | +| `EdgeError::MethodNotAllowed.allowed: String` | Structured `Vec` used to render the mandatory `Allow` header | +| `config_out_of_date_from_serde` | `store_deserialization_from_serde` and `EdgeError::StoreExtraction { reason, .. }` | +| Store implementations with only unbounded reads | Required `ConfigStore::get_bounded` / `SecretStore::get_bytes_bounded` implementations | +| Decoder helpers without a memory policy | Codec helpers accepting decoder budgets and Brotli window policy | +| Response-returning Fastly entrypoints and `#[fastly::main]` | An undecorated `main` calling a send-owning adapter runner | +| Transport-blind response converters and stream-buffer fallback constants | Adapter-owned response-egress coordinators | +| Fastly `runtime_env_config(...) -> EnvConfig` | `FastlyRuntimeConfig`, using `.env` and `emit_boot_diagnostics()` after logger setup | + +Earlier PR revisions additionally require these migrations: + +| Superseded intermediate API | Current API | +| ---------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `send_all` returning bare positional outcomes | `start_batch_until` / `next` / `cancel` / `collect`, or fallible `send_all_until` | +| Batch `cutoff` parameter described as a batch deadline | Explicit `observation_cutoff` that stops observation regardless of slot budgets | +| Bare `ContentEncoding::Passthrough` | `ContentEncoding::Passthrough(PassthroughReason)` | +| Codec-specific `max_brotli_decoder_bytes` | Codec-independent `max_decoder_bytes` | +| Admission refusal/fallback without response completion ownership | Decision-owned `ResponseEgressCompletion` transferred through ingress and egress | +| Detached completion-only factory | `DetachedResponseEgressDecision::Send { completion, deadline }` or `Abort` | + +### Dependencies And Toolchain + +- Hard-cut platform SDKs to Fastly `0.13.1` (SDK/shared/sys/logger), Cloudflare `worker 0.8.5`, and + `spin-sdk 7.0.0`, adapting Spin to its WASI HTTP 0.3 response/body/result resource protocol. +- Pin native outbound dependencies (`reqwest 0.13.4`, `url 2.5.8`) and audited decoder graphs, + including `async-compression 0.4.43`, `compression-codecs 0.4.38`, `brotli-decompressor 5.0.1`, + and `flate2 1.1.9`. Decoder charge changes require explicit pin/audit updates, not lockfile-only + assumptions. Spin's SQLite config writer is reverified against Spin 4.1.0. +- Update runtime/tool pins to Spin CLI `4.1.0`, Viceroy `0.21.0`, and Wasmtime `48.0.2`; generated + projects select corresponding SDK generations and applicable platform CLI/Viceroy pins. Root + workspace dependencies use exact SDK pins. Rust `1.95.0`, Node `24.12.0`, and Fastly CLI `15.1.0` + remain the workspace toolchain. + +### Tests, Documentation, And Maintenance + +- Expand native and provider contract coverage for deadlines, decoding, limits, batch ordering, + driver failure/cancellation, admission/fallback precedence, clocks, framing, and resource release. + Add Hyper raw-socket, Spin SDK-resource, Fastly closed-lifecycle, and demo logging regressions. +- Gate native/WASM feature combinations independently, include `test-utils` runtime paths, and + require named nonzero test sentinels. Consolidate adapter clippy into one workflow matrix while + retaining distinct per-target check names, including native Axum. +- Verify generated all-adapter projects and the excluded demo workspace. Fetch both locked + dependency graphs before offline audits; enforce decoder dependency, JSON-map, documentation, + public-rustdoc, and retired-API contracts in CI. +- Update outbound, ingress, egress, and configuration specs/plans, capability matrices, migration + guides, and examples. Replace ordinary hand-built JSON values/fixtures with `serde_json::json!`, + retaining raw input only for syntax, exact-byte, static-input, and serialization-free fallback + requirements. + +### Capability And Evidence Boundaries + +- Axum alone reports native outbound and inbound-read deadline enforcement. Cloudflare outbound + and streamed-upload deadlines remain `BestEffort` pending deployed host-observed cancellation + evidence; Fastly and Spin retain their documented phase-timer/teardown limitations. +- Fastly fanout is dispatch-before-harvest, not guaranteed simultaneous overlap. Synchronous + registration/body drain and bounded handle-selection groups limit completion order, cancellation, + and slot isolation; dynamic backends require provider enablement. A 25 ms dispatch-slack guard can + reject a still-live slot before synchronous cold registration, including heterogeneous hosts and + budgets. Cloudflare header fidelity and authority override remain `BestEffort`; Spin authority + override is rejected because routing and authority cannot be separated through its provider API. +- Response-egress abort, backpressure, completion, and write deadlines remain `BestEffort` on all + adapters. An exactly-once guest lifecycle report does not prove end-client receipt or a finite + provider teardown interval. Configuration-read deadlines also remain `BestEffort` everywhere. +- Raw ingress head limits/framing guarantees, complete outbound resource accounting, and complete + config-read allocation bounds remain `Unsupported`. Provider parsing, materialization, SDK copies, + hidden informational fields/trailers, allocator overhead, and host buffering are not certified by + guest-visible byte, header, chunk, or decoder-policy caps. + +See [the capability guide](docs/guide/capabilities.md) for the exact per-target matrix, accounting +boundaries, canonical platform facts, and evidence required before promoting support. diff --git a/Cargo.lock b/Cargo.lock index 0113bda2..bd55a416 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,15 @@ dependencies = [ "validator", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "async-compression" version = "0.4.43" @@ -178,6 +187,49 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-config" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d7b388a9fc3a6db15a5ec778c38b354eff1364882c94d08e0252f7a47dcaa4" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.2", + "sha1", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" version = "1.17.0" @@ -200,6 +252,383 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "aws-runtime" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 1.4.2", + "http-body", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-dynamodb" +version = "1.120.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f578638cafe182113da770e9798e7063d6de96cbbfe06f647d0026f197c9b97c" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "regex-lite", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-secretsmanager" +version = "1.120.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832dc9d5dbd19c0ac6b24e21ef9f3e553695d0f3928de7134567407b3b7a447d" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 1.4.2", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12be2f9c8eef7f5fc919c96d538e629698469a02b4cb75408b26b1bd984ebe79" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 1.4.2", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.116.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7645db8724ea3b82fdccfb67e1b0f637c9d8ab0e7ef29d84884d8d8d73f805d" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 1.4.2", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.119.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e490aa904849b38e770922faac779dd245f5cf8be81f19065acf18d276e4ad" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 1.4.2", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 1.4.2", + "percent-encoding", + "sha2 0.11.0", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.64.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "639b4d8f8555f24a9be649811c3eb0b4d4616f4d61daf0c32e28873bc1ea9af1" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.2", + "http-body", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51c89cc3f1f281d659a67a519a1b5c6d445b5ce09fa7e5aee40c2c2707e9509d" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http 1.4.2", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.63.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3385d469edbe8b60cc72002784652b5efca39178192aa9cc4b44c9875c6bdc18" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d1d71f6562be974caa85442ecd90194c40fdb5df045f182a6c2e872ce95056" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6e302ac1d88b99652489df31abdec6ac42a2ab2ac3982ad0ac49f64dfaf28ba" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 1.4.2", + "http-body", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0730c16f91124c6a2abb4932c77e299288b3dd9f967ea2e9ec48cc6731e87a4" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 1.4.2", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8f395d93304280b64b7632fea798d177e74897fe7f063416ce627cd6fa24829" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.2", +] + +[[package]] +name = "aws-smithy-types" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 1.4.2", + "http-body", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b932c8d6dc127fc980eecd78f8694ae9b9551b69a93a7def2a199c1c0033daf" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.9" @@ -211,7 +640,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -242,7 +671,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.2", "http-body", "http-body-util", "mime", @@ -270,6 +699,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -300,6 +739,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "brotli" version = "8.0.4" @@ -333,6 +781,16 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cast" version = "0.3.0" @@ -436,6 +894,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -479,6 +943,12 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.10.1" @@ -532,6 +1002,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctor" version = "1.0.13" @@ -542,6 +1021,15 @@ dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.11" @@ -634,7 +1122,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "crypto-common", + "crypto-common 0.1.6", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -681,13 +1181,15 @@ dependencies = [ "ctor", "edgezero-adapter", "edgezero-core", + "edgezero-store-aws", "flate2", "futures", "futures-util", - "http", + "http 1.4.2", "http-body", "hyper", "hyper-util", + "libc", "log", "redb", "reqwest", @@ -700,6 +1202,7 @@ dependencies = [ "toml", "tower", "tracing", + "validator", "walkdir", ] @@ -724,6 +1227,7 @@ dependencies = [ "toml_edit", "walkdir", "wasm-bindgen-test", + "wasm-streams 0.6.0", "web-sys", "worker", ] @@ -837,7 +1341,7 @@ dependencies = [ "flate2", "futures", "futures-util", - "http", + "http 1.4.2", "http-body", "log", "matchit 0.9.2", @@ -878,6 +1382,30 @@ dependencies = [ "validator", ] +[[package]] +name = "edgezero-store-aws" +version = "0.1.0" +dependencies = [ + "async-trait", + "aws-config", + "aws-sdk-dynamodb", + "aws-sdk-secretsmanager", + "base64", + "bytes", + "edgezero-core", + "futures", + "futures-util", + "percent-encoding", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.20", + "tokio", + "toml", + "url", +] + [[package]] name = "either" version = "1.16.0" @@ -935,7 +1463,7 @@ dependencies = [ "fastly-macros", "fastly-shared", "fastly-sys", - "http", + "http 1.4.2", "itertools", "mime", "serde", @@ -967,7 +1495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "191c2cf082f5d17f4ca0685cedaa0642ad4ade949fac8d1dbdd62ff756b13500" dependencies = [ "bitflags 1.3.2", - "http", + "http 1.4.2", ] [[package]] @@ -978,7 +1506,7 @@ checksum = "239c77706cf9110b423a89b6b03b8011729b144390d2a7f641d95ef66b2768b8" dependencies = [ "bitflags 1.3.2", "fastly-shared", - "http", + "http 1.4.2", "wasip2", "wit-bindgen 0.51.0", ] @@ -1192,6 +1720,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.2", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "handlebars" version = "6.4.3" @@ -1250,6 +1797,32 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.2" @@ -1267,7 +1840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.2", ] [[package]] @@ -1278,7 +1851,7 @@ checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.2", "http-body", "pin-project-lite", ] @@ -1295,17 +1868,27 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.10.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "http", + "h2", + "http 1.4.2", "http-body", "httparse", "httpdate", @@ -1322,10 +1905,11 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", + "http 1.4.2", "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -1341,7 +1925,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", + "http 1.4.2", "http-body", "hyper", "ipnet", @@ -1667,6 +2251,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "433713b6191ad3e1d5248db9536f3e2d99a12d7ff998afd272a98d96e6dbc2ed" dependencies = [ + "anyhow", "fastly", "log", "regex", @@ -1754,6 +2339,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-modular" version = "0.6.1" @@ -1818,6 +2412,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1893,6 +2493,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" + [[package]] name = "pkg-config" version = "0.3.33" @@ -2091,6 +2697,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -2108,7 +2720,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -2127,12 +2739,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.5.0", "web-sys", ] @@ -2424,6 +3038,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.9.9" @@ -2448,6 +3073,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "shlex" version = "2.0.1" @@ -2546,7 +3182,7 @@ dependencies = [ "anyhow", "bytes", "futures", - "http", + "http 1.4.2", "http-body", "http-body-util", "spin-macro", @@ -2809,6 +3445,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -2886,7 +3536,7 @@ dependencies = [ "bitflags 2.11.1", "bytes", "futures-util", - "http", + "http 1.4.2", "http-body", "pin-project-lite", "tower", @@ -3002,6 +3652,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3014,6 +3670,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "validator" version = "0.20.0" @@ -3056,6 +3722,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -3068,9 +3740,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -3106,7 +3778,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75e9ed91ee5a995b6fe16c89d4511a8c12eb76967be20b57557618e02e781bcc" dependencies = [ "bytes", - "http", + "http 1.4.2", "http-body", "thiserror 2.0.20", "wit-bindgen 0.57.1", @@ -3250,6 +3922,19 @@ dependencies = [ "wasmparser 0.247.0", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.6.0" @@ -3740,7 +4425,7 @@ dependencies = [ "chrono", "futures-channel", "futures-util", - "http", + "http 1.4.2", "http-body", "js-sys", "matchit 0.7.3", @@ -3754,7 +4439,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.6.0", "web-sys", "worker-macros", "worker-sys", @@ -3794,6 +4479,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index c7ae4144..fbb37abd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/edgezero-cli", "crates/edgezero-core", "crates/edgezero-macros", + "crates/edgezero-store-aws", ] exclude = ["examples/app-demo"] resolver = "2" @@ -39,10 +40,14 @@ async-compression = { version = "=0.4.43", default-features = false, features = ] } async-stream = "0.3" async-trait = "0.1" +aws-config = { version = "=1.12.0", default-features = false, features = ["default-https-client", "rt-tokio", "credentials-process", "sso"] } +aws-sdk-dynamodb = { version = "=1.120.0", default-features = false, features = ["default-https-client", "rt-tokio"] } +aws-sdk-secretsmanager = { version = "=1.120.0", default-features = false, features = ["default-https-client", "rt-tokio"] } axum = { version = "0.8", default-features = true } +base64 = "=0.22.1" brotli = "=8.0.4" brotli-decompressor = "=5.0.1" -bytes = "1" +bytes = "=1.12.1" chrono = "0.4" compression-codecs = { version = "=0.4.38", default-features = false, features = ["brotli", "gzip", "zlib"] } compression-core = "=0.4.32" @@ -54,6 +59,7 @@ edgezero-adapter-cloudflare = { path = "crates/edgezero-adapter-cloudflare", def edgezero-adapter-fastly = { path = "crates/edgezero-adapter-fastly", default-features = false } edgezero-adapter-spin = { path = "crates/edgezero-adapter-spin", default-features = false } edgezero-core = { path = "crates/edgezero-core", default-features = false } +edgezero-store-aws = { path = "crates/edgezero-store-aws", default-features = false } edgezero-cli = { path = "crates/edgezero-cli", default-features = false } fastly = "=0.13.1" fastly-shared = "=0.13.1" @@ -63,12 +69,13 @@ flate2 = { version = "=1.1.9", default-features = false, features = ["rust_backe futures = { version = "0.3", features = ["std", "executor"] } futures-util = { version = "0.3", features = ["alloc", "io"] } handlebars = "6" -http = "1" +http = "=1.4.2" http-body = "1" http-body-util = "0.1" -hyper = { version = "1", features = ["http1", "server"] } +hyper = { version = "=1.12.0", features = ["http1", "server"] } hyper-util = { version = "0.1", features = ["tokio"] } log = "0.4" +libc = "0.2" miniz_oxide = { version = "=0.8.9", default-features = false, features = ["simd", "with-alloc"] } log-fastly = "=0.13.1" matchit = "0.9" @@ -84,12 +91,13 @@ simd-adler32 = { version = "=0.3.9", default-features = false } rusqlite = { version = "0.32", default-features = false, features = ["bundled"] } ryu = "1" serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = "=1.0.150" serde_path_to_error = "0.1" serde_urlencoded = "0.7" sha2 = "0.10" similar = "2" simple_logger = "5" +percent-encoding = "=2.3.2" proc-macro2 = { version = "1", features = ["span-locations"] } quote = "1" syn = { version = "3", features = ["full", "extra-traits", "visit"] } @@ -112,6 +120,7 @@ validator = { version = "0.20", features = ["derive"] } walkdir = { version = "2" } web-time = "1" worker = { version = "=0.8.5", default-features = false, features = ["http"] } +wasm-streams = "=0.6.0" [workspace.lints.clippy] # Same strict gate as the demo workspace. Allow-list mirrors the demo's diff --git a/crates/edgezero-adapter-axum/Cargo.toml b/crates/edgezero-adapter-axum/Cargo.toml index c1e20907..4255c4a2 100644 --- a/crates/edgezero-adapter-axum/Cargo.toml +++ b/crates/edgezero-adapter-axum/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [features] default = ["axum"] axum = [ + "native-bindings", "dep:async-stream", "dep:axum", "dep:tokio", @@ -24,14 +25,19 @@ axum = [ "dep:reqwest", "dep:redb", ] +native-bindings = ["dep:toml", "dep:edgezero-store-aws"] +aws-appconfig-agent = ["axum", "edgezero-store-aws/appconfig-agent"] +aws-secrets-manager = ["axum", "edgezero-store-aws/secrets-manager"] +aws-stores = ["aws-appconfig-agent", "aws-secrets-manager"] +aws-dynamodb = ["axum", "edgezero-store-aws/dynamodb-kv"] cli = [ + "native-bindings", "dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", - "dep:toml", "dep:walkdir", ] -test-utils = [] +test-utils = ["dep:validator"] [dependencies] edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = [ @@ -53,7 +59,9 @@ hyper-util = { workspace = true, optional = true } log = { workspace = true } redb = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } +serde = { workspace = true } serde_json = { workspace = true } +validator = { workspace = true, optional = true } simple_logger = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, optional = true } @@ -62,6 +70,29 @@ tower = { workspace = true, optional = true } tracing = { workspace = true } walkdir = { workspace = true, optional = true } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +edgezero-store-aws = { workspace = true, optional = true } + +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } + +[[bin]] +name = "edgezero-hosting-fixture" +path = "tests/fixtures/production_host.rs" +required-features = ["axum", "test-utils"] +test = false + +[[bin]] +name = "edgezero-native-aws-host" +path = "tests/fixtures/native_aws_host.rs" +required-features = ["aws-stores", "test-utils"] +test = false + +[[test]] +name = "native_aws_startup" +path = "tests/native_aws_startup.rs" +required-features = ["aws-stores", "test-utils"] + [dev-dependencies] async-trait = { workspace = true } axum = { workspace = true, features = ["macros"] } @@ -72,4 +103,4 @@ hyper = { workspace = true } hyper-util = { workspace = true } serde = { workspace = true } tempfile = { workspace = true } -tokio = { workspace = true, features = ["io-util", "macros", "net", "rt", "rt-multi-thread"] } +tokio = { workspace = true, features = ["io-util", "macros", "net", "rt", "rt-multi-thread", "test-util"] } diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 3ce10231..d2d7a3ac 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -24,6 +24,8 @@ use edgezero_core::{Capability, CapabilitySupport, PlatformMetadata}; use toml::Value; use walkdir::WalkDir; +use crate::native_build::NativeCargoSelection; + static AXUM_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ TemplateRegistration { name: "axum_Cargo_toml", @@ -116,6 +118,7 @@ struct AxumProject { axum_host: Option, axum_manifest: PathBuf, axum_port: Option, + build_features: Vec, cargo_manifest: PathBuf, crate_dir: PathBuf, crate_name: String, @@ -131,7 +134,7 @@ struct EdgezeroAxumConfig { #[expect( clippy::missing_trait_methods, - reason = "axum has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `read_config_entry` delegates to `read_config_entry_local` (axum is local-only). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." + reason = "axum has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `read_config_entry` delegates to `read_config_entry_local` (axum is local-only). `single_store_kinds` explicitly declares Multi support for every store kind." )] impl Adapter for AxumCliAdapter { fn capability(&self, capability: Capability) -> CapabilitySupport { @@ -409,9 +412,9 @@ impl Adapter for AxumCliAdapter { } 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"] + // Every kind supports multiple logical IDs. Local secret bindings may + // share the environment backend; that does not restrict their ID count. + &[] } } @@ -428,22 +431,22 @@ fn register_ctor() { fn build(extra_args: &[String]) -> Result<(), String> { let project = locate_project()?; - run_cargo(&project, "build", extra_args) + run_cargo(&project, "build", extra_args, &[]) } fn build_target(target: &AdapterExecutionTarget, extra_args: &[String]) -> Result<(), String> { let project = read_axum_project(&target_manifest(target, "axum.toml")?)?; - run_cargo(&project, "build", extra_args) + run_cargo(&project, "build", extra_args, target.environment_defaults()) } fn serve(extra_args: &[String]) -> Result<(), String> { let project = locate_project()?; - run_cargo(&project, "run", extra_args) + run_cargo(&project, "run", extra_args, &[]) } fn serve_target(target: &AdapterExecutionTarget, extra_args: &[String]) -> Result<(), String> { let project = read_axum_project(&target_manifest(target, "axum.toml")?)?; - run_cargo(&project, "run", extra_args) + run_cargo(&project, "run", extra_args, target.environment_defaults()) } fn target_manifest(target: &AdapterExecutionTarget, name: &str) -> Result { @@ -466,10 +469,23 @@ fn deploy(_extra_args: &[String]) -> Result<(), String> { 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) + let mut project = read_axum_project(&manifest)?; + if let Some(edgezero_manifest) = find_manifest_upwards(&cwd, "edgezero.toml") { + let loader = ManifestLoader::from_path(&edgezero_manifest) + .map_err(|err| format!("failed to load {}: {err}", edgezero_manifest.display()))?; + if let Some((_name, adapter)) = loader.manifest().adapter_entry("axum") { + project.build_features.clone_from(&adapter.build.features); + } + } + Ok(project) } -fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> Result<(), String> { +fn cargo_command( + project: &AxumProject, + subcommand: &str, + extra_args: &[String], + defaults: &[(String, String)], +) -> Result { let resolution = resolve_subprocess_addr(project)?; for warning in &resolution.warnings { log::warn!("[edgezero] {warning}"); @@ -490,7 +506,17 @@ fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> .to_str() .ok_or_else(|| format!("invalid manifest path {}", project.cargo_manifest.display()))?, ); - command.args(extra_args); + let selection = NativeCargoSelection::normalize_args(&project.build_features, extra_args)?; + command.args(selection.cargo_args()); + match subcommand { + "run" if !selection.run_args.is_empty() => { + command.arg("--").args(&selection.run_args); + } + "build" if !selection.run_args.is_empty() => { + return Err("`cargo build` does not accept runtime arguments after `--`".to_owned()); + } + _ => {} + } 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`); @@ -498,6 +524,17 @@ fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> // 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()); + command.envs(defaults.iter().map(|(key, value)| (key, value))); + Ok(command) +} + +fn run_cargo( + project: &AxumProject, + subcommand: &str, + extra_args: &[String], + defaults: &[(String, String)], +) -> Result<(), String> { + let mut command = cargo_command(project, subcommand, extra_args, defaults)?; let status = command .status() .map_err(|err| format!("failed to run cargo {subcommand}: {err}"))?; @@ -760,6 +797,9 @@ fn read_axum_project_with_env( axum_host: config_host, axum_manifest: manifest.to_path_buf(), axum_port: config_port, + // Pinned targets use only the feature inputs selected by the caller; + // the CLI dispatcher has already forwarded manifest build features. + build_features: Vec::new(), cargo_manifest, crate_dir, crate_name, @@ -775,6 +815,47 @@ mod tests { use std::net::Ipv6Addr; use tempfile::tempdir; + #[test] + fn all_store_kinds_accept_multiple_logical_ids() { + assert!(AXUM_ADAPTER.single_store_kinds().is_empty()); + } + + #[test] + fn cargo_commands_preserve_ingress_defaults_and_bind_precedence() { + let dir = tempdir().unwrap(); + let project = AxumProject { + addr: SocketAddr::from(([127, 0, 0, 1], 8787)), + axum_host: None, + axum_manifest: dir.path().join("axum.toml"), + axum_port: None, + build_features: Vec::new(), + cargo_manifest: dir.path().join("Cargo.toml"), + crate_dir: dir.path().to_path_buf(), + crate_name: "fixture".to_owned(), + env_host: None, + env_port: None, + }; + let defaults = vec![ + ( + "EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS".to_owned(), + "3".to_owned(), + ), + ("EDGEZERO__ADAPTER__PORT".to_owned(), "9090".to_owned()), + ]; + for subcommand in ["build", "run"] { + let command = cargo_command(&project, subcommand, &[], &defaults).unwrap(); + assert!(command.get_envs().any(|(key, value)| key + == "EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS" + && value.is_some_and(|configured| configured == "3"))); + assert!( + command + .get_envs() + .any(|(key, value)| key == "EDGEZERO__ADAPTER__PORT" + && value.is_some_and(|configured| configured == "9090")) + ); + } + } + #[test] fn adapter_capability_matrix_matches_contracts() { let expected = [ diff --git a/crates/edgezero-adapter-axum/src/config_snapshot.rs b/crates/edgezero-adapter-axum/src/config_snapshot.rs new file mode 100644 index 00000000..7270fae5 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/config_snapshot.rs @@ -0,0 +1,351 @@ +//! Flat-string snapshots: no intermediate JSON tree or per-read payload copies. + +use std::fmt; +use std::io::Read; +use std::mem::size_of; + +use edgezero_core::ConfigValue; +use edgezero_core::config_store::ConfigStoreError; +use serde::Deserializer; +use serde::de::{self, DeserializeSeed, Error as _, MapAccess, Visitor}; + +use crate::config_store_limits::ConfigStoreLimits; + +pub(crate) struct Snapshot { + entries: Vec<(String, ConfigValue)>, + resident_bytes: usize, +} + +struct SnapshotSeed<'snapshot> { + existing: usize, + failure: &'snapshot mut Option, + limits: ConfigStoreLimits, + snapshot: &'snapshot mut Snapshot, +} + +struct StringSeed<'failure> { + failure: &'failure mut Option, + max_bytes: usize, +} + +impl Snapshot { + pub(crate) fn empty( + limits: ConfigStoreLimits, + existing: usize, + ) -> Result { + let index_bytes = limits + .max_entries() + .checked_mul(size_of::<(String, ConfigValue)>()) + .ok_or(ConfigStoreError::ValueTooLarge)?; + check_charge(limits, existing, index_bytes)?; + let mut entries = Vec::new(); + entries + .try_reserve_exact(limits.max_entries()) + .map_err(|_allocation| { + ConfigStoreError::internal(anyhow::anyhow!("config snapshot allocation failed")) + })?; + Ok(Self { + resident_bytes: entries + .capacity() + .checked_mul(size_of::<(String, ConfigValue)>()) + .ok_or(ConfigStoreError::ValueTooLarge)?, + entries, + }) + } + + fn finish(&mut self) -> Result<(), ConfigStoreError> { + self.entries + .sort_unstable_by(|(left, _value), (right, _other)| left.cmp(right)); + if self.entries.windows(2).any(|pair| { + pair.first() + .zip(pair.get(1)) + .is_some_and(|(left, right)| left.0 == right.0) + }) { + return Err(ConfigStoreError::unavailable( + "duplicate config snapshot key", + )); + } + Ok(()) + } + + pub(crate) fn from_entries( + entries: E, + limits: ConfigStoreLimits, + ) -> Result + where + E: IntoIterator, + { + let mut snapshot = Self::empty(limits, 0)?; + for (key, value) in entries { + snapshot.insert(&key, &value, limits, 0)?; + } + snapshot.finish()?; + Ok(snapshot) + } + + pub(crate) fn get(&self, key: &str) -> Option<&ConfigValue> { + self.entries + .binary_search_by(|(stored, _value)| stored.as_str().cmp(key)) + .ok() + .and_then(|index| self.entries.get(index)) + .map(|(_key, value)| value) + } + + fn insert( + &mut self, + key: &str, + value: &str, + limits: ConfigStoreLimits, + existing: usize, + ) -> Result<(), ConfigStoreError> { + if self.entries.len() >= limits.max_entries() + || key.len() > limits.max_key_bytes() + || value.len() > limits.max_value_bytes() + { + return Err(ConfigStoreError::ValueTooLarge); + } + // The Vec index was reserved already. Strings have exact requested size; + // Arc has two reference counts in addition to the UTF-8 payload. + let charge = self + .resident_bytes + .checked_add(key.len()) + .and_then(|bytes| bytes.checked_add(value.len())) + .and_then(|bytes| { + bytes.checked_add(size_of::().saturating_mul(3).saturating_sub(1)) + }) + .ok_or(ConfigStoreError::ValueTooLarge)?; + check_charge(limits, existing, charge)?; + self.entries + .push((key.to_owned(), ConfigValue::from(value))); + self.resident_bytes = charge; + Ok(()) + } + + pub(crate) fn load( + reader: &mut impl Read, + limits: ConfigStoreLimits, + existing: usize, + ) -> Result { + let mut snapshot = Self::empty(limits, existing)?; + let raw = read_capped(reader, limits.max_file_bytes())?; + // Reject non-object roots before Serde can format their contents into + // an expanded type-error diagnostic outside the decoder scratch budget. + if raw + .iter() + .copied() + .find(|byte| !matches!(byte, b' ' | b'\n' | b'\r' | b'\t')) + != Some(b'{') + { + return Err(ConfigStoreError::unavailable( + "config snapshot requires an object", + )); + } + let mut decoder = serde_json::Deserializer::from_slice(&raw); + let mut failure = None; + let result = SnapshotSeed { + snapshot: &mut snapshot, + limits, + existing, + failure: &mut failure, + } + .deserialize(&mut decoder) + .and_then(|()| decoder.end()); + if result.is_err() { + return Err( + failure.unwrap_or_else(|| ConfigStoreError::unavailable("invalid config snapshot")) + ); + } + snapshot.finish()?; + Ok(snapshot) + } + + pub(crate) const fn resident_bytes(&self) -> usize { + self.resident_bytes + } +} + +impl<'de> DeserializeSeed<'de> for SnapshotSeed<'_> { + type Value = (); + fn deserialize>(self, deserializer: D) -> Result<(), D::Error> { + deserializer.deserialize_map(self) + } +} + +#[expect( + clippy::missing_trait_methods, + reason = "all non-map visitor methods intentionally retain Serde's type rejection" +)] +impl<'de> Visitor<'de> for SnapshotSeed<'_> { + type Value = (); + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a flat string map") + } + fn visit_map>(self, mut map: A) -> Result<(), A::Error> { + while let Some(key) = map.next_key_seed(StringSeed { + max_bytes: self.limits.max_key_bytes(), + failure: self.failure, + })? { + if self.snapshot.entries.len() >= self.limits.max_entries() { + *self.failure = Some(ConfigStoreError::ValueTooLarge); + return Err(A::Error::custom("snapshot limit")); + } + let value = map.next_value_seed(StringSeed { + max_bytes: self.limits.max_value_bytes(), + failure: self.failure, + })?; + if let Err(error) = self + .snapshot + .insert(&key, &value, self.limits, self.existing) + { + *self.failure = Some(error); + return Err(A::Error::custom("snapshot limit")); + } + } + Ok(()) + } +} + +impl<'de> DeserializeSeed<'de> for StringSeed<'_> { + type Value = String; + fn deserialize>(self, deserializer: D) -> Result { + deserializer.deserialize_str(self) + } +} + +#[expect( + clippy::missing_trait_methods, + reason = "Serde's borrowed string method forwards to the bounded visit_str; non-strings are rejected by default" +)] +impl Visitor<'_> for StringSeed<'_> { + type Value = String; + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a bounded UTF-8 string") + } + fn visit_str(self, v: &str) -> Result { + if v.len() > self.max_bytes { + *self.failure = Some(ConfigStoreError::ValueTooLarge); + return Err(E::custom("snapshot string limit")); + } + Ok(v.to_owned()) + } +} + +fn check_charge( + limits: ConfigStoreLimits, + existing: usize, + additional: usize, +) -> Result<(), ConfigStoreError> { + let resident = existing + .checked_add(additional) + .ok_or(ConfigStoreError::ValueTooLarge)?; + if resident > limits.max_resident_bytes() + || resident.saturating_add(limits.staging_bytes()) > limits.max_startup_bytes() + { + return Err(ConfigStoreError::ValueTooLarge); + } + Ok(()) +} + +fn read_capped(reader: &mut impl Read, max_bytes: usize) -> Result, ConfigStoreError> { + let mut raw = Vec::new(); + let mut chunk = [0_u8; 4096]; + loop { + let remaining = max_bytes.saturating_sub(raw.len()); + let read_len = chunk.len().min(remaining.saturating_add(1)); + let read = reader + .read( + chunk + .get_mut(..read_len) + .ok_or(ConfigStoreError::ValueTooLarge)?, + ) + .map_err(|_io| ConfigStoreError::unavailable("config snapshot read failed"))?; + if read == 0 { + return Ok(raw); + } + if read > remaining { + return Err(ConfigStoreError::ValueTooLarge); + } + raw.try_reserve_exact(read).map_err(|_allocation| { + ConfigStoreError::internal(anyhow::anyhow!("config snapshot allocation failed")) + })?; + raw.extend_from_slice(chunk.get(..read).ok_or(ConfigStoreError::ValueTooLarge)?); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn limits(file: usize, entries: usize, key: usize, value: usize) -> ConfigStoreLimits { + ConfigStoreLimits::new(file, entries, key, value, 8192, 0x8000).expect("limits") + } + + #[test] + fn file_growth_is_capped_by_reads_not_metadata() { + let mut reader = Cursor::new(vec![b' '; 9]); + assert!(matches!( + read_capped(&mut reader, 8), + Err(ConfigStoreError::ValueTooLarge) + )); + assert_eq!(reader.position(), 9); + assert_eq!( + read_capped(&mut Cursor::new(vec![b' '; 8]), 8).expect("exact"), + vec![b' '; 8] + ); + } + + #[test] + fn malformed_root_strings_are_rejected_before_diagnostic_expansion() { + let raw = serde_json::to_vec(&serde_json::json!("\u{0001}".repeat(8192))).expect("fixture"); + let policy = ConfigStoreLimits::new( + raw.len(), + 1, + 1, + 1, + 4096, + raw.len().saturating_mul(5).saturating_add(4096), + ) + .expect("policy"); + let error = Snapshot::load(&mut raw.as_slice(), policy, 0) + .err() + .expect("non-object"); + assert_eq!( + error.to_string(), + ConfigStoreError::unavailable("config snapshot requires an object").to_string() + ); + } + + #[test] + fn escaped_values_and_entry_limits_are_enforced() { + let raw = serde_json::to_vec(&serde_json::json!({"a": "\n\n\n"})).expect("fixture"); + let _snapshot = Snapshot::load(&mut raw.as_slice(), limits(128, 1, 1, 3), 0) + .expect("exact decoded cap"); + assert!(matches!( + Snapshot::load(&mut raw.as_slice(), limits(128, 1, 1, 2), 0), + Err(ConfigStoreError::ValueTooLarge) + )); + let many = serde_json::to_vec(&serde_json::json!({"a": "a", "b": "b"})).expect("fixture"); + assert!(matches!( + Snapshot::load(&mut many.as_slice(), limits(128, 1, 1, 3), 0), + Err(ConfigStoreError::ValueTooLarge) + )); + } + + #[test] + fn all_store_residency_is_reserved_before_loading() { + let limits = limits(128, 1, 1, 3); + let raw = serde_json::to_vec(&serde_json::json!({"a": "abc"})).expect("fixture"); + let first = Snapshot::load(&mut raw.as_slice(), limits, 0).expect("snapshot"); + assert!(matches!( + Snapshot::load( + &mut raw.as_slice(), + limits, + limits.max_resident_bytes() - first.resident_bytes() + 1 + ), + Err(ConfigStoreError::ValueTooLarge) + )); + let _recovered = Snapshot::load(&mut raw.as_slice(), limits, 0) + .expect("failed load has no retained charge"); + } +} diff --git a/crates/edgezero-adapter-axum/src/config_store.rs b/crates/edgezero-adapter-axum/src/config_store.rs index f5841300..c30f8d72 100644 --- a/crates/edgezero-adapter-axum/src/config_store.rs +++ b/crates/edgezero-adapter-axum/src/config_store.rs @@ -12,10 +12,11 @@ //! every key). This keeps `edgezero serve --adapter axum` permissive when //! the project hasn't seeded any local config yet. -use std::collections::HashMap; use std::env; use std::fs; use std::io::ErrorKind; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt as _; use std::path::{Path, PathBuf}; #[cfg(test)] use std::sync::atomic::{AtomicUsize, Ordering}; @@ -24,26 +25,20 @@ use async_trait::async_trait; use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; use edgezero_core::{BoundedStoreRead, Deadline, MonotonicClock}; +use crate::config_snapshot::Snapshot; +use crate::config_store_limits::ConfigStoreLimits; + /// Local-file config store used by the Axum dev server. /// -/// Construction is fallible only when the backing file is present but -/// malformed JSON — a missing file is a documented "no values seeded yet" -/// state, not an error. +/// Construction validates finite allocation budgets and the flat-string shape. +/// A missing file is an empty declared snapshot, not an absent binding. pub struct AxumConfigStore { - data: HashMap, + data: Snapshot, #[cfg(test)] unbounded_get_calls: AtomicUsize, } impl AxumConfigStore { - fn empty() -> Self { - Self { - data: HashMap::new(), - #[cfg(test)] - unbounded_get_calls: AtomicUsize::new(0), - } - } - /// Open the local-file config store for a given logical id. /// /// Reads `.edgezero/local-config-.json` if present and parses it @@ -54,24 +49,29 @@ impl AxumConfigStore { /// /// # Errors /// Returns [`ConfigStoreError::Unavailable`] when the backing file - /// exists but cannot be read or parsed. + /// exists but cannot be read or parsed, and [`ConfigStoreError::ValueTooLarge`] + /// for file, entry, string or allocation-budget violations. #[inline] - pub fn from_local_file(id: &str) -> Result { - Self::from_path(&Self::local_path(id)) + pub fn from_local_file(id: &str, limits: ConfigStoreLimits) -> Result { + Self::from_path(&Self::local_path(id), limits) } /// Build a store from an explicit `{key -> value}` map. Intended for /// tests and for callers that already have parsed config in memory. + /// Caller-owned input allocations are outside the snapshot's allocation charge. + /// + /// # Errors + /// Returns a typed size error for allocation limits, or unavailable for duplicates. #[inline] - pub fn from_map(entries: E) -> Self + pub fn from_map(entries: E, limits: ConfigStoreLimits) -> Result where E: IntoIterator, { - Self { - data: entries.into_iter().collect(), + Ok(Self { + data: Snapshot::from_entries(entries, limits)?, #[cfg(test)] unbounded_get_calls: AtomicUsize::new(0), - } + }) } /// Open the local-file config store at an explicit path @@ -103,27 +103,67 @@ impl AxumConfigStore { /// /// # Errors /// Returns [`ConfigStoreError::Unavailable`] when the file - /// exists but cannot be read or parsed. + /// exists but cannot be read or parsed, and [`ConfigStoreError::ValueTooLarge`] + /// for file, entry, string or allocation-budget violations. #[inline] - pub fn from_path(path: &Path) -> Result { - let raw = match fs::read_to_string(path) { - Ok(raw) => raw, + pub fn from_path(path: &Path, limits: ConfigStoreLimits) -> Result { + Self::load_at_startup(path, limits, 0) + } + + pub(crate) fn load_at_startup( + path: &Path, + limits: ConfigStoreLimits, + existing: usize, + ) -> Result { + Self::load_snapshot(path, limits, existing, false) + } + + pub(crate) fn load_required_at_startup( + path: &Path, + limits: ConfigStoreLimits, + existing: usize, + ) -> Result { + Self::load_snapshot(path, limits, existing, true) + } + + fn load_snapshot( + path: &Path, + limits: ConfigStoreLimits, + existing: usize, + required: bool, + ) -> Result { + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + options.custom_flags(libc::O_NONBLOCK); + let data = match options.open(path) { + Ok(mut file) => { + let metadata = file.metadata().map_err(|_io| { + ConfigStoreError::unavailable("config snapshot metadata unavailable") + })?; + if !metadata.is_file() { + return Err(ConfigStoreError::unavailable( + "config snapshot requires a regular file", + )); + } + if metadata.len() + > u64::try_from(limits.max_file_bytes()) + .map_err(|_size| ConfigStoreError::ValueTooLarge)? + { + return Err(ConfigStoreError::ValueTooLarge); + } + Snapshot::load(&mut file, limits, existing)? + } + Err(err) if err.kind() == ErrorKind::NotFound && !required => { + Snapshot::empty(limits, existing)? + } Err(err) if err.kind() == ErrorKind::NotFound => { - return Ok(Self::empty()); + return Err(ConfigStoreError::unavailable("config file missing")); } - Err(err) => { - return Err(ConfigStoreError::unavailable(format!( - "failed to read {}: {err}", - path.display() - ))); + Err(_io) => { + return Err(ConfigStoreError::unavailable("config snapshot open failed")); } }; - let data: HashMap = serde_json::from_str(&raw).map_err(|err| { - ConfigStoreError::unavailable(format!( - "{} is not a flat string -> string JSON object: {err}", - path.display() - )) - })?; Ok(Self { data, #[cfg(test)] @@ -165,12 +205,19 @@ impl AxumConfigStore { } suffix } + + /// Charged snapshot index, key and shared-payload requested allocation bytes. + #[inline] + #[must_use] + pub const fn resident_allocation_bytes(&self) -> usize { + self.data.resident_bytes() + } } #[async_trait(?Send)] impl ConfigStore for AxumConfigStore { #[inline] - async fn get(&self, key: &str) -> Result, ConfigStoreError> { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { #[cfg(test)] self.unbounded_get_calls.fetch_add(1, Ordering::Relaxed); Ok(self.data.get(key).cloned()) @@ -184,13 +231,12 @@ impl ConfigStore for AxumConfigStore { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { + ) -> Result, ConfigStoreError> { if deadline.is_expired_at(clock.now()) { return Err(ConfigStoreError::DeadlineExceeded); } - // Values are already resident after startup JSON parsing. This bounds - // request-time materialization; it does not bound startup file allocation. + // Payloads are already bounded resident snapshots; reads clone only Arc. let stored_value = self.data.get(key); if deadline.is_expired_at(clock.now()) { return Err(ConfigStoreError::DeadlineExceeded); @@ -245,10 +291,14 @@ fn find_project_root_dir_from(start: &Path) -> Option { mod tests { // Run the shared contract tests against AxumConfigStore. edgezero_core::config_store_contract_tests!(axum_config_store_contract, { - AxumConfigStore::from_map([ - ("contract.key.a".to_owned(), "value_a".to_owned()), - ("contract.key.b".to_owned(), "value_b".to_owned()), - ]) + AxumConfigStore::from_map( + [ + ("contract.key.a".to_owned(), "value_a".to_owned()), + ("contract.key.b".to_owned(), "value_b".to_owned()), + ], + ConfigStoreLimits::default(), + ) + .expect("snapshot") }); use super::*; @@ -258,9 +308,99 @@ mod tests { use std::time::Duration; use tempfile::tempdir; + #[test] + fn bounded_get_shares_payload_across_reads() { + let store = AxumConfigStore::from_map( + [("key".to_owned(), "payload".to_owned())], + ConfigStoreLimits::default(), + ) + .expect("snapshot"); + let clock = MonotonicClock::default(); + let deadline = Deadline::after(Duration::from_secs(1)); + let first = block_on(store.get_bounded("key", &clock, deadline, 7, 7)) + .expect("first read") + .value + .expect("first value"); + let second = block_on(store.get_bounded("key", &clock, deadline, 7, 7)) + .expect("second read") + .value + .expect("second value"); + assert_eq!( + first.as_ptr(), + second.as_ptr(), + "reads must share the snapshot payload" + ); + } + + #[test] + fn startup_rejects_keys_over_the_default_allocation_limit() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("config.json"); + let key = "k".repeat(1025); + fs::write(&path, serde_json::json!({key: "value"}).to_string()).expect("write"); + assert!(matches!( + AxumConfigStore::from_path(&path, ConfigStoreLimits::default()), + Err(ConfigStoreError::ValueTooLarge) + )); + } + + #[test] + fn startup_rejects_duplicate_keys_instead_of_overwriting() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("config.json"); + fs::write(&path, r#"{"key":"first","key":"second"}"#).expect("write duplicate-key fixture"); + assert!(matches!( + AxumConfigStore::from_path(&path, ConfigStoreLimits::default()), + Err(ConfigStoreError::Unavailable { .. }) + )); + } + + #[cfg(unix)] + #[test] + fn named_pipe_is_rejected_without_waiting_for_a_writer() { + use std::process::Command; + use std::sync::mpsc::{self, RecvTimeoutError}; + use std::thread; + let directory = tempdir().expect("directory"); + let path = directory.path().join("config.fifo"); + assert!( + Command::new("mkfifo") + .arg(&path) + .status() + .expect("mkfifo") + .success() + ); + let reader_path = path.clone(); + let (sender, receiver) = mpsc::channel(); + let loader = thread::spawn(move || { + sender + .send(AxumConfigStore::from_path(&reader_path, ConfigStoreLimits::default()).err()) + .expect("send"); + }); + let result = receiver.recv_timeout(Duration::from_secs(2)); + if matches!(result, Err(RecvTimeoutError::Timeout)) { + // Unblock a regressed blocking open before failing, so the test leaks no thread. + drop( + fs::OpenOptions::new() + .write(true) + .open(&path) + .expect("unblock reader"), + ); + } + loader.join().expect("loader"); + assert!(matches!( + result.expect("nonblocking rejection"), + Some(ConfigStoreError::Unavailable { .. }) + )); + } + #[test] fn bounded_get_accepts_exact_caps_and_reports_exact_backend_bytes() { - let cs = AxumConfigStore::from_map([("greeting".to_owned(), "hello".to_owned())]); + let cs = AxumConfigStore::from_map( + [("greeting".to_owned(), "hello".to_owned())], + ConfigStoreLimits::default(), + ) + .expect("snapshot"); let clock = MonotonicClock::default(); let read = block_on(cs.get_bounded( @@ -279,7 +419,11 @@ mod tests { #[test] fn bounded_get_rejects_either_cap_without_using_cloning_get() { for (max_backend_bytes, max_value_bytes) in [(4, 5), (5, 4)] { - let cs = AxumConfigStore::from_map([("greeting".to_owned(), "hello".to_owned())]); + let cs = AxumConfigStore::from_map( + [("greeting".to_owned(), "hello".to_owned())], + ConfigStoreLimits::default(), + ) + .expect("snapshot"); let clock = MonotonicClock::default(); let error = block_on(cs.get_bounded( @@ -298,7 +442,11 @@ mod tests { #[test] fn bounded_get_rejects_an_expired_deadline_before_reading() { - let cs = AxumConfigStore::from_map([("greeting".to_owned(), "hello".to_owned())]); + let cs = AxumConfigStore::from_map( + [("greeting".to_owned(), "hello".to_owned())], + ConfigStoreLimits::default(), + ) + .expect("snapshot"); let expired = Deadline::at_instant(MonotonicInstant::now()); let clock = MonotonicClock::default(); @@ -311,7 +459,11 @@ mod tests { #[test] fn bounded_config_read_uses_injected_clock() { - let cs = AxumConfigStore::from_map([("greeting".to_owned(), "hello".to_owned())]); + let cs = AxumConfigStore::from_map( + [("greeting".to_owned(), "hello".to_owned())], + ConfigStoreLimits::default(), + ) + .expect("snapshot"); let process_now = MonotonicInstant::now(); let injected_now = process_now .checked_sub(Duration::from_mins(1)) @@ -331,10 +483,14 @@ mod tests { #[test] fn axum_config_store_from_map_returns_values() { - let cs = AxumConfigStore::from_map([("greeting".to_owned(), "hello".to_owned())]); + let cs = AxumConfigStore::from_map( + [("greeting".to_owned(), "hello".to_owned())], + ConfigStoreLimits::default(), + ) + .expect("snapshot"); assert_eq!( block_on(cs.get("greeting")).expect("config value"), - Some("hello".to_owned()) + Some(edgezero_core::ConfigValue::from("hello")) ); assert_eq!(block_on(cs.get("missing")).expect("missing config"), None); } @@ -401,8 +557,11 @@ mod tests { #[test] fn axum_config_store_from_path_returns_empty_for_missing_file() { let temp = tempdir().expect("tempdir"); - let cs = AxumConfigStore::from_path(&temp.path().join("nope.json")) - .expect("missing file is permissive"); + let cs = AxumConfigStore::from_path( + &temp.path().join("nope.json"), + ConfigStoreLimits::default(), + ) + .expect("missing file is permissive"); assert_eq!(block_on(cs.get("anything")).expect("empty store"), None); } @@ -417,14 +576,15 @@ mod tests { ) .expect("write json"); - let cs = AxumConfigStore::from_path(&path).expect("parse json"); + let cs = + AxumConfigStore::from_path(&path, ConfigStoreLimits::default()).expect("parse json"); assert_eq!( block_on(cs.get("greeting")).expect("value"), - Some("hello from file".to_owned()) + Some(edgezero_core::ConfigValue::from("hello from file")) ); assert_eq!( block_on(cs.get("feature.new_checkout")).expect("dotted value"), - Some("false".to_owned()) + Some(edgezero_core::ConfigValue::from("false")) ); assert_eq!(block_on(cs.get("missing")).expect("missing"), None); } @@ -435,7 +595,7 @@ mod tests { let path = temp.path().join("local-config-bad.json"); fs::write(&path, "{not json}").expect("write"); - match AxumConfigStore::from_path(&path) { + match AxumConfigStore::from_path(&path, ConfigStoreLimits::default()) { Err(ConfigStoreError::Unavailable { .. }) => {} Err(other) => panic!("expected Unavailable, got {other:?}"), Ok(_) => panic!("malformed JSON must surface as error"), @@ -448,7 +608,7 @@ mod tests { let path = temp.path().join("local-config-numeric.json"); fs::write(&path, serde_json::json!({"greeting": 42_u32}).to_string()).expect("write"); - match AxumConfigStore::from_path(&path) { + match AxumConfigStore::from_path(&path, ConfigStoreLimits::default()) { Err(ConfigStoreError::Unavailable { .. }) => {} Err(other) => panic!("expected Unavailable, got {other:?}"), Ok(_) => panic!("non-string values must surface as error"), diff --git a/crates/edgezero-adapter-axum/src/config_store_limits.rs b/crates/edgezero-adapter-axum/src/config_store_limits.rs new file mode 100644 index 00000000..4aa1840f --- /dev/null +++ b/crates/edgezero-adapter-axum/src/config_store_limits.rs @@ -0,0 +1,230 @@ +//! Validated allocation budgets for immutable local configuration snapshots. + +use edgezero_core::env_config::EnvConfig; + +/// Limits shared by every local config store loaded during one server startup. +/// +/// Resident and startup allowances are requested allocation charges, not RSS. +/// Startup reserves five file budgets for input, decoder growth and temporary strings. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ConfigStoreLimits { + entries: usize, + file_bytes: usize, + key_bytes: usize, + resident_bytes: usize, + startup_bytes: usize, + value_bytes: usize, +} + +/// A rejected setting. The supplied value is never included in diagnostics. +#[derive(Debug, thiserror::Error)] +#[error("invalid Axum config-store setting `{field}`")] +pub struct ConfigStoreLimitsError { + field: &'static str, +} + +impl Default for ConfigStoreLimits { + #[inline] + fn default() -> Self { + Self { + entries: 1024, + file_bytes: 0x0100_0000, + key_bytes: 1024, + resident_bytes: 0x0200_0000, + startup_bytes: 0x0700_0000, + value_bytes: 0x0080_0000, + } + } +} + +impl ConfigStoreLimits { + /// Resolve `EDGEZERO__ADAPTER__CONFIG_STORE__*`. Missing settings alone use defaults. + /// + /// # Errors + /// Rejects malformed, zero, out-of-range or inconsistent explicit settings. + #[inline] + pub fn from_env(env: &EnvConfig) -> Result { + let defaults = Self::default(); + Self::new( + setting(env, "max_file_bytes", defaults.file_bytes)?, + setting(env, "max_entries", defaults.entries)?, + setting(env, "max_key_bytes", defaults.key_bytes)?, + setting(env, "max_value_bytes", defaults.value_bytes)?, + setting(env, "max_resident_bytes", defaults.resident_bytes)?, + setting(env, "max_startup_bytes", defaults.startup_bytes)?, + ) + } + + /// Maximum entries in any one snapshot, including duplicate-key attempts. + #[inline] + #[must_use] + pub const fn max_entries(self) -> usize { + self.entries + } + + /// Maximum wire bytes read from a single opened local file. + #[inline] + #[must_use] + pub const fn max_file_bytes(self) -> usize { + self.file_bytes + } + + /// Maximum decoded UTF-8 bytes per key. + #[inline] + #[must_use] + pub const fn max_key_bytes(self) -> usize { + self.key_bytes + } + + /// Maximum charged residency across the entire startup registry. + #[inline] + #[must_use] + pub const fn max_resident_bytes(self) -> usize { + self.resident_bytes + } + + /// Maximum charged residency plus staging during sequential startup loading. + #[inline] + #[must_use] + pub const fn max_startup_bytes(self) -> usize { + self.startup_bytes + } + + /// Maximum decoded UTF-8 bytes per value, before creating its shared allocation. + #[inline] + #[must_use] + pub const fn max_value_bytes(self) -> usize { + self.value_bytes + } + + /// Construct finite limits. File/value/resident limits support up to 256 MiB; + /// startup up to 1 GiB, keys up to 64 KiB and entries up to 65536. + /// + /// # Errors + /// Rejects invalid ranges, keys/values larger than the wire budget, or a startup + /// budget smaller than the worst-case input/decoder staging reservation. + #[inline] + pub fn new( + max_file_bytes: usize, + max_entries: usize, + max_key_bytes: usize, + max_value_bytes: usize, + max_resident_bytes: usize, + max_startup_bytes: usize, + ) -> Result { + for (valid, field) in [ + ( + (1..=0x1000_0000).contains(&max_file_bytes), + "max_file_bytes", + ), + ((1..=0x0001_0000).contains(&max_entries), "max_entries"), + ( + (1..=0x0001_0000).contains(&max_key_bytes) && max_key_bytes <= max_file_bytes, + "max_key_bytes", + ), + ( + (1..=0x1000_0000).contains(&max_value_bytes) && max_value_bytes <= max_file_bytes, + "max_value_bytes", + ), + ( + (1..=0x1000_0000).contains(&max_resident_bytes), + "max_resident_bytes", + ), + ( + (1..=0x4000_0000).contains(&max_startup_bytes) + && max_startup_bytes >= max_file_bytes.max(32).saturating_mul(5), + "max_startup_bytes", + ), + ] { + if !valid { + return Err(ConfigStoreLimitsError { field }); + } + } + Ok(Self { + entries: max_entries, + file_bytes: max_file_bytes, + key_bytes: max_key_bytes, + resident_bytes: max_resident_bytes, + startup_bytes: max_startup_bytes, + value_bytes: max_value_bytes, + }) + } + + /// Reserved requested input/decoder capacity while one file is loaded. + #[inline] + #[must_use] + pub const fn staging_bytes(self) -> usize { + let base = if self.file_bytes < 32 { + 32 + } else { + self.file_bytes + }; + base.saturating_mul(5) + } +} + +fn setting( + env: &EnvConfig, + field: &'static str, + default: usize, +) -> Result { + let Some(value) = env.get(&["adapter", "config_store", field]) else { + return Ok(default); + }; + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(ConfigStoreLimitsError { field }); + } + value + .parse() + .map_err(|_invalid_integer| ConfigStoreLimitsError { field }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_and_explicit_limits_are_validated() { + let defaults = ConfigStoreLimits::from_env(&EnvConfig::default()).expect("defaults"); + assert_eq!(defaults, ConfigStoreLimits::default()); + assert_eq!(defaults.staging_bytes(), 5 * defaults.max_file_bytes()); + let env = EnvConfig::from_vars([("EDGEZERO__ADAPTER__CONFIG_STORE__MAX_ENTRIES", "2")]); + assert_eq!( + ConfigStoreLimits::from_env(&env) + .expect("explicit") + .max_entries(), + 2 + ); + } + + #[test] + fn invalid_explicit_limits_never_fall_back_or_echo_values() { + for field in [ + "MAX_FILE_BYTES", + "MAX_ENTRIES", + "MAX_KEY_BYTES", + "MAX_VALUE_BYTES", + "MAX_RESIDENT_BYTES", + "MAX_STARTUP_BYTES", + ] { + for value in [ + "", + "0", + "-1", + "+2", + " 2", + "credential=private", + "18446744073709551616", + ] { + let key = format!("EDGEZERO__ADAPTER__CONFIG_STORE__{field}"); + let env = EnvConfig::from_vars([(key.as_str(), value)]); + let error = + ConfigStoreLimits::from_env(&env).expect_err("invalid explicit setting"); + assert!(!error.to_string().contains("credential=private")); + } + } + ConfigStoreLimits::new(10, 1, 1, 1, 10, 29).expect_err("invalid policy"); + ConfigStoreLimits::new(10, 1, 11, 1, 10, 30).expect_err("invalid policy"); + ConfigStoreLimits::new(10, 1, 1, 11, 10, 30).expect_err("invalid policy"); + } +} diff --git a/crates/edgezero-adapter-axum/src/connection.rs b/crates/edgezero-adapter-axum/src/connection.rs index 67037eca..16e77ff6 100644 --- a/crates/edgezero-adapter-axum/src/connection.rs +++ b/crates/edgezero-adapter-axum/src/connection.rs @@ -1,5 +1,8 @@ use std::error::Error as StdError; +use std::future::{self, Future}; +use std::io; +use edgezero_core::probe::LifecyclePhase; use http_body::Body as HttpBody; use hyper::Request; use hyper::body::Incoming; @@ -7,8 +10,11 @@ use hyper::server::conn::http1::Builder as Http1Builder; use hyper::service::Service; use hyper_util::rt::TokioIo; use tokio::net::TcpStream; +use tokio::sync::watch; use tokio::time::{Instant as TokioInstant, sleep_until}; +use crate::header_deadline::{HeaderDeadline, HeaderDeadlineExceeded, HeaderIo, HeaderService}; +use crate::ingress_config::AxumIngressConfig; use crate::response::AxumBodyError; use crate::response::EgressConnection; use crate::service::AxumIngressAbort; @@ -20,53 +26,112 @@ pub(crate) enum ConnectionExit { DeadlineExceeded, } -/// Owns one HTTP/1 connection until Hyper completes or an active response deadline closes it. +#[cfg(test)] +pub(crate) fn serve_http1( + stream: TcpStream, + service: ServiceType, + responses: EgressConnection, + limits: AxumIngressConfig, +) -> impl Future> +where + ServiceType: Service, Response = hyper::Response>, + ServiceType::Error: Into>, + ServiceType::Future: 'static, + ResponseBody: HttpBody + 'static, + ResponseBody::Error: Into>, +{ + serve_http1_with_shutdown(stream, service, responses, None, limits) +} + +/// Owns bounded HTTP/1 parsing, response deadlines and graceful lifecycle drain. #[expect( clippy::integer_division_remainder_used, reason = "tokio::select! expands to internal randomized branch selection arithmetic" )] -pub(crate) async fn serve_http1( +pub(crate) fn serve_http1_with_shutdown( stream: TcpStream, service: ServiceType, responses: EgressConnection, -) -> Result + mut phase: Option>, + limits: AxumIngressConfig, +) -> impl Future> where ServiceType: Service, Response = hyper::Response>, ServiceType::Error: Into>, + ServiceType::Future: 'static, ResponseBody: HttpBody + 'static, ResponseBody::Error: Into>, { - let connection_future = Http1Builder::new() - .keep_alive(true) - .serve_connection(TokioIo::new(stream), service); - let mut connection = Box::pin(connection_future); - - loop { - let changed = responses.changed(); - tokio::pin!(changed); - if let Some(deadline) = responses.next_deadline() { - tokio::select! { - result = &mut connection => { - responses.signal_transport_error(); - drop(connection); - return classify_connection_result(result); + // Stamp before scheduling the connection future, not on its first poll. + let head = HeaderDeadline::new(limits.head_read_timeout()); + async move { + let connection_future = Http1Builder::new() + .keep_alive(true) + .max_headers(limits.max_header_count()) + .max_header_size(limits.max_raw_head_bytes()) + .max_buf_size(limits.parser_buffer_bytes()) + .header_read_timeout(limits.head_read_timeout()) + .timer(head.clone()) + .serve_connection( + TokioIo::new(HeaderIo::new(stream, head.clone())), + HeaderService::new(service, head.clone(), limits, responses.clone()), + ); + let mut connection = Box::pin(connection_future); + + loop { + if phase.as_ref().is_some_and(|reader| { + matches!( + *reader.borrow(), + LifecyclePhase::Draining | LifecyclePhase::Stopped + ) + }) { + connection.as_mut().graceful_shutdown(); + phase = None; + } + let draining = async { + if let Some(reader) = phase.as_mut() { + let _changed = reader.changed().await; + } else { + future::pending::<()>().await; } - () = sleep_until(deadline) => { + }; + if head + .deadline() + .is_some_and(|deadline| TokioInstant::now() >= deadline) + { + responses.signal_elapsed_deadline(TokioInstant::now()); + responses.signal_transport_error(); + drop(connection); + return Ok(ConnectionExit::DeadlineExceeded); + } + let next_deadline = head + .deadline() + .into_iter() + .chain(responses.next_deadline()) + .min(); + let wait = async move { + if let Some(deadline) = next_deadline { + sleep_until(deadline).await; + } else { + future::pending::<()>().await; + } + }; + tokio::select! { + biased; + () = wait => { if responses.signal_elapsed_deadline(TokioInstant::now()) { drop(connection); return Ok(ConnectionExit::DeadlineExceeded); } } - () = &mut changed => {} - } - } else { - tokio::select! { result = &mut connection => { responses.signal_transport_error(); drop(connection); return classify_connection_result(result); } - () = &mut changed => {} + () = head.changed() => {} + () = responses.changed() => {} + () = draining => {} } } } @@ -97,6 +162,9 @@ fn error_chain_has_admission_abort(error: &hyper::Error) -> bool { } fn error_chain_has_deadline(error: &hyper::Error) -> bool { + if error.is_timeout() { + return true; + } let mut source = error.source(); while let Some(current) = source { if current @@ -105,27 +173,155 @@ fn error_chain_has_deadline(error: &hyper::Error) -> bool { { return true; } + if current.downcast_ref::().is_some() { + return true; + } + if current + .downcast_ref::() + .and_then(io::Error::get_ref) + .is_some_and(::is::) + { + return true; + } source = current.source(); } false } +#[cfg(test)] +mod bounded_tests { + use std::convert::Infallible; + use std::time::Duration; + + use axum::body::Body; + use futures::future::join; + use hyper::Response; + use hyper::service::service_fn; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + use tokio::net::{TcpListener, TcpStream}; + use tokio::time::timeout; + + use super::serve_http1; + use crate::ingress_config::AxumIngressConfig; + use crate::response::EgressConnection; + + async fn raw_exchange(request: &[u8]) -> Vec { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = async move { + let (socket, _) = listener.accept().await.unwrap(); + let service = service_fn(|_request| async { + Ok::<_, Infallible>(Response::new(Body::from("ok"))) + }); + let _result = serve_http1( + socket, + service, + EgressConnection::default(), + AxumIngressConfig::default(), + ) + .await; + }; + let client = async { + let mut stream = TcpStream::connect(address).await.unwrap(); + stream.write_all(request).await.unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.unwrap(); + response + }; + let ((), response) = timeout(Duration::from_secs(2), join(server, client)) + .await + .unwrap(); + response + } + + #[tokio::test] + async fn accepted_deadline_precedes_first_poll_but_not_admitted_handler_work() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::time::sleep; + + for delayed_poll in [true, false] { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&calls); + let server = async move { + let (socket, _) = listener.accept().await.unwrap(); + let service = service_fn(move |_request| { + let counted = Arc::clone(&observed); + async move { + counted.fetch_add(1, Ordering::SeqCst); + sleep(Duration::from_millis(200)).await; + Ok::<_, Infallible>(Response::new(Body::from("ok"))) + } + }); + let limits = + AxumIngressConfig::new(1, 8192, 100, Duration::from_millis(100)).unwrap(); + let connection = serve_http1(socket, service, EgressConnection::default(), limits); + if delayed_poll { + sleep(Duration::from_millis(150)).await; + } + let _result = connection.await; + }; + let client = async { + let mut stream = TcpStream::connect(address).await.unwrap(); + stream + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + let mut response = Vec::new(); + let _read = stream.read_to_end(&mut response).await; + response + }; + let ((), response) = timeout(Duration::from_secs(2), join(server, client)) + .await + .unwrap(); + if delayed_poll { + assert!(response.is_empty()); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } else { + assert!(response.starts_with(b"HTTP/1.1 200")); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + } + } + + #[tokio::test] + async fn default_raw_head_limit_rejects_first_byte_over_before_service() { + let prefix = b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nX-Pad: "; + let mut request = prefix.to_vec(); + request.extend(vec![b'a'; 0x0001_0000 - prefix.len() - 4]); + request.extend_from_slice(b"\r\n\r\n"); + let response = raw_exchange(&request).await; + assert!(response.starts_with(b"HTTP/1.1 200")); + request.insert(prefix.len(), b'a'); + let rejected = raw_exchange(&request).await; + assert!(rejected.starts_with(b"HTTP/1.1 431"), "unexpected status"); + } +} + #[cfg(test)] mod tests { use std::convert::Infallible; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; + use std::task::Poll; use std::time::Duration; + use bytes::Bytes; + use edgezero_core::app::App; use edgezero_core::body::Body; - use edgezero_core::http::{HeaderMap, Method, StatusCode, Version}; + use edgezero_core::error::EdgeError; + use edgezero_core::http::{HeaderMap, Method, StatusCode, Version, response_builder}; + use edgezero_core::ingress::{AdmissionDecision, IngressGrant}; use edgezero_core::response_egress::{ ResponseEgressAttempt, ResponseEgressCompletion, ResponseEgressHead, ResponseEgressObserver, ResponseEgressObserverHandle, ResponseEgressOutcome, - ResponseEgressReport, + ResponseEgressPolicy, ResponseEgressReport, }; - use edgezero_core::router::RouteMetadata; + use edgezero_core::router::{RouteMetadata, RouterService}; use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; - use futures_util::stream::{pending, repeat_with}; + use futures_util::stream::{pending, poll_fn, repeat_with}; use hyper::Request; use hyper::Response; use hyper::body::Incoming; @@ -136,17 +332,424 @@ mod tests { use tokio::time::timeout; use super::{ConnectionExit, serve_http1}; + use crate::ingress_config::AxumIngressConfig; use crate::response::{AxumEgressBody, EgressConnection}; + use crate::service::AxumServiceState; + + #[derive(Clone, Copy)] + enum DeclaredTail { + Clean, + Empty, + EmptyForever, + Error, + Excess, + Pending, + } #[derive(Clone, Default)] struct RecordingObserver(Arc>>); + struct SourceDropProbe(Arc); + impl ResponseEgressObserver for RecordingObserver { fn complete(&self, report: &ResponseEgressReport) { self.0.lock().expect("reports lock").push(report.clone()); } } + impl Drop for SourceDropProbe { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + fn declared_stream_app( + length: &'static str, + tail: DeclaredTail, + observer: &RecordingObserver, + polls: &Arc, + drops: &Arc, + ) -> App { + let source_polls = Arc::clone(polls); + let source_drops = Arc::clone(drops); + let router = RouterService::builder() + .get("/", move |_ctx| { + let observed_polls = Arc::clone(&source_polls); + let drop_probe = SourceDropProbe(Arc::clone(&source_drops)); + async move { + let stream = poll_fn(move |_context| { + let _keep_source_alive = &drop_probe; + let index = observed_polls.fetch_add(1, Ordering::SeqCst); + if index == 0 && length != "0" { + return Poll::Ready(Some(Ok(Bytes::from_static(b"abc")))); + } + match tail { + DeclaredTail::Empty if index <= 1 => { + Poll::Ready(Some(Ok(Bytes::new()))) + } + DeclaredTail::Clean | DeclaredTail::Empty => Poll::Ready(None), + DeclaredTail::EmptyForever => Poll::Ready(Some(Ok(Bytes::new()))), + DeclaredTail::Error => Poll::Ready(Some(Err(EdgeError::internal( + anyhow::anyhow!("late source error"), + )))), + DeclaredTail::Excess => { + Poll::Ready(Some(Ok(Bytes::from_static(b"extra")))) + } + DeclaredTail::Pending => Poll::Pending, + } + }); + let response = response_builder() + .header("content-length", length) + .body(Body::from_stream(stream)) + .expect("declared response"); + Ok::<_, EdgeError>(response) + } + }) + .build(); + let mut app = App::new(router); + app.set_response_egress_observer(observer.clone()); + let budget = if matches!(tail, DeclaredTail::Pending | DeclaredTail::EmptyForever) { + Duration::from_millis(100) + } else { + Duration::from_secs(5) + }; + app.set_response_egress_policy(move |_, started_at| ResponseEgressPolicy { + write_deadline: Deadline::at_instant(started_at.checked_add(budget).expect("deadline")), + }); + app + } + + async fn exchange_with_app(app: App) -> (String, Result) { + exchange_with_method(app, "GET").await + } + + async fn exchange_with_method( + app: App, + method: &str, + ) -> (String, Result) { + LocalSet::new() + .run_until(async { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind listener"); + let address = listener.local_addr().expect("listener address"); + let state = AxumServiceState::from_app(app); + let server = spawn_local(async move { + let (socket, peer) = listener.accept().await.expect("accept client"); + let responses = EgressConnection::default(); + let service = state.for_connection(peer, responses.clone()); + serve_http1(socket, service, responses, AxumIngressConfig::default()).await + }); + let mut client = TcpStream::connect(address).await.expect("connect client"); + client + .write_all( + format!( + "{method} / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await + .expect("write request"); + let mut wire = Vec::new(); + timeout(Duration::from_secs(2), client.read_to_end(&mut wire)) + .await + .expect("bounded response lifetime") + .expect("read response"); + let exit = server.await.expect("server task"); + (String::from_utf8(wire).expect("ASCII response"), exit) + }) + .await + } + + #[tokio::test(flavor = "current_thread")] + async fn bodyless_tcp_responses_release_the_response_resource_exactly_once() { + for (method, status) in [ + ("HEAD", StatusCode::OK), + ("GET", StatusCode::NO_CONTENT), + ("GET", StatusCode::NOT_MODIFIED), + ] { + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let completed = Arc::new(AtomicUsize::new(0)); + let resources = Arc::new(AtomicUsize::new(0)); + let source_polls = Arc::clone(&polls); + let source_drops = Arc::clone(&drops); + let router = RouterService::builder() + .route( + "/", + Method::from_bytes(method.as_bytes()).expect("method"), + move |_| { + let observed = Arc::clone(&source_polls); + let guard = SourceDropProbe(Arc::clone(&source_drops)); + async move { + let body = poll_fn(move |_cx| { + let _keep_alive = &guard; + observed.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + }); + Ok::<_, EdgeError>( + response_builder() + .status(status) + .body(Body::from_stream(body)) + .expect("response"), + ) + } + }, + ) + .build(); + let mut app = App::new(router); + let observer = RecordingObserver::default(); + app.set_response_egress_observer(observer.clone()); + let terminals = Arc::clone(&completed); + let released = Arc::clone(&resources); + app.set_ingress_admission_policy(move |head| AdmissionDecision::Admit { + completion: ResponseEgressCompletion::new({ + let callback = Arc::clone(&terminals); + let resource = SourceDropProbe(Arc::clone(&released)); + move |report| { + assert_eq!(report.outcome, ResponseEgressOutcome::HostHandoff); + callback.fetch_add(1, Ordering::SeqCst); + drop(resource); + } + }), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + }); + let (wire, exit) = exchange_with_method(app, method).await; + assert_eq!(exit.expect("complete"), ConnectionExit::Completed); + assert_eq!(wire.split_once("\r\n\r\n").expect("wire head").1, ""); + assert_eq!(polls.load(Ordering::SeqCst), 0); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!(completed.load(Ordering::SeqCst), 1); + assert_eq!(resources.load(Ordering::SeqCst), 1); + let reports = observer.0.lock().expect("reports"); + assert_eq!(reports.len(), 1); + assert_eq!(reports.first().expect("report").bytes_written, 0); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn oversized_response_head_closes_and_completes_once_without_polling_body() { + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let resource_drops = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&polls); + let observed_drops = Arc::clone(&drops); + let router = RouterService::builder() + .get("/", move |_ctx| { + let source_polls = Arc::clone(&observed_polls); + let probe = SourceDropProbe(Arc::clone(&observed_drops)); + async move { + let source = poll_fn(move |_cx| { + let _keep_alive = &probe; + source_polls.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + }); + Ok::<_, EdgeError>( + response_builder() + .header( + "x-oversized", + "a".repeat(AxumIngressConfig::default().max_raw_head_bytes()), + ) + .body(Body::from_stream(source)) + .unwrap(), + ) + } + }) + .build(); + let mut app = App::new(router); + app.set_response_egress_observer(observer.clone()); + let observed_resources = Arc::clone(&resource_drops); + app.set_ingress_admission_policy(move |head| AdmissionDecision::Admit { + completion: ResponseEgressCompletion::new({ + let resource = SourceDropProbe(Arc::clone(&observed_resources)); + move |_report| drop(resource) + }), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + }); + let (wire, exit) = exchange_with_app(app).await; + assert!(wire.is_empty()); + let _error = exit.expect_err("oversized response head must close the connection"); + assert_eq!(polls.load(Ordering::SeqCst), 0); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!(resource_drops.load(Ordering::SeqCst), 1); + let reports = observer.0.lock().unwrap(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::TransportError); + assert_eq!(reports[0].bytes_written, 0); + } + + #[tokio::test(flavor = "current_thread")] + async fn hyper_declared_stream_checks_clean_eof_including_zero() { + for (length, tail, expected_body, expected_polls) in [ + ("3", DeclaredTail::Clean, "abc", 2), + ("0", DeclaredTail::Clean, "", 1), + ("3", DeclaredTail::Empty, "abc", 3), + ("0", DeclaredTail::Empty, "", 3), + ] { + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let app = declared_stream_app(length, tail, &observer, &polls, &drops); + + let (wire, exit) = exchange_with_app(app).await; + assert_eq!(exit.expect("clean connection"), ConnectionExit::Completed); + let (head, body) = wire.split_once("\r\n\r\n").expect("response head"); + assert!(head.starts_with("HTTP/1.1 200")); + assert_eq!(body, expected_body); + assert_eq!(polls.load(Ordering::SeqCst), expected_polls); + assert_eq!(drops.load(Ordering::SeqCst), 1); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::HostHandoff); + assert_eq!( + reports[0].bytes_written, + u64::try_from(expected_body.len()).expect("body length") + ); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn hyper_declared_stream_rejects_excess_and_error_tails() { + for tail in [DeclaredTail::Excess, DeclaredTail::Error] { + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let app = declared_stream_app("3", tail, &observer, &polls, &drops); + + let (wire, exit) = exchange_with_app(app).await; + assert!(exit.is_err(), "bad tail must close the connection"); + assert!(!wire.ends_with("abc"), "unvalidated final frame was sent"); + assert_eq!(polls.load(Ordering::SeqCst), 2); + assert_eq!(drops.load(Ordering::SeqCst), 1); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::SourceError); + assert_eq!(reports[0].bytes_written, 0); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn hyper_declared_stream_pending_eof_is_deadline_bounded() { + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let app = declared_stream_app("3", DeclaredTail::Pending, &observer, &polls, &drops); + + let (wire, exit) = exchange_with_app(app).await; + assert_eq!( + exit.expect("deadline closes connection"), + ConnectionExit::DeadlineExceeded + ); + assert!(!wire.ends_with("abc")); + assert!(polls.load(Ordering::SeqCst) >= 2); + assert_eq!(drops.load(Ordering::SeqCst), 1); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + assert_eq!(reports[0].bytes_written, 0); + } + + #[tokio::test(flavor = "current_thread")] + async fn hyper_zero_declared_stream_validation_is_bounded_before_handoff() { + for tail in [ + DeclaredTail::Excess, + DeclaredTail::Error, + DeclaredTail::Pending, + ] { + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let app = declared_stream_app("0", tail, &observer, &polls, &drops); + + let (wire, exit) = exchange_with_app(app).await; + assert_eq!(exit.expect("bounded fallback"), ConnectionExit::Completed); + let expected = if matches!(tail, DeclaredTail::Pending) { + ("HTTP/1.1 504", ResponseEgressOutcome::DeadlineExceeded) + } else { + ("HTTP/1.1 500", ResponseEgressOutcome::ConversionError) + }; + assert!(wire.starts_with(expected.0)); + assert!(polls.load(Ordering::SeqCst) > 0); + assert_eq!(drops.load(Ordering::SeqCst), 1); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, expected.1); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn hyper_eof_lookahead_yields_to_host_deadlines_with_a_frozen_clock() { + for length in ["3", "0"] { + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let mut app = declared_stream_app( + length, + DeclaredTail::EmptyForever, + &observer, + &polls, + &drops, + ); + let frozen_at = MonotonicInstant::now(); + app.set_monotonic_clock(MonotonicClock::new(move || frozen_at)); + + let (wire, exit) = exchange_with_app(app).await; + if length == "3" { + assert_eq!( + exit.expect("host deadline closes connection"), + ConnectionExit::DeadlineExceeded + ); + assert!(!wire.ends_with("abc")); + } else { + assert_eq!(exit.expect("bounded fallback"), ConnectionExit::Completed); + assert!(wire.starts_with("HTTP/1.1 504")); + } + assert!(polls.load(Ordering::SeqCst) > 1); + assert_eq!(drops.load(Ordering::SeqCst), 1); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn hyper_zero_length_registration_expiry_uses_timeout_fallback() { + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let mut app = declared_stream_app("0", DeclaredTail::Clean, &observer, &polls, &drops); + let started_at = MonotonicInstant::now(); + let deadline = started_at + .checked_add(Duration::from_secs(5)) + .expect("deadline"); + let observed_polls = Arc::clone(&polls); + let after_eof_samples = AtomicUsize::new(0); + app.set_monotonic_clock(MonotonicClock::new(move || { + if observed_polls.load(Ordering::SeqCst) == 0 + || after_eof_samples.fetch_add(1, Ordering::SeqCst) == 0 + { + started_at + } else { + deadline + } + })); + + let (wire, exit) = exchange_with_app(app).await; + assert_eq!(exit.expect("bounded fallback"), ConnectionExit::Completed); + assert!( + wire.starts_with("HTTP/1.1 504"), + "expiry at registration must remain a timeout" + ); + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(drops.load(Ordering::SeqCst), 1); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + } + fn attempt( observer: &RecordingObserver, started_at: MonotonicInstant, @@ -208,7 +811,7 @@ mod tests { } }); - serve_http1(stream, service, responses).await + serve_http1(stream, service, responses, AxumIngressConfig::default()).await }); let mut client = TcpStream::connect(address).await.expect("connect client"); @@ -277,7 +880,7 @@ mod tests { Ok::<_, Infallible>(Response::new(registered_body)) } }); - serve_http1(stream, service, responses).await + serve_http1(stream, service, responses, AxumIngressConfig::default()).await }); let mut client = TcpStream::connect(address).await.expect("connect client"); diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 81f48ada..c022aa22 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -1,39 +1,102 @@ +#![expect( + clippy::pub_use, + reason = "validated options retain the existing dev_server API location" +)] +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "compatibility entrypoints, normalization, native ownership and startup policy remain grouped by responsibility" +)] + +use std::any::Any; +use std::collections::BTreeMap; +use std::env::vars_os; use std::fs; -use std::future; +use std::future::{self, Future}; +use std::io::ErrorKind; #[cfg(test)] use std::iter; use std::net::{SocketAddr, TcpListener as StdTcpListener}; +use std::panic::AssertUnwindSafe; use std::path::{Path, PathBuf}; -use std::str::FromStr as _; +use std::pin::Pin; +use std::process; use std::sync::Arc; +use std::time::Duration; use anyhow::Context as _; -use tokio::net::TcpListener as TokioTcpListener; -use tokio::runtime::Builder as RuntimeBuilder; -use tokio::signal; -use tokio::sync::oneshot::{Receiver as ShutdownReceiver, Sender as ShutdownSender, channel}; -use tokio::task::{LocalSet, spawn_blocking, spawn_local}; - use edgezero_core::addr; use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; -use edgezero_core::config_store::ConfigStoreHandle; +use edgezero_core::config_store::{ConfigStoreError, ConfigStoreHandle, ConfigStoreOpenFailure}; use edgezero_core::env_config::EnvConfig; use edgezero_core::key_value_store::KvHandle; +use edgezero_core::logging::{BOOT_LOG_LEVEL, BOOT_LOG_TARGET, resolve_logging_level}; +use edgezero_core::probe::LifecyclePhase; use edgezero_core::router::RouterService; use edgezero_core::secret_store::SecretHandle; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, }; +use futures_util::{FutureExt as _, StreamExt as _, stream::FuturesUnordered}; use log::LevelFilter; use simple_logger::SimpleLogger; -use std::collections::BTreeMap; +use tokio::net::TcpListener as TokioTcpListener; +use tokio::runtime::Builder as RuntimeBuilder; +use tokio::signal; +#[cfg(test)] +use tokio::sync::oneshot::{Receiver as ShutdownReceiver, Sender as ShutdownSender, channel}; +use tokio::sync::watch; +#[cfg(test)] +use tokio::task::spawn_blocking; +use tokio::task::{LocalSet, yield_now}; +use tokio::time::{Instant as NativeInstant, sleep_until}; use crate::config_store::AxumConfigStore; -use crate::connection::serve_http1; +use crate::config_store_limits::ConfigStoreLimits; +use crate::connection::{ConnectionExit, serve_http1_with_shutdown}; +use crate::ingress_config::AxumIngressConfig; use crate::key_value_store::PersistentKvStore; +use crate::native_stores::{self, NativeSourcePlan, NativeStoreOverrides, NativeStoreSource}; +use crate::outbound::AxumOutboundClient; use crate::response::EgressConnection; +pub use crate::run_options::AxumRunOptions; +use crate::run_options::{failure, shutdown_grace, strip_native_config_prefix}; use crate::secret_store::EnvSecretStore; -use crate::service::AxumServiceState; +use crate::service::{ + AxumServiceState, legacy_config_registry, legacy_kv_registry, legacy_secret_registry, +}; + +/// Local application-owned startup work, borrowing the unshared App and prepared stores. +pub type LocalStartupFuture<'startup> = + Pin> + 'startup>>; + +/// The exact registries prepared for requests. Handles may be cloned by the application; +/// retained KV clones must be released by their owners before reopening or backing up. +#[derive(Default)] +pub struct PreparedStores { + config: Option, + kv: Option, + secrets: Option, +} + +impl PreparedStores { + #[must_use] + #[inline] + pub fn config(&self) -> Option<&ConfigRegistry> { + self.config.as_ref() + } + + #[must_use] + #[inline] + pub fn kv(&self) -> Option<&KvRegistry> { + self.kv.as_ref() + } + + #[must_use] + #[inline] + pub fn secrets(&self) -> Option<&SecretRegistry> { + self.secrets.as_ref() + } +} #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum KvInitRequirement { @@ -46,6 +109,8 @@ enum KvInitRequirement { pub struct AxumDevServerConfig { pub addr: SocketAddr, pub enable_ctrl_c: bool, + /// Validated listener, parser and idle-plus-header limits. + pub ingress: AxumIngressConfig, } impl Default for AxumDevServerConfig { @@ -54,6 +119,7 @@ impl Default for AxumDevServerConfig { Self { addr: SocketAddr::from((addr::DEFAULT_HOST, addr::DEFAULT_PORT)), enable_ctrl_c: true, + ingress: AxumIngressConfig::default(), } } } @@ -94,32 +160,19 @@ impl AxumDevServer { /// Returns an error if the dev server fails to bind, the Tokio runtime fails to start, or the underlying request loop returns an error. #[inline] pub fn run(self) -> anyhow::Result<()> { - let runtime = RuntimeBuilder::new_multi_thread() - .enable_all() - .build() - .context("failed to build tokio runtime")?; - - runtime.block_on(async move { self.run_async().await }) - } - - async fn run_async(self) -> anyhow::Result<()> { - let AxumDevServer { + let Self { router, config, stores, } = self; - - // Allow binding to already-open listener if caller created one to surface errors early. - let std_listener = StdTcpListener::bind(config.addr) - .with_context(|| format!("failed to bind dev server to {}", config.addr))?; - std_listener - .set_nonblocking(true) - .context("failed to set listener to non-blocking")?; - - let listener = TokioTcpListener::from_std(std_listener) - .context("failed to adopt std listener into tokio")?; - - serve_with_stores(App::new(router), listener, config.enable_ctrl_c, stores).await + run_local( + ListenerSource::Bind(config.addr), + config.enable_ctrl_c, + Duration::from_secs(10), + config.ingress, + future::pending::<()>(), + async move { prepare_transport(App::new(router), stores.into()) }, + ) } #[cfg(test)] @@ -129,7 +182,14 @@ impl AxumDevServer { config, stores, } = self; - serve_with_stores(App::new(router), listener, config.enable_ctrl_c, stores).await + serve_with_stores( + App::new(router), + listener, + config.enable_ctrl_c, + config.ingress, + stores, + ) + .await } #[must_use] @@ -203,18 +263,16 @@ fn kv_init_requirement(stores: StoresMetadata) -> KvInitRequirement { } } -fn kv_store_path(store_name: &str) -> PathBuf { - // Every declared id gets its own slug-based filename. The - // pre-rewrite hard-coded `.edgezero/kv.redb` shortcut for - // store_name == "EDGEZERO_KV" is gone -- the runtime no longer - // hands out a default name; if you reach here you have a real - // declared id and the slug encoding handles every shape - // uniformly. - PathBuf::from(".edgezero").join(format!( +fn kv_store_file_name(store_name: &str) -> String { + format!( "kv-{}-{:016x}.redb", store_name_slug(store_name), stable_store_name_hash(store_name) - )) + ) +} + +fn kv_store_path(store_name: &str) -> PathBuf { + PathBuf::from(".edgezero").join(kv_store_file_name(store_name)) } fn store_name_slug(store_name: &str) -> String { @@ -274,10 +332,29 @@ fn kv_handle_from_path(kv_path: &Path) -> anyhow::Result { Ok(KvHandle::new(kv_store)) } +impl From for PreparedStores { + #[inline] + fn from(stores: Stores) -> Self { + Self { + config: stores + .config_registry + .or_else(|| stores.config_store.map(legacy_config_registry)), + kv: stores + .kv_registry + .or_else(|| stores.kv.map(legacy_kv_registry)), + secrets: stores + .secret_registry + .or_else(|| stores.secrets.map(legacy_secret_registry)), + } + } +} + +#[cfg(test)] async fn serve_with_stores( app: App, listener: TokioTcpListener, enable_ctrl_c: bool, + ingress: AxumIngressConfig, stores: Stores, ) -> anyhow::Result<()> { struct ShutdownOnDrop(Option>); @@ -300,6 +377,7 @@ async fn serve_with_stores( app, native_listener, enable_ctrl_c, + ingress, stores, shutdown_receiver, ) @@ -309,77 +387,295 @@ async fn serve_with_stores( result } +#[cfg(test)] fn serve_local( app: App, std_listener: StdTcpListener, enable_ctrl_c: bool, + ingress: AxumIngressConfig, stores: Stores, shutdown_receiver: ShutdownReceiver<()>, ) -> anyhow::Result<()> { - let mut service = AxumServiceState::from_app(app); - if let Some(registry) = stores.config_registry { - service = service.with_config_registry(registry); - } - if let Some(handle) = stores.config_store { - service = service.with_config_store_handle(handle); - } - if let Some(registry) = stores.kv_registry { - service = service.with_kv_registry(registry); - } - if let Some(handle) = stores.kv { - service = service.with_kv_handle(handle); + run_local( + ListenerSource::Prebound(std_listener), + enable_ctrl_c, + Duration::ZERO, + ingress, + async move { + let _closed = shutdown_receiver.await; + }, + async move { prepare_transport(app, stores.into()) }, + ) +} + +type PreparedApp = (App, PreparedStores, Arc); +type ConnectionResult = Result, Box>; +type Connections = FuturesUnordered>>>; + +enum ListenerSource { + Bind(SocketAddr), + #[cfg(test)] + Prebound(StdTcpListener), +} + +#[cfg_attr( + not(test), + expect( + clippy::needless_pass_by_value, + reason = "test embedding consumes a prebound listener through the same source" + ) +)] +fn adopt_listener(source: ListenerSource) -> anyhow::Result { + let listener = match source { + ListenerSource::Bind(addr) => StdTcpListener::bind(addr) + .map_err(|_error| failure("bind", "listener", "address unavailable"))?, + #[cfg(test)] + ListenerSource::Prebound(listener) => listener, + }; + listener + .set_nonblocking(true) + .map_err(|_error| failure("bind", "listener", "nonblocking setup failed"))?; + TokioTcpListener::from_std(listener) + .map_err(|_error| failure("bind", "listener", "runtime adoption failed")) +} + +fn prepare_transport(app: App, stores: PreparedStores) -> anyhow::Result { + let transport = AxumOutboundClient::try_transport() + .map_err(|_error| failure("transport", "HTTP client", "initialization failed"))?; + Ok((app, stores, transport)) +} + +struct Signals { + #[cfg(unix)] + interrupt: signal::unix::Signal, + #[cfg(unix)] + terminate: signal::unix::Signal, +} + +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "registration precedes observation in this lifecycle owner" +)] +impl Signals { + fn register(enabled: bool) -> anyhow::Result> { + if !enabled { + return Ok(None); + } + #[cfg(unix)] + { + let interrupt = signal::unix::signal(signal::unix::SignalKind::interrupt()) + .map_err(|_error| failure("signals", "SIGINT", "registration failed"))?; + let terminate = signal::unix::signal(signal::unix::SignalKind::terminate()) + .map_err(|_error| failure("signals", "SIGTERM", "registration failed"))?; + Ok(Some(Self { + interrupt, + terminate, + })) + } + #[cfg(not(unix))] + { + Ok(Some(Self {})) + } } - if let Some(registry) = stores.secret_registry { - service = service.with_secret_registry(registry); + + #[expect( + clippy::integer_division_remainder_used, + reason = "tokio select branch arithmetic" + )] + async fn next(&mut self) -> anyhow::Result<()> { + #[cfg(unix)] + { + let received = tokio::select! { + received = self.interrupt.recv() => received, + received = self.terminate.recv() => received, + }; + received.ok_or_else(|| failure("signals", "termination", "observation closed")) + } + #[cfg(not(unix))] + { + signal::ctrl_c() + .await + .map_err(|_error| failure("signals", "SIGINT", "registration failed")) + } } - if let Some(handle) = stores.secrets { - service = service.with_secret_handle(handle); +} + +async fn next_signal(signals: &mut Option) -> anyhow::Result<()> { + match signals { + Some(source) => source.next().await, + None => future::pending().await, } +} +fn run_local( + listener: ListenerSource, + enable_signals: bool, + grace: Duration, + ingress: AxumIngressConfig, + stop: Stop, + prepare: Prepare, +) -> anyhow::Result<()> +where + Prepare: Future>, + Stop: Future, +{ let runtime = RuntimeBuilder::new_current_thread() .enable_all() .build() - .context("failed to build axum connection runtime")?; + .map_err(|_error| failure("runtime", "local runner", "creation failed"))?; let local = LocalSet::new(); - #[expect( - clippy::integer_division_remainder_used, - reason = "tokio::select! expands to internal randomized branch selection arithmetic" - )] - let serve = async move { - let listener = TokioTcpListener::from_std(std_listener) - .context("failed to adopt listener on axum connection runtime")?; - let shutdown = async move { - let _closed = shutdown_receiver.await; - }; - let ctrl_c = async move { - if enable_ctrl_c { - let _signal = signal::ctrl_c().await; - } else { - future::pending::<()>().await; - } - }; - tokio::pin!(shutdown); - tokio::pin!(ctrl_c); + runtime.block_on(local.run_until(async move { + let mut signals = Signals::register(enable_signals)?; + let (phase, reader) = watch::channel(LifecyclePhase::Starting); + let result = run_owned( + listener, + grace, + ingress, + stop, + prepare, + &mut signals, + &phase, + reader, + ) + .await; + phase.send_replace(LifecyclePhase::Stopped); + result + })) +} - loop { - tokio::select! { - accepted = listener.accept() => { - let (stream, remote_addr) = accepted.context("axum listener accept failed")?; - let responses = EgressConnection::default(); - let connection_service = service.for_connection(remote_addr, responses.clone()); - spawn_local(async move { - if let Err(error) = serve_http1(stream, connection_service, responses).await { - log::debug!("axum HTTP/1 connection ended: {error}"); +#[expect( + clippy::integer_division_remainder_used, + reason = "tokio select branch arithmetic" +)] +#[expect( + clippy::too_many_arguments, + reason = "one lifecycle owner carries the validated ingress policy and stop sources" +)] +async fn run_owned( + listener_source: ListenerSource, + grace: Duration, + ingress: AxumIngressConfig, + stop: Stop, + prepare: Prepare, + signals: &mut Option, + phase: &watch::Sender, + reader: watch::Receiver, +) -> anyhow::Result<()> +where + Prepare: Future>, + Stop: Future, +{ + tokio::pin!(stop); + tokio::pin!(prepare); + let prepared = tokio::select! { + biased; + signal = next_signal(signals) => { + signal?; + phase.send_replace(LifecyclePhase::Draining); + return Ok(()); + } + () = &mut stop => { + phase.send_replace(LifecyclePhase::Draining); + return Ok(()); + } + prepared = &mut prepare => prepared?, + }; + let (app, stores, transport) = prepared; + let mut service = AxumServiceState::with_transport(app, transport).with_phase(reader.clone()); + if let Some(registry) = stores.config { + service = service.with_config_registry(registry); + } + if let Some(registry) = stores.kv { + service = service.with_kv_registry(registry); + } + if let Some(registry) = stores.secrets { + service = service.with_secret_registry(registry); + } + // Give stop observation another poll before bind/adoption and readiness. + let listener = tokio::select! { + biased; + signal = next_signal(signals) => { signal?; phase.send_replace(LifecyclePhase::Draining); return Ok(()); } + () = &mut stop => { phase.send_replace(LifecyclePhase::Draining); return Ok(()); } + result = async { yield_now().await; adopt_listener(listener_source) } => result?, + }; + phase.send_replace(LifecyclePhase::Ready); + log::info!("[edgezero] native server ready"); + let mut tasks = Connections::new(); + let mut outcome = Ok(()); + loop { + tokio::select! { + biased; + signal = next_signal(signals) => { outcome = signal; break; } + () = &mut stop => break, + Some(result) = tasks.next(), if !tasks.is_empty() => { + report_connection_exit(&result); + } + accepted = listener.accept() => { + match accepted { + Ok((stream, remote_addr)) => { + if tasks.len() >= ingress.max_connections() { + drop(stream); + continue; } - }); + let responses = EgressConnection::default(); + let connection_service = service.for_connection(remote_addr, responses.clone()); + let connection = serve_http1_with_shutdown(stream, connection_service, responses, Some(reader.clone()), ingress); + tasks.push(Box::pin(AssertUnwindSafe(connection).catch_unwind())); + } + Err(_error) => { + outcome = Err(failure("runner", "listener", "accept failed")); + break; + } } - () = &mut shutdown => break, - () = &mut ctrl_c => break, } } - Ok(()) + } + phase.send_replace(LifecyclePhase::Draining); + drop(listener); + let result = drain_tasks(&mut tasks, grace, signals, outcome).await; + // Service/registry clones are dropped only after every owned connection settles. + drop(service); + result +} + +#[expect( + clippy::integer_division_remainder_used, + reason = "tokio select branch arithmetic" +)] +async fn drain_tasks( + tasks: &mut Connections, + grace: Duration, + signals: &mut Option, + mut outcome: anyhow::Result<()>, +) -> anyhow::Result<()> { + let Some(deadline) = NativeInstant::now().checked_add(grace) else { + tasks.clear(); + return Err(failure("shutdown", "budget", "unusable deadline")); }; - runtime.block_on(local.run_until(serve)) + while !tasks.is_empty() { + tokio::select! { + biased; + signal = next_signal(signals) => { + outcome = Err(signal.err().unwrap_or_else(|| failure("shutdown", "signal", "forced stop"))); + break; + } + () = sleep_until(deadline) => { + outcome = Err(failure("shutdown", "budget", "grace exhausted")); + break; + } + Some(result) = tasks.next() => { + report_connection_exit(&result); + } + } + } + tasks.clear(); + outcome +} + +fn report_connection_exit(result: &ConnectionResult) { + if !matches!(result, Ok(Ok(_))) { + log::debug!("axum HTTP/1 connection rejected or disconnected"); + } } /// Entry point for an Axum dev-server application. @@ -392,65 +688,436 @@ fn serve_local( /// # Errors /// Returns an error if the dev server fails to bind or any required store handle cannot be initialised. fn build_app_for_dispatch() -> anyhow::Result { - App::build::(crate::AXUM_PLATFORM).context("application configuration failed") + App::build::(crate::AXUM_PLATFORM) + .map_err(|_error| anyhow::anyhow!("application configuration failed")) +} + +fn app_logger(env: &EnvConfig) -> SimpleLogger { + SimpleLogger::new() + .with_level(resolve_logging_level(env)) + .with_module_level(BOOT_LOG_TARGET, BOOT_LOG_LEVEL) +} + +/// Generated application entrypoint: retain the development runner unless an +/// explicit native bindings file is selected. A selected file always enters +/// strict production startup, where a provider compiled out of the binary fails +/// closed instead of falling back to local stores. +/// +/// # Errors +/// Returns startup or listener errors from the selected runner. +#[inline] +pub fn run_generated_app() -> anyhow::Result<()> { + let bindings_selected = + vars_os().any(|(key, _value)| key.to_str().is_some_and(is_store_bindings_locator_key)); + if bindings_selected { + // `run_production_app` captures all bootstrap settings and the selected + // bindings document once, then rejects unavailable compiled providers. + run_production_app::() + } else { + // Preserve existing generated local-development behavior exactly when + // no native bindings locator was selected. + run_app::() + } +} + +fn is_store_bindings_locator_key(key: &str) -> bool { + strip_native_config_prefix(key) + .is_some_and(|path| path.eq_ignore_ascii_case("STORE_BINDINGS_FILE")) } /// Runs an application with the Axum development server. /// /// # Errors -/// Returns an error if application configuration, runtime setup, store initialization, listener +/// Returns an error if logger setup, application configuration, runtime setup, store initialization, listener /// binding, or connection serving fails. #[inline] pub fn run_app() -> anyhow::Result<()> { - let app = build_app_for_dispatch::()?; - let env = EnvConfig::from_env(); - let stores = A::stores(); - let kv_init_requirement = kv_init_requirement(stores); + run_app_with_preflight::(|| Ok(())) +} - let level = env - .logging_level() - .and_then(|raw| LevelFilter::from_str(raw).ok()) - .unwrap_or(LevelFilter::Info); +/// Runs a startup check after logging and policy validation, before app configuration. +/// +/// # Errors +/// Returns an error for logging, preflight, configuration, stores, or serving failure. +#[inline] +pub fn run_app_with_preflight(preflight: F) -> anyhow::Result<()> +where + A: Hooks, + F: FnOnce() -> anyhow::Result<()>, +{ + let env = EnvConfig::from_env(); + if !A::owns_logging() { + app_logger(&env) + .init() + .context("failed to initialize application logger")?; + } + let ingress = AxumIngressConfig::from_env(&env)?; + let config_limits = ConfigStoreLimits::from_env(&env)?; + log::info!("[edgezero] axum HTTP/1 ingress limits: {ingress:?}"); + log::info!("[edgezero] axum config snapshot limits: {config_limits:?}"); + preflight()?; + let grace = shutdown_grace(&env)?; + let resolution = resolve_addr(&env); + run_local( + ListenerSource::Bind(resolution.addr), + true, + grace, + ingress, + future::pending::<()>(), + async move { + let app = build_app_for_dispatch::()?; + let metadata = A::stores(); + for warning in &resolution.warnings { + log::warn!(target: BOOT_LOG_TARGET, "{warning}"); + } + let stores = PreparedStores { + kv: build_kv_registry(metadata.kv, &env, kv_init_requirement(metadata))?, + config: build_config_registry(metadata.config, &env, config_limits)?, + secrets: build_secret_registry(metadata.secrets, &env), + }; + prepare_transport(app, stores) + }, + ) +} +fn init_logging(level: LevelFilter) -> anyhow::Result<()> { if !A::owns_logging() { - let _logger_init = SimpleLogger::new().with_level(level).init(); + SimpleLogger::new() + .with_level(level) + .with_module_level(BOOT_LOG_TARGET, BOOT_LOG_LEVEL) + .init() + .map_err(|_error| failure("logging", "application", "initialization failed"))?; } + Ok(()) +} - let resolution = resolve_addr(&env); - for warning in &resolution.warnings { - log::warn!("{warning}"); +fn no_initialize<'startup>( + _app: &'startup mut App, + _stores: &'startup PreparedStores, +) -> LocalStartupFuture<'startup> { + Box::pin(future::ready(Ok(()))) +} + +/// Runs strict framework startup with SIGINT and Unix SIGTERM. No callback is +/// required. Ready does not claim application-specific schema/bootstrap checks. +/// +/// # Errors +/// Returns classified startup/runner errors or non-success for forced shutdown. +#[inline] +pub fn run_production_app() -> anyhow::Result<()> { + run_production_selected::( + AxumRunOptions::from_env()?, + NativeStoreOverrides::default(), + true, + future::pending::<()>(), + no_initialize, + ) +} + +/// Runs strict startup plus exactly one application-owned local initializer. +/// Pass an inline `|app, stores| Box::pin(async move { ... })` or a boxed function +/// so Rust can infer the higher-ranked borrow lifetime. Futures need not be Send. +/// +/// # Errors +/// Returns safe classified errors; callback/provider source chains are discarded. +#[inline] +pub fn run_production_app_with_initializer( + initialize: Initialize, +) -> anyhow::Result<()> +where + A: Hooks, + Initialize: for<'startup> FnOnce( + &'startup mut App, + &'startup PreparedStores, + ) -> LocalStartupFuture<'startup>, +{ + run_production_selected::( + AxumRunOptions::from_env()?, + NativeStoreOverrides::default(), + true, + future::pending::<()>(), + initialize, + ) +} + +/// Runs production with explicit options and caller-owned stop, installing no +/// process-wide signal handlers and merging no ambient hosting/store settings. +/// +/// # Errors +/// Returns classified startup/runner errors or grace exhaustion. +#[inline] +pub fn run_app_with_options(options: AxumRunOptions, stop: Stop) -> anyhow::Result<()> +where + A: Hooks, + Stop: Future, +{ + run_app_with_options_and_initializer::(options, stop, no_initialize) +} + +/// Runs production embedding with app-selected startup checks before acceptance. +/// Retained cloned KV handles remain the caller's responsibility after return. +/// +/// # Errors +/// Returns classified startup/runner errors or grace exhaustion. +#[inline] +pub fn run_app_with_options_and_initializer( + options: AxumRunOptions, + stop: Stop, + initialize: Initialize, +) -> anyhow::Result<()> +where + A: Hooks, + Stop: Future, + Initialize: for<'startup> FnOnce( + &'startup mut App, + &'startup PreparedStores, + ) -> LocalStartupFuture<'startup>, +{ + run_app_with_native_stores::( + options, + NativeStoreOverrides::default(), + stop, + initialize, + ) +} + +/// Runs explicit native bindings and app-owned checks before accepting requests. +/// Setup futures are polled sequentially on the existing current-thread runtime. +/// +/// # Errors +/// Rejects invalid sources before opening local providers or polling deferred setup. +/// Preparation failures discard all staged handles and skip initializer/readiness. +#[inline] +pub fn run_app_with_native_stores( + options: AxumRunOptions, + overrides: NativeStoreOverrides, + stop: Stop, + initialize: Initialize, +) -> anyhow::Result<()> +where + A: Hooks, + Stop: Future, + Initialize: for<'startup> FnOnce( + &'startup mut App, + &'startup PreparedStores, + ) -> LocalStartupFuture<'startup>, +{ + run_production_selected::(options, overrides, false, stop, initialize) +} + +fn run_production_selected( + options: AxumRunOptions, + overrides: NativeStoreOverrides, + signals: bool, + stop: Stop, + initialize: Initialize, +) -> anyhow::Result<()> +where + A: Hooks, + Stop: Future, + Initialize: for<'startup> FnOnce( + &'startup mut App, + &'startup PreparedStores, + ) -> LocalStartupFuture<'startup>, +{ + run_local( + ListenerSource::Bind(options.addr()), + signals, + options.shutdown_grace(), + AxumIngressConfig::from_env(options.env_config())?, + stop, + async move { + let metadata = A::stores(); + let sources = NativeSourcePlan::validate(metadata, &options, overrides)?; + init_logging::(options.logging_level())?; + let mut app = build_app_for_dispatch::()?; + let stores = build_production_stores(metadata, &options, sources).await?; + let transport = AxumOutboundClient::try_transport() + .map_err(|_error| failure("transport", "HTTP client", "initialization failed"))?; + initialize(&mut app, &stores).await.map_err(|error| { + let category = error.store_extraction_reason().map_or_else( + || "application checks failed".to_owned(), + |reason| format!("{reason:?}"), + ); + failure("initializer", "application", &category) + })?; + Ok((app, stores, transport)) + }, + ) +} + +fn check_data_write_access(root: &Path) -> anyhow::Result<()> { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + for _attempt in 0..8_u8 { + let sequence = NEXT.fetch_add(1, Ordering::Relaxed); + let path = root.join(format!( + ".edgezero-write-check-{}-{sequence}", + process::id() + )); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(file) => { + drop(file); + fs::remove_file(path).map_err(|_error| { + failure("stores", "DATA_DIR", "write-probe cleanup failed") + })?; + return Ok(()); + } + Err(error) if error.kind() == ErrorKind::AlreadyExists => {} + Err(_error) => return Err(failure("stores", "DATA_DIR", "directory is not writable")), + } } - let addr = resolution.addr; - log::info!("[edgezero] starting axum server on http://{addr}"); + Err(failure("stores", "DATA_DIR", "write-probe unavailable")) +} - let runtime = RuntimeBuilder::new_multi_thread() - .enable_all() - .build() - .context("failed to build tokio runtime")?; - - runtime.block_on(async move { - let std_listener = StdTcpListener::bind(addr) - .with_context(|| format!("failed to bind dev server to {addr}"))?; - std_listener - .set_nonblocking(true) - .context("failed to set listener to non-blocking")?; - let listener = TokioTcpListener::from_std(std_listener) - .context("failed to adopt std listener into tokio")?; - - let kv_registry = build_kv_registry(stores.kv, &env, kv_init_requirement)?; - let config_registry = build_config_registry(stores.config, &env); - let secret_registry = build_secret_registry(stores.secrets, &env); - - let request_stores = Stores { - config_registry, - kv_registry, - secret_registry, - ..Stores::default() +#[expect( + clippy::too_many_lines, + reason = "keep all sequential staging in one ownership scope before atomic publication" +)] +async fn build_production_stores( + metadata: StoresMetadata, + options: &AxumRunOptions, + sources: NativeSourcePlan, +) -> anyhow::Result { + let mut config = BTreeMap::new(); + let mut kv = BTreeMap::new(); + let mut secrets = BTreeMap::new(); + let mut resident = 0_usize; + let mut aws = sources.session()?; + for (id, source) in sources.config { + let binding = match source { + NativeStoreSource::BuiltIn(builtin) => { + native_stores::prepare_aws_config(builtin, &mut aws, id).await? + } + local @ (NativeStoreSource::Local + | NativeStoreSource::Ready(_) + | NativeStoreSource::Prepare(_)) => { + native_stores::prepare(local, "config", id, || { + let root = options + .config_dir() + .ok_or_else(|| failure("settings", "CONFIG_DIR", "required"))?; + let store = AxumConfigStore::load_required_at_startup( + &root.join(format!("local-config-{id}.json")), + sources.config_limits, + resident, + ) + .map_err(|error| { + let category = match error { + ConfigStoreError::ValueTooLarge => "snapshot size limit", + ConfigStoreError::Unavailable { message } + if message == "config file missing" => + { + "required snapshot missing" + } + ConfigStoreError::Unavailable { message } + if message == "config snapshot requires a regular file" => + { + "snapshot requires a regular file" + } + ConfigStoreError::DeadlineExceeded + | ConfigStoreError::Internal { .. } + | ConfigStoreError::InvalidKey { .. } + | ConfigStoreError::Unavailable { .. } + | _ => "required snapshot unavailable or malformed", + }; + failure("config", id, category) + })?; + resident = resident + .checked_add(store.resident_allocation_bytes()) + .ok_or_else(|| failure("config", id, "snapshot allocation limit"))?; + Ok(ConfigStoreBinding { + handle: ConfigStoreHandle::new(Arc::new(store)), + default_key: options.env_config().store_key("config", id), + }) + }) + .await? + } + }; + config.insert(id.to_owned(), binding); + } + let mut handles: BTreeMap = BTreeMap::new(); + if sources + .kv + .values() + .any(|source| matches!(source, NativeStoreSource::Local)) + { + check_data_write_access( + options + .data_dir() + .ok_or_else(|| failure("settings", "DATA_DIR", "required"))?, + )?; + } + for (id, source) in sources.kv { + let handle = match source { + NativeStoreSource::BuiltIn(builtin) => { + native_stores::prepare_aws_kv(builtin, &mut aws, id).await? + } + local @ (NativeStoreSource::Local + | NativeStoreSource::Ready(_) + | NativeStoreSource::Prepare(_)) => { + native_stores::prepare(local, "kv", id, || { + let root = options + .data_dir() + .ok_or_else(|| failure("settings", "DATA_DIR", "required"))?; + let name = options.env_config().store_name("kv", id); + if let Some(handle) = handles.get(&name) { + return Ok(handle.clone()); + } + let store = PersistentKvStore::new(root.join(kv_store_file_name(&name))) + .map_err(|_error| failure("kv", id, "database unavailable or locked"))?; + let handle = KvHandle::new(Arc::new(store)); + handles.insert(name, handle.clone()); + Ok(handle) + }) + .await? + } }; - serve_with_stores(app, listener, true, request_stores).await + kv.insert(id.to_owned(), handle); + } + let environment_secrets = SecretHandle::new(Arc::new(EnvSecretStore::new())); + for (id, source) in sources.secrets { + let binding = match source { + NativeStoreSource::BuiltIn(builtin) => { + native_stores::prepare_aws_secrets(builtin, &mut aws, id).await? + } + local @ (NativeStoreSource::Local + | NativeStoreSource::Ready(_) + | NativeStoreSource::Prepare(_)) => { + native_stores::prepare(local, "secrets", id, || { + Ok(BoundSecretStore::new( + environment_secrets.clone(), + options.env_config().store_name("secrets", id), + )) + }) + .await? + } + }; + secrets.insert(id.to_owned(), binding); + } + Ok(PreparedStores { + config: finish_registry(metadata.config, config, "config")?, + kv: finish_registry(metadata.kv, kv, "kv")?, + secrets: finish_registry(metadata.secrets, secrets, "secrets")?, }) } +fn finish_registry( + metadata: Option, + handles: BTreeMap, + kind: &str, +) -> anyhow::Result>> { + metadata + .map(|meta| { + StoreRegistry::from_parts(handles, meta.default.to_owned()) + .ok_or_else(|| failure(kind, meta.default, "invalid declared default binding")) + }) + .transpose() +} + /// Build the per-request KV registry from baked store metadata. /// /// Each declared id resolves to a [`PersistentKvStore`] at @@ -466,38 +1133,34 @@ fn build_kv_registry( }; let mut by_id: BTreeMap = BTreeMap::new(); + let mut handles: BTreeMap = BTreeMap::new(); for id in meta.ids { let store_name = env.store_name("kv", id); + if let Some(handle) = handles.get(&store_name) { + by_id.insert((*id).to_owned(), handle.clone()); + continue; + } let kv_path = kv_store_path(&store_name); let handle = match kv_handle_from_path(&kv_path) { Ok(handle) => handle, - Err(err) => match init { + Err(_error) => match init { KvInitRequirement::Optional => { - log::warn!( - "KV store '{}' (id `{}`) could not be initialized at {}: {}", - store_name, - id, - kv_path.display(), - err - ); + log::warn!(target: BOOT_LOG_TARGET, "KV store id `{id}` unavailable; omitting optional binding"); continue; } KvInitRequirement::Required => { - return Err(err.context(format!( - "KV store '{}' (id `{}`) is explicitly configured for axum but could not be initialized at {}", - store_name, - id, - kv_path.display() - ))); + return Err(failure("KV", id, "declared database unavailable or locked")); } }, }; + handles.insert(store_name, handle.clone()); by_id.insert((*id).to_owned(), handle); } let default_id = meta.default.to_owned(); if !by_id.contains_key(&default_id) { log::warn!( + target: BOOT_LOG_TARGET, "KV registry default id `{default_id}` failed to initialize; dropping the KV registry — \ handlers will see no KV store" ); @@ -510,48 +1173,73 @@ fn build_kv_registry( /// Each declared id reads `.edgezero/local-config-.json`. A missing /// file yields an empty store for that id — the dev server stays usable /// before any `config push` has populated the file. A malformed file logs a -/// warning and the id is dropped from the registry rather than failing -/// startup, matching the cloudflare config-binding behaviour. +/// warning and preserves a typed failing binding. Allocation violations fail +/// startup before any listener is bound. Snapshots load sequentially. fn build_config_registry( config_meta: Option, env: &EnvConfig, -) -> Option { - let meta = config_meta?; + limits: ConfigStoreLimits, +) -> Result, ConfigStoreError> { + build_config_registry_at(config_meta, env, limits, AxumConfigStore::local_path) +} + +fn build_config_registry_at( + config_meta: Option, + env: &EnvConfig, + limits: ConfigStoreLimits, + path_for: impl Fn(&str) -> PathBuf, +) -> Result, ConfigStoreError> { + let Some(meta) = config_meta else { + return Ok(None); + }; + if meta.ids.is_empty() + || !meta.ids.contains(&meta.default) + || meta.ids.iter().enumerate().any(|(index, id)| { + id.is_empty() + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + || meta.ids.iter().take(index).any(|previous| previous == id) + }) + { + return Err(ConfigStoreError::internal(anyhow::anyhow!( + "invalid declared config-store metadata" + ))); + } let mut by_id: BTreeMap = BTreeMap::new(); + let mut resident = 0_usize; for id in meta.ids { - let store = match AxumConfigStore::from_local_file(id) { - Ok(store) => store, - Err(err) => { + let handle = match AxumConfigStore::load_at_startup(&path_for(id), limits, resident) { + Ok(store) => { + resident = resident + .checked_add(store.resident_allocation_bytes()) + .ok_or(ConfigStoreError::ValueTooLarge)?; + ConfigStoreHandle::new(Arc::new(store)) + } + Err(ConfigStoreError::ValueTooLarge) => return Err(ConfigStoreError::ValueTooLarge), + Err(_error) => { log::warn!( - "config store for id `{}` could not be loaded from {}: {}; \ - dropping this id from the registry", - id, - AxumConfigStore::local_path(id).display(), - err + target: BOOT_LOG_TARGET, + "config snapshot unavailable; retaining a failed declared binding" ); - continue; + ConfigStoreHandle::failed_open(ConfigStoreOpenFailure::Unavailable) } }; by_id.insert( (*id).to_owned(), ConfigStoreBinding { - handle: ConfigStoreHandle::new(Arc::new(store)), + handle, default_key: env.store_key("config", id), }, ); } let default_id = meta.default.to_owned(); - if !by_id.contains_key(&default_id) { - log::warn!( - "config registry default id `{default_id}` failed to load; dropping the config registry — \ - handlers will see no config store" - ); - } - StoreRegistry::from_parts(by_id, default_id) + log::info!("[edgezero] axum config snapshot resident charge: {resident} bytes"); + Ok(StoreRegistry::from_parts(by_id, default_id)) } -/// Build the per-request secret registry. Axum is `Single` for secrets — every -/// declared id maps to the same env-backed [`EnvSecretStore`]. Each binding +/// Build the per-request local secret registry. Every declared ID maps to the +/// shared env-backed [`EnvSecretStore`], without limiting the number of IDs. Each binding /// captures the platform store name resolved from /// `EDGEZERO__STORES__SECRETS____NAME` (defaulting to the logical id); /// the axum env-secret backend ignores the name on lookup, so the binding @@ -590,11 +1278,110 @@ pub(crate) fn resolve_addr(env: &EnvConfig) -> addr::BindAddrResolution { mod tests { use super::*; use edgezero_core::error::EdgeError; + use futures::executor::block_on; + use log::LevelFilter; + use std::env; use std::net::{IpAddr, Ipv4Addr}; + use std::process::Command; use std::sync::atomic::{AtomicUsize, Ordering}; + #[test] + fn generated_entry_selects_native_mode_only_for_bindings_locator() { + assert!(is_store_bindings_locator_key( + "EDGEZERO__STORE_BINDINGS_FILE" + )); + assert!(is_store_bindings_locator_key( + "EDGEZERO__store_bindings_file" + )); + assert!(!is_store_bindings_locator_key( + "EDGEZERO__ADAPTER__DATA_DIR" + )); + assert!(!is_store_bindings_locator_key( + "EDGEZERO_STORE_BINDINGS_FILE" + )); + for key in [ + "edgezero__store_bindings_file", + "EdGeZeRo__STORE_BINDINGS_FILE", + ] { + assert_eq!(is_store_bindings_locator_key(key), cfg!(windows)); + } + } + + #[test] + fn generated_bindings_locator_child() { + let Ok(expected) = env::var("EDGEZERO_TEST_BINDINGS_ERROR") else { + return; + }; + // This hook fails construction even if dispatch incorrectly selects local defaults. + let error = run_generated_app::().expect_err("startup failure"); + assert_eq!(error.to_string(), expected); + assert_eq!(error.chain().count(), 1); + } + + #[cfg(any(unix, windows))] + #[test] + fn generated_locator_casing_and_non_unicode_fail_closed() { + use std::ffi::OsString; + #[cfg(unix)] + use std::os::unix::ffi::OsStringExt as _; + #[cfg(windows)] + use std::os::windows::ffi::OsStringExt as _; + + let root = tempfile::tempdir().expect("root"); + let missing = root.path().join("missing.toml"); + #[cfg(unix)] + let non_unicode = OsString::from_vec(vec![0xff]); + #[cfg(windows)] + let non_unicode = OsString::from_wide(&[0xd800]); + for prefix in ["EDGEZERO__", "edgezero__", "EdGeZeRo__"] { + for (value, selected_error) in [ + ( + missing.clone().into_os_string(), + "startup settings: bindings: native bindings file unavailable", + ), + ( + non_unicode.clone(), + "startup settings: STORE_BINDINGS_FILE: non-Unicode value", + ), + ] { + let recognized = prefix == "EDGEZERO__" || cfg!(windows); + // Non-Unicode rejection belongs to selected strict startup; do not exercise + // the unchanged development runner's Unicode-only environment collection. + if !recognized && value.to_str().is_none() { + continue; + } + let expected = if recognized { + selected_error + } else { + "application configuration failed" + }; + let output = Command::new(env::current_exe().expect("test executable")) + .args([ + "--exact", + "dev_server::tests::generated_bindings_locator_child", + ]) + .env_clear() + .env("EDGEZERO_TEST_BINDINGS_ERROR", expected) + .env(format!("{prefix}store_bindings_file"), value) + .output() + .expect("isolated generated entrypoint"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{stdout}\n{stderr}"); + assert!( + stdout.contains("1 passed"), + "child test must execute: {stdout}" + ); + } + } + } + struct FailingConfiguration; + struct LoggingConfiguration; + + struct OwnedLoggingConfiguration; + struct PlatformAwareConfiguration; #[expect( @@ -611,6 +1398,42 @@ mod tests { } } + #[expect( + clippy::missing_trait_methods, + reason = "test hook probes startup logging" + )] + impl Hooks for LoggingConfiguration { + fn configure(_app: &mut App) -> Result<(), EdgeError> { + log::warn!(target: edgezero_core::BOOT_LOG_TARGET, "boot-warning-probe"); + log::info!(target: edgezero_core::BOOT_LOG_TARGET, "boot-info-probe"); + log::warn!("runtime-warning-probe"); + log::debug!("runtime-debug-probe"); + Err(EdgeError::service_unavailable("stop before listening")) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[expect( + clippy::missing_trait_methods, + reason = "test hook probes application logger ownership" + )] + impl Hooks for OwnedLoggingConfiguration { + fn configure(app: &mut App) -> Result<(), EdgeError> { + LoggingConfiguration::configure(app) + } + + fn owns_logging() -> bool { + true + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + #[expect( clippy::missing_trait_methods, reason = "test hook exercises only target metadata propagation" @@ -629,6 +1452,230 @@ mod tests { } } + fn logging_probe(scenario: &str, level: &str) -> String { + let output = Command::new(env::current_exe().expect("test executable")) + .args([ + "--exact", + "dev_server::tests::startup_logging_child", + "--nocapture", + ]) + .env("EDGEZERO_TEST_LOGGING_SCENARIO", scenario) + .env("EDGEZERO__LOGGING__LEVEL", level) + .env_remove("EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS") + .env_remove("EDGEZERO__ADAPTER__INGRESS__MAX_RAW_HEAD_BYTES") + .env_remove("EDGEZERO__ADAPTER__INGRESS__MAX_HEADER_COUNT") + .env_remove("EDGEZERO__ADAPTER__INGRESS__HEAD_READ_TIMEOUT_MS") + .envs( + (scenario == "invalid-ingress") + .then_some(("EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS", "0")), + ) + .output() + .expect("isolated logging probe"); + let stdout = String::from_utf8(output.stdout).expect("probe stdout"); + let stderr = String::from_utf8(output.stderr).expect("probe stderr"); + assert!(output.status.success(), "{stdout}\n{stderr}"); + assert!( + stdout.contains("1 passed"), + "child probe must execute: {stdout}" + ); + format!("{stdout}\n{stderr}") + } + + fn strict_logging_options() -> AxumRunOptions { + AxumRunOptions::from_vars([( + "EDGEZERO__LOGGING__LEVEL", + env::var("EDGEZERO__LOGGING__LEVEL").expect("probe logging level"), + )]) + .expect("strict probe options") + } + + #[test] + fn startup_logging_child() { + let Ok(scenario) = env::var("EDGEZERO_TEST_LOGGING_SCENARIO") else { + return; + }; + let result = match scenario.as_str() { + "strict-managed" => run_app_with_options::( + strict_logging_options(), + future::pending::<()>(), + ), + "strict-invalid-sources" => { + SimpleLogger::new().init().expect("existing logger"); + let mut overrides = NativeStoreOverrides::default(); + overrides + .prepare_config("undeclared", async { panic!("setup must not be polled") }) + .expect("deferred source"); + let result = run_app_with_native_stores::( + strict_logging_options(), + overrides, + future::pending::<()>(), + no_initialize, + ); + assert!( + result + .expect_err("source error must precede logger conflict") + .to_string() + .contains("undeclared binding"), + ); + return; + } + "invalid-ingress" => { + let result = run_app_with_preflight::(|| { + panic!( + "invalid ingress must fail before preflight or application configuration" + ) + }); + assert_eq!( + result + .expect_err("invalid ingress must stop startup") + .to_string(), + "invalid Axum ingress setting `max_connections`" + ); + return; + } + "installed" | "strict-installed" => { + SimpleLogger::new() + .with_level(LevelFilter::Trace) + .init() + .expect("existing logger"); + let strict = scenario == "strict-installed"; + let result = if strict { + run_app_with_options::( + strict_logging_options(), + future::pending::<()>(), + ) + } else { + run_app_with_preflight::(|| { + log::warn!(target: BOOT_LOG_TARGET, "unexpected-preflight-probe"); + Ok(()) + }) + }; + let expected = if strict { + "startup logging: application: initialization failed" + } else { + "failed to initialize application logger" + }; + assert_eq!( + result + .expect_err("logger conflict must stop startup") + .to_string(), + expected + ); + return; + } + "owned" | "strict-owned" => { + SimpleLogger::new() + .with_level(LevelFilter::Trace) + .init() + .expect("owned logger"); + log::set_max_level(LevelFilter::Debug); + let result = if scenario == "strict-owned" { + run_app_with_options::( + strict_logging_options(), + future::pending::<()>(), + ) + } else { + run_app::() + }; + assert_eq!(log::max_level(), LevelFilter::Debug); + result + } + "preflight" => run_app_with_preflight::(|| { + log::warn!(target: edgezero_core::BOOT_LOG_TARGET, "preflight-warning-probe"); + anyhow::bail!("preflight stopped startup"); + }), + _ => run_app::(), + }; + assert!(result.is_err(), "probe must stop before listener setup"); + } + + #[test] + fn invalid_ingress_stops_before_preflight_configuration_and_binding() { + let output = logging_probe("invalid-ingress", "info"); + assert!(!output.contains("boot-warning-probe"), "{output}"); + } + + #[test] + fn logging_is_ready_before_configure_with_separate_boot_filter() { + for level in ["off", "error", "trace"] { + let output = logging_probe("managed", level); + assert!(output.contains("boot-warning-probe"), "{output}"); + assert!(!output.contains("boot-info-probe"), "{output}"); + assert_eq!( + output.contains("runtime-warning-probe"), + level == "trace", + "{output}" + ); + assert_eq!( + output.contains("runtime-debug-probe"), + level == "trace", + "{output}" + ); + } + } + + #[test] + fn strict_logging_is_ready_before_configure_with_separate_boot_filter() { + for level in ["off", "error", "trace"] { + let output = logging_probe("strict-managed", level); + assert!(output.contains("boot-warning-probe"), "{output}"); + assert!(!output.contains("boot-info-probe"), "{output}"); + assert_eq!( + output.contains("runtime-warning-probe"), + level == "trace", + "{output}" + ); + assert_eq!( + output.contains("runtime-debug-probe"), + level == "trace", + "{output}" + ); + } + } + + #[test] + fn strict_logger_conflict_prevents_configuration() { + let output = logging_probe("strict-installed", "trace"); + assert!(!output.contains("boot-warning-probe"), "{output}"); + } + + #[test] + fn strict_owned_logging_preserves_backend_and_facade_filter() { + let output = logging_probe("strict-owned", "off"); + assert!(output.contains("runtime-debug-probe"), "{output}"); + assert!(output.contains("boot-info-probe"), "{output}"); + } + + #[test] + fn strict_source_validation_precedes_logging_and_configuration() { + let output = logging_probe("strict-invalid-sources", "trace"); + assert!(!output.contains("boot-warning-probe"), "{output}"); + } + + #[test] + fn owned_logging_preserves_backend_and_facade_filter() { + let output = logging_probe("owned", "off"); + assert!(output.contains("runtime-debug-probe"), "{output}"); + assert!( + output.contains("boot-info-probe"), + "application owns boot filtering: {output}" + ); + } + + #[test] + fn preflight_runs_after_logger_and_before_configuration() { + let output = logging_probe("preflight", "off"); + assert!(output.contains("preflight-warning-probe"), "{output}"); + assert!(!output.contains("boot-warning-probe"), "{output}"); + } + + #[test] + fn logger_conflict_prevents_preflight_and_configuration() { + let output = logging_probe("installed", "trace"); + assert!(!output.contains("unexpected-preflight-probe"), "{output}"); + assert!(!output.contains("boot-warning-probe"), "{output}"); + } + #[test] fn failing_configuration_prevents_listener_boundary() { let bind_calls = AtomicUsize::new(0); @@ -648,6 +1695,45 @@ mod tests { assert_eq!(app.platform(), crate::AXUM_PLATFORM); } + #[test] + fn initializer_borrows_inputs_across_await_and_captures_rc() { + use futures::executor::block_on; + use std::cell::Cell; + use std::rc::Rc; + + async fn initialize( + app: &mut App, + stores: &PreparedStores, + callback: Initialize, + ) -> Result<(), EdgeError> + where + Initialize: for<'startup> FnOnce( + &'startup mut App, + &'startup PreparedStores, + ) -> LocalStartupFuture<'startup>, + { + callback(app, stores).await + } + let calls = Rc::new(Cell::new(0_u32)); + let recorded = Rc::clone(&calls); + let mut owned_app = App::new(RouterService::builder().build()); + block_on(initialize( + &mut owned_app, + &PreparedStores::default(), + move |app, stores| { + Box::pin(async move { + future::ready(()).await; + assert!(stores.config().is_none()); + app.insert_state(Arc::::from("initialized")); + recorded.set(recorded.get() + 1); + Ok(()) + }) + }, + )) + .expect("initialize"); + assert_eq!(calls.get(), 1); + } + #[test] fn default_config_uses_expected_address() { let config = AxumDevServerConfig::default(); @@ -675,6 +1761,7 @@ mod tests { let config = AxumDevServerConfig { addr, enable_ctrl_c: false, + ingress: AxumIngressConfig::default(), }; assert_eq!(config.addr.ip(), IpAddr::V4(Ipv4Addr::UNSPECIFIED)); assert_eq!(config.addr.port(), 3000); @@ -699,12 +1786,94 @@ mod tests { let config = AxumDevServerConfig { addr: SocketAddr::from(([127, 0, 0, 1], 9000)), enable_ctrl_c: false, + ingress: AxumIngressConfig::default(), }; let server = AxumDevServer::with_config(router, config); assert_eq!(server.config.addr.port(), 9000); assert!(!server.config.enable_ctrl_c); } + #[test] + fn prepared_stores_preserve_legacy_defaults_and_explicit_registry_precedence() { + let temp = tempfile::tempdir().expect("database directory"); + let config = ConfigStoreHandle::new(Arc::new( + AxumConfigStore::from_map([], ConfigStoreLimits::default()).expect("config store"), + )); + let kv = KvHandle::new(Arc::new( + PersistentKvStore::new(temp.path().join("registry.redb")).expect("KV store"), + )); + let secrets = SecretHandle::new(Arc::new(EnvSecretStore::new())); + let with_handles = |server: AxumDevServer| { + server + .with_config_store(config.clone()) + .with_kv_handle(kv.clone()) + .with_secret_handle(secrets.clone()) + }; + let legacy: PreparedStores = + with_handles(AxumDevServer::new(RouterService::builder().build())) + .stores + .into(); + let legacy_config = legacy.config().expect("config registry"); + let legacy_kv = legacy.kv().expect("KV registry"); + let legacy_secrets = legacy.secrets().expect("secret registry"); + assert_eq!(legacy_config.ids().collect::>(), ["default"]); + assert_eq!(legacy_config.default_id(), "default"); + assert_eq!( + legacy_config.default_ref().expect("binding").default_key, + "default" + ); + assert_eq!(legacy_kv.ids().collect::>(), ["default"]); + assert_eq!(legacy_kv.default_id(), "default"); + assert_eq!(legacy_secrets.ids().collect::>(), ["default"]); + assert_eq!(legacy_secrets.default_id(), "default"); + assert_eq!( + legacy_secrets.default_ref().expect("binding").store_name(), + "default" + ); + + let with_registries = |server: AxumDevServer| { + server + .with_config_registry(ConfigRegistry::single_id( + "application".to_owned(), + ConfigStoreBinding { + handle: config.clone(), + default_key: "settings".to_owned(), + }, + )) + .with_kv_registry(KvRegistry::single_id("sessions".to_owned(), kv.clone())) + .with_secret_registry(SecretRegistry::single_id( + "runtime".to_owned(), + BoundSecretStore::new(secrets.clone(), "vault".to_owned()), + )) + }; + for registry_first in [true, false] { + let unwired = AxumDevServer::new(RouterService::builder().build()); + let server = if registry_first { + with_handles(with_registries(unwired)) + } else { + with_registries(with_handles(unwired)) + }; + let prepared: PreparedStores = server.stores.into(); + let config_registry = prepared.config().expect("config registry"); + let kv_registry = prepared.kv().expect("KV registry"); + let secret_registry = prepared.secrets().expect("secret registry"); + assert_eq!(config_registry.default_id(), "application"); + assert_eq!( + config_registry.default_ref().expect("binding").default_key, + "settings" + ); + assert_eq!(kv_registry.default_id(), "sessions"); + assert_eq!(secret_registry.default_id(), "runtime"); + assert_eq!( + secret_registry.default_ref().expect("binding").store_name(), + "vault" + ); + assert!(config_registry.named("default").is_none()); + assert!(kv_registry.named("default").is_none()); + assert!(secret_registry.named("default").is_none()); + } + } + #[test] fn every_store_name_gets_a_slug_based_path() { // The pre-rewrite shortcut hard-coded `.edgezero/kv.redb` @@ -804,57 +1973,693 @@ mod tests { assert!(resolution.warnings.is_empty()); } - #[test] - fn resolve_addr_partial_env_override() { - let env = EnvConfig::from_vars([("EDGEZERO__ADAPTER__HOST", "0.0.0.0")]); - let resolution = resolve_addr(&env); - assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 8787))); - assert!(resolution.warnings.is_empty()); + #[test] + fn resolve_addr_partial_env_override() { + let env = EnvConfig::from_vars([("EDGEZERO__ADAPTER__HOST", "0.0.0.0")]); + let resolution = resolve_addr(&env); + assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 8787))); + assert!(resolution.warnings.is_empty()); + } + + #[test] + fn resolve_addr_invalid_env_falls_back_to_default() { + let env = EnvConfig::from_vars([ + ("EDGEZERO__ADAPTER__HOST", "not-an-ip"), + ("EDGEZERO__ADAPTER__PORT", "abc"), + ]); + let resolution = resolve_addr(&env); + assert_eq!(resolution.addr, SocketAddr::from(([127, 0, 0, 1], 8787))); + assert_eq!(resolution.warnings.len(), 2); + } + + /// `build_config_registry` must pack `default_key` from + /// `EDGEZERO__STORES__CONFIG____KEY` (12.7). + /// A missing local-config file yields an empty store, so the + /// binding is still created — this test exercises the key-override + /// path without requiring the file to exist. + #[test] + fn build_config_registry_packs_resolved_default_key_from_env() { + use edgezero_core::app::StoreMetadata; + let env = EnvConfig::from_vars([( + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY", + "app_config_staging", + )]); + let meta = StoreMetadata { + default: "app_config", + ids: &["app_config"], + }; + let registry = build_config_registry(Some(meta), &env, ConfigStoreLimits::default()) + .expect("load") + .expect("registry built"); + let binding = registry.named("app_config").expect("binding registered"); + assert_eq!(binding.default_key, "app_config_staging"); + } + + #[test] + fn build_config_registry_falls_back_to_id_when_env_key_unset() { + use edgezero_core::app::StoreMetadata; + let env = EnvConfig::from_vars(iter::empty::<(&str, &str)>()); + let meta = StoreMetadata { + default: "app_config", + ids: &["app_config"], + }; + let registry = build_config_registry(Some(meta), &env, ConfigStoreLimits::default()) + .expect("load") + .expect("registry built"); + let binding = registry.named("app_config").expect("binding registered"); + assert_eq!(binding.default_key, "app_config"); + } + + #[test] + fn malformed_declared_snapshot_is_not_an_absent_binding() { + let directory = tempfile::tempdir().expect("directory"); + let path = directory.path().join("config.json"); + fs::write(&path, "credential=private invalid JSON").expect("fixture"); + let meta = StoreMetadata { + default: "config", + ids: &["config"], + }; + let registry = build_config_registry_at( + Some(meta), + &EnvConfig::default(), + ConfigStoreLimits::default(), + |_id| path.clone(), + ) + .expect("failed binding") + .expect("registry"); + let binding = registry.named("config").expect("declared binding survives"); + let error = block_on(binding.handle.get("key")).expect_err("unavailable, not absent"); + assert!(matches!(error, ConfigStoreError::Unavailable { .. })); + assert!(!error.to_string().contains("credential=private")); + } + + #[test] + fn aggregate_snapshot_overflow_fails_startup_and_retry_recovers() { + let directory = tempfile::tempdir().expect("directory"); + let path = directory.path().join("config.json"); + fs::write(&path, serde_json::json!({"k": "v"}).to_string()).expect("fixture"); + let limits = ConfigStoreLimits::new(128, 1, 1, 1, 4096, 8192).expect("limits"); + let charge = AxumConfigStore::from_path(&path, limits) + .expect("snapshot") + .resident_allocation_bytes(); + let capped = ConfigStoreLimits::new( + 128, + 1, + 1, + 1, + charge.saturating_mul(2).saturating_sub(1), + 8192, + ) + .expect("limits"); + let meta = StoreMetadata { + default: "first", + ids: &["first", "second"], + }; + assert!(matches!( + build_config_registry_at(Some(meta), &EnvConfig::default(), capped, |_id| path + .clone()), + Err(ConfigStoreError::ValueTooLarge) + )); + let registry = build_config_registry_at(Some(meta), &EnvConfig::default(), limits, |_id| { + path.clone() + }) + .expect("retry") + .expect("registry"); + assert!(registry.named("first").is_some()); + assert!(registry.named("second").is_some()); + } + + #[test] + fn invalid_config_registry_metadata_fails_without_opening_stores() { + for meta in [ + StoreMetadata { + default: "first", + ids: &[], + }, + StoreMetadata { + default: "absent", + ids: &["first"], + }, + StoreMetadata { + default: "first", + ids: &["first", "first"], + }, + StoreMetadata { + default: "bad/id", + ids: &["bad/id"], + }, + ] { + let error = build_config_registry_at( + Some(meta), + &EnvConfig::default(), + ConfigStoreLimits::default(), + |_id| panic!("must not open invalid metadata"), + ) + .expect_err("invalid metadata"); + assert!(matches!(error, ConfigStoreError::Internal { .. })); + } + } +} + +#[cfg(test)] +mod bounded_ingress_tests { + use std::future::pending; + use std::net::SocketAddr; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + use bytes::Bytes; + use edgezero_core::app::App; + use edgezero_core::body::Body; + use edgezero_core::error::EdgeError; + use edgezero_core::http::response_builder; + use edgezero_core::ingress::{AdmissionDecision, IngressGrant}; + use edgezero_core::response_egress::{ResponseEgressCompletion, ResponseEgressOutcome}; + use edgezero_core::router::RouterService; + use edgezero_core::{Deadline, MonotonicClock, MonotonicInstant, ResponseEgressPolicy}; + use futures_util::stream::poll_fn; + use std::task::Poll; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + use tokio::net::{TcpListener, TcpStream}; + use tokio::task::{JoinHandle, yield_now}; + use tokio::time::{Instant, sleep, timeout}; + + use super::{AxumDevServer, AxumDevServerConfig}; + use crate::ingress_config::AxumIngressConfig; + + struct Server { + address: SocketAddr, + calls: Arc, + panics: Arc, + task: JoinHandle>, + } + + struct DropProbe(Arc); + + #[derive(Clone, Default)] + struct EgressProbes { + completions: Arc, + polls: Arc, + resources: Arc, + sources: Arc, + } + + impl Drop for DropProbe { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } + } + + async fn closed(stream: &mut TcpStream) { + let mut byte = [0]; + let read = timeout(Duration::from_secs(2), stream.read(&mut byte)) + .await + .expect("connection must close"); + assert!( + matches!(read, Ok(0) | Err(_)), + "refusal must not send a response" + ); + } + + async fn healthy(stream: &mut TcpStream) { + stream + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + let read = async { + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(stream.read_u8().await.unwrap()); + assert!(head.len() < 4096); + } + assert!(head.starts_with(b"HTTP/1.1 200")); + let mut body = [0; 2]; + stream.read_exact(&mut body).await.unwrap(); + assert_eq!(&body, b"ok"); + }; + timeout(Duration::from_secs(2), read).await.unwrap(); } - #[test] - fn resolve_addr_invalid_env_falls_back_to_default() { - let env = EnvConfig::from_vars([ - ("EDGEZERO__ADAPTER__HOST", "not-an-ip"), - ("EDGEZERO__ADAPTER__PORT", "abc"), - ]); - let resolution = resolve_addr(&env); - assert_eq!(resolution.addr, SocketAddr::from(([127, 0, 0, 1], 8787))); - assert_eq!(resolution.warnings.len(), 2); + fn stalled_egress_app( + ready: bool, + expected: ResponseEgressOutcome, + probes: &EgressProbes, + ) -> App { + let sources = Arc::clone(&probes.sources); + let polls = Arc::clone(&probes.polls); + let chunk = Bytes::from(vec![b'a'; 0x0004_0000]); + let router = RouterService::builder() + .get("/", |_| async { Ok::<_, EdgeError>("ok") }) + .get("/slow", move |_| { + let source = DropProbe(Arc::clone(&sources)); + let observed_polls = Arc::clone(&polls); + let payload = chunk.clone(); + async move { + let body = poll_fn(move |_cx| { + let _keep_alive = &source; + let polled = observed_polls.fetch_add(1, Ordering::SeqCst); + if ready || polled == 0 { + Poll::Ready(Some(Ok(payload.clone()))) + } else { + Poll::Pending + } + }); + Ok::<_, EdgeError>( + response_builder() + .body(Body::from_stream(body)) + .expect("response"), + ) + } + }) + .build(); + let mut app = App::new(router); + let frozen = MonotonicInstant::now(); + app.set_monotonic_clock(MonotonicClock::new(move || frozen)); + let budget = if expected == ResponseEgressOutcome::TransportError { + Duration::from_secs(1) + } else { + Duration::from_millis(100) + }; + app.set_response_egress_policy(move |_, started| ResponseEgressPolicy { + write_deadline: Deadline::at_instant(started.checked_add(budget).expect("deadline")), + }); + let completions = Arc::clone(&probes.completions); + let resources = Arc::clone(&probes.resources); + app.set_ingress_admission_policy(move |head| { + let completion = if head.target().path() == "/slow" { + ResponseEgressCompletion::new({ + let callback = Arc::clone(&completions); + let resource = DropProbe(Arc::clone(&resources)); + move |report| { + assert_eq!(report.outcome, expected); + callback.fetch_add(1, Ordering::SeqCst); + drop(resource); + } + }) + } else { + ResponseEgressCompletion::empty() + }; + AdmissionDecision::Admit { + completion, + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + } + }); + app } - /// `build_config_registry` must pack `default_key` from - /// `EDGEZERO__STORES__CONFIG____KEY` (12.7). - /// A missing local-config file yields an empty store, so the - /// binding is still created — this test exercises the key-override - /// path without requiring the file to exist. - #[test] - fn build_config_registry_packs_resolved_default_key_from_env() { - use edgezero_core::app::StoreMetadata; - let env = EnvConfig::from_vars([( - "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY", - "app_config_staging", - )]); - let meta = StoreMetadata { - default: "app_config", - ids: &["app_config"], - }; - let registry = build_config_registry(Some(meta), &env).expect("registry built"); - let binding = registry.named("app_config").expect("binding registered"); - assert_eq!(binding.default_key, "app_config_staging"); + #[tokio::test] + async fn stalled_egress_termination_releases_capacity_and_recovers() { + for (ready, disconnect) in [(false, true), (false, false), (true, false)] { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener"); + let address = listener.local_addr().expect("address"); + let probes = EgressProbes::default(); + let expected = if disconnect { + ResponseEgressOutcome::TransportError + } else { + ResponseEgressOutcome::DeadlineExceeded + }; + let app = stalled_egress_app(ready, expected, &probes); + let task = tokio::spawn(super::serve_with_stores( + app, + listener, + false, + AxumIngressConfig::new(1, 8192, 100, Duration::from_secs(5)).expect("ingress"), + super::Stores::default(), + )); + let mut client = TcpStream::connect(address).await.expect("client"); + client + .write_all(b"GET /slow HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .expect("request"); + timeout(Duration::from_secs(2), async { + while probes.polls.load(Ordering::SeqCst) < 2 { + yield_now().await; + } + }) + .await + .expect("producer is live"); + if disconnect { + drop(client); + } + timeout(Duration::from_secs(2), async { + while probes.completions.load(Ordering::SeqCst) != 1 { + yield_now().await; + } + }) + .await + .expect("terminal callback"); + assert_eq!(probes.resources.load(Ordering::SeqCst), 1); + assert_eq!(probes.sources.load(Ordering::SeqCst), 1); + let stopped = probes.polls.load(Ordering::SeqCst); + timeout(Duration::from_secs(2), async { + loop { + let mut recovered = TcpStream::connect(address) + .await + .expect("recovery connection"); + if recovered + .write_all( + b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ) + .await + .is_ok() + { + let mut wire = Vec::new(); + if recovered.read_to_end(&mut wire).await.is_ok() && wire.ends_with(b"ok") { + break; + } + } + yield_now().await; + } + }) + .await + .expect("capacity recovery"); + assert_eq!( + probes.polls.load(Ordering::SeqCst), + stopped, + "terminated sources must stop" + ); + assert_eq!(probes.completions.load(Ordering::SeqCst), 1); + task.abort(); + let _shutdown = task.await; + } } - #[test] - fn build_config_registry_falls_back_to_id_when_env_key_unset() { - use edgezero_core::app::StoreMetadata; - let env = EnvConfig::from_vars(iter::empty::<(&str, &str)>()); - let meta = StoreMetadata { - default: "app_config", - ids: &["app_config"], + async fn start(connections: usize, milliseconds: u64, count: usize) -> Server { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&calls); + let panics = Arc::new(AtomicUsize::new(0)); + let observed_panics = Arc::clone(&panics); + let router = RouterService::builder() + .get("/panic", move |_| { + let counted = Arc::clone(&observed_panics); + async move { + counted.fetch_add(1, Ordering::SeqCst); + panic!("isolated connection failure"); + #[expect( + unreachable_code, + reason = "sets the handler response type after the intentional panic" + )] + Ok::<_, EdgeError>("unreachable") + } + }) + .get("/", move |_| { + let counted = Arc::clone(&observed); + async move { + counted.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("ok") + } + }) + .build(); + let server = AxumDevServer::with_config( + router, + AxumDevServerConfig { + addr: address, + enable_ctrl_c: false, + ingress: AxumIngressConfig::new( + connections, + 8192, + count, + Duration::from_millis(milliseconds), + ) + .unwrap(), + }, + ); + let task = tokio::spawn(server.run_with_listener(listener)); + Server { + address, + calls, + panics, + task, + } + } + + #[tokio::test] + async fn parser_and_unwinding_failures_release_capacity_and_leave_server_healthy() { + let server = start(1, 10_000, 100).await; + for request in [ + &b"invalid\r\n\r\n"[..], + &b"GET /panic HTTP/1.1\r\nHost: localhost\r\n\r\n"[..], + ] { + let mut failed = TcpStream::connect(server.address).await.unwrap(); + failed.write_all(request).await.unwrap(); + let mut response = Vec::new(); + let _read = timeout(Duration::from_secs(2), failed.read_to_end(&mut response)) + .await + .unwrap(); + assert!(!response.starts_with(b"HTTP/1.1 200")); + let mut recovered = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut recovered).await; + drop(recovered); + } + assert_eq!(server.calls.load(Ordering::SeqCst), 2); + assert_eq!(server.panics.load(Ordering::SeqCst), 1); + assert!(!server.task.is_finished()); + } + + #[tokio::test] + async fn shutdown_releases_pending_handler_stream_and_completion_resources() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let started = Arc::new(AtomicUsize::new(0)); + let released = Arc::new(AtomicUsize::new(0)); + let resources = Arc::new(AtomicUsize::new(0)); + let terminals = Arc::new(AtomicUsize::new(0)); + let handler_started = Arc::clone(&started); + let handler_released = Arc::clone(&released); + let stream_started = Arc::clone(&started); + let stream_released = Arc::clone(&released); + let router = RouterService::builder() + .get("/handler", move |_| { + let entered = Arc::clone(&handler_started); + let guard = DropProbe(Arc::clone(&handler_released)); + async move { + entered.fetch_add(1, Ordering::SeqCst); + pending::<()>().await; + drop(guard); + Ok::<_, EdgeError>("unreachable") + } + }) + .get("/stream", move |_| { + let entered = Arc::clone(&stream_started); + let guard = DropProbe(Arc::clone(&stream_released)); + async move { + let body = poll_fn(move |_cx| { + let _keep_alive = &guard; + entered.store(2, Ordering::SeqCst); + Poll::>>::Pending + }); + Ok::<_, EdgeError>(response_builder().body(Body::from_stream(body)).unwrap()) + } + }) + .build(); + let mut app = App::new(router); + let completion_resources = Arc::clone(&resources); + let completion_terminals = Arc::clone(&terminals); + app.set_ingress_admission_policy(move |head| AdmissionDecision::Admit { + completion: ResponseEgressCompletion::new({ + let resource = DropProbe(Arc::clone(&completion_resources)); + let observed = Arc::clone(&completion_terminals); + move |report| { + assert_eq!(report.outcome, ResponseEgressOutcome::TransportError); + observed.fetch_add(1, Ordering::SeqCst); + drop(resource); + } + }), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(10)), + }); + let task = tokio::spawn(super::serve_with_stores( + app, + listener, + false, + AxumIngressConfig::new(2, 8192, 100, Duration::from_secs(10)).unwrap(), + super::Stores::default(), + )); + let mut handler = TcpStream::connect(address).await.unwrap(); + handler + .write_all(b"GET /handler HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + timeout(Duration::from_secs(2), async { + while started.load(Ordering::SeqCst) != 1 { + yield_now().await; + } + }) + .await + .unwrap(); + let mut stream = TcpStream::connect(address).await.unwrap(); + stream + .write_all(b"GET /stream HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + let mut head = Vec::new(); + timeout(Duration::from_secs(2), async { + while !head.ends_with(b"\r\n\r\n") { + head.push(stream.read_u8().await.unwrap()); + assert!(head.len() < 4096); + } + }) + .await + .unwrap(); + assert!(head.starts_with(b"HTTP/1.1 200")); + assert_eq!(started.load(Ordering::SeqCst), 2); + assert_eq!(released.load(Ordering::SeqCst), 0); + assert_eq!(resources.load(Ordering::SeqCst), 0); + task.abort(); + closed(&mut handler).await; + closed(&mut stream).await; + timeout(Duration::from_secs(2), async { + while released.load(Ordering::SeqCst) != 2 || resources.load(Ordering::SeqCst) != 2 { + yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(terminals.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn saturation_refuses_without_queueing_and_disconnect_recovers() { + let server = start(2, 10_000, 100).await; + let mut first = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut first).await; + let mut second = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut second).await; + for _ in 0_usize..64 { + let mut refused = TcpStream::connect(server.address).await.unwrap(); + closed(&mut refused).await; + } + assert_eq!(server.calls.load(Ordering::SeqCst), 2); + drop(first); + // Wait for disconnect processing without depending on an arbitrary sleep. + let recover = async { + loop { + let mut stream = TcpStream::connect(server.address).await.unwrap(); + let written = stream + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .await; + let mut response = Vec::new(); + let read = stream.read_to_end(&mut response).await; + if written.is_ok() && read.is_ok() && response.starts_with(b"HTTP/1.1 200") { + break; + } + yield_now().await; + } }; - let registry = build_config_registry(Some(meta), &env).expect("registry built"); - let binding = registry.named("app_config").expect("binding registered"); - assert_eq!(binding.default_key, "app_config"); + timeout(Duration::from_secs(2), recover).await.unwrap(); + healthy(&mut second).await; + assert_eq!(server.calls.load(Ordering::SeqCst), 4); + } + + #[tokio::test] + async fn silence_and_trickling_initial_and_keepalive_heads_expire() { + let server = start(1, 150, 100).await; + let mut silent = TcpStream::connect(server.address).await.unwrap(); + closed(&mut silent).await; + for keepalive in [false, true] { + let mut stream = TcpStream::connect(server.address).await.unwrap(); + if keepalive { + healthy(&mut stream).await; + } + stream + .write_all(b"GET / HTTP/1.1\r\nX-Trickle: ") + .await + .unwrap(); + let started = Instant::now(); + let trickle = async { + loop { + sleep(Duration::from_millis(10)).await; + if stream.write_all(b"a").await.is_err() { + break; + } + let mut byte = [0]; + if let Ok(read) = + timeout(Duration::from_millis(1), stream.peek(&mut byte)).await + && !matches!(read, Ok(1..)) + { + break; + } + } + }; + timeout(Duration::from_secs(2), trickle) + .await + .expect("trickle must not extend deadline"); + assert!(started.elapsed() < Duration::from_secs(2)); + closed(&mut stream).await; + } + let mut recovered = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut recovered).await; + assert_eq!(server.calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn idle_keepalive_timeout_releases_capacity() { + let server = start(1, 100, 100).await; + let mut idle = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut idle).await; + closed(&mut idle).await; + let mut recovered = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut recovered).await; + } + + #[tokio::test] + async fn raw_count_duplicates_and_byte_boundaries_precede_dispatch() { + let server = start(1, 10_000, 4).await; + let prefix = b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nX-Pad: "; + let mut exact = prefix.to_vec(); + exact.extend(vec![b'a'; 8192 - prefix.len() - 4]); + exact.extend_from_slice(b"\r\n\r\n"); + let mut over = exact.clone(); + over.insert(prefix.len(), b'a'); + let count_exact = b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nX-Dup: a\r\nX-Dup: b\r\n\r\n".to_vec(); + let count_over = b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nX-Dup: a\r\nX-Dup: b\r\nX-Dup: c\r\n\r\n".to_vec(); + let mut long_line = b"GET /".to_vec(); + long_line.extend(vec![b'a'; 8192]); + long_line.extend_from_slice(b" HTTP/1.1\r\nHost: localhost\r\n\r\n"); + for (request, expected) in [ + (exact, b"HTTP/1.1 200"), + (over, b"HTTP/1.1 431"), + (count_exact, b"HTTP/1.1 200"), + (count_over, b"HTTP/1.1 431"), + (long_line, b"HTTP/1.1 431"), + ] { + let mut stream = TcpStream::connect(server.address).await.unwrap(); + stream.write_all(&request).await.unwrap(); + let mut response = Vec::new(); + let _read = timeout(Duration::from_secs(2), stream.read_to_end(&mut response)) + .await + .unwrap(); + assert!(response.starts_with(expected), "unexpected response status"); + } + assert_eq!(server.calls.load(Ordering::SeqCst), 2); + let mut recovered = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut recovered).await; + } + + #[tokio::test] + async fn cancellation_closes_idle_and_partial_connections() { + let server = start(2, 10_000, 100).await; + let mut idle = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut idle).await; + let mut partial = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut partial).await; + partial + .write_all(b"GET / HTTP/1.1\r\nX-Pending:") + .await + .unwrap(); + server.task.abort(); + closed(&mut idle).await; + closed(&mut partial).await; } } @@ -891,6 +2696,7 @@ mod integration_tests { let config = AxumDevServerConfig { addr, enable_ctrl_c: false, + ingress: AxumIngressConfig::default(), }; // Use a unique temp directory for each test server let temp_dir = tempfile::tempdir().expect("create temp dir"); @@ -1021,6 +2827,7 @@ mod integration_tests { let config = AxumDevServerConfig { addr, enable_ctrl_c: false, + ingress: AxumIngressConfig::default(), }; let server = AxumDevServer::with_config(router, config); @@ -1248,6 +3055,7 @@ mod integration_tests { let config = super::AxumDevServerConfig { addr, enable_ctrl_c: false, + ingress: AxumIngressConfig::default(), }; let mut server = super::AxumDevServer::with_config(router, config); if let Some(handle) = secret_handle { @@ -1351,3 +3159,640 @@ mod integration_tests { server.handle.abort(); } } + +#[cfg(test)] +mod native_store_tests { + use super::*; + use crate::native_stores::NativeStorePreparationError; + use bytes::Bytes; + use edgezero_core::config_store::{ConfigStore, finish_bounded_config_read}; + use edgezero_core::context::RequestContext; + use edgezero_core::extractor::State; + use edgezero_core::key_value_store::NoopKvStore; + use edgezero_core::secret_store::InMemorySecretStore; + use edgezero_core::{ConfigValue, EdgeError, action}; + use futures::executor::block_on; + use std::cell::Cell; + use std::rc::Rc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + use tokio::net::TcpStream; + use tokio::time::timeout; + + struct ProbeConfig { + drops: Arc, + reads: Arc, + } + + impl Drop for ProbeConfig { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + + #[async_trait::async_trait(?Send)] + impl ConfigStore for ProbeConfig { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + self.reads.fetch_add(1, Ordering::SeqCst); + Ok((key == "owned_key").then(|| ConfigValue::from("snapshot"))) + } + + async fn get_bounded( + &self, + key: &str, + clock: &edgezero_core::MonotonicClock, + deadline: edgezero_core::Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + finish_bounded_config_read( + self.get(key).await, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + } + } + + fn binding(drops: &Arc, reads: &Arc) -> ConfigStoreBinding { + ConfigStoreBinding { + default_key: "owned_key".to_owned(), + handle: ConfigStoreHandle::new(Arc::new(ProbeConfig { + drops: Arc::clone(drops), + reads: Arc::clone(reads), + })), + } + } + + fn options() -> AxumRunOptions { + let listener = StdTcpListener::bind("127.0.0.1:0").unwrap(); + AxumRunOptions::new(listener.local_addr().unwrap()).unwrap() + } + + fn metadata() -> StoresMetadata { + StoresMetadata { + config: Some(StoreMetadata { + ids: &["first", "second"], + default: "first", + }), + ..StoresMetadata::default() + } + } + + struct CustomApp; + + #[expect( + clippy::missing_trait_methods, + reason = "fixture overrides only routes, store declarations and logging ownership" + )] + impl Hooks for CustomApp { + fn owns_logging() -> bool { + true + } + fn stores() -> StoresMetadata { + metadata() + } + fn routes() -> RouterService { + RouterService::builder().get("/", read_snapshot).build() + } + } + + #[action] + async fn read_snapshot( + ctx: RequestContext, + State(state): State>, + ) -> Result { + let selected = ctx + .config_store_default_binding() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("missing fixture binding")))?; + let value = selected + .handle + .get(&selected.default_key) + .await? + .unwrap_or_default(); + Ok(format!("{}:{state}", value.as_str())) + } + + #[test] + fn ready_and_rc_deferred_inputs_share_completed_registries_with_requests() { + let options = options(); + let address = options.addr(); + let drops = Arc::new(AtomicUsize::new(0)); + let reads = Arc::new(AtomicUsize::new(0)); + let initialized = Rc::new(Cell::new(false)); + let polls = Rc::new(Cell::new(0_usize)); + let mut overrides = NativeStoreOverrides::default(); + let deferred = binding(&drops, &reads); + let counted = Rc::clone(&polls); + overrides + .prepare_config("first", async move { + counted.set(counted.get() + 1); + yield_now().await; + Ok(deferred) + }) + .unwrap(); + overrides + .insert_config("second", binding(&drops, &Arc::new(AtomicUsize::new(0)))) + .unwrap(); + let completed = Rc::clone(&initialized); + run_app_with_native_stores::( + options, + overrides, + async { + timeout(Duration::from_secs(5), async { + while !initialized.get() { + yield_now().await; + } + let mut socket = loop { + if let Ok(socket) = TcpStream::connect(address).await { + break socket; + } + yield_now().await; + }; + socket + .write_all( + b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + let mut response = Vec::new(); + socket.read_to_end(&mut response).await.unwrap(); + assert!(response.ends_with(b"snapshot:initialized")); + }) + .await + .unwrap(); + }, + move |app, stores| { + Box::pin(async move { + let registry = stores.config().unwrap(); + assert_eq!(registry.default_id(), "first"); + let selected = registry.default_ref().unwrap(); + assert_eq!(selected.default_key, "owned_key"); + assert_eq!( + selected + .handle + .get(&selected.default_key) + .await + .unwrap() + .unwrap() + .as_str(), + "snapshot" + ); + assert!(registry.named("second").is_some()); + app.insert_state(Arc::::from("initialized")); + completed.set(true); + Ok(()) + }) + }, + ) + .unwrap(); + assert_eq!(polls.get(), 1); + assert_eq!(reads.load(Ordering::SeqCst), 2); + assert_eq!(drops.load(Ordering::SeqCst), 2); + } + + #[test] + fn ready_and_deferred_kv_need_no_local_data_root() { + let opened = Rc::new(Cell::new(0_usize)); + let tracked = Rc::clone(&opened); + let mut overrides = NativeStoreOverrides::default(); + overrides + .insert_kv("first", KvHandle::new(Arc::new(NoopKvStore))) + .unwrap(); + overrides + .prepare_kv("second", async move { + tracked.set(tracked.get() + 1); + Ok(KvHandle::new(Arc::new(NoopKvStore))) + }) + .unwrap(); + let metadata = StoresMetadata { + kv: Some(StoreMetadata { + ids: &["first", "second"], + default: "second", + }), + ..StoresMetadata::default() + }; + let options = options(); + let plan = NativeSourcePlan::validate(metadata, &options, overrides).unwrap(); + let stores = block_on(build_production_stores(metadata, &options, plan)).unwrap(); + assert_eq!(opened.get(), 1); + assert_eq!(stores.kv().unwrap().default_id(), "second"); + assert!(stores.kv().unwrap().named("first").is_some()); + } + + #[test] + fn malformed_metadata_is_rejected_before_any_deferred_poll() { + for declaration in [ + StoreMetadata { + default: "missing", + ids: &["first"], + }, + StoreMetadata { + default: "first", + ids: &["first", "first"], + }, + StoreMetadata { + default: "bad/id", + ids: &["bad/id"], + }, + StoreMetadata { + default: "first", + ids: &[], + }, + ] { + let polled = Rc::new(Cell::new(false)); + let observed = Rc::clone(&polled); + let mut overrides = NativeStoreOverrides::default(); + overrides + .prepare_kv("first", async move { + observed.set(true); + Ok(KvHandle::new(Arc::new(NoopKvStore))) + }) + .unwrap(); + let metadata = StoresMetadata { + kv: Some(declaration), + ..StoresMetadata::default() + }; + assert!(NativeSourcePlan::validate(metadata, &options(), overrides).is_err()); + assert!(!polled.get()); + } + } + + #[test] + fn duplicate_inputs_never_replace_an_existing_source_and_debug_is_redacted() { + let mut overrides = NativeStoreOverrides::default(); + overrides + .insert_kv("SENTINEL", KvHandle::new(Arc::new(NoopKvStore))) + .unwrap(); + assert_eq!( + overrides.prepare_kv("SENTINEL", async { panic!("must not poll") }), + Err(NativeStorePreparationError::DuplicateBinding) + ); + let drops = Arc::new(AtomicUsize::new(0)); + overrides + .insert_config("SENTINEL", binding(&drops, &drops)) + .unwrap(); + assert_eq!( + overrides.insert_config("SENTINEL", binding(&drops, &drops)), + Err(NativeStorePreparationError::DuplicateBinding) + ); + let secret = BoundSecretStore::new( + SecretHandle::new(Arc::new(InMemorySecretStore::new([("right/key", "value")]))), + "right".to_owned(), + ); + overrides + .insert_secrets("SENTINEL", secret.clone()) + .unwrap(); + assert_eq!( + overrides.prepare_secrets("SENTINEL", async move { Ok(secret) }), + Err(NativeStorePreparationError::DuplicateBinding) + ); + assert!(!format!("{overrides:?}").contains("SENTINEL")); + } + + #[test] + fn file_and_inline_source_errors_precede_deferred_polls_and_local_opens() { + use crate::native_bindings::{NativeBindingsLimits, NativeStoreBindings}; + + for file_input in [false, true] { + for invalid in ["duplicate", "undeclared", "overlay", "disabled"] { + if invalid == "disabled" && cfg!(feature = "aws-appconfig-agent") { + continue; + } + let data = tempfile::tempdir().unwrap(); + let bootstrap = tempfile::tempdir().unwrap(); + let mut selected = options().with_data_dir(data.path()).unwrap(); + if invalid == "overlay" { + selected = AxumRunOptions::from_vars([( + "EDGEZERO__STORES__CONFIG__FIRST__KEY", + "OVERLAY_SENTINEL", + )]) + .unwrap() + .with_data_dir(data.path()) + .unwrap(); + } + let input = "version = 1\n[config.first]\nprovider = 'aws-appconfig-agent'\ndefault_key = 'settings'\nendpoint = 'http://127.0.0.1:2772'\n[config.first.documents.settings]\napplication = 'example'\nenvironment = 'production'\nprofile = 'PROFILE_SENTINEL'\n"; + let document = if invalid == "undeclared" { + input.replace("config.first", "config.UNKNOWN_SENTINEL") + } else { + input.to_owned() + }; + if file_input { + let path = bootstrap.path().join("bindings.toml"); + fs::write(&path, document).unwrap(); + selected = selected.with_store_bindings_file(&path).unwrap(); + fs::remove_file(path).unwrap(); + } else { + selected = selected + .with_store_bindings( + NativeStoreBindings::parse(&document, NativeBindingsLimits::default()) + .unwrap(), + ) + .unwrap(); + } + let polls = Rc::new(Cell::new(0_usize)); + let tracked = Rc::clone(&polls); + let drops = Arc::new(AtomicUsize::new(0)); + let handle = binding(&drops, &drops); + let mut overrides = NativeStoreOverrides::default(); + let id = if invalid == "duplicate" { + "first" + } else { + "second" + }; + overrides + .prepare_config(id, async move { + tracked.set(tracked.get() + 1); + Ok(handle) + }) + .unwrap(); + let initialized = Rc::new(Cell::new(false)); + let called = Rc::clone(&initialized); + let error = run_app_with_native_stores::( + selected, + overrides, + future::pending(), + move |_, _| { + called.set(true); + Box::pin(future::ready(Ok(()))) + }, + ) + .unwrap_err(); + assert_eq!(polls.get(), 0); + assert!(!initialized.get()); + assert_eq!(fs::read_dir(data.path()).unwrap().count(), 0); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert!(!format!("{error:?}").contains("SENTINEL")); + let expected = match invalid { + "duplicate" => "duplicate", + "undeclared" => "undeclared", + "overlay" => "NAME/KEY", + _ => "not compiled", + }; + assert!(error.to_string().contains(expected), "{error}"); + } + } + } + + #[test] + fn source_errors_do_not_poll_futures_open_databases_or_run_initializer() { + for invalid in ["undeclared", "name", "key", "config_root", "data_root"] { + let root = tempfile::tempdir().unwrap(); + let mut options = options().with_data_dir(root.path()).unwrap(); + if matches!(invalid, "name" | "key") { + options = AxumRunOptions::from_vars([( + format!( + "EDGEZERO__STORES__CONFIG__FIRST__{}", + invalid.to_uppercase() + ), + "OVERLAY_SENTINEL".to_owned(), + )]) + .unwrap() + .with_data_dir(root.path()) + .unwrap(); + } + if invalid == "data_root" { + options = AxumRunOptions::new(options.addr()).unwrap(); + } + let polled = Rc::new(Cell::new(0_usize)); + let tracked = Rc::clone(&polled); + let drops = Arc::new(AtomicUsize::new(0)); + let supplied = binding(&drops, &Arc::new(AtomicUsize::new(0))); + let mut overrides = NativeStoreOverrides::default(); + overrides + .prepare_config("first", async move { + tracked.set(tracked.get() + 1); + Ok(supplied) + }) + .unwrap(); + if invalid != "config_root" { + overrides + .insert_config("second", binding(&drops, &Arc::new(AtomicUsize::new(0)))) + .unwrap(); + } + if invalid == "undeclared" { + overrides + .insert_kv("OTHER_SENTINEL", KvHandle::new(Arc::new(NoopKvStore))) + .unwrap(); + } + let calls = Rc::new(Cell::new(0_usize)); + let called = Rc::clone(&calls); + let result = run_app_with_native_stores::( + options, + overrides, + future::pending(), + move |_, _| { + called.set(called.get() + 1); + Box::pin(future::ready(Ok(()))) + }, + ); + let error = result.unwrap_err(); + assert_eq!(polled.get(), 0, "{invalid}"); + assert_eq!(calls.get(), 0); + assert_eq!(fs::read_dir(root.path()).unwrap().count(), 0); + assert!(!format!("{error:?}").contains("SENTINEL")); + } + } + + struct WithLocalKv; + + #[expect( + clippy::missing_trait_methods, + reason = "fixture overrides only routes, store declarations and logging ownership" + )] + impl Hooks for WithLocalKv { + fn owns_logging() -> bool { + true + } + fn stores() -> StoresMetadata { + StoresMetadata { + kv: Some(StoreMetadata { + ids: &["state"], + default: "state", + }), + ..metadata() + } + } + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[test] + fn local_roots_are_required_only_for_remaining_local_ids() { + let root = tempfile::tempdir().unwrap(); + fs::write( + root.path().join("local-config-second.json"), + r#"{"second":"local"}"#, + ) + .unwrap(); + let drops = Arc::new(AtomicUsize::new(0)); + let config = binding(&drops, &Arc::new(AtomicUsize::new(0))); + let mut overrides = NativeStoreOverrides::default(); + overrides.insert_config("first", config).unwrap(); + overrides + .insert_kv("state", KvHandle::new(Arc::new(NoopKvStore))) + .unwrap(); + let metadata = WithLocalKv::stores(); + let options = options().with_config_dir(root.path()).unwrap(); + let plan = NativeSourcePlan::validate(metadata, &options, overrides).unwrap(); + let stores = block_on(build_production_stores(metadata, &options, plan)).unwrap(); + let registry = stores.config().unwrap(); + assert_eq!( + registry.named_ref("first").unwrap().default_key, + "owned_key" + ); + let local = registry.named_ref("second").unwrap(); + assert_eq!(local.default_key, "second"); + assert_eq!( + block_on(local.handle.get("second")) + .unwrap() + .unwrap() + .as_str(), + "local" + ); + assert!(stores.kv().unwrap().named("state").is_some()); + assert_eq!(fs::read_dir(root.path()).unwrap().count(), 1); + } + + #[test] + fn supplied_secret_namespace_is_preserved_and_wrong_namespaces_miss() { + let handle = + SecretHandle::new(Arc::new(InMemorySecretStore::new([("right/key", "value")]))); + let mut overrides = NativeStoreOverrides::default(); + overrides + .insert_secrets( + "first", + BoundSecretStore::new(handle.clone(), "right".to_owned()), + ) + .unwrap(); + overrides + .prepare_secrets("second", async move { + Ok(BoundSecretStore::new(handle, "wrong".to_owned())) + }) + .unwrap(); + let metadata = StoresMetadata { + secrets: Some(StoreMetadata { + ids: &["first", "second"], + default: "second", + }), + ..StoresMetadata::default() + }; + let options = options(); + let plan = NativeSourcePlan::validate(metadata, &options, overrides).unwrap(); + let stores = block_on(build_production_stores(metadata, &options, plan)).unwrap(); + let registry = stores.secrets().unwrap(); + assert_eq!(registry.default_id(), "second"); + assert_eq!( + block_on(registry.named("first").unwrap().get_bytes("key")).unwrap(), + Some(Bytes::from("value")) + ); + assert_eq!( + block_on(registry.default().unwrap().get_bytes("key")).unwrap(), + None + ); + } + + #[test] + fn later_failure_drops_staged_handles_and_unpolled_futures_without_readiness() { + let options = options(); + let address = options.addr(); + let drops = Arc::new(AtomicUsize::new(0)); + let order = Rc::new(Cell::new(0_usize)); + let first = binding(&drops, &Arc::new(AtomicUsize::new(0))); + let recorded = Rc::clone(&order); + let mut overrides = NativeStoreOverrides::default(); + overrides + .prepare_config("first", async move { + assert_eq!(recorded.get(), 0); + recorded.set(1); + Ok(first) + }) + .unwrap(); + let failing_order = Rc::clone(&order); + overrides + .prepare_config("second", async move { + assert_eq!(failing_order.get(), 1); + failing_order.set(2); + Err(NativeStorePreparationError::Unavailable) + }) + .unwrap(); + let unused = binding(&drops, &Arc::new(AtomicUsize::new(0))); + overrides + .prepare_secrets("third", async move { + let _retained = unused; + panic!("later future must not poll after preparation failure") + }) + .unwrap(); + let result = run_app_with_native_stores::( + options, + overrides, + future::pending(), + |_, _| panic!("initializer must not run after failed preparation"), + ); + assert!(result.is_err()); + assert_eq!(order.get(), 2); + assert_eq!(drops.load(Ordering::SeqCst), 2); + StdTcpListener::bind(address).unwrap(); + } + + struct WithSecrets; + + #[expect( + clippy::missing_trait_methods, + reason = "fixture overrides only routes, store declarations and logging ownership" + )] + impl Hooks for WithSecrets { + fn owns_logging() -> bool { + true + } + fn stores() -> StoresMetadata { + StoresMetadata { + secrets: Some(StoreMetadata { + ids: &["third"], + default: "third", + }), + ..metadata() + } + } + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[test] + fn stop_during_pending_preparation_drops_staged_state_without_binding() { + let options = options(); + let address = options.addr(); + let drops = Arc::new(AtomicUsize::new(0)); + let mut overrides = NativeStoreOverrides::default(); + overrides + .insert_config("first", binding(&drops, &Arc::new(AtomicUsize::new(0)))) + .unwrap(); + let entered = Rc::new(Cell::new(false)); + let observed = Rc::clone(&entered); + overrides + .prepare_config("second", async move { + observed.set(true); + future::pending().await + }) + .unwrap(); + run_app_with_native_stores::( + options, + overrides, + async { + while !entered.get() { + yield_now().await; + } + }, + |_, _| panic!("initializer must not run after startup stop"), + ) + .unwrap(); + assert_eq!(drops.load(Ordering::SeqCst), 1); + StdTcpListener::bind(address).unwrap(); + } +} diff --git a/crates/edgezero-adapter-axum/src/header_deadline.rs b/crates/edgezero-adapter-axum/src/header_deadline.rs new file mode 100644 index 00000000..a2b30746 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/header_deadline.rs @@ -0,0 +1,333 @@ +use std::error::Error as StdError; +use std::future::Future; +use std::io::{self, IoSlice}; +use std::pin::Pin; +use std::sync::{Arc, Mutex, PoisonError}; +use std::task::{Context, Poll}; +use std::time::{Duration, Instant}; + +use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue}; +use hyper::Request; +use hyper::body::Incoming; +use hyper::ext::ReasonPhrase; +use hyper::rt::{Sleep, Timer}; +use hyper::service::Service; +use hyper_util::rt::TokioTimer; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::TcpStream; +use tokio::sync::Notify; +use tokio::time::Instant as TokioInstant; + +use crate::ingress_config::AxumIngressConfig; +use crate::response::EgressConnection; +/// Shares Hyper's head-read phase with the connection's independent timer race. +/// A successful service call disarms it; parser-error writes retain the deadline. +#[derive(Clone)] +pub(crate) struct HeaderDeadline { + changed: Arc, + deadline: Arc>>, +} + +#[derive(Debug, thiserror::Error)] +#[error("HTTP/1 request head deadline exceeded")] +pub(crate) struct HeaderDeadlineExceeded; + +pub(crate) struct HeaderIo { + deadline: HeaderDeadline, + stream: TcpStream, +} + +pub(crate) struct HeaderService { + deadline: HeaderDeadline, + limits: AxumIngressConfig, + responses: EgressConnection, + service: S, +} + +#[derive(Debug, thiserror::Error)] +#[error("HTTP/1 response head exceeds adapter parser storage limits")] +struct ResponseHeadExceeded; + +impl HeaderDeadline { + pub(crate) async fn changed(&self) { + self.changed.notified().await; + } + + fn check_io(&self) -> io::Result<()> { + if self + .deadline() + .is_some_and(|limit| TokioInstant::now() >= limit) + { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + HeaderDeadlineExceeded, + )); + } + Ok(()) + } + + pub(crate) fn deadline(&self) -> Option { + *self.deadline.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub(crate) fn finish_head(&self, now: TokioInstant) -> Result<(), HeaderDeadlineExceeded> { + let mut deadline = self.deadline.lock().unwrap_or_else(PoisonError::into_inner); + if deadline.is_some_and(|limit| now >= limit) { + return Err(HeaderDeadlineExceeded); + } + *deadline = None; + self.changed.notify_one(); + Ok(()) + } + + pub(crate) fn new(timeout: Duration) -> Self { + let now = TokioInstant::now(); + Self { + changed: Arc::new(Notify::new()), + deadline: Arc::new(Mutex::new(Some(now.checked_add(timeout).unwrap_or(now)))), + } + } +} + +impl Timer for HeaderDeadline { + fn now(&self) -> Instant { + TokioInstant::now().into_std() + } + + fn reset(&self, sleep: &mut Pin>, new_deadline: Instant) { + *sleep = self.sleep_until(new_deadline); + } + + fn sleep(&self, duration: Duration) -> Pin> { + let now = self.now(); + self.sleep_until(now.checked_add(duration).unwrap_or(now)) + } + + fn sleep_until(&self, deadline: Instant) -> Pin> { + let requested = TokioInstant::from_std(deadline); + let mut state = self.deadline.lock().unwrap_or_else(PoisonError::into_inner); + let effective = state.map_or(requested, |existing| existing.min(requested)); + *state = Some(effective); + self.changed.notify_one(); + TokioTimer::new().sleep_until(effective.into_std()) + } +} + +impl HeaderIo { + pub(crate) fn new(stream: TcpStream, deadline: HeaderDeadline) -> Self { + Self { deadline, stream } + } +} + +impl AsyncRead for HeaderIo { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + this.deadline.check_io()?; + Pin::new(&mut this.stream).poll_read(cx, buf) + } +} + +impl AsyncWrite for HeaderIo { + fn is_write_vectored(&self) -> bool { + self.stream.is_write_vectored() + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + this.deadline.check_io()?; + Pin::new(&mut this.stream).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_shutdown(cx) + } + + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let this = self.get_mut(); + this.deadline.check_io()?; + Pin::new(&mut this.stream).poll_write(cx, buf) + } + + fn poll_write_vectored( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bufs: &[IoSlice<'_>], + ) -> Poll> { + let this = self.get_mut(); + this.deadline.check_io()?; + Pin::new(&mut this.stream).poll_write_vectored(cx, bufs) + } +} + +impl HeaderService { + pub(crate) fn new( + service: S, + deadline: HeaderDeadline, + limits: AxumIngressConfig, + responses: EgressConnection, + ) -> Self { + Self { + deadline, + limits, + responses, + service, + } + } +} + +impl Service> for HeaderService +where + S: Service, Response = hyper::Response>, + S::Error: Into>, + S::Future: 'static, + B: 'static, +{ + type Error = Box; + type Future = Pin>>>; + type Response = hyper::Response; + + fn call(&self, req: Request) -> Self::Future { + if let Err(error) = self.deadline.finish_head(TokioInstant::now()) { + let boxed_error: Self::Error = Box::new(error); + return Box::pin(async move { Err(boxed_error) }); + } + let future = self.service.call(req); + let limits = self.limits; + let responses = self.responses.clone(); + Box::pin(async move { + let mut response = future.await.map_err(Into::into)?; + if let Err(error) = bound_response_head(&mut response, limits) { + responses.signal_transport_error(); + let boxed_error: Self::Error = Box::new(error); + return Err(boxed_error); + } + Ok(response) + }) + } +} + +fn bound_response_head( + response: &mut hyper::Response, + limits: AxumIngressConfig, +) -> Result<(), ResponseHeadExceeded> { + let count = response.headers().len(); + let bytes = response + .headers() + .iter() + .try_fold(0_usize, |charged, (name, value)| { + charged + .checked_add(name.as_str().len())? + .checked_add(value.as_bytes().len())? + .checked_add(4) + }); + if count > limits.max_response_header_count() + || bytes.is_none_or(|total| total > limits.max_response_header_bytes()) + { + return Err(ResponseHeadExceeded); + } + // Discard application-supplied spare capacity before Hyper caches this map. + let mut compact = HeaderMap::with_capacity(count); + for (name, value) in response.headers() { + let copied_name = HeaderName::from_bytes(name.as_str().as_bytes()) + .map_err(|_invalid_name| ResponseHeadExceeded)?; + let mut copied_value = HeaderValue::from_bytes(value.as_bytes()) + .map_err(|_invalid_value| ResponseHeadExceeded)?; + copied_value.set_sensitive(value.is_sensitive()); + compact.append(copied_name, copied_value); + } + *response.headers_mut() = compact; + // Only canonical status phrases participate in the bounded encoded head. + response.extensions_mut().remove::(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use edgezero_core::http::{HeaderMap, HeaderValue}; + use hyper::ext::ReasonPhrase; + use hyper::rt::Timer as _; + use tokio::time::{Instant, advance}; + + use super::{HeaderDeadline, bound_response_head}; + use crate::ingress_config::AxumIngressConfig; + + #[test] + fn response_head_compacts_capacity_preserves_duplicates_and_rejects_overflow() { + let limits = AxumIngressConfig::default(); + let mut response = hyper::Response::new(()); + *response.headers_mut() = HeaderMap::with_capacity(0x4000); + response + .headers_mut() + .append("set-cookie", HeaderValue::from_static("a=1")); + let mut sensitive = HeaderValue::from_static("b=2"); + sensitive.set_sensitive(true); + response.headers_mut().append("set-cookie", sensitive); + response + .extensions_mut() + .insert(ReasonPhrase::from_static(b"custom")); + bound_response_head(&mut response, limits).unwrap(); + assert_eq!(response.headers().get_all("set-cookie").iter().count(), 2); + assert!(response.headers().capacity() < 100); + assert!( + response + .headers() + .get_all("set-cookie") + .iter() + .nth(1) + .unwrap() + .is_sensitive() + ); + assert!(response.extensions().get::().is_none()); + let oversized = HeaderValue::from_bytes(&vec![b'a'; limits.max_raw_head_bytes()]).unwrap(); + response.headers_mut().insert("x-large", oversized); + bound_response_head(&mut response, limits).unwrap_err(); + response.headers_mut().clear(); + for _ in 0..=limits.max_response_header_count() { + response + .headers_mut() + .append("x-dup", HeaderValue::from_static("a")); + } + bound_response_head(&mut response, limits).unwrap_err(); + } + + #[tokio::test(start_paused = true)] + async fn rearmed_header_timer_remains_in_tokio_clock_domain() { + let deadline = HeaderDeadline::new(Duration::from_secs(10)); + deadline.finish_head(Instant::now()).unwrap(); + advance(Duration::from_secs(20)).await; + drop(deadline.sleep(Duration::from_secs(10))); + assert_eq!( + deadline + .deadline() + .unwrap() + .checked_duration_since(Instant::now()), + Some(Duration::from_secs(10)) + ); + } + + #[tokio::test] + async fn first_arm_cannot_extend_accept_deadline_and_next_head_gets_new_budget() { + let timer = HeaderDeadline::new(Duration::from_millis(10)); + let accepted_deadline = timer.deadline().unwrap(); + let _sleep = timer.sleep_until((Instant::now() + Duration::from_secs(1)).into_std()); + assert_eq!(timer.deadline(), Some(accepted_deadline)); + timer + .finish_head(accepted_deadline - Duration::from_millis(1)) + .unwrap(); + assert_eq!(timer.deadline(), None); + let next = Instant::now() + Duration::from_secs(1); + let _next_sleep = timer.sleep_until(next.into_std()); + assert_eq!(timer.deadline(), Some(next)); + assert!(timer.finish_head(next).is_err()); + } +} diff --git a/crates/edgezero-adapter-axum/src/ingress_config.rs b/crates/edgezero-adapter-axum/src/ingress_config.rs new file mode 100644 index 00000000..08cccf72 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/ingress_config.rs @@ -0,0 +1,280 @@ +//! Validated limits for Axum's adapter-owned HTTP/1 listener and parser. + +use std::time::Duration; + +use edgezero_core::env_config::EnvConfig; +use edgezero_core::http::{HeaderName, HeaderValue}; + +/// Effective HTTP/1 ingress limits, frozen before the listener starts. +/// +/// The raw-head budget includes the request line, field syntax, CRLFs and final +/// blank line. It bounds both the request line and headers jointly, not with +/// independent byte allowances. The head timeout also bounds keep-alive idle. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AxumIngressConfig { + head_read_timeout: Duration, + max_connections: usize, + max_header_count: usize, + max_raw_head_bytes: usize, +} + +/// A rejected setting. Diagnostics never include the supplied value. +#[derive(Debug, thiserror::Error)] +#[error("invalid Axum ingress setting `{field}`")] +pub struct IngressConfigError { + field: &'static str, +} + +impl Default for AxumIngressConfig { + #[inline] + fn default() -> Self { + Self { + head_read_timeout: Duration::from_secs(10), + max_connections: 256, + max_header_count: 100, + max_raw_head_bytes: 0x0001_0000, + } + } +} + +impl AxumIngressConfig { + /// Resolve `EDGEZERO__ADAPTER__INGRESS__*`; only missing settings use defaults. + /// + /// # Errors + /// Rejects malformed, zero, overflowing or out-of-range explicit settings. + #[inline] + pub fn from_env(env: &EnvConfig) -> Result { + let defaults = Self::default(); + let milliseconds = setting(env, "head_read_timeout_ms", 10_000)?; + let timeout_millis = + u64::try_from(milliseconds).map_err(|_overflow| IngressConfigError { + field: "head_read_timeout_ms", + })?; + Self::new( + setting(env, "max_connections", defaults.max_connections)?, + setting(env, "max_raw_head_bytes", defaults.max_raw_head_bytes)?, + setting(env, "max_header_count", defaults.max_header_count)?, + Duration::from_millis(timeout_millis), + ) + } + + /// Absolute idle-plus-header budget for each request head. + #[must_use] + #[inline] + pub const fn head_read_timeout(self) -> Duration { + self.head_read_timeout + } + + /// Maximum owned live HTTP/1 connections, including keep-alive. + #[must_use] + #[inline] + pub const fn max_connections(self) -> usize { + self.max_connections + } + + /// Maximum raw fields in a request head, counting duplicates separately. + #[must_use] + #[inline] + pub const fn max_header_count(self) -> usize { + self.max_header_count + } + + /// Combined raw request-head byte limit, inclusive for complete heads. + #[must_use] + #[inline] + pub const fn max_raw_head_bytes(self) -> usize { + self.max_raw_head_bytes + } + + /// Maximum application response field bytes before Hyper encodes/caches them. + #[must_use] + #[inline] + pub const fn max_response_header_bytes(self) -> usize { + self.max_raw_head_bytes + } + + /// Response field cap bounds the header map Hyper reuses for request parsing. + #[must_use] + #[inline] + pub fn max_response_header_count(self) -> usize { + self.max_header_count.max(100) + } + + /// Construct validated settings. + /// + /// Ranges: 1..=4096 connections, 8192..=1048576 raw-head bytes, + /// 1..=1024 fields, and 1 ms..=120 s idle-plus-header timeout. + /// + /// # Errors + /// Rejects values outside the supported finite ranges. + #[inline] + pub fn new( + max_connections: usize, + max_raw_head_bytes: usize, + max_header_count: usize, + head_read_timeout: Duration, + ) -> Result { + for (valid, field) in [ + ((1..=4096).contains(&max_connections), "max_connections"), + ( + (8192..=0x0010_0000).contains(&max_raw_head_bytes), + "max_raw_head_bytes", + ), + ((1..=1024).contains(&max_header_count), "max_header_count"), + ( + (Duration::from_millis(1)..=Duration::from_mins(2)).contains(&head_read_timeout), + "head_read_timeout_ms", + ), + ] { + if !valid { + return Err(IngressConfigError { field }); + } + } + Ok(Self { + head_read_timeout, + max_connections, + max_header_count, + max_raw_head_bytes, + }) + } + + /// Hyper's logical buffer threshold; allocation overhead is accounted separately. + #[must_use] + #[inline] + pub const fn parser_buffer_bytes(self) -> usize { + self.max_raw_head_bytes + } + + /// Conservative requested metadata capacity per parser/cache header map. + /// Excludes name/value bytes, the fixed map object, allocator overhead and RSS. + /// Audited against http 1.4.2 and Rust 1.95.0, including collision-driven growth. + #[must_use] + #[inline] + pub fn parser_header_map_allocation_bytes(self) -> usize { + let fields = self.max_response_header_count().saturating_add(1); + let raw_slots = 1_usize << fields.saturating_mul(10).ilog2(); + let buckets = raw_slots.saturating_mul(3) >> 2_usize; + let extras = fields.saturating_sub(1).max(4).next_power_of_two(); + let word = size_of::(); + let bucket_bytes = size_of::() + .saturating_add(size_of::()) + .saturating_add(word.saturating_mul(8)); + let extra_bytes = size_of::().saturating_add(word.saturating_mul(6)); + raw_slots + .saturating_mul(4) + .saturating_add(buckets.saturating_mul(bucket_bytes)) + .saturating_add(extras.saturating_mul(extra_bytes)) + } + + /// Conservative capacity bound per receive backing allocation, not RSS. + /// Audited against Hyper 1.12.0, bytes 1.12.1 and Rust 1.95.0. + #[must_use] + #[inline] + pub const fn parser_read_allocation_bytes(self) -> usize { + self.max_raw_head_bytes.saturating_mul(4) + } + + /// Conservative capacity bound for the separate raw chunked-trailer buffer. + #[must_use] + #[inline] + pub const fn parser_trailer_allocation_bytes(self) -> usize { + self.max_raw_head_bytes.next_power_of_two() + } +} + +fn setting( + env: &EnvConfig, + field: &'static str, + default: usize, +) -> Result { + let Some(value) = env.get(&["adapter", "ingress", field]) else { + return Ok(default); + }; + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(IngressConfigError { field }); + } + value + .parse() + .map_err(|_invalid_integer| IngressConfigError { field }) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use edgezero_core::env_config::EnvConfig; + use edgezero_core::http::HeaderValue; + + use super::AxumIngressConfig; + + #[test] + fn defaults_are_finite_and_effective() { + let config = AxumIngressConfig::default(); + assert_eq!(config.max_connections(), 256); + assert_eq!(config.max_raw_head_bytes(), 0x0001_0000); + assert_eq!(config.max_header_count(), 100); + assert_eq!(config.parser_buffer_bytes(), 0x0001_0000); + assert_eq!(config.parser_read_allocation_bytes(), 0x0004_0000); + assert_eq!(config.parser_trailer_allocation_bytes(), 0x0001_0000); + assert_eq!(config.head_read_timeout(), Duration::from_secs(10)); + assert!(config.parser_header_map_allocation_bytes() > 100 * size_of::()); + assert_eq!( + AxumIngressConfig::from_env(&EnvConfig::default()).unwrap(), + config + ); + } + + #[test] + fn explicit_environment_values_are_validated() { + let env = EnvConfig::from_vars([ + ("EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS", "2"), + ("EDGEZERO__ADAPTER__INGRESS__MAX_RAW_HEAD_BYTES", "8192"), + ("EDGEZERO__ADAPTER__INGRESS__MAX_HEADER_COUNT", "4"), + ("EDGEZERO__ADAPTER__INGRESS__HEAD_READ_TIMEOUT_MS", "25"), + ]); + let config = AxumIngressConfig::from_env(&env).unwrap(); + assert_eq!(config.max_connections(), 2); + assert_eq!(config.max_raw_head_bytes(), 8192); + assert_eq!(config.max_header_count(), 4); + assert_eq!(config.head_read_timeout(), Duration::from_millis(25)); + let non_power_of_two = AxumIngressConfig::new(2, 8193, 4, Duration::from_secs(1)).unwrap(); + assert_eq!(non_power_of_two.parser_buffer_bytes(), 8193); + assert_eq!(non_power_of_two.parser_read_allocation_bytes(), 0x8004); + assert_eq!(non_power_of_two.parser_trailer_allocation_bytes(), 0x4000); + } + + #[test] + fn invalid_explicit_values_never_fall_back() { + for field in [ + "MAX_CONNECTIONS", + "MAX_RAW_HEAD_BYTES", + "MAX_HEADER_COUNT", + "HEAD_READ_TIMEOUT_MS", + ] { + for value in ["", " ", "0", "-1", "+1", "abc", "184467440737095516160"] { + let env = + EnvConfig::from_vars([(format!("EDGEZERO__ADAPTER__INGRESS__{field}"), value)]); + let error = AxumIngressConfig::from_env(&env).unwrap_err(); + assert!(!error.to_string().contains(value) || value.is_empty() || value == " "); + } + } + } + + #[test] + fn programmatic_limits_accept_boundaries_and_reject_overflow() { + AxumIngressConfig::new(1, 8192, 1, Duration::from_millis(1)).unwrap(); + AxumIngressConfig::new(4096, 0x0010_0000, 1024, Duration::from_mins(2)).unwrap(); + for (connections, bytes, count, timeout) in [ + (0, 8192, 1, Duration::from_secs(1)), + (4097, 8192, 1, Duration::from_secs(1)), + (1, 8191, 1, Duration::from_secs(1)), + (1, 0x0010_0001, 1, Duration::from_secs(1)), + (1, 8192, 0, Duration::from_secs(1)), + (1, 8192, 1025, Duration::from_secs(1)), + (1, 8192, 1, Duration::ZERO), + (1, 8192, 1, Duration::from_secs(121)), + ] { + AxumIngressConfig::new(connections, bytes, count, timeout).unwrap_err(); + } + } +} diff --git a/crates/edgezero-adapter-axum/src/key_value_store.rs b/crates/edgezero-adapter-axum/src/key_value_store.rs index bb375cf3..c4cf72b0 100644 --- a/crates/edgezero-adapter-axum/src/key_value_store.rs +++ b/crates/edgezero-adapter-axum/src/key_value_store.rs @@ -158,11 +158,9 @@ impl PersistentKvStore { /// Returns an error if the database file cannot be opened or initialised (corrupted file, locked by another process, or insufficient permissions). #[inline] pub fn new>(path: P) -> Result { - let db_path = path.as_ref().display().to_string(); - let db = Database::create(path).map_err(|err| { + let db = Database::create(path).map_err(|_error| { KvError::Internal(anyhow::anyhow!( - "Failed to open KV database at {db_path}. If the file is corrupted or locked \ - by another process, try deleting it and restarting: {err}" + "KV database unavailable; release other owners or check storage permissions, capacity and integrity" )) })?; diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index d4e33f85..e6c5292b 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -1,7 +1,10 @@ //! Axum adapter for `EdgeZero` routers and applications. +#[cfg(feature = "axum")] +mod config_snapshot; #[cfg(feature = "axum")] pub mod config_store; +pub mod config_store_limits; #[cfg(feature = "axum")] mod connection; #[cfg(feature = "axum")] @@ -9,7 +12,16 @@ pub mod context; #[cfg(feature = "axum")] pub mod dev_server; #[cfg(feature = "axum")] +mod header_deadline; +pub mod ingress_config; +#[cfg(feature = "axum")] pub mod key_value_store; +#[cfg(all(feature = "native-bindings", not(target_arch = "wasm32")))] +pub mod native_bindings; +#[cfg(all(feature = "native-bindings", not(target_arch = "wasm32")))] +pub mod native_build; +#[cfg(feature = "axum")] +pub mod native_stores; #[cfg(feature = "axum")] pub mod outbound; #[cfg(feature = "axum")] @@ -17,6 +29,8 @@ pub mod request; #[cfg(feature = "axum")] mod response; #[cfg(feature = "axum")] +mod run_options; +#[cfg(feature = "axum")] pub mod secret_store; #[cfg(feature = "axum")] mod service; diff --git a/crates/edgezero-adapter-axum/src/native_bindings.rs b/crates/edgezero-adapter-axum/src/native_bindings.rs new file mode 100644 index 00000000..3873a41f --- /dev/null +++ b/crates/edgezero-adapter-axum/src/native_bindings.rs @@ -0,0 +1,1069 @@ +//! Native bootstrap schema and pure per-ID source decisions, without service clients. + +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "schema, bootstrap I/O and source validation are grouped" +)] + +#[cfg(unix)] +use libc::O_NONBLOCK; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs::OpenOptions; +use std::io::Read as _; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt as _; +use std::path::{Path, PathBuf}; + +use edgezero_core::app::{StoreMetadata, StoresMetadata}; +use edgezero_core::env_config::EnvConfig; +use edgezero_core::manifest::{ManifestStores, StoreDeclaration}; +use edgezero_core::secret_store::MAX_NAME_LEN; +use edgezero_store_aws::settings::{ + AppConfigAgentSettings, AwsPreparationLimits, DynamoDbKvSettings, SecretsManagerSettings, +}; +use serde::Deserialize; + +/// Closed bootstrap failures. File, TOML and provider inputs never become source chains. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum NativeBindingsError { + #[error("duplicate binding source")] + Duplicate, + #[error("invalid native bindings")] + Invalid, + #[error("invalid declared metadata")] + InvalidMetadata, + #[error("explicit binding conflicts with NAME/KEY overlay")] + OverlayConflict, + #[error("undeclared binding")] + Undeclared, + #[error("malformed native bindings")] + Malformed, + #[error("native bindings size limit")] + SizeLimit, + #[error("native bindings file unavailable")] + Unavailable, + #[error("provider not compiled")] + UnsupportedProvider, +} + +/// Trusted bootstrap bounds. The file cannot raise its own read/ID limits. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct NativeBindingsLimits { + pub max_file_bytes: usize, + pub max_explicit_ids: usize, +} + +impl Default for NativeBindingsLimits { + #[inline] + fn default() -> Self { + Self { + max_file_bytes: 256 * 1_024, + max_explicit_ids: 128, + } + } +} + +impl NativeBindingsLimits { + /// Checks positive counters and the cap+1 overflow probe. + /// + /// # Errors + /// Rejects zero or unusable counter values. + #[inline] + pub fn validate(&self) -> Result<(), NativeBindingsError> { + if self.max_file_bytes == 0 + || self.max_explicit_ids == 0 + || self.max_file_bytes.checked_add(1).is_none() + || u64::try_from(self.max_file_bytes).is_err() + { + return Err(NativeBindingsError::Invalid); + } + Ok(()) + } +} + +/// Providers present in a resolved target, independent of the running CLI's features. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct NativeProviderCapabilities { + pub appconfig_agent: bool, + pub secrets_manager: bool, + pub dynamodb: bool, +} + +/// Strict configuration dispatch. Provider-specific fields are validated by AWS settings. +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "provider", deny_unknown_fields)] +pub enum NativeConfigBinding { + #[serde(rename = "aws-appconfig-agent")] + AppConfigAgent(AppConfigAgentSettings), +} + +/// Strict KV dispatch. The physical namespace is independent of the declared ID. +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "provider", deny_unknown_fields)] +pub enum NativeKvBinding { + #[serde(rename = "aws-dynamodb")] + DynamoDb(DynamoDbKvSettings), +} + +/// Strict secret dispatch. No arbitrary remote names or per-request discovery. +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "provider", deny_unknown_fields)] +pub enum NativeSecretBinding { + #[serde(rename = "aws-secrets-manager")] + SecretsManager(SecretsManagerSettings), +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct LimitsTable { + aws: AwsPreparationLimits, +} + +/// Version 1 native document. Logical IDs and lookup keys remain case-sensitive. +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NativeStoreBindings { + version: u32, + #[serde(default)] + config: BTreeMap, + #[serde(default)] + secrets: BTreeMap, + #[serde(default)] + kv: BTreeMap, + limits: Option, +} + +impl fmt::Debug for NativeStoreBindings { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("NativeStoreBindings") + .finish_non_exhaustive() + } +} + +impl NativeStoreBindings { + /// Builds a typed document with no file or ambient environment access. + #[inline] + #[must_use] + pub fn new( + config: BTreeMap, + secrets: BTreeMap, + ) -> Self { + Self { + version: 1, + config, + secrets, + kv: BTreeMap::new(), + limits: None, + } + } + + /// Parses strict TOML and validates resource shapes and trusted metadata bounds. + /// + /// # Errors + /// Returns fixed categories, never TOML excerpts or raw settings. + #[inline] + pub fn parse(input: &str, limits: NativeBindingsLimits) -> Result { + Self::parse_with_policy(input, limits, None) + } + + /// Parses using exactly one document or explicit remote policy source. + /// + /// # Errors + /// Rejects competing policies and malformed/invalid/over-limit documents. + #[inline] + pub fn parse_with_policy( + input: &str, + limits: NativeBindingsLimits, + aws: Option, + ) -> Result { + limits.validate()?; + if input.len() > limits.max_file_bytes { + return Err(NativeBindingsError::SizeLimit); + } + let bindings: Self = + toml::from_str(input).map_err(|_error| NativeBindingsError::Malformed)?; + bindings.validate(limits, aws)?; + Ok(bindings) + } + + /// Reads one explicitly selected absolute regular file, capped before parsing. + /// + /// # Errors + /// Missing, non-file, malformed and cap+1 inputs fail without echoing paths/contents. + #[inline] + pub fn read(path: &Path, limits: NativeBindingsLimits) -> Result { + Self::read_with_policy(path, limits, None) + } + + /// Captures one capped file under an explicit remote limits policy if supplied. + /// + /// # Errors + /// Rejects competing policies, unusable files and malformed/over-limit documents. + #[inline] + pub fn read_with_policy( + path: &Path, + limits: NativeBindingsLimits, + aws: Option, + ) -> Result { + limits.validate()?; + if !path.is_absolute() { + return Err(NativeBindingsError::Invalid); + } + let mut open = OpenOptions::new(); + open.read(true); + // A named pipe must not block before metadata can reject it. The check + // uses the opened handle, so replacing a path cannot bypass its type. + #[cfg(unix)] + open.custom_flags(O_NONBLOCK); + let file = open + .open(path) + .map_err(|_error| NativeBindingsError::Unavailable)?; + if !file + .metadata() + .map_err(|_error| NativeBindingsError::Unavailable)? + .is_file() + { + return Err(NativeBindingsError::Unavailable); + } + let probe = limits + .max_file_bytes + .checked_add(1) + .ok_or(NativeBindingsError::Invalid)?; + let mut bytes = Vec::new(); + file.take(u64::try_from(probe).map_err(|_error| NativeBindingsError::Invalid)?) + .read_to_end(&mut bytes) + .map_err(|_error| NativeBindingsError::Unavailable)?; + if bytes.len() > limits.max_file_bytes { + return Err(NativeBindingsError::SizeLimit); + } + let input = String::from_utf8(bytes).map_err(|_error| NativeBindingsError::Malformed)?; + Self::parse_with_policy(&input, limits, aws) + } + + /// Checks the whole settings document before constructing providers. + /// + /// # Errors + /// Rejects unsupported schema, mappings, limits and competing policy sources. + #[inline] + pub fn validate( + &self, + limits: NativeBindingsLimits, + explicit_aws: Option, + ) -> Result { + limits.validate()?; + if self.version != 1 { + return Err(NativeBindingsError::Invalid); + } + if self + .config + .len() + .checked_add(self.secrets.len()) + .and_then(|count| count.checked_add(self.kv.len())) + .ok_or(NativeBindingsError::SizeLimit)? + > limits.max_explicit_ids + { + return Err(NativeBindingsError::SizeLimit); + } + if self.limits.is_some() && explicit_aws.is_some() { + return Err(NativeBindingsError::Duplicate); + } + let policy = explicit_aws + .or_else(|| self.limits.as_ref().map(|table| table.aws)) + .unwrap_or_default(); + policy + .validate() + .map_err(|_category| NativeBindingsError::Invalid)?; + let mut mappings = + u64::try_from(self.kv.len()).map_err(|_error| NativeBindingsError::SizeLimit)?; + for binding in self.kv.values() { + let NativeKvBinding::DynamoDb(settings) = binding; + settings + .validate() + .map_err(|_category| NativeBindingsError::Invalid)?; + } + for binding in self.config.values() { + let NativeConfigBinding::AppConfigAgent(settings) = binding; + settings + .validate() + .map_err(|_category| NativeBindingsError::Invalid)?; + mappings = mappings + .checked_add( + u64::try_from(settings.documents.len()) + .map_err(|_error| NativeBindingsError::SizeLimit)?, + ) + .ok_or(NativeBindingsError::SizeLimit)?; + } + for binding in self.secrets.values() { + let NativeSecretBinding::SecretsManager(settings) = binding; + settings + .validate() + .map_err(|_category| NativeBindingsError::Invalid)?; + mappings = mappings + .checked_add( + u64::try_from(settings.entries.len()) + .map_err(|_error| NativeBindingsError::SizeLimit)?, + ) + .ok_or(NativeBindingsError::SizeLimit)?; + } + if mappings > policy.max_remote_values { + return Err(NativeBindingsError::SizeLimit); + } + Ok(policy) + } + + /// Explicit configuration mappings. + #[inline] + #[must_use] + pub fn config(&self) -> &BTreeMap { + &self.config + } + + /// Explicit live KV bindings. + #[inline] + #[must_use] + pub fn kv(&self) -> &BTreeMap { + &self.kv + } + + /// Adds KV bindings without changing the original config/secrets constructor. + #[inline] + #[must_use] + pub fn with_kv(mut self, kv: BTreeMap) -> Self { + self.kv = kv; + self + } + + /// Explicit secret mappings. + #[inline] + #[must_use] + pub fn secrets(&self) -> &BTreeMap { + &self.secrets + } + + /// Selects one typed remote limits policy; it does not merge policy sources. + #[inline] + #[must_use] + pub fn with_aws_limits(mut self, limits: AwsPreparationLimits) -> Self { + self.limits = Some(LimitsTable { aws: limits }); + self + } +} + +/// Cloneable bootstrap selection. File contents are captured once at selection. +#[derive(Clone, Default)] +pub enum NativeStoreBindingsInput { + #[default] + LocalDefaults, + File { + path: PathBuf, + bindings: NativeStoreBindings, + }, + Inline(NativeStoreBindings), +} + +impl NativeStoreBindingsInput { + /// Captured settings, without reopening the selected file. + #[inline] + #[must_use] + pub fn document(&self) -> Option<&NativeStoreBindings> { + match self { + Self::LocalDefaults => None, + Self::File { bindings, .. } | Self::Inline(bindings) => Some(bindings), + } + } +} + +impl fmt::Debug for NativeStoreBindingsInput { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::LocalDefaults => "LocalDefaults", + Self::File { .. } => "File([redacted])", + Self::Inline(_) => "Inline([redacted])", + }) + } +} + +/// Pure source classification. Custom handles are not inspected or quota-accounted. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NativeBindingSource { + Local, + Supplied, + BuiltIn, +} + +/// Supplied IDs only. This carries no handles/futures and is usable by offline CLI checks. +#[derive(Default)] +pub struct NativeSuppliedIds { + pub config: BTreeSet, + pub kv: BTreeSet, + pub secrets: BTreeSet, +} + +/// Borrowed declarations for compiled metadata or a CLI-owned portable manifest. +pub struct NativeStoreDeclarations<'declarations> { + pub config: Option>, + pub kv: Option>, + pub secrets: Option>, +} + +/// Names remain borrowed from the caller; validation does not leak or intern IDs. +pub struct NativeStoreDeclaration<'declarations> { + pub ids: Vec<&'declarations str>, + pub default: &'declarations str, +} + +impl From for NativeStoreDeclaration<'static> { + #[inline] + fn from(metadata: StoreMetadata) -> Self { + Self { + ids: metadata.ids.to_vec(), + default: metadata.default, + } + } +} + +impl From for NativeStoreDeclarations<'static> { + #[inline] + fn from(metadata: StoresMetadata) -> Self { + Self { + config: metadata.config.map(Into::into), + kv: metadata.kv.map(Into::into), + secrets: metadata.secrets.map(Into::into), + } + } +} + +impl<'declarations> TryFrom<&'declarations StoreDeclaration> + for NativeStoreDeclaration<'declarations> +{ + type Error = NativeBindingsError; + + #[inline] + fn try_from(declaration: &'declarations StoreDeclaration) -> Result { + if !declaration.legacy.is_empty() + || (declaration.ids.len() > 1 && declaration.default.is_none()) + { + return Err(NativeBindingsError::InvalidMetadata); + } + Ok(Self { + ids: declaration.ids.iter().map(String::as_str).collect(), + default: declaration.default_id(), + }) + } +} + +impl<'declarations> TryFrom<&'declarations ManifestStores> + for NativeStoreDeclarations<'declarations> +{ + type Error = NativeBindingsError; + + #[inline] + fn try_from(stores: &'declarations ManifestStores) -> Result { + Ok(Self { + config: stores + .config + .as_ref() + .map(NativeStoreDeclaration::try_from) + .transpose()?, + kv: stores + .kv + .as_ref() + .map(NativeStoreDeclaration::try_from) + .transpose()?, + secrets: stores + .secrets + .as_ref() + .map(NativeStoreDeclaration::try_from) + .transpose()?, + }) + } +} + +/// Complete per-ID decisions. Registries keep their original metadata/defaults. +pub struct NativeSourceSelection<'declarations> { + pub config: BTreeMap<&'declarations str, NativeBindingSource>, + pub kv: BTreeMap<&'declarations str, NativeBindingSource>, + pub secrets: BTreeMap<&'declarations str, NativeBindingSource>, +} + +impl<'declarations> NativeSourceSelection<'declarations> { + /// Validates metadata, every source and overlay, then checks resolved capabilities. + /// + /// # Errors + /// Duplicate/undeclared sources, scalar collisions and absent target providers fail closed. + #[inline] + pub fn validate>>( + metadata: Declarations, + bindings: &NativeStoreBindings, + supplied: &NativeSuppliedIds, + env: &EnvConfig, + capabilities: NativeProviderCapabilities, + limits: NativeBindingsLimits, + aws: Option, + ) -> Result { + bindings.validate(limits, aws)?; + let declarations = metadata.into(); + let selection = Self { + config: resolve( + declarations.config, + &supplied.config, + bindings.config.keys(), + env, + "config", + )?, + kv: resolve(declarations.kv, &supplied.kv, bindings.kv.keys(), env, "kv")?, + secrets: resolve( + declarations.secrets, + &supplied.secrets, + bindings.secrets.keys(), + env, + "secrets", + )?, + }; + if (!bindings.config.is_empty() && !capabilities.appconfig_agent) + || (!bindings.secrets.is_empty() && !capabilities.secrets_manager) + || (!bindings.kv.is_empty() && !capabilities.dynamodb) + { + return Err(NativeBindingsError::UnsupportedProvider); + } + Ok(selection) + } +} + +fn resolve<'declarations, 'settings, Ids: Iterator>( + metadata: Option>, + supplied: &BTreeSet, + bound: Ids, + env: &EnvConfig, + kind: &str, +) -> Result, NativeBindingsError> { + let settings: BTreeSet<&str> = bound.map(String::as_str).collect(); + let Some(meta) = metadata else { + return if supplied.is_empty() && settings.is_empty() { + Ok(BTreeMap::new()) + } else { + Err(NativeBindingsError::Undeclared) + }; + }; + if meta.ids.is_empty() + || !meta.ids.contains(&meta.default) + || meta.ids.iter().enumerate().any(|(index, id)| { + id.is_empty() + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + || meta.ids.iter().take(index).any(|previous| previous == id) + }) + { + return Err(NativeBindingsError::InvalidMetadata); + } + if supplied.iter().any(|id| !meta.ids.contains(&id.as_str())) + || settings.iter().any(|id| !meta.ids.contains(id)) + { + return Err(NativeBindingsError::Undeclared); + } + let mut result = BTreeMap::new(); + for id in meta.ids { + let source = match (supplied.contains(id), settings.contains(id)) { + (true, true) => return Err(NativeBindingsError::Duplicate), + (true, false) => NativeBindingSource::Supplied, + (false, true) => NativeBindingSource::BuiltIn, + (false, false) => NativeBindingSource::Local, + }; + if source != NativeBindingSource::Local + && ["name", "key"] + .iter() + .any(|field| env.store_setting(kind, id, field).is_some()) + { + return Err(NativeBindingsError::OverlayConflict); + } + if source == NativeBindingSource::Local + && ["name", "key"].iter().any(|field| { + env.store_setting(kind, id, field).is_some_and(|value| { + value.trim().is_empty() || value.chars().any(char::is_control) + }) + }) + { + return Err(NativeBindingsError::Invalid); + } + if kind == "secrets" && source != NativeBindingSource::Supplied { + let namespace = if source == NativeBindingSource::Local { + env.store_setting(kind, id, "name").map_or(id, str::trim) + } else { + id + }; + if namespace.len() > MAX_NAME_LEN { + return Err( + if source == NativeBindingSource::Local + && env.store_setting(kind, id, "name").is_some() + { + NativeBindingsError::Invalid + } else { + NativeBindingsError::InvalidMetadata + }, + ); + } + } + result.insert(id, source); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + #![expect( + clippy::assertions_on_result_states, + reason = "strict parser tests compare accepted/rejected input matrices" + )] + #![expect( + clippy::panic_in_result_fn, + reason = "tests use fallible setup and assertions" + )] + + use std::collections::{BTreeMap, BTreeSet}; + use std::error::Error; + use std::fmt::Write as _; + use std::fs; + use std::path::Path; + + use edgezero_core::app::{StoreMetadata, StoresMetadata}; + use edgezero_core::env_config::EnvConfig; + use edgezero_core::manifest::ManifestStores; + use edgezero_store_aws::settings::AwsPreparationLimits; + + use super::{ + NativeBindingSource, NativeBindingsError, NativeBindingsLimits, NativeProviderCapabilities, + NativeSourceSelection, NativeStoreBindings, NativeStoreDeclarations, NativeSuppliedIds, + }; + + const CONFIG: &str = "version = 1\n[config.app]\nprovider = 'aws-appconfig-agent'\ndefault_key = 'settings'\nendpoint = 'http://127.0.0.1:2772'\n[config.app.documents.settings]\napplication = 'example-app'\nenvironment = 'production'\nprofile = 'envelope'\n"; + + fn metadata() -> StoresMetadata { + StoresMetadata { + config: Some(StoreMetadata { + ids: &["app", "local"], + default: "app", + }), + kv: Some(StoreMetadata { + ids: &["state"], + default: "state", + }), + secrets: None, + } + } + + #[test] + fn dynamodb_binding_source_is_per_id_and_fails_closed() -> Result<(), NativeBindingsError> { + let limits = NativeBindingsLimits::default(); + let bindings = NativeStoreBindings::parse( + "version=1\n[kv.state]\nprovider='aws-dynamodb'\nregion='us-east-1'\ntable='edgezero-kv'\nnamespace='private-space'", + limits, + )?; + let capabilities = NativeProviderCapabilities { + dynamodb: true, + ..Default::default() + }; + let selection = NativeSourceSelection::validate( + metadata(), + &bindings, + &NativeSuppliedIds::default(), + &EnvConfig::default(), + capabilities, + limits, + None, + )?; + assert_eq!( + selection.kv.get("state"), + Some(&NativeBindingSource::BuiltIn) + ); + assert!(matches!( + NativeSourceSelection::validate( + metadata(), + &bindings, + &NativeSuppliedIds::default(), + &EnvConfig::default(), + NativeProviderCapabilities::default(), + limits, + None + ), + Err(NativeBindingsError::UnsupportedProvider) + )); + let supplied = NativeSuppliedIds { + kv: ["state".to_owned()].into_iter().collect(), + ..Default::default() + }; + assert!(matches!( + NativeSourceSelection::validate( + metadata(), + &bindings, + &supplied, + &EnvConfig::default(), + capabilities, + limits, + None + ), + Err(NativeBindingsError::Duplicate) + )); + assert!(!format!("{bindings:?}").contains("private-space")); + Ok(()) + } + + #[test] + fn manifest_ids_remain_borrowed_and_invalid_declarations_fail() -> Result<(), Box> { + let owned: ManifestStores = + toml::from_str("[config]\nids = ['app', 'local']\ndefault = 'app'\n")?; + let bindings = NativeStoreBindings::parse(CONFIG, NativeBindingsLimits::default())?; + let selection = NativeSourceSelection::validate( + NativeStoreDeclarations::try_from(&owned)?, + &bindings, + &NativeSuppliedIds::default(), + &EnvConfig::default(), + NativeProviderCapabilities { + appconfig_agent: true, + secrets_manager: false, + dynamodb: false, + }, + NativeBindingsLimits::default(), + None, + )?; + assert_eq!( + selection.config.get("app"), + Some(&NativeBindingSource::BuiltIn) + ); + let stored_id = selection + .config + .get_key_value("app") + .ok_or(NativeBindingsError::InvalidMetadata)? + .0; + let original_id = &owned + .config + .as_ref() + .ok_or(NativeBindingsError::InvalidMetadata)? + .ids[0]; + assert_eq!(stored_id.as_ptr(), original_id.as_ptr()); + for input in [ + "[config]\nids = ['app', 'local']\n", + "[config]\nids = ['app']\nname = 'legacy'\n", + ] { + let invalid: ManifestStores = toml::from_str(input)?; + assert!(matches!( + NativeStoreDeclarations::try_from(&invalid), + Err(NativeBindingsError::InvalidMetadata) + )); + } + Ok(()) + } + + #[test] + fn secret_namespace_limits_are_checked_before_preparation() { + let metadata = StoresMetadata { + config: None, + kv: None, + secrets: Some(StoreMetadata { + ids: &["signing"], + default: "signing", + }), + }; + let bindings = NativeStoreBindings::new(BTreeMap::new(), BTreeMap::new()); + for (bytes, accepted) in [(512, true), (513, false)] { + let env = EnvConfig::from_vars([( + "EDGEZERO__STORES__SECRETS__SIGNING__NAME", + "x".repeat(bytes), + )]); + let result = NativeSourceSelection::validate( + metadata, + &bindings, + &NativeSuppliedIds::default(), + &env, + NativeProviderCapabilities::default(), + NativeBindingsLimits::default(), + None, + ); + assert_eq!(result.is_ok(), accepted); + } + } + + #[test] + fn strict_tagged_schema_and_case_sensitive_mappings() { + let limits = NativeBindingsLimits::default(); + assert!(NativeStoreBindings::parse(CONFIG, limits).is_ok()); + for input in [ + CONFIG.replace("version = 1", "version = 2"), + CONFIG.replace("version = 1", "version = 1\nunknown = 'sentinel'"), + CONFIG.replace("aws-appconfig-agent", "dynamodb"), + CONFIG.replace("default_key = 'settings'", "default_key = 'Settings'"), + CONFIG.replace("endpoint =", "unknown = 'sentinel'\nendpoint ="), + CONFIG.replace("application =", "unknown = 'sentinel'\napplication ="), + CONFIG.replace("[config.app.documents.settings]", "[config.app.documents]"), + format!("{CONFIG}\n[kv.state]\nprovider = 'dynamodb'"), + format!("{CONFIG}\n[unknown]\nvalue = 'sentinel'"), + CONFIG.replace("version = 1", "version = 1\nversion = 1"), + format!("{CONFIG}\n[config.app]\nprovider = 'aws-appconfig-agent'"), + format!("{CONFIG}\n[config.app.documents.settings]\nprofile = 'sentinel'"), + ] { + let error = NativeStoreBindings::parse(&input, limits).err(); + assert!(error.is_some(), "accepted malformed bindings"); + assert!(!format!("{error:?}").contains("sentinel")); + } + } + + #[test] + fn capped_file_read_and_trusted_id_limit() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("sentinel.toml"); + let limits = NativeBindingsLimits { + max_file_bytes: CONFIG.len(), + max_explicit_ids: 1, + }; + fs::write(&path, CONFIG)?; + assert!(NativeStoreBindings::read(&path, limits).is_ok()); + fs::write(&path, format!("{CONFIG} "))?; + assert_eq!( + NativeStoreBindings::read(&path, limits).err(), + Some(NativeBindingsError::SizeLimit) + ); + fs::write(&path, [0xff])?; + let malformed = NativeStoreBindings::read(&path, limits) + .err() + .ok_or(NativeBindingsError::Invalid)?; + assert_eq!(malformed, NativeBindingsError::Malformed); + assert!(!format!("{malformed:?}").contains("sentinel")); + assert!(malformed.source().is_none()); + assert_eq!( + NativeStoreBindings::read(directory.path(), limits).err(), + Some(NativeBindingsError::Unavailable) + ); + assert_eq!( + NativeStoreBindings::read(Path::new("relative.toml"), limits).err(), + Some(NativeBindingsError::Invalid) + ); + let mut many = String::from("version = 1\n"); + for index in 0..=128_u32 { + many.push_str( + &CONFIG + .replace("version = 1\n", "") + .replace("config.app", &format!("config.store{index}")), + ); + if index == 127 { + assert!(NativeStoreBindings::parse(&many, NativeBindingsLimits::default()).is_ok()); + } + } + assert_eq!( + NativeStoreBindings::parse(&many, NativeBindingsLimits::default()).err(), + Some(NativeBindingsError::SizeLimit) + ); + Ok(()) + } + + #[test] + fn whole_source_validation_and_target_capabilities() -> Result<(), NativeBindingsError> { + let limits = NativeBindingsLimits::default(); + let bindings = NativeStoreBindings::parse(CONFIG, limits)?; + let available = NativeProviderCapabilities { + appconfig_agent: true, + secrets_manager: false, + dynamodb: false, + }; + let mut supplied = NativeSuppliedIds::default(); + let env = EnvConfig::default(); + let selection = NativeSourceSelection::validate( + metadata(), + &bindings, + &supplied, + &env, + available, + limits, + None, + )?; + assert_eq!( + selection.config.get("app"), + Some(&NativeBindingSource::BuiltIn) + ); + assert_eq!( + selection.config.get("local"), + Some(&NativeBindingSource::Local) + ); + assert_eq!(selection.kv.get("state"), Some(&NativeBindingSource::Local)); + assert!(matches!( + NativeSourceSelection::validate( + metadata(), + &bindings, + &supplied, + &env, + NativeProviderCapabilities::default(), + limits, + None + ), + Err(NativeBindingsError::UnsupportedProvider) + )); + supplied.config = BTreeSet::from(["app".to_owned()]); + assert!(matches!( + NativeSourceSelection::validate( + metadata(), + &bindings, + &supplied, + &env, + available, + limits, + None + ), + Err(NativeBindingsError::Duplicate) + )); + supplied.config = BTreeSet::from(["APP".to_owned()]); + assert!(matches!( + NativeSourceSelection::validate( + metadata(), + &bindings, + &supplied, + &env, + available, + limits, + None + ), + Err(NativeBindingsError::Undeclared) + )); + supplied.config.clear(); + for field in ["NAME", "KEY"] { + let overlay = + EnvConfig::from_vars([(format!("EDGEZERO__STORES__CONFIG__APP__{field}"), "")]); + assert!(matches!( + NativeSourceSelection::validate( + metadata(), + &bindings, + &supplied, + &overlay, + available, + limits, + None + ), + Err(NativeBindingsError::OverlayConflict) + )); + } + Ok(()) + } + + #[test] + fn explicit_policy_can_raise_mapping_caps_before_file_parsing() -> Result<(), Box> { + let mut input = CONFIG.to_owned(); + for index in 0..1_024_u32 { + writeln!( + input, + "\n[config.app.documents.alias{index}]\napplication = 'example-app'\nenvironment = 'production'\nprofile = 'envelope'" + )?; + } + let limits = NativeBindingsLimits::default(); + assert_eq!( + NativeStoreBindings::parse(&input, limits).err(), + Some(NativeBindingsError::SizeLimit) + ); + let aws = AwsPreparationLimits { + max_remote_values: 2_048, + ..AwsPreparationLimits::default() + }; + let root = tempfile::tempdir()?; + let path = root.path().join("bindings.toml"); + fs::write(&path, input)?; + let bindings = NativeStoreBindings::read_with_policy(&path, limits, Some(aws))?; + assert_eq!( + bindings.validate(limits, Some(aws))?.max_remote_values, + 2_048 + ); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn named_pipe_is_rejected_without_waiting_for_a_writer() -> Result<(), Box> { + use std::process::Command; + let root = tempfile::tempdir()?; + let path = root.path().join("bindings.fifo"); + assert!(Command::new("mkfifo").arg(&path).status()?.success()); + assert_eq!( + NativeStoreBindings::read(&path, NativeBindingsLimits::default()).err(), + Some(NativeBindingsError::Unavailable) + ); + Ok(()) + } + + #[test] + fn secret_schema_rejects_irrelevant_fields_and_preserves_selectors() + -> Result<(), NativeBindingsError> { + let input = "version = 1\n[secrets.signing]\nprovider = 'aws-secrets-manager'\nregion = 'us-east-1'\n[secrets.signing.entries.active]\nsecret_id = 'arn:aws:secretsmanager:us-east-1:111122223333:secret:SENTINEL-AbCdEf'\nversion_stage = 'AWSCURRENT'\n"; + let limits = NativeBindingsLimits::default(); + let document = NativeStoreBindings::parse(input, limits)?; + assert_eq!(document.secrets().len(), 1); + assert!(!format!("{document:?}").contains("SENTINEL")); + for bad in [ + input.replace("region =", "endpoint = 'http://127.0.0.1'\nregion ="), + input.replace("version_stage =", "unknown = 'SENTINEL'\nversion_stage ="), + input.replace("us-east-1'\n[secrets", "us-west-2'\n[secrets"), + input.replace( + "version_stage =", + "version_id = '12345678901234567890123456789012'\nversion_stage =", + ), + input.replace("secret_id =", "secret_id = 'partial-name'\nother ="), + ] { + let error = NativeStoreBindings::parse(&bad, limits) + .err() + .ok_or(NativeBindingsError::Invalid)?; + assert!(!format!("{error:?}").contains("SENTINEL")); + assert!(error.source().is_none()); + } + Ok(()) + } + + #[test] + fn exact_mapping_boundary_uses_one_file_or_explicit_policy() -> Result<(), NativeBindingsError> + { + let limits = NativeBindingsLimits::default(); + let aws = AwsPreparationLimits { + max_remote_values: 1, + ..AwsPreparationLimits::default() + }; + let explicit = NativeStoreBindings::parse_with_policy(CONFIG, limits, Some(aws))?; + assert_eq!(explicit.validate(limits, Some(aws))?, aws); + let file = NativeStoreBindings::parse( + &format!("{CONFIG}\n[limits.aws]\nmax_remote_values = 1"), + limits, + )?; + assert_eq!(file.validate(limits, None)?, aws); + let alias = "\n[config.app.documents.alias]\napplication = 'example-app'\nenvironment = 'production'\nprofile = 'envelope'\n"; + assert_eq!( + NativeStoreBindings::parse_with_policy(&format!("{CONFIG}{alias}"), limits, Some(aws)) + .err(), + Some(NativeBindingsError::SizeLimit) + ); + assert_eq!( + NativeStoreBindings::parse( + &format!("{CONFIG}{alias}\n[limits.aws]\nmax_remote_values = 1"), + limits + ) + .err(), + Some(NativeBindingsError::SizeLimit) + ); + Ok(()) + } + + #[test] + fn limit_policy_sources_do_not_merge_and_aliases_count_before_dedup() + -> Result<(), NativeBindingsError> { + let limits = NativeBindingsLimits::default(); + let with_policy = format!("{CONFIG}\n[limits.aws]\nmax_snapshot_bytes = 16777216"); + let bindings = NativeStoreBindings::parse(&with_policy, limits)?; + assert_eq!( + bindings.validate(limits, Some(AwsPreparationLimits::default())), + Err(NativeBindingsError::Duplicate) + ); + let aliases = format!( + "{CONFIG}\n[config.app.documents.alias]\napplication = 'example-app'\nenvironment = 'production'\nprofile = 'envelope'\n[limits.aws]\nmax_remote_values = 1" + ); + assert_eq!( + NativeStoreBindings::parse(&aliases, limits).err(), + Some(NativeBindingsError::SizeLimit) + ); + Ok(()) + } +} diff --git a/crates/edgezero-adapter-axum/src/native_build.rs b/crates/edgezero-adapter-axum/src/native_build.rs new file mode 100644 index 00000000..d76d6239 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/native_build.rs @@ -0,0 +1,310 @@ +//! SDK-free native Cargo selection and exact generated Axum command matching. + +use std::collections::BTreeSet; + +/// Normalized Cargo feature and target inputs shared by native CLI paths. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +#[must_use] +pub struct NativeCargoSelection { + /// Whether Cargo should enable every package feature. + pub all_features: bool, + /// Non-feature Cargo args before `--`. + pub cargo_args: Vec, + /// Whether Cargo should suppress package defaults. + pub no_default_features: bool, + /// Explicit additions, including `[adapters.axum.build].features`. + pub requested_features: Vec, + /// Arguments after Cargo's `--`, for `cargo run` only. + pub run_args: Vec, + /// Cargo target triple. `None` means Cargo's host target. + pub target: Option, +} + +impl NativeCargoSelection { + /// Cargo arguments in normalized form; runtime args are intentionally omitted. + #[inline] + #[must_use] + pub fn cargo_args(&self) -> Vec { + let mut args = self.cargo_args.clone(); + if !self.requested_features.is_empty() { + args.extend(["--features".to_owned(), self.requested_features.join(",")]); + } + args + } + + /// Merge manifest features with Cargo feature controls, preserving unrelated + /// arguments and separating runtime args after `--`. + /// + /// # Errors + /// Returns an error for missing/empty feature lists, missing or conflicting + /// targets, or the non-Cargo `native` target sentinel. + #[inline] + pub fn normalize_args(manifest_features: &[String], args: &[String]) -> Result { + let mut selection = Self::default(); + let mut feature_values = Vec::new(); + let mut pending_value: Option<&str> = None; + let mut after_separator = false; + + for arg in args { + if let Some(option) = pending_value.take() { + if option == "--target" { + selection.set_target(arg)?; + selection + .cargo_args + .extend([option.to_owned(), arg.clone()]); + } else { + feature_values.extend(split_features(arg)?); + } + continue; + } + if after_separator { + selection.run_args.push(arg.clone()); + continue; + } + match arg.as_str() { + "--" => after_separator = true, + "--features" | "-F" | "--target" => pending_value = Some(arg), + "--all-features" => { + selection.all_features = true; + selection.cargo_args.push(arg.clone()); + } + "--no-default-features" => { + selection.no_default_features = true; + selection.cargo_args.push(arg.clone()); + } + _ => { + if let Some(value) = arg.strip_prefix("--features=") { + feature_values.extend(split_features(value)?); + } else if let Some(value) = arg.strip_prefix("-F") { + if value.is_empty() { + selection.cargo_args.push(arg.clone()); + } else { + feature_values.extend(split_features(value)?); + } + } else if let Some(value) = arg.strip_prefix("--target=") { + selection.set_target(value)?; + selection.cargo_args.push(arg.clone()); + } else { + selection.cargo_args.push(arg.clone()); + } + } + } + } + if let Some(option) = pending_value { + return Err(if option == "--target" { + "--target requires a triple".to_owned() + } else { + format!("{option} requires a feature list") + }); + } + + let mut seen = BTreeSet::new(); + for feature in manifest_features.iter().chain(feature_values.iter()) { + for normalized in split_features(feature)? { + if seen.insert(normalized.clone()) { + selection.requested_features.push(normalized); + } + } + } + Ok(selection) + } + + fn set_target(&mut self, target: &str) -> Result<(), String> { + if target.trim().is_empty() { + return Err("--target requires a non-empty target triple".to_owned()); + } + if target == "native" { + return Err("`native` selects Cargo's host target; omit `--target`".to_owned()); + } + match &self.target { + Some(previous) if previous != target => { + Err("conflicting --target selections; refusing to silently choose one".to_owned()) + } + _ => { + self.target = Some(target.to_owned()); + Ok(()) + } + } + } +} + +/// Recognize only the exact generated Axum command base, never arbitrary shell text. +/// +/// # Examples +/// ``` +/// assert_eq!( +/// edgezero_adapter_axum::native_build::generated_axum_cargo_action("cargo build -p app"), +/// Some(("build", "app")), +/// ); +/// ``` +#[inline] +#[must_use] +pub fn generated_axum_cargo_action(command: &str) -> Option<(&'static str, &str)> { + let mut words = command.split_ascii_whitespace(); + let (Some("cargo"), Some(action_word), Some("-p"), Some(package), None) = ( + words.next(), + words.next(), + words.next(), + words.next(), + words.next(), + ) else { + return None; + }; + let action = match action_word { + "build" => "build", + "run" => "run", + _ => return None, + }; + if !valid_package_token(package) || command != format!("cargo {action} -p {package}") { + return None; + } + Some((action, package)) +} + +fn split_features(value: &str) -> Result, String> { + let values: Vec<_> = value + .split(|ch: char| ch == ',' || ch.is_ascii_whitespace()) + .filter(|feature| !feature.is_empty()) + .map(str::to_owned) + .collect(); + if values.is_empty() { + return Err("Cargo feature list must not be empty".to_owned()); + } + Ok(values) +} + +fn valid_package_token(package: &str) -> bool { + let mut bytes = package.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + first.is_ascii_alphanumeric() + && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +#[cfg(test)] +mod tests { + use super::{NativeCargoSelection, generated_axum_cargo_action}; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + #[test] + fn controls_after_separator_are_runtime_args() { + let selection = NativeCargoSelection::normalize_args( + &[], + &strings(&["--features", "compile", "--", "--features", "runtime"]), + ) + .expect("feature controls should parse"); + assert_eq!(selection.requested_features, strings(&["compile"])); + assert_eq!(selection.run_args, strings(&["--features", "runtime"])); + } + + #[test] + fn normalizes_repeated_features_and_preserves_cargo_controls() { + let selection = NativeCargoSelection::normalize_args( + &strings(&["aws-appconfig-agent", "shared"]), + &strings(&[ + "--features", + "shared,aws-secrets-manager", + "-F", + "logging extra\ttabbed", + "--features=metrics", + "-Fcache", + "--all-features", + "--no-default-features", + "--target", + "aarch64-unknown-linux-gnu", + "--locked", + ]), + ) + .expect("Cargo controls should normalize"); + assert_eq!( + selection.requested_features, + strings(&[ + "aws-appconfig-agent", + "shared", + "aws-secrets-manager", + "logging", + "extra", + "tabbed", + "metrics", + "cache", + ]) + ); + assert!(selection.all_features); + assert!(selection.no_default_features); + assert_eq!( + selection.target.as_deref(), + Some("aarch64-unknown-linux-gnu") + ); + assert_eq!( + selection.cargo_args(), + strings(&[ + "--all-features", + "--no-default-features", + "--target", + "aarch64-unknown-linux-gnu", + "--locked", + "--features", + "aws-appconfig-agent,shared,aws-secrets-manager,logging,extra,tabbed,metrics,cache", + ]) + ); + } + + #[test] + fn recognizes_only_exact_generated_package_commands() { + assert_eq!( + generated_axum_cargo_action("cargo build -p app_cli"), + Some(("build", "app_cli")) + ); + assert_eq!( + generated_axum_cargo_action("cargo run -p app-cli"), + Some(("run", "app-cli")) + ); + for command in [ + "cargo build -p app; touch /tmp/x", + "cargo build -p app | cat", + "cargo build -p 'app'", + "cargo build -p caf\u{e9}", + "cargo build -p app.extra", + "CARGO build -p app", + "cargo run -p app -- --help", + "cargo\tbuild -p app", + ] { + assert_eq!(generated_axum_cargo_action(command), None, "{command}"); + } + } + + #[test] + fn rejects_missing_features_and_invalid_or_conflicting_targets() { + assert_eq!( + NativeCargoSelection::normalize_args(&[], &strings(&["--features"])) + .expect_err("missing --features value must fail"), + "--features requires a feature list" + ); + assert_eq!( + NativeCargoSelection::normalize_args(&[], &strings(&["-F"])) + .expect_err("missing -F value must fail"), + "-F requires a feature list" + ); + assert_eq!( + NativeCargoSelection::normalize_args( + &[], + &strings(&[ + "--target", + "x86_64-unknown-linux-gnu", + "--target=aarch64-unknown-linux-gnu", + ]), + ) + .expect_err("conflicting targets must fail"), + "conflicting --target selections; refusing to silently choose one" + ); + assert_eq!( + NativeCargoSelection::normalize_args(&[], &strings(&["--target=native"])) + .expect_err("native is not a Cargo target triple"), + "`native` selects Cargo's host target; omit `--target`" + ); + } +} diff --git a/crates/edgezero-adapter-axum/src/native_stores.rs b/crates/edgezero-adapter-axum/src/native_stores.rs new file mode 100644 index 00000000..5c51937f --- /dev/null +++ b/crates/edgezero-adapter-axum/src/native_stores.rs @@ -0,0 +1,598 @@ +//! Native per-ID provider inputs. Deferred opening belongs inside the future body. + +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "typed inputs and per-kind insertion methods precede source validation and preparation" +)] + +use std::collections::{BTreeMap, btree_map::Entry}; +use std::convert::Infallible; +use std::fmt; +use std::future::Future; +use std::pin::Pin; + +use edgezero_core::app::StoresMetadata; +use edgezero_core::key_value_store::KvHandle; +use edgezero_core::store_registry::{BoundSecretStore, ConfigStoreBinding}; +#[cfg(any( + feature = "aws-appconfig-agent", + feature = "aws-secrets-manager", + feature = "aws-dynamodb" +))] +use edgezero_core::time::MonotonicClock; +#[cfg(any( + feature = "aws-appconfig-agent", + feature = "aws-secrets-manager", + feature = "aws-dynamodb" +))] +use edgezero_store_aws::AwsPreparation; +use edgezero_store_aws::settings::{AgentAccessToken, AwsPreparationLimits}; + +use crate::config_store_limits::ConfigStoreLimits; +use crate::native_bindings::{ + NativeBindingSource, NativeConfigBinding, NativeKvBinding, NativeProviderCapabilities, + NativeSecretBinding, NativeSourceSelection, NativeStoreBindings, NativeSuppliedIds, +}; +use crate::run_options::{AxumRunOptions, failure}; + +/// Closed preparation categories. Never wrap provider errors, settings or payloads. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum NativeStorePreparationError { + #[error("invalid binding")] + InvalidBinding, + #[error("duplicate binding")] + DuplicateBinding, + #[error("provider unavailable")] + Unavailable, + #[error("preparation deadline")] + Deadline, + #[error("size limit")] + SizeLimit, + #[error("malformed value")] + Malformed, +} + +/// Owned native setup. No `Send` bound; provider handles retain their core bounds. +pub type NativeStoreFuture = + Pin> + 'static>>; + +/// Supplied inputs have no built-in payload; validated plans select one per kind. +pub(crate) enum NativeStoreSource { + Local, + Ready(H), + Prepare(NativeStoreFuture), + BuiltIn(B), +} + +pub(crate) type NativeConfigSource = (NativeConfigBinding, Option); + +#[cfg(any( + feature = "aws-appconfig-agent", + feature = "aws-secrets-manager", + feature = "aws-dynamodb" +))] +pub(crate) type NativeAwsSession = Option; +#[cfg(not(any( + feature = "aws-appconfig-agent", + feature = "aws-secrets-manager", + feature = "aws-dynamodb" +)))] +pub(crate) struct NativeAwsSession; + +/// Move-only inputs, separate from cloneable hosting options. +/// +/// Deferred construction must be side-effect-free. The runner promises not to poll +/// setup until source validation succeeds; it cannot undo earlier caller-owned work. +#[derive(Default)] +pub struct NativeStoreOverrides { + config: BTreeMap>, + kv: BTreeMap>, + secrets: BTreeMap>, +} + +impl fmt::Debug for NativeStoreOverrides { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("NativeStoreOverrides") + .finish_non_exhaustive() + } +} + +impl NativeStoreOverrides { + /// Supplies an existing config binding, including its own default key. + /// + /// # Errors + /// Rejects a second supplied input for the same config ID. + #[inline] + pub fn insert_config>( + &mut self, + id: Id, + binding: ConfigStoreBinding, + ) -> Result<(), NativeStorePreparationError> { + insert( + &mut self.config, + id.into(), + NativeStoreSource::Ready(binding), + ) + } + + /// Defers config opening until the runner has validated all sources. + /// + /// # Errors + /// Rejects a second supplied input for the same config ID. + #[inline] + pub fn prepare_config( + &mut self, + id: Id, + future: Setup, + ) -> Result<(), NativeStorePreparationError> + where + Id: Into, + Setup: Future> + 'static, + { + insert( + &mut self.config, + id.into(), + NativeStoreSource::Prepare(Box::pin(future)), + ) + } + + /// Supplies an already-open KV handle. + /// + /// # Errors + /// Rejects a second supplied input for the same KV ID. + #[inline] + pub fn insert_kv>( + &mut self, + id: Id, + handle: KvHandle, + ) -> Result<(), NativeStorePreparationError> { + insert(&mut self.kv, id.into(), NativeStoreSource::Ready(handle)) + } + + /// Defers KV opening until the runner has validated all sources. + /// + /// # Errors + /// Rejects a second supplied input for the same KV ID. + #[inline] + pub fn prepare_kv( + &mut self, + id: Id, + future: Setup, + ) -> Result<(), NativeStorePreparationError> + where + Id: Into, + Setup: Future> + 'static, + { + insert( + &mut self.kv, + id.into(), + NativeStoreSource::Prepare(Box::pin(future)), + ) + } + + /// Supplies a secret binding, preserving its explicit namespace. + /// + /// # Errors + /// Rejects a second supplied input for the same secret ID. + #[inline] + pub fn insert_secrets>( + &mut self, + id: Id, + binding: BoundSecretStore, + ) -> Result<(), NativeStorePreparationError> { + insert( + &mut self.secrets, + id.into(), + NativeStoreSource::Ready(binding), + ) + } + + /// Defers secret opening until the runner has validated all sources. + /// + /// # Errors + /// Rejects a second supplied input for the same secret ID. + #[inline] + pub fn prepare_secrets( + &mut self, + id: Id, + future: Setup, + ) -> Result<(), NativeStorePreparationError> + where + Id: Into, + Setup: Future> + 'static, + { + insert( + &mut self.secrets, + id.into(), + NativeStoreSource::Prepare(Box::pin(future)), + ) + } +} + +fn insert( + map: &mut BTreeMap>, + id: String, + source: NativeStoreSource, +) -> Result<(), NativeStorePreparationError> { + match map.entry(id) { + Entry::Vacant(entry) => { + entry.insert(source); + Ok(()) + } + Entry::Occupied(_) => Err(NativeStorePreparationError::DuplicateBinding), + } +} + +pub(crate) struct NativeSourcePlan { + pub config_limits: ConfigStoreLimits, + pub aws_limits: AwsPreparationLimits, + pub config: BTreeMap<&'static str, NativeStoreSource>, + pub kv: BTreeMap<&'static str, NativeStoreSource>, + pub secrets: BTreeMap<&'static str, NativeStoreSource>, +} + +impl NativeSourcePlan { + pub(crate) fn validate( + metadata: StoresMetadata, + options: &AxumRunOptions, + overrides: NativeStoreOverrides, + ) -> anyhow::Result { + options.validate_store_overlays(metadata)?; + let local_defaults = NativeStoreBindings::new(BTreeMap::new(), BTreeMap::new()); + let bindings = options.bindings().document().unwrap_or(&local_defaults); + let supplied = NativeSuppliedIds { + config: overrides.config.keys().cloned().collect(), + kv: overrides.kv.keys().cloned().collect(), + secrets: overrides.secrets.keys().cloned().collect(), + }; + let selection = NativeSourceSelection::validate( + metadata, + bindings, + &supplied, + options.env_config(), + NativeProviderCapabilities { + appconfig_agent: cfg!(feature = "aws-appconfig-agent"), + secrets_manager: cfg!(feature = "aws-secrets-manager"), + dynamodb: cfg!(feature = "aws-dynamodb"), + }, + options.bindings_limits(), + options.aws_limits(), + ) + .map_err(|category| failure("sources", "bindings", &category.to_string()))?; + for binding in bindings.config().values() { + let NativeConfigBinding::AppConfigAgent(settings) = binding; + if let Some(name) = &settings.access_token_env { + options.agent_token(name)?; + } + } + let plan = Self { + config_limits: ConfigStoreLimits::from_env(options.env_config())?, + aws_limits: bindings + .validate(options.bindings_limits(), options.aws_limits()) + .map_err(|category| failure("sources", "bindings", &category.to_string()))?, + config: resolve(selection.config, overrides.config, "config", |id| { + let NativeConfigBinding::AppConfigAgent(settings) = bindings + .config() + .get(id) + .ok_or_else(|| failure("config", id, "invalid binding"))?; + let token = settings + .access_token_env + .as_ref() + .map(|name| options.agent_token(name)) + .transpose()?; + Ok((NativeConfigBinding::AppConfigAgent(settings.clone()), token)) + })?, + kv: resolve(selection.kv, overrides.kv, "kv", |id| { + bindings + .kv() + .get(id) + .cloned() + .ok_or_else(|| failure("kv", id, "invalid binding")) + })?, + secrets: resolve(selection.secrets, overrides.secrets, "secrets", |id| { + bindings + .secrets() + .get(id) + .cloned() + .ok_or_else(|| failure("secrets", id, "invalid binding")) + })?, + }; + options.validate_local_roots( + plan.config + .values() + .any(|source| matches!(source, NativeStoreSource::Local)), + plan.kv + .values() + .any(|source| matches!(source, NativeStoreSource::Local)), + )?; + Ok(plan) + } + + #[cfg_attr( + not(any( + feature = "aws-appconfig-agent", + feature = "aws-secrets-manager", + feature = "aws-dynamodb" + )), + expect( + clippy::unnecessary_wraps, + reason = "the same fallible session boundary is used when native services are compiled" + ) + )] + pub(crate) fn session(&self) -> anyhow::Result { + #[cfg(any( + feature = "aws-appconfig-agent", + feature = "aws-secrets-manager", + feature = "aws-dynamodb" + ))] + { + let selected = self + .kv + .values() + .any(|source| matches!(source, NativeStoreSource::BuiltIn(_))) + || self + .config + .values() + .any(|source| matches!(source, NativeStoreSource::BuiltIn(_))) + || self + .secrets + .values() + .any(|source| matches!(source, NativeStoreSource::BuiltIn(_))); + if selected { + return AwsPreparation::new(MonotonicClock::default(), self.aws_limits) + .map(Some) + .map_err(|category| failure("sources", "bindings", &category.to_string())); + } + Ok(None) + } + #[cfg(not(any( + feature = "aws-appconfig-agent", + feature = "aws-secrets-manager", + feature = "aws-dynamodb" + )))] + { + let _: AwsPreparationLimits = self.aws_limits; + Ok(NativeAwsSession) + } + } +} + +fn resolve anyhow::Result>( + selection: BTreeMap<&'static str, NativeBindingSource>, + mut supplied: BTreeMap>, + kind: &str, + builtin: BuiltIn, +) -> anyhow::Result>> { + selection + .into_iter() + .map(|(id, selected)| { + let source = match selected { + NativeBindingSource::Local => NativeStoreSource::Local, + NativeBindingSource::Supplied => match supplied + .remove(id) + .ok_or_else(|| failure(kind, id, "invalid binding"))? + { + NativeStoreSource::Local => NativeStoreSource::Local, + NativeStoreSource::Ready(handle) => NativeStoreSource::Ready(handle), + NativeStoreSource::Prepare(future) => NativeStoreSource::Prepare(future), + NativeStoreSource::BuiltIn(never) => match never {}, + }, + NativeBindingSource::BuiltIn => NativeStoreSource::BuiltIn(builtin(id)?), + }; + Ok((id, source)) + }) + .collect() +} + +pub(crate) async fn prepare( + source: NativeStoreSource, + kind: &str, + id: &str, + local: Open, +) -> anyhow::Result +where + Open: FnOnce() -> anyhow::Result, +{ + match source { + NativeStoreSource::Local => local(), + NativeStoreSource::Ready(handle) => Ok(handle), + NativeStoreSource::Prepare(future) => future + .await + .map_err(|category| failure(kind, id, &category.to_string())), + NativeStoreSource::BuiltIn(_) => Err(failure(kind, id, "provider not compiled")), + } +} + +#[cfg_attr( + not(feature = "aws-appconfig-agent"), + expect( + clippy::unused_async, + reason = "compiled-out provider retains the same preparation future boundary" + ) +)] +pub(crate) async fn prepare_aws_config( + builtin: NativeConfigSource, + session: &mut NativeAwsSession, + id: &str, +) -> anyhow::Result { + #[cfg(feature = "aws-appconfig-agent")] + { + let (NativeConfigBinding::AppConfigAgent(settings), token) = builtin; + let handle = session + .as_mut() + .ok_or_else(|| failure("config", id, "invalid preparation session"))? + .prepare_appconfig(&settings, token.as_ref()) + .await + .map_err(|category| failure("config", id, &category.to_string()))?; + Ok(ConfigStoreBinding { + default_key: settings.default_key, + handle, + }) + } + #[cfg(not(feature = "aws-appconfig-agent"))] + { + let _: (NativeConfigSource, &mut NativeAwsSession) = (builtin, session); + Err(failure("config", id, "provider not compiled")) + } +} + +#[cfg_attr( + not(feature = "aws-secrets-manager"), + expect( + clippy::unused_async, + reason = "compiled-out provider retains the same preparation future boundary" + ) +)] +pub(crate) async fn prepare_aws_secrets( + builtin: NativeSecretBinding, + session: &mut NativeAwsSession, + id: &str, +) -> anyhow::Result { + #[cfg(feature = "aws-secrets-manager")] + { + let NativeSecretBinding::SecretsManager(settings) = builtin; + let handle = session + .as_mut() + .ok_or_else(|| failure("secrets", id, "invalid preparation session"))? + .prepare_secrets(&settings, id) + .await + .map_err(|category| failure("secrets", id, &category.to_string()))?; + Ok(BoundSecretStore::new(handle, id.to_owned())) + } + #[cfg(not(feature = "aws-secrets-manager"))] + { + let _: (NativeSecretBinding, &mut NativeAwsSession) = (builtin, session); + Err(failure("secrets", id, "provider not compiled")) + } +} + +#[cfg_attr( + not(feature = "aws-dynamodb"), + expect( + clippy::unused_async, + reason = "compiled-out KV provider retains the same preparation future boundary" + ) +)] +pub(crate) async fn prepare_aws_kv( + builtin: NativeKvBinding, + session: &mut NativeAwsSession, + id: &str, +) -> anyhow::Result { + #[cfg(feature = "aws-dynamodb")] + { + let NativeKvBinding::DynamoDb(settings) = builtin; + session + .as_mut() + .ok_or_else(|| failure("kv", id, "invalid preparation session"))? + .prepare_dynamodb(&settings) + .await + .map_err(|category| failure("kv", id, &category.to_string())) + } + #[cfg(not(feature = "aws-dynamodb"))] + { + let _: (NativeKvBinding, &mut NativeAwsSession) = (builtin, session); + Err(failure("kv", id, "provider not compiled")) + } +} + +#[cfg(test)] +mod tests { + use super::{NativeSourcePlan, NativeStoreOverrides, NativeStoreSource}; + use crate::{ + native_bindings::{NativeBindingsLimits, NativeKvBinding, NativeStoreBindings}, + run_options::AxumRunOptions, + }; + use edgezero_core::app::{StoreMetadata, StoresMetadata}; + use std::collections::BTreeMap; + + #[cfg(feature = "aws-dynamodb")] + #[test] + fn dynamodb_local_ready_and_non_send_deferred_sources_validate_without_polling() { + use crate::key_value_store::PersistentKvStore; + use edgezero_core::key_value_store::KvHandle; + use std::cell::Cell; + use std::fs::read_dir; + use std::rc::Rc; + use std::sync::Arc; + let directory = tempfile::tempdir().unwrap(); + // The caller owns this already-open handle; validation opens no other store. + let supplied = KvHandle::new(Arc::new( + PersistentKvStore::new(directory.path().join("supplied.redb")).unwrap(), + )); + let polls = Rc::new(Cell::new(0_usize)); + let observed = Rc::clone(&polls); + let deferred_handle = supplied.clone(); + let mut overrides = NativeStoreOverrides::default(); + overrides.insert_kv("supplied", supplied).unwrap(); + overrides + .prepare_kv("deferred", async move { + observed.set(1); + Ok(deferred_handle) + }) + .unwrap(); + let bindings = NativeStoreBindings::parse("version=1\n[kv.remote]\nprovider='aws-dynamodb'\nregion='us-east-1'\ntable='edgezero-kv'\nnamespace='scope'", NativeBindingsLimits::default()).unwrap(); + let options = AxumRunOptions::new("127.0.0.1:8787".parse().unwrap()) + .unwrap() + .with_data_dir(directory.path()) + .unwrap() + .with_store_bindings(bindings) + .unwrap(); + let metadata = StoresMetadata { + kv: Some(StoreMetadata { + ids: &["remote", "local", "supplied", "deferred"], + default: "remote", + }), + config: None, + secrets: None, + }; + let validated = NativeSourcePlan::validate(metadata, &options, overrides).unwrap(); + assert!(matches!( + validated.kv.get("remote"), + Some(NativeStoreSource::BuiltIn(NativeKvBinding::DynamoDb(_))) + )); + assert!(matches!( + validated.kv.get("local"), + Some(NativeStoreSource::Local) + )); + assert!(matches!( + validated.kv.get("supplied"), + Some(NativeStoreSource::Ready(_)) + )); + assert!(matches!( + validated.kv.get("deferred"), + Some(NativeStoreSource::Prepare(_)) + )); + assert_eq!(polls.get(), 0); + assert_eq!(read_dir(directory.path()).unwrap().count(), 1); + } + + #[test] + fn dynamodb_only_needs_no_local_root_and_checks_actual_feature() { + let bindings = NativeStoreBindings::parse("version=1\n[kv.state]\nprovider='aws-dynamodb'\nregion='us-east-1'\ntable='edgezero-kv'\nnamespace='scope'", NativeBindingsLimits::default()).unwrap(); + let options = AxumRunOptions::new("127.0.0.1:8787".parse().unwrap()) + .unwrap() + .with_store_bindings(bindings) + .unwrap(); + let metadata = StoresMetadata { + kv: Some(StoreMetadata { + ids: &["state"], + default: "state", + }), + config: None, + secrets: None, + }; + let plan = NativeSourcePlan::validate(metadata, &options, NativeStoreOverrides::default()); + if cfg!(feature = "aws-dynamodb") { + let validated_plan = plan.unwrap(); + assert!(matches!( + validated_plan.kv.get("state"), + Some(NativeStoreSource::BuiltIn(NativeKvBinding::DynamoDb(_))) + )); + } else { + assert!(plan.is_err()); + } + let empty = NativeStoreBindings::new(BTreeMap::new(), BTreeMap::new()); + assert!(empty.kv().is_empty()); + } +} diff --git a/crates/edgezero-adapter-axum/src/outbound.rs b/crates/edgezero-adapter-axum/src/outbound.rs index e229e7fe..b2dad949 100644 --- a/crates/edgezero-adapter-axum/src/outbound.rs +++ b/crates/edgezero-adapter-axum/src/outbound.rs @@ -2,29 +2,22 @@ use async_stream::stream; use async_trait::async_trait; use bytes::Bytes; use core::num::NonZeroU64; -use core::task::Poll; use core::time::Duration; use edgezero_core::body::{Body, BodyStream}; -use edgezero_core::compression::{ - ContentEncoding, classify_content_encoding, decode_brotli_stream, decode_deflate_stream, - decode_gzip_stream, -}; use edgezero_core::error::{BadGatewayReason, BudgetSource, EdgeError}; -use edgezero_core::http::header::{ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH, HOST}; +use edgezero_core::http::header::{ACCEPT_ENCODING, HOST}; use edgezero_core::http::{HeaderMap, HeaderValue, Method, StatusCode}; use edgezero_core::outbound::{ - OutboundBatch, OutboundBatchDriverEvent, OutboundHttpClient, OutboundRequest, - OutboundRequestParts, OutboundResponse, ResponseBodyDisposition, ResponseHeaderLimiter, - ResponseMode, collect_response_stream, enforce_payload_content_length, finish_batch_item, - insert_proxy_header, limit_decoded_stream, limit_encoded_stream, normalize_for_dispatch, - normalize_response_headers, rechunk_stream, validate_for_dispatch, + OutboundBatch, OutboundHttpClient, OutboundRequest, OutboundRequestParts, OutboundResponse, + ResponseBodyDisposition, ResponseBodyPolicy, ResponseHeaderLimiter, ResponseMode, + apply_response_body_policy, collect_response_stream, insert_proxy_header, + normalize_for_dispatch, normalize_response_headers, validate_for_dispatch, }; use edgezero_core::time::{ Deadline, DispatchBudget, MonotonicClock, MonotonicInstant, dispatch_budget, }; use futures_util::StreamExt as _; -use futures_util::future::{FutureExt as _, LocalBoxFuture, poll_fn}; -use futures_util::stream::FuturesUnordered; +use futures_util::future::{FutureExt as _, LocalBoxFuture}; use reqwest::header::HeaderMap as ReqwestHeaderMap; use reqwest::redirect::Policy; use std::sync::Arc; @@ -199,7 +192,7 @@ impl OutboundHttpClient for AxumOutboundClient { } let mut completed = Vec::new(); - let pending_slots = FuturesUnordered::>::new(); + let mut pending_slots = Vec::>::new(); for (index, request) in requests.into_iter().enumerate() { match Self::prepare_batch(request, batch_started_at, observation_cutoff) { Ok(prepared) => { @@ -216,48 +209,13 @@ impl OutboundHttpClient for AxumOutboundClient { } } - let completions = stream! { - let mut pending = pending_slots; - poll_fn(|context| { - loop { - match pending.poll_next_unpin(context) { - Poll::Ready(Some(item)) => completed.push(item), - Poll::Ready(None) | Poll::Pending => { - return Poll::Ready(()); - } - } - } - }).await; - - for (index, completed_at, outcome) in completed { - let Some(item) = finish_batch_item( - index, - batch_started_at, - completed_at, - observation_cutoff, - outcome, - ) else { - yield OutboundBatchDriverEvent::Cutoff; - return; - }; - yield OutboundBatchDriverEvent::Item(item); - } - - while let Some((index, completed_at, outcome)) = pending.next().await { - let Some(item) = finish_batch_item( - index, - batch_started_at, - completed_at, - observation_cutoff, - outcome, - ) else { - yield OutboundBatchDriverEvent::Cutoff; - return; - }; - yield OutboundBatchDriverEvent::Item(item); - } - }; - OutboundBatch::from_driver(slot_count, completions) + OutboundBatch::from_futures( + slot_count, + batch_started_at, + observation_cutoff, + completed, + pending_slots, + ) } } @@ -412,42 +370,18 @@ async fn process_response( )); } - let encoding = classify_content_encoding(&headers); - let max_buffered = match response_mode { - ResponseMode::Buffered { max_bytes } => Some(max_bytes), - ResponseMode::Streamed => None, - }; - enforce_payload_content_length( - &headers, - &encoding, - max_buffered, - max_decoded_response_bytes, - max_encoded_response_bytes, + let shaped = apply_response_body_policy( + native, + &mut headers, + ResponseBodyPolicy { + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_decoder_bytes, + max_encoded_response_bytes, + response_mode, + }, )?; - let encoded = limit_encoded_stream(native, max_encoded_response_bytes); - let decoded = match &encoding { - ContentEncoding::Brotli => { - decode_brotli_stream(encoded, max_brotli_window_bits, max_decoder_bytes) - } - ContentEncoding::Deflate => decode_deflate_stream(encoded, max_decoder_bytes), - ContentEncoding::Gzip => decode_gzip_stream(encoded, max_decoder_bytes), - ContentEncoding::Identity | ContentEncoding::Passthrough(_) => encoded, - }; - if matches!( - &encoding, - ContentEncoding::Brotli | ContentEncoding::Deflate | ContentEncoding::Gzip - ) { - headers.remove(CONTENT_ENCODING); - headers.remove(CONTENT_LENGTH); - } - let output = match &encoding { - ContentEncoding::Brotli - | ContentEncoding::Deflate - | ContentEncoding::Gzip - | ContentEncoding::Identity => limit_decoded_stream(decoded, max_decoded_response_bytes), - ContentEncoding::Passthrough(_) => decoded, - }; - let shaped = rechunk_stream(output, max_chunk_bytes); let deadline_bound = deadline_stream(shaped, budget, clock); let body = match response_mode { ResponseMode::Buffered { max_bytes } => { diff --git a/crates/edgezero-adapter-axum/src/response.rs b/crates/edgezero-adapter-axum/src/response.rs index d5dabcab..8ba11595 100644 --- a/crates/edgezero-adapter-axum/src/response.rs +++ b/crates/edgezero-adapter-axum/src/response.rs @@ -2,6 +2,7 @@ use std::cell::{Cell, RefCell}; use std::collections::VecDeque; use std::error::Error as StdError; use std::fmt; +use std::mem; use std::pin::Pin; use std::ptr; use std::rc::{Rc, Weak}; @@ -10,18 +11,23 @@ use std::task::{Context, Poll}; use axum::http::Response; use bytes::Bytes; use edgezero_core::body::{Body, BodyStream}; +use edgezero_core::error::EdgeError; use edgezero_core::http::{Method, Response as CoreResponse, StatusCode}; use edgezero_core::response_egress::{ RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET, ResponseEgressAttempt, ResponseEgressEnvelope, ResponseEgressFallbackDisposition, ResponseEgressOutcome, }; use edgezero_core::response_egress_framing::{ - PreparedResponseEgress, prepare_response_egress, response_egress_source_error_category, + PreparedResponseEgress, ResponseEgressBodyLengthError, prepare_response_egress, + response_egress_source_error_category, }; use edgezero_core::time::{Deadline, MonotonicClock}; +use futures_util::StreamExt as _; use http_body::{Body as HttpBody, Frame, SizeHint}; use tokio::sync::Notify; +use tokio::task::yield_now; use tokio::time::Instant as TokioInstant; +use tokio::time::timeout; const INTERNAL_FALLBACK_BODY: &[u8] = b"internal server error"; const TIMEOUT_FALLBACK_BODY: &[u8] = b"response write deadline exceeded"; @@ -207,6 +213,7 @@ pub(crate) struct AxumEgressBody { active: Option, remaining_hint: Option, source: ResponseSource, + staged_final: Option, } #[expect( @@ -243,6 +250,7 @@ impl AxumEgressBody { } else { ResponseSource::Once(Some(bytes)) }, + staged_final: None, } } @@ -290,21 +298,20 @@ impl AxumEgressBody { }), remaining_hint: declared_length.or(inferred_length), source, + staged_final: None, }) } fn account(&mut self, length: usize) -> Result<(), AxumBodyError> { - let Some(active) = self.active.as_mut() else { - return Ok(()); - }; let Ok(accepted_length) = u64::try_from(length) else { self.fail(ResponseEgressOutcome::TransportError); return Err(AxumBodyError(ResponseEgressOutcome::TransportError)); }; - if !active - .attempt - .account_bytes(accepted_length, active.clock.now()) - { + if self.active.as_mut().is_some_and(|active| { + !active + .attempt + .account_bytes(accepted_length, active.clock.now()) + }) { self.fail(ResponseEgressOutcome::TransportError); return Err(AxumBodyError(ResponseEgressOutcome::TransportError)); } @@ -315,6 +322,9 @@ impl AxumEgressBody { } fn fail(&mut self, outcome: ResponseEgressOutcome) { + self.source = ResponseSource::Done; + self.staged_final = None; + self.remaining_hint = Some(0); let Some(mut active) = self.active.take() else { return; }; @@ -336,6 +346,55 @@ impl AxumEgressBody { active.state.unregister(); } + fn poll_chunk(&mut self, cx: &mut Context<'_>) -> Poll>> { + if self.staged_final.is_some() { + return self.poll_final_eof(cx); + } + let next = match &mut self.source { + ResponseSource::Done => Poll::Ready(None), + ResponseSource::Once(bytes) => { + let final_bytes = bytes.take(); + self.source = ResponseSource::Done; + Poll::Ready(final_bytes.map(Ok)) + } + ResponseSource::Stream(stream) => stream.as_mut().poll_next(cx), + }; + if matches!(self.source, ResponseSource::Stream(_)) + && let Some(remaining) = self.remaining_hint + && let Poll::Ready(Some(Ok(bytes))) = &next + && u64::try_from(bytes.len()) == Ok(remaining) + { + self.staged_final = Some(bytes.clone()); + return self.poll_final_eof(cx); + } + next + } + + // Hyper stops polling once Content-Length is satisfied, so validate EOF before handing it + // the final frame. At most that one source-controlled frame is retained during lookahead. + fn poll_final_eof(&mut self, cx: &mut Context<'_>) -> Poll>> { + let ResponseSource::Stream(stream) = &mut self.source else { + return Poll::Ready(Some(Err(EdgeError::internal(anyhow::anyhow!( + "response EOF lookahead lost its source" + ))))); + }; + match stream.as_mut().poll_next(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(None) => { + self.source = ResponseSource::Done; + Poll::Ready(self.staged_final.take().map(Ok)) + } + Poll::Ready(Some(Ok(bytes))) if bytes.is_empty() => { + cx.waker().wake_by_ref(); + Poll::Pending + } + Poll::Ready(Some(Ok(_bytes))) => Poll::Ready(Some(Err(EdgeError::internal( + ResponseEgressBodyLengthError::Exceeded, + )))), + Poll::Ready(Some(Err(error))) => Poll::Ready(Some(Err(error))), + } + } + fn finish(&mut self) { let Some(mut active) = self.active.take() else { return; @@ -393,7 +452,10 @@ impl HttpBody for AxumEgressBody { type Error = AxumBodyError; fn is_end_stream(&self) -> bool { - matches!(self.source, ResponseSource::Done) || self.active.is_none() + matches!( + self.source, + ResponseSource::Done | ResponseSource::Once(None) + ) && self.staged_final.is_none() } fn poll_frame( @@ -415,14 +477,7 @@ impl HttpBody for AxumEgressBody { )))); } - let next = match &mut this.source { - ResponseSource::Done | ResponseSource::Once(None) => { - this.finish(); - return Poll::Ready(None); - } - ResponseSource::Once(bytes) => Poll::Ready(bytes.take().map(Ok)), - ResponseSource::Stream(stream) => stream.as_mut().poll_next(cx), - }; + let next = this.poll_chunk(cx); if !matches!(next, Poll::Pending) { if let Some(outcome) = this.requested_failure() { this.fail(outcome); @@ -471,7 +526,7 @@ impl HttpBody for AxumEgressBody { } /// Converts one owned core envelope into the private body used by `EdgeZero`'s Hyper server. -pub(crate) fn prepare_egress_response( +pub(crate) async fn prepare_egress_response( egress: ResponseEgressEnvelope, connection: &EgressConnection, ) -> Response { @@ -487,8 +542,21 @@ pub(crate) fn prepare_egress_response( connection, ); } - match start_prepared( + let validated = match validate_empty_stream( prepared, + &request_method, + policy.write_deadline, + &clock, + ) + .await + { + Ok(validated) => validated, + Err(cause) => { + return prepare_fallback(&request_method, cause, attempt, &clock, connection); + } + }; + match start_prepared( + validated, policy.write_deadline, attempt, clock.clone(), @@ -496,13 +564,20 @@ pub(crate) fn prepare_egress_response( connection, ) { Ok(response) => response, - Err(returned_attempt) => prepare_fallback( - &request_method, - ResponseEgressOutcome::ConversionError, - *returned_attempt, - &clock, - connection, - ), + Err(returned_attempt) => { + let cause = if policy.write_deadline.is_expired_at(clock.now()) { + ResponseEgressOutcome::DeadlineExceeded + } else { + ResponseEgressOutcome::ConversionError + }; + prepare_fallback( + &request_method, + cause, + *returned_attempt, + &clock, + connection, + ) + } } } Err(failure) => { @@ -512,6 +587,51 @@ pub(crate) fn prepare_egress_response( } } +async fn validate_empty_stream( + prepared: PreparedResponseEgress, + request_method: &Method, + deadline: Deadline, + clock: &MonotonicClock, +) -> Result { + if !prepared.transmits_body() || prepared.declared_length() != Some(0) { + return Ok(prepared); + } + let mut response = prepared.into_response(); + if let Body::Stream(mut source) = mem::take(response.body_mut()) { + let remaining = deadline + .remaining_at(clock.now()) + .ok_or(ResponseEgressOutcome::DeadlineExceeded)?; + let validation = async { + loop { + if deadline.is_expired_at(clock.now()) { + return Err(ResponseEgressOutcome::DeadlineExceeded); + } + let next = source.next().await; + if deadline.is_expired_at(clock.now()) { + return Err(ResponseEgressOutcome::DeadlineExceeded); + } + match next { + None => return Ok(()), + Some(Ok(bytes)) if bytes.is_empty() => yield_now().await, + Some(Ok(_bytes)) => return Err(ResponseEgressOutcome::ConversionError), + Some(Err(error)) => { + log::warn!( + "response-egress Axum zero-length source failed before commit: {}", + response_egress_source_error_category(&error) + ); + return Err(ResponseEgressOutcome::ConversionError); + } + } + } + }; + timeout(remaining, validation) + .await + .map_err(|_elapsed| ResponseEgressOutcome::DeadlineExceeded)??; + } + prepare_response_egress(request_method, response) + .map_err(|_error| ResponseEgressOutcome::ConversionError) +} + fn start_prepared( prepared: PreparedResponseEgress, deadline: Deadline, @@ -547,18 +667,18 @@ fn prepare_fallback( ) -> Response { if !attempt.begin_fallback(cause) { attempt.terminate(ResponseEgressOutcome::ConversionError, clock.now()); - return detached_fallback(ResponseEgressOutcome::ConversionError); + return detached_fallback(request_method, ResponseEgressOutcome::ConversionError); } let started_at = clock.now(); let Some(fallback_deadline) = started_at.checked_add(RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET) else { attempt.finish_fallback(ResponseEgressFallbackDisposition::Aborted, clock.now()); - return detached_fallback(cause); + return detached_fallback(request_method, cause); }; let fallback = fallback_response(cause); let Ok(prepared) = prepare_response_egress(request_method, fallback) else { attempt.finish_fallback(ResponseEgressFallbackDisposition::Aborted, clock.now()); - return detached_fallback(cause); + return detached_fallback(request_method, cause); }; match start_prepared( prepared, @@ -572,7 +692,7 @@ fn prepare_fallback( Err(mut returned_attempt) => { returned_attempt .finish_fallback(ResponseEgressFallbackDisposition::Aborted, clock.now()); - detached_fallback(cause) + detached_fallback(request_method, cause) } } } @@ -584,9 +704,17 @@ fn fallback_response(cause: ResponseEgressOutcome) -> CoreResponse { response } -fn detached_fallback(cause: ResponseEgressOutcome) -> Response { +fn detached_fallback( + request_method: &Method, + cause: ResponseEgressOutcome, +) -> Response { let (status, body) = fallback_parts(cause); - let mut response = Response::new(AxumEgressBody::detached(Bytes::from_static(body))); + let payload = if *request_method == Method::HEAD { + Bytes::new() + } else { + Bytes::from_static(body) + }; + let mut response = Response::new(AxumEgressBody::detached(payload)); *response.status_mut() = status; response } @@ -609,19 +737,24 @@ mod tests { use bytes::Bytes; use edgezero_core::body::Body; - use edgezero_core::http::{HeaderMap, Method, StatusCode, Version}; + use edgezero_core::http::{HeaderMap, Method, StatusCode, Version, response_builder}; use edgezero_core::response_egress::{ ResponseEgressAttempt, ResponseEgressCompletion, ResponseEgressHead, ResponseEgressObserver, ResponseEgressObserverHandle, ResponseEgressOutcome, ResponseEgressReport, }; + use edgezero_core::response_egress_framing::prepare_response_egress; use edgezero_core::router::RouteMetadata; use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; + use futures_util::FutureExt as _; use futures_util::future::poll_fn as poll_future; use futures_util::stream::{pending, poll_fn as poll_stream}; use http_body::Body as _; - use super::{AxumEgressBody, EgressConnection}; + use super::{ + AxumEgressBody, EgressConnection, EgressKind, INTERNAL_FALLBACK_BODY, + TIMEOUT_FALLBACK_BODY, prepare_fallback, start_prepared, + }; #[derive(Clone, Default)] struct RecordingObserver(Arc>>); @@ -823,4 +956,164 @@ mod tests { assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); assert!(connection.is_empty()); } + + #[tokio::test(flavor = "current_thread")] + async fn expired_fallback_registration_preserves_get_payload_and_head_suppression() { + for (method, cause, payload) in [ + ( + Method::GET, + ResponseEgressOutcome::DeadlineExceeded, + TIMEOUT_FALLBACK_BODY, + ), + ( + Method::HEAD, + ResponseEgressOutcome::DeadlineExceeded, + TIMEOUT_FALLBACK_BODY, + ), + ( + Method::GET, + ResponseEgressOutcome::ConversionError, + INTERNAL_FALLBACK_BODY, + ), + ( + Method::HEAD, + ResponseEgressOutcome::ConversionError, + INTERNAL_FALLBACK_BODY, + ), + ] { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let clock_polls = Arc::new(AtomicUsize::new(0)); + let clock = MonotonicClock::new(move || { + let tick = + u64::try_from(clock_polls.fetch_add(1, Ordering::SeqCst)).expect("clock tick"); + started_at + .checked_add(Duration::from_secs(tick * 2)) + .expect("clock sample") + }); + let connection = EgressConnection::default(); + let response = prepare_fallback( + &method, + cause, + attempt(&observer, started_at), + &clock, + &connection, + ); + let status = if cause == ResponseEgressOutcome::DeadlineExceeded { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + assert_eq!(response.status(), status); + let mut body = Box::pin(response.into_body()); + if method == Method::HEAD { + assert!(body.is_end_stream()); + assert!( + poll_future(|context| body.as_mut().poll_frame(context)) + .await + .is_none() + ); + } else { + assert!(!body.is_end_stream(), "detached payload is not exhausted"); + let frame = poll_future(|context| body.as_mut().poll_frame(context)) + .await + .expect("fallback frame") + .expect("fallback result"); + assert_eq!(frame.into_data().expect("payload"), payload); + assert!( + body.is_end_stream(), + "final detached frame exhausts the source" + ); + assert!( + poll_future(|context| body.as_mut().poll_frame(context)) + .await + .is_none() + ); + } + assert!(connection.is_empty()); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, cause); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn declared_stream_is_incremental_and_releases_staged_data_on_failure() { + struct DropProbe(Arc); + impl Drop for DropProbe { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let source_polls = Arc::clone(&polls); + let drop_probe = DropProbe(Arc::clone(&drops)); + let source = poll_stream(move |_context| { + let _keep_source_alive = &drop_probe; + match source_polls.fetch_add(1, Ordering::SeqCst) { + 0 => Poll::Ready(Some(Ok(Bytes::from_static(b"ab")))), + 1 => Poll::Ready(Some(Ok(Bytes::from_static(b"c")))), + _ => Poll::Pending, + } + }); + let response = response_builder() + .header("content-length", "3") + .body(Body::from_stream(source)) + .expect("response"); + let prepared = prepare_response_egress(&Method::GET, response).expect("prepared response"); + let started_at = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || started_at); + let connection = EgressConnection::default(); + let Ok(registered_response) = start_prepared( + prepared, + clock.deadline_after(Duration::from_secs(1)), + attempt(&observer, started_at), + clock, + EgressKind::Application, + &connection, + ) else { + panic!("registered response"); + }; + let mut body = Box::pin(registered_response.into_body()); + + let first = poll_future(|context| body.as_mut().poll_frame(context)) + .await + .expect("first frame") + .expect("valid frame"); + assert_eq!(first.into_data().expect("data"), "ab"); + assert_eq!(polls.load(Ordering::SeqCst), 1, "no eager whole-body read"); + assert!( + poll_future(|context| body.as_mut().poll_frame(context)) + .now_or_never() + .is_none() + ); + assert_eq!(polls.load(Ordering::SeqCst), 3); + assert_eq!(drops.load(Ordering::SeqCst), 0); + + connection.signal_transport_error(); + let error = poll_future(|context| body.as_mut().poll_frame(context)) + .await + .expect("failure frame") + .expect_err("transport signal wins"); + assert_eq!(error.0, ResponseEgressOutcome::TransportError); + assert!(body.is_end_stream()); + assert_eq!( + drops.load(Ordering::SeqCst), + 1, + "failed source drops before body drop" + ); + assert!( + poll_future(|context| body.as_mut().poll_frame(context)) + .await + .is_none() + ); + drop(body); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].bytes_written, 2); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::TransportError); + assert!(connection.is_empty()); + } } diff --git a/crates/edgezero-adapter-axum/src/run_options.rs b/crates/edgezero-adapter-axum/src/run_options.rs new file mode 100644 index 00000000..8eff97b5 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/run_options.rs @@ -0,0 +1,992 @@ +use std::collections::{BTreeMap, BTreeSet, btree_map::Entry}; +use std::env::{var_os, vars_os}; +use std::ffi::OsString; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::str::FromStr as _; +use std::time::{Duration, Instant}; + +use edgezero_core::addr; +use edgezero_core::app::StoresMetadata; +use edgezero_core::env_config::EnvConfig; +use edgezero_store_aws::settings::{AgentAccessToken, AwsPreparationLimits}; +use log::LevelFilter; + +use crate::native_bindings::{ + NativeBindingsLimits, NativeConfigBinding, NativeStoreBindings, NativeStoreBindingsInput, +}; + +/// Validated explicit production hosting options. Construction never selects a +/// policy from build profile, cwd or container detection. Explicit constructors +/// do not merge environment values; only `from_env` reads hosting environment. +#[derive(Clone)] +pub struct AxumRunOptions { + addr: SocketAddr, + aws_limits: Option, + bindings: NativeStoreBindingsInput, + bindings_limits: NativeBindingsLimits, + bootstrap_values: BTreeMap, + config_dir: Option, + data_dir: Option, + env: EnvConfig, + grace: Duration, + level: LevelFilter, + non_unicode_overrides: Vec>, +} + +#[derive(Clone)] +enum BootstrapValue { + Ambiguous, + Value(String), +} + +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "scalar validation precedes local-root validation; compatibility constructors remain grouped" +)] +impl AxumRunOptions { + pub(crate) fn addr(&self) -> SocketAddr { + self.addr + } + + pub(crate) fn bindings(&self) -> &NativeStoreBindingsInput { + &self.bindings + } + + pub(crate) fn bindings_limits(&self) -> NativeBindingsLimits { + self.bindings_limits + } + + pub(crate) fn aws_limits(&self) -> Option { + self.aws_limits + } + + pub(crate) fn agent_token(&self, name: &str) -> anyhow::Result { + let value = match self.bootstrap_values.get(name) { + Some(BootstrapValue::Value(value)) => value.clone(), + Some(BootstrapValue::Ambiguous) => { + return Err(failure( + "settings", + "Agent token", + "duplicate configured value", + )); + } + None => { + return Err(failure( + "settings", + "Agent token", + "configured value absent", + )); + } + }; + AgentAccessToken::new(value) + .map_err(|_category| failure("settings", "Agent token", "invalid configured value")) + } + + pub(crate) fn config_dir(&self) -> Option<&Path> { + self.config_dir.as_deref() + } + + pub(crate) fn data_dir(&self) -> Option<&Path> { + self.data_dir.as_deref() + } + + pub(crate) fn env_config(&self) -> &EnvConfig { + &self.env + } + + /// Captures hosting settings and store overrides once. Secret values still + /// use the existing environment-backed secret backend. + /// + /// # Errors + /// Returns safe setting categories for supplied invalid hosting settings. + #[inline] + pub fn from_env() -> anyhow::Result { + let mut pairs = Vec::new(); + let mut non_unicode = Vec::new(); + for (os_key, os_value) in vars_os() { + let Some(key) = os_key.to_str() else { continue }; + let Some(rest) = strip_native_config_prefix(key) else { + continue; + }; + let path: Vec = rest.split("__").map(str::to_ascii_lowercase).collect(); + if let Some(value) = os_value.to_str() { + pairs.push((key.to_owned(), value.to_owned())); + } else if let Some(setting) = fixed_setting(&path) { + return Err(failure("settings", setting, "non-Unicode value")); + } else { + let segments: Vec<&str> = path.iter().map(String::as_str).collect(); + if matches!( + segments.as_slice(), + ["stores", "config" | "kv" | "secrets", _, "name" | "key"] + ) { + non_unicode.push(path); + } + } + } + let mut options = Self::from_vars(pairs)?; + options.non_unicode_overrides = non_unicode; + // Only this env-loading boundary may resolve named token variables. + // Explicit constructors and selection methods never call var_os. + options.capture_agent_tokens(|name| var_os(name))?; + Ok(options) + } + + fn capture_agent_tokens(&mut self, mut lookup: Lookup) -> anyhow::Result<()> + where + Lookup: FnMut(&str) -> Option, + { + let names: BTreeSet = self + .bindings + .document() + .into_iter() + .flat_map(|document| document.config().values()) + .filter_map(|binding| { + let NativeConfigBinding::AppConfigAgent(settings) = binding; + settings.access_token_env.clone() + }) + .collect(); + for name in names { + let value = lookup(&name) + .and_then(|value| value.into_string().ok()) + .ok_or_else(|| { + failure( + "settings", + "Agent token", + "configured value absent or non-Unicode", + ) + })?; + AgentAccessToken::new(value.clone()).map_err(|_category| { + failure("settings", "Agent token", "invalid configured value") + })?; + self.bootstrap_values + .insert(name, BootstrapValue::Value(value)); + } + Ok(()) + } + + /// Constructs options using only supplied string pairs and omission defaults. + /// Declared-store overrides are validated against baked metadata at startup. + /// + /// # Errors + /// Returns safe setting categories for invalid bind, logging, roots or grace. + #[inline] + pub fn from_vars(vars: I) -> anyhow::Result + where + I: IntoIterator, + K: AsRef, + V: Into, + { + let pairs: Vec<(String, String)> = vars + .into_iter() + .map(|(key, value)| (key.as_ref().to_owned(), value.into())) + .collect(); + let mut seen = BTreeSet::new(); + for (key, _value) in &pairs { + if let Some(rest) = strip_native_config_prefix(key) { + let path: Vec = rest.split("__").map(str::to_ascii_lowercase).collect(); + let segments: Vec<&str> = path.iter().map(String::as_str).collect(); + if (fixed_setting(&path).is_some() + || matches!( + segments.as_slice(), + ["stores", "config" | "kv" | "secrets", _, "name" | "key"] + )) + && !seen.insert(path) + { + return Err(failure( + "settings", + "bootstrap", + "duplicate normalized setting", + )); + } + } + } + let env = EnvConfig::from_vars(pairs.iter().map(|(key, value)| (key, value.clone()))); + let host = env.adapter_host().map_or(Ok(addr::DEFAULT_HOST), |raw| { + raw.parse() + .map_err(|_error| failure("settings", "HOST", "invalid IP literal")) + })?; + let port = env.adapter_port().map_or(Ok(addr::DEFAULT_PORT), |raw| { + raw.parse::() + .ok() + .filter(|port| *port != 0) + .ok_or_else(|| failure("settings", "PORT", "invalid nonzero port")) + })?; + let mut options = Self::new(SocketAddr::new(host, port))?; + if let Some(level) = env.logging_level() { + options.level = LevelFilter::from_str(level) + .map_err(|_error| failure("settings", "LOGGING__LEVEL", "invalid level"))?; + } + options.grace = shutdown_grace(&env)?; + if let Some(root) = env.get(&["adapter", "config_dir"]) { + options = options.with_config_dir(root)?; + } + if let Some(root) = env.get(&["adapter", "data_dir"]) { + options = options.with_data_dir(root)?; + } + options.env = env; + for (key, value) in pairs { + match options.bootstrap_values.entry(key) { + Entry::Vacant(entry) => { + entry.insert(BootstrapValue::Value(value)); + } + Entry::Occupied(mut entry) => { + entry.insert(BootstrapValue::Ambiguous); + } + } + } + if let Some(path) = options.env.get(&["store_bindings_file"]).map(str::to_owned) { + options = options.with_store_bindings_file(path)?; + } + Ok(options) + } + + pub(crate) fn logging_level(&self) -> LevelFilter { + self.level + } + + /// Explicit bind with default grace/logging, no roots, and no store overrides. + /// + /// # Errors + /// Rejects port zero. Production omission defaults apply only to omitted values. + #[inline] + pub fn new(addr: SocketAddr) -> anyhow::Result { + if addr.port() == 0 { + return Err(failure("settings", "PORT", "invalid nonzero port")); + } + Ok(Self { + bindings: NativeStoreBindingsInput::LocalDefaults, + bindings_limits: NativeBindingsLimits::default(), + aws_limits: None, + bootstrap_values: BTreeMap::new(), + addr, + config_dir: None, + data_dir: None, + env: EnvConfig::default(), + grace: Duration::from_secs(10), + level: LevelFilter::Info, + non_unicode_overrides: Vec::new(), + }) + } + + pub(crate) fn shutdown_grace(&self) -> Duration { + self.grace + } + + #[cfg(test)] + fn validate_stores(&self, metadata: StoresMetadata) -> anyhow::Result<()> { + self.validate_store_overlays(metadata)?; + self.validate_local_roots(metadata.config.is_some(), metadata.kv.is_some()) + } + + pub(crate) fn validate_store_overlays(&self, metadata: StoresMetadata) -> anyhow::Result<()> { + for (kind, declaration) in [ + ("config", metadata.config), + ("kv", metadata.kv), + ("secrets", metadata.secrets), + ] { + if let Some(meta) = declaration { + for id in meta.ids { + for field in ["name", "key"] { + if self + .non_unicode_overrides + .iter() + .any(|path| match path.as_slice() { + [_, selected_kind, selected_id, selected_field] => { + selected_kind == kind + && selected_id.eq_ignore_ascii_case(id) + && selected_field == field + } + _ => false, + }) + { + return Err(failure("settings", id, "non-Unicode store override")); + } + if self.env.store_setting(kind, id, field).is_some_and(|raw| { + raw.is_empty() + || raw.chars().all(char::is_whitespace) + || raw.chars().any(char::is_control) + }) { + return Err(failure("settings", id, "invalid store NAME/KEY override")); + } + } + } + } + } + Ok(()) + } + + pub(crate) fn validate_local_roots(&self, config: bool, kv: bool) -> anyhow::Result<()> { + for (required, root, label) in [ + (config, self.config_dir.as_deref(), "CONFIG_DIR"), + (kv, self.data_dir.as_deref(), "DATA_DIR"), + ] { + if required && root.is_none() { + return Err(failure("settings", label, "required for declared stores")); + } + if let Some(directory) = root { + validate_root(directory, label)?; + } + } + Ok(()) + } + + /// Selects typed bindings. A second explicit selection is an error. + /// + /// # Errors + /// Rejects duplicate selection and invalid settings/policy sources. + #[inline] + pub fn with_store_bindings(mut self, bindings: NativeStoreBindings) -> anyhow::Result { + if !matches!(self.bindings, NativeStoreBindingsInput::LocalDefaults) { + return Err(failure("settings", "bindings", "duplicate selection")); + } + bindings + .validate(self.bindings_limits, self.aws_limits) + .map_err(|category| failure("settings", "bindings", &category.to_string()))?; + self.bindings = NativeStoreBindingsInput::Inline(bindings); + Ok(self) + } + + /// Captures one absolute bindings file once. Explicit selection does not read environment values. + /// + /// # Errors + /// Rejects duplicate/relative/missing/malformed/oversized selections. + #[inline] + pub fn with_store_bindings_file>( + mut self, + locator: Root, + ) -> anyhow::Result { + if !matches!(self.bindings, NativeStoreBindingsInput::LocalDefaults) { + return Err(failure("settings", "bindings", "duplicate selection")); + } + let path = locator.into(); + let bindings = + NativeStoreBindings::read_with_policy(&path, self.bindings_limits, self.aws_limits) + .map_err(|category| failure("settings", "bindings", &category.to_string()))?; + bindings + .validate(self.bindings_limits, self.aws_limits) + .map_err(|category| failure("settings", "bindings", &category.to_string()))?; + self.bindings = NativeStoreBindingsInput::File { path, bindings }; + Ok(self) + } + + /// Supplies named bootstrap values explicitly, never merging ambient variables. + /// + /// # Errors + /// Rejects duplicate variable inputs rather than selecting precedence. + #[inline] + pub fn with_bootstrap_values(mut self, values: I) -> anyhow::Result + where + I: IntoIterator, + K: Into, + V: Into, + { + for (name, value) in values { + if self + .bootstrap_values + .insert(name.into(), BootstrapValue::Value(value.into())) + .is_some() + { + return Err(failure("settings", "bootstrap", "duplicate variable")); + } + } + Ok(self) + } + + /// Selects trusted file/ID bounds before selecting a document. + /// + /// # Errors + /// Rejects invalid bounds or policy changes after a file/inline selection. + #[inline] + pub fn with_native_bindings_limits( + mut self, + limits: NativeBindingsLimits, + ) -> anyhow::Result { + if !matches!(self.bindings, NativeStoreBindingsInput::LocalDefaults) { + return Err(failure( + "settings", + "bindings", + "limits must precede selection", + )); + } + limits + .validate() + .map_err(|category| failure("settings", "bindings", &category.to_string()))?; + self.bindings_limits = limits; + Ok(self) + } + + /// Selects one explicit remote preparation policy, not an override/merge of file policies. + /// + /// # Errors + /// Rejects duplicate policies, competing document policies and invalid limits. + #[inline] + pub fn with_aws_preparation_limits( + mut self, + limits: AwsPreparationLimits, + ) -> anyhow::Result { + if self.aws_limits.is_some() { + return Err(failure("settings", "AWS limits", "duplicate policy")); + } + limits + .validate() + .map_err(|category| failure("settings", "AWS limits", &category.to_string()))?; + if let Some(document) = self.bindings.document() { + document + .validate(self.bindings_limits, Some(limits)) + .map_err(|category| failure("settings", "AWS limits", &category.to_string()))?; + } + self.aws_limits = Some(limits); + Ok(self) + } + + /// Selects an existing absolute config root; never creates it. + /// + /// # Errors + /// Rejects relative, missing or non-directory roots. + #[inline] + pub fn with_config_dir>(mut self, root: Root) -> anyhow::Result { + let directory = root.into(); + validate_root(&directory, "CONFIG_DIR")?; + self.config_dir = Some(directory); + Ok(self) + } + + /// Selects an existing absolute data root. Startup also checks actual write access. + /// + /// # Errors + /// Rejects relative, missing or non-directory roots. + #[inline] + pub fn with_data_dir>(mut self, root: Root) -> anyhow::Result { + let directory = root.into(); + validate_root(&directory, "DATA_DIR")?; + self.data_dir = Some(directory); + Ok(self) + } + + #[inline] + #[must_use] + pub fn with_logging_level(mut self, level: LevelFilter) -> Self { + self.level = level; + self + } + + /// Selects a positive, representable integer-second drain budget. + /// + /// # Errors + /// Rejects zero, fractional seconds and unusable native timer values. + #[inline] + pub fn with_shutdown_grace(mut self, grace: Duration) -> anyhow::Result { + validate_grace(grace)?; + self.grace = grace; + Ok(self) + } +} + +pub(crate) fn failure(stage: &str, subject: &str, category: &str) -> anyhow::Error { + // No source chain survives this boundary. Subject is a fixed setting name or + // baked logical id, never settings/config/secret contents or a provider string. + anyhow::anyhow!("startup {stage}: {subject}: {category}") +} + +fn fixed_setting(path: &[String]) -> Option<&'static str> { + let segments: Vec<&str> = path.iter().map(String::as_str).collect(); + match segments.as_slice() { + ["adapter", "host"] => Some("HOST"), + ["adapter", "port"] => Some("PORT"), + ["adapter", "config_dir"] => Some("CONFIG_DIR"), + ["adapter", "data_dir"] => Some("DATA_DIR"), + ["adapter", "shutdown_grace_seconds"] => Some("SHUTDOWN_GRACE_SECONDS"), + ["logging", "level"] => Some("LOGGING__LEVEL"), + ["store_bindings_file"] => Some("STORE_BINDINGS_FILE"), + _ => None, + } +} + +pub(crate) fn shutdown_grace(env: &EnvConfig) -> anyhow::Result { + let Some(raw) = env.get(&["adapter", "shutdown_grace_seconds"]) else { + return Ok(Duration::from_secs(10)); + }; + let seconds = raw + .parse::() + .ok() + .filter(|_seconds| raw.bytes().all(|byte| byte.is_ascii_digit())) + .ok_or_else(|| { + failure( + "settings", + "SHUTDOWN_GRACE_SECONDS", + "invalid positive integer seconds", + ) + })?; + let grace = Duration::from_secs(seconds); + validate_grace(grace)?; + Ok(grace) +} + +/// Match the process environment's key identity, as `EnvConfig` does. +pub(crate) fn strip_native_config_prefix(key: &str) -> Option<&str> { + const PREFIX: &str = "EDGEZERO__"; + if cfg!(windows) { + let prefix = key.get(..PREFIX.len())?; + if !prefix.eq_ignore_ascii_case(PREFIX) { + return None; + } + key.get(PREFIX.len()..) + } else { + key.strip_prefix(PREFIX) + } +} + +fn validate_grace(grace: Duration) -> anyhow::Result<()> { + if grace.is_zero() || grace.subsec_nanos() != 0 || Instant::now().checked_add(grace).is_none() { + return Err(failure( + "settings", + "SHUTDOWN_GRACE_SECONDS", + "invalid or unusable positive integer seconds", + )); + } + Ok(()) +} + +fn validate_root(root: &Path, setting: &str) -> anyhow::Result<()> { + if !root.is_absolute() || !root.is_dir() { + return Err(failure( + "settings", + setting, + "requires an absolute existing directory", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + use edgezero_core::app::StoreMetadata; + + const BINDINGS: &str = "version = 1\n[config.app]\nprovider = 'aws-appconfig-agent'\ndefault_key = 'settings'\nendpoint = 'http://127.0.0.1:2772'\naccess_token_env = 'AGENT_TOKEN'\n[config.app.documents.settings]\napplication = 'example'\nenvironment = 'production'\nprofile = 'envelope'\n"; + + fn explicit() -> AxumRunOptions { + AxumRunOptions::new(SocketAddr::from(([127, 0, 0, 1], 1234))).expect("explicit options") + } + + #[test] + fn native_prefix_matching_agrees_with_portable_env_config() { + for key in [ + "EDGEZERO__ADAPTER__PORT", + "edgezero__adapter__port", + "EdGeZeRo__adapter__port", + "EDGEZERO_ADAPTER__PORT", + "short", + "EDGEZERO\u{e9}__adapter__port", + ] { + let portable = EnvConfig::from_vars([(key, "9090")]); + assert_eq!( + strip_native_config_prefix(key).is_some(), + portable.adapter_port().is_some(), + "prefix identity for {key}" + ); + } + } + + #[test] + fn prefix_case_variants_capture_bindings_and_reject_duplicate_settings() { + let root = tempfile::tempdir().expect("root"); + let path = root.path().join("bindings.toml"); + fs::write(&path, BINDINGS).expect("bindings"); + for prefix in ["EDGEZERO__", "edgezero__", "EdGeZeRo__"] { + let options = AxumRunOptions::from_vars([( + format!("{prefix}store_bindings_file"), + path.to_str().expect("path"), + )]) + .expect("valid locator or unrecognized Unix prefix"); + assert_eq!( + options.bindings().document().is_some(), + prefix == "EDGEZERO__" || cfg!(windows) + ); + let duplicate = AxumRunOptions::from_vars([ + ("EDGEZERO__ADAPTER__PORT".to_owned(), "9090"), + (format!("{prefix}adapter__port"), "9091"), + ]); + if prefix == "EDGEZERO__" || cfg!(windows) { + let error = duplicate.err().expect("duplicate normalized setting"); + assert!(error.to_string().contains("duplicate normalized setting")); + } else { + assert_eq!(duplicate.expect("Unix prefix ignored").addr.port(), 9090); + } + } + } + + #[test] + fn file_selection_is_captured_once_and_conflicts_do_not_read_a_second_file() { + let root = tempfile::tempdir().expect("root"); + let path = root.path().join("bindings.toml"); + fs::write(&path, BINDINGS).expect("write bindings"); + let options = explicit() + .with_store_bindings_file(&path) + .expect("selected file"); + fs::remove_file(&path).expect("remove file after capture"); + assert_eq!( + options + .bindings() + .document() + .expect("captured") + .config() + .len(), + 1 + ); + let document = NativeStoreBindings::parse(BINDINGS, NativeBindingsLimits::default()) + .expect("typed bindings"); + assert!( + options + .clone() + .with_store_bindings(document) + .err() + .expect("duplicate") + .to_string() + .contains("duplicate") + ); + assert!( + options + .with_store_bindings_file(path) + .err() + .expect("duplicate file") + .to_string() + .contains("duplicate") + ); + assert!( + explicit() + .with_store_bindings_file("relative.toml") + .is_err() + ); + } + + #[test] + fn explicit_bootstrap_values_do_not_consult_ambient_variables() { + let document = NativeStoreBindings::parse(BINDINGS, NativeBindingsLimits::default()) + .expect("bindings"); + let unconfigured = explicit().with_store_bindings(document).expect("inline"); + let missing = unconfigured + .agent_token("AGENT_TOKEN") + .expect_err("missing token"); + assert_eq!(missing.chain().count(), 1); + let options = unconfigured + .with_bootstrap_values([("AGENT_TOKEN", "TOKEN_SENTINEL")]) + .expect("supplied token"); + assert_eq!( + options + .agent_token("AGENT_TOKEN") + .expect("captured") + .expose(), + "TOKEN_SENTINEL" + ); + assert!(!format!("{:?}", options.agent_token("AGENT_TOKEN")).contains("SENTINEL")); + assert!( + options + .with_bootstrap_values([("AGENT_TOKEN", "OTHER_SENTINEL")]) + .is_err() + ); + } + + #[test] + fn explicit_vars_capture_locator_and_named_values_without_reopening() { + let root = tempfile::tempdir().expect("root"); + let path = root.path().join("bindings.toml"); + fs::write(&path, BINDINGS).expect("write bindings"); + let options = AxumRunOptions::from_vars([ + ( + "EDGEZERO__STORE_BINDINGS_FILE", + path.to_str().expect("path"), + ), + ("AGENT_TOKEN", "TOKEN_SENTINEL"), + ]) + .expect("supplied variables"); + fs::remove_file(path).expect("remove"); + assert!(options.bindings().document().is_some()); + assert_eq!( + options.agent_token("AGENT_TOKEN").expect("token").expose(), + "TOKEN_SENTINEL" + ); + for (first, second) in [ + ( + "EDGEZERO__STORE_BINDINGS_FILE", + "EDGEZERO__store_bindings_file", + ), + ( + "EDGEZERO__STORES__CONFIG__APP__NAME", + "EDGEZERO__stores__config__app__name", + ), + ("EDGEZERO__ADAPTER__PORT", "EDGEZERO__adapter__port"), + ] { + let error = AxumRunOptions::from_vars([(first, "SENTINEL"), (second, "SENTINEL")]) + .err() + .expect("duplicate normalized path"); + assert!(error.to_string().contains("duplicate")); + assert!(!format!("{error:?}").contains("SENTINEL")); + } + } + + #[test] + fn duplicate_unrelated_vars_keep_existing_env_semantics_but_tokens_fail_closed() { + let options = AxumRunOptions::from_vars([ + ("EDGEZERO__UNRELATED", "first"), + ("EDGEZERO__UNRELATED", "second"), + ("AGENT_TOKEN", "FIRST_SENTINEL"), + ("AGENT_TOKEN", "SECOND_SENTINEL"), + ]) + .expect("unrelated env keys retain existing semantics"); + assert_eq!(options.env.get(&["unrelated"]), Some("second")); + let error = options + .agent_token("AGENT_TOKEN") + .expect_err("ambiguous token"); + assert!(error.to_string().contains("duplicate")); + assert!(!format!("{error:?}").contains("SENTINEL")); + } + + #[test] + fn named_token_capture_is_once_per_variable_and_rejects_missing_empty() { + let duplicate = format!( + "{BINDINGS}\n{}", + BINDINGS + .replace("version = 1\n", "") + .replace("config.app", "config.other") + ); + let document = NativeStoreBindings::parse(&duplicate, NativeBindingsLimits::default()) + .expect("shared token bindings"); + let options = explicit().with_store_bindings(document).expect("inline"); + let mut captured = options.clone(); + let mut calls = 0_usize; + captured + .capture_agent_tokens(|name| { + assert_eq!(name, "AGENT_TOKEN"); + calls += 1; + Some(OsString::from("TOKEN_SENTINEL")) + }) + .expect("capture"); + assert_eq!(calls, 1); + for value in [None, Some(OsString::new())] { + let error = options + .clone() + .capture_agent_tokens(|_name| value.clone()) + .expect_err("configured token invalid"); + assert!(!format!("{error:?}").contains("SENTINEL")); + } + } + + #[cfg(unix)] + #[test] + fn non_unicode_configured_token_fails_closed() { + use std::os::unix::ffi::OsStringExt as _; + let document = NativeStoreBindings::parse(BINDINGS, NativeBindingsLimits::default()) + .expect("bindings"); + let mut options = explicit().with_store_bindings(document).expect("inline"); + let error = options + .capture_agent_tokens(|_name| Some(OsString::from_vec(vec![0xff]))) + .expect_err("non-Unicode token"); + assert_eq!(error.chain().count(), 1); + assert!(error.to_string().contains("non-Unicode")); + } + + #[test] + fn trusted_caps_and_remote_policy_sources_are_explicit() { + let document = NativeStoreBindings::parse(BINDINGS, NativeBindingsLimits::default()) + .expect("bindings"); + let options = explicit().with_store_bindings(document).expect("inline"); + assert!( + options + .with_native_bindings_limits(NativeBindingsLimits::default()) + .is_err() + ); + let with_policy = NativeStoreBindings::parse( + &format!("{BINDINGS}\n[limits.aws]\nmax_snapshot_bytes = 16777216"), + NativeBindingsLimits::default(), + ) + .expect("policy"); + assert!( + explicit() + .with_aws_preparation_limits(AwsPreparationLimits::default()) + .expect("explicit policy") + .with_store_bindings(with_policy.clone()) + .is_err() + ); + assert!( + explicit() + .with_store_bindings(with_policy) + .expect("file policy") + .with_aws_preparation_limits(AwsPreparationLimits::default()) + .is_err() + ); + } + + #[test] + fn env_bootstrap_child() { + use std::env; + + let Some(expected) = env::var_os("EDGEZERO_NATIVE_BOOTSTRAP_TEST") else { + return; + }; + if expected == "valid" { + let options = AxumRunOptions::from_env().expect("env token capture"); + assert_eq!( + options + .agent_token("AGENT_TOKEN") + .expect("captured token") + .expose(), + "TOKEN_SENTINEL" + ); + let path = env::var_os("EDGEZERO__STORE_BINDINGS_FILE").expect("locator"); + fs::write( + PathBuf::from(path), + BINDINGS.replace("AGENT_TOKEN", "OTHER_SENTINEL"), + ) + .expect("change file after capture"); + let NativeConfigBinding::AppConfigAgent(settings) = options + .bindings() + .document() + .expect("captured document") + .config() + .get("app") + .expect("captured binding"); + assert_eq!(settings.access_token_env.as_deref(), Some("AGENT_TOKEN")); + assert_eq!( + options + .agent_token("AGENT_TOKEN") + .expect("retained token") + .expose(), + "TOKEN_SENTINEL" + ); + } else { + let error = AxumRunOptions::from_env() + .err() + .expect("invalid env bootstrap"); + assert_eq!(error.chain().count(), 1); + assert!(!format!("{error:?}").contains("SENTINEL")); + } + } + + #[cfg(unix)] + #[test] + fn real_env_entrypoint_captures_named_tokens_and_rejects_non_unicode_inputs() { + use std::env::current_exe; + use std::os::unix::ffi::OsStringExt as _; + use std::process::Command; + + let root = tempfile::tempdir().expect("root"); + let path = root.path().join("bindings.toml"); + for (token, valid) in [ + (Some(OsString::from("TOKEN_SENTINEL")), true), + (None, false), + (Some(OsString::new()), false), + (Some(OsString::from_vec(vec![0xff])), false), + ] { + fs::write(&path, BINDINGS).expect("fresh bindings"); + let mut command = Command::new(current_exe().expect("test binary")); + command + .env_clear() + .arg("--exact") + .arg("run_options::tests::env_bootstrap_child") + .env( + "EDGEZERO_NATIVE_BOOTSTRAP_TEST", + if valid { "valid" } else { "invalid" }, + ) + .env("EDGEZERO__STORE_BINDINGS_FILE", &path); + if let Some(value) = token { + command.env("AGENT_TOKEN", value); + } + let output = command.output().expect("isolated env fixture"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!String::from_utf8_lossy(&output.stdout).contains("SENTINEL")); + assert!(!String::from_utf8_lossy(&output.stderr).contains("SENTINEL")); + } + let output = Command::new(current_exe().expect("binary")) + .env_clear() + .arg("--exact") + .arg("run_options::tests::env_bootstrap_child") + .env("EDGEZERO_NATIVE_BOOTSTRAP_TEST", "invalid") + .env( + "EDGEZERO__STORE_BINDINGS_FILE", + OsString::from_vec(vec![0xff]), + ) + .output() + .expect("non-Unicode locator fixture"); + assert!(output.status.success()); + } + + #[test] + fn defaults_and_invalid_supplied_scalars_are_distinct() { + let options = AxumRunOptions::from_vars(Vec::<(String, String)>::new()).expect("defaults"); + assert_eq!( + options.addr, + SocketAddr::from((addr::DEFAULT_HOST, addr::DEFAULT_PORT)) + ); + assert_eq!(options.grace, Duration::from_secs(10)); + for (key, values) in [ + ( + "EDGEZERO__ADAPTER__HOST", + vec!["sentinel-host", "", "localhost"], + ), + ( + "EDGEZERO__ADAPTER__PORT", + vec!["0", "65536", "sentinel-port", ""], + ), + ("EDGEZERO__LOGGING__LEVEL", vec!["sentinel-level", ""]), + ( + "EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS", + vec![ + "0", + "", + "-1", + "+1", + "sentinel-duration", + "18446744073709551615", + ], + ), + ] { + for value in values { + let error = AxumRunOptions::from_vars([(key, value)]) + .err() + .expect("invalid"); + assert!(error.chain().count() == 1); + assert!(!format!("{error:?}").contains("sentinel")); + } + } + } + + #[test] + fn selected_override_validation_precedes_normalization() { + let metadata = StoresMetadata { + config: Some(StoreMetadata { + ids: &["settings"], + default: "settings", + }), + ..StoresMetadata::default() + }; + let root = tempfile::tempdir().expect("root"); + for value in ["", " ", "sentinel\n"] { + let options = AxumRunOptions::from_vars([ + ( + "EDGEZERO__ADAPTER__CONFIG_DIR", + root.path().to_str().expect("path"), + ), + ("EDGEZERO__STORES__CONFIG__SETTINGS__KEY", value), + ("EDGEZERO__STORES__KV__UNDECLARED__NAME", "\n"), + ]) + .expect("native settings"); + let error = options + .validate_stores(metadata) + .expect_err("declared invalid override"); + assert!(!format!("{error:?}").contains("sentinel")); + } + let options = + AxumRunOptions::new(SocketAddr::from(([127, 0, 0, 1], 1234))).expect("explicit"); + assert!(options.env.adapter_host().is_none()); + assert_eq!(options.env.store_key("config", "settings"), "settings"); + assert!(options.validate_stores(metadata).is_err()); + } +} diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index ed1ddbfe..45848c52 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -17,6 +17,7 @@ use edgezero_core::ingress::{ IngressHeadParts, validate_normalized_ingress_parts, }; use edgezero_core::key_value_store::KvHandle; +use edgezero_core::probe::{LifecyclePhase, LifecycleReader}; #[cfg(test)] use edgezero_core::router::RouterService; use edgezero_core::secret_store::SecretHandle; @@ -26,11 +27,15 @@ use edgezero_core::store_registry::{ use edgezero_core::time::MonotonicInstant; use hyper::body::Incoming; use hyper::service::Service as HyperService; +use tokio::sync::watch; #[cfg(test)] use tower::Service as TowerService; #[cfg(test)] use crate::connection::{ConnectionExit, serve_http1}; +#[cfg(test)] +use crate::ingress_config::AxumIngressConfig; +#[cfg(test)] use crate::outbound::AxumOutboundClient; use crate::request::into_core_request_parts; use crate::response::{AxumEgressBody, EgressConnection, prepare_egress_response}; @@ -49,6 +54,7 @@ pub(crate) struct AxumServiceState { kv_handle: Option, kv_registry: Option, outbound_transport: Option>, + phase: Option>, secret_handle: Option, secret_registry: Option, } @@ -61,14 +67,23 @@ impl AxumServiceState { /// Creates a service that preserves all policies configured on `App`. #[must_use] #[inline] + #[cfg(test)] pub fn from_app(app: App) -> Self { + Self::with_transport( + app, + AxumOutboundClient::try_transport().expect("test transport"), + ) + } + + pub(crate) fn with_transport(app: App, transport: Arc) -> Self { Self { app: Arc::new(app), config_registry: None, config_store_handle: None, kv_handle: None, kv_registry: None, - outbound_transport: AxumOutboundClient::try_transport().ok(), + outbound_transport: Some(transport), + phase: None, secret_handle: None, secret_registry: None, } @@ -101,6 +116,7 @@ impl AxumServiceState { /// directly. #[must_use] #[inline] + #[cfg(test)] pub fn with_config_store_handle(mut self, handle: ConfigStoreHandle) -> Self { self.config_store_handle = Some(handle); self @@ -118,6 +134,7 @@ impl AxumServiceState { /// directly. #[must_use] #[inline] + #[cfg(test)] pub fn with_kv_handle(mut self, handle: KvHandle) -> Self { self.kv_handle = Some(handle); self @@ -144,6 +161,7 @@ impl AxumServiceState { /// [`Self::with_secret_registry`] directly. #[must_use] #[inline] + #[cfg(test)] pub fn with_secret_handle(mut self, handle: SecretHandle) -> Self { self.secret_handle = Some(handle); self @@ -157,6 +175,11 @@ impl AxumServiceState { self } + pub(crate) fn with_phase(mut self, phase: watch::Receiver) -> Self { + self.phase = Some(phase); + self + } + pub(crate) fn for_connection( &self, remote_addr: SocketAddr, @@ -178,39 +201,38 @@ impl AxumServiceState { ) { // Legacy single-handle constructors synthesize only the conventional // `default` registry entry; request extensions contain registries only. - let config_registry = self.config_registry.clone().or_else(|| { - self.config_store_handle.clone().map(|handle| { - ConfigRegistry::single_id( - "default".to_owned(), - ConfigStoreBinding { - handle, - default_key: "default".to_owned(), - }, - ) - }) - }); - let kv_registry = self.kv_registry.clone().or_else(|| { - self.kv_handle - .clone() - .map(|handle| KvRegistry::single_id("default".to_owned(), handle)) - }); - let secret_registry = self.secret_registry.clone().or_else(|| { - self.secret_handle.clone().map(|handle| { - SecretRegistry::single_id( - "default".to_owned(), - BoundSecretStore::new(handle, "default".to_owned()), - ) - }) - }); + let config_registry = self + .config_registry + .clone() + .or_else(|| self.config_store_handle.clone().map(legacy_config_registry)); + let kv_registry = self + .kv_registry + .clone() + .or_else(|| self.kv_handle.clone().map(legacy_kv_registry)); + let secret_registry = self + .secret_registry + .clone() + .or_else(|| self.secret_handle.clone().map(legacy_secret_registry)); (config_registry, kv_registry, secret_registry) } + fn admission_open(&self) -> bool { + self.phase + .as_ref() + .is_none_or(|phase| *phase.borrow() == LifecyclePhase::Ready) + } + async fn dispatch_request( &self, mut request: Request, connection: &EgressConnection, remote_addr: Option, ) -> Result, AxumIngressAbort> { + // This private gate defines dispatched work. Public extensions and app + // admission policy cannot admit new work after the owner starts draining. + if !self.admission_open() { + return Err(AxumIngressAbort); + } if let Some(peer_addr) = remote_addr { request.extensions_mut().insert(ConnectInfo(peer_addr)); } @@ -227,7 +249,8 @@ impl AxumServiceState { request_method.clone(), request_start, connection, - ); + ) + .await; } let head_parts = IngressHeadParts::from_parts( &parts, @@ -237,7 +260,7 @@ impl AxumServiceState { let prepared = match app.begin_ingress(head_parts, request_start) { Ok(IngressBeginOutcome::Admitted(prepared)) => prepared, Ok(IngressBeginOutcome::Refused(response)) => { - return Ok(prepare_egress_response(response, connection)); + return Ok(prepare_egress_response(response, connection).await); } Ok(IngressBeginOutcome::Aborted) => return Err(AxumIngressAbort), Ok(_) => { @@ -247,7 +270,8 @@ impl AxumServiceState { request_method, request_start, connection, - ); + ) + .await; } Err(error) => { return prepare_detached_ingress_error( @@ -256,7 +280,8 @@ impl AxumServiceState { request_method.clone(), request_start, connection, - ); + ) + .await; } }; let read_deadline = prepared.read_deadline(); @@ -268,7 +293,7 @@ impl AxumServiceState { "failed to initialize outbound HTTP transport" )), ); - return Ok(prepare_egress_response(egress, connection)); + return Ok(prepare_egress_response(egress, connection).await); }; let mut core_request = match into_core_request_parts( parts, @@ -284,10 +309,15 @@ impl AxumServiceState { "failed to convert inbound request: {error}" )), ); - return Ok(prepare_egress_response(egress, connection)); + return Ok(prepare_egress_response(egress, connection).await); } }; + if let Some(phase) = self.phase.clone() { + core_request + .extensions_mut() + .insert(LifecycleReader::new(move || Some(*phase.borrow()))); + } if let Some(registry) = config_registry { core_request.extensions_mut().insert(registry); } @@ -299,7 +329,7 @@ impl AxumServiceState { } let egress = app.dispatch_admitted(prepared, core_request).await; - Ok(prepare_egress_response(egress, connection)) + Ok(prepare_egress_response(egress, connection).await) } } @@ -349,7 +379,28 @@ impl TowerService> for AxumServiceState { } } -fn prepare_detached_ingress_error( +pub(crate) fn legacy_config_registry(handle: ConfigStoreHandle) -> ConfigRegistry { + ConfigRegistry::single_id( + "default".to_owned(), + ConfigStoreBinding { + handle, + default_key: "default".to_owned(), + }, + ) +} + +pub(crate) fn legacy_kv_registry(handle: KvHandle) -> KvRegistry { + KvRegistry::single_id("default".to_owned(), handle) +} + +pub(crate) fn legacy_secret_registry(handle: SecretHandle) -> SecretRegistry { + SecretRegistry::single_id( + "default".to_owned(), + BoundSecretStore::new(handle, "default".to_owned()), + ) +} + +async fn prepare_detached_ingress_error( app: &App, error: EdgeError, request_method: Method, @@ -358,7 +409,7 @@ fn prepare_detached_ingress_error( ) -> Result, AxumIngressAbort> { match app.detached_ingress_error_egress(error, request_method, request_start) { IngressDispatchOutcome::Response(envelope) => { - Ok(prepare_egress_response(*envelope, connection)) + Ok(prepare_egress_response(*envelope, connection).await) } IngressDispatchOutcome::Aborted | _ => Err(AxumIngressAbort), } @@ -462,8 +513,11 @@ mod tests { #[async_trait::async_trait(?Send)] impl ConfigStore for FixedConfigStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } async fn get_bounded( @@ -473,7 +527,7 @@ mod tests { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { + ) -> Result, ConfigStoreError> { if deadline.is_expired_at(clock.now()) { return Err(ConfigStoreError::DeadlineExceeded); } @@ -562,6 +616,7 @@ mod tests { stream, state.for_connection(remote_addr, responses.clone()), responses, + AxumIngressConfig::default(), ) .await .expect("intentional admission abort") @@ -781,6 +836,7 @@ mod tests { stream, state.for_connection(remote_addr, responses.clone()), responses, + AxumIngressConfig::default(), ) .await .expect("intentional detached error abort") @@ -1471,7 +1527,7 @@ mod tests { .unwrap_or_default(); let response = response_builder() .status(StatusCode::OK) - .body(Body::from(val)) + .body(Body::from(val.as_str())) .expect("response"); Ok::<_, EdgeError>(response) }) @@ -1525,6 +1581,76 @@ mod tests { assert_eq!(&*body, b"injected"); } + #[test] + fn explicit_registries_win_in_both_setter_orders() { + use crate::config_store::AxumConfigStore; + use crate::config_store_limits::ConfigStoreLimits; + use crate::key_value_store::PersistentKvStore; + use crate::secret_store::EnvSecretStore; + + let temp = tempfile::tempdir().expect("database directory"); + let config = ConfigStoreHandle::new(Arc::new( + AxumConfigStore::from_map([], ConfigStoreLimits::default()).expect("config store"), + )); + let kv = KvHandle::new(Arc::new( + PersistentKvStore::new(temp.path().join("registry.redb")).expect("KV store"), + )); + let secrets = SecretHandle::new(Arc::new(EnvSecretStore::new())); + let with_handles = |service: AxumServiceState| { + service + .with_config_store_handle(config.clone()) + .with_kv_handle(kv.clone()) + .with_secret_handle(secrets.clone()) + }; + let with_registries = |service: AxumServiceState| { + service + .with_config_registry(ConfigRegistry::single_id( + "application".to_owned(), + ConfigStoreBinding { + handle: config.clone(), + default_key: "settings".to_owned(), + }, + )) + .with_kv_registry(KvRegistry::single_id("sessions".to_owned(), kv.clone())) + .with_secret_registry(SecretRegistry::single_id( + "runtime".to_owned(), + BoundSecretStore::new(secrets.clone(), "vault".to_owned()), + )) + }; + for registry_first in [true, false] { + let unwired = AxumServiceState::new(RouterService::builder().build()); + let service = if registry_first { + with_handles(with_registries(unwired)) + } else { + with_registries(with_handles(unwired)) + }; + let (config_option, kv_option, secret_option) = service.resolved_store_registries(); + let config_registry = config_option.expect("config registry"); + let kv_registry = kv_option.expect("KV registry"); + let secret_registry = secret_option.expect("secret registry"); + assert_eq!(config_registry.default_id(), "application"); + assert_eq!( + config_registry + .default_ref() + .expect("config binding") + .default_key, + "settings" + ); + assert_eq!(kv_registry.default_id(), "sessions"); + assert_eq!(secret_registry.default_id(), "runtime"); + assert_eq!( + secret_registry + .default_ref() + .expect("secret binding") + .store_name(), + "vault" + ); + assert!(config_registry.named("default").is_none()); + assert!(kv_registry.named("default").is_none()); + assert!(secret_registry.named("default").is_none()); + } + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn kv_registry_wins_over_bare_handle_when_both_wired() { // Documents the precedence rule baked into the dispatcher: diff --git a/crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs b/crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs index 6823d907..91ad1830 100644 --- a/crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs +++ b/crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs @@ -24,3 +24,10 @@ log = { workspace = true } simple_logger = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tracing = { workspace = true } + +[features] +default = [] +aws-appconfig-agent = ["edgezero-adapter-axum/aws-appconfig-agent"] +aws-secrets-manager = ["edgezero-adapter-axum/aws-secrets-manager"] +aws-stores = ["aws-appconfig-agent", "aws-secrets-manager"] +aws-dynamodb = ["edgezero-adapter-axum/aws-dynamodb"] diff --git a/crates/edgezero-adapter-axum/src/templates/axum.toml.hbs b/crates/edgezero-adapter-axum/src/templates/axum.toml.hbs index 30fc6e19..322d0aee 100644 --- a/crates/edgezero-adapter-axum/src/templates/axum.toml.hbs +++ b/crates/edgezero-adapter-axum/src/templates/axum.toml.hbs @@ -3,3 +3,8 @@ crate = "{{proj_axum}}" crate_dir = "." host = "127.0.0.1" port = 8787 + +# Config snapshots load before listener bind; restart after config push. +# Set EDGEZERO__ADAPTER__CONFIG_STORE__* for allocation budgets. +# Defaults: file 16 MiB, 1024 entries, key 1024 bytes, value 8 MiB, +# all-store residency 32 MiB, startup residency plus staging 112 MiB. diff --git a/crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs b/crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs index a73eb876..19030546 100644 --- a/crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs +++ b/crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs @@ -1,6 +1,6 @@ -use edgezero_adapter_axum::dev_server::run_app; +use edgezero_adapter_axum::dev_server::run_generated_app; fn main() -> anyhow::Result<()> { - run_app::<{{proj_core_mod}}::App>()?; + run_generated_app::<{{proj_core_mod}}::App>()?; Ok(()) } diff --git a/crates/edgezero-adapter-axum/tests/fixtures/native_aws_host.rs b/crates/edgezero-adapter-axum/tests/fixtures/native_aws_host.rs new file mode 100644 index 00000000..178376e9 --- /dev/null +++ b/crates/edgezero-adapter-axum/tests/fixtures/native_aws_host.rs @@ -0,0 +1,109 @@ +//! Child executable used by `tests/native_aws_startup.rs`. + +use std::future; +use std::sync::Arc; + +use edgezero_adapter_axum::dev_server::{ + AxumRunOptions, LocalStartupFuture, PreparedStores, run_app_with_native_stores, +}; +use edgezero_adapter_axum::native_stores::NativeStoreOverrides; +use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; +use edgezero_core::context::RequestContext; +use edgezero_core::extractor::{AppConfig as TypedAppConfig, State}; +use edgezero_core::router::RouterService; +use edgezero_core::{EdgeError, action}; +use serde::Deserialize; +use validator::Validate; + +struct NativeAwsApp; + +#[derive(Deserialize, Validate, edgezero_core::AppConfig)] +struct FixtureConfig { + #[validate(length(min = 4_u64))] + greeting: String, + #[secret] + #[validate(length(min = 8_u64))] + token: String, +} + +#[derive(Clone)] +struct Snapshot { + greeting: String, + token: String, +} + +#[expect( + clippy::missing_trait_methods, + reason = "fixture retains unused Hooks defaults" +)] +impl Hooks for NativeAwsApp { + fn routes() -> RouterService { + RouterService::builder() + .get("/snapshot", snapshot) + .get("/local-secret", local_secret) + .build() + } + + fn stores() -> StoresMetadata { + StoresMetadata { + config: Some(StoreMetadata { + ids: &["app"], + default: "app", + }), + kv: None, + secrets: Some(StoreMetadata { + ids: &["vault", "local"], + default: "vault", + }), + } + } +} + +#[action] +async fn snapshot(State(snapshot): State>) -> Result { + Ok(format!("{}|{}", snapshot.greeting, snapshot.token)) +} + +#[action] +async fn local_secret(ctx: RequestContext) -> Result { + let local = ctx + .secret_store("local") + .ok_or_else(|| EdgeError::service_unavailable("local fixture store missing"))?; + Ok(local.require_str("LOCAL_FIXTURE_SECRET").await?) +} + +fn initialize<'startup>( + app: &'startup mut App, + stores: &'startup PreparedStores, +) -> LocalStartupFuture<'startup> { + Box::pin(async move { + let binding = stores + .config() + .and_then(|registry| registry.named_ref("app")) + .ok_or_else(|| EdgeError::service_unavailable("config fixture missing"))?; + let config = TypedAppConfig::::from_binding( + binding, + None, + stores.secrets(), + app.config_extraction_limits(), + app.monotonic_clock(), + ) + .await?; + app.insert_state(Arc::new(Snapshot { + greeting: config.greeting, + token: config.token, + })); + Ok(()) + }) +} + +fn main() -> anyhow::Result<()> { + let options = AxumRunOptions::from_env()?; + run_app_with_native_stores::( + options, + NativeStoreOverrides::default(), + future::pending::<()>(), + initialize, + )?; + Ok(()) +} diff --git a/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs b/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs new file mode 100644 index 00000000..059cf88b --- /dev/null +++ b/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs @@ -0,0 +1,353 @@ +//! Test-only executable for real startup, signal, drain and storage acceptance. +#![expect(clippy::print_stdout, reason = "bounded subprocess IPC markers")] + +#[path = "../../../edgezero-core/tests/fixtures/production_handlers.rs"] +mod fixture; + +use std::cell::Cell; +use std::env; +use std::future; +use std::net::SocketAddr; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use edgezero_adapter_axum::dev_server::{ + AxumRunOptions, run_app, run_app_with_options_and_initializer, run_production_app, + run_production_app_with_initializer, +}; +use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; +use edgezero_core::context::RequestContext; +use edgezero_core::extractor::{AppConfig, State}; +use edgezero_core::key_value_store::KvHandle; +use edgezero_core::probe::{self, LifecyclePhase, LifecycleReader}; +use edgezero_core::router::RouterService; +use edgezero_core::{Body, EdgeError, MonotonicClock, MonotonicInstant, action}; +use futures::stream; +use tokio::sync::oneshot; +use tokio::time::sleep; + +struct FixtureApp; + +#[expect( + clippy::missing_trait_methods, + reason = "fixture retains unused Hooks defaults" +)] +impl Hooks for FixtureApp { + fn configure(app: &mut App) -> Result<(), EdgeError> { + if mode() == "hook-error" { + return Err(EdgeError::internal(anyhow::anyhow!("HOOK_SENTINEL"))); + } + if env::var_os("FIXTURE_FREEZE_CLOCK").is_some() { + let frozen = MonotonicInstant::now(); + app.set_monotonic_clock(MonotonicClock::new(move || frozen)); + } + Ok(()) + } + + fn owns_logging() -> bool { + true + } + + fn routes() -> RouterService { + RouterService::builder() + .get("/ready", probe::readiness) + .get("/live", probe::liveness) + .get("/state", fixture::state) + .get("/typed", fixture::typed) + .get("/finite", finite) + .get("/pending", pending) + .get("/stream", endless_stream) + .get("/flood", flood) + .get("/after", after) + .get("/write", write) + .get("/read", read) + .get("/alias", alias) + .get("/distinct", distinct) + .get("/snapshot", snapshot) + .get("/stop", stop) + .build() + } + + fn stores() -> StoresMetadata { + if matches!( + mode().as_str(), + "bare" + | "bare-dev" + | "hook-error" + | "initializer-error" + | "starting" + | "embedding-bare" + ) { + return StoresMetadata::default(); + } + StoresMetadata { + config: Some(StoreMetadata { + ids: &["settings"], + default: "settings", + }), + kv: Some(StoreMetadata { + ids: &["first", "second", "third"], + default: "first", + }), + secrets: Some(StoreMetadata { + ids: &["vault"], + default: "vault", + }), + } + } +} + +struct WorkGuard(&'static str); + +impl Drop for WorkGuard { + fn drop(&mut self) { + println!("{}", self.0); + } +} + +struct StopState(Mutex>>); + +fn mode() -> String { + env::var("FIXTURE_MODE").unwrap_or_else(|_| "initialized".to_owned()) +} + +fn reader(ctx: &RequestContext) -> Result { + ctx.extensions() + .get::() + .cloned() + .ok_or_else(|| EdgeError::service_unavailable("fixture reader missing")) +} + +#[action] +async fn after() -> Result<&'static str, EdgeError> { + println!("FORBIDDEN_DISPATCH"); + Ok("forbidden dispatch") +} + +#[action] +async fn finite(ctx: RequestContext) -> Result<&'static str, EdgeError> { + let observed = reader(&ctx)?; + let _guard = WorkGuard("FINITE_DROPPED"); + println!("DISPATCH"); + while observed.phase() == Some(LifecyclePhase::Ready) { + sleep(Duration::from_millis(5)).await; + } + println!("DRAINING"); + sleep(Duration::from_millis(80)).await; + Ok("finite-complete") +} + +#[action] +async fn pending(ctx: RequestContext) -> Result<&'static str, EdgeError> { + let observed = reader(&ctx)?; + let _guard = WorkGuard("WORK_DROPPED"); + println!("DISPATCH"); + let mut reported = false; + #[expect( + clippy::infinite_loop, + reason = "fixture proves process shutdown cancels a never-ending handler" + )] + loop { + if !reported && observed.phase() == Some(LifecyclePhase::Draining) { + println!("DRAINING"); + reported = true; + } + sleep(Duration::from_millis(5)).await; + } +} + +#[action] +async fn endless_stream(ctx: RequestContext) -> Result { + let observed = reader(&ctx)?; + let guard = Rc::new(WorkGuard("STREAM_DROPPED")); + println!("STREAM_READY"); + Ok(Body::from_stream(async_stream::stream! { + let _retained = guard; + yield Ok(Bytes::from_static(b"stream-prefix")); + let mut reported = false; + loop { + if !reported && observed.phase() == Some(LifecyclePhase::Draining) { + println!("DRAINING"); + reported = true; + } + sleep(Duration::from_millis(5)).await; + } + })) +} + +#[action] +async fn flood() -> Result { + static BLOCK: [u8; 0x0001_0000] = [b'x'; 0x0001_0000]; + let guard = Rc::new(WorkGuard("STREAM_DROPPED")); + println!("STREAM_READY"); + Ok(Body::from_stream(stream::repeat_with(move || { + let _retained = &guard; + Ok(Bytes::from_static(&BLOCK)) + }))) +} + +fn kv(ctx: &RequestContext, id: &str) -> Result { + ctx.kv_store(id) + .ok_or_else(|| EdgeError::service_unavailable("fixture KV missing")) +} + +#[action] +async fn write(ctx: RequestContext) -> Result<&'static str, EdgeError> { + kv(&ctx, "first")? + .put("persisted", &"durable-value") + .await?; + Ok("written") +} + +#[action] +async fn read(ctx: RequestContext) -> Result { + Ok(kv(&ctx, "first")? + .get::("persisted") + .await? + .unwrap_or_default()) +} + +#[action] +async fn alias(ctx: RequestContext) -> Result { + Ok(kv(&ctx, "second")? + .get::("persisted") + .await? + .unwrap_or_default()) +} + +#[action] +async fn distinct(ctx: RequestContext) -> Result { + Ok(kv(&ctx, "third")? + .get::("persisted") + .await? + .unwrap_or_default()) +} + +#[action] +async fn snapshot(ctx: RequestContext) -> Result { + let binding = ctx + .config_store_default_binding() + .ok_or_else(|| EdgeError::service_unavailable("fixture config missing"))?; + Ok(binding + .handle + .get(&binding.default_key) + .await? + .unwrap_or_default() + .as_str() + .to_owned()) +} + +#[action] +async fn stop(State(state): State>) -> Result<&'static str, EdgeError> { + if let Some(sender) = state + .0 + .lock() + .map_err(|_error| EdgeError::internal(anyhow::anyhow!("fixture stop lock poisoned")))? + .take() + { + let _sent = sender.send(()); + } + Ok("stopping") +} + +#[expect( + clippy::panic_in_result_fn, + reason = "test-only executable asserts initializer ownership and call counts" +)] +fn main() -> anyhow::Result<()> { + let selected = mode(); + if matches!(selected.as_str(), "development" | "bare-dev") { + return run_app::(); + } + if matches!(selected.as_str(), "framework" | "bare" | "hook-error") { + return run_production_app::(); + } + if selected == "starting" { + return run_production_app_with_initializer::(|_app, _stores| { + Box::pin(async { + let _guard = WorkGuard("STARTUP_DROPPED"); + println!("INITIALIZING"); + future::pending::>().await + }) + }); + } + if selected == "initializer-error" { + return run_production_app_with_initializer::(|_app, _stores| { + Box::pin(async { Err(EdgeError::internal(anyhow::anyhow!("CALLBACK_SENTINEL"))) }) + }); + } + let calls = Rc::new(Cell::new(0_u32)); + let initialized = Rc::clone(&calls); + if selected.starts_with("embedding") { + let address: SocketAddr = env::var("FIXTURE_BIND")?.parse()?; + let mut options = AxumRunOptions::new(address)?; + if selected == "embedding" { + options = options + .with_config_dir(env::var("FIXTURE_CONFIG_ROOT")?)? + .with_data_dir(env::var("FIXTURE_DATA_ROOT")?)?; + } + let (sender, receiver) = oneshot::channel(); + let stop_capture = Rc::clone(&calls); + return run_app_with_options_and_initializer::( + options, + async move { + let _closed = receiver.await; + assert_eq!( + stop_capture.get(), + 1, + "initializer ran once before caller stop" + ); + }, + move |app, stores| { + Box::pin(async move { + if let Some(binding) = + stores.config().and_then(|registry| registry.default_ref()) + { + let config = AppConfig::::from_binding( + binding, + None, + stores.secrets(), + app.config_extraction_limits(), + app.monotonic_clock(), + ) + .await?; + app.insert_state(Arc::new(fixture::HostState { + greeting: config.greeting, + })); + } + app.insert_state(Arc::new(StopState(Mutex::new(Some(sender))))); + initialized.set(initialized.get().saturating_add(1)); + println!("INITIALIZED"); + Ok(()) + }) + }, + ); + } + run_production_app_with_initializer::(move |app, stores| { + Box::pin(async move { + let binding = stores + .config() + .and_then(|registry| registry.default_ref()) + .ok_or_else(|| { + EdgeError::service_unavailable("required fixture binding missing") + })?; + let config = AppConfig::::from_binding( + binding, + None, + stores.secrets(), + app.config_extraction_limits(), + app.monotonic_clock(), + ) + .await?; + app.insert_state(Arc::new(fixture::HostState { + greeting: config.greeting, + })); + initialized.set(initialized.get().saturating_add(1)); + assert_eq!(initialized.get(), 1, "initializer runs exactly once"); + println!("INITIALIZED"); + Ok(()) + }) + }) +} diff --git a/crates/edgezero-adapter-axum/tests/native_aws_startup.rs b/crates/edgezero-adapter-axum/tests/native_aws_startup.rs new file mode 100644 index 00000000..a72c00f2 --- /dev/null +++ b/crates/edgezero-adapter-axum/tests/native_aws_startup.rs @@ -0,0 +1,624 @@ +//! Loopback-only production startup tests. +//! +//! These tests require `aws-stores` and `test-utils` and launch `fixtures/native_aws_host.rs`. +//! They exercise the public runner and real pinned SDK, not a provider seam. No AWS account, +//! DNS hostname, external socket, image, or real cloud credential is used. + +#[cfg(test)] +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "loopback fixture helpers remain grouped before startup scenarios" +)] +mod tests { + use std::fmt::Write as _; + use std::fs; + use std::io::{ErrorKind, Read, Write as _}; + use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::path::Path; + use std::process::{Child, Command, Stdio}; + use std::sync::mpsc; + use std::sync::{Arc, Mutex}; + use std::thread::{self, JoinHandle}; + use std::time::{Duration, Instant}; + + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::{Value, json}; + use tempfile::TempDir; + + const WAIT: Duration = Duration::from_secs(12); + const ARN: &str = "arn:aws:secretsmanager:us-east-1:111122223333:secret:native-fixture-AbCdEf"; + const TOKEN_SENTINEL: &str = "native-fixture-secret-value"; + const LOCAL_SENTINEL: &str = "native-fixture-local-secret"; + const AGENT_TOKEN: &str = "native-fixture-agent-token"; + const FALLBACK_SENTINEL: &str = "local-fallback-must-not-be-used"; + + #[derive(Debug)] + struct Request { + headers: String, + body: String, + } + + struct Reply { + status: u16, + reason: &'static str, + body: String, + content_type: &'static str, + } + + impl Reply { + fn ok(body: String, content_type: &'static str) -> Self { + Self { + status: 200, + reason: "OK", + body, + content_type, + } + } + } + + /// One finite server bound to an IPv4 loopback literal. `expected=false` watches briefly for an + /// unexpected request, which makes the Agent-failure/no-Secrets-call assertion observable. + struct FixtureServer { + endpoint: String, + request: mpsc::Receiver, + worker: Option>>, + } + + impl FixtureServer { + fn once(reply: Reply, expected: bool) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind literal loopback only"); + listener + .set_nonblocking(true) + .expect("nonblocking loopback listener"); + let address = listener.local_addr().expect("fixture address"); + let endpoint = format!("http://{address}"); + let (sender, request_receiver) = mpsc::channel(); + let worker = thread::spawn(move || { + let watch = if expected { + WAIT + } else { + Duration::from_millis(300) + }; + let deadline = Instant::now() + .checked_add(watch) + .expect("fixture deadline fits the monotonic clock"); + loop { + match listener.accept() { + Ok((mut stream, peer)) => { + assert!(peer.ip().is_loopback(), "fixture peer was not loopback"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("bounded request read"); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .expect("bounded response write"); + let request = read_request(&mut stream); + send_response(&mut stream, &reply); + let _sent = sender.send(Request { + headers: request.headers.clone(), + body: request.body.clone(), + }); + return Some(request); + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + assert!(!expected, "expected fixture request did not arrive"); + return None; + } + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("loopback fixture accept failed: {error}"), + } + } + }); + Self { + endpoint, + request: request_receiver, + worker: Some(worker), + } + } + + fn endpoint(&self) -> &str { + &self.endpoint + } + + fn take_request(&self) -> Request { + self.request + .recv_timeout(WAIT) + .expect("fixture captured one request") + } + + #[expect( + clippy::unwrap_in_result, + reason = "a broken fixture or panicked thread must fail the test, never become a normal absence" + )] + fn finish(mut self) -> Option { + self.worker + .take() + .expect("fixture worker") + .join() + .expect("fixture thread") + } + } + + fn read_request(stream: &mut TcpStream) -> Request { + let mut bytes = Vec::new(); + let mut scratch = [0_u8; 2048]; + let header_end = loop { + let count = stream.read(&mut scratch).expect("read request"); + assert_ne!(count, 0, "peer closed before request headers"); + let received = scratch + .get(..count) + .expect("read count does not exceed scratch buffer"); + bytes.extend_from_slice(received); + assert!(bytes.len() <= 64 * 1024, "bounded fixture request"); + if let Some(offset) = bytes.windows(4).position(|part| part == b"\r\n\r\n") { + break offset + .checked_add(4) + .expect("header delimiter offset fits request"); + } + }; + let header_bytes = bytes + .get(..header_end) + .expect("header terminator is within received request"); + let headers = String::from_utf8_lossy(header_bytes).into_owned(); + let content_length = headers + .lines() + .find_map(|line| { + let (_, value) = line + .split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("content-length"))?; + value.trim().parse::().ok() + }) + .unwrap_or(0); + let body_end = header_end + .checked_add(content_length) + .expect("bounded request body end fits usize"); + while bytes.len() < body_end { + let count = stream.read(&mut scratch).expect("read bounded body"); + assert_ne!(count, 0, "peer closed before request body"); + let received = scratch + .get(..count) + .expect("read count does not exceed scratch buffer"); + bytes.extend_from_slice(received); + assert!( + bytes.len() <= body_end, + "request body stays within declared length" + ); + } + let body_bytes = bytes + .get(header_end..body_end) + .expect("declared request body is within received bytes"); + Request { + headers: headers.to_ascii_lowercase(), + body: String::from_utf8_lossy(body_bytes).into_owned(), + } + } + + fn send_response(stream: &mut TcpStream, reply: &Reply) { + write!( + stream, + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + reply.status, + reply.reason, + reply.content_type, + reply.body.len() + ) + .expect("write response headers"); + stream + .write_all(reply.body.as_bytes()) + .expect("write response body"); + } + + struct ChildHost { + child: Child, + address: SocketAddr, + output: Arc>>, + readers: Vec>, + } + + impl ChildHost { + fn spawn(root: &TempDir, bindings: &Path, secrets_endpoint: &str) -> Self { + let home = root.path().join("home"); + let profiles = root.path().join("profiles"); + fs::create_dir_all(&home).expect("isolated HOME"); + fs::create_dir_all(&profiles).expect("isolated profile directory"); + let config = profiles.join("config"); + let credentials = profiles.join("credentials"); + fs::write(&config, "").expect("empty AWS config; no profiles"); + fs::write(&credentials, "").expect("empty AWS credentials; no profiles"); + let address = reserve_address(); + let mut child = Command::new(env!("CARGO_BIN_EXE_edgezero-native-aws-host")) + .env_clear() + .current_dir(root.path()) + .env("HOME", home) + .env("AWS_CONFIG_FILE", config) + .env("AWS_SHARED_CREDENTIALS_FILE", credentials) + .env("AWS_REGION", "us-east-1") + .env("AWS_EC2_METADATA_DISABLED", "true") + .env("AWS_ACCESS_KEY_ID", "DUMMY_LOOPBACK_ACCESS_KEY") + .env("AWS_SECRET_ACCESS_KEY", "DUMMY_LOOPBACK_SECRET_KEY") + .env("AWS_ENDPOINT_URL", secrets_endpoint) + .env("AWS_ENDPOINT_URL_SECRETS_MANAGER", secrets_endpoint) + .env("EDGEZERO__STORE_BINDINGS_FILE", bindings) + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", address.port().to_string()) + .env("EDGEZERO__LOGGING__LEVEL", "off") + .env("APPCONFIG_AGENT_TOKEN", AGENT_TOKEN) + .env("LOCAL_FIXTURE_SECRET", LOCAL_SENTINEL) + .env("token", FALLBACK_SENTINEL) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("start production host fixture"); + let output = Arc::new(Mutex::new(Vec::new())); + let streams: [Box; 2] = [ + Box::new(child.stdout.take().expect("child stdout")), + Box::new(child.stderr.take().expect("child stderr")), + ]; + let readers = streams + .into_iter() + .map(|mut stream| { + let captured_output = Arc::clone(&output); + thread::spawn(move || { + let mut captured = Vec::new(); + stream + .read_to_end(&mut captured) + .expect("capture child output"); + captured_output + .lock() + .expect("captured output") + .extend(captured); + }) + }) + .collect(); + Self { + child, + address, + output, + readers, + } + } + + fn wait_http(&mut self, path: &str) -> String { + let deadline = Instant::now() + .checked_add(WAIT) + .expect("host readiness deadline fits the monotonic clock"); + loop { + if let Some(status) = self.child.try_wait().expect("child status") { + self.child.wait().expect("reap exited child"); + let output = self.collect_output(); + panic!("fixture exited before {path} (status {status}): {output}"); + } + if let Ok(mut stream) = + TcpStream::connect_timeout(&self.address, Duration::from_millis(40)) + { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("bounded app response read"); + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" + ) + .expect("write app request"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("read complete app response"); + if response.starts_with("HTTP/1.1 200") { + return response; + } + } + assert!(Instant::now() < deadline, "host readiness deadline"); + thread::sleep(Duration::from_millis(10)); + } + } + + fn wait_failure(&mut self) -> String { + let deadline = Instant::now() + .checked_add(WAIT) + .expect("startup failure deadline fits the monotonic clock"); + loop { + if let Some(status) = self.child.try_wait().expect("child status") { + assert!(!status.success(), "expected startup failure"); + self.child.wait().expect("reap failed child"); + return self.collect_output(); + } + assert!(Instant::now() < deadline, "failed startup stayed alive"); + thread::sleep(Duration::from_millis(10)); + } + } + + fn stop(&mut self) { + if self.child.try_wait().expect("child status").is_none() { + self.child.kill().expect("stop loopback host"); + } + self.child.wait().expect("reap host"); + let _output = self.collect_output(); + } + + fn collect_output(&mut self) -> String { + for reader in self.readers.drain(..) { + reader.join().expect("child output reader"); + } + String::from_utf8_lossy(&self.output.lock().expect("captured output")).into_owned() + } + + fn assert_no_listener(&self) { + TcpStream::connect_timeout(&self.address, Duration::from_millis(50)) + .expect_err("startup failure must happen before listener acceptance"); + } + } + + impl Drop for ChildHost { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _killed = self.child.kill(); + } + let _status = self.child.wait(); + for reader in self.readers.drain(..) { + let _joined = reader.join(); + } + } + } + + fn reserve_address() -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").expect("reserve literal loopback port"); + listener.local_addr().expect("reserved address") + } + + fn response_body(response: &str) -> &str { + response + .split_once("\r\n\r\n") + .expect("HTTP response header terminator") + .1 + } + + fn envelope(token_reference: &str, greeting: &str) -> String { + serde_json::to_string(&BlobEnvelope::new( + json!({"greeting": greeting, "token": token_reference}), + "2026-10-07T12:00:00Z".to_owned(), + )) + .expect("serialize actual core BlobEnvelope") + } + + fn agent_reply(document: &str, status: u16) -> Reply { + if status == 200 { + Reply::ok(document.to_owned(), "application/json") + } else { + Reply { + status, + reason: "Service Unavailable", + body: "AGENT_BODY_SENTINEL".to_owned(), + content_type: "text/plain", + } + } + } + + fn secrets_reply(value: &str) -> Reply { + Reply::ok( + json!({ + "ARN": ARN, + "Name": "native-fixture", + "VersionId": "12345678901234567890123456789012", + "SecretString": value, + }) + .to_string(), + "application/x-amz-json-1.1", + ) + } + + fn denied_secrets_reply() -> Reply { + Reply { + status: 400, + reason: "Bad Request", + body: json!({ + "__type": "AccessDeniedException", + "message": "AWS_ERROR_SENTINEL ARN_SENTINEL CREDENTIAL_SENTINEL", + }) + .to_string(), + content_type: "application/x-amz-json-1.1", + } + } + + fn bindings_file(path: &Path, agent: &str, aws_limits: Option<(usize, usize, usize)>) { + let mut content = format!( + r#"version = 1 + +[config.app] +provider = "aws-appconfig-agent" +default_key = "settings" +endpoint = "{agent}" +access_token_env = "APPCONFIG_AGENT_TOKEN" + +[config.app.documents.settings] +application = "native-fixture" +environment = "test" +profile = "startup-proof" + +[secrets.vault] +provider = "aws-secrets-manager" +region = "us-east-1" + +[secrets.vault.entries.token] +secret_id = "{ARN}" +"# + ); + if let Some((snapshot, config, secret)) = aws_limits { + write!(content, "\n[limits.aws]\nmax_snapshot_bytes = {snapshot}\nmax_config_document_bytes = {config}\nmax_secret_value_bytes = {secret}\n").expect("format fixture limits"); + } + fs::write(path, content).expect("write explicit fictional bindings"); + } + + fn assert_safe_error(text: &str) { + for sentinel in [ + TOKEN_SENTINEL, + LOCAL_SENTINEL, + AGENT_TOKEN, + FALLBACK_SENTINEL, + "AGENT_BODY_SENTINEL", + "AWS_ERROR_SENTINEL", + "ARN_SENTINEL", + "CREDENTIAL_SENTINEL", + "DUMMY_LOOPBACK_ACCESS_KEY", + "DUMMY_LOOPBACK_SECRET_KEY", + ] { + assert!(!text.contains(sentinel), "startup output leaked {sentinel}"); + } + } + + fn request_body_is_serialized_get_secret(request: &Request) { + assert!( + request + .headers + .contains("x-amz-target: secretsmanager.getsecretvalue"), + "real SDK request target observed" + ); + assert!( + request + .headers + .contains("credential=dummy_loopback_access_key/") + ); + let body: Value = serde_json::from_str(&request.body).expect("SDK JSON request body"); + assert_eq!(body["SecretId"], ARN); + assert!(body.get("VersionStage").is_none()); + assert!(body.get("VersionId").is_none()); + } + + #[test] + fn production_runner_loads_envelope_and_real_sdk_secret_before_serving_mixed_stores() { + let root = tempfile::tempdir().expect("temporary process root"); + let document = envelope("token", "startup-greeting"); + let agent = FixtureServer::once(agent_reply(&document, 200), true); + let secrets = FixtureServer::once(secrets_reply(TOKEN_SENTINEL), true); + let bindings = root.path().join("native-stores.toml"); + bindings_file(&bindings, agent.endpoint(), None); + let mut child = ChildHost::spawn(&root, &bindings, secrets.endpoint()); + + // The app initializer loads the actual serialized BlobEnvelope through core AppConfig, + // resolves its #[secret] field from the SDK-backed snapshot, and inserts typed state. + let first = child.wait_http("/snapshot"); + assert_eq!( + response_body(&first), + "startup-greeting|native-fixture-secret-value" + ); + let second = child.wait_http("/snapshot"); + assert_eq!( + response_body(&second), + "startup-greeting|native-fixture-secret-value" + ); + let local = child.wait_http("/local-secret"); + assert_eq!(response_body(&local), "native-fixture-local-secret"); + + let agent_request = agent.take_request(); + assert!(agent_request.headers.starts_with( + "get /applications/native-fixture/environments/test/configurations/startup-proof " + )); + assert!( + agent_request + .headers + .contains(&format!("authorization: bearer {AGENT_TOKEN}")) + ); + assert_eq!(agent_request.body, ""); + assert!(agent_request.headers.contains("accept-encoding: identity")); + let secret_request = secrets.take_request(); + request_body_is_serialized_get_secret(&secret_request); + assert_eq!( + agent.finish().expect("Agent call").headers, + agent_request.headers + ); + assert_eq!( + secrets.finish().expect("SDK call").body, + secret_request.body + ); + + // Each fixture accepted exactly one startup GET. Both request-time reads still succeeded + // after their fixture listeners closed, so no request-time AWS call occurred. + child.stop(); + } + + #[test] + fn agent_startup_error_stops_before_secret_client_request_and_listener() { + let root = tempfile::tempdir().expect("temporary process root"); + let agent = FixtureServer::once(agent_reply("AGENT_BODY_SENTINEL", 503), true); + let secrets = FixtureServer::once(denied_secrets_reply(), false); + let bindings = root.path().join("native-stores.toml"); + bindings_file(&bindings, agent.endpoint(), None); + let mut child = ChildHost::spawn(&root, &bindings, secrets.endpoint()); + let output = child.wait_failure(); + assert_safe_error(&output); + child.assert_no_listener(); + assert!(agent.finish().is_some(), "Agent request observed"); + assert!( + secrets.finish().is_none(), + "no SDK request after Agent failure" + ); + } + + #[test] + fn explicit_secret_denial_fails_without_local_fallback_or_listener() { + let root = tempfile::tempdir().expect("temporary process root"); + let document = envelope("token", "startup-greeting"); + let agent = FixtureServer::once(agent_reply(&document, 200), true); + let secrets = FixtureServer::once(denied_secrets_reply(), true); + let bindings = root.path().join("native-stores.toml"); + bindings_file(&bindings, agent.endpoint(), None); + let mut child = ChildHost::spawn(&root, &bindings, secrets.endpoint()); + let output = child.wait_failure(); + assert_safe_error(&output); + child.assert_no_listener(); + assert!(agent.finish().is_some()); + let request = secrets.finish().expect("real SDK request captured"); + request_body_is_serialized_get_secret(&request); + } + + #[test] + fn nested_secret_miss_fails_initializer_before_listener() { + let root = tempfile::tempdir().expect("temporary process root"); + let document = envelope("unmapped-secret-key", "startup-greeting"); + let agent = FixtureServer::once(agent_reply(&document, 200), true); + let secrets = FixtureServer::once(secrets_reply(TOKEN_SENTINEL), true); + let bindings = root.path().join("native-stores.toml"); + bindings_file(&bindings, agent.endpoint(), None); + let mut child = ChildHost::spawn(&root, &bindings, secrets.endpoint()); + let output = child.wait_failure(); + assert_safe_error(&output); + child.assert_no_listener(); + assert!(agent.finish().is_some()); + assert!(secrets.finish().is_some()); + } + + #[test] + fn shared_payload_quota_spans_appconfig_and_secrets_before_readiness() { + let root = tempfile::tempdir().expect("temporary process root"); + let document = envelope("token", "startup-greeting"); + let secret = TOKEN_SENTINEL; + let aggregate_cap = document.len().max(secret.len()); + let combined_payload_bytes = document + .len() + .checked_add(secret.len()) + .expect("fixture payload lengths fit usize"); + assert!(aggregate_cap < combined_payload_bytes); + let agent = FixtureServer::once(agent_reply(&document, 200), true); + let secrets = FixtureServer::once(secrets_reply(secret), true); + let bindings = root.path().join("native-stores.toml"); + bindings_file( + &bindings, + agent.endpoint(), + Some((aggregate_cap, document.len(), secret.len())), + ); + let mut child = ChildHost::spawn(&root, &bindings, secrets.endpoint()); + let output = child.wait_failure(); + assert_safe_error(&output); + assert!( + output.contains("size limit"), + "aggregate quota category: {output}" + ); + child.assert_no_listener(); + assert!(agent.finish().is_some(), "config snapshot charged first"); + assert!( + secrets.finish().is_some(), + "secret response fetched before aggregate charge" + ); + } +} diff --git a/crates/edgezero-adapter-axum/tests/production_hosting.rs b/crates/edgezero-adapter-axum/tests/production_hosting.rs new file mode 100644 index 00000000..97e8db9a --- /dev/null +++ b/crates/edgezero-adapter-axum/tests/production_hosting.rs @@ -0,0 +1,652 @@ +//! Real local processes, public entrypoints and ordinary main termination output. +#![cfg(all(feature = "axum", feature = "test-utils", unix))] + +#[cfg(test)] +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "subprocess fixtures group setup, process ownership and acceptance scenarios" +)] +mod tests { + use std::fs; + use std::io::{self, BufRead as _, BufReader, Read, Write as _}; + use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _, symlink}; + use std::os::unix::process::ExitStatusExt as _; + use std::process::{Child, Command, ExitStatus, Stdio}; + use std::sync::{Arc, Mutex, mpsc}; + use std::thread::{self, JoinHandle}; + use std::time::{Duration, Instant}; + + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::{Value, json}; + use tempfile::TempDir; + + const WAIT: Duration = Duration::from_secs(5); + + struct Roots { + config: TempDir, + cwd: TempDir, + data: TempDir, + } + + impl Roots { + fn new() -> Self { + let roots = Self { + config: tempfile::tempdir().expect("config root"), + cwd: tempfile::tempdir().expect("unrelated cwd"), + data: tempfile::tempdir().expect("data root"), + }; + roots.map(&json!({"settings": blob("configured-greeting", "fixture_token")})); + roots + } + + fn map(&self, value: &Value) { + fs::write( + self.config.path().join("local-config-settings.json"), + serde_json::to_vec(value).expect("JSON"), + ) + .expect("config fixture"); + } + } + + fn blob(greeting: &str, token: &str) -> String { + serde_json::to_string(&BlobEnvelope::new( + json!({"greeting": greeting, "token": token}), + "2026-10-05T00:00:00Z".to_owned(), + )) + .expect("envelope") + } + + fn address() -> SocketAddr { + TcpListener::bind("127.0.0.1:0") + .expect("port reservation") + .local_addr() + .expect("address") + } + + fn command(mode: &str, roots: &Roots, addr: SocketAddr) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_edgezero-hosting-fixture")); + command + .env_clear() + .current_dir(roots.cwd.path()) + .env("FIXTURE_MODE", mode) + .env("FIXTURE_BIND", addr.to_string()) + .env("FIXTURE_CONFIG_ROOT", roots.config.path()) + .env("FIXTURE_DATA_ROOT", roots.data.path()) + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", addr.port().to_string()) + .env("EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS", "1") + .env("EDGEZERO__ADAPTER__CONFIG_DIR", roots.config.path()) + .env("EDGEZERO__ADAPTER__DATA_DIR", roots.data.path()) + .env("EDGEZERO__STORES__KV__FIRST__NAME", "shared-fixture") + .env("EDGEZERO__STORES__KV__SECOND__NAME", "shared-fixture") + .env("fixture_token", "resolved-fixture-token"); + command + } + + struct Server { + addr: SocketAddr, + child: Child, + events: mpsc::Receiver, + output: Arc>, + readers: Vec>, + } + + impl Server { + fn spawn(mut command: Command, addr: SocketAddr) -> Self { + let mut child = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("fixture process"); + let output = Arc::new(Mutex::new(String::new())); + let (sender, events) = mpsc::channel(); + let stdout = child.stdout.take().expect("stdout"); + let stderr = child.stderr.take().expect("stderr"); + let mut readers = Vec::new(); + let streams: [Box; 2] = [Box::new(stdout), Box::new(stderr)]; + for stream in streams { + let captured = Arc::clone(&output); + let stream_sender = sender.clone(); + readers.push(thread::spawn(move || { + for result in BufReader::new(stream).lines() { + let Ok(line) = result else { break }; + let mut captured_output = captured.lock().expect("output"); + captured_output.push_str(&line); + captured_output.push('\n'); + drop(captured_output); + let _sent = stream_sender.send(line); + } + })); + } + Self { + addr, + child, + events, + output, + readers, + } + } + + fn log(&self) -> String { + self.output.lock().expect("output").clone() + } + + fn ready(&mut self) { + let deadline = Instant::now().checked_add(WAIT).expect("startup timer"); + loop { + assert!( + Instant::now() < deadline, + "startup deadline: {}", + self.log() + ); + assert!( + self.child.try_wait().expect("status").is_none(), + "early process failure: {}", + self.log() + ); + if let Ok(response) = self.request("/ready") + && response.starts_with("HTTP/1.1 200") + { + return; + } + thread::sleep(Duration::from_millis(5)); + } + } + + fn request(&self, path: &str) -> io::Result { + let mut socket = self.socket()?; + write!( + socket, + "GET {path} HTTP/1.1\r\nHost: fixture\r\nConnection: close\r\n\r\n" + )?; + let mut response = String::new(); + socket.read_to_string(&mut response)?; + Ok(response) + } + + fn signal(&self, name: &str) { + assert!( + Command::new("kill") + .arg(format!("-{name}")) + .arg(self.child.id().to_string()) + .status() + .expect("send signal") + .success() + ); + } + + fn socket(&self) -> io::Result { + let socket = TcpStream::connect_timeout(&self.addr, Duration::from_millis(50))?; + socket.set_read_timeout(Some(WAIT))?; + socket.set_write_timeout(Some(WAIT))?; + Ok(socket) + } + + fn wait(&mut self) -> ExitStatus { + let deadline = Instant::now().checked_add(WAIT).expect("exit timer"); + loop { + if let Some(status) = self.child.try_wait().expect("process status") { + for reader in self.readers.drain(..) { + reader.join().expect("output reader"); + } + return status; + } + assert!( + Instant::now() < deadline, + "process exit deadline: {}", + self.log() + ); + thread::sleep(Duration::from_millis(5)); + } + } + + fn wait_line(&self, expected: &str) { + let deadline = Instant::now().checked_add(WAIT).expect("event timer"); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + let line = self.events.recv_timeout(remaining).unwrap_or_else(|error| { + panic!("waiting for {expected}: {error}; {}", self.log()) + }); + if line == expected { + return; + } + } + } + } + + impl Drop for Server { + fn drop(&mut self) { + let _killed = self.child.kill(); + let _status = self.child.wait(); + for reader in self.readers.drain(..) { + let _joined = reader.join(); + } + } + } + + fn failed(command: Command, addr: SocketAddr, category: &str) -> String { + let mut server = Server::spawn(command, addr); + assert!(!server.wait().success()); + let log = server.log(); + assert!(log.contains(category), "missing category {category}: {log}"); + assert!( + !log.contains("SENTINEL"), + "startup diagnostic leaked fixture contents: {log}" + ); + assert!(!log.contains("INITIALIZED")); + TcpStream::connect_timeout(&addr, Duration::from_millis(50)) + .expect_err("failed startup never accepts connections"); + log + } + + #[test] + fn both_signals_stop_idle_processes() { + for signal in ["INT", "TERM"] { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("bare", &roots, addr), addr); + server.ready(); + server.signal(signal); + assert!(server.wait().success(), "{}", server.log()); + } + } + + #[test] + fn sigterm_drains_dispatched_response_closes_idle_and_gates_pipeline() { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("bare", &roots, addr), addr); + server.ready(); + let mut idle = server.socket().expect("idle socket"); + idle.write_all(b"GET /ready HTTP/1.1\r\nHost: fixture\r\n\r\n") + .expect("idle request"); + let mut idle_response = Vec::new(); + while !idle_response.ends_with(b"ready") { + let mut buffer = [0_u8; 1024]; + let read = idle.read(&mut buffer).expect("idle response"); + assert_ne!(read, 0); + idle_response.extend_from_slice(buffer.get(..read).expect("read length")); + } + let mut client = server.socket().expect("finite socket"); + client.write_all(b"GET /finite HTTP/1.1\r\nHost: fixture\r\n\r\nGET /after HTTP/1.1\r\nHost: fixture\r\nConnection: close\r\n\r\n") + .expect("pipeline"); + server.wait_line("DISPATCH"); + server.signal("TERM"); + server.wait_line("DRAINING"); + let mut response = String::new(); + client + .read_to_string(&mut response) + .expect("complete finite response"); + assert!(response.contains("finite-complete")); + assert!(!response.contains("forbidden")); + assert_eq!(idle.read(&mut [0_u8; 1]).expect("idle close"), 0); + assert!(server.wait().success(), "{}", server.log()); + assert!(!server.log().contains("FORBIDDEN_DISPATCH")); + } + + #[test] + fn process_budget_forces_pending_handler_stream_and_zero_read_peer() { + for (path, marker, dropped) in [ + ("/pending", "DISPATCH", "WORK_DROPPED"), + ("/stream", "STREAM_READY", "STREAM_DROPPED"), + ("/flood", "STREAM_READY", "STREAM_DROPPED"), + ] { + let roots = Roots::new(); + let addr = address(); + let mut cmd = command("bare", &roots, addr); + cmd.env("FIXTURE_FREEZE_CLOCK", "1"); + let mut server = Server::spawn(cmd, addr); + server.ready(); + let mut client = server.socket().expect("pending socket"); + write!(client, "GET {path} HTTP/1.1\r\nHost: fixture\r\n\r\n").expect("request"); + server.wait_line(marker); + let started = Instant::now(); + server.signal("TERM"); + assert!(!server.wait().success()); + assert!(started.elapsed() >= Duration::from_millis(700)); + assert!(started.elapsed() < Duration::from_secs(4)); + assert!(server.log().contains("grace exhausted"), "{}", server.log()); + assert_eq!(server.log().matches(dropped).count(), 1); + } + } + + #[test] + fn second_signal_forces_before_budget_exhaustion() { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("bare", &roots, addr), addr); + server.ready(); + let mut client = server.socket().expect("pending socket"); + client + .write_all(b"GET /pending HTTP/1.1\r\nHost: fixture\r\n\r\n") + .expect("request"); + server.wait_line("DISPATCH"); + server.signal("TERM"); + server.wait_line("DRAINING"); + let started = Instant::now(); + server.signal("INT"); + assert!(!server.wait().success()); + assert!(started.elapsed() < Duration::from_millis(700)); + assert!(server.log().contains("forced stop")); + assert_eq!(server.log().matches("WORK_DROPPED").count(), 1); + } + + #[test] + fn stop_during_startup_never_binds_or_becomes_ready() { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("starting", &roots, addr), addr); + server.wait_line("INITIALIZING"); + TcpStream::connect_timeout(&addr, Duration::from_millis(50)) + .expect_err("initialization precedes binding"); + server.signal("TERM"); + assert!(server.wait().success()); + assert!(server.log().contains("STARTUP_DROPPED")); + } + + #[test] + fn invalid_settings_and_callback_sources_are_redacted_nonzero_failures() { + let roots = Roots::new(); + for (key, value, category) in [ + ("EDGEZERO__ADAPTER__HOST", "HOST_SENTINEL", "HOST"), + ("EDGEZERO__ADAPTER__PORT", "PORT_SENTINEL", "PORT"), + ("EDGEZERO__ADAPTER__PORT", "0", "PORT"), + ("EDGEZERO__LOGGING__LEVEL", "LOGGING_SENTINEL", "LEVEL"), + ( + "EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS", + "0", + "SHUTDOWN_GRACE_SECONDS", + ), + ( + "EDGEZERO__ADAPTER__CONFIG_DIR", + "ROOT_SENTINEL", + "CONFIG_DIR", + ), + ( + "EDGEZERO__STORES__CONFIG__SETTINGS__KEY", + "KEY_SENTINEL\n", + "settings", + ), + ] { + let addr = address(); + let mut cmd = command("initialized", &roots, addr); + cmd.env(key, value); + failed(cmd, addr, category); + } + for (mode, category) in [ + ("hook-error", "application configuration"), + ("initializer-error", "initializer"), + ] { + let addr = address(); + failed(command(mode, &roots, addr), addr, category); + } + let addr = address(); + let mut cmd = command("initialized", &roots, addr); + cmd.env_remove("EDGEZERO__ADAPTER__CONFIG_DIR"); + failed(cmd, addr, "CONFIG_DIR"); + let missing = roots.cwd.path().join("missing-data-SENTINEL"); + let missing_addr = address(); + let mut missing_cmd = command("initialized", &roots, missing_addr); + missing_cmd.env("EDGEZERO__ADAPTER__DATA_DIR", &missing); + failed(missing_cmd, missing_addr, "DATA_DIR"); + assert!(!missing.exists()); + } + + #[test] + fn occupied_bind_is_a_real_redacted_process_failure() { + let roots = Roots::new(); + let occupied = TcpListener::bind("127.0.0.1:0").expect("occupied port"); + let addr = occupied.local_addr().expect("address"); + let mut server = Server::spawn(command("bare", &roots, addr), addr); + assert!(!server.wait().success()); + assert!(server.log().contains("bind")); + assert!(!server.log().contains("SENTINEL")); + } + + #[test] + fn required_files_and_typed_initializer_fail_before_acceptance() { + let roots = Roots::new(); + let file = roots.config.path().join("local-config-settings.json"); + fs::remove_file(&file).expect("missing file fixture"); + let addr = address(); + failed(command("initialized", &roots, addr), addr, "missing"); + fs::create_dir_all(&file).expect("non-regular fixture"); + let directory_addr = address(); + failed( + command("initialized", &roots, directory_addr), + directory_addr, + "regular file", + ); + fs::remove_dir(&file).expect("remove fixture directory"); + roots.map(&json!({"settings": {"CONFIG_SENTINEL": true}})); + let malformed_addr = address(); + failed( + command("initialized", &roots, malformed_addr), + malformed_addr, + "malformed", + ); + for (map, category) in [ + (json!({}), "MissingBlob"), + (json!({"settings": "BLOB_SENTINEL"}), "MalformedEnvelope"), + ( + json!({"settings": blob("x", "fixture_token")}), + "Validation", + ), + ( + json!({"settings": blob("valid-greeting", "MISSING_SECRET_SENTINEL")}), + "MissingSecret", + ), + ] { + roots.map(&map); + let typed_addr = address(); + failed( + command("initialized", &roots, typed_addr), + typed_addr, + category, + ); + } + let mut envelope: Value = + serde_json::from_str(&blob("valid-greeting", "fixture_token")).expect("envelope"); + envelope["data"]["greeting"] = json!("CHANGED_CONFIG_SENTINEL"); + roots.map(&json!({"settings": serde_json::to_string(&envelope).expect("envelope")})); + let integrity_addr = address(); + failed( + command("initialized", &roots, integrity_addr), + integrity_addr, + "IntegrityMismatch", + ); + } + + #[test] + fn convenience_checks_framework_without_guessing_required_schema() { + let roots = Roots::new(); + roots.map(&json!({"settings": "INVALID_TYPED_SENTINEL"})); + let addr = address(); + let mut server = Server::spawn(command("framework", &roots, addr), addr); + server.ready(); + assert!( + !server + .request("/typed") + .expect("typed failure") + .starts_with("HTTP/1.1 200") + ); + server.signal("TERM"); + assert!(server.wait().success()); + assert!(!server.log().contains("INITIALIZED")); + } + + #[test] + fn retained_state_key_aliases_snapshot_restart_and_closed_backup_restore() { + let roots = Roots::new(); + roots.map(&json!({"settings": blob("wrong-key-greeting", "fixture_token"), "selected": blob("selected-greeting", "fixture_token")})); + let addr = address(); + let mut cmd = command("initialized", &roots, addr); + cmd.env("EDGEZERO__STORES__CONFIG__SETTINGS__KEY", "selected"); + let mut server = Server::spawn(cmd, addr); + server.ready(); + for _request in 0..3_u8 { + assert!( + server + .request("/state") + .expect("state") + .ends_with("selected-greeting") + ); + assert!( + server + .request("/typed") + .expect("typed") + .ends_with("selected-greeting") + ); + } + assert!( + server + .request("/write") + .expect("write") + .ends_with("written") + ); + assert!( + server + .request("/alias") + .expect("alias") + .ends_with("durable-value") + ); + assert!( + !server + .request("/distinct") + .expect("distinct") + .contains("durable-value") + ); + roots.map(&json!({"settings": blob("restart-greeting", "fixture_token")})); + assert!( + server + .request("/typed") + .expect("snapshot") + .ends_with("selected-greeting") + ); + let second_addr = address(); + failed( + command("initialized", &roots, second_addr), + second_addr, + "locked", + ); + assert_eq!( + fs::read_dir(roots.data.path()).expect("databases").count(), + 2 + ); + server.signal("TERM"); + assert!(server.wait().success()); + assert_eq!(server.log().matches("INITIALIZED").count(), 1); + let mut restarted = Server::spawn(command("initialized", &roots, addr), addr); + restarted.ready(); + assert!( + restarted + .request("/state") + .expect("restarted config") + .ends_with("restart-greeting") + ); + assert!( + restarted + .request("/read") + .expect("persisted") + .ends_with("durable-value") + ); + restarted.signal("TERM"); + assert!(restarted.wait().success()); + let restore = tempfile::tempdir().expect("restore root"); + for result in fs::read_dir(roots.data.path()).expect("closed databases") { + let entry = result.expect("database entry"); + fs::copy(entry.path(), restore.path().join(entry.file_name())) + .expect("stopped-writer backup"); + } + let mut restored_command = command("initialized", &roots, addr); + restored_command.env("EDGEZERO__ADAPTER__DATA_DIR", restore.path()); + let mut restored = Server::spawn(restored_command, addr); + restored.ready(); + assert!( + restored + .request("/read") + .expect("restored value") + .ends_with("durable-value") + ); + restored.signal("TERM"); + assert!(restored.wait().success()); + } + + #[test] + fn explicit_embedding_ignores_ambient_settings_and_uses_local_rc_stop() { + let roots = Roots::new(); + let addr = address(); + let mut cmd = command("embedding", &roots, addr); + cmd.env("EDGEZERO__ADAPTER__HOST", "IGNORED_HOST_SENTINEL") + .env("EDGEZERO__ADAPTER__PORT", "0") + .env("EDGEZERO__ADAPTER__CONFIG_DIR", "IGNORED_ROOT_SENTINEL") + .env( + "EDGEZERO__STORES__CONFIG__SETTINGS__KEY", + "IGNORED_KEY_SENTINEL", + ); + let mut server = Server::spawn(cmd, addr); + server.ready(); + assert!( + server + .request("/state") + .expect("state") + .ends_with("configured-greeting") + ); + assert!( + server + .request("/stop") + .expect("caller stop") + .ends_with("stopping") + ); + assert!(server.wait().success(), "{}", server.log()); + assert_eq!(server.log().matches("INITIALIZED").count(), 1); + } + + #[test] + fn embedding_does_not_install_process_signal_handlers() { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("embedding-bare", &roots, addr), addr); + server.ready(); + server.signal("TERM"); + assert_eq!(server.wait().signal(), Some(15_i32)); + } + + #[test] + fn projected_readonly_config_and_writable_data_preserve_uid_and_permissions() { + let roots = Roots::new(); + let file = roots.config.path().join("local-config-settings.json"); + let target = roots.config.path().join("projected-value"); + fs::rename(&file, &target).expect("projected target"); + symlink(&target, &file).expect("projected symlink"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o444)).expect("read-only config"); + fs::set_permissions(roots.config.path(), fs::Permissions::from_mode(0o555)) + .expect("read-only root"); + let config_before = fs::metadata(roots.config.path()).expect("config metadata"); + let data_before = fs::metadata(roots.data.path()).expect("data metadata"); + let addr = address(); + let mut server = Server::spawn(command("initialized", &roots, addr), addr); + server.ready(); + assert!( + server + .request("/write") + .expect("write") + .ends_with("written") + ); + server.signal("TERM"); + assert!(server.wait().success()); + let config_after = fs::metadata(roots.config.path()).expect("config metadata"); + let data_after = fs::metadata(roots.data.path()).expect("data metadata"); + assert_eq!( + (config_before.uid(), config_before.mode()), + (config_after.uid(), config_after.mode()) + ); + assert_eq!( + (data_before.uid(), data_before.mode()), + (data_after.uid(), data_after.mode()) + ); + fs::set_permissions(roots.config.path(), fs::Permissions::from_mode(0o700)) + .expect("fixture cleanup"); + } +} diff --git a/crates/edgezero-adapter-cloudflare/Cargo.toml b/crates/edgezero-adapter-cloudflare/Cargo.toml index 183935e6..e127424f 100644 --- a/crates/edgezero-adapter-cloudflare/Cargo.toml +++ b/crates/edgezero-adapter-cloudflare/Cargo.toml @@ -12,7 +12,7 @@ workspace = true [features] default = [] -cloudflare = ["dep:async-stream", "dep:worker", "dep:serde_json", "dep:web-sys"] +cloudflare = ["dep:async-stream", "dep:worker", "dep:serde_json", "dep:web-sys", "dep:wasm-streams"] cli = [ "dep:edgezero-adapter", "edgezero-adapter/cli", @@ -43,6 +43,7 @@ serde_json = { workspace = true, optional = true } tempfile = { workspace = true, optional = true } toml_edit = { workspace = true, optional = true } worker = { workspace = true, optional = true } +wasm-streams = { workspace = true, optional = true } walkdir = { workspace = true, optional = true } web-sys = { version = "0.3", optional = true, features = [ "AbortSignal", diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index c823cc2f..cf985f1c 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -1219,6 +1219,7 @@ fn target_manifest(target: &AdapterExecutionTarget) -> Result { #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] use edgezero_core::test_env::PathPrepend; @@ -1238,6 +1239,32 @@ mod tests { const TEST_CONFIG_ID: &str = "app_config"; const TEST_SECRET_ID: &str = "default"; + #[test] + fn bounded_config_scaffolds_require_gc_cleanup_support() { + for source in [ + include_str!("templates/wrangler.toml.hbs"), + include_str!( + "../../../examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml" + ), + include_str!( + "../../../tests/fixtures/reusable-app/crates/fixture-cloudflare/wrangler.toml" + ), + ] { + let document = source + .parse::() + .expect("Wrangler TOML"); + let flags = document + .get("compatibility_flags") + .and_then(toml_edit::Item::as_array) + .expect("explicit compatibility flags"); + assert!( + flags + .iter() + .any(|value| value.as_str() == Some("enable_weak_ref")) + ); + } + } + #[test] fn adapter_capability_matrix_matches_contracts() { let expected = [ diff --git a/crates/edgezero-adapter-cloudflare/src/config_store.rs b/crates/edgezero-adapter-cloudflare/src/config_store.rs index d2ffd4f9..a389be19 100644 --- a/crates/edgezero-adapter-cloudflare/src/config_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/config_store.rs @@ -2,7 +2,8 @@ //! //! Each declared config id maps to its own Cloudflare KV namespace binding, //! resolved at request time from `EDGEZERO__STORES__CONFIG____NAME`. -//! Reads are async (`worker::kv::KvStore::get(key).text().await`). +//! Bounded reads drain a byte stream into caller-sized buffers; raw `get` is +//! explicitly unbounded. Neither path certifies opaque host allocations. //! //! ```toml //! # wrangler.toml @@ -16,6 +17,17 @@ //! arbitrary dotted keys had to be JSON-packed inside one variable. The KV //! backing has no such restriction. +#![cfg_attr( + all(feature = "cloudflare", target_arch = "wasm32"), + expect( + clippy::self_named_module_files, + reason = "the workspace denies both contradictory module-file conventions; retain its self-named-file convention" + ) +)] + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +mod kv; + use std::future::Future; #[cfg(test)] use std::future::{pending, ready}; @@ -30,8 +42,6 @@ use futures_util::future::{Either, select}; #[cfg(test)] use std::collections::HashMap; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -use worker::kv::KvStore as WorkerKvStore; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use worker::{Delay, Env}; /// Config store backed by a Cloudflare KV namespace. @@ -46,7 +56,7 @@ enum CloudflareConfigBackend { #[cfg(test)] InMemory(HashMap), #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] - Kv(WorkerKvStore), + Kv(kv::KvBinding), } impl CloudflareConfigStore { @@ -65,30 +75,37 @@ impl CloudflareConfigStore { #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[inline] pub fn from_env(env: &Env, binding_name: &str) -> Result { - let store = env.kv(binding_name).map_err(|err| { - ConfigStoreError::unavailable(format!( - "failed to open config KV binding '{binding_name}': {err}" - )) - })?; + let store = kv::KvBinding::from_this(env.as_ref(), binding_name)?; Ok(Self { inner: CloudflareConfigBackend::Kv(store), }) } -} -#[async_trait(?Send)] -impl ConfigStore for CloudflareConfigStore { - #[inline] - async fn get(&self, key: &str) -> Result, ConfigStoreError> { + #[cfg_attr( + not(all(feature = "cloudflare", target_arch = "wasm32")), + expect( + clippy::unused_async, + reason = "native fixture is synchronous; the production SDK read is asynchronous" + ) + )] + async fn materialized_get(&self, key: &str) -> Result, ConfigStoreError> { match &self.inner { #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] - CloudflareConfigBackend::Kv(store) => store.get(key).text().await.map_err(|err| { - ConfigStoreError::internal(anyhow::anyhow!("kv config get failed: {err}")) - }), + CloudflareConfigBackend::Kv(store) => store.materialized_get(key).await, #[cfg(test)] CloudflareConfigBackend::InMemory(data) => Ok(data.get(key).cloned()), } } +} + +#[async_trait(?Send)] +impl ConfigStore for CloudflareConfigStore { + #[inline] + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + self.materialized_get(key) + .await + .map(|value| value.map(Into::into)) + } #[inline] async fn get_bounded( @@ -98,27 +115,48 @@ impl ConfigStore for CloudflareConfigStore { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { - bounded_config_read( - self.get(key), - clock, - deadline, - max_backend_bytes, - max_value_bytes, - ) - .await + ) -> Result, ConfigStoreError> { + match &self.inner { + #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] + CloudflareConfigBackend::Kv(store) => { + bounded_config_read( + store.streaming_get( + key, + max_backend_bytes.min(max_value_bytes), + clock, + deadline, + ), + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + .await + } + #[cfg(test)] + CloudflareConfigBackend::InMemory(_) => { + bounded_config_read( + self.materialized_get(key), + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + .await + } + } } } -// Workers KV returns a complete string, so the byte bounds apply after host -// materialization. The timer race bounds guest return but does not prove host cancellation. +// The timer covers acquisition, draining and conversion. Reader drop requests +// cancellation, but finite host teardown is a separate deployed evidence gate. async fn bounded_config_read( read: F, clock: &MonotonicClock, deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, -) -> Result, ConfigStoreError> +) -> Result, ConfigStoreError> where F: Future, ConfigStoreError>>, { @@ -155,7 +193,7 @@ async fn bounded_config_read_with_timer( max_backend_bytes: u64, max_value_bytes: u64, mut make_timer: MakeTimer, -) -> Result, ConfigStoreError> +) -> Result, ConfigStoreError> where F: Future, ConfigStoreError>>, MakeTimer: FnMut(Duration) -> Timer, @@ -189,6 +227,325 @@ where #[cfg(test)] mod tests { + #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] + mod streaming { + use super::*; + use wasm_bindgen_test::wasm_bindgen_test; + use worker::js_sys::{Function, Number, Promise, Reflect, global}; + use worker::wasm_bindgen::JsCast as _; + use worker::wasm_bindgen::JsValue; + use worker::wasm_bindgen_futures::JsFuture; + + fn streaming_store(length: usize, stalled: bool) -> (CloudflareConfigStore, JsValue) { + // An actual KV binding double: text reads fail and the byte source only + // fills caller-owned BYOB views, without materializing a complete value. + let factory = Function::new_with_args( + "length, stalled", + " + // worker::Delay expects browser numeric handles, not Node objects. + if (!globalThis.edgezeroNumericTimers) { + globalThis.edgezeroNumericTimers = true; + const set = globalThis.setTimeout; + const clear = globalThis.clearTimeout; + const timers = new Map(); + let next = 0; + globalThis.setTimeout = (callback, delay, ...args) => { + const id = ++next; + timers.set(id, set(() => { timers.delete(id); callback(...args); }, delay)); + return id; + }; + globalThis.clearTimeout = id => { + clear(timers.get(id) ?? id); + timers.delete(id); + }; + } + const stats = { cancels: 0, gets: 0, pulls: 0, maxView: 0 }; + const kv = { + get: async (key, options) => { + stats.gets++; + if (options.type !== 'stream') throw new Error('text read forbidden'); + if (key === 'missing') return null; + if (key === 'bad-binding') return 'not-a-stream'; + if (key === 'acquisition-error') throw new Error('TOKEN-DO-NOT-LEAK'); + if (key === 'non-byte') return new ReadableStream({ + cancel() { stats.cancels++; return Promise.reject(new Error('TOKEN-DO-NOT-LEAK')); } + }); + let position = 0; + const stream = new ReadableStream({ + type: 'bytes', + pull(controller) { + stats.pulls++; + if (key === 'source-error') throw new Error('TOKEN-DO-NOT-LEAK'); + const view = controller.byobRequest.view; + stats.maxView = Math.max(stats.maxView, view.byteLength); + if (stalled) return new Promise(() => {}); + const count = Math.min(view.byteLength, length - position); + if (count === 0) { + controller.close(); + controller.byobRequest.respond(0); + } else { + view.fill(key === 'invalid-utf8' ? 255 : 97, 0, count); + position += count; + controller.byobRequest.respond(count); + } + }, + cancel() { + stats.cancels++; + if (key === 'reject-cancel') return Promise.reject(new Error('TOKEN-DO-NOT-LEAK')); + } + }, { highWaterMark: 0 }); + if (key === 'late') return new Promise(resolve => { stats.resolve = () => resolve(stream); }); + return stream; + }, + getWithMetadata() {}, put() {}, list() {}, delete() {} + }; + return { kv, stats }; + ", + ); + let js_length = u32::try_from(length).expect("fixture length fits JS integer"); + let binding = factory + .call2( + &JsValue::NULL, + &JsValue::from(js_length), + &JsValue::from(stalled), + ) + .expect("KV fixture"); + let stats = Reflect::get(&binding, &JsValue::from("stats")).expect("stats"); + let kv = kv::KvBinding::from_this(&binding, "kv").expect("KV binding"); + ( + CloudflareConfigStore { + inner: CloudflareConfigBackend::Kv(kv), + }, + stats, + ) + } + + fn counter(stats: &JsValue, field: &str) -> usize { + Number::from(Reflect::get(stats, &JsValue::from(field)).expect("counter")) + .to_string_with_radix(10) + .expect("decimal counter") + .as_string() + .expect("counter string") + .parse() + .expect("integer counter") + } + + #[wasm_bindgen_test] + async fn bounded_kv_requires_gc_cleanup_support_before_provider_dispatch() { + let (store, stats) = streaming_store(1, false); + let global_object = global(); + let property = JsValue::from("FinalizationRegistry"); + let previous = Reflect::get(&global_object, &property).expect("runtime feature"); + Reflect::set(&global_object, &property, &JsValue::UNDEFINED).expect("disable feature"); + let result = store + .get_bounded( + "value", + &MonotonicClock::default(), + Deadline::after(Duration::from_secs(1)), + 1, + 1, + ) + .await; + Reflect::set(&global_object, &property, &previous).expect("restore feature"); + assert!(matches!(result, Err(ConfigStoreError::Unavailable { .. }))); + assert_eq!(counter(&stats, "gets"), 0); + } + + #[wasm_bindgen_test] + async fn bounded_kv_failed_binding_and_cancellation_never_publish_provider_errors() { + for key in [ + "bad-binding", + "acquisition-error", + "non-byte", + "source-error", + "reject-cancel", + ] { + let (store, stats) = streaming_store(2, false); + let error = store + .get_bounded( + key, + &MonotonicClock::default(), + Deadline::after(Duration::from_secs(1)), + 1, + 1, + ) + .await + .expect_err("typed rejection"); + assert!(!error.to_string().contains("TOKEN-DO-NOT-LEAK")); + assert!(matches!( + error, + ConfigStoreError::Unavailable { .. } | ConfigStoreError::ValueTooLarge + )); + if matches!(key, "non-byte" | "reject-cancel") { + assert_eq!(counter(&stats, "cancels"), 1); + } + // Node's runner fails on unhandled promise rejection. + Delay::from(Duration::from_millis(10)).await; + } + } + + #[wasm_bindgen_test] + async fn bounded_kv_cancels_stream_arriving_after_acquisition_is_dropped() { + let (store, stats) = streaming_store(1, false); + let clock = MonotonicClock::default(); + let mut read = Box::pin(store.get_bounded( + "late", + &clock, + Deadline::after(Duration::from_secs(1)), + 1, + 1, + )); + assert!(futures_util::poll!(read.as_mut()).is_pending()); + drop(read); + let resolve = Reflect::get(&stats, &JsValue::from("resolve")) + .expect("resolver") + .dyn_into::() + .expect("resolve function"); + resolve.call0(&JsValue::NULL).expect("resolve late stream"); + Delay::from(Duration::from_millis(10)).await; + assert_eq!( + counter(&stats, "cancels"), + 1, + "late acquisition still owns cancellation" + ); + assert_eq!( + counter(&stats, "pulls"), + 0, + "late stream must never be read" + ); + } + + #[wasm_bindgen_test] + async fn bounded_kv_ready_reads_check_the_clock_between_pulls() { + let (store, stats) = streaming_store(32 * 1024, false); + let start = edgezero_core::MonotonicInstant::now(); + let end = start.checked_add(Duration::from_secs(1)).expect("end"); + let calls = Arc::new(AtomicUsize::new(0)); + let samples = Arc::clone(&calls); + let clock = MonotonicClock::new(move || { + if samples.fetch_add(1, Ordering::SeqCst) < 2 { + start + } else { + end + } + }); + let error = store + .get_bounded( + "value", + &clock, + Deadline::at_instant(end), + 32 * 1024, + 32 * 1024, + ) + .await + .expect_err("deadline"); + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + assert!( + counter(&stats, "pulls") <= 1, + "ready microtasks must not drain past expiry" + ); + } + + #[wasm_bindgen_test] + async fn bounded_kv_uses_streaming_and_inclusive_caps() { + for length in [0, 1, 0x4000, 0x8001] { + let (store, stats) = streaming_store(length, false); + let limit = u64::try_from(length).expect("limit"); + let read = store + .get_bounded( + "value", + &MonotonicClock::default(), + Deadline::after(Duration::from_secs(1)), + limit, + limit, + ) + .await + .expect("bounded stream"); + assert_eq!(read.backend_bytes, limit); + assert_eq!(read.value.as_deref().map(str::len), Some(length)); + assert_eq!(counter(&stats, "cancels"), 0); + assert!(counter(&stats, "maxView") <= 0x4000); + } + } + + #[wasm_bindgen_test] + async fn bounded_kv_cancels_before_appending_first_excess_byte() { + for (backend, value) in [(4, 5), (5, 4), (0, 0)] { + let (store, stats) = streaming_store(5, false); + let error = store + .get_bounded( + "value", + &MonotonicClock::default(), + Deadline::after(Duration::from_secs(1)), + backend, + value, + ) + .await + .expect_err("overflow"); + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); + assert_eq!(counter(&stats, "cancels"), 1); + } + } + + #[wasm_bindgen_test] + async fn bounded_kv_missing_and_invalid_utf8_are_distinct() { + let (store, _) = streaming_store(1, false); + let clock = MonotonicClock::default(); + let deadline = Deadline::after(Duration::from_secs(1)); + let read = store + .get_bounded("missing", &clock, deadline, 1, 1) + .await + .expect("missing"); + assert!(read.value.is_none()); + assert_eq!(read.backend_bytes, 0); + let error = store + .get_bounded("invalid-utf8", &clock, deadline, 1, 1) + .await + .expect_err("UTF-8"); + assert!(matches!(error, ConfigStoreError::Unavailable { .. })); + } + + #[wasm_bindgen_test] + async fn bounded_kv_dropped_read_cancels_pending_reader() { + let (store, stats) = streaming_store(1, true); + let clock = MonotonicClock::default(); + let mut read = Box::pin(store.get_bounded( + "value", + &clock, + Deadline::after(Duration::from_mins(1)), + 1, + 1, + )); + // Allow promise acquisition and the first read to become pending. + for _ in 0_u32..4 { + assert!(futures_util::poll!(read.as_mut()).is_pending()); + JsFuture::from(Promise::resolve(&JsValue::NULL)) + .await + .expect("microtask"); + } + assert_eq!(counter(&stats, "pulls"), 1); + drop(read); + assert_eq!(counter(&stats, "cancels"), 1); + } + + #[wasm_bindgen_test] + async fn bounded_kv_timer_cancels_a_permanently_pending_read() { + let (store, stats) = streaming_store(1, true); + let error = store + .get_bounded( + "value", + &MonotonicClock::default(), + Deadline::after(Duration::from_millis(20)), + 1, + 1, + ) + .await + .expect_err("deadline"); + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + assert_eq!(counter(&stats, "cancels"), 1); + } + } + use super::*; use std::cell::Cell; use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/edgezero-adapter-cloudflare/src/config_store/kv.rs b/crates/edgezero-adapter-cloudflare/src/config_store/kv.rs new file mode 100644 index 00000000..edf76a48 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/config_store/kv.rs @@ -0,0 +1,226 @@ +//! Workers-only KV acquisition and caller-sized byte reads. + +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +use edgezero_core::config_store::ConfigStoreError; +use edgezero_core::time::{Deadline, MonotonicClock}; +use wasm_streams::readable::ReadableStreamBYOBReader; +use worker::Delay; +use worker::js_sys::{Function, Object, Promise, Reflect, Uint8Array, global}; +use worker::kv::KvStore as WorkerKvStore; +use worker::wasm_bindgen::closure::Closure; +use worker::wasm_bindgen::{JsCast as _, JsValue}; +use worker::wasm_bindgen_futures::JsFuture; +use worker::web_sys::ReadableStream; + +const CONFIG_READ_CHUNK_BYTES: usize = 0x4000; + +struct Acquisition(Rc>); + +#[derive(Default)] +struct AcquisitionState { + abandoned: bool, + missing: bool, + stream: Option, +} + +struct ConfigReader<'stream> { + completed: bool, + reader: ReadableStreamBYOBReader<'stream>, +} + +pub(super) struct KvBinding { + get: Function, + namespace: JsValue, + store: WorkerKvStore, +} + +impl Drop for Acquisition { + fn drop(&mut self) { + let acquired = { + let mut state = self.0.borrow_mut(); + state.abandoned = true; + state.stream.take() + }; + if let Some(stream) = acquired { + consume_cancellation(&stream.cancel()); + } + } +} + +impl Drop for ConfigReader<'_> { + fn drop(&mut self) { + if !self.completed { + consume_cancellation(&self.reader.as_raw().cancel()); + } + } +} + +impl KvBinding { + pub(super) fn from_this(env: &JsValue, name: &str) -> Result { + let store = WorkerKvStore::from_this(env, name).map_err(|_error| unavailable())?; + let namespace = Reflect::get(env, &JsValue::from(name)).map_err(|_error| unavailable())?; + let get = Reflect::get(&namespace, &JsValue::from("get")) + .map_err(|_error| unavailable())? + .dyn_into::() + .map_err(|_error| unavailable())?; + Ok(Self { + get, + namespace, + store, + }) + } + + pub(super) async fn materialized_get( + &self, + key: &str, + ) -> Result, ConfigStoreError> { + self.store + .get(key) + .text() + .await + .map_err(|_error| unavailable()) + } + + pub(super) async fn streaming_get( + &self, + key: &str, + max_bytes: u64, + clock: &MonotonicClock, + deadline: Deadline, + ) -> Result, ConfigStoreError> { + // Transferred callbacks require wasm-bindgen's JS-GC cleanup support. + // Older/explicitly disabled Workers profiles must fail before dispatch. + if Reflect::get(&global(), &JsValue::from("FinalizationRegistry")) + .map_err(|_error| unavailable())? + .dyn_into::() + .is_err() + { + return Err(unavailable()); + } + let options = Object::new(); + Reflect::set(&options, &JsValue::from("type"), &JsValue::from("stream")) + .map_err(|_error| unavailable())?; + let promise = self + .get + .call2(&self.namespace, &JsValue::from(key), &options) + .map_err(|_error| unavailable())? + .dyn_into::() + .map_err(|_error| unavailable())?; + let acquisition = Acquisition(Rc::new(RefCell::new(AcquisitionState::default()))); + let state = Rc::clone(&acquisition.0); + // The JS promise retains the callback even if the Rust waiter is dropped. + // Transfer the closure to JS GC ownership; no detached Rust cleanup task. + let resolved = Closure::new(move |value: JsValue| { + if value.is_null() { + state.borrow_mut().missing = true; + return; + } + if let Ok(stream) = value.dyn_into::() { + let mut acquired = state.borrow_mut(); + if acquired.abandoned { + drop(acquired); + consume_cancellation(&stream.cancel()); + } else { + acquired.stream = Some(stream); + } + } + }); + let observed = promise.then(&resolved); + drop(resolved.into_js_value()); + JsFuture::from(observed) + .await + .map_err(|_error| unavailable())?; + if acquisition.0.borrow().missing { + return Ok(None); + } + let raw = acquisition + .0 + .borrow_mut() + .stream + .take() + .ok_or_else(unavailable)?; + drain(raw, max_bytes, clock, deadline).await.map(Some) + } +} + +fn consume_cancellation(promise: &Promise) { + // Both branches must be handled: an ignored rejected cancel promise can + // otherwise publish secret-bearing provider diagnostics as an unhandled rejection. + let ignore = Closure::new(|_value: JsValue| {}); + drop(promise.then2(&ignore, &ignore)); + drop(ignore.into_js_value()); +} + +async fn drain( + raw: ReadableStream, + max_bytes: u64, + clock: &MonotonicClock, + deadline: Deadline, +) -> Result { + let mut stream = wasm_streams::ReadableStream::from_raw(raw.clone().unchecked_into()); + let reader = match stream.try_get_byob_reader() { + Ok(reader) => reader, + Err(_error) => { + consume_cancellation(&raw.cancel()); + return Err(unavailable()); + } + }; + let mut guard = ConfigReader { + completed: false, + reader, + }; + let limit = usize::try_from(max_bytes).unwrap_or(usize::MAX); + let mut bytes = Vec::new(); + let mut scratch = vec![0_u8; CONFIG_READ_CHUNK_BYTES]; + let mut buffer = Uint8Array::new_with_length(0x4000); + let mut pulls = 0_u32; + loop { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + let remaining = limit.saturating_sub(bytes.len()); + let wanted = remaining.saturating_add(1).min(CONFIG_READ_CHUNK_BYTES); + let (count, returned_buffer) = guard + .reader + .read_with_buffer(scratch.get_mut(..wanted).ok_or_else(unavailable)?, buffer) + .await + .map_err(|_error| unavailable())?; + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + buffer = returned_buffer.ok_or_else(unavailable)?; + if count == 0 { + guard.completed = true; + break; + } + if count > remaining { + return Err(ConfigStoreError::ValueTooLarge); + } + let next_len = bytes + .len() + .checked_add(count) + .ok_or(ConfigStoreError::ValueTooLarge)?; + if next_len > bytes.capacity() { + let capacity = next_len.max(bytes.capacity().saturating_mul(2)).min(limit); + bytes + .try_reserve_exact(capacity.saturating_sub(bytes.len())) + .map_err(|_error| ConfigStoreError::ValueTooLarge)?; + } + bytes.extend_from_slice(scratch.get(..count).ok_or_else(unavailable)?); + pulls = pulls.saturating_add(1); + if pulls == 16 { + // Workers may freeze its clock within a task. Yield to the task queue, + // not just the promise microtask queue, so timers can make progress. + Delay::from(Duration::ZERO).await; + pulls = 0; + } + } + String::from_utf8(bytes).map_err(|_error| unavailable()) +} + +fn unavailable() -> ConfigStoreError { + ConfigStoreError::unavailable("config KV byte stream is unavailable") +} diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index 6594fe61..562824c6 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -65,7 +65,7 @@ pub const CLOUDFLARE_PLATFORM: edgezero_core::PlatformMetadata = #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] fn build_app_for_dispatch() -> Result { build_target_app::().map_err(|error| { - log::error!("application configuration failed: {}", error.kind()); + log::error!(target: edgezero_core::BOOT_LOG_TARGET, "application configuration failed: {}", error.kind()); WorkerError::RustError("application configuration failed".to_owned()) }) } @@ -84,10 +84,9 @@ pub fn build_app_for_test() -> Result { } /// # Errors -/// Never; this is currently a no-op on Cloudflare Workers (Workers manages -/// its own logging). The signature still returns [`log::SetLoggerError`] so -/// callers and the non-wasm stub stay drop-in compatible if a real logger -/// is wired in later. +/// Never; this is a no-op. Workers console logging does not install a Rust +/// `log` backend. Applications needing facade output must install a backend +/// and its filters before [`run_app`] and set `Hooks::owns_logging()` to `true`. #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[inline] pub fn init_logger() -> Result<(), log::SetLoggerError> { @@ -168,13 +167,29 @@ pub async fn run_app( env: Env, ctx: Context, ) -> Result { - let app = build_app_for_dispatch::()?; - // Best-effort: if a logger is already installed, ignore the error rather - // than panicking — every Worker request re-enters this function. Skipped - // entirely when the app owns logging. if !A::owns_logging() { drop(init_logger()); } + let app = build_app_for_dispatch::()?; + dispatch_app::(&app, req, env, ctx).await +} + +/// Dispatch a caller-owned app through canonical admission and response egress. +/// +/// The app must be built from `A` with [`CLOUDFLARE_PLATFORM`]. This function does not +/// initialize logging or retain an app. Bindings and runtime variables are resolved +/// for every request; only application-owned, concurrency-safe state may be retained. +/// +/// # Errors +/// Returns category-safe configuration, admission-abort, or adapter delivery errors. +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[inline] +pub async fn dispatch_app( + app: &App, + req: Request, + env: Env, + ctx: Context, +) -> Result { let stores = A::stores(); let env_config = env_config_from_worker(&env, stores); let runtime_variables = match A::manifest() { @@ -192,7 +207,7 @@ pub async fn run_app( } }; request::dispatch_with_registries( - &app, + app, req, env, ctx, diff --git a/crates/edgezero-adapter-cloudflare/src/outbound.rs b/crates/edgezero-adapter-cloudflare/src/outbound.rs index b6f27355..c8e335a7 100644 --- a/crates/edgezero-adapter-cloudflare/src/outbound.rs +++ b/crates/edgezero-adapter-cloudflare/src/outbound.rs @@ -100,29 +100,23 @@ mod worker_impl { use async_trait::async_trait; use bytes::Bytes; use edgezero_core::body::{Body, BodyStream}; - use edgezero_core::compression::{ - ContentEncoding, classify_content_encoding, decode_brotli_stream, decode_deflate_stream, - decode_gzip_stream, - }; #[cfg(feature = "test-utils")] use edgezero_core::error::BudgetSource; use edgezero_core::error::{BadGatewayReason, EdgeError}; - use edgezero_core::http::header::{ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH, HOST}; + use edgezero_core::http::header::{ACCEPT_ENCODING, HOST}; use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode}; use edgezero_core::outbound::{ - OutboundBatch, OutboundBatchDriverEvent, OutboundCachePolicy, OutboundHttpClient, - OutboundRequest, OutboundRequestParts, OutboundResponse, ResponseBodyDisposition, - ResponseHeaderLimiter, ResponseMode, collect_response_stream, - enforce_payload_content_length, finish_batch_item, insert_proxy_header, - limit_decoded_stream, limit_encoded_stream, normalize_for_dispatch, - normalize_response_headers, rechunk_stream, validate_for_dispatch, + OutboundBatch, OutboundCachePolicy, OutboundHttpClient, OutboundRequest, + OutboundRequestParts, OutboundResponse, ResponseBodyDisposition, ResponseBodyPolicy, + ResponseHeaderLimiter, ResponseMode, apply_response_body_policy, collect_response_stream, + insert_proxy_header, normalize_for_dispatch, normalize_response_headers, + validate_for_dispatch, }; use edgezero_core::time::{ Deadline, DispatchBudget, MonotonicClock, MonotonicInstant, dispatch_budget, }; use futures_util::StreamExt as _; - use futures_util::future::{Either, FutureExt as _, LocalBoxFuture, poll_fn, select}; - use futures_util::stream::FuturesUnordered; + use futures_util::future::{Either, FutureExt as _, LocalBoxFuture, select}; use worker::js_sys::{Function, Reflect, Uint8Array, global}; use worker::wasm_bindgen::closure::Closure; use worker::wasm_bindgen::{JsCast as _, JsValue}; @@ -379,7 +373,7 @@ mod worker_impl { } let mut completed = Vec::new(); - let pending_slots = FuturesUnordered::>::new(); + let mut pending_slots = Vec::>::new(); for (index, request) in requests.into_iter().enumerate() { match Self::prepare_batch(request, batch_started_at, observation_cutoff) { Ok(prepared) => { @@ -396,46 +390,13 @@ mod worker_impl { } } - let completions = stream! { - let mut pending = pending_slots; - poll_fn(|context| { - loop { - match pending.poll_next_unpin(context) { - Poll::Ready(Some(item)) => completed.push(item), - Poll::Ready(None) | Poll::Pending => return Poll::Ready(()), - } - } - }).await; - - for (index, completed_at, outcome) in completed { - let Some(item) = finish_batch_item( - index, - batch_started_at, - completed_at, - observation_cutoff, - outcome, - ) else { - yield OutboundBatchDriverEvent::Cutoff; - return; - }; - yield OutboundBatchDriverEvent::Item(item); - } - - while let Some((index, completed_at, outcome)) = pending.next().await { - let Some(item) = finish_batch_item( - index, - batch_started_at, - completed_at, - observation_cutoff, - outcome, - ) else { - yield OutboundBatchDriverEvent::Cutoff; - return; - }; - yield OutboundBatchDriverEvent::Item(item); - } - }; - OutboundBatch::from_driver(slot_count, completions) + OutboundBatch::from_futures( + slot_count, + batch_started_at, + observation_cutoff, + completed, + pending_slots, + ) } } @@ -528,7 +489,6 @@ mod worker_impl { #[expect( clippy::too_many_arguments, - clippy::too_many_lines, reason = "the adapter consumes the independent request policy fields without hiding them" )] async fn process_response( @@ -607,44 +567,18 @@ mod worker_impl { } let native = response_stream(&mut response)?; - let encoding = classify_content_encoding(&headers); - let max_buffered = match response_mode { - ResponseMode::Buffered { max_bytes } => Some(max_bytes), - ResponseMode::Streamed => None, - }; - enforce_payload_content_length( - &headers, - &encoding, - max_buffered, - max_decoded_response_bytes, - max_encoded_response_bytes, + let shaped = apply_response_body_policy( + native, + &mut headers, + ResponseBodyPolicy { + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_decoder_bytes, + max_encoded_response_bytes, + response_mode, + }, )?; - let encoded = limit_encoded_stream(native, max_encoded_response_bytes); - let decoded = match &encoding { - ContentEncoding::Brotli => { - decode_brotli_stream(encoded, max_brotli_window_bits, max_decoder_bytes) - } - ContentEncoding::Deflate => decode_deflate_stream(encoded, max_decoder_bytes), - ContentEncoding::Gzip => decode_gzip_stream(encoded, max_decoder_bytes), - ContentEncoding::Identity | ContentEncoding::Passthrough(_) => encoded, - }; - if matches!( - &encoding, - ContentEncoding::Brotli | ContentEncoding::Deflate | ContentEncoding::Gzip - ) { - headers.remove(CONTENT_ENCODING); - headers.remove(CONTENT_LENGTH); - } - let output = match &encoding { - ContentEncoding::Brotli - | ContentEncoding::Deflate - | ContentEncoding::Gzip - | ContentEncoding::Identity => { - limit_decoded_stream(decoded, max_decoded_response_bytes) - } - ContentEncoding::Passthrough(_) => decoded, - }; - let shaped = rechunk_stream(output, max_chunk_bytes); let deadline_bound = deadline_stream(shaped, budget, clock, abort_guard); let body = match response_mode { ResponseMode::Buffered { max_bytes } => { diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 573877a3..bb58e0ed 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -1045,7 +1045,10 @@ mod synthesis_tests { struct StubConfig; #[async_trait::async_trait(?Send)] impl ConfigStore for StubConfig { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Ok(None) } @@ -1056,7 +1059,7 @@ mod synthesis_tests { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { + ) -> Result, ConfigStoreError> { if deadline.is_expired_at(clock.now()) { return Err(ConfigStoreError::DeadlineExceeded); } diff --git a/crates/edgezero-adapter-cloudflare/src/templates/wrangler.toml.hbs b/crates/edgezero-adapter-cloudflare/src/templates/wrangler.toml.hbs index d96c8f2e..766c3d0e 100644 --- a/crates/edgezero-adapter-cloudflare/src/templates/wrangler.toml.hbs +++ b/crates/edgezero-adapter-cloudflare/src/templates/wrangler.toml.hbs @@ -1,7 +1,7 @@ name = "{{proj_cloudflare}}" main = "build/worker/shim.mjs" compatibility_date = "2023-05-01" -compatibility_flags = ["enable_request_signal"] +compatibility_flags = ["enable_request_signal", "enable_weak_ref"] [build] command = "worker-build --release" diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index b4a6f2a0..e957e0cb 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -43,9 +43,7 @@ mod tests { #[cfg(feature = "test-utils")] use edgezero_adapter_cloudflare::request::deadline_body_releases_source_for_test; use edgezero_adapter_cloudflare::request::{CloudflareService, into_core_request}; - use edgezero_core::app::App; - #[cfg(feature = "test-utils")] - use edgezero_core::app::Hooks; + use edgezero_core::app::{App, Hooks}; use edgezero_core::body::Body; use edgezero_core::config_store::{ BoundedStoreRead, ConfigStore, ConfigStoreError, ConfigStoreHandle, @@ -102,8 +100,11 @@ mod tests { #[async_trait::async_trait(?Send)] impl ConfigStore for FixedConfigStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.to_owned())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.into())) } async fn get_bounded( @@ -113,7 +114,7 @@ mod tests { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { + ) -> Result, ConfigStoreError> { if deadline.is_expired_at(clock.now()) { return Err(ConfigStoreError::DeadlineExceeded); } @@ -185,7 +186,7 @@ mod tests { .await .ok() .flatten() - .unwrap_or_else(|| "missing".to_owned()), + .map_or_else(|| "missing".to_owned(), |value| value.to_string()), None => "missing".to_owned(), }; let response = response_builder() @@ -246,6 +247,33 @@ mod tests { assert!(deadline_body_releases_source_for_test()); } + #[wasm_bindgen_test] + async fn dispatch_app_reuses_router_with_fresh_request_bodies() { + struct TestApp; + // This fixture deliberately exercises the default Hooks metadata. + #[expect( + clippy::missing_trait_methods, + reason = "exercise default Hooks metadata and construction" + )] + impl Hooks for TestApp { + fn routes() -> RouterService { + build_test_app().router().clone() + } + } + let app = App::build::(edgezero_adapter_cloudflare::CLOUDFLARE_PLATFORM) + .expect("prebuilt application"); + for body in [b"first".as_slice(), b"second".as_slice()] { + let (env, ctx) = test_env_ctx(); + let request = cf_request(CfMethod::Post, "/mirror", Some(body)); + let mut response = + edgezero_adapter_cloudflare::dispatch_app::(&app, request, env, ctx) + .await + .expect("prebuilt dispatch response"); + assert_eq!(response.status_code(), 200); + assert_eq!(response.bytes().await.expect("response bytes"), body); + } + } + #[wasm_bindgen_test] async fn dispatch_passes_request_body_to_handlers() { let app = build_test_app(); diff --git a/crates/edgezero-adapter-fastly/Cargo.toml b/crates/edgezero-adapter-fastly/Cargo.toml index 311db788..c9b94bd6 100644 --- a/crates/edgezero-adapter-fastly/Cargo.toml +++ b/crates/edgezero-adapter-fastly/Cargo.toml @@ -44,7 +44,7 @@ log-fastly = { workspace = true, optional = true } fern = { workspace = true } chrono = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } +serde_json = { workspace = true, features = ["raw_value"] } sha2 = { workspace = true } thiserror = { workspace = true } toml_edit = { workspace = true, optional = true } @@ -54,3 +54,6 @@ walkdir = { workspace = true, optional = true } edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } handlebars = { workspace = true } tempfile = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +log-fastly = { workspace = true, features = ["native-test-stubs"] } diff --git a/crates/edgezero-adapter-fastly/src/chunked_config.rs b/crates/edgezero-adapter-fastly/src/chunked_config.rs index 1e35c128..cc9591ef 100644 --- a/crates/edgezero-adapter-fastly/src/chunked_config.rs +++ b/crates/edgezero-adapter-fastly/src/chunked_config.rs @@ -19,7 +19,10 @@ //! } //! ``` +use serde::de::{IgnoredAny, MapAccess, Visitor}; +use serde_json::value::RawValue; use sha2::{Digest as _, Sha256}; +use std::fmt; #[cfg(any(feature = "fastly", test))] use edgezero_core::config_store::ConfigStoreError; @@ -100,6 +103,17 @@ enum RootValueKind { UnknownKind, } +// Borrow only the two discriminator tokens. Skipped payloads must never become +// a Value tree before the extractor's application-owned structural preflight. +#[derive(Default)] +struct RootMetadata<'raw> { + ambiguous: bool, + edgezero_kind: Option<&'raw RawValue>, + version: Option<&'raw RawValue>, +} + +struct MetadataVisitor; + /// Why resolving a stored root value failed. Keeping the future-format case /// DISTINCT from corruption is the whole point: a newer format must never be /// overwritten (CLI) or read as repairable corruption, whereas genuinely corrupt @@ -187,6 +201,88 @@ pub(crate) enum GcRootValue { // Public helpers // --------------------------------------------------------------------------- +impl<'de> serde::Deserialize<'de> for RootMetadata<'de> { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_map(MetadataVisitor) + } + + fn deserialize_in_place>( + deserializer: D, + place: &mut Self, + ) -> Result<(), D::Error> { + *place = Self::deserialize(deserializer)?; + Ok(()) + } +} + +#[expect( + clippy::missing_trait_methods, + reason = "deserialize_map only dispatches JSON objects to this metadata visitor" +)] +impl<'de> Visitor<'de> for MetadataVisitor { + type Value = RootMetadata<'de>; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a config metadata object") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut metadata = RootMetadata::default(); + while let Some(key) = map.next_key::()? { + match key.as_str() { + "edgezero_kind" => { + metadata.ambiguous |= metadata.edgezero_kind.is_some(); + metadata.edgezero_kind = Some(map.next_value()?); + } + "version" => { + metadata.ambiguous |= metadata.version.is_some(); + metadata.version = Some(map.next_value()?); + } + _ => { + map.next_value::()?; + } + } + } + Ok(metadata) + } +} + +#[cfg(any(feature = "fastly", test))] +fn check_bounded_root( + root_key: &str, + root_value: &str, + remaining_backend: u64, + max_value_bytes: u64, +) -> Result<(), BoundedResolveFailure> { + match classify_root_value(root_value) { + RootValueKind::Pointer + if root_value.len() <= FASTLY_CONFIG_ENTRY_LIMIT + && !value_is_future_format(root_value) => + { + let pointer: FastlyChunkPointer = + serde_json::from_str(root_value).map_err(|_error| { + BoundedResolveFailure::Resolve(ResolveFailure::Corrupt( + "config chunk pointer is malformed".to_owned(), + )) + })?; + validate_pointer_chunks(root_key, &pointer) + .map_err(|error| BoundedResolveFailure::Resolve(ResolveFailure::Corrupt(error)))?; + let envelope_bytes = u64::try_from(pointer.envelope_len) + .map_err(|_error| BoundedResolveFailure::ValueTooLarge)?; + if envelope_bytes > max_value_bytes || envelope_bytes > remaining_backend { + return Err(BoundedResolveFailure::ValueTooLarge); + } + } + RootValueKind::Foreign | RootValueKind::MalformedObject => { + if u64::try_from(root_value.len()).map_or(true, |length| length > max_value_bytes) { + return Err(BoundedResolveFailure::ValueTooLarge); + } + } + RootValueKind::Pointer | RootValueKind::UnknownKind => {} + } + Ok(()) +} + /// Account for the exact bytes materialized by a Fastly store operation. #[cfg(any(feature = "fastly", test))] pub(crate) fn exact_fastly_read( @@ -506,9 +602,21 @@ pub(crate) fn chunk_lengths(chunks: &[GcChunkRef]) -> Vec { /// not be overwritten) from repairable corruption. [`resolve_fastly_config_value`] /// is the string-erased wrapper for callers that do not need the distinction. pub(crate) fn resolve_fastly_config_value_typed( + root_key: &str, + root_value: String, + fetch: F, +) -> Result +where + F: FnMut(&str) -> Result, String>, +{ + resolve_fastly_config_value_inner(root_key, root_value, fetch, true) +} + +fn resolve_fastly_config_value_inner( root_key: &str, root_value: String, mut fetch: F, + verify_inner_envelope: bool, ) -> Result where F: FnMut(&str) -> Result, String>, @@ -588,11 +696,14 @@ where // Fetch, verify, and concatenate all chunks. // - // NOT `with_capacity(pointer.envelope_len)`: that length is untrusted stored - // metadata, and this runs in the edge guest. A pointer declaring `usize::MAX` - // would abort the worker on a capacity overflow before any of the checks - // below could reject it. Grow from the bytes we actually fetch instead. + // Metadata has already passed checked length summation and per-chunk caps. + // The bounded caller also checks both aggregate allowances before this point. let mut reconstructed = String::new(); + reconstructed + .try_reserve_exact(pointer.envelope_len) + .map_err(|_error| { + ResolveFailure::Corrupt("config reassembly allocation failed".to_owned()) + })?; for (position, chunk_ref) in pointer.chunks.iter().enumerate() { let chunk_value = fetch(&chunk_ref.key) .map_err(|_err| { @@ -642,8 +753,7 @@ where } // Verify total envelope SHA. - let actual_env_sha = sha256_hex(reconstructed.as_bytes()); - if actual_env_sha != pointer.envelope_sha256 { + if sha256_hex(reconstructed.as_bytes()) != pointer.envelope_sha256 { // Neither SHA is echoed: the expected one is `pointer.envelope_sha256`, // a stored, value-controlled string that a malformed pointer can set to // anything (a secret), and this runs on the read path where diagnostics @@ -658,7 +768,14 @@ where verify_writer_split_layout(root_key, &reconstructed, &declared_lens) .map_err(ResolveFailure::Corrupt)?; - finalize_reconstructed_envelope(root_key, reconstructed) + if verify_inner_envelope { + finalize_reconstructed_envelope(root_key, reconstructed) + } else { + // This path verifies storage transport integrity, not typed config. + // The extractor owns envelope verification after its structural scan, + // exactly as for a directly stored envelope. + Ok(reconstructed) + } } /// Resolve a Fastly value while accounting for every materialized store byte. @@ -694,72 +811,92 @@ where return Err(BoundedResolveFailure::ValueTooLarge); } + let preflight = check_bounded_root( + root_key, + &root_value, + max_backend_bytes.saturating_sub(root_backend_bytes), + max_value_bytes, + ); + if deadline.is_expired_at(clock.now()) { + return Err(BoundedResolveFailure::DeadlineExceeded); + } + preflight?; + let mut backend_bytes = root_backend_bytes; let mut chunk_value_bytes = 0_u64; let mut boundary_error = None; - let resolve_outcome = resolve_fastly_config_value_typed(root_key, root_value, |chunk_key| { - if deadline.is_expired_at(clock.now()) { - boundary_error = Some(BoundedResolveFailure::DeadlineExceeded); - return Err("bounded config read failed".to_owned()); - } - - let Some(remaining_backend_bytes) = max_backend_bytes.checked_sub(backend_bytes) else { - boundary_error = Some(BoundedResolveFailure::ValueTooLarge); - return Err("bounded config read failed".to_owned()); - }; - let read = match fetch(chunk_key, remaining_backend_bytes) { - Ok(read) => read, - Err(error) => { - boundary_error = Some(BoundedResolveFailure::Backend(error)); + let resolve_outcome = resolve_fastly_config_value_inner( + root_key, + root_value, + |chunk_key| { + if deadline.is_expired_at(clock.now()) { + boundary_error = Some(BoundedResolveFailure::DeadlineExceeded); return Err("bounded config read failed".to_owned()); } - }; - - if deadline.is_expired_at(clock.now()) { - boundary_error = Some(BoundedResolveFailure::DeadlineExceeded); - return Err("bounded config read failed".to_owned()); - } - let value_bytes = match read.value.as_ref() { - Some(value) => { - let Ok(value_bytes) = u64::try_from(value.len()) else { - boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + let Some(remaining_backend_bytes) = max_backend_bytes.checked_sub(backend_bytes) else { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + }; + let remaining_value_bytes = max_value_bytes.saturating_sub(chunk_value_bytes); + let read_limit = remaining_backend_bytes + .min(remaining_value_bytes) + .min(7_000); + let read = match fetch(chunk_key, read_limit) { + Ok(read) => read, + Err(error) => { + boundary_error = Some(BoundedResolveFailure::Backend(error)); return Err("bounded config read failed".to_owned()); - }; - value_bytes + } + }; + + if deadline.is_expired_at(clock.now()) { + boundary_error = Some(BoundedResolveFailure::DeadlineExceeded); + return Err("bounded config read failed".to_owned()); } - None => 0, - }; - if read.backend_bytes != value_bytes || read.backend_bytes > remaining_backend_bytes { - boundary_error = Some(BoundedResolveFailure::ValueTooLarge); - return Err("bounded config read failed".to_owned()); - } - let Some(next_backend_bytes) = backend_bytes.checked_add(read.backend_bytes) else { - boundary_error = Some(BoundedResolveFailure::ValueTooLarge); - return Err("bounded config read failed".to_owned()); - }; - let Some(next_chunk_value_bytes) = chunk_value_bytes.checked_add(value_bytes) else { - boundary_error = Some(BoundedResolveFailure::ValueTooLarge); - return Err("bounded config read failed".to_owned()); - }; - if next_backend_bytes > max_backend_bytes || next_chunk_value_bytes > max_value_bytes { - boundary_error = Some(BoundedResolveFailure::ValueTooLarge); - return Err("bounded config read failed".to_owned()); - } + let value_bytes = match read.value.as_ref() { + Some(value) => { + let Ok(value_bytes) = u64::try_from(value.len()) else { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + }; + value_bytes + } + None => 0, + }; + if read.backend_bytes != value_bytes || read.backend_bytes > read_limit { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + } - backend_bytes = next_backend_bytes; - chunk_value_bytes = next_chunk_value_bytes; - Ok(read.value) - }); + let Some(next_backend_bytes) = backend_bytes.checked_add(read.backend_bytes) else { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + }; + let Some(next_chunk_value_bytes) = chunk_value_bytes.checked_add(value_bytes) else { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + }; + if next_backend_bytes > max_backend_bytes || next_chunk_value_bytes > max_value_bytes { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + } + backend_bytes = next_backend_bytes; + chunk_value_bytes = next_chunk_value_bytes; + Ok(read.value) + }, + false, + ); + + if deadline.is_expired_at(clock.now()) { + return Err(BoundedResolveFailure::DeadlineExceeded); + } if let Some(error) = boundary_error { return Err(error); } let resolved_value = resolve_outcome.map_err(BoundedResolveFailure::Resolve)?; - if deadline.is_expired_at(clock.now()) { - return Err(BoundedResolveFailure::DeadlineExceeded); - } let resolved_bytes = u64::try_from(resolved_value.len()) .map_err(|_length_error| BoundedResolveFailure::ValueTooLarge)?; if resolved_bytes > max_value_bytes { @@ -1052,17 +1189,25 @@ fn classify_root_value(raw: &str) -> RootValueKind { } // It LOOKS like an object. If it does not parse, it is a malformed object -- // possibly a truncated/corrupt pointer -- NOT a value GC may assume is inert. - let Ok(value) = serde_json::from_str::(raw) else { + let Ok(value) = serde_json::from_str::>(raw) else { return RootValueKind::MalformedObject; }; + if value.ambiguous { + return RootValueKind::UnknownKind; + } // Discriminate on the PRESENCE of `edgezero_kind`, not just a string match. A // field of ANY type claims our reserved namespace, so a non-string value // (`edgezero_kind: 7`, an object, null) is `UnknownKind`, never `Foreign` -- // otherwise GC would wrongly treat it as an inert value and reclaim chunks a // future/foreign format still references. - match value.get("edgezero_kind") { + match value.edgezero_kind { None => RootValueKind::Foreign, - Some(serde_json::Value::String(kind)) if kind == POINTER_KIND => RootValueKind::Pointer, + Some(kind) + if serde_json::from_str::(kind.get()) + .is_ok_and(|token| token == POINTER_KIND) => + { + RootValueKind::Pointer + } Some(_) => RootValueKind::UnknownKind, } } @@ -1140,19 +1285,27 @@ pub(crate) fn value_is_unknown_kind(raw: &str) -> bool { /// the resolver is compiled regardless of feature set. pub(crate) fn value_is_future_format(raw: &str) -> bool { use edgezero_core::blob_envelope::ENVELOPE_VERSION_V1; - let Ok(serde_json::Value::Object(obj)) = serde_json::from_str::(raw) else { + let Ok(obj) = serde_json::from_str::>(raw) else { return false; }; + if obj.ambiguous { + return true; + } // A `version` field that is PRESENT but not EXACTLY the JSON integer 1 is a // newer format. Keying on `as_u64()` alone fails open: `"2"`, `-1`, `2.5`, or // `1.0` all yield `None` and would slip through as repairable corruption. Any // present-and-not-integer-1 version fails closed. - let version_is_not_one = obj - .get("version") - .is_some_and(|version| version.as_u64() != Some(u64::from(ENVELOPE_VERSION_V1))); - match obj.get("edgezero_kind") { + let version_is_not_one = obj.version.is_some_and(|version| { + serde_json::from_str::(version.get()).ok() != Some(u64::from(ENVELOPE_VERSION_V1)) + }); + match obj.edgezero_kind { // Our pointer kind, but a newer POINTER version. - Some(serde_json::Value::String(kind)) if kind == POINTER_KIND => version_is_not_one, + Some(kind) + if serde_json::from_str::(kind.get()) + .is_ok_and(|token| token == POINTER_KIND) => + { + version_is_not_one + } // A discriminator we do not recognise (unknown string, or non-string). Some(_) => true, // No discriminator: any object carrying a bumped `version` is a newer @@ -1526,6 +1679,64 @@ mod tests { assert_eq!(wrapper_result, "materialized"); } + #[test] + fn metadata_classification_rejects_duplicate_reserved_fields_and_arrays() { + // Duplicate JSON members intentionally cannot be constructed with json!. + for raw in [ + r#"{"version":1,"version":2}"#, + r#"{"version":1,"version":1}"#, + r#"{"edgezero_kind":"fastly_config_chunks","edgezero_kind":"future"}"#, + ] { + assert!( + value_is_future_format(raw), + "ambiguous metadata must never be overwrite-safe" + ); + } + assert!( + !value_is_future_format("[null,2]"), + "arrays are not envelope metadata objects" + ); + } + + #[test] + fn bounded_resolver_rejects_declared_overflow_before_fetching() { + let root_key = "app_config"; + let envelope = serde_json::to_string(&BlobEnvelope::new( + serde_json::json!({ "pad": "x".repeat(9_000) }), + "2026-01-01T00:00:00Z".to_owned(), + )) + .expect("envelope"); + let mut entries = prepare_fastly_config_entries(root_key, &envelope).expect("entries"); + let (_, pointer) = entries.pop().expect("pointer"); + let pointer_bytes = u64::try_from(pointer.len()).expect("pointer bytes"); + let envelope_bytes = u64::try_from(envelope.len()).expect("envelope bytes"); + for (backend_limit, value_limit) in [ + (u64::MAX, envelope_bytes - 1), + (pointer_bytes + envelope_bytes - 1, envelope_bytes), + ] { + let mut calls = 0_u32; + let error = resolve_fastly_config_value_typed_bounded( + root_key, + pointer.clone(), + pointer_bytes, + &MonotonicClock::default(), + Deadline::after(Duration::from_secs(1)), + backend_limit, + value_limit, + |_key, _remaining| { + calls += 1; + Ok(BoundedStoreRead { + backend_bytes: 0, + value: None, + }) + }, + ) + .expect_err("declared overflow"); + assert!(matches!(error, BoundedResolveFailure::ValueTooLarge)); + assert_eq!(calls, 0, "reject metadata before fetching or assembling"); + } + } + #[test] fn bounded_resolver_accepts_exact_direct_caps_and_rejects_over_cap() { let clock = MonotonicClock::default(); @@ -1571,6 +1782,35 @@ mod tests { assert!(matches!(over_value, BoundedResolveFailure::ValueTooLarge)); } + #[test] + fn bounded_resolver_deadline_wins_metadata_preflight_errors() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let start = edgezero_core::MonotonicInstant::now(); + let end = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let samples = Arc::new(AtomicUsize::new(0)); + let clock = MonotonicClock::new(move || { + if samples.fetch_add(1, Ordering::SeqCst) == 0 { + start + } else { + end + } + }); + let error = resolve_fastly_config_value_typed_bounded( + "root", + "value".to_owned(), + 5, + &clock, + Deadline::at_instant(end), + 5, + 4, + unexpected_chunk_fetch, + ) + .expect_err("deadline must win a simultaneously observed size failure"); + assert!(matches!(error, BoundedResolveFailure::DeadlineExceeded)); + } + #[test] fn bounded_resolver_rejects_expired_deadline_without_fetching() { let mut fetched = false; @@ -1642,7 +1882,7 @@ mod tests { assert_eq!(resolved.backend_bytes, total_backend_bytes); assert_eq!(resolved.value.as_deref(), Some(envelope.as_str())); - assert_eq!(remaining_allowances.first(), Some(&chunk_bytes)); + assert_eq!(remaining_allowances.first(), Some(&chunk_bytes.min(7_000))); assert!( remaining_allowances .windows(2) diff --git a/crates/edgezero-adapter-fastly/src/config_store.rs b/crates/edgezero-adapter-fastly/src/config_store.rs index 1431f4ed..3b31c78f 100644 --- a/crates/edgezero-adapter-fastly/src/config_store.rs +++ b/crates/edgezero-adapter-fastly/src/config_store.rs @@ -1,4 +1,4 @@ -//! Fastly adapter config store: wraps `fastly::ConfigStore`. +//! Fastly config reads use one caller-sized host buffer, without SDK retries. use std::cell::Cell; #[cfg(test)] @@ -12,8 +12,12 @@ use crate::chunked_config::{ use async_trait::async_trait; use edgezero_core::config_store::{BoundedStoreRead, ConfigStore, ConfigStoreError}; use edgezero_core::{Deadline, MonotonicClock}; -use fastly::ConfigStore as FastlyConfigStoreInner; -use fastly::config_store::{LookupError, OpenError}; +use fastly_shared::FastlyStatus; +use fastly_sys::fastly_config_store; + +// A physical entry is at most 8,000 Unicode scalars. This adapter also imposes +// a finite byte ceiling independently of changes to the provider's quota. +const MAX_ENTRY_BYTES: u64 = 32_000; /// Config store backed by a Fastly Config Store resource link. pub struct FastlyConfigStore { @@ -21,7 +25,7 @@ pub struct FastlyConfigStore { } enum FastlyConfigStoreBackend { - Fastly(FastlyConfigStoreInner), + Fastly(u32), #[cfg(test)] InMemory(HashMap), } @@ -34,16 +38,65 @@ impl FastlyConfigStore { } } + #[expect( + unsafe_code, + reason = "the pinned ABI receives a live initialized slice and cannot resize it" + )] + fn read_entry(&self, key: &str, limit: u64) -> Result, ConfigStoreError> { + let read_limit = limit.min(MAX_ENTRY_BYTES); + match &self.inner { + FastlyConfigStoreBackend::Fastly(handle) => { + read_host_buffer(read_limit, |buffer| { + let mut written = 0; + // SAFETY: host output is confined to this initialized slice; + // reported lengths are validated before indexing or truncation. + let status = unsafe { + fastly_config_store::get( + *handle, + key.as_ptr(), + key.len(), + buffer.as_mut_ptr(), + buffer.len(), + &raw mut written, + ) + }; + (status, written) + }) + } + #[cfg(test)] + FastlyConfigStoreBackend::InMemory(data) => match data.get(key) { + Some(value) + if u64::try_from(value.len()).map_or(true, |length| length > read_limit) => + { + Err(ConfigStoreError::ValueTooLarge) + } + value => Ok(value.cloned()), + }, + } + } + /// Open a Fastly Config Store by resource link name. /// - /// Returns an error if the configured store cannot be opened. - /// /// # Errors - /// Returns the underlying [`fastly::config_store::OpenError`] when the named store does not exist or cannot be opened. + /// Returns a redacted typed failure if the host cannot open the store. #[inline] - pub fn try_open(name: &str) -> Result { - FastlyConfigStoreInner::try_open(name).map(|inner| Self { - inner: FastlyConfigStoreBackend::Fastly(inner), + #[expect( + unsafe_code, + reason = "the pinned host ABI writes only the supplied handle out-parameter" + )] + pub fn try_open(name: &str) -> Result { + let mut handle = fastly_shared::INVALID_CONFIG_STORE_HANDLE; + // SAFETY: the UTF-8 name slice and initialized out-parameter remain live + // for this synchronous call. Config Store exposes no close operation. + let status = + unsafe { fastly_config_store::open(name.as_ptr(), name.len(), &raw mut handle) }; + if status != FastlyStatus::OK || handle == fastly_shared::INVALID_CONFIG_STORE_HANDLE { + return Err(ConfigStoreError::unavailable( + "config store could not be opened", + )); + } + Ok(Self { + inner: FastlyConfigStoreBackend::Fastly(handle), }) } } @@ -51,14 +104,8 @@ impl FastlyConfigStore { #[async_trait(?Send)] impl ConfigStore for FastlyConfigStore { #[inline] - async fn get(&self, key: &str) -> Result, ConfigStoreError> { - let root_value = match &self.inner { - FastlyConfigStoreBackend::Fastly(inner) => { - inner.try_get(key).map_err(|err| map_lookup_error(&err))? - } - #[cfg(test)] - FastlyConfigStoreBackend::InMemory(data) => data.get(key).cloned(), - }; + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + let root_value = self.read_entry(key, MAX_ENTRY_BYTES)?; let Some(value) = root_value else { return Ok(None); }; @@ -95,20 +142,14 @@ impl ConfigStore for FastlyConfigStore { // here, because those ARE our reserved namespace. let transient = Cell::new(false); let outcome = resolve_fastly_config_value_typed(key, value, |chunk_key| { - let got = match &self.inner { - FastlyConfigStoreBackend::Fastly(inner) => { - inner.try_get(chunk_key).map_err(|err| { - if is_transient_lookup(&err) { - transient.set(true); - } - // The pointer-controlled chunk key is not echoed (the resolver - // adds a safe position locator); the SDK `err` carries no value. - format!("config store lookup failed: {err}") - })? - } - #[cfg(test)] - FastlyConfigStoreBackend::InMemory(data) => data.get(chunk_key).cloned(), - }; + let got = self + .read_entry(chunk_key, MAX_ENTRY_BYTES) + .map_err(|error| { + if !matches!(error, ConfigStoreError::InvalidKey { .. }) { + transient.set(true); + } + "config store chunk read failed".to_owned() + })?; if got.is_none() { // Referenced chunk absent at this POP: treat as propagation lag // (transient) rather than corruption. @@ -117,6 +158,7 @@ impl ConfigStore for FastlyConfigStore { Ok(got) }); outcome + .map(edgezero_core::ConfigValue::from) .map(Some) .map_err(|error| map_resolve_failure(key, transient.get(), error)) } @@ -129,21 +171,18 @@ impl ConfigStore for FastlyConfigStore { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { - let materialized_root = run_sync_host_call(clock, deadline, || { - Ok(match &self.inner { - FastlyConfigStoreBackend::Fastly(inner) => { - inner.try_get(key).map_err(|err| map_lookup_error(&err))? - } - #[cfg(test)] - FastlyConfigStoreBackend::InMemory(data) => data.get(key).cloned(), - }) - }) - .map_err(map_sync_config_error)?; + ) -> Result, ConfigStoreError> { + let root_limit = max_backend_bytes.min(max_value_bytes.max(8_000)); + let materialized_root = + run_sync_host_call(clock, deadline, || self.read_entry(key, root_limit)) + .map_err(map_sync_config_error)?; let root_read = exact_fastly_read(materialized_root, max_backend_bytes) .map_err(|_size_error| ConfigStoreError::ValueTooLarge)?; let Some(root_value) = root_read.value else { - return Ok(root_read); + return Ok(BoundedStoreRead { + backend_bytes: root_read.backend_bytes, + value: None, + }); }; let transient = Cell::new(false); @@ -157,24 +196,7 @@ impl ConfigStore for FastlyConfigStore { max_value_bytes, |chunk_key, remaining_backend_bytes| { let chunk_value = run_sync_host_call(clock, deadline, || { - Ok(match &self.inner { - FastlyConfigStoreBackend::Fastly(inner) => { - inner.try_get(chunk_key).map_err(|err| { - if is_transient_lookup(&err) { - transient.set(true); - ConfigStoreError::unavailable( - "config store temporarily unavailable", - ) - } else { - ConfigStoreError::internal(anyhow::anyhow!( - "config store chunk key is invalid" - )) - } - })? - } - #[cfg(test)] - FastlyConfigStoreBackend::InMemory(data) => data.get(chunk_key).cloned(), - }) + self.read_entry(chunk_key, remaining_backend_bytes) }) .map_err(map_sync_config_error)?; if chunk_value.is_none() { @@ -185,8 +207,20 @@ impl ConfigStore for FastlyConfigStore { }, ); + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } match outcome { - Ok(read) => Ok(read), + Ok(read) => { + let value = read.value.map(Into::into); + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + Ok(BoundedStoreRead { + backend_bytes: read.backend_bytes, + value, + }) + } Err(BoundedResolveFailure::Backend(error)) => Err(error), Err(BoundedResolveFailure::DeadlineExceeded) => Err(ConfigStoreError::DeadlineExceeded), Err(BoundedResolveFailure::Resolve(error)) => { @@ -197,6 +231,33 @@ impl ConfigStore for FastlyConfigStore { } } +fn read_host_buffer( + limit: u64, + read: impl FnOnce(&mut [u8]) -> (FastlyStatus, usize), +) -> Result, ConfigStoreError> { + let length = usize::try_from(limit).map_err(|_error| ConfigStoreError::ValueTooLarge)?; + let mut buffer = Vec::new(); + buffer + .try_reserve_exact(length) + .map_err(|_error| ConfigStoreError::ValueTooLarge)?; + buffer.resize(length, 0); + let (status, written) = read(&mut buffer); + match status { + FastlyStatus::OK if written <= buffer.len() => { + buffer.truncate(written); + String::from_utf8(buffer) + .map(Some) + .map_err(|_error| ConfigStoreError::unavailable("config value is not UTF-8")) + } + FastlyStatus::NONE => Ok(None), + FastlyStatus::BUFLEN => Err(ConfigStoreError::ValueTooLarge), + FastlyStatus::INVAL | FastlyStatus::UNSUPPORTED => { + Err(ConfigStoreError::invalid_key("invalid config key")) + } + _ => Err(ConfigStoreError::unavailable("config store read failed")), + } +} + fn map_sync_config_error(call_error: SyncHostCallError) -> ConfigStoreError { match call_error { SyncHostCallError::Backend(backend_error) => backend_error, @@ -235,38 +296,6 @@ fn map_resolve_failure(key: &str, transient: bool, error: ResolveFailure) -> Con } } -/// Is a CHUNK lookup failure environmental (retry) rather than corrupt config -/// (`config push` to repair)? Only a bad KEY names corrupt state a re-push -/// rewrites; everything else — an invalid store handle, lookup exhaustion, an -/// unclassified/future failure, or a value that outgrew the read buffer — is -/// transient, because re-pushing cannot fix a request-scoped condition. -fn is_transient_lookup(err: &LookupError) -> bool { - // `ValueTooLong` is TRANSIENT, not corruption: the SDK already retried with - // the reported buffer size, so a `ValueTooLong` reaching us means the value - // GREW between host calls (a concurrent write) — a race a retry resolves, not - // a re-push-me corruption. - !matches!(err, LookupError::KeyInvalid | LookupError::KeyTooLong) -} - -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" - )] - match err { - LookupError::KeyInvalid | LookupError::KeyTooLong => { - ConfigStoreError::invalid_key("invalid config key") - } - _ => { - log::warn!("Fastly config store lookup failed: {err}"); - ConfigStoreError::unavailable("config store temporarily unavailable") - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -349,31 +378,64 @@ mod tests { } #[test] - fn key_invalid_maps_to_invalid_key_error() { - let err = map_lookup_error(&LookupError::KeyInvalid); - assert!(matches!(err, ConfigStoreError::InvalidKey { .. })); + fn fixture_matches_physical_entry_byte_ceiling() { + let store = FastlyConfigStore::from_entries([("key".to_owned(), "x".repeat(32_001))]); + let error = store.read_entry("key", u64::MAX).expect_err("physical cap"); + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); } #[test] - fn key_too_long_maps_to_invalid_key_error() { - let err = map_lookup_error(&LookupError::KeyTooLong); - assert!(matches!(err, ConfigStoreError::InvalidKey { .. })); + fn host_buffer_accepts_exact_cap_without_copying_the_string() { + let value = read_host_buffer(4, |buffer| { + assert_eq!(buffer.len(), 4); + buffer.copy_from_slice(b"test"); + (FastlyStatus::OK, 4) + }) + .expect("exact cap"); + assert_eq!(value.as_deref(), Some("test")); } - /// A CHUNK lookup that fails transiently (lookup exhaustion, an invalid - /// store handle, an unclassified error, or a value that outgrew the read - /// buffer) must keep Unavailable semantics — re-pushing cannot repair a - /// request-scoped condition. Only a bad KEY is corrupt state a re-push fixes. #[test] - fn transient_chunk_lookups_are_not_treated_as_corruption() { - assert!(is_transient_lookup(&LookupError::TooManyLookups)); - assert!(is_transient_lookup(&LookupError::ConfigStoreInvalid)); - assert!(is_transient_lookup(&LookupError::Other)); - // ValueTooLong is TRANSIENT: the SDK already retried at the reported size, - // so it reaching us means the value grew between host calls (a race). - assert!(is_transient_lookup(&LookupError::ValueTooLong)); - assert!(!is_transient_lookup(&LookupError::KeyInvalid)); - assert!(!is_transient_lookup(&LookupError::KeyTooLong)); + fn host_buffer_never_retries_or_allocates_the_reported_oversize_length() { + for limit in [0, 1, 7_000] { + let calls = Cell::new(0_u32); + let error = read_host_buffer(limit, |buffer| { + calls.set(calls.get() + 1); + assert_eq!(u64::try_from(buffer.len()).expect("buffer length"), limit); + (FastlyStatus::BUFLEN, usize::MAX) + }) + .expect_err("oversize"); + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); + assert_eq!(calls.get(), 1); + } + } + + #[test] + fn host_buffer_status_and_malformed_results_are_typed_and_redacted() { + for status in [FastlyStatus::INVAL, FastlyStatus::UNSUPPORTED] { + let error = read_host_buffer(1, |_buffer| (status, 0)).expect_err("key failure"); + assert!(matches!(error, ConfigStoreError::InvalidKey { .. })); + } + for status in [ + FastlyStatus::ERROR, + FastlyStatus::BADF, + FastlyStatus::LIMITEXCEEDED, + FastlyStatus::OK, + ] { + let error = read_host_buffer(1, |_buffer| (status, 2)).expect_err("bad result"); + assert!(matches!(error, ConfigStoreError::Unavailable { .. })); + } + assert!( + read_host_buffer(0, |_buffer| (FastlyStatus::NONE, 0)) + .expect("missing") + .is_none() + ); + let error = read_host_buffer(1, |buffer| { + buffer.fill(255); + (FastlyStatus::OK, 1) + }) + .expect_err("invalid UTF-8"); + assert!(matches!(error, ConfigStoreError::Unavailable { .. })); } /// A referenced chunk that is ABSENT maps to Unavailable (HTTP 503), not diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index da369c12..53694eda 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -1,6 +1,14 @@ //! Utilities for bridging Fastly Compute@Edge requests into the //! `edgezero-core` service abstractions. +#![cfg_attr( + feature = "fastly", + expect( + clippy::pub_use, + reason = "reuse the platform serving limits and summary" + ) +)] + #[cfg(feature = "fastly")] use anyhow::Context as _; @@ -20,7 +28,8 @@ pub mod config_store; pub mod context; #[cfg(feature = "fastly")] pub mod key_value_store; -#[cfg(feature = "fastly")] +pub mod lifecycle; +#[cfg(any(feature = "fastly", all(test, not(target_arch = "wasm32"))))] pub mod logger; #[cfg(any(test, feature = "test-utils", feature = "fastly"))] pub mod outbound; @@ -45,6 +54,10 @@ use edgezero_core::http::{Extensions, Response}; use edgezero_core::manifest::ResolvedLoggingConfig; #[cfg(feature = "fastly")] use fastly::compute_runtime::service_id; +#[cfg(feature = "fastly")] +pub use fastly::http::serve::{Serve, ServeSummary}; +#[cfg(any(feature = "fastly", test))] +use std::mem; use std::num::NonZeroU32; #[cfg(feature = "fastly")] use std::sync::Once; @@ -73,6 +86,13 @@ pub const FASTLY_PLATFORM: edgezero_core::PlatformMetadata = edgezero_core::Plat #[cfg(any(feature = "fastly", all(feature = "cli", not(target_arch = "wasm32"))))] const RUNTIME_ENV_PREFIX: &str = "EDGEZERO__"; +#[cfg(any(feature = "fastly", test))] +const RUNTIME_ENV_WARNING: &str = "Fastly Config Store `edgezero_runtime_env` could not be opened; \ + EDGEZERO__* runtime overrides will use baked-in defaults. \ + Run `edgezero provision --adapter fastly` to create the store, \ + then populate per-environment override keys with \ + `fastly config-store-entry update --upsert`."; + /// Name of the Fastly Config Store the runtime opens for `EDGEZERO__*` /// overrides. /// @@ -93,6 +113,35 @@ pub struct FastlyLogging { pub use_fastly_logger: bool, } +/// Resolved runtime overrides and bounded diagnostics deferred until logging is ready. +#[cfg(any(feature = "fastly", test))] +#[derive(Debug)] +#[expect( + clippy::partial_pub_fields, + reason = "runtime overrides are public but one-shot diagnostic state must remain private" +)] +pub struct FastlyRuntimeConfig { + /// Canonical `EDGEZERO__*` overrides for logging and request dispatch. + pub env: EnvConfig, + runtime_env_unavailable: bool, +} + +#[cfg(any(feature = "fastly", test))] +impl FastlyRuntimeConfig { + /// Emit pending boot warnings at most once, after installing a logging backend. + /// Without a backend the warning is not observable; this does not install one. + #[inline] + pub fn emit_boot_diagnostics(&mut self) { + if let Some(warning) = self.take_boot_warning() { + log::warn!(target: edgezero_core::BOOT_LOG_TARGET, "{warning}"); + } + } + + fn take_boot_warning(&mut self) -> Option<&'static str> { + mem::take(&mut self.runtime_env_unavailable).then_some(RUNTIME_ENV_WARNING) + } +} + #[cfg(any(feature = "fastly", test))] impl From for FastlyLogging { #[inline] @@ -118,12 +167,7 @@ impl From for FastlyLogging { impl From<&EnvConfig> for FastlyLogging { #[inline] fn from(env: &EnvConfig) -> Self { - use std::str::FromStr as _; - - let level = env - .logging_level() - .and_then(|raw| log::LevelFilter::from_str(raw).ok()) - .unwrap_or(log::LevelFilter::Info); + let level = edgezero_core::resolve_logging_level(env); // Only attach Fastly's named-endpoint logger when `EDGEZERO__LOGGING__ENDPOINT` // is set. Production deployments set it to a real `[log_endpoints]` entry from // `fastly.toml`; local Viceroy runs leave it unset and avoid the @@ -238,15 +282,77 @@ where Finalize: FnOnce(&mut Response) -> State, { init_fastly_abi(); - let app = build_app_for_dispatch::()?; let stores = A::stores(); - let env = runtime_env_config(stores); - let logging = FastlyLogging::from(&env); + let mut config = runtime_env_config(stores); + let logging = FastlyLogging::from(&config.env); if logging.use_fastly_logger && !A::owns_logging() { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); init_logger(endpoint, logging.level, logging.echo_stdout)?; } - request::send_with_registries_and_hooks(&app, stores, &env, prepare, finalize) + let app = build_app_for_dispatch::(); + config.emit_boot_diagnostics(); + request::send_with_registries_and_hooks(&app?, stores, &config.env, prepare, finalize) +} + +/// Retain a successfully built app while the SDK serves sequential requests. +/// +/// Each callback resolves fresh runtime configuration and store registries. Logging +/// uses the first snapshot, and the adapter owns response delivery without SDK resends. +#[cfg(feature = "fastly")] +#[must_use = "inspect the summary or handle its terminal error with into_result()"] +#[inline] +pub fn serve_app(serve: Serve) -> ServeSummary { + serve_app_with_hooks::(serve, |_request, _extensions| {}, |_response| ()) +} + +/// Retained serving with request-local preparation and routed response finalization. +/// +/// The hooks follow [`run_app_with_hooks`]. Preparation runs before admission; +/// finalization runs only for routed responses, before adapter-owned delivery. +/// Captures persist across callbacks and must not accidentally retain request resources. +#[cfg(feature = "fastly")] +#[must_use = "inspect the summary or handle its terminal error with into_result()"] +#[inline] +pub fn serve_app_with_hooks( + serve: Serve, + mut prepare: Prepare, + mut finalize: Finalize, +) -> ServeSummary +where + A: Hooks, + Prepare: FnMut(&mut fastly::Request, &mut Extensions), + Finalize: FnMut(&mut Response) -> State, +{ + init_fastly_abi(); + let stores = A::stores(); + lifecycle::serve_custom(serve, move |req, retained: &mut lifecycle::Sandbox| { + let mut runtime = runtime_env_config(stores); + retained.setup_once(|| { + let logging = FastlyLogging::from(&runtime.env); + if logging.use_fastly_logger && !A::owns_logging() { + init_logger( + logging.endpoint.as_deref().unwrap_or("stdout"), + logging.level, + logging.echo_stdout, + )?; + } + Ok::<(), fastly::Error>(()) + })?; + runtime.emit_boot_diagnostics(); + retained.initialize(build_app_for_dispatch::)?; + let app = retained.state().ok_or_else(|| { + fastly::Error::msg("application initialization did not retain an app") + })?; + request::send_request_with_registries_and_hooks( + app, + stores, + req, + &runtime.env, + &mut prepare, + &mut finalize, + ) + .map(|_state| ()) + }) } /// Build an [`EnvConfig`] from the optional `edgezero_runtime_env` @@ -271,34 +377,26 @@ where /// [`Hooks`] impl inherits the empty [`StoresMetadata::default`] and must /// override `stores()` or pass explicit metadata here. /// -/// If the store cannot be opened, the function logs a warning and returns an -/// empty [`EnvConfig`]. Callers then use their baked-in adapter and store defaults. +/// If the store cannot be opened, the result contains an empty [`EnvConfig`] and +/// one deferred warning. Custom entrypoints must initialize their logger, then +/// call [`FastlyRuntimeConfig::emit_boot_diagnostics`]. No logging occurs here. #[cfg(feature = "fastly")] #[must_use] #[inline] -pub fn runtime_env_config(stores: StoresMetadata) -> EnvConfig { +pub fn runtime_env_config(stores: StoresMetadata) -> FastlyRuntimeConfig { use fastly::ConfigStore; - use std::iter::empty; let Ok(dict) = ConfigStore::try_open(RUNTIME_ENV_STORE_NAME) else { - // The store is optional -- a clean cutover deploy with all - // baked-in defaults works without it. But the absence means - // EDGEZERO__* runtime overrides (spec 5.4 __KEY, spec 5.2 - // __NAME) will silently fall back to baked defaults. Log - // once at request time so operators can spot the gap in - // their Fastly logs and run `edgezero provision --adapter fastly` - // to create the store. - log::warn!( - "Fastly Config Store `edgezero_runtime_env` not found; \ - EDGEZERO__* runtime overrides will use baked-in defaults. \ - Run `edgezero provision --adapter fastly` to create the store, \ - then populate per-environment override keys with \ - `fastly config-store-entry update --upsert`." - ); - return EnvConfig::from_vars(empty::<(String, String)>()); + return FastlyRuntimeConfig { + env: EnvConfig::default(), + runtime_env_unavailable: true, + }; }; let current_service_id = service_id(); let vars = runtime_env_vars_for_service(stores, current_service_id, |key| dict.get(key)); - EnvConfig::from_vars(vars) + FastlyRuntimeConfig { + env: EnvConfig::from_vars(vars), + runtime_env_unavailable: false, + } } #[cfg(any( @@ -372,11 +470,11 @@ pub fn run_app_with_config( config_store_name: Option<&str>, ) -> Result<(), fastly::Error> { init_fastly_abi(); - let app = build_app_for_dispatch::()?; if logging.use_fastly_logger && !A::owns_logging() { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); init_logger(endpoint, logging.level, logging.echo_stdout)?; } + let app = build_app_for_dispatch::()?; let mut service = request::FastlyService::new(&app); if let Some(name) = config_store_name { service = service.with_config(name); @@ -389,6 +487,53 @@ mod fastly_logging_tests { use super::*; use edgezero_core::manifest::LogLevel; + #[test] + fn runtime_env_warning_is_deferred_and_taken_once() { + let mut config = FastlyRuntimeConfig { + env: EnvConfig::default(), + runtime_env_unavailable: true, + }; + assert!(config.env.logging_level().is_none()); + assert_eq!(config.take_boot_warning(), Some(RUNTIME_ENV_WARNING)); + assert!(config.take_boot_warning().is_none()); + } + + #[test] + fn available_empty_runtime_env_has_no_boot_warning() { + let mut config = FastlyRuntimeConfig { + env: EnvConfig::default(), + runtime_env_unavailable: false, + }; + assert!(config.take_boot_warning().is_none()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn public_boot_diagnostic_emission_is_once_and_empty_store_is_silent() { + let guard = log_fastly::reset_logger( + log_fastly::Logger::builder() + .default_endpoint("diagnostic-test") + .max_level(log::LevelFilter::Warn) + .build() + .expect("SDK capture"), + ); + let mut config = FastlyRuntimeConfig { + env: EnvConfig::default(), + runtime_env_unavailable: true, + }; + config.emit_boot_diagnostics(); + guard.assert_contains("diagnostic-test", RUNTIME_ENV_WARNING); + log::warn!("diagnostic emission sentinel"); + config.emit_boot_diagnostics(); + guard.assert_contains("diagnostic-test", "diagnostic emission sentinel"); + let mut available = FastlyRuntimeConfig { + env: EnvConfig::default(), + runtime_env_unavailable: false, + }; + available.emit_boot_diagnostics(); + guard.assert_contains("diagnostic-test", "diagnostic emission sentinel"); + } + #[test] fn fastly_logging_from_manifest_converts_defaults() { let config = ResolvedLoggingConfig { diff --git a/crates/edgezero-adapter-fastly/src/lifecycle.rs b/crates/edgezero-adapter-fastly/src/lifecycle.rs new file mode 100644 index 00000000..64f75c56 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/lifecycle.rs @@ -0,0 +1,246 @@ +//! Lazy, successful-only state retention for custom Fastly dispatch. +//! +//! Only application-owned state belongs here. Native request handles, bodies, +//! metadata, extensions, and pending work must remain local to each callback. + +#[cfg(feature = "fastly")] +use fastly::http::serve::{HandlerResult, Serve, ServeSummary}; + +/// State owned by one invocation of a custom serving entry point. +/// +/// A new sandbox can start at any request. Initialization must therefore be +/// repeatable; retaining state never guarantees its lifetime or persistence. +pub struct Sandbox { + initialization_attempts: u64, + requests: u64, + setup_complete: bool, + state: Option, +} + +#[cfg(any(feature = "fastly", test))] +struct DeliveryResult(Result<(), E>); + +#[cfg(feature = "fastly")] +impl HandlerResult for DeliveryResult { + type Error = E; + + fn send(self) -> Result<(), E> { + self.0 + } +} + +impl Default for Sandbox { + #[inline] + fn default() -> Self { + Self { + state: None, + requests: 0, + initialization_attempts: 0, + setup_complete: false, + } + } +} + +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "group accessors before lifecycle operations" +)] +impl Sandbox { + /// Successfully initialized application state, if any. + #[inline] + pub fn state(&self) -> Option<&T> { + self.state.as_ref() + } + + /// Attempted callbacks, including the current callback and early returns. + #[inline] + pub fn requests(&self) -> u64 { + self.requests + } + + /// Builder invocations, including failed attempts. + #[inline] + pub fn initialization_attempts(&self) -> u64 { + self.initialization_attempts + } + + /// Build only when empty; retain only success and return errors unchanged. + /// + /// An error can carry a request-local fallback router. The callback decides + /// how to respond and whether to continue serving. A later call retries an + /// unsuccessful build. Panics propagate normally. + /// + /// # Errors + /// Returns the builder error unchanged without retaining it. + #[inline] + pub fn initialize(&mut self, build: F) -> Result<(), E> + where + F: FnOnce() -> Result, + { + if self.state.is_none() { + self.initialization_attempts = self.initialization_attempts.saturating_add(1); + self.state = Some(build()?); + } + Ok(()) + } + + /// Run setup until it succeeds, independently of application initialization. + /// + /// For example, install logging after its configuration becomes available. + /// This guard belongs to this `Sandbox`, not the process. Callers must make + /// failed setup safe to retry: partial side effects are not rolled back. + /// + /// # Errors + /// Returns the setup error, leaving setup eligible for retry. + #[inline] + pub fn setup_once(&mut self, setup: F) -> Result<(), E> + where + F: FnOnce() -> Result<(), E>, + { + if !self.setup_complete { + setup()?; + self.setup_complete = true; + } + Ok(()) + } + + #[cfg(any(feature = "fastly", test))] + fn handle(&mut self, request: Q, handler: F) -> R + where + F: FnOnce(Q, &mut Self) -> R, + { + self.requests = self.requests.saturating_add(1); + handler(request, self) + } +} + +/// Serve custom callbacks with lazy retained state and the supplied SDK limits. +/// +/// The callback controls initialization, dispatch, finalization and streaming. +/// The callback owns sending and returns only a terminal `Result<(), E>`. The +/// SDK records that result without sending a synthetic error response. Errors +/// terminate serving, including failures after commitment, without a second send. +/// +/// SDK limits are upper bounds, not a guarantee of reuse. This function does not +/// read configuration or enable reuse in existing entry points. +#[cfg(feature = "fastly")] +#[must_use = "inspect the serving summary or call into_result() to handle terminal errors"] +#[inline] +pub fn serve_custom(serve: Serve, mut handler: F) -> ServeSummary +where + F: FnMut(fastly::Request, &mut Sandbox) -> Result<(), E>, +{ + crate::init_fastly_abi(); + let mut sandbox = Sandbox::default(); + serve.run_with_context( + |request, context: &mut Sandbox| DeliveryResult(context.handle(request, &mut handler)), + &mut sandbox, + ) +} + +/// Handle one request with fresh state, without entering the SDK serving loop. +/// +/// Takes a request already received by the caller. Sending remains owned by the +/// callback; this function never asks the SDK to render an error. Use an ordinary `main`. +/// +/// # Errors +/// Returns the callback error unchanged without another response attempt. +#[cfg(feature = "fastly")] +#[inline] +pub fn run_custom(request: fastly::Request, handler: F) -> Result<(), E> +where + F: FnOnce(fastly::Request, &mut Sandbox) -> Result<(), E>, +{ + crate::init_fastly_abi(); + Sandbox::default().handle(request, handler) +} + +#[cfg(test)] +mod tests { + use super::{DeliveryResult, Sandbox}; + + // Neither the retained value nor an error payload needs framework traits. + struct App(u64); + struct Fallback(&'static str); + + #[test] + fn owned_delivery_retains_terminal_error_without_synthetic_send() { + let result = DeliveryResult(Err::<(), _>("post-commit error")); + assert_eq!(result.0, Err("post-commit error")); + } + + #[cfg(feature = "fastly")] + #[test] + fn sdk_delivery_result_returns_terminal_error_without_synthetic_send() { + use fastly::http::serve::HandlerResult as _; + + assert_eq!( + DeliveryResult(Err::<(), _>("post-commit error")).send(), + Err("post-commit error") + ); + assert_eq!(DeliveryResult(Ok::<(), &str>(())).send(), Ok(())); + } + + #[test] + fn early_return_counts_request_without_setup_or_build() { + let mut sandbox = Sandbox::::default(); + sandbox.handle("health", |request, state| { + assert_eq!(request, "health"); + assert_eq!(state.requests(), 1); + assert_eq!(state.initialization_attempts(), 0); + assert!(state.state().is_none()); + assert!(!state.setup_complete); + }); + } + + #[test] + fn failed_build_returns_payload_then_success_is_retained() { + let mut sandbox = Sandbox::::default(); + sandbox.handle("first", |_, state| { + assert!(state.setup_once(|| Ok::<_, ()>(())).is_ok()); + let result = state.initialize(|| Err(Fallback("current request only"))); + assert!(matches!(result, Err(Fallback("current request only")))); + assert!(state.state().is_none()); + assert_eq!(state.initialization_attempts(), 1); + }); + for ordinal in 2..=4 { + sandbox.handle(ordinal, |request, state| { + assert!( + state + .setup_once::<(), _>(|| panic!("setup repeated")) + .is_ok() + ); + assert!( + state + .initialize::<(), _>(|| { + assert_eq!(request, 2, "successful initialization repeated"); + Ok(App(request)) + }) + .is_ok() + ); + assert_eq!(state.state().unwrap().0, 2); + assert_eq!(state.requests(), request); + assert_eq!(state.initialization_attempts(), 2); + }); + } + let fresh = Sandbox::::default(); + assert!(fresh.state().is_none()); + assert_eq!(fresh.requests(), 0); + assert_eq!(fresh.initialization_attempts(), 0); + } + + #[test] + fn failed_setup_can_retry_without_initializing_the_app() { + let mut sandbox = Sandbox::::default(); + assert_eq!(sandbox.setup_once(|| Err("not ready")), Err("not ready")); + assert!(!sandbox.setup_complete); + assert!(sandbox.setup_once(|| Ok::<_, ()>(())).is_ok()); + assert!( + sandbox + .setup_once::<(), _>(|| panic!("setup repeated")) + .is_ok() + ); + assert_eq!(sandbox.initialization_attempts(), 0); + assert_eq!(sandbox.requests(), 0); + } +} diff --git a/crates/edgezero-adapter-fastly/src/logger.rs b/crates/edgezero-adapter-fastly/src/logger.rs index 1b040eaf..86d1a7ef 100644 --- a/crates/edgezero-adapter-fastly/src/logger.rs +++ b/crates/edgezero-adapter-fastly/src/logger.rs @@ -1,3 +1,4 @@ +use edgezero_core::logging::{BOOT_LOG_LEVEL, BOOT_LOG_TARGET}; use log::LevelFilter; /// Errors that can occur when initialising the Fastly logger. @@ -13,29 +14,23 @@ pub enum InitLoggerError { SetLogger(#[from] log::SetLoggerError), } -/// Initialize logging (opinionated): formatted timestamps using `fern`, -/// chained to the Fastly logger. -/// -/// # Errors -/// Returns [`InitLoggerError::Build`] if the underlying logger builder -/// rejects its inputs (e.g. an empty endpoint), or -/// [`InitLoggerError::SetLogger`] if a global logger is already installed. -#[inline] -pub fn init_logger( +fn build_backend( endpoint: &str, level: LevelFilter, echo_stdout: bool, -) -> Result<(), InitLoggerError> { - let logger = log_fastly::Logger::builder() +) -> Result { + log_fastly::Logger::builder() .default_endpoint(endpoint) .echo_stdout(echo_stdout) - .max_level(level) + .max_level(level.max(BOOT_LOG_LEVEL)) .build() - .map_err(|err| InitLoggerError::Build(err.to_string()))?; + .map_err(|err| InitLoggerError::Build(err.to_string())) +} - // Format timestamps in RFC3339 with milliseconds using UTC to avoid TZ issues in WASM. - let dispatch = fern::Dispatch::new() +fn build_dispatch(logger: Box, level: LevelFilter) -> fern::Dispatch { + fern::Dispatch::new() .level(level) + .level_for(BOOT_LOG_TARGET, BOOT_LOG_LEVEL) .format(|out, message, record| { out.finish(format_args!( "{} {} {}", @@ -44,12 +39,122 @@ pub fn init_logger( message )); }) - .chain({ - let boxed: Box = Box::new(logger); - boxed - }); + .chain(logger) +} - dispatch.apply()?; - log::set_max_level(level); +/// Initialize logging (opinionated): formatted timestamps using `fern`, +/// chained to the Fastly logger. Boot warnings are retained independently of +/// the ordinary runtime level, including `Off`. +/// +/// # Errors +/// Returns [`InitLoggerError::Build`] if the underlying logger builder +/// rejects its inputs (e.g. an empty endpoint), or +/// [`InitLoggerError::SetLogger`] if a global logger is already installed. +#[inline] +pub fn init_logger( + endpoint: &str, + level: LevelFilter, + echo_stdout: bool, +) -> Result<(), InitLoggerError> { + let logger = build_backend(endpoint, level, echo_stdout)?; + build_dispatch(Box::new(logger), level).apply()?; Ok(()) } + +#[cfg(test)] +#[cfg(not(target_arch = "wasm32"))] +mod tests { + use edgezero_core::logging::{BOOT_LOG_LEVEL, BOOT_LOG_TARGET}; + use log::{Level, LevelFilter, Log as _, Metadata, Record}; + + #[test] + fn backend_rejects_records_even_when_facade_allows_them() { + let logger = log_fastly::Logger::builder() + .default_endpoint("logging-test") + .max_level(LevelFilter::Error) + .build() + .expect("SDK logger"); + assert!( + !logger.enabled( + &Metadata::builder() + .target(BOOT_LOG_TARGET) + .level(Level::Warn) + .build() + ) + ); + } + + #[test] + fn production_filters_preserve_boot_warnings_and_runtime_level() { + let guard = log_fastly::reset_logger( + log_fastly::Logger::builder() + .max_level(LevelFilter::Off) + .build() + .expect("isolated SDK capture"), + ); + + for runtime_level in [ + LevelFilter::Off, + LevelFilter::Error, + LevelFilter::Warn, + LevelFilter::Info, + LevelFilter::Debug, + LevelFilter::Trace, + ] { + let backend = + super::build_backend("logging-test", runtime_level, false).expect("SDK backend"); + let (ceiling, logger) = + super::build_dispatch(Box::new(backend), runtime_level).into_log(); + assert_eq!(ceiling, runtime_level.max(BOOT_LOG_LEVEL)); + let preserved_warning = format!("preserved boot warning {runtime_level}"); + + for level in [ + Level::Error, + Level::Warn, + Level::Info, + Level::Debug, + Level::Trace, + ] { + let boot = Metadata::builder() + .target(BOOT_LOG_TARGET) + .level(level) + .build(); + let ordinary = Metadata::builder() + .target("application") + .level(level) + .build(); + assert_eq!(logger.enabled(&boot), level <= BOOT_LOG_LEVEL); + assert_eq!(logger.enabled(&ordinary), level <= runtime_level); + } + + logger.log( + &Record::builder() + .target(BOOT_LOG_TARGET) + .level(Level::Warn) + .args(format_args!("{preserved_warning}")) + .build(), + ); + guard.assert_contains("logging-test", &preserved_warning); + + logger.log( + &Record::builder() + .target(BOOT_LOG_TARGET) + .level(Level::Info) + .args(format_args!("suppressed boot info")) + .build(), + ); + guard.assert_absent("logging-test", "suppressed boot info"); + + if runtime_level <= LevelFilter::Error { + logger.log( + &Record::builder() + .target("application") + .level(Level::Warn) + .args(format_args!("suppressed runtime warning")) + .build(), + ); + guard.assert_absent("logging-test", "suppressed runtime warning"); + } + } + } +} diff --git a/crates/edgezero-adapter-fastly/src/outbound.rs b/crates/edgezero-adapter-fastly/src/outbound.rs index afa776b4..97e870aa 100644 --- a/crates/edgezero-adapter-fastly/src/outbound.rs +++ b/crates/edgezero-adapter-fastly/src/outbound.rs @@ -187,20 +187,15 @@ mod fastly_impl { use async_trait::async_trait; use bytes::Bytes; use edgezero_core::body::{Body, BodyStream}; - use edgezero_core::compression::{ - ContentEncoding, classify_content_encoding, decode_brotli_stream, decode_deflate_stream, - decode_gzip_stream, - }; use edgezero_core::error::{BadGatewayReason, EdgeError}; - use edgezero_core::http::header::{ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH}; + use edgezero_core::http::header::ACCEPT_ENCODING; use edgezero_core::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri}; use edgezero_core::outbound::{ OutboundBatch, OutboundBatchDriverEvent, OutboundCachePolicy, OutboundHttpClient, OutboundRequest, OutboundRequestParts, OutboundResponse, ResponseBodyDisposition, - ResponseHeaderLimiter, ResponseMode, collect_response_stream, - enforce_payload_content_length, insert_proxy_header, limit_decoded_stream, - limit_encoded_stream, normalize_for_dispatch, normalize_response_headers, rechunk_stream, - validate_for_dispatch, + ResponseBodyPolicy, ResponseHeaderLimiter, ResponseMode, apply_response_body_policy, + collect_response_stream, insert_proxy_header, normalize_for_dispatch, + normalize_response_headers, validate_for_dispatch, }; use edgezero_core::time::{ BATCH_DISPATCH_SLACK_MAX, Deadline, DispatchBudget, MonotonicClock, MonotonicInstant, @@ -986,10 +981,6 @@ mod fastly_impl { clippy::too_many_arguments, reason = "the adapter consumes independent response-policy fields" )] - #[expect( - clippy::too_many_lines, - reason = "the response conversion keeps framing, coding, limits, and deadline ownership together" - )] async fn process_response( mut response: FastlyResponse, request_method: Method, @@ -1055,44 +1046,18 @@ mod fastly_impl { )); } - let encoding = classify_content_encoding(&headers); - let max_buffered = match response_mode { - ResponseMode::Buffered { max_bytes } => Some(max_bytes), - ResponseMode::Streamed => None, - }; - enforce_payload_content_length( - &headers, - &encoding, - max_buffered, - max_decoded_response_bytes, - max_encoded_response_bytes, + let shaped = apply_response_body_policy( + native, + &mut headers, + ResponseBodyPolicy { + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_decoder_bytes, + max_encoded_response_bytes, + response_mode, + }, )?; - let encoded = limit_encoded_stream(native, max_encoded_response_bytes); - let decoded = match &encoding { - ContentEncoding::Brotli => { - decode_brotli_stream(encoded, max_brotli_window_bits, max_decoder_bytes) - } - ContentEncoding::Deflate => decode_deflate_stream(encoded, max_decoder_bytes), - ContentEncoding::Gzip => decode_gzip_stream(encoded, max_decoder_bytes), - ContentEncoding::Identity | ContentEncoding::Passthrough(_) => encoded, - }; - if matches!( - &encoding, - ContentEncoding::Brotli | ContentEncoding::Deflate | ContentEncoding::Gzip - ) { - headers.remove(CONTENT_ENCODING); - headers.remove(CONTENT_LENGTH); - } - let output = match &encoding { - ContentEncoding::Brotli - | ContentEncoding::Deflate - | ContentEncoding::Gzip - | ContentEncoding::Identity => { - limit_decoded_stream(decoded, max_decoded_response_bytes) - } - ContentEncoding::Passthrough(_) => decoded, - }; - let shaped = rechunk_stream(output, max_chunk_bytes); let deadline_bound = deadline_stream(shaped, budget, clock); let body = match response_mode { ResponseMode::Buffered { max_bytes } => { @@ -1394,7 +1359,7 @@ mod fastly_impl { use std::thread; use edgezero_core::error::{BudgetSource, ResponseLimitReason}; - use edgezero_core::http::header::CONNECTION; + use edgezero_core::http::header::{CONNECTION, CONTENT_ENCODING, CONTENT_LENGTH}; use edgezero_core::time::Deadline; use flate2::{Compression, write::GzEncoder}; use futures::executor::block_on; diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index 64784f6f..0dd2bae6 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -536,6 +536,30 @@ where { init_fastly_abi(); let req = FastlyRequest::from_client(); + send_request_with_registries_and_hooks(app, stores, req, env, prepare, finalize) +} + +/// Deliver an already-received request through canonical admission and owned egress. +/// +/// Use this inside a retained serving loop; it never receives another client request. +/// Request registries, preparation, finalization, and body handles remain request-local. +/// +/// # Errors +/// Returns store setup, admission-abort, or terminal delivery errors without sending a second response. +#[inline] +pub fn send_request_with_registries_and_hooks( + app: &App, + stores: StoresMetadata, + req: FastlyRequest, + env: &EnvConfig, + prepare: Prepare, + finalize: Finalize, +) -> Result, FastlyError> +where + Prepare: FnOnce(&mut FastlyRequest, &mut Extensions), + Finalize: FnOnce(&mut Response) -> State, +{ + init_fastly_abi(); let request_start = app.monotonic_now(); let kv_registry = build_kv_registry(stores.kv, env)?; let config_registry = build_config_registry(stores.config, env); @@ -975,7 +999,10 @@ mod synthesis_tests { struct StubConfig; #[async_trait::async_trait(?Send)] impl ConfigStore for StubConfig { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Ok(None) } @@ -986,7 +1013,7 @@ mod synthesis_tests { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { + ) -> Result, ConfigStoreError> { if deadline.is_expired_at(clock.now()) { return Err(ConfigStoreError::DeadlineExceeded); } diff --git a/crates/edgezero-adapter-fastly/tests/contract.rs b/crates/edgezero-adapter-fastly/tests/contract.rs index ea4ea775..842aeee0 100644 --- a/crates/edgezero-adapter-fastly/tests/contract.rs +++ b/crates/edgezero-adapter-fastly/tests/contract.rs @@ -80,8 +80,11 @@ mod tests { #[async_trait::async_trait(?Send)] impl ConfigStore for FixedConfigStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.to_owned())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.into())) } async fn get_bounded( @@ -91,7 +94,7 @@ mod tests { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { + ) -> Result, ConfigStoreError> { if deadline.is_expired_at(clock.now()) { return Err(ConfigStoreError::DeadlineExceeded); } @@ -147,7 +150,7 @@ mod tests { .await .ok() .flatten() - .unwrap_or_else(|| "missing".to_owned()), + .map_or_else(|| "missing".to_owned(), |value| value.to_string()), None => "missing".to_owned(), }; let response = response_builder() diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index c1a5ded7..66f11c94 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -602,14 +602,13 @@ impl Adapter for SpinCliAdapter { if !is_valid_spin_key(&spin_var) { let reason = spin_key_rule_violation(&spin_var); return Err(format!( - "`#[secret]` field `{field}` value `{value}` translates to Spin variable `{spin_var}`, which is not a valid Spin variable name. {reason}. Pick a `#[secret]` value that conforms.", + "`#[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, - value = entry.key_value, )); } if let Some(prev_field) = seen.insert(spin_var.clone(), entry.field_name.as_str()) { return Err(format!( - "Spin variable `{spin_var}` would receive values from BOTH `#[secret]` field `{prev_field}` AND `#[secret]` field `{this_field}`; Spin's flat variable namespace cannot disambiguate them. Pick distinct `#[secret]` values whose lowercased forms differ.", + "`#[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, )); } @@ -1534,6 +1533,37 @@ mod tests { ); } + #[test] + fn secret_validation_errors_redact_original_and_normalized_values() { + for entries in [ + vec![TypedSecretEntry::new( + "default", + "credential", + "Private-Canary", + )], + vec![ + TypedSecretEntry::new("default", "first", "Private_Canary"), + TypedSecretEntry::new("default", "second", "private_canary"), + ], + ] { + let err = SpinCliAdapter.validate_typed_secrets(&entries).unwrap_err(); + for entry in &entries { + assert!( + !err.contains(entry.key_value), + "raw reference leaked: {err}" + ); + assert!( + !err.contains(&entry.key_value.to_ascii_lowercase()), + "normalized reference leaked: {err}" + ); + assert!( + err.contains(entry.field_name.as_str()), + "missing field: {err}" + ); + } + } + } + #[test] fn validate_typed_secrets_passes_with_no_collision() { SpinCliAdapter @@ -1555,13 +1585,10 @@ mod tests { .validate_typed_secrets(&[TypedSecretEntry::new("default", "api_token", "api-token")]) .expect_err("dashed secret value must error"); assert!( - // The error must name BOTH the field name (`api_token`, - // underscore) and the offending value (`api-token`, - // dash), plus mark it as a Spin variable issue. The prior - // assertion double-checked the value and silently missed - // the field-name half. - err.contains("api_token") && err.contains("api-token") && err.contains("Spin variable"), - "error names the field, the bad value, and the Spin-variable bucket: {err}" + err.contains("api_token") + && !err.contains("api-token") + && err.contains("Spin variable"), + "error names the field and rule without its value: {err}" ); } @@ -1577,15 +1604,15 @@ mod tests { ]) .expect_err("two values lowercasing to the same name must collide"); assert!( - err.contains("shared_name") && (err.contains("first") || err.contains("second")), - "error names the shared canonical name and at least one field: {err}" + !err.contains("shared_name") && err.contains("first") && err.contains("second"), + "error names both fields without the shared value: {err}" ); } // named-store secret adapter validation #[test] - fn collision_error_names_both_field_names_and_lowercased_variable() { + fn collision_error_names_both_fields_without_the_lowercased_value() { // case (b): KeyInDefault and KeyInNamedStore that // collide on the lowercased Spin variable. let entries = [ @@ -1595,7 +1622,7 @@ mod tests { let err = SpinCliAdapter.validate_typed_secrets(&entries).unwrap_err(); assert!(err.contains("`one`"), "{err}"); assert!(err.contains("`two`"), "{err}"); - assert!(err.contains("demo_token"), "{err}"); + assert!(!err.contains("demo_token"), "{err}"); } #[test] @@ -1605,9 +1632,9 @@ mod tests { let entries = [TypedSecretEntry::new("vault", "api_token", "demo-token")]; let err = SpinCliAdapter.validate_typed_secrets(&entries).unwrap_err(); assert!(err.contains("`api_token`"), "{err}"); - assert!(err.contains("demo-token"), "{err}"); + assert!(!err.contains("demo-token"), "{err}"); assert!( - err.to_lowercase().contains("hyphen") || err.contains("not a valid"), + err.contains("lowercase letters, digits, and underscores"), "{err}" ); } diff --git a/crates/edgezero-adapter-spin/src/config_store.rs b/crates/edgezero-adapter-spin/src/config_store.rs index 6bc794ab..85198e53 100644 --- a/crates/edgezero-adapter-spin/src/config_store.rs +++ b/crates/edgezero-adapter-spin/src/config_store.rs @@ -61,12 +61,15 @@ impl SpinConfigStore { inner: SpinConfigBackend::InMemory(entries.into_iter().collect()), } } -} -#[async_trait(?Send)] -impl ConfigStore for SpinConfigStore { - #[inline] - async fn get(&self, key: &str) -> Result, ConfigStoreError> { + #[cfg_attr( + not(all(feature = "spin", target_arch = "wasm32")), + expect( + clippy::unused_async, + reason = "native fixture is synchronous; the production SDK read is asynchronous" + ) + )] + async fn materialized_get(&self, key: &str) -> Result, ConfigStoreError> { match &self.inner { #[cfg(test)] SpinConfigBackend::InMemory(map) => match map.get(key) { @@ -106,6 +109,16 @@ impl ConfigStore for SpinConfigStore { } } } +} + +#[async_trait(?Send)] +impl ConfigStore for SpinConfigStore { + #[inline] + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + self.materialized_get(key) + .await + .map(|value| value.map(Into::into)) + } #[inline] async fn get_bounded( @@ -115,9 +128,9 @@ impl ConfigStore for SpinConfigStore { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { + ) -> Result, ConfigStoreError> { bounded_config_read( - self.get(key), + self.materialized_get(key), clock, deadline, max_backend_bytes, @@ -147,7 +160,7 @@ async fn bounded_config_read( deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, -) -> Result, ConfigStoreError> +) -> Result, ConfigStoreError> where F: Future, ConfigStoreError>>, { @@ -184,7 +197,7 @@ async fn bounded_config_read_with_timer( max_backend_bytes: u64, max_value_bytes: u64, mut make_timer: MakeTimer, -) -> Result, ConfigStoreError> +) -> Result, ConfigStoreError> where F: Future, ConfigStoreError>>, MakeTimer: FnMut(Duration) -> Timer, @@ -378,11 +391,11 @@ mod tests { assert_eq!( block_on(store.get("feature.new_checkout")).expect("dotted lookup"), - Some("false".to_owned()), + Some(edgezero_core::ConfigValue::from("false")), ); assert_eq!( block_on(store.get("service.timeout_ms")).expect("dotted lookup"), - Some("1500".to_owned()), + Some(edgezero_core::ConfigValue::from("1500")), ); // Negative: the legacy flat form is NOT a fallback any more. assert_eq!( diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 299cbc48..28c0d782 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -121,16 +121,12 @@ pub fn build_app_for_test() -> anyhow::Result { /// Initialize the logger for Spin. /// -/// Currently a no-op — Spin manages its own logging internally. -/// When a real logger is needed for one target, split this into -/// `#[cfg(all(feature = "spin", target_arch = "wasm32"))]` / -/// `#[cfg(not(...))]` branches following the Fastly/Cloudflare pattern. -// TODO: wire in real Spin logger when available +/// This is a no-op. Host stdout/stderr capture does not install a Rust `log` +/// backend. Applications needing facade output must install their backend and +/// filters before the adapter entrypoint and set `Hooks::owns_logging()` to `true`. /// /// # Errors -/// Never; this is currently a no-op because Spin manages logging -/// internally. The signature still returns [`log::SetLoggerError`] so -/// the future "wire in a real logger" branch stays drop-in compatible. +/// Never; no logger is installed or reconfigured. #[inline] pub fn init_logger() -> Result<(), log::SetLoggerError> { Ok(()) @@ -184,17 +180,29 @@ pub async fn run_app_with_platform( req: SpinRequest, platform: edgezero_core::PlatformMetadata, ) -> anyhow::Result { - let app = build_app_for_dispatch::(platform)?; - // Best-effort: every Spin `#[http_service]` re-enters this function, so a - // second `log::set_logger` call returns Err — drop the result instead of - // `.expect()` to avoid panicking on every subsequent request. Skipped - // entirely when the app owns logging. if !A::owns_logging() { drop(init_logger()); } - let env = EnvConfig::from_env(); + let app = build_app_for_dispatch::(platform)?; + dispatch_app::(&app, req).await +} + +/// Dispatch a caller-owned app through canonical admission and response egress. +/// +/// This does not build or cache an app or install logging. The caller must pass +/// an app built from `A` with its selected platform; `App` erases its construction type, so this pairing +/// remains a caller precondition. Retain only application-owned values and keep +/// native handles and pending work request-local. Shared state must support +/// overlapping invocations. +/// +/// # Errors +/// Returns configuration, admission-abort, or adapter delivery errors. +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[inline] +pub async fn dispatch_app(app: &App, req: SpinRequest) -> anyhow::Result { let stores = A::stores(); - request::dispatch_with_registries(&app, req, stores.config, stores.kv, stores.secrets, &env) + let env = EnvConfig::from_env(); + request::dispatch_with_registries(app, req, stores.config, stores.kv, stores.secrets, &env) .await } diff --git a/crates/edgezero-adapter-spin/src/outbound.rs b/crates/edgezero-adapter-spin/src/outbound.rs index ac5e6257..76c8d057 100644 --- a/crates/edgezero-adapter-spin/src/outbound.rs +++ b/crates/edgezero-adapter-spin/src/outbound.rs @@ -237,35 +237,29 @@ use exchange::{ #[cfg(all(feature = "spin", target_arch = "wasm32"))] mod spin_impl { use std::num::NonZeroU64; - use std::task::Poll; use std::time::Duration; use async_stream::stream; use async_trait::async_trait; use bytes::Bytes; use edgezero_core::body::{Body, BodyStream}; - use edgezero_core::compression::{ - ContentEncoding, classify_content_encoding, decode_brotli_stream, decode_deflate_stream, - decode_gzip_stream, - }; #[cfg(feature = "test-utils")] use edgezero_core::error::BudgetSource; use edgezero_core::error::{BadGatewayReason, EdgeError}; - use edgezero_core::http::header::{ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH}; + use edgezero_core::http::header::ACCEPT_ENCODING; use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}; use edgezero_core::outbound::{ - OutboundBatch, OutboundBatchDriverEvent, OutboundHttpClient, OutboundRequest, - OutboundRequestParts, OutboundResponse, ResponseBodyDisposition, ResponseHeaderLimiter, - ResponseMode, collect_response_stream, enforce_payload_content_length, finish_batch_item, - insert_proxy_header, limit_decoded_stream, limit_encoded_stream, normalize_for_dispatch, - normalize_response_headers, rechunk_stream, + OutboundBatch, OutboundHttpClient, OutboundRequest, OutboundRequestParts, OutboundResponse, + ResponseBodyDisposition, ResponseBodyPolicy, ResponseHeaderLimiter, ResponseMode, + apply_response_body_policy, collect_response_stream, insert_proxy_header, + normalize_for_dispatch, normalize_response_headers, }; use edgezero_core::time::{ Deadline, DispatchBudget, MonotonicClock, MonotonicInstant, dispatch_budget, }; use futures_util::StreamExt as _; - use futures_util::future::{Either, FutureExt as _, LocalBoxFuture, poll_fn, select}; - use futures_util::stream::{FuturesUnordered, once}; + use futures_util::future::{Either, FutureExt as _, LocalBoxFuture, select}; + use futures_util::stream::once; use spin_sdk::time::sleep; use spin_sdk::wasip3::http::client; use spin_sdk::wasip3::http::types::{ @@ -432,7 +426,7 @@ mod spin_impl { } let mut completed = Vec::new(); - let pending_slots = FuturesUnordered::>::new(); + let mut pending_slots = Vec::>::new(); for (index, request) in requests.into_iter().enumerate() { match Self::prepare_batch(request, batch_started_at, observation_cutoff) { Ok(prepared) => { @@ -449,48 +443,13 @@ mod spin_impl { } } - let completions = stream! { - let mut pending = pending_slots; - poll_fn(|context| { - loop { - match pending.poll_next_unpin(context) { - Poll::Ready(Some(item)) => completed.push(item), - Poll::Ready(None) | Poll::Pending => { - return Poll::Ready(()); - } - } - } - }).await; - - for (index, completed_at, outcome) in completed { - let Some(item) = finish_batch_item( - index, - batch_started_at, - completed_at, - observation_cutoff, - outcome, - ) else { - yield OutboundBatchDriverEvent::Cutoff; - return; - }; - yield OutboundBatchDriverEvent::Item(item); - } - - while let Some((index, completed_at, outcome)) = pending.next().await { - let Some(item) = finish_batch_item( - index, - batch_started_at, - completed_at, - observation_cutoff, - outcome, - ) else { - yield OutboundBatchDriverEvent::Cutoff; - return; - }; - yield OutboundBatchDriverEvent::Item(item); - } - }; - OutboundBatch::from_driver(slot_count, completions) + OutboundBatch::from_futures( + slot_count, + batch_started_at, + observation_cutoff, + completed, + pending_slots, + ) } } @@ -731,44 +690,18 @@ mod spin_impl { )); } - let encoding = classify_content_encoding(&headers); - let max_buffered = match response_mode { - ResponseMode::Buffered { max_bytes } => Some(max_bytes), - ResponseMode::Streamed => None, - }; - enforce_payload_content_length( - &headers, - &encoding, - max_buffered, - max_decoded_response_bytes, - max_encoded_response_bytes, + let shaped = apply_response_body_policy( + native, + &mut headers, + ResponseBodyPolicy { + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_decoder_bytes, + max_encoded_response_bytes, + response_mode, + }, )?; - let encoded = limit_encoded_stream(native, max_encoded_response_bytes); - let decoded = match &encoding { - ContentEncoding::Brotli => { - decode_brotli_stream(encoded, max_brotli_window_bits, max_decoder_bytes) - } - ContentEncoding::Deflate => decode_deflate_stream(encoded, max_decoder_bytes), - ContentEncoding::Gzip => decode_gzip_stream(encoded, max_decoder_bytes), - ContentEncoding::Identity | ContentEncoding::Passthrough(_) => encoded, - }; - if matches!( - &encoding, - ContentEncoding::Brotli | ContentEncoding::Deflate | ContentEncoding::Gzip - ) { - headers.remove(CONTENT_ENCODING); - headers.remove(CONTENT_LENGTH); - } - let output = match &encoding { - ContentEncoding::Brotli - | ContentEncoding::Deflate - | ContentEncoding::Gzip - | ContentEncoding::Identity => { - limit_decoded_stream(decoded, max_decoded_response_bytes) - } - ContentEncoding::Passthrough(_) => decoded, - }; - let shaped = rechunk_stream(output, max_chunk_bytes); let deadline_bound = deadline_stream(super::cooperative_stream(shaped), budget, clock); let body = match response_mode { ResponseMode::Buffered { max_bytes } => { diff --git a/crates/edgezero-adapter-spin/src/request.rs b/crates/edgezero-adapter-spin/src/request.rs index c64b22ab..a1afef3f 100644 --- a/crates/edgezero-adapter-spin/src/request.rs +++ b/crates/edgezero-adapter-spin/src/request.rs @@ -806,7 +806,10 @@ mod synthesis_tests { struct StubConfig; #[async_trait::async_trait(?Send)] impl ConfigStore for StubConfig { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Ok(None) } @@ -817,7 +820,7 @@ mod synthesis_tests { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { + ) -> Result, ConfigStoreError> { if deadline.is_expired_at(clock.now()) { return Err(ConfigStoreError::DeadlineExceeded); } diff --git a/crates/edgezero-adapter-spin/tests/contract.rs b/crates/edgezero-adapter-spin/tests/contract.rs index e54dd533..067622ee 100644 --- a/crates/edgezero-adapter-spin/tests/contract.rs +++ b/crates/edgezero-adapter-spin/tests/contract.rs @@ -94,9 +94,12 @@ mod tests { #[async_trait::async_trait(?Send)] impl ConfigStore for FixedConfigStore { - async fn get(&self, key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + key: &str, + ) -> Result, ConfigStoreError> { if key == self.key { - Ok(Some(self.value.to_owned())) + Ok(Some(self.value.into())) } else { Ok(None) } @@ -109,7 +112,7 @@ mod tests { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { + ) -> Result, ConfigStoreError> { if deadline.is_expired_at(clock.now()) { return Err(ConfigStoreError::DeadlineExceeded); } @@ -252,7 +255,7 @@ mod tests { .await .ok() .flatten() - .unwrap_or_else(|| "missing".to_owned()), + .map_or_else(|| "missing".to_owned(), |value| value.to_string()), None => "missing".to_owned(), }; let response = response_builder() diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 36789373..ce51cbb2 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -49,6 +49,7 @@ pub enum AdapterAction { pub struct AdapterExecutionTarget { app_root: PathBuf, component: Option, + environment_defaults: Vec<(String, String)>, platform_manifest: Option, } @@ -67,6 +68,14 @@ impl AdapterExecutionTarget { self.component.as_deref() } + /// Selected-manifest defaults not already supplied by the parent environment. + /// Declaration order is retained; child commands apply the last applicable value. + #[must_use] + #[inline] + pub fn environment_defaults(&self) -> &[(String, String)] { + &self.environment_defaults + } + /// Construct a target after the caller has canonicalized and validated it. #[must_use] #[inline] @@ -78,6 +87,7 @@ impl AdapterExecutionTarget { Self { app_root, component, + environment_defaults: Vec::new(), platform_manifest, } } @@ -88,6 +98,14 @@ impl AdapterExecutionTarget { pub fn platform_manifest(&self) -> Option<&Path> { self.platform_manifest.as_deref() } + + /// Carry filtered runtime defaults to the adapter without mutating process state. + #[must_use] + #[inline] + pub fn with_environment_defaults(mut self, defaults: Vec<(String, String)>) -> Self { + self.environment_defaults = defaults; + self + } } /// A single declared store id, paired with the platform name the @@ -980,4 +998,19 @@ mod tests { ); assert!(local_err.contains("--local"), "msg: {local_err}"); } + + #[test] + fn execution_target_retains_ordered_defaults_without_changing_paths() { + let empty = AdapterExecutionTarget::new(PathBuf::from("/tmp"), None, None); + assert!(empty.environment_defaults().is_empty()); + let defaults = vec![ + ("key".to_owned(), "first".to_owned()), + ("KEY".to_owned(), "last".to_owned()), + ]; + let selected = empty.with_environment_defaults(defaults.clone()); + let cloned = selected.clone(); + assert_eq!(cloned.environment_defaults(), defaults); + assert_eq!(cloned.app_root(), Path::new("/tmp")); + assert_eq!(cloned, selected); + } } diff --git a/crates/edgezero-cli/Cargo.toml b/crates/edgezero-cli/Cargo.toml index 4fe54f60..981fb61e 100644 --- a/crates/edgezero-cli/Cargo.toml +++ b/crates/edgezero-cli/Cargo.toml @@ -22,7 +22,7 @@ required-features = ["nested-app-config-check"] [dependencies] edgezero-core = { workspace = true } edgezero-adapter = { path = "../edgezero-adapter" } -edgezero-adapter-axum = { workspace = true, features = ["cli", "axum"], optional = true } +edgezero-adapter-axum = { workspace = true, features = ["cli", "native-bindings"], optional = true } edgezero-adapter-cloudflare = { workspace = true, features = ["cli"], optional = true } edgezero-adapter-fastly = { workspace = true, features = ["cli"], optional = true } edgezero-adapter-spin = { workspace = true, features = ["cli"], optional = true } @@ -58,6 +58,6 @@ default = [ "edgezero-adapter-cloudflare", "edgezero-adapter-spin", ] -cli = ["dep:clap", "dep:handlebars"] -demo-example = ["dep:app-demo-core", "edgezero-adapter-axum"] +cli = ["dep:clap", "dep:handlebars", "edgezero-adapter/cli"] +demo-example = ["dep:app-demo-core", "edgezero-adapter-axum", "edgezero-adapter-axum/axum"] nested-app-config-check = ["dep:proc-macro2", "dep:syn", "dep:walkdir"] diff --git a/crates/edgezero-cli/README.md b/crates/edgezero-cli/README.md index d04dec2b..77157550 100644 --- a/crates/edgezero-cli/README.md +++ b/crates/edgezero-cli/README.md @@ -46,6 +46,13 @@ Contributor build with the bundled example: cargo run -p edgezero-cli --features "cli,demo-example" -- demo ``` +The bundled demo uses the same Axum logger as the standalone app-demo adapter, not the CLI's +fixed-Info backend. It honors `EDGEZERO__LOGGING__LEVEL`; runtime `Off` suppresses ordinary +startup messages, while managed boot warnings remain at `Warn`. Capability checks run after +logger setup and before application configuration or listener binding. Terminal failures print +to stderr even with logging disabled. Generated adapter entrypoints likewise delegate to reusable +runners; no logging implementation is copied into their templates. + ## Built-in commands `edgezero` ships with these subcommands (see `edgezero --help` for full diff --git a/crates/edgezero-cli/src/adapter.rs b/crates/edgezero-cli/src/adapter.rs index 275eaeb9..9fb9d9fe 100644 --- a/crates/edgezero-cli/src/adapter.rs +++ b/crates/edgezero-cli/src/adapter.rs @@ -1,5 +1,7 @@ use edgezero_adapter::cli_support::validate_spin_outbound_host_contract; use edgezero_adapter::registry::{self as adapter_registry, AdapterAction}; +#[cfg(feature = "edgezero-adapter-axum")] +use edgezero_adapter_axum::native_build::{NativeCargoSelection, generated_axum_cargo_action}; use edgezero_core::manifest::{ CapabilitySupport, Manifest, ManifestContract, ManifestLoader, ResolvedEnvironment, }; @@ -85,14 +87,7 @@ fn apply_environment( // `env::var_os` first and skip the explicit set when the parent // already has one. This mirrors the precedence the plan + the // typed-config env-overlay docs both promise. - for binding in &environment.variables { - if let Some(value) = &binding.value { - if env::var_os(&binding.env).is_some() { - continue; - } - command.env(&binding.env, value); - } - } + command.envs(resolve_environment_defaults(environment)); let mut missing = Vec::new(); for binding in &environment.secrets { @@ -124,6 +119,7 @@ pub fn execute( let root = loader.manifest().root().unwrap_or_else(|| Path::new(".")); let env = loader.manifest().environment_for(adapter_name); let adapter_bind = adapter_bind_from_manifest(loader.manifest(), adapter_name); + let build_features = adapter_build_features(loader.manifest(), adapter_name); return run_shell( command, root, @@ -132,6 +128,7 @@ pub fn execute( Some(env), adapter_bind, adapter_args, + &build_features, ); } @@ -184,6 +181,7 @@ pub fn execute_capture( let root = loader.manifest().root().unwrap_or_else(|| Path::new(".")); let env = loader.manifest().environment_for(adapter_name); let adapter_bind = adapter_bind_from_manifest(loader.manifest(), adapter_name); + let build_features = adapter_build_features(loader.manifest(), adapter_name); return run_shell_tee( command, root, @@ -192,6 +190,7 @@ pub fn execute_capture( Some(env), adapter_bind, adapter_args, + &build_features, ) .map(Some); } @@ -215,6 +214,9 @@ pub(crate) fn execute_capture_runtime( ) -> Result, String> { ensure_action_capabilities(runtime)?; if let ResolvedAdapterTarget::Shell(shell) = runtime.target() { + let build_features = runtime.manifest().map_or_else(Vec::new, |manifest| { + adapter_build_features(manifest, runtime.adapter_name()) + }); return run_shell_tee( shell.command(), shell.root(), @@ -223,6 +225,7 @@ pub(crate) fn execute_capture_runtime( Some(shell.environment().clone()), (shell.bind_host().map(str::to_owned), shell.bind_port()), adapter_args, + &build_features, ) .map(Some); } @@ -271,15 +274,21 @@ fn execute_after_gate(runtime: &ResolvedRuntime, adapter_args: &[String]) -> Res ResolvedAdapterTarget::Registered(_target) => { execute_registered_after_gate(runtime, adapter_args) } - ResolvedAdapterTarget::Shell(shell) => run_shell( - shell.command(), - shell.root(), - runtime.adapter_name(), - runtime.action(), - Some(shell.environment().clone()), - (shell.bind_host().map(str::to_owned), shell.bind_port()), - adapter_args, - ), + ResolvedAdapterTarget::Shell(shell) => { + let build_features = runtime.manifest().map_or_else(Vec::new, |manifest| { + adapter_build_features(manifest, runtime.adapter_name()) + }); + run_shell( + shell.command(), + shell.root(), + runtime.adapter_name(), + runtime.action(), + Some(shell.environment().clone()), + (shell.bind_host().map(str::to_owned), shell.bind_port()), + adapter_args, + &build_features, + ) + } } } @@ -305,7 +314,71 @@ fn execute_registered_after_gate( ) } })?; - adapter.execute_target(AdapterAction::from(runtime.action()), target, adapter_args) + let defaults = runtime.manifest().map_or_else(Vec::new, |manifest| { + resolve_environment_defaults(&manifest.environment_for(runtime.adapter_name())) + }); + let selected = target.clone().with_environment_defaults(defaults); + let normalized_args = normalize_registered_axum_args(runtime, adapter_args)?; + adapter.execute_target( + AdapterAction::from(runtime.action()), + &selected, + &normalized_args, + ) +} + +#[cfg(feature = "edgezero-adapter-axum")] +fn normalize_registered_axum_args( + runtime: &ResolvedRuntime, + adapter_args: &[String], +) -> Result, String> { + if !runtime.adapter_name().eq_ignore_ascii_case("axum") + || !matches!(runtime.action(), Action::Build | Action::Serve) + { + return Ok(adapter_args.to_vec()); + } + let features = runtime + .manifest() + .and_then(|manifest| { + manifest + .adapter_entry(runtime.adapter_name()) + .map(|(_name, adapter)| adapter.build.features.clone()) + }) + .unwrap_or_default(); + let selection = NativeCargoSelection::normalize_args(&features, adapter_args)?; + if matches!(runtime.action(), Action::Build) && !selection.run_args.is_empty() { + return Err("Axum build does not accept runtime arguments after `--`".to_owned()); + } + let mut normalized = selection.cargo_args(); + if matches!(runtime.action(), Action::Serve) && !selection.run_args.is_empty() { + normalized.push("--".to_owned()); + normalized.extend(selection.run_args); + } + Ok(normalized) +} + +#[cfg(not(feature = "edgezero-adapter-axum"))] +#[expect( + clippy::unnecessary_wraps, + reason = "retain the common fallible dispatch boundary without the optional native adapter" +)] +fn normalize_registered_axum_args( + _runtime: &ResolvedRuntime, + adapter_args: &[String], +) -> Result, String> { + Ok(adapter_args.to_vec()) +} + +fn resolve_environment_defaults(environment: &ResolvedEnvironment) -> Vec<(String, String)> { + let mut defaults = Vec::new(); + for binding in &environment.variables { + if let Some(value) = &binding.value + && env::var_os(&binding.env).is_none() + { + // Keep declaration order; Command owns platform-specific key identity. + defaults.push((binding.env.clone(), value.clone())); + } + } + defaults } pub(crate) fn ensure_capabilities( @@ -332,10 +405,12 @@ pub(crate) fn ensure_capabilities( if capabilities.required.is_empty() { if capabilities.optional.is_empty() { log::warn!( + target: edgezero_core::BOOT_LOG_TARGET, "adapter '{adapter_name}' not in registry; capability check skipped (no capabilities declared)" ); } else { log::warn!( + target: edgezero_core::BOOT_LOG_TARGET, "adapter '{adapter_name}' not in registry; cannot verify its OPTIONAL capabilities; proceeding, since optional capabilities never hard-fail" ); } @@ -375,15 +450,18 @@ pub(crate) fn ensure_capabilities( for capability in capabilities.optional.iter().copied() { match adapter.capability(capability) { CapabilitySupport::BestEffort => log::warn!( + target: edgezero_core::BOOT_LOG_TARGET, "adapter '{adapter_name}': optional capability '{}' is best-effort; see https://edgezero.dev/guide/capabilities", capability.as_str() ), CapabilitySupport::BoundedCooperative | CapabilitySupport::Native => {} CapabilitySupport::Unsupported => log::warn!( + target: edgezero_core::BOOT_LOG_TARGET, "adapter '{adapter_name}': optional capability '{}' unavailable", capability.as_str() ), _ => log::warn!( + target: edgezero_core::BOOT_LOG_TARGET, "adapter '{adapter_name}': optional capability '{}' reports an unrecognized support level; treating as degraded", capability.as_str() ), @@ -446,19 +524,26 @@ fn adapter_bind_from_manifest( /// command, with the manifest environment / bind hints applied. Shared /// by [`run_shell`] (inherited stdio) and [`run_shell_tee`] (piped + /// echoed stdio) so both dispatch paths apply identical env precedence. +fn adapter_build_features(manifest: &Manifest, adapter_name: &str) -> Vec { + manifest + .adapter_entry(adapter_name) + .map_or_else(Vec::new, |(_name, config)| config.build.features.clone()) +} + +#[expect( + clippy::too_many_arguments, + reason = "keeps command, action, environment, bind, passthrough, and selected features explicit at the shared shell boundary" +)] fn build_shell_command( command: &str, cwd: &Path, adapter_name: &str, + action: Action, environment: Option, adapter_bind: (Option, Option), adapter_args: &[String], + manifest_features: &[String], ) -> Result { - let full_command = if adapter_args.is_empty() { - command.to_owned() - } else { - format!("{} {}", command, shell_join(adapter_args)) - }; // Log only the manifest-defined `command`, never the trailing // `adapter_args` — passthrough args from `edgezero build/deploy // -- --token …` can carry deploy tokens, API keys, or other secrets that @@ -470,8 +555,17 @@ fn build_shell_command( cwd.display() ); - let mut cmd = Command::new("sh"); - cmd.arg("-c").arg(&full_command).current_dir(cwd); + let mut cmd = match native_axum_cargo_command( + adapter_name, + action, + command, + cwd, + adapter_args, + manifest_features, + )? { + Some(native_command) => native_command, + None => shell_command(command, adapter_args, cwd), + }; // Precedence (high to low) for `EDGEZERO__ADAPTER__HOST/PORT` on the // subprocess: @@ -504,6 +598,73 @@ fn build_shell_command( Ok(cmd) } +#[cfg(feature = "edgezero-adapter-axum")] +fn native_axum_cargo_command( + adapter_name: &str, + action: Action, + command: &str, + cwd: &Path, + adapter_args: &[String], + manifest_features: &[String], +) -> Result, String> { + if !adapter_name.eq_ignore_ascii_case("axum") + || !matches!(action, Action::Build | Action::Serve) + { + return Ok(None); + } + let Some((subcommand, package)) = generated_axum_cargo_action(command) else { + return Ok(None); + }; + let selection = NativeCargoSelection::normalize_args(manifest_features, adapter_args)?; + if subcommand == "build" && !selection.run_args.is_empty() { + return Err( + "generated `cargo build` does not accept runtime arguments after `--`".to_owned(), + ); + } + let mut cargo = Command::new("cargo"); + cargo + .arg(subcommand) + .arg("-p") + .arg(package) + .args(selection.cargo_args()) + .current_dir(cwd); + if subcommand == "run" && !selection.run_args.is_empty() { + cargo.arg("--").args(&selection.run_args); + } + Ok(Some(cargo)) +} + +#[cfg(not(feature = "edgezero-adapter-axum"))] +#[expect( + clippy::unnecessary_wraps, + reason = "retain the common fallible command boundary without the optional native adapter" +)] +fn native_axum_cargo_command( + _adapter_name: &str, + _action: Action, + _command: &str, + _cwd: &Path, + _adapter_args: &[String], + _manifest_features: &[String], +) -> Result, String> { + Ok(None) +} + +fn shell_command(command: &str, adapter_args: &[String], cwd: &Path) -> Command { + let full_command = if adapter_args.is_empty() { + command.to_owned() + } else { + format!("{} {}", command, shell_join(adapter_args)) + }; + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(full_command).current_dir(cwd); + cmd +} + +#[expect( + clippy::too_many_arguments, + reason = "keeps command, action, environment, bind, passthrough, and selected features explicit for inherited stdio" +)] fn run_shell( command: &str, cwd: &Path, @@ -512,14 +673,17 @@ fn run_shell( environment: Option, adapter_bind: (Option, Option), adapter_args: &[String], + manifest_features: &[String], ) -> Result<(), String> { let mut cmd = build_shell_command( command, cwd, adapter_name, + action, environment, adapter_bind, adapter_args, + manifest_features, )?; let status = cmd @@ -569,6 +733,10 @@ fn tee_stream(reader: R, mut writer: W) -> String { /// captured. Returns the captured `stdout + stderr` text so the caller /// can parse machine-readable lines (e.g. Fastly's activated /// `version=`) out of a command it does not otherwise control. +#[expect( + clippy::too_many_arguments, + reason = "keeps command, action, environment, bind, passthrough, and selected features explicit for captured stdio" +)] fn run_shell_tee( command: &str, cwd: &Path, @@ -577,14 +745,17 @@ fn run_shell_tee( environment: Option, adapter_bind: (Option, Option), adapter_args: &[String], + manifest_features: &[String], ) -> Result { let mut cmd = build_shell_command( command, cwd, adapter_name, + action, environment, adapter_bind, adapter_args, + manifest_features, )?; cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); @@ -641,24 +812,32 @@ fn shell_join(args: &[String]) -> String { #[cfg(test)] mod tests { + #[cfg(feature = "edgezero-adapter-axum")] + use super::native_axum_cargo_command; use super::{ResolvedEnvironment, apply_environment, ensure_capabilities, execute_runtime}; use crate::adapter::Action; use crate::manifest_source::resolve_runtime_from; use crate::test_support::manifest_guard; - use edgezero_adapter::registry::{Adapter, AdapterAction, register_adapter}; + use edgezero_adapter::registry::{ + Adapter, AdapterAction, AdapterExecutionTarget, register_adapter, + }; use edgezero_core::manifest::{ Capability, CapabilitySupport, ManifestContract, ManifestLoader, ResolvedEnvironmentBinding, }; use edgezero_core::test_env::EnvOverride; - use std::ffi::OsString; + use std::env; + use std::ffi::{OsStr, OsString}; use std::fs; use std::process::Command; + use std::sync::Mutex; use tempfile::tempdir; static BEST_EFFORT_ADAPTER: GateAdapter = GateAdapter { name: "gate-best-effort", support: CapabilitySupport::BestEffort, }; + static ENVIRONMENT_ADAPTER: EnvironmentAdapter = EnvironmentAdapter; + static ENVIRONMENT_TARGETS: Mutex> = Mutex::new(Vec::new()); static BOUNDED_ADAPTER: GateAdapter = GateAdapter { name: "gate-bounded", support: CapabilitySupport::BoundedCooperative, @@ -677,6 +856,35 @@ mod tests { support: CapabilitySupport, } + struct EnvironmentAdapter; + + #[expect( + clippy::missing_trait_methods, + reason = "records pinned target dispatch without launching a child" + )] + impl Adapter for EnvironmentAdapter { + fn execute(&self, _action: AdapterAction, _args: &[String]) -> Result<(), String> { + Err("pinned dispatch must not rediscover the invocation project".to_owned()) + } + + fn execute_target( + &self, + _action: AdapterAction, + target: &AdapterExecutionTarget, + _args: &[String], + ) -> Result<(), String> { + ENVIRONMENT_TARGETS + .lock() + .map_err(|_poison| "fixture target lock poisoned")? + .push(target.clone()); + Ok(()) + } + + fn name(&self) -> &'static str { + "gate-environment" + } + } + #[expect( clippy::missing_trait_methods, reason = "capability-gate fixture overrides only the relevant trait methods" @@ -699,6 +907,115 @@ mod tests { ManifestLoader::load_from_str(&format!("[capabilities]\n{section}\n[adapters.gate]\n")) } + #[cfg(feature = "edgezero-adapter-axum")] + #[test] + fn generated_axum_cargo_intercept_is_build_and_serve_only() { + use std::path::Path; + + for action in [Action::Deploy, Action::AuthLogin] { + assert!( + native_axum_cargo_command( + "axum", + action, + "cargo build -p app", + Path::new("."), + &[], + &[], + ) + .unwrap() + .is_none() + ); + } + assert!( + native_axum_cargo_command( + "axum", + Action::Build, + "cargo build -p app", + Path::new("."), + &[], + &[], + ) + .unwrap() + .is_some() + ); + } + + #[test] + fn registered_dispatch_carries_only_selected_manifest_defaults_in_order() { + const KEY: &str = "EDGEZERO_TEST_REGISTERED_INGRESS"; + let _lock = manifest_guard().lock().unwrap(); + let _unset = EnvOverride::remove(KEY); + register_adapter(&ENVIRONMENT_ADAPTER); + let invocation = tempdir().unwrap(); + let selected = tempdir().unwrap(); + fs::write(invocation.path().join("edgezero.toml"), + format!("[adapters.gate-environment]\n[[environment.variables]]\nname = '{KEY}'\nvalue = 'wrong-project'\n")).unwrap(); + let manifest_path = selected.path().join("edgezero.toml"); + fs::write(&manifest_path, format!( + "[adapters.gate-environment]\n\ + [[environment.variables]]\nname = 'first'\nenv = '{KEY}'\nvalue = 'global'\n\ + [[environment.variables]]\nname = 'second'\nenv = '{KEY}'\nvalue = 'selected'\nadapters = ['gate-environment']\n\ + [[environment.variables]]\nname = 'excluded'\nenv = '{KEY}'\nvalue = 'wrong-adapter'\nadapters = ['spin']\n\ + [[environment.variables]]\nname = 'last'\nenv = '{KEY}'\nvalue = ''\n" + )).unwrap(); + for action in [Action::Build, Action::Serve] { + let runtime = resolve_runtime_from( + "gate-environment", + action, + invocation.path(), + Some(OsString::from(&manifest_path)), + ) + .unwrap(); + execute_runtime(&runtime, &[]).unwrap(); + } + let mut targets = ENVIRONMENT_TARGETS.lock().unwrap(); + assert_eq!(targets.len(), 2); + for target in targets.drain(..) { + assert_eq!(target.app_root(), selected.path().canonicalize().unwrap()); + assert_eq!( + target.environment_defaults(), + &[ + (KEY.to_owned(), "global".to_owned()), + (KEY.to_owned(), "selected".to_owned()), + (KEY.to_owned(), String::new()) + ] + ); + let mut child = Command::new("echo"); + child.envs(target.environment_defaults().iter().cloned()); + assert!( + child + .get_envs() + .any(|(key, value)| key == KEY && value.is_some_and(OsStr::is_empty)) + ); + } + assert!(env::var_os(KEY).is_none()); + } + + #[test] + fn environment_defaults_filter_before_collapsing_and_preserve_parent_values() { + const KEY: &str = "EDGEZERO_TEST_INGRESS_PROPAGATION"; + let _lock = manifest_guard().lock().unwrap(); + let _unset = EnvOverride::remove(KEY); + let loader = ManifestLoader::load_from_str(&format!( + "[[environment.variables]]\nname = 'first'\nenv = '{KEY}'\nvalue = 'global'\n\ + [[environment.variables]]\nname = 'selected'\nenv = '{KEY}'\nvalue = 'selected'\nadapters = ['AxUm']\n\ + [[environment.variables]]\nname = 'excluded'\nenv = '{KEY}'\nvalue = 'wrong'\nadapters = ['spin']\n\ + [[environment.variables]]\nname = 'valueless'\nenv = '{KEY}'\n" + )); + let resolved = loader.manifest().environment_for("axum"); + let defaults = super::resolve_environment_defaults(&resolved); + let mut child = Command::new("echo"); + child.envs(defaults); + assert!( + child.get_envs().any(|(key, value)| key == KEY + && value.is_some_and(|configured| configured == "selected")) + ); + for value in ["operator", ""] { + let _parent = EnvOverride::set(KEY, value); + assert!(super::resolve_environment_defaults(&resolved).is_empty()); + } + } + #[test] fn apply_environment_sets_defaults_and_checks_secrets() { let _lock = manifest_guard().lock().expect("env lock"); diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index cc86e79c..9789d92a 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -575,6 +575,9 @@ pub struct ConfigPushArgs { /// `[stores.config].ids` has length 1). #[arg(long)] pub store: Option, + /// Explicit native bindings file. Used only to reject unsupported AWS-bound publication. + #[arg(long)] + pub store_bindings: Option, /// Skip the inline diff prompt and write unconditionally. #[arg(long, short)] pub yes: bool, @@ -596,6 +599,7 @@ impl Default for ConfigPushArgs { runtime_config: None, staging: false, store: None, + store_bindings: None, yes: false, } } @@ -604,7 +608,14 @@ impl Default for ConfigPushArgs { /// Arguments for the `config validate` command. #[derive(clap::Args, Debug)] #[non_exhaustive] +#[expect( + clippy::struct_excessive_bools, + reason = "each bool is an independent Clap control; target toggles mirror Cargo feature flags" +)] pub struct ConfigValidateArgs { + /// Select bindings-only native validation; must be `axum`. + #[arg(long)] + pub adapter: Option, /// Path to the typed app-config file (default: `.toml` /// resolved from the manifest's `[app].name`, next to the manifest). #[arg(long)] @@ -614,13 +625,28 @@ pub struct ConfigValidateArgs { pub manifest: PathBuf, /// Skip the `__…__` env-var overlay when loading the /// typed app-config. The default loads the overlay so validation - /// sees the same values the runtime would. + /// sees the values the runtime would. #[arg(long)] pub no_env: bool, + /// Explicit absolute native bindings file for bindings-only validation. + #[arg(long)] + pub store_bindings: Option, /// Strict mode: additionally check capability-aware completeness /// for the declared adapter set and well-formed handler paths. #[arg(long)] pub strict: bool, + /// Native Cargo target triple used for provider capability resolution. + #[arg(long, requires = "store_bindings")] + pub target: Option, + /// Resolve with all target features enabled. + #[arg(long)] + pub target_all_features: bool, + /// Add native Cargo features for target capability resolution. + #[arg(long, value_delimiter = ',')] + pub target_features: Vec, + /// Suppress target default features. + #[arg(long)] + pub target_no_default_features: bool, } impl Default for ConfigValidateArgs { @@ -628,10 +654,16 @@ impl Default for ConfigValidateArgs { #[inline] fn default() -> Self { Self { + adapter: None, app_config: None, manifest: default_manifest_path(), no_env: false, + store_bindings: None, strict: false, + target: None, + target_all_features: false, + target_features: Vec::new(), + target_no_default_features: false, } } } @@ -745,6 +777,7 @@ mod tests { assert_eq!(args.manifest, PathBuf::from("edgezero.toml")); assert!(args.adapter.is_empty()); assert!(args.app_config.is_none()); + assert!(args.store_bindings.is_none()); assert!(!args.dry_run); assert!(args.key.is_none()); assert!(!args.local); @@ -760,6 +793,12 @@ mod tests { let args = ConfigValidateArgs::default(); assert_eq!(args.manifest, PathBuf::from("edgezero.toml")); assert!(args.app_config.is_none()); + assert!(args.adapter.is_none()); + assert!(args.store_bindings.is_none()); + assert!(args.target.is_none()); + assert!(args.target_features.is_empty()); + assert!(!args.target_all_features); + assert!(!args.target_no_default_features); assert!(!args.no_env); assert!(!args.strict); } @@ -848,6 +887,39 @@ mod tests { assert!(!validate.strict); } + #[test] + fn config_validate_parses_native_bindings_target_controls() { + let args = Args::try_parse_from([ + "edgezero", + "config", + "validate", + "--adapter", + "axum", + "--store-bindings", + "/tmp/native.toml", + "--target", + "x86_64-unknown-linux-gnu", + "--target-features", + "aws-appconfig-agent,aws-secrets-manager", + "--target-no-default-features", + ]) + .expect("parse native bindings validation"); + let Command::Config(ConfigCmd::Validate(validate)) = args.cmd else { + panic!("expected config validate"); + }; + assert_eq!(validate.adapter.as_deref(), Some("axum")); + assert_eq!( + validate.store_bindings, + Some(PathBuf::from("/tmp/native.toml")) + ); + assert_eq!(validate.target.as_deref(), Some("x86_64-unknown-linux-gnu")); + assert_eq!( + validate.target_features, + ["aws-appconfig-agent", "aws-secrets-manager"] + ); + assert!(validate.target_no_default_features); + } + #[test] fn config_gc_parses_explicit_dry_run() { let args = Args::try_parse_from([ diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 7ee2660f..2b133ff3 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -27,6 +27,13 @@ use crate::ensure_adapter_defined; use edgezero_adapter::registry::{ self as adapter_registry, ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, }; +#[cfg(feature = "edgezero-adapter-axum")] +use edgezero_adapter_axum::native_bindings::{ + NativeBindingsLimits, NativeConfigBinding, NativeProviderCapabilities, NativeSourceSelection, + NativeStoreBindings, NativeStoreDeclarations, NativeSuppliedIds, +}; +#[cfg(feature = "edgezero-adapter-axum")] +use edgezero_adapter_axum::native_build::NativeCargoSelection; use edgezero_core::app_config::{ self, AppConfigError, AppConfigLoadOptions, AppConfigMeta, SecretField, SecretKind, SecretPathSegment, @@ -217,8 +224,12 @@ struct ResolvedTomlLeaf<'raw> { /// Returns a human-readable error string on any validation failure. #[inline] pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String> { + if args.store_bindings.is_some() || args.adapter.is_some() { + return run_native_bindings_validate(args); + } + reject_target_controls_without_bindings(args)?; let ctx = load_validation_context(args)?; - run_shared_checks(&ctx)?; + run_shared_checks(&ctx, None)?; log::info!( "[edgezero] config validate (raw): {} OK{}", args.manifest.display(), @@ -236,8 +247,12 @@ pub fn run_config_validate_typed(args: &ConfigValidateArgs) -> Result<(), Str where C: DeserializeOwned + Validate + AppConfigMeta, { + if args.store_bindings.is_some() || args.adapter.is_some() { + return run_native_bindings_validate(args); + } + reject_target_controls_without_bindings(args)?; let ctx = load_validation_context(args)?; - run_shared_checks(&ctx)?; + run_shared_checks(&ctx, None)?; // Typed deserialise + validate_excluding_secrets (push, // diff, AND typed validate all use deserialize-only + @@ -255,7 +270,7 @@ where .map_err(|err| format!("typed app-config failed validation: {err}"))?; typed_secret_checks(&typed, &ctx)?; - run_adapter_typed_checks::(&ctx)?; + run_adapter_typed_checks::(&ctx, None)?; log::info!( "[edgezero] config validate (typed): {} + {} OK{}", @@ -443,9 +458,10 @@ pub fn run_config_push_typed(args: &ConfigPushArgs) -> Result<(), String> where C: DeserializeOwned + Serialize + Validate + AppConfigMeta, { + reject_aws_bound_config_push(args)?; // Pre-flight: load + validate. let ctx = load_push_context(args)?; - run_shared_checks(&ctx.validation)?; + run_shared_checks(&ctx.validation, Some(&args.adapter))?; let mut opts = AppConfigLoadOptions::default(); opts.env_overlay = !args.no_env; let typed: C = app_config::deserialize_app_config_with_options::( @@ -457,7 +473,7 @@ where app_config::validate_excluding_secrets(&typed) .map_err(|err| format!("typed app-config failed validation: {err}"))?; typed_secret_checks(&typed, &ctx.validation)?; - run_adapter_typed_checks::(&ctx.validation)?; + run_adapter_typed_checks::(&ctx.validation, Some(&args.adapter))?; // Resolve adapter paths. let (manifest_root, adapter_manifest_path, component_selector, push_ctx) = @@ -606,12 +622,19 @@ where // adapter_typed_checks; no consent gate, no re-fetch). let validate_args = ConfigValidateArgs { app_config: args.app_config.clone(), + adapter: None, + store_bindings: None, + target: None, + target_features: Vec::new(), + target_all_features: false, + target_no_default_features: false, manifest: args.manifest.clone(), no_env: args.no_env, strict: false, }; let ctx = load_validation_context(&validate_args)?; - run_shared_checks(&ctx)?; + ensure_adapter_defined(&args.adapter, Some(&ctx.manifest_loader))?; + run_shared_checks(&ctx, Some(&args.adapter))?; let mut opts = AppConfigLoadOptions::default(); opts.env_overlay = !args.no_env; let typed: C = app_config::deserialize_app_config_with_options::( @@ -623,7 +646,7 @@ where app_config::validate_excluding_secrets(&typed) .map_err(|err| format!("local validation failed: {err}"))?; typed_secret_checks(&typed, &ctx)?; - run_adapter_typed_checks::(&ctx)?; + run_adapter_typed_checks::(&ctx, Some(&args.adapter))?; // Build the local envelope. let local_data: serde_json::Value = serde_json::to_value(&typed) @@ -632,7 +655,6 @@ where let local_sha = local_envelope.sha256.clone(); // Resolve adapter + store + key (mirrors the push flow). - ensure_adapter_defined(&args.adapter, Some(&ctx.manifest_loader))?; let adapter = adapter_registry::get_adapter(&args.adapter).ok_or_else(|| { format!( "adapter `{}` is declared in {} but not registered in this build", @@ -1326,9 +1348,15 @@ fn load_push_context(args: &ConfigPushArgs) -> Result { // Push is strict — the synthesized validate args // unconditionally request `--strict` so `run_shared_checks` // runs the capability-completeness + handler-path checks - // alongside the schema and per-adapter shared checks. + // alongside the schema and selected-adapter shared checks. let validate_args = ConfigValidateArgs { app_config: args.app_config.clone(), + adapter: None, + store_bindings: None, + target: None, + target_features: Vec::new(), + target_all_features: false, + target_no_default_features: false, manifest: args.manifest.clone(), no_env: args.no_env, strict: true, @@ -1464,6 +1492,136 @@ fn generated_at_rfc3339() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } +fn reject_target_controls_without_bindings(args: &ConfigValidateArgs) -> Result<(), String> { + if args.target.is_some() + || !args.target_features.is_empty() + || args.target_all_features + || args.target_no_default_features + { + return Err( + "native target controls require `--adapter axum --store-bindings `" + .to_owned(), + ); + } + Ok(()) +} + +#[cfg(feature = "edgezero-adapter-axum")] +fn load_validated_native_bindings( + manifest: &Manifest, + path: &Path, +) -> Result { + let limits = NativeBindingsLimits::default(); + let bindings = NativeStoreBindings::read_with_policy(path, limits, None) + .map_err(|_error| "invalid native store bindings".to_owned())?; + let declarations = NativeStoreDeclarations::try_from(&manifest.stores) + .map_err(|_error| "invalid store declarations for native bindings".to_owned())?; + NativeSourceSelection::validate( + declarations, + &bindings, + &NativeSuppliedIds::default(), + &EnvConfig::default(), + NativeProviderCapabilities { + appconfig_agent: true, + secrets_manager: true, + dynamodb: true, + }, + limits, + None, + ) + .map_err(|_error| "native store bindings do not match manifest declarations".to_owned())?; + Ok(bindings) +} + +/// Validate only a caller-selected native bindings file. This path intentionally +/// does not load app TOML, ambient overlays, tokens, credentials, or deployment env. +#[cfg(feature = "edgezero-adapter-axum")] +fn run_native_bindings_validate(args: &ConfigValidateArgs) -> Result<(), String> { + let adapter = args + .adapter + .as_deref() + .ok_or_else(|| "bindings-only validation requires `--adapter axum`".to_owned())?; + if !adapter.eq_ignore_ascii_case("axum") { + return Err("bindings-only validation is supported only for `--adapter axum`".to_owned()); + } + let path = args.store_bindings.as_deref().ok_or_else(|| { + "bindings-only validation requires `--store-bindings `".to_owned() + })?; + if args.app_config.is_some() { + return Err("`--app-config` cannot be used with bindings-only validation".to_owned()); + } + let manifest = ManifestLoader::from_path(&args.manifest) + .map_err(|_error| "failed to load application manifest".to_owned())?; + load_validated_native_bindings(manifest.manifest(), path)?; + let mut target_args = Vec::new(); + for feature in &args.target_features { + target_args.extend(["--features".to_owned(), feature.clone()]); + } + if args.target_all_features { + target_args.push("--all-features".to_owned()); + } + if args.target_no_default_features { + target_args.push("--no-default-features".to_owned()); + } + if let Some(target) = &args.target { + target_args.extend(["--target".to_owned(), target.clone()]); + } + // Normalize and validate target controls, but this first version deliberately + // makes no provider-availability claim and runs no Cargo command. + let manifest_features = manifest + .manifest() + .adapter_entry("axum") + .map_or_else(Vec::new, |(_name, definition)| { + definition.build.features.clone() + }); + let selection = NativeCargoSelection::normalize_args(&manifest_features, &target_args)?; + log::info!( + "[edgezero] native store bindings structurally valid; target provider availability unknown (target={}, requested_features={}, all_features={}, no_default_features={})", + selection.target.as_deref().unwrap_or("host"), + selection.requested_features.len(), + selection.all_features, + selection.no_default_features, + ); + Ok(()) +} + +#[cfg(not(feature = "edgezero-adapter-axum"))] +fn run_native_bindings_validate(_args: &ConfigValidateArgs) -> Result<(), String> { + Err("bindings-only validation requires the CLI Axum native-bindings feature".to_owned()) +} + +#[cfg(feature = "edgezero-adapter-axum")] +fn reject_aws_bound_config_push(args: &ConfigPushArgs) -> Result<(), String> { + let Some(path) = args.store_bindings.as_deref() else { + return Ok(()); + }; + if !args.adapter.eq_ignore_ascii_case("axum") { + return Err("`--store-bindings` push selection requires `--adapter axum`".to_owned()); + } + if !path.is_absolute() { + return Err("`--store-bindings` must be an absolute path".to_owned()); + } + let manifest = ManifestLoader::from_path(&args.manifest) + .map_err(|_error| "failed to load application manifest".to_owned())?; + let logical = resolve_config_store_id(args.store.as_deref(), manifest.manifest())?; + let bindings = load_validated_native_bindings(manifest.manifest(), path)?; + if matches!( + bindings.config().get(&logical), + Some(NativeConfigBinding::AppConfigAgent(_)) + ) { + return Err("config push does not publish to AWS-bound native config stores; update the selected binding through its deployment workflow".to_owned()); + } + Ok(()) +} + +#[cfg(not(feature = "edgezero-adapter-axum"))] +fn reject_aws_bound_config_push(args: &ConfigPushArgs) -> Result<(), String> { + if args.store_bindings.is_some() { + return Err("`--store-bindings` requires the CLI Axum native-bindings feature".to_owned()); + } + Ok(()) +} + fn load_validation_context(args: &ConfigValidateArgs) -> Result { let manifest_loader = ManifestLoader::from_path(&args.manifest) .map_err(|err| format!("failed to load {}: {err}", args.manifest.display()))?; @@ -1518,10 +1676,18 @@ fn resolve_app_config_path( ) } -fn run_shared_checks(ctx: &ValidationContext) -> Result<(), String> { - run_adapter_shared_checks(ctx)?; +/// Adapter keys are matched case-insensitively everywhere else (see +/// `Manifest::adapter_entry`), so the `--adapter` selection filter must be +/// too: `[adapters.Spin]` with `--adapter spin` (or the reverse) selects the +/// same entry and must not skip its validation. +fn is_unselected_adapter(selected: Option<&str>, name: &str) -> bool { + selected.is_some_and(|target| !target.eq_ignore_ascii_case(name)) +} + +fn run_shared_checks(ctx: &ValidationContext, selected: Option<&str>) -> Result<(), String> { + run_adapter_shared_checks(ctx, selected)?; if ctx.args_strict { - strict_capability_completeness(ctx.manifest())?; + strict_capability_completeness(ctx.manifest(), selected)?; strict_handler_paths(ctx.manifest())?; } Ok(()) @@ -1533,12 +1699,15 @@ fn run_shared_checks(ctx: &ValidationContext) -> Result<(), String> { // ------------------------------------------------------------------- /// Run the adapter-agnostic shared checks: for every adapter -/// declared in the manifest, look up its `Adapter` impl in the +/// declared in the manifest (or only the selected push/diff target), look up its `Adapter` impl in the /// registry and invoke `validate_app_config_keys` + /// `validate_adapter_manifest`. Adapters not in the registry (e.g. /// a feature-gated build that omitted some) are silently skipped — /// they can't validate what they don't link. -fn run_adapter_shared_checks(ctx: &ValidationContext) -> Result<(), String> { +fn run_adapter_shared_checks( + ctx: &ValidationContext, + selected: Option<&str>, +) -> Result<(), String> { let raw_table = ctx .raw_config .as_table() @@ -1549,6 +1718,9 @@ fn run_adapter_shared_checks(ctx: &ValidationContext) -> Result<(), String> { let env_config = EnvConfig::from_env(); for (name, adapter_cfg) in &ctx.manifest().adapters { + if is_unselected_adapter(selected, name) { + continue; + } let Some(adapter) = adapter_registry::get_adapter(name) else { continue; }; @@ -1742,7 +1914,10 @@ fn collect_secret_leaves<'raw>( /// runtime store ids, not flat-namespace candidates) so adapters /// whose secret store has a flat-namespace constraint (Spin) can /// detect within-secrets collisions. -fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result<(), String> { +fn run_adapter_typed_checks( + ctx: &ValidationContext, + selected: Option<&str>, +) -> Result<(), String> { let default_store_id = ctx .manifest() .stores @@ -1773,6 +1948,9 @@ fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result } for name in ctx.manifest().adapters.keys() { + if is_unselected_adapter(selected, name) { + continue; + } if let Some(adapter) = adapter_registry::get_adapter(name) { adapter.validate_typed_secrets(&entries)?; } @@ -1880,12 +2058,18 @@ fn flatten_keys_into(table: &Table, prefix: &str, out: &mut Vec) { // --strict checks // ------------------------------------------------------------------- -fn strict_capability_completeness(manifest: &Manifest) -> Result<(), String> { +fn strict_capability_completeness( + manifest: &Manifest, + selected: Option<&str>, +) -> Result<(), String> { // Capability matrix, driven by each adapter crate's // `Adapter::single_store_kinds()` impl. Adapters not in the // registry (e.g. a feature-gated build that omitted some) are // skipped — we can't speak for what isn't linked. for adapter_name in manifest.adapters.keys() { + if is_unselected_adapter(selected, adapter_name) { + continue; + } enforce_single_store_capability(manifest, adapter_name)?; } Ok(()) @@ -2410,6 +2594,12 @@ source = "target/wasm32-wasip2/release/demo.wasm" fn args_for(manifest: &Path) -> ConfigValidateArgs { ConfigValidateArgs { app_config: None, + adapter: None, + store_bindings: None, + target: None, + target_features: Vec::new(), + target_all_features: false, + target_no_default_features: false, manifest: manifest.to_path_buf(), no_env: true, // tests don't want env leakage strict: false, @@ -2420,6 +2610,7 @@ source = "target/wasm32-wasip2/release/demo.wasm" ConfigPushArgs { adapter: adapter.to_owned(), app_config: None, + store_bindings: None, dry_run: false, key: None, local: false, @@ -2435,6 +2626,124 @@ source = "target/wasm32-wasip2/release/demo.wasm" // ---------- raw flow ---------- + #[cfg(feature = "edgezero-adapter-axum")] + const AWS_BINDINGS_FIXTURE: &str = r#"version = 1 +[config.remote] +provider = "aws-appconfig-agent" +default_key = "main" +endpoint = "http://127.0.0.1:2772" +[config.remote.documents.main] +application = "demo-app" +environment = "production" +profile = "envelope" +"#; + + #[cfg(feature = "edgezero-adapter-axum")] + #[test] + fn bindings_only_validation_does_not_require_app_toml() { + let manifest_text = r#" +[app] +name = "demo-app" +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" +[stores.config] +ids = ["remote"] +default = "remote" +"#; + let (dir, manifest, app_config) = setup_project(manifest_text, "not used"); + fs::remove_file(&app_config).expect("remove local config"); + let bindings_path = dir.path().join("bindings.toml"); + fs::write(&bindings_path, AWS_BINDINGS_FIXTURE).expect("write native bindings"); + let args = ConfigValidateArgs { + app_config: None, + adapter: Some("axum".to_owned()), + store_bindings: Some(bindings_path), + target: None, + target_features: Vec::new(), + target_all_features: false, + target_no_default_features: false, + manifest, + no_env: false, + strict: false, + }; + run_config_validate(&args).expect("structural validation does not load app TOML"); + } + + #[cfg(feature = "edgezero-adapter-axum")] + #[test] + fn push_guard_rejects_only_the_selected_aws_config_id() { + let manifest_text = PUSH_MANIFEST.replace( + "ids = [\"app_config\"]", + "ids = [\"app_config\", \"remote\"]\ndefault = \"app_config\"", + ); + let (dir, manifest, app_config) = setup_project(&manifest_text, VALID_APP_CONFIG); + fs::remove_file(app_config).expect("guard must not require local app TOML"); + let bindings_dir = tempfile::tempdir().expect("bindings directory"); + let bindings = bindings_dir.path().join("bindings.toml"); + fs::write( + &bindings, + AWS_BINDINGS_FIXTURE.replace("remote", "app_config"), + ) + .expect("write bindings"); + let mut args = push_args(&manifest, "axum"); + args.store_bindings = Some(bindings.clone()); + let error = reject_aws_bound_config_push(&args).expect_err("AWS-bound push is refused"); + assert!(error.contains("does not publish to AWS-bound")); + for (local, dry_run) in [(false, false), (true, false), (false, true), (true, true)] { + args.local = local; + args.dry_run = dry_run; + let push_error = run_config_push_typed::(&args) + .expect_err("public typed push refuses AWS before loading app TOML"); + assert!(push_error.contains("does not publish to AWS-bound")); + assert!( + !dir.path().join(".edgezero").exists(), + "refusal performs no local write" + ); + } + + fs::write(&bindings, AWS_BINDINGS_FIXTURE).expect("write unrelated AWS binding"); + reject_aws_bound_config_push(&args).expect("unrelated declared AWS binding is allowed"); + fs::write(&bindings, AWS_BINDINGS_FIXTURE.replace("remote", "unknown")) + .expect("write typo"); + reject_aws_bound_config_push(&args) + .expect_err("undeclared bindings are not a valid push selection"); + } + + #[cfg(feature = "edgezero-adapter-axum")] + #[test] + fn bindings_only_validation_and_push_share_structural_errors() { + let (dir, manifest, app_config) = setup_project(PUSH_MANIFEST, FIXTURE_APP_CONFIG); + fs::remove_file(app_config).expect("neither path needs app TOML for this rejection"); + let bindings = dir.path().join("bindings.toml"); + let mut validate = args_for(&manifest); + validate.adapter = Some("axum".to_owned()); + validate.store_bindings = Some(bindings.clone()); + let mut push = push_args(&manifest, "axum"); + push.store_bindings = Some(bindings.clone()); + for (input, expected) in [ + ("not valid TOML", "invalid native store bindings"), + ( + AWS_BINDINGS_FIXTURE, + "native store bindings do not match manifest declarations", + ), + ] { + fs::write(&bindings, input).expect("write invalid bindings"); + assert_eq!( + run_config_validate(&validate).expect_err("invalid bindings"), + expected + ); + assert_eq!( + run_config_push_typed::(&push).expect_err("invalid bindings"), + expected, + ); + assert!(!dir.path().join(".edgezero").exists()); + } + } + #[test] fn raw_validates_a_well_formed_project() { let _lock = manifest_guard().lock().expect("manifest guard"); @@ -3352,6 +3661,34 @@ default = "alpha" ); } + #[cfg(feature = "edgezero-adapter-axum")] + #[test] + fn axum_multi_secret_ids_pass_strict_validation_and_typed_push() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let manifest_text = PUSH_MANIFEST.replace( + "ids = [\"default\"]", + "ids = [\"default\", \"extra\"]\ndefault = \"default\"", + ); + let (dir, manifest, _) = setup_project(&manifest_text, FIXTURE_APP_CONFIG); + let mut args = args_for(&manifest); + args.strict = true; + run_config_validate(&args).expect("Axum accepts multiple secret IDs"); + run_config_validate_typed::(&args) + .expect("typed Axum validation accepts multiple secret IDs"); + + let mut push = push_args(&manifest, "axum"); + push.yes = true; + run_config_push_typed::(&push) + .expect("Axum push accepts multiple secret IDs"); + let raw = fs::read_to_string(dir.path().join(".edgezero/local-config-app_config.json")) + .expect("push wrote the local snapshot"); + let entries: BTreeMap = serde_json::from_str(&raw).expect("snapshot map"); + let envelope: BlobEnvelope = + serde_json::from_str(&entries["app_config"]).expect("complete envelope"); + assert_eq!(envelope.data["greeting"], "hello"); + assert_eq!(envelope.data["api_token"], "demo_api_token"); + } + #[test] fn strict_handler_paths_rejects_malformed_handler() { let _lock = manifest_guard().lock().expect("manifest guard"); @@ -4097,6 +4434,96 @@ timeout_ms = 50 // ---------- push runs the strict preflight (regression) ---------- + #[test] + fn selected_adapter_strict_checks_preserve_portability_validation() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let manifest_text = format!( + "{}\n[adapters.spin.adapter]\ncrate = \"unused\"\nmanifest = \"spin.toml\"\n", + PUSH_MANIFEST + .replace("adapters.axum", "adapters.fastly") + .replace( + "ids = [\"default\"]", + "ids = [\"default\", \"extra\"]\ndefault = \"default\"" + ) + ); + let (dir, manifest, _) = setup_project(&manifest_text, FIXTURE_APP_CONFIG); + fs::write(dir.path().join("spin.toml"), VALID_SPIN_TOML).unwrap(); + let mut args = args_for(&manifest); + args.strict = true; + let ctx = load_validation_context(&args).unwrap(); + run_shared_checks(&ctx, Some("fastly")) + .expect("selected multi-store adapter accepts multiple stores"); + for selected in [None, Some("spin")] { + let err = run_shared_checks(&ctx, selected).unwrap_err(); + assert!(err.contains("Single") && err.contains("secrets"), "{err}"); + } + } + + /// Selection folds case the same way `Manifest::adapter_entry` and the + /// registry do, so `[adapters.Spin]` with `--adapter spin` (or the reverse) + /// still runs the selected adapter's checks. + #[test] + fn selected_adapter_filter_matches_manifest_key_case_insensitively() { + let _lock = manifest_guard().lock().expect("manifest guard"); + for (manifest_key, selected) in [("Spin", "spin"), ("spin", "SPIN")] { + let manifest_text = format!( + "{}\n[adapters.{manifest_key}.adapter]\ncrate = \"unused\"\nmanifest = \"spin.toml\"\n", + PUSH_MANIFEST + .replace("adapters.axum", "adapters.fastly") + .replace( + "ids = [\"default\"]", + "ids = [\"default\", \"extra\"]\ndefault = \"default\"" + ) + ); + let (dir, manifest, _) = setup_project(&manifest_text, FIXTURE_APP_CONFIG); + fs::write(dir.path().join("spin.toml"), VALID_SPIN_TOML).unwrap(); + let mut args = args_for(&manifest); + args.strict = true; + let ctx = load_validation_context(&args).unwrap(); + let err = run_shared_checks(&ctx, Some(selected)).unwrap_err(); + assert!( + err.contains("Single") && err.contains("secrets"), + "[adapters.{manifest_key}] with --adapter {selected} must still be validated: {err}" + ); + } + } + + #[test] + fn selected_adapter_push_and_diff_ignore_unrelated_adapter_constraints() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let manifest_text = format!( + "{PUSH_MANIFEST}\n[adapters.spin.adapter]\ncrate = \"unused\"\nmanifest = \"spin.toml\"\n" + ); + for spin_manifest in ["spin_manifest_version = 2\n", VALID_SPIN_TOML] { + let app_config = FIXTURE_APP_CONFIG.replace("demo_api_token", "Private-Reference"); + let (dir, manifest, _) = setup_project(&manifest_text, &app_config); + fs::write(dir.path().join("spin.toml"), spin_manifest).unwrap(); + let mut push = push_args(&manifest, "axum"); + push.local = true; + push.dry_run = true; + run_config_push_typed::(&push) + .expect("an unrelated adapter must not block a selected-adapter push"); + let diff = ConfigDiffArgs { + adapter: "axum".into(), + app_config: None, + exit_code: false, + format: DiffFormat::Unified, + key: None, + local: true, + manifest: manifest.clone(), + no_env: true, + runtime_config: None, + store: None, + staging: false, + }; + run_config_diff_typed::(&diff) + .expect("an unrelated adapter must not block a selected-adapter diff"); + assert!(run_config_validate_typed::(&args_for(&manifest)).is_err()); + push.adapter = "spin".into(); + assert!(run_config_push_typed::(&push).is_err()); + } + } + /// Push must run the same shared adapter checks `config /// validate` runs (strict pre-flight). Pre-fix, /// `load_push_context` synthesised `ConfigValidateArgs { strict: @@ -4192,20 +4619,11 @@ default = "one" "spin_manifest_version = 2\n[application]\nname = \"x\"\nversion = \"0\"\n[component.demo]\nsource = \"a.wasm\"\n", ) .expect("write spin.toml"); - // Adapter the push targets doesn't matter — the strict - // capability check fires per declared adapter set. We - // push to axum to keep the rest of the flow simple. - let err = run_config_push_typed::(&push_args(&manifest, "axum")) + // Only the selected adapter's capability check rejects the push. + let err = run_config_push_typed::(&push_args(&manifest, "spin")) .expect_err("Single-capable adapter with multi-id store must fail preflight"); - // BTreeMap iteration order on the manifest's adapter set - // means the check reports whichever Single-capable - // adapter sorts first (axum or spin) — both are - // Single-capable for secrets in this fixture. The - // contract that matters is "the strict check ran before - // the per-adapter push", which the `Single` + - // `secrets` substrings prove. assert!( - err.contains("Single") && err.contains("secrets"), + err.contains("spin") && err.contains("Single") && err.contains("secrets"), "error must come from --strict capability check: {err}" ); } diff --git a/crates/edgezero-cli/src/demo_server.rs b/crates/edgezero-cli/src/demo_server.rs index 0f703642..54d9e512 100644 --- a/crates/edgezero-cli/src/demo_server.rs +++ b/crates/edgezero-cli/src/demo_server.rs @@ -4,7 +4,7 @@ //! //! `demo` runs the bundled `app-demo` example locally — the **same way** //! `app-demo`'s own axum adapter runs it: via -//! [`edgezero_adapter_axum::dev_server::run_app`], which reads the store +//! [`edgezero_adapter_axum::dev_server::run_app_with_preflight`], which reads the store //! config baked into `App` plus `EDGEZERO__*` environment variables and //! wires the full setup (routing, KV / config / secret stores, logging, //! host/port). @@ -15,16 +15,20 @@ /// Run the bundled `app-demo` example on the local axum server. /// -/// Delegates to `run_app`, so `edgezero demo` behaves identically to -/// `cargo run -p app-demo-adapter-axum`. +/// Delegates logger and server setup to Axum. Capability validation runs after +/// logging is ready and before application configuration or listener binding. /// /// # Errors /// /// Returns an error if the demo server fails to start. pub fn run_demo() -> Result<(), String> { use app_demo_core::App; - use edgezero_adapter_axum::dev_server::run_app; + use edgezero_adapter_axum::dev_server::run_app_with_preflight; + use std::io; - crate::demo_capability_gate::()?; - run_app::().map_err(|err| format!("demo server error: {err}")) + run_app_with_preflight::(|| { + crate::demo_capability_gate::().map_err(io::Error::other)?; + Ok(()) + }) + .map_err(|err| format!("demo server error: {err}")) } diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index 2a6630f3..60ca8d84 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -1440,6 +1440,24 @@ mod tests { } fn assert_generated_axum_entrypoint(project_dir: &Path) { + let axum_cargo = + fs::read_to_string(project_dir.join("crates/demo-app-adapter-axum/Cargo.toml")) + .expect("read generated axum Cargo.toml"); + for feature in [ + "aws-appconfig-agent", + "aws-secrets-manager", + "aws-stores", + "aws-dynamodb", + ] { + assert!( + axum_cargo.contains(feature), + "missing generated native feature {feature}" + ); + } + assert!( + axum_cargo.contains("default = []"), + "AWS features must be opt-in" + ); let axum_main = fs::read_to_string(project_dir.join("crates/demo-app-adapter-axum/src/main.rs")) .expect("read axum main.rs"); @@ -1447,7 +1465,7 @@ mod tests { !axum_main.contains("process::exit") && !axum_main.contains("EdgeZeroAxumService") && !axum_main.contains("into_axum_response") - && axum_main.contains("run_app::()"), + && axum_main.contains("run_generated_app::()"), "generated Axum entrypoint must use the connection-owning runner", ); } @@ -1465,10 +1483,17 @@ mod tests { project_dir.join("crates/demo-app-adapter-cloudflare/wrangler.toml"), ) .expect("read Cloudflare wrangler.toml"); - assert!( - manifest.contains("compatibility_flags = [\"enable_request_signal\"]"), - "generated Cloudflare manifest must expose the request abort signal", - ); + let parsed: toml::Value = toml::from_str(&manifest).expect("parse Wrangler manifest"); + let flags = parsed + .get("compatibility_flags") + .and_then(toml::Value::as_array) + .expect("Cloudflare compatibility flags"); + for required in ["enable_request_signal", "enable_weak_ref"] { + assert!( + flags.iter().any(|flag| flag.as_str() == Some(required)), + "generated Cloudflare manifest must enable {required}" + ); + } } fn assert_generated_fastly_entrypoint(project_dir: &Path) { diff --git a/crates/edgezero-cli/src/main.rs b/crates/edgezero-cli/src/main.rs index bdcf076c..491c4492 100644 --- a/crates/edgezero-cli/src/main.rs +++ b/crates/edgezero-cli/src/main.rs @@ -6,8 +6,14 @@ fn main() { use edgezero_cli::args::{self, Args, Command, ConfigCmd}; use std::process; + let cmd = Args::parse().cmd; + #[cfg(feature = "demo-example")] + if !matches!(&cmd, Command::Demo) { + edgezero_cli::init_cli_logger(); + } + #[cfg(not(feature = "demo-example"))] edgezero_cli::init_cli_logger(); - let result = match Args::parse().cmd { + let result = match cmd { Command::Auth(cmd_args) => edgezero_cli::run_auth(&cmd_args), Command::Build(cmd_args) => edgezero_cli::run_build(&cmd_args), // `config push` and `config diff` require a typed app-config struct @@ -18,7 +24,7 @@ fn main() { #[expect( clippy::print_stderr, reason = "intentional: pointer text must reach the user even when \ - stdout is piped; this is the only stderr write in main" + stdout is piped" )] { eprintln!("{}", args::STUB_POINTER_AFTER_HELP); @@ -42,7 +48,13 @@ fn main() { Command::Serve(cmd_args) => edgezero_cli::run_serve(&cmd_args), }; if let Err(err) = result { - log::error!("[edgezero] {err}"); + #[expect( + clippy::print_stderr, + reason = "terminal command errors must remain visible when runtime logging is Off" + )] + { + eprintln!("[edgezero] {err}"); + }; process::exit(1); } } diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 723576f9..f8c49fc6 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -189,6 +189,27 @@ mod tests { .expect("axum dry-run also exits 0"); } + #[cfg(feature = "edgezero-adapter-axum")] + #[test] + fn axum_multi_secret_ids_pass_provision() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + let manifest = PROVISION_MANIFEST.replace( + "ids = [\"default\"]", + "ids = [\"default\", \"extra\"]\ndefault = \"default\"", + ); + fs::write(&manifest_path, manifest).expect("write manifest"); + for dry_run in [true, false] { + run_provision(&ProvisionArgs { + adapter: "axum".to_owned(), + dry_run, + manifest: manifest_path.clone(), + }) + .expect("Axum provision accepts multiple secret IDs"); + } + } + #[test] fn run_provision_errors_on_unknown_adapter() { let _lock = manifest_guard().lock().expect("manifest guard"); diff --git a/crates/edgezero-cli/src/templates/root/README.md.hbs b/crates/edgezero-cli/src/templates/root/README.md.hbs index 31498396..a0c6f348 100644 --- a/crates/edgezero-cli/src/templates/root/README.md.hbs +++ b/crates/edgezero-cli/src/templates/root/README.md.hbs @@ -43,6 +43,21 @@ that retain partial results after cancellation should also require the appropria {{{readme_adapter_dev}}} +## Logging + +Axum and Fastly entrypoints delegate logging to the reusable adapter runners; +do not initialize a second CLI logger before a runtime runner. Ordinary levels +come from `EDGEZERO__LOGGING__LEVEL` (Fastly reads its runtime Config Store), with +`Info` as the absent/invalid fallback. Managed boot warnings use `edgezero::boot` +at `Warn`, independently of ordinary `Error` or `Off`. + +Cloudflare and Spin logger initializers are no-ops, not Rust facade backends. +To own logging on any target, set `owns_logging = true` in `app!`, install your +backend before entering the runner, and configure both backend and facade filters. +Reuse `edgezero_core::resolve_logging_level(&EnvConfig)`, `BOOT_LOG_TARGET`, and +`BOOT_LOG_LEVEL`; raising only `log::set_max_level` cannot restore backend-filtered +messages. No logging implementation is duplicated in the generated entrypoints. + ## Configuration Environment variables are declared in `edgezero.toml`. Set `API_BASE_URL` to the upstream origin you want `/proxy/...` to target; the handlers read it through `RequestContext::variable`, including from the declared Cloudflare Worker binding. Adapter-specific secrets are opt-in: uncomment the `#[secret]` field in `crates/{{proj_core}}/src/config.rs` and the matching `[stores.secrets]` block in `edgezero.toml`, then provide the secret value through the wired backend (Cloudflare Worker secret, Fastly Secret Store, Spin secret variable, ...). @@ -74,6 +89,11 @@ cargo run -p {{proj_cli}} -- config diff --adapter --format json --exit-c Uncomment `[stores.config]` in `edgezero.toml` (and the matching adapter binding in the per-adapter manifest) before running `config push`. +For Axum, push before starting the server or restart after updating config: the +runner loads bounded immutable snapshots before listener binding. Configure allocation +budgets through `EDGEZERO__ADAPTER__CONFIG_STORE__*`; invalid budgets fail startup. +Raw custom store implementations return shared `edgezero_core::ConfigValue`, while +the typed config extractor and generated handlers require no manual store reads. Use `--key ` for per-environment overrides (staging / canary): the same `{{name}}.toml` can land under multiple keys, and the runtime diff --git a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs index b23d6d64..388d5074 100644 --- a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs +++ b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs @@ -139,6 +139,29 @@ name = "API_BASE_URL" description = "Base URL for upstream API calls" env = "API_BASE_URL" +# Axum enforces finite pre-admission HTTP/1 limits by default. To override a +# limit through the CLI, uncomment an adapter-filtered default below. Parent +# environment values take precedence. See docs/guide/adapters/axum.md. +# [[environment.variables]] +# name = "EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS" +# value = "256" +# adapters = ["axum"] +# +# [[environment.variables]] +# name = "EDGEZERO__ADAPTER__INGRESS__MAX_RAW_HEAD_BYTES" +# value = "65536" +# adapters = ["axum"] +# +# [[environment.variables]] +# name = "EDGEZERO__ADAPTER__INGRESS__MAX_HEADER_COUNT" +# value = "100" +# adapters = ["axum"] +# +# [[environment.variables]] +# name = "EDGEZERO__ADAPTER__INGRESS__HEAD_READ_TIMEOUT_MS" +# value = "10000" +# adapters = ["axum"] + # [[environment.secrets]] # name = "API_TOKEN" # description = "Secret token used when calling the upstream API" diff --git a/crates/edgezero-cli/tests/demo_logging.rs b/crates/edgezero-cli/tests/demo_logging.rs new file mode 100644 index 00000000..a9108679 --- /dev/null +++ b/crates/edgezero-cli/tests/demo_logging.rs @@ -0,0 +1,61 @@ +//! Subprocess probes keep application logger installation isolated from the test runner. +#![cfg(all(feature = "cli", feature = "demo-example"))] + +#[cfg(test)] +mod tests { + use std::net::TcpListener; + use std::process::Command; + + fn demo_startup(level: &str, host: &str) -> (String, String) { + let directory = tempfile::tempdir().expect("isolated demo state"); + let listener = TcpListener::bind("127.0.0.1:0").expect("reserve demo port"); + let port = listener.local_addr().expect("reserved address").port(); + let output = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .arg("demo") + .current_dir(directory.path()) + .env("EDGEZERO__LOGGING__LEVEL", level) + .env("EDGEZERO__ADAPTER__HOST", host) + .env("EDGEZERO__ADAPTER__PORT", port.to_string()) + .output() + .expect("demo subprocess"); + assert!( + !output.status.success(), + "reserved port must prevent serving" + ); + ( + String::from_utf8(output.stdout).expect("demo stdout"), + String::from_utf8(output.stderr).expect("demo stderr"), + ) + } + + #[test] + fn demo_respects_off_without_hiding_terminal_errors() { + let (stdout, stderr) = demo_startup("off", "not-an-ip"); + assert!(!stdout.contains("starting axum server"), "{stdout}"); + assert!(stdout.contains("[edgezero::boot]"), "{stdout}"); + assert!(stdout.contains("not a valid IP address"), "{stdout}"); + assert!( + stderr.contains("[edgezero] demo server error: failed to bind"), + "{stderr}" + ); + assert!( + !stderr.contains("initialize application logger"), + "{stderr}" + ); + } + + #[test] + fn demo_uses_application_logger_not_the_cli_backend() { + let (stdout, stderr) = demo_startup("info", "127.0.0.1"); + assert!(stdout.contains("INFO"), "{stdout}"); + assert!( + stdout.contains("[edgezero_adapter_axum::dev_server]"), + "{stdout}" + ); + assert!(stdout.contains("starting axum server"), "{stdout}"); + assert!( + stderr.contains("[edgezero] demo server error: failed to bind"), + "{stderr}" + ); + } +} diff --git a/crates/edgezero-cli/tests/dynamodb_generated_proof.rs b/crates/edgezero-cli/tests/dynamodb_generated_proof.rs new file mode 100644 index 00000000..50bc842c --- /dev/null +++ b/crates/edgezero-cli/tests/dynamodb_generated_proof.rs @@ -0,0 +1,592 @@ +//! Ignored end-to-end proof for the generated Axum `DynamoDB` KV consumer. +//! +//! This test runs explicitly in CI. To run it locally, use +//! `cargo test -p edgezero-cli --test dynamodb_generated_proof -- --ignored`. +//! The generated child uses the real generated `main`/`run_generated_app`; only its handler +//! and manifest are extended in the temporary project. SDK traffic uses a literal-loopback +//! fixture with dummy credentials and a cleared child environment. Serialize builds that share +//! `EDGEZERO_GENERATED_TEST_TARGET_DIR`. + +#[cfg(test)] +mod tests { + #[cfg(unix)] + mod generated_dynamodb_proof { + #![expect( + clippy::arbitrary_source_item_ordering, + reason = "group test fixture types and their protocol helpers by responsibility" + )] + + use std::collections::BTreeMap; + use std::env; + use std::fs; + use std::io::{ErrorKind, Read as _, Write as _}; + use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::path::{Path, PathBuf}; + use std::process::{Child, Command, Stdio}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::thread::{self, JoinHandle}; + use std::time::{Duration, Instant}; + + use serde_json::{Value, json}; + use tempfile::TempDir; + + const WAIT: Duration = Duration::from_secs(25); + const TABLE: &str = "edgezero-kv"; + const NAMESPACE: &str = "fixture/state"; + + fn deadline_after(duration: Duration) -> Instant { + Instant::now() + .checked_add(duration) + .expect("proof deadline within supported range") + } + + /// Tiny `DynamoDB` JSON-protocol fixture. Persists rows across generated process restarts and + /// implements only the DescribeTable/GetItem/PutItem/DeleteItem/Query calls this proof sends. + struct DynamoFixture { + endpoint: String, + requests: Arc, + stop: Arc, + worker: Option>, + } + + impl DynamoFixture { + fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind literal loopback"); + listener.set_nonblocking(true).expect("nonblocking fixture"); + let endpoint = format!("http://{}", listener.local_addr().expect("fixture addr")); + let rows = Arc::new(Mutex::new(BTreeMap::new())); + let shared_rows = Arc::clone(&rows); + let stop = Arc::new(AtomicBool::new(false)); + let worker_stop = Arc::clone(&stop); + let requests = Arc::new(AtomicUsize::new(0)); + let worker_requests = Arc::clone(&requests); + let worker = thread::spawn(move || { + let deadline = deadline_after(Duration::from_mins(3)); + while !worker_stop.load(Ordering::Relaxed) && Instant::now() < deadline { + match listener.accept() { + Ok((mut stream, peer)) => { + assert!(peer.ip().is_loopback(), "fixture accepts loopback only"); + stream + .set_read_timeout(Some(Duration::from_secs(4))) + .expect("read timeout"); + stream + .set_write_timeout(Some(Duration::from_secs(4))) + .expect("write timeout"); + worker_requests.fetch_add(1, Ordering::Relaxed); + let (target, body) = read_aws_request(&mut stream); + let response = dispatch(&target, &body, &shared_rows); + write_aws_response(&mut stream, &response); + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("DynamoDB fixture accept: {error}"), + } + } + }); + Self { + endpoint, + requests, + stop, + worker: Some(worker), + } + } + + fn count(&self) -> usize { + self.requests.load(Ordering::Relaxed) + } + } + + impl Drop for DynamoFixture { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(worker) = self.worker.take() { + let _joined = worker.join(); + } + } + } + + fn read_aws_request(stream: &mut TcpStream) -> (String, Value) { + let mut data = Vec::new(); + let mut chunk = [0_u8; 4096]; + let header_end = loop { + let count = stream.read(&mut chunk).expect("read request"); + assert_ne!(count, 0, "request ended before headers"); + let read_bytes = chunk + .get(..count) + .expect("Read does not report more bytes than the supplied buffer"); + data.extend_from_slice(read_bytes); + if let Some(index) = data.windows(4).position(|window| window == b"\r\n\r\n") { + break index + .checked_add(4) + .expect("bounded header delimiter offset"); + } + assert!(data.len() < 64 * 1024, "bounded request headers"); + }; + let headers = String::from_utf8( + data.get(..header_end) + .expect("header boundary within request") + .to_vec(), + ) + .expect("HTTP headers"); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().expect("length")) + }) + .expect("content-length"); + while data.len().saturating_sub(header_end) < content_length { + let count = stream.read(&mut chunk).expect("read body"); + assert_ne!(count, 0, "request ended before body"); + let read_bytes = chunk + .get(..count) + .expect("Read does not report more bytes than the supplied buffer"); + data.extend_from_slice(read_bytes); + } + let target = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("x-amz-target") + .then(|| value.trim().to_owned()) + }) + .expect("AWS target header"); + let body_end = header_end + .checked_add(content_length) + .expect("request body boundary does not overflow"); + let body_bytes = data + .get(header_end..body_end) + .expect("complete request body"); + let body = serde_json::from_slice(body_bytes).expect("AWS JSON body"); + (target, body) + } + + fn dispatch( + target: &str, + body: &Value, + rows: &Arc>>, + ) -> Value { + let operation = target.rsplit('.').next().unwrap_or(target); + match operation { + "DescribeTable" => json!({"Table": { + "TableName": TABLE, "TableStatus": "ACTIVE", + "KeySchema": [ + {"AttributeName":"pk", "KeyType":"HASH"}, + {"AttributeName":"sk", "KeyType":"RANGE"} + ], + "AttributeDefinitions": [ + {"AttributeName":"pk", "AttributeType":"S"}, + {"AttributeName":"sk", "AttributeType":"S"} + ], + "GlobalSecondaryIndexes": [], "LocalSecondaryIndexes": [] + }}), + "PutItem" => { + assert_eq!( + body.get("TableName").and_then(Value::as_str), + Some(TABLE), + "PutItem targets the fixture table" + ); + let item = body + .get("Item") + .cloned() + .expect("PutItem request includes Item"); + let key = required_string(string_attribute(&item, "sk"), "PutItem sort key") + .to_owned(); + rows.lock().expect("fixture rows").insert(key, item); + json!({}) + } + "GetItem" => { + assert_eq!( + body.get("TableName").and_then(Value::as_str), + Some(TABLE), + "GetItem targets the fixture table" + ); + let key = required_string( + body.get("Key").and_then(|key| string_attribute(key, "sk")), + "GetItem sort key", + ); + let item = rows.lock().expect("fixture rows").get(key).cloned(); + item.map_or_else(|| json!({}), |found_item| json!({"Item": found_item})) + } + "DeleteItem" => { + assert_eq!( + body.get("TableName").and_then(Value::as_str), + Some(TABLE), + "DeleteItem targets the fixture table" + ); + let key = required_string( + body.get("Key").and_then(|key| string_attribute(key, "sk")), + "DeleteItem sort key", + ); + rows.lock().expect("fixture rows").remove(key); + json!({}) + } + "Query" => { + assert_eq!( + body.get("TableName").and_then(Value::as_str), + Some(TABLE), + "Query targets the fixture table" + ); + let values = body + .get("ExpressionAttributeValues") + .expect("Query includes expression values"); + let pk = + required_string(string_attribute(values, ":pk"), "Query partition key"); + let prefix = string_attribute(values, ":prefix").unwrap_or(""); + let limit = body + .get("Limit") + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(1000); + let all = rows.lock().expect("fixture rows"); + let items = all + .values() + .filter(|item| { + string_attribute(item, "pk") == Some(pk) + && string_attribute(item, "sk") + .is_some_and(|key| key.starts_with(prefix)) + }) + .take(limit) + .cloned() + .collect::>(); + json!({"Items": items, "Count": items.len(), "ScannedCount": items.len()}) + } + unknown => panic!("unexpected DynamoDB operation {unknown}: {body}"), + } + } + + fn string_attribute<'attribute>( + item: &'attribute Value, + name: &str, + ) -> Option<&'attribute str> { + item.get(name)?.get("S")?.as_str() + } + + fn required_string<'attribute>( + value: Option<&'attribute str>, + field: &str, + ) -> &'attribute str { + value.expect(field) + } + + fn write_aws_response(stream: &mut TcpStream, body: &Value) { + let bytes = serde_json::to_vec(body).expect("serialize response"); + write!(stream, + "HTTP/1.1 200 OK\r\ncontent-type: application/x-amz-json-1.0\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + bytes.len()).expect("response headers"); + stream.write_all(&bytes).expect("response body"); + } + + struct GeneratedApp { + address: SocketAddr, + child: Child, + } + + impl GeneratedApp { + fn start( + executable: &Path, + project: &Path, + binding: &Path, + endpoint: &str, + mode: &str, + ) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("reserve app port"); + let address = listener.local_addr().expect("app address"); + drop(listener); + let mut command = Command::new(executable); + command + .current_dir(project) + .env_clear() + .env("HOME", project.join("empty-home")) + .env("AWS_ACCESS_KEY_ID", "dummy-access") + .env("AWS_SECRET_ACCESS_KEY", "dummy-secret") + .env("AWS_EC2_METADATA_DISABLED", "true") + .env("AWS_CONFIG_FILE", project.join("empty-aws-config")) + .env( + "AWS_SHARED_CREDENTIALS_FILE", + project.join("empty-aws-creds"), + ) + .env("AWS_ENDPOINT_URL", endpoint) + .env("EDGEZERO__STORE_BINDINGS_FILE", binding) + .env("EDGEZERO__DATA_DIR", project.join("must-not-create-data")) + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", address.port().to_string()) + .env("KV_PROOF_MODE", mode) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + Self { + child: command.spawn().expect("spawn generated app"), + address, + } + } + + fn request(&mut self, path: &str, body: Option<&str>) -> String { + let deadline = deadline_after(WAIT); + loop { + if let Some(status) = self.child.try_wait().expect("child status") { + panic!("generated server exited before request {path}: {status}"); + } + if let Ok(mut stream) = + TcpStream::connect_timeout(&self.address, Duration::from_millis(50)) + { + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("response timeout"); + let (method, content_type, payload) = body + .map_or(("GET", "", ""), |value| { + ("POST", "Content-Type: application/json\r\n", value) + }); + write!(stream, "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n{content_type}Content-Length: {}\r\nConnection: close\r\n\r\n{payload}", payload.len()).expect("send handler request"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("read handler response"); + if !response.starts_with("HTTP/1.1 503") + && !response.starts_with("HTTP/1.1 500") + { + return response; + } + return response; + } + assert!( + Instant::now() < deadline, + "generated listener did not start" + ); + thread::sleep(Duration::from_millis(10)); + } + } + + fn stop(mut self) { + if self.child.try_wait().expect("child status").is_none() { + let _killed = self.child.kill(); + } + self.child.wait().expect("reap generated child"); + } + } + + impl Drop for GeneratedApp { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _killed = self.child.kill(); + let _reaped = self.child.wait(); + } + } + } + + fn install_consumer(project: &Path) { + let manifest = + fs::read_to_string(project.join("edgezero.toml")).expect("generated manifest"); + let entry = manifest + .lines() + .find_map(|line| line.strip_prefix("entry = ")) + .expect("generated core entry") + .trim_matches('"'); + let core_dir = project.join(entry); + let core_name = core_dir + .file_name() + .expect("core directory") + .to_string_lossy() + .replace('-', "_"); + let handlers = core_dir.join("src/handlers.rs"); + let mut handler_source = fs::read_to_string(&handlers).expect("generated handlers"); + handler_source.push_str(r#" +#[action] +pub async fn kv_probe(kv: edgezero_core::extractor::Kv) -> Result, EdgeError> { + let store = kv.default().ok_or_else(|| EdgeError::service_unavailable("default KV unavailable"))?; + let mode = std::env::var("KV_PROOF_MODE").unwrap_or_default(); + match mode.as_str() { + "put" => { + store.put("fixture/item", &"persisted-value").await?; + store.put_with_ttl("fixture/ttl", &"ttl-value", std::time::Duration::from_secs(60)).await?; + } + "ttl" => { store.put_with_ttl("fixture/ttl", &"ttl-value", std::time::Duration::from_secs(60)).await?; } + "delete" => { store.delete("fixture/item").await?; } + _ => {} + } + let value = store.get::("fixture/item").await?.unwrap_or_else(|| "missing".to_owned()); + let ttl = store.get::("fixture/ttl").await?.unwrap_or_else(|| "missing".to_owned()); + let page = store.list_keys_page("fixture/", None, 100).await?; + Ok(Text::new(format!("value={value};ttl={ttl};keys={}", page.keys.join(",")))) +} +"#); + fs::write(handlers, handler_source).expect("write temporary generated handler"); + + let manifest_path = project.join("edgezero.toml"); + let mut manifest_source = + fs::read_to_string(&manifest_path).expect("generated manifest"); + manifest_source.push_str( + r#" +[[triggers.http]] +id = "kv-proof" +path = "/kv-proof" +methods = ["GET", "POST"] +handler = "{core_name}::handlers::kv_probe" +adapters = ["axum"] + +[stores.kv] +ids = ["state"] +"#, + ); + let rendered_manifest = manifest_source.replace("{core_name}", &core_name); + fs::write(manifest_path, rendered_manifest) + .expect("write temporary generated manifest"); + } + + fn write_bindings(project: &Path) -> PathBuf { + let path = project.join("bindings.toml"); + let bindings = format!( + r#"version = 1 + +[kv.state] +provider = "aws-dynamodb" +region = "us-east-1" +table = "{TABLE}" +namespace = "{NAMESPACE}" +"# + ); + fs::write(&path, bindings).expect("write native TOML bindings fixture"); + path + } + + fn target_dir(project: &Path) -> PathBuf { + env::var_os("EDGEZERO_GENERATED_TEST_TARGET_DIR") + .map_or_else(|| project.join("target"), PathBuf::from) + } + + fn build_generated(project: &Path, target: &Path, enabled: bool) -> PathBuf { + let mut command = Command::new(env!("CARGO_BIN_EXE_edgezero")); + command.args(["build", "--adapter", "axum", "--", "--offline"]); + if enabled { + command.args(["--features", "aws-dynamodb"]); + } + let output = command + .current_dir(project) + .env("CARGO_TARGET_DIR", target) + .output() + .expect("real edgezero build"); + assert!( + output.status.success(), + "generated {}build should succeed: {}", + if enabled { "enabled " } else { "default-off " }, + String::from_utf8_lossy(&output.stderr) + ); + target.join("debug/dynamodb-proof-adapter-axum") + } + + fn prove_default_off( + project: &Path, + bindings: &Path, + fixture: &DynamoFixture, + binary: &Path, + ) { + // Reject the explicit binding before local data, SDK calls, or listener startup. + let mut rejected = + GeneratedApp::start(binary, project, bindings, &fixture.endpoint, "read"); + let exit_deadline = deadline_after(WAIT); + loop { + if rejected + .child + .try_wait() + .expect("default-off process state") + .is_some() + { + break; + } + assert!( + Instant::now() < exit_deadline, + "default-off selection unexpectedly listened" + ); + thread::sleep(Duration::from_millis(10)); + } + assert!( + !project.join("must-not-create-data").exists(), + "default-off selection must not create local DATA_DIR" + ); + assert!( + TcpStream::connect_timeout(&rejected.address, Duration::from_millis(50)).is_err(), + "default-off selection must not bind listener" + ); + assert_eq!( + fixture.count(), + 0, + "provider rejection must happen before SDK calls" + ); + rejected.stop(); + } + + fn prove_enabled_consumer( + project: &Path, + bindings: &Path, + fixture: &DynamoFixture, + binary: &Path, + ) { + let mut first = + GeneratedApp::start(binary, project, bindings, &fixture.endpoint, "put"); + let first_response = first.request("/kv-proof", None); + assert!( + first_response.contains("200 OK") && first_response.contains("persisted-value"), + "KV put/get: {first_response}" + ); + assert!( + first_response.contains("fixture/item"), + "KV query/list: {first_response}" + ); + assert!( + first_response.contains("ttl-value"), + "TTL write/read: {first_response}" + ); + first.stop(); + + // A fresh generated process reads fixture-retained data, not process-local state. + let mut second = + GeneratedApp::start(binary, project, bindings, &fixture.endpoint, "read"); + let second_response = second.request("/kv-proof", None); + assert!( + second_response.contains("persisted-value"), + "data survives generated process restart: {second_response}" + ); + second.stop(); + + let mut third = + GeneratedApp::start(binary, project, bindings, &fixture.endpoint, "delete"); + let third_response = third.request("/kv-proof", None); + assert!( + third_response.contains("value=missing") + && third_response.contains("ttl=ttl-value"), + "KV delete preserves other key: {third_response}" + ); + third.stop(); + } + + #[test] + #[ignore = "builds and launches a generated native app; coordinator owns Cargo integration"] + fn generated_cli_default_off_and_enabled_dynamodb_crud_list_ttl_restart() { + let temp = TempDir::new().expect("temp project"); + let output = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .args(["new", "dynamodb-proof", "--dir"]) + .arg(temp.path()) + .output() + .expect("real edgezero new"); + assert!( + output.status.success(), + "edgezero new: {}", + String::from_utf8_lossy(&output.stderr) + ); + let project = temp.path().join("dynamodb-proof"); + install_consumer(&project); + let bindings = write_bindings(&project); + let fixture = DynamoFixture::start(); + let target = target_dir(&project); + + let default_binary = build_generated(&project, &target, false); + prove_default_off(&project, &bindings, &fixture, &default_binary); + + // The generated Axum alias opts into the actual provider feature. + let enabled_binary = build_generated(&project, &target, true); + prove_enabled_consumer(&project, &bindings, &fixture, &enabled_binary); + } + } +} diff --git a/crates/edgezero-cli/tests/native_aws_emulator_proof.rs b/crates/edgezero-cli/tests/native_aws_emulator_proof.rs new file mode 100644 index 00000000..eb8d5252 --- /dev/null +++ b/crates/edgezero-cli/tests/native_aws_emulator_proof.rs @@ -0,0 +1,1089 @@ +//! Independent local AWS proof. The runner supplies pinned `DynamoDB` Local, Moto and Agent +//! services. This test uses one real scaffold and one generated build with all three providers. +//! Run with `node scripts/run_native_aws_integration.mjs`. +//! No AWS SDK dependency is added to the CLI or to the temporary application. + +#[cfg(test)] +#[cfg(unix)] +mod tests { + #![expect( + clippy::arbitrary_source_item_ordering, + reason = "Group local-service proof helpers and source fixtures by responsibility." + )] + + use std::collections::BTreeSet; + use std::env; + use std::fmt::Write as _; + use std::fs; + use std::io::{self, Read as _, Write as _}; + use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; + use std::path::{Path, PathBuf}; + use std::process::{Child, Command, ExitStatus, Stdio}; + use std::str::from_utf8; + use std::thread; + use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::{Value, json}; + use tempfile::{NamedTempFile, TempDir}; + use toml::map::Map; + + const WAIT: Duration = Duration::from_secs(45); + const BUILD_WAIT: Duration = Duration::from_mins(15); + const RAW_A: &str = " opaque A\nnot JSON: caf\u{e9}\t\n"; + const RAW_B: &str = "\tupdated B\nnot JSON: e\u{301}\n"; + + struct Services { + dynamodb: String, + secrets: String, + agent: String, + directory: PathBuf, + node: String, + token: String, + } + + impl Services { + fn required() -> Self { + let required = + |name| env::var(name).unwrap_or_else(|_| panic!("required harness env {name}")); + let endpoint = |name| { + let value = required(name); + let address = value + .strip_prefix("http://") + .and_then(|authority| authority.parse::().ok()) + .expect("endpoint must be a literal http://127.0.0.1:"); + assert_eq!(address.ip(), Ipv4Addr::LOCALHOST); + assert_ne!(address.port(), 0); + assert_eq!(value, format!("http://{address}")); + value + }; + let directory = PathBuf::from(required("EDGEZERO_AWS_TEST_AGENT_DIRECTORY")); + assert!(directory.is_absolute() && directory.is_dir()); + let token = required("EDGEZERO_AWS_TEST_AGENT_TOKEN"); + assert_eq!(token, "local-agent-token", "only dummy Agent credentials"); + let node = required("EDGEZERO_AWS_TEST_NODE"); + assert!( + Path::new(&node).is_absolute(), + "runner supplies its Node executable" + ); + Self { + dynamodb: endpoint("EDGEZERO_AWS_TEST_DYNAMODB_ENDPOINT"), + secrets: endpoint("EDGEZERO_AWS_TEST_SECRETS_ENDPOINT"), + agent: endpoint("EDGEZERO_AWS_TEST_AGENT_ENDPOINT"), + directory, + node, + token, + } + } + + fn admin(&self, action: &str, input: &Value) -> Value { + let mut command = Command::new(&self.node); + command + .arg( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../scripts/native_aws_test_admin.mjs"), + ) + .arg(action) + .arg(input.to_string()) + .env("EDGEZERO_AWS_TEST_DYNAMODB_ENDPOINT", &self.dynamodb) + .env("EDGEZERO_AWS_TEST_SECRETS_ENDPOINT", &self.secrets); + let output = checked(command, WAIT, &format!("admin {action}")); + serde_json::from_slice(&output).expect("admin stdout is JSON") + } + + fn publish(&self, message: &str, raw: &str) -> String { + let typed = serde_json::to_string(&BlobEnvelope::new( + json!({"message": message, "nested": {"token": "current"}}), + "2026-10-07T12:00:00Z".to_owned(), + )) + .expect("serialize real typed envelope"); + for (name, bytes) in [ + ("probe:test:raw", raw), + ("probe:test:typed", typed.as_str()), + ] { + let pending = self.directory.join(format!(".{name}.pending")); + fs::write(&pending, bytes).expect("writable Agent mounted directory"); + fs::rename(pending, self.directory.join(name)) + .expect("atomic Agent file replacement"); + } + typed + } + + fn wait_agent(&self, profile: &str, expected: &str) { + let address = self + .agent + .strip_prefix("http://") + .expect("validated endpoint") + .parse() + .expect("literal Agent address"); + let deadline = deadline(WAIT); + loop { + if let Ok((status, body)) = http( + address, + "GET", + &format!("/applications/probe/environments/test/configurations/{profile}"), + &format!("Authorization: Bearer {}\r\n", self.token), + b"", + ) && status == 200 + && body == expected.as_bytes() + { + return; + } + assert!( + Instant::now() < deadline, + "Agent did not independently serve updated {profile}" + ); + thread::sleep(Duration::from_millis(100)); + } + } + } + + /// Output goes to files, so a full pipe cannot deadlock startup or a failed build. All children + /// inherit the runner's process group; the Node runner also cleans descendants on every exit. + struct Process { + child: Child, + stdout: NamedTempFile, + stderr: NamedTempFile, + } + + impl Process { + fn spawn(mut command: Command) -> Self { + let stdout = NamedTempFile::new().expect("stdout file"); + let stderr = NamedTempFile::new().expect("stderr file"); + command + .stdout(Stdio::from(stdout.reopen().expect("stdout handle"))) + .stderr(Stdio::from(stderr.reopen().expect("stderr handle"))); + Self { + child: command.spawn().expect("spawn bounded subprocess"), + stdout, + stderr, + } + } + + fn logs(&self) -> String { + format!( + "{}\n{}", + String::from_utf8_lossy(&fs::read(self.stdout.path()).expect("stdout")), + String::from_utf8_lossy(&fs::read(self.stderr.path()).expect("stderr")) + ) + } + + fn wait(&mut self, timeout: Duration) -> ExitStatus { + let until = deadline(timeout); + loop { + if let Some(status) = self.child.try_wait().expect("subprocess status") { + return status; + } + assert!( + Instant::now() < until, + "subprocess timed out: {}", + self.logs() + ); + thread::sleep(Duration::from_millis(20)); + } + } + } + + impl Drop for Process { + fn drop(&mut self) { + let _killed = self.child.kill(); + let _reaped = self.child.wait(); + } + } + + #[expect( + clippy::print_stdout, + reason = "The mandatory integration runner needs phase progress on slow generated builds and service failures." + )] + fn phase(message: &str) { + println!("{message}"); + } + + fn checked(command: Command, timeout: Duration, label: &str) -> Vec { + let mut process = Process::spawn(command); + assert!( + process.wait(timeout).success(), + "{label} failed: {}", + process.logs() + ); + fs::read(process.stdout.path()).expect("command stdout") + } + + fn deadline(timeout: Duration) -> Instant { + Instant::now() + .checked_add(timeout) + .expect("finite deadline") + } + + fn reserve_address() -> SocketAddr { + TcpListener::bind("127.0.0.1:0") + .expect("reserve loopback port") + .local_addr() + .expect("loopback address") + } + + fn http( + address: SocketAddr, + method: &str, + path: &str, + headers: &str, + body: &[u8], + ) -> io::Result<(u16, Vec)> { + let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(200))?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + write!( + stream, + "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + )?; + stream.write_all(body)?; + let mut response = Vec::new(); + stream.read_to_end(&mut response)?; + let boundary = response + .windows(4) + .position(|bytes| bytes == b"\r\n\r\n") + .ok_or_else(|| io::Error::other("missing HTTP headers end"))?; + let (head_bytes, header_tail) = response + .split_at_checked(boundary) + .ok_or_else(|| io::Error::other("HTTP header boundary"))?; + let head = from_utf8(head_bytes).map_err(io::Error::other)?; + let status = head + .split_whitespace() + .nth(1) + .ok_or_else(|| io::Error::other("missing HTTP status"))? + .parse::() + .map_err(io::Error::other)?; + let mut payload = header_tail + .strip_prefix(b"\r\n\r\n") + .ok_or_else(|| io::Error::other("HTTP response boundary"))? + .to_vec(); + if head + .lines() + .any(|line| line.eq_ignore_ascii_case("transfer-encoding: chunked")) + { + let mut decoded = Vec::new(); + let mut remaining = payload.as_slice(); + loop { + let end = remaining + .windows(2) + .position(|bytes| bytes == b"\r\n") + .ok_or_else(|| io::Error::other("missing chunk header"))?; + let (chunk_header, chunk_tail) = remaining + .split_at_checked(end) + .ok_or_else(|| io::Error::other("chunk header boundary"))?; + let size = usize::from_str_radix( + from_utf8(chunk_header) + .map_err(io::Error::other)? + .split(';') + .next() + .ok_or_else(|| io::Error::other("missing chunk size"))?, + 16, + ) + .map_err(io::Error::other)?; + if size == 0 { + break; + } + let chunk_payload = chunk_tail + .strip_prefix(b"\r\n") + .ok_or_else(|| io::Error::other("chunk header delimiter"))?; + let (data, body_tail) = chunk_payload + .split_at_checked(size) + .ok_or_else(|| io::Error::other("truncated HTTP chunk"))?; + decoded.extend_from_slice(data); + remaining = body_tail + .strip_prefix(b"\r\n") + .ok_or_else(|| io::Error::other("chunk body delimiter"))?; + } + payload = decoded; + } + Ok((status, payload)) + } + + struct App { + process: Process, + address: SocketAddr, + entered: PathBuf, + release: PathBuf, + _startup: TempDir, + } + + impl App { + fn spawn( + project: &Path, + binary: &Path, + bindings: &Path, + services: &Services, + token: &str, + ) -> Self { + let address = reserve_address(); + let startup = tempfile::tempdir_in(project).expect("unique startup gate directory"); + let entered = startup.path().join("entered"); + let release = startup.path().join("release"); + let mut command = Command::new(binary); + command.current_dir(project).env_clear() + .env("HOME", project.join("empty-home")) + .env("AWS_CONFIG_FILE", project.join("empty-aws-config")) + .env("AWS_SHARED_CREDENTIALS_FILE", project.join("empty-aws-credentials")) + .env("AWS_EC2_METADATA_DISABLED", "true") + .env("AWS_ACCESS_KEY_ID", "DUMMYLOCALACCESS123") + .env("AWS_SECRET_ACCESS_KEY", "DUMMYLOCALSECRET456") + .env("AWS_REGION", "us-east-1") + .env("AWS_ENDPOINT_URL", format!("http://{}", reserve_address())) + .env("AWS_ENDPOINT_URL_DYNAMODB", &services.dynamodb) + .env("AWS_ENDPOINT_URL_SECRETS_MANAGER", &services.secrets) + .env("AWS_IGNORE_CONFIGURED_ENDPOINT_URLS", "false") + .env("APPCONFIG_AGENT_TOKEN", token) + .env("EDGEZERO__STORE_BINDINGS_FILE", bindings) + .env("EDGEZERO__DATA_DIR", project.join("must-not-create-data")) + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", address.port().to_string()) + .env("EDGEZERO__LOGGING__LEVEL", "off") + .env("RUST_LOG", "off,aws_config=off,aws_smithy_runtime=off,aws_sdk_dynamodb=off,aws_sdk_secretsmanager=off,reqwest=off,hyper=off") + .env("PROOF_ENTERED", &entered).env("PROOF_RELEASE", &release); + Self { + process: Process::spawn(command), + address, + entered, + release, + _startup: startup, + } + } + + fn ready(&mut self, expected_secret: &str) { + let until = deadline(WAIT); + while !self.entered.is_file() { + assert!( + self.process + .child + .try_wait() + .expect("startup status") + .is_none(), + "startup failed before initializer: {}", + self.process.logs() + ); + self.assert_closed(); + assert!( + Instant::now() < until, + "initializer timeout: {}", + self.process.logs() + ); + thread::sleep(Duration::from_millis(10)); + } + assert_eq!( + fs::read_to_string(&self.entered).expect("resolved initializer marker"), + expected_secret, + "nested secret must resolve before readiness marker" + ); + self.assert_closed(); + fs::write(&self.release, b"release").expect("release initializer"); + while TcpStream::connect_timeout(&self.address, Duration::from_millis(30)).is_err() { + assert!( + self.process + .child + .try_wait() + .expect("startup status") + .is_none(), + "{}", + self.process.logs() + ); + assert!( + Instant::now() < until, + "listener timeout: {}", + self.process.logs() + ); + thread::sleep(Duration::from_millis(10)); + } + } + + fn assert_closed(&self) { + assert!( + TcpStream::connect_timeout(&self.address, Duration::from_millis(30)).is_err(), + "application listened before successful initialization" + ); + } + + fn rejects(mut self) { + let until = deadline(WAIT); + loop { + self.assert_closed(); + assert!( + !self.entered.exists(), + "invalid provider reached typed readiness marker" + ); + if let Some(status) = self + .process + .child + .try_wait() + .expect("negative startup status") + { + assert!(!status.success(), "invalid startup exited successfully"); + self.assert_closed(); + return; + } + assert!( + Instant::now() < until, + "invalid startup did not fail closed: {}", + self.process.logs() + ); + thread::sleep(Duration::from_millis(10)); + } + } + + fn call(&mut self, request: &Value) -> Value { + assert!( + self.process + .child + .try_wait() + .expect("running app status") + .is_none(), + "{}", + self.process.logs() + ); + let bytes = serde_json::to_vec(request).expect("probe request JSON"); + let (status, response) = http( + self.address, + "POST", + "/proof", + "Content-Type: application/json\r\n", + &bytes, + ) + .expect("bounded application HTTP request"); + assert_eq!( + status, + 200, + "probe {request}: {}", + String::from_utf8_lossy(&response) + ); + serde_json::from_slice(&response).expect("probe JSON response") + } + } + + fn install_consumer(project: &Path) { + let core = project.join("crates/native-aws-probe-core"); + let mut manifest: toml::Value = + toml::from_str(&fs::read_to_string(project.join("edgezero.toml")).expect("manifest")) + .expect("scaffold TOML"); + let root = manifest.as_table_mut().expect("manifest root table"); + root.insert( + "stores".to_owned(), + toml::Value::try_from(json!({ + "config": {"ids": ["app_config"], "default": "app_config"}, + "secrets": {"ids": ["vault"], "default": "vault"}, + "kv": {"ids": ["primary", "secondary"], "default": "primary"} + })) + .expect("store declaration TOML"), + ); + root.get_mut("triggers") + .and_then(toml::Value::as_table_mut) + .expect("scaffold triggers") + .insert( + "http".to_owned(), + toml::Value::try_from(json!([{ + "id": "proof", "path": "/proof", "methods": ["POST"], + "handler": "native_aws_probe_core::handlers::proof", "adapters": ["axum"] + }])) + .expect("probe trigger TOML"), + ); + root.get_mut("adapters") + .and_then(toml::Value::as_table_mut) + .and_then(|adapters| adapters.get_mut("axum")) + .and_then(toml::Value::as_table_mut) + .expect("scaffold Axum adapter") + .entry("build") + .or_insert_with(|| toml::Value::Table(Map::new())) + .as_table_mut() + .expect("Axum build settings") + .insert( + "features".to_owned(), + toml::Value::try_from(vec![ + "aws-appconfig-agent", + "aws-secrets-manager", + "aws-dynamodb", + ]) + .expect("all three native provider features"), + ); + fs::write( + project.join("edgezero.toml"), + toml::to_string(&manifest).expect("manifest TOML"), + ) + .expect("write temporary store declarations"); + let core_manifest = core.join("Cargo.toml"); + let mut dependencies: toml::Value = + toml::from_str(&fs::read_to_string(&core_manifest).expect("core Cargo manifest")) + .expect("core TOML"); + dependencies["dependencies"] + .as_table_mut() + .expect("core dependencies") + .insert("serde_json".to_owned(), toml::Value::String("1".to_owned())); + fs::write( + core_manifest, + toml::to_string(&dependencies).expect("core TOML"), + ) + .expect("temporary core JSON dependency"); + fs::write( + core.join("src/lib.rs"), + "pub mod config;\nmod handlers;\nedgezero_core::app!(\"../../edgezero.toml\");\n", + ) + .expect("minimal temporary generated core"); + fs::write(core.join("src/config.rs"), CONFIG_SOURCE).expect("typed nested config"); + fs::write(core.join("src/handlers.rs"), HANDLER_SOURCE).expect("public store API consumer"); + fs::write( + project.join("native-aws-probe.toml"), + "message = \"local validation only\"\n[nested]\ntoken = \"current\"\n", + ) + .expect("local build validation config"); + fs::write( + project.join("crates/native-aws-probe-adapter-axum/src/main.rs"), + MAIN_SOURCE, + ) + .expect("public production initializer"); + fs::create_dir_all(project.join("empty-home")).expect("isolated HOME"); + fs::write(project.join("empty-aws-config"), "").expect("empty config"); + fs::write(project.join("empty-aws-credentials"), "").expect("empty credentials"); + } + + const CONFIG_SOURCE: &str = " +use serde::{Deserialize, Serialize}; +use validator::Validate; +#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +pub struct NativeAwsProbeConfig { + pub message: String, + #[app_config(nested)] + #[validate(nested)] + pub nested: Nested, +} +#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +pub struct Nested { + #[secret] + #[validate(length(min = 8_u64))] + pub token: String, +} +pub struct StartupState { + pub message: String, + pub token: String, +} +"; + + const MAIN_SOURCE: &str = r#" +use std::sync::Arc; +use std::time::{Duration, Instant}; +use edgezero_adapter_axum::dev_server::run_production_app_with_initializer; +use edgezero_core::extractor::AppConfig; +use edgezero_core::EdgeError; +use native_aws_probe_core::config::{NativeAwsProbeConfig, StartupState}; +fn main() -> anyhow::Result<()> { + run_production_app_with_initializer::(|app, stores| { + Box::pin(async move { + let binding = stores.config().and_then(|registry| registry.named_ref("app_config")) + .ok_or_else(|| EdgeError::service_unavailable("missing typed config binding"))?; + let config = AppConfig::::from_binding( + binding, None, stores.secrets(), app.config_extraction_limits(), app.monotonic_clock(), + ).await?; + let entered = std::env::var("PROOF_ENTERED").map_err(EdgeError::internal)?; + std::fs::write(entered, &config.nested.token).map_err(EdgeError::internal)?; + let release = std::path::PathBuf::from(std::env::var("PROOF_RELEASE").map_err(EdgeError::internal)?); + let deadline = Instant::now() + Duration::from_secs(45); + while !release.is_file() { + if Instant::now() >= deadline { + return Err(EdgeError::service_unavailable("initializer gate timed out")); + } + std::thread::sleep(Duration::from_millis(10)); + } + app.insert_state(Arc::new(StartupState { message: config.message, token: config.nested.token })); + Ok(()) + }) + })?; + Ok(()) +} +"#; + + const HANDLER_SOURCE: &str = r#" +use std::sync::Arc; +use bytes::Bytes; +use edgezero_core::{action, EdgeError}; +use edgezero_core::extractor::{Config, Json as RequestJson, Kv, Secrets, State}; +use edgezero_core::body::Body; +use edgezero_core::http::{Response, StatusCode, response_builder}; +use serde_json::{Value, json}; +use crate::config::StartupState; +#[action] +pub async fn proof( + State(state): State>, config: Config, secrets: Secrets, + kv: Kv, RequestJson(input): RequestJson, +) -> Result { + let op = input["op"].as_str().unwrap_or(""); + if op == "snapshot" { + let config = config.named("app_config").ok_or_else(|| EdgeError::service_unavailable("missing config"))?; + let raw = config.get("raw").await?.ok_or_else(|| EdgeError::service_unavailable("missing raw"))?; + let secrets = secrets.named("vault").ok_or_else(|| EdgeError::service_unavailable("missing secrets"))?; + let mut values = serde_json::Map::new(); + for name in ["current", "pinned", "candidate", "version", "json", "binary"] { + values.insert(name.to_owned(), json!(secrets.require_bytes(name).await?.to_vec())); + } + return json_response(json!({"message": state.message, "token": state.token, + "raw": raw.as_bytes(), "secrets": values})); + } + let namespace = input["namespace"].as_str().unwrap_or("primary"); + let store = kv.named(namespace).ok_or_else(|| EdgeError::service_unavailable("missing KV"))?; + let key = input["key"].as_str().unwrap_or(""); + let result = match op { + "put" | "ttl" => { + let value: Vec = serde_json::from_value(input["value"].clone()).map_err(EdgeError::internal)?; + if op == "ttl" { + store.put_bytes_with_ttl(key, Bytes::from(value), std::time::Duration::from_secs(120)).await?; + } else { store.put_bytes(key, Bytes::from(value)).await?; } + json!(true) + } + "large_put" => { store.put_bytes(key, Bytes::from(vec![input["byte"].as_u64().unwrap_or(0) as u8; 256 * 1024])).await?; json!(true) } + "get" => json!(store.get_bytes(key).await?.map(|value| value.to_vec())), + "exists" => json!(store.exists(key).await?), + "delete" => { store.delete(key).await?; json!(true) } + "list" => { + let page = store.list_keys_page(input["prefix"].as_str().unwrap_or(""), input["cursor"].as_str(), + input["limit"].as_u64().unwrap_or(100) as usize).await?; + json!({"keys": page.keys, "cursor": page.cursor}) + } + _ => return Err(EdgeError::bad_request("unknown proof operation")), + }; + json_response(result) +} +fn json_response(value: Value) -> Result { + response_builder().status(StatusCode::OK).header("content-type", "application/json") + .body(Body::json(&value).map_err(EdgeError::internal)?) + .map_err(EdgeError::internal) +} +"#; + + fn write_bindings( + project: &Path, + services: &Services, + seed: &Value, + table: &str, + profile: &str, + name: &str, + ) -> PathBuf { + let mut source = format!( + r#"version = 1 +[config.app_config] +provider = "aws-appconfig-agent" +default_key = "settings" +endpoint = "{}" +access_token_env = "APPCONFIG_AGENT_TOKEN" +[config.app_config.documents.settings] +application = "probe" +environment = "test" +profile = "{profile}" +[config.app_config.documents.raw] +application = "probe" +environment = "test" +profile = "raw" +[secrets.vault] +provider = "aws-secrets-manager" +region = "us-east-1" +"#, + services.agent + ); + for (key, secret, selector) in [ + ("current", "text", String::new()), + ("pinned", "text", "version_stage = \"PINNED\"\n".to_owned()), + ( + "candidate", + "text", + "version_stage = \"CANDIDATE\"\n".to_owned(), + ), + ( + "version", + "text", + format!( + "version_id = {}\n", + toml_string( + seed["secrets"]["text"]["version_a"] + .as_str() + .expect("text version A") + ) + ), + ), + ("json", "json", String::new()), + ("binary", "binary", String::new()), + ] { + write!( + source, + "[secrets.vault.entries.{key}]\nsecret_id = {}\n{selector}", + toml_string( + seed["secrets"][secret]["arn"] + .as_str() + .expect("seed secret ARN") + ) + ) + .expect("write secret binding to String"); + } + for namespace in ["primary", "secondary"] { + write!(source, "[kv.{namespace}]\nprovider = \"aws-dynamodb\"\nregion = \"us-east-1\"\ntable = {}\nnamespace = \"{namespace}\"\n", toml_string(table)).expect("write KV binding to String"); + } + let path = project.join(format!("{name}.toml")); + fs::write(&path, source).expect("emulator bindings"); + path + } + + fn toml_string(value: &str) -> String { + toml::Value::String(value.to_owned()).to_string() + } + + fn snapshot(app: &mut App, message: &str, raw: &str, current: &str) { + assert_eq!( + app.call(&json!({"op": "snapshot"})), + json!({ + "message": message, "token": current, "raw": raw.as_bytes(), "secrets": { + "current": current.as_bytes(), "pinned": current.as_bytes(), + "candidate": b"secret-B".as_slice(), "version": b"secret-A".as_slice(), "json": b" { \"answer\": 42 }\n".as_slice(), + "binary": [0_u8, 255_u8, 10_u8, 34_u8] + } + }), + "all config and secret bytes must remain exact startup snapshots" + ); + } + + fn put(app: &mut App, namespace: &str, key: &str, value: &[u8]) { + assert_eq!( + app.call(&json!({"op":"put", "namespace":namespace, "key":key, "value":value})), + true + ); + } + + fn get(app: &mut App, namespace: &str, key: &str) -> Value { + app.call(&json!({"op":"get", "namespace":namespace, "key":key})) + } + + fn list(app: &mut App, namespace: &str, prefix: &str, cursor: &Value, limit: u32) -> Value { + app.call(&json!({"op":"list", "namespace":namespace, "prefix":prefix, "cursor":cursor, "limit":limit})) + } + + fn traverse(app: &mut App, namespace: &str, prefix: &str, limit: u32) -> Vec { + let mut cursor = Value::Null; + let mut keys = Vec::new(); + let mut cursors = BTreeSet::new(); + for _ in 0_u32..100_u32 { + let page = list(app, namespace, prefix, &cursor, limit); + for value in page["keys"].as_array().expect("list keys") { + let key = value.as_str().expect("string key").to_owned(); + assert!(key.starts_with(prefix)); + assert!( + keys.last().is_none_or(|previous| previous < &key), + "sorted pages without duplicates" + ); + keys.push(key); + } + cursor = page["cursor"].clone(); + if cursor.is_null() { + return keys; + } + assert!( + cursors.insert(cursor.as_str().expect("cursor string").to_owned()), + "cursor must advance" + ); + } + panic!("pagination did not terminate"); + } + + fn prove_kv_crud_and_namespaces(app: &mut App) { + phase("AWS emulator proof: raw KV CRUD, namespaces, Unicode and cursors"); + let raw = [0, 255, 10, 34, 128]; + assert_eq!(get(app, "primary", "crud/missing"), Value::Null); + assert_eq!( + app.call(&json!({"op":"exists", "key":"crud/missing"})), + false + ); + put(app, "primary", "crud/raw", &raw); + assert_eq!(get(app, "primary", "crud/raw"), json!(raw)); + assert_eq!(app.call(&json!({"op":"exists", "key":"crud/raw"})), true); + put(app, "primary", "crud/raw", b"overwritten"); + assert_eq!( + get(app, "primary", "crud/raw"), + json!(b"overwritten".as_slice()) + ); + put(app, "primary", "crud/empty", b""); + assert_eq!(get(app, "primary", "crud/empty"), json!([])); + assert_eq!(app.call(&json!({"op":"exists", "key":"crud/empty"})), true); + for key in ["crud/raw", "crud/empty", "crud/missing"] { + assert_eq!(app.call(&json!({"op":"delete", "key":key})), true); + assert_eq!(get(app, "primary", key), Value::Null); + assert_eq!(app.call(&json!({"op":"exists", "key":key})), false); + } + put(app, "primary", "isolation/same", b"primary"); + put(app, "secondary", "isolation/same", b"secondary"); + put(app, "primary", "isolation/only-primary", b"p"); + put(app, "secondary", "isolation/only-secondary", b"s"); + assert_eq!( + get(app, "primary", "isolation/same"), + json!(b"primary".as_slice()) + ); + assert_eq!( + get(app, "secondary", "isolation/same"), + json!(b"secondary".as_slice()) + ); + assert_eq!(get(app, "primary", "isolation/only-secondary"), Value::Null); + assert_eq!(get(app, "secondary", "isolation/only-primary"), Value::Null); + assert_eq!( + traverse(app, "primary", "isolation/", 1), + ["isolation/only-primary", "isolation/same"] + ); + assert_eq!( + traverse(app, "secondary", "isolation/", 1), + ["isolation/only-secondary", "isolation/same"] + ); + put(app, "primary", "unicode/\u{e9}", b"NFC"); + put(app, "primary", "unicode/e\u{301}", b"NFD"); + assert_eq!( + get(app, "primary", "unicode/\u{e9}"), + json!(b"NFC".as_slice()) + ); + assert_eq!( + get(app, "primary", "unicode/e\u{301}"), + json!(b"NFD".as_slice()) + ); + assert_eq!( + traverse(app, "primary", "unicode/", 1), + ["unicode/e\u{301}", "unicode/\u{e9}"] + ); + let expected = [ + "prefix/01", + "prefix/02", + "prefix/03", + "prefix/04", + "prefix/05", + ]; + for key in expected.into_iter().rev() { + put(app, "primary", key, b"v"); + } + put(app, "primary", "prefix-outside", b"excluded"); + assert_eq!(traverse(app, "primary", "prefix/", 2), expected); + } + + fn prove_kv_large_pages(app: &mut App) { + phase("AWS emulator proof: DynamoDB 1 MiB page boundary with keys-only projection"); + let large_keys = (0_u8..6_u8) + .map(|index| format!("large/{index}")) + .collect::>(); + for (index, key) in large_keys.iter().enumerate() { + assert_eq!( + app.call(&json!({"op":"large_put", "key":key, "byte":index})), + true + ); + } + let first = list(app, "primary", "large/", &Value::Null, 100); + let count = first["keys"].as_array().expect("large keys").len(); + assert!( + count > 0 && count < large_keys.len(), + "service byte cap must produce a short first page: {first}" + ); + assert!( + first["cursor"].is_string(), + "short service page retains continuation" + ); + assert_eq!(traverse(app, "primary", "large/", 100), large_keys); + for (index, key) in large_keys.iter().enumerate() { + let byte = u8::try_from(index).expect("six fixture values"); + assert_eq!( + get(app, "primary", key), + json!(vec![byte; 256 * 1024]), + "large value exact bytes" + ); + } + } + + fn prove_kv_expiry(app: &mut App, services: &Services) { + phase( + "AWS emulator proof: expiry filtering does not delete; renewal writes both TTL fields", + ); + let expired = list(app, "primary", "expired/", &Value::Null, 1); + assert_eq!(expired["keys"], json!([])); + assert!( + expired["cursor"].is_string(), + "empty filtered page must keep service cursor" + ); + let alive = list(app, "primary", "expired/", &expired["cursor"], 1); + assert_eq!(alive["keys"], json!(["expired/1"])); + assert_eq!(traverse(app, "primary", "expired/", 1), ["expired/1"]); + assert_eq!(get(app, "primary", "expired/0"), Value::Null); + assert_eq!(app.call(&json!({"op":"exists", "key":"expired/0"})), false); + let stored = services.admin("item", &json!({"namespace":"primary", "key":"expired/0"})); + assert_eq!( + stored["present"], true, + "expired read must not physically delete" + ); + assert_eq!(stored["expires_at_ms"], 1_u64); + assert_eq!(stored["ttl_epoch_s"], 1_u64); + assert_eq!(get(app, "primary", "expired/1"), json!(b"live".as_slice())); + assert_eq!(get(app, "primary", "renew/item"), Value::Null); + let before = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("epoch") + .as_millis(); + assert_eq!( + app.call(&json!({"op":"ttl", "key":"renew/item", "value":b"renewed".as_slice()})), + true + ); + assert_eq!( + get(app, "primary", "renew/item"), + json!(b"renewed".as_slice()) + ); + let renewed = services.admin("item", &json!({"namespace":"primary", "key":"renew/item"})); + assert_eq!(renewed["present"], true); + assert_eq!(renewed["value"], json!(b"renewed".as_slice())); + let expiration_ms = u128::from(renewed["expires_at_ms"].as_u64().expect("millisecond TTL")); + let ttl = u128::from( + renewed["ttl_epoch_s"] + .as_u64() + .expect("service TTL seconds"), + ); + assert!( + expiration_ms >= before.checked_add(60_000).expect("bounded epoch threshold"), + "renewed TTL is at least 60 seconds" + ); + assert_eq!( + ttl, + expiration_ms.div_ceil(1000), + "TTL fields describe the same expiry" + ); + put(app, "primary", "renew/item", b"permanent"); + let permanent = services.admin("item", &json!({"namespace":"primary", "key":"renew/item"})); + assert_eq!( + permanent, + json!({"present":true, "value":b"permanent".as_slice(), "expires_at_ms":null, "ttl_epoch_s":null}), + "normal overwrite must clear both expiration fields" + ); + } + + #[test] + #[expect( + clippy::too_many_lines, + reason = "Keep the single-build, startup and restart lifecycle together as one end-to-end proof." + )] + #[ignore = "requires pinned local services; run node scripts/run_native_aws_integration.mjs"] + fn generated_native_aws_emulator_contracts() { + let services = Services::required(); + phase("AWS emulator proof: seed independent services and scaffold real project"); + let seed = services.admin("seed", &json!({})); + let typed_a = services.publish("message-A", RAW_A); + services.wait_agent("raw", RAW_A); + services.wait_agent("typed", &typed_a); + let temp = TempDir::new().expect("temporary generated project"); + let mut new = Command::new(env!("CARGO_BIN_EXE_edgezero")); + new.args(["new", "native-aws-probe", "--dir"]) + .arg(temp.path()); + checked(new, WAIT, "real edgezero new"); + let project = temp.path().join("native-aws-probe"); + install_consumer(&project); + let target = env::var_os("EDGEZERO_GENERATED_TEST_TARGET_DIR").map_or_else( + || project.join("target"), + |value| { + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else { + project.join(path) + } + }, + ); + phase("AWS emulator proof: one generated all-provider build"); + let mut build = Command::new(env!("CARGO_BIN_EXE_edgezero")); + build + .args(["build", "--adapter", "axum", "--", "--offline"]) + .current_dir(&project) + .env("CARGO_TARGET_DIR", &target); + checked(build, BUILD_WAIT, "single combined generated build"); + let binary = target.join("debug/native-aws-probe-adapter-axum"); + assert!(binary.is_file(), "generated executable"); + let table = seed["table"].as_str().expect("seed table"); + let bindings = write_bindings(&project, &services, &seed, table, "typed", "valid-bindings"); + phase("AWS emulator proof: enabled invalid resources fail before listener"); + for field in ["missing_table", "wrong_table", "indexed_table"] { + let bad = write_bindings( + &project, + &services, + &seed, + seed[field].as_str().expect("negative table"), + "typed", + field, + ); + App::spawn(&project, &binary, &bad, &services, &services.token).rejects(); + } + App::spawn( + &project, + &binary, + &bindings, + &services, + "wrong-local-agent-token", + ) + .rejects(); + let missing = write_bindings( + &project, + &services, + &seed, + table, + "missing-profile", + "missing-profile", + ); + App::spawn(&project, &binary, &missing, &services, &services.token).rejects(); + + phase("AWS emulator proof: combined typed initializer, exact config and secret snapshots"); + let mut first = App::spawn(&project, &binary, &bindings, &services, &services.token); + first.ready("secret-A"); + snapshot(&mut first, "message-A", RAW_A, "secret-A"); + prove_kv_crud_and_namespaces(&mut first); + prove_kv_large_pages(&mut first); + prove_kv_expiry(&mut first, &services); + phase( + "AWS emulator proof: rotate services, independently observe Agent B, retain running A", + ); + services.admin("rotate", &json!({})); + let typed_b = services.publish("message-B", RAW_B); + services.wait_agent("raw", RAW_B); + services.wait_agent("typed", &typed_b); + snapshot(&mut first, "message-A", RAW_A, "secret-A"); + drop(first); + + phase("AWS emulator proof: restart sees B, pinned version stays A, KV persists"); + let mut second = App::spawn(&project, &binary, &bindings, &services, &services.token); + second.ready("secret-B"); + snapshot(&mut second, "message-B", RAW_B, "secret-B"); + assert_eq!( + get(&mut second, "primary", "renew/item"), + json!(b"permanent".as_slice()) + ); + assert_eq!( + get(&mut second, "primary", "isolation/same"), + json!(b"primary".as_slice()) + ); + assert_eq!( + get(&mut second, "secondary", "isolation/same"), + json!(b"secondary".as_slice()) + ); + assert_eq!( + second.call(&json!({"op":"delete", "key":"renew/item"})), + true + ); + assert_eq!(get(&mut second, "primary", "renew/item"), Value::Null); + assert_eq!( + get(&mut second, "primary", "isolation/same"), + json!(b"primary".as_slice()) + ); + assert_eq!( + get(&mut second, "primary", "expired/1"), + json!(b"live".as_slice()) + ); + phase("AWS emulator proof: deletion preserves loaded B but fresh startup fails closed"); + services.admin("delete_secret", &json!({})); + snapshot(&mut second, "message-B", RAW_B, "secret-B"); + drop(second); + App::spawn(&project, &binary, &bindings, &services, &services.token).rejects(); + assert!( + !project.join("must-not-create-data").exists(), + "explicit AWS stores must not create local DATA_DIR" + ); + phase("AWS emulator proof: all independent local-service contracts passed"); + } +} diff --git a/crates/edgezero-cli/tests/native_store_generated_proof.rs b/crates/edgezero-cli/tests/native_store_generated_proof.rs new file mode 100644 index 00000000..d98bf8b5 --- /dev/null +++ b/crates/edgezero-cli/tests/native_store_generated_proof.rs @@ -0,0 +1,941 @@ +//! Ignored end-to-end proof for generated native AWS stores. +//! +//! It invokes the real `edgezero new` and `edgezero build --adapter axum -- --offline` commands, +//! builds the generated Axum target with default-off, Agent-only, Secrets-only and combined +//! manifest feature selections, and launches its binary. +//! +//! The untouched generated main calls `run_generated_app`, proving explicit AWS bindings fail +//! closed when services were not compiled. The success main is replaced only in the temporary +//! generated project with an application-owned public production initializer. That initializer +//! loads the real serialized envelope and resolves its nested `#[secret]` reference before +//! inserting typed state. The test holds both provider responses and the initializer on gates, +//! then confirms the generated server does not listen until they complete. +//! +//! All service traffic uses short-lived literal `127.0.0.1` fixtures. The generated child starts +//! with `env_clear`, empty profile files and HOME, dummy credentials, metadata lookup disabled, +//! and both `AWS_ENDPOINT_URL_SECRETS_MANAGER` and the global endpoint override pointing to the +//! Secrets Manager fixture. No AWS account or real credentials, DNS, external socket, image or +//! IAM action is involved. This ignored test runs explicitly in CI. To run it locally, use +//! `cargo test -p edgezero-cli --test native_store_generated_proof -- --ignored`. +//! `EDGEZERO_GENERATED_TEST_TARGET_DIR` can select a shared target directory; otherwise builds +//! use the generated project's `target/`. Serialize builds that share a target directory. + +#[cfg(test)] +#[cfg(unix)] +mod tests { + + use std::env::var_os; + use std::fs; + use std::io::{ErrorKind, Read as _, Write as _}; + use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::path::{Path, PathBuf}; + use std::process::{Child, Command, Output, Stdio}; + use std::sync::mpsc::{self, Receiver, Sender}; + use std::thread::{self, JoinHandle}; + use std::time::{Duration, Instant}; + + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::{Value, json}; + use tempfile::TempDir; + use toml::Value as TomlValue; + + const WAIT: Duration = Duration::from_secs(20); + const SERVICE_GRACE: Duration = Duration::from_millis(400); + const SECRET_ARN: &str = + "arn:aws:secretsmanager:us-east-1:111122223333:secret:native-probe-signing-AbCdEf"; + const APP_TOKEN: &str = "generated-proof-secret-value"; + const AGENT_TOKEN: &str = "generated-proof-agent-token"; + const FALLBACK_SENTINEL: &str = "must-not-fall-back-to-environment"; + + #[derive(Debug)] + struct HttpRequest { + body: String, + headers: String, + } + + struct HttpReply { + body: String, + content_type: &'static str, + reason: &'static str, + status: u16, + } + + impl HttpReply { + fn ok(body: String, content_type: &'static str) -> Self { + Self { + body, + content_type, + reason: "OK", + status: 200, + } + } + } + + /// A gated one-request HTTP/1 fixture. It holds the first response until the test releases it, + /// then watches for extra calls while the generated app serves requests from its snapshots. + struct GatedService { + endpoint: String, + finish_tx: Sender<()>, + reply_tx: Sender, + request_rx: Receiver, + worker: Option>>, + } + + impl GatedService { + fn bind() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind literal loopback"); + listener + .set_nonblocking(true) + .expect("nonblocking fixture listener"); + let endpoint = format!("http://{}", listener.local_addr().expect("fixture address")); + let (request_tx, request_rx) = mpsc::channel(); + let (reply_tx, reply_rx) = mpsc::channel(); + let (finish_tx, finish_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + let (mut startup_stream, _) = accept_before(&listener, deadline_after(WAIT)); + startup_stream + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("bounded fixture request read"); + startup_stream + .set_write_timeout(Some(Duration::from_secs(3))) + .expect("bounded fixture response write"); + let startup_request = read_request(&mut startup_stream); + request_tx + .send(startup_request) + .expect("parent receives fixture request"); + let reply = reply_rx + .recv_timeout(WAIT) + .expect("test releases fixture response"); + write_reply(&mut startup_stream, &reply); + finish_rx + .recv_timeout(WAIT) + .expect("parent completes snapshot assertions"); + + let mut extra = Vec::new(); + let deadline = deadline_after(SERVICE_GRACE); + while Instant::now() < deadline { + match listener.accept() { + Ok((mut extra_stream, peer)) => { + assert!(peer.ip().is_loopback(), "only literal loopback peers"); + extra_stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("bounded extra request read"); + let extra_request = read_request(&mut extra_stream); + let _sent = request_tx.send(HttpRequest { + body: extra_request.body.clone(), + headers: extra_request.headers.clone(), + }); + extra.push(extra_request); + write_reply( + &mut extra_stream, + &HttpReply { + body: "REQUEST_TIME_PROVIDER_ACCESS".to_owned(), + content_type: "text/plain", + reason: "Unexpected request", + status: 500, + }, + ); + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("fixture accept failed: {error}"), + } + } + extra + }); + Self { + endpoint, + finish_tx, + reply_tx, + request_rx, + worker: Some(worker), + } + } + + fn endpoint(&self) -> &str { + &self.endpoint + } + + fn finish(mut self) -> Vec { + self.finish_tx + .send(()) + .expect("fixture waits until handler assertions finish"); + self.worker + .take() + .expect("fixture worker") + .join() + .expect("fixture thread") + } + + fn reply(&self, reply: HttpReply) { + self.reply_tx.send(reply).expect("service awaits response"); + } + + fn request(&self) -> HttpRequest { + self.request_rx + .recv_timeout(WAIT) + .expect("startup request reaches loopback service") + } + } + + struct RunningHost { + address: SocketAddr, + child: Child, + stderr: Option>>, + stdout: Option>>, + } + + impl RunningHost { + fn http(&mut self, path: &str) -> String { + let deadline = deadline_after(WAIT); + loop { + if let Some(status) = self.child.try_wait().expect("generated process status") { + panic!("generated server exited before {path} ({status})"); + } + if let Ok(mut stream) = + TcpStream::connect_timeout(&self.address, Duration::from_millis(40)) + { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("bounded generated response"); + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" + ) + .expect("send generated handler request"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("read generated response"); + if response.starts_with("HTTP/1.1 200") { + return response; + } + } + assert!( + Instant::now() < deadline, + "generated listener startup timeout" + ); + thread::sleep(Duration::from_millis(10)); + } + } + + fn spawn(mut command: Command, address: SocketAddr) -> Self { + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = command.spawn().expect("spawn generated server"); + let stdout_pipe = child.stdout.take().expect("capture generated stdout"); + let stderr_pipe = child.stderr.take().expect("capture generated stderr"); + let stdout_reader = thread::spawn(move || { + let mut output = Vec::new(); + let mut pipe = stdout_pipe; + pipe.read_to_end(&mut output) + .expect("read generated stdout"); + output + }); + let stderr_reader = thread::spawn(move || { + let mut output = Vec::new(); + let mut pipe = stderr_pipe; + pipe.read_to_end(&mut output) + .expect("read generated stderr"); + output + }); + Self { + address, + child, + stderr: Some(stderr_reader), + stdout: Some(stdout_reader), + } + } + + fn stop(mut self) -> Output { + if self + .child + .try_wait() + .expect("generated process state") + .is_none() + { + let _killed = self.child.kill(); + } + let status = self.child.wait().expect("reap generated app"); + let stdout = self + .stdout + .take() + .expect("stdout reader") + .join() + .expect("stdout thread"); + let stderr = self + .stderr + .take() + .expect("stderr reader") + .join() + .expect("stderr thread"); + Output { + status, + stderr, + stdout, + } + } + } + + impl Drop for RunningHost { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _killed = self.child.kill(); + } + let _status = self.child.wait(); + if let Some(stderr) = self.stderr.take() { + let _output = stderr.join(); + } + if let Some(stdout) = self.stdout.take() { + let _output = stdout.join(); + } + } + } + + fn deadline_after(duration: Duration) -> Instant { + Instant::now() + .checked_add(duration) + .expect("deadline within representable range") + } + + fn accept_before(listener: &TcpListener, deadline: Instant) -> (TcpStream, SocketAddr) { + loop { + match listener.accept() { + Ok((stream, peer)) => { + assert!(peer.ip().is_loopback(), "fixture peer must be loopback"); + return (stream, peer); + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "fixture request deadline"); + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("fixture accept failed: {error}"), + } + } + } + + fn read_request(stream: &mut TcpStream) -> HttpRequest { + let mut bytes = Vec::new(); + let mut chunk = [0_u8; 2048]; + let header_end = loop { + let read = stream.read(&mut chunk).expect("read request headers"); + assert_ne!(read, 0, "peer closed before request headers"); + bytes.extend_from_slice(&chunk[..read]); + assert!(bytes.len() <= 64 * 1024, "fixture request header bound"); + if let Some(index) = bytes.windows(4).position(|part| part == b"\r\n\r\n") { + break index.checked_add(4).expect("header end offset"); + } + }; + let headers = String::from_utf8_lossy(&bytes[..header_end]).into_owned(); + let content_length = headers + .lines() + .find_map(|line| { + let (_, value) = line + .split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("content-length"))?; + value.trim().parse::().ok() + }) + .unwrap_or(0); + assert!(content_length <= 64 * 1024, "fixture request body bound"); + let body_end = header_end + .checked_add(content_length) + .expect("request body end offset"); + while bytes.len() < body_end { + let read = stream.read(&mut chunk).expect("read request body"); + assert_ne!(read, 0, "peer closed before request body"); + bytes.extend_from_slice(&chunk[..read]); + assert!(bytes.len() <= body_end, "bounded request body"); + } + HttpRequest { + body: String::from_utf8_lossy(&bytes[header_end..body_end]).into_owned(), + headers: headers.to_ascii_lowercase(), + } + } + + fn write_reply(stream: &mut TcpStream, reply: &HttpReply) { + write!( + stream, + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + reply.status, + reply.reason, + reply.content_type, + reply.body.len() + ) + .expect("write fixture response headers"); + stream + .write_all(reply.body.as_bytes()) + .expect("write fixture response body"); + } + + fn toml_table(entries: impl IntoIterator) -> TomlValue { + TomlValue::Table(entries.into_iter().collect()) + } + + fn toml_strings(values: &[&str]) -> TomlValue { + TomlValue::Array( + values + .iter() + .map(|value| TomlValue::String((*value).to_owned())) + .collect(), + ) + } + + fn install_store_declarations(project: &Path) { + let manifest_path = project.join("edgezero.toml"); + let source = fs::read_to_string(&manifest_path).expect("scaffold manifest"); + let mut manifest: TomlValue = toml::from_str(&source).expect("parse scaffold TOML"); + let root = manifest.as_table_mut().expect("manifest root table"); + let stores = toml_table([ + ( + "config".to_owned(), + toml_table([ + ("ids".to_owned(), toml_strings(&["app_config"])), + ( + "default".to_owned(), + TomlValue::String("app_config".to_owned()), + ), + ]), + ), + ( + "secrets".to_owned(), + toml_table([ + ("ids".to_owned(), toml_strings(&["signing"])), + ( + "default".to_owned(), + TomlValue::String("signing".to_owned()), + ), + ]), + ), + ]); + root.insert("stores".to_owned(), stores); + fs::write( + &manifest_path, + toml::to_string(&manifest).expect("serialize manifest"), + ) + .expect("write portable store IDs"); + } + + fn set_axum_features(project: &Path, features: &[&str]) { + let manifest_path = project.join("edgezero.toml"); + let source = fs::read_to_string(&manifest_path).expect("generated manifest"); + let mut manifest: TomlValue = toml::from_str(&source).expect("parse generated manifest"); + let axum_build = manifest + .as_table_mut() + .and_then(|root| root.get_mut("adapters")) + .and_then(TomlValue::as_table_mut) + .and_then(|adapters| adapters.get_mut("axum")) + .and_then(TomlValue::as_table_mut) + .and_then(|axum| axum.get_mut("build")) + .and_then(TomlValue::as_table_mut) + .expect("generated Axum build settings"); + axum_build.insert("features".to_owned(), toml_strings(features)); + fs::write( + &manifest_path, + toml::to_string(&manifest).expect("serialize manifest"), + ) + .expect("write selected Axum build features"); + } + + fn prepare_typed_generated_app(project: &Path) { + let core = project.join("crates/native-probe-core/src"); + fs::write( + core.join("config.rs"), + concat!( + "use serde::Deserialize;\n", + "use validator::Validate;\n\n", + "#[derive(Debug, Deserialize, Validate, edgezero_core::AppConfig)]\n", + "#[serde(deny_unknown_fields)]\n", + "pub struct NativeProbeConfig {\n", + " #[validate(length(min = 4_u64))]\n", + " pub greeting: String,\n", + " #[secret]\n", + " #[validate(length(min = 8_u64))]\n", + " pub api_token: String,\n", + "}\n\n", + "#[derive(Clone)]\n", + "pub struct StartupState {\n", + " pub greeting: String,\n", + " pub api_token: String,\n", + "}\n", + ), + ) + .expect("write typed app schema"); + + let handlers_path = core.join("handlers.rs"); + let mut handlers = fs::read_to_string(&handlers_path).expect("generated handlers"); + handlers = handlers.replace( + "use edgezero_core::extractor::{Headers, Json, Path};", + "use edgezero_core::extractor::{Headers, Json, Path, State};\nuse std::sync::Arc;", + ); + let start = handlers + .find("#[action]\npub async fn root()") + .expect("generated root handler"); + let handler_tail = handlers.get(start..).expect("root handler UTF-8 boundary"); + let relative_end = handler_tail + .find("\n}\n") + .expect("generated root handler end"); + let handler_end = start + .checked_add(relative_end) + .expect("generated root handler end offset"); + let close = handler_end + .checked_add(3) + .expect("generated root handler closing offset"); + handlers.replace_range( + start..close, + r#"#[action] + pub async fn root(State(state): State>) -> Text { + Text::new(format!("{}|{}", state.greeting, state.api_token)) + } + "#, + ); + fs::write(handlers_path, handlers).expect("wire typed response state"); + fs::write( + project.join("native-probe.toml"), + "greeting = \"local-typed-config\"\napi_token = \"signing_key\"\n", + ) + .expect("write local typed validation fixture"); + } + + fn install_startup_initializer(project: &Path) { + fs::write( + project.join("crates/native-probe-adapter-axum/src/main.rs"), + r#"use std::fs; + use std::path::PathBuf; + use std::sync::Arc; + use std::thread; + use std::time::{Duration, Instant}; + + use edgezero_adapter_axum::dev_server::run_production_app_with_initializer; + use edgezero_core::extractor::AppConfig; + use edgezero_core::EdgeError; + use native_probe_core::config::{NativeProbeConfig, StartupState}; + + fn main() -> anyhow::Result<()> { + run_production_app_with_initializer::(|app, stores| { + Box::pin(async move { + let binding = stores + .config() + .and_then(|registry| registry.named_ref("app_config")) + .ok_or_else(|| EdgeError::service_unavailable("config binding missing"))?; + let config = AppConfig::::from_binding( + binding, + None, + stores.secrets(), + app.config_extraction_limits(), + app.monotonic_clock(), + ) + .await?; + if let (Ok(entered), Ok(release)) = ( + std::env::var("NATIVE_PROBE_INITIALIZER_ENTERED"), + std::env::var("NATIVE_PROBE_INITIALIZER_RELEASE"), + ) { + fs::write(entered, b"typed config is valid") + .map_err(EdgeError::internal)?; + let release = PathBuf::from(release); + let deadline = Instant::now().checked_add(Duration::from_secs(10)) + .ok_or_else(|| EdgeError::service_unavailable("initializer deadline unavailable"))?; + while !release.is_file() { + if Instant::now() >= deadline { + return Err(EdgeError::internal(anyhow::anyhow!( + "test initializer gate timed out" + ))); + } + thread::sleep(Duration::from_millis(5)); + } + } + app.insert_state(Arc::new(StartupState { + greeting: config.greeting, + api_token: config.api_token, + })); + Ok(()) + }) + })?; + Ok(()) + } + "#, + ) + .expect("install app-owned typed startup check"); + } + + fn bindings_file(path: &Path, agent_endpoint: &str) { + fs::write( + path, + format!( + r#"version = 1 + + [config.app_config] + provider = "aws-appconfig-agent" + default_key = "settings" + endpoint = "{agent_endpoint}" + access_token_env = "APPCONFIG_AGENT_TOKEN" + + [config.app_config.documents.settings] + application = "native-probe" + environment = "test" + profile = "startup-proof" + + [secrets.signing] + provider = "aws-secrets-manager" + region = "us-east-1" + + [secrets.signing.entries.signing_key] + secret_id = "{SECRET_ARN}" + "# + ), + ) + .expect("write absolute fictional bindings file"); + } + + fn serialized_envelope() -> String { + serde_json::to_string(&BlobEnvelope::new( + json!({ + "greeting": "generated-startup-greeting", + "api_token": "signing_key", + }), + "2026-10-07T12:00:00Z".to_owned(), + )) + .expect("serialize actual core envelope") + } + + fn secret_reply() -> HttpReply { + HttpReply::ok( + json!({ + "ARN": SECRET_ARN, + "Name": "native-probe-signing", + "VersionId": "12345678901234567890123456789012", + "SecretString": APP_TOKEN, + }) + .to_string(), + "application/x-amz-json-1.1", + ) + } + + fn run_disabled_child(mut command: Command, address: SocketAddr) -> Output { + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = command.spawn().expect("spawn default-off generated app"); + let deadline = deadline_after(WAIT); + loop { + if child + .try_wait() + .expect("default-off child status") + .is_some() + { + return child + .wait_with_output() + .expect("capture disabled-provider error"); + } + if TcpStream::connect_timeout(&address, Duration::from_millis(25)).is_ok() { + let _killed = child.kill(); + let _output = child.wait_with_output(); + panic!("default-off generated app accepted traffic for an AWS binding"); + } + if Instant::now() >= deadline { + let _killed = child.kill(); + let _output = child.wait_with_output(); + panic!("disabled provider did not fail closed"); + } + thread::sleep(Duration::from_millis(10)); + } + } + + fn assert_no_listener(address: SocketAddr) { + TcpStream::connect_timeout(&address, Duration::from_millis(60)) + .expect_err("startup must not accept before provider and app checks finish"); + } + + fn reserve_app_address() -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").expect("reserve loopback app port"); + listener.local_addr().expect("app address") + } + + fn empty_endpoint() -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("reserve local closed endpoint"); + let address = listener.local_addr().expect("closed loopback endpoint"); + drop(listener); + format!("http://{address}") + } + + fn profiles(root: &Path) -> (PathBuf, PathBuf, PathBuf) { + let home = root.join("empty-home"); + let aws = root.join("empty-aws-profiles"); + fs::create_dir_all(&home).expect("empty HOME"); + fs::create_dir_all(&aws).expect("profile directory"); + let config = aws.join("config"); + let credentials = aws.join("credentials"); + fs::write(&config, "").expect("no AWS config profiles"); + fs::write(&credentials, "").expect("no AWS credential profiles"); + (home, config, credentials) + } + + fn child_command( + binary: &Path, + root: &Path, + bindings: &Path, + app_address: SocketAddr, + secrets_endpoint: &str, + initializer_gate: Option<(&Path, &Path)>, + ) -> Command { + let (home, aws_config, aws_credentials) = profiles(root); + let mut command = Command::new(binary); + command + .env_clear() + .current_dir(root) + .env("HOME", home) + .env("AWS_CONFIG_FILE", aws_config) + .env("AWS_SHARED_CREDENTIALS_FILE", aws_credentials) + .env("AWS_REGION", "us-east-1") + .env("AWS_EC2_METADATA_DISABLED", "true") + .env("AWS_ACCESS_KEY_ID", "DUMMY_GENERATED_ACCESS_KEY") + .env("AWS_SECRET_ACCESS_KEY", "DUMMY_GENERATED_SECRET_KEY") + .env("AWS_ENDPOINT_URL", secrets_endpoint) + .env("AWS_ENDPOINT_URL_SECRETS_MANAGER", secrets_endpoint) + .env("AWS_IGNORE_CONFIGURED_ENDPOINT_URLS", "false") + .env("EDGEZERO__STORE_BINDINGS_FILE", bindings) + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", app_address.port().to_string()) + .env("EDGEZERO__LOGGING__LEVEL", "off") + .env("APPCONFIG_AGENT_TOKEN", AGENT_TOKEN) + .env("signing_key", FALLBACK_SENTINEL); + if let Some((entered, release)) = initializer_gate { + command + .env("NATIVE_PROBE_INITIALIZER_ENTERED", entered) + .env("NATIVE_PROBE_INITIALIZER_RELEASE", release); + } + command + } + + fn assert_no_sensitive_output(output: &Output) { + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + for sentinel in [ + APP_TOKEN, + AGENT_TOKEN, + FALLBACK_SENTINEL, + "DUMMY_GENERATED_ACCESS_KEY", + "DUMMY_GENERATED_SECRET_KEY", + "AGENT_BODY_SENTINEL", + "SECRET_BODY_SENTINEL", + ] { + assert!(!text.contains(sentinel), "child output leaked {sentinel}"); + } + } + + fn assert_cargo_command(project: &Path, features: &[&str]) { + set_axum_features(project, features); + let output = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .args(["build", "--adapter", "axum", "--", "--offline"]) + .current_dir(project) + .env("CARGO_TARGET_DIR", target_directory(project)) + .output() + .expect("run root CLI native build"); + assert!( + output.status.success(), + "edgezero build with selected manifest features {features:?} failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + fn target_directory(project: &Path) -> PathBuf { + match var_os("EDGEZERO_GENERATED_TEST_TARGET_DIR") { + Some(target_override) => { + let target_path = PathBuf::from(target_override); + if target_path.is_absolute() { + target_path + } else { + project.join(target_path) + } + } + None => project.join("target"), + } + } + + fn generated_binary(project: &Path) -> PathBuf { + target_directory(project).join("debug/native-probe-adapter-axum") + } + + fn wait_for_file(path: &Path) { + let deadline = deadline_after(WAIT); + while !path.is_file() { + assert!( + Instant::now() < deadline, + "generated initializer gate timeout" + ); + thread::sleep(Duration::from_millis(5)); + } + } + + fn http_body(response: &str) -> &str { + response + .split_once("\r\n\r\n") + .expect("HTTP response boundary") + .1 + } + + fn prepare_workspace(temp: &TempDir) -> PathBuf { + let scaffold = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .args(["new", "native-probe", "--dir"]) + .arg(temp.path()) + .output() + .expect("run real edgezero new command"); + assert!( + scaffold.status.success(), + "edgezero new failed:\n{}\n{}", + String::from_utf8_lossy(&scaffold.stdout), + String::from_utf8_lossy(&scaffold.stderr) + ); + let project = temp.path().join("native-probe"); + assert!( + project + .join("crates/native-probe-adapter-axum/src/main.rs") + .is_file() + ); + install_store_declarations(&project); + prepare_typed_generated_app(&project); + project + } + + #[test] + #[ignore = "runs the real scaffold and five offline generated native builds; coordinator-owned Cargo check"] + #[expect( + clippy::too_many_lines, + reason = "Keep this generated CLI lifecycle as one readable end-to-end proof." + )] + fn generated_cli_build_features_and_native_aws_startup_are_proven_together() { + let temp = tempfile::tempdir().expect("generated project root"); + let project = prepare_workspace(&temp); + let bindings = temp.path().join("native-stores.toml"); + let no_service_endpoint = empty_endpoint(); + bindings_file(&bindings, &no_service_endpoint); + + // Default generated Cargo features stay empty. With an explicit AWS binding selected, + // run_generated_app must use strict startup and reject the disabled provider. Port 1 is + // just a closed loopback port, so a broken feature guard still cannot reach AWS. + assert_cargo_command(&project, &[]); + let disabled_address = reserve_app_address(); + let disabled = run_disabled_child( + child_command( + &generated_binary(&project), + temp.path(), + &bindings, + disabled_address, + &no_service_endpoint, + None, + ), + disabled_address, + ); + assert!( + !disabled.status.success(), + "disabled provider must fail startup" + ); + assert_no_listener(disabled_address); + let disabled_stderr = String::from_utf8_lossy(&disabled.stderr); + assert!( + disabled_stderr.contains("provider not compiled"), + "disabled provider must report a closed capability error: {disabled_stderr}" + ); + assert_no_sensitive_output(&disabled); + + // The build command under test reads `[adapters.axum.build].features`, not features on + // this CLI binary or arbitrary dependency unification in the root workspace. + assert_cargo_command(&project, &["aws-appconfig-agent"]); + assert_cargo_command(&project, &["aws-secrets-manager"]); + assert_cargo_command(&project, &["aws-appconfig-agent", "aws-secrets-manager"]); + + // This app-owned check is deliberately in the generated consumer. The generated default + // main remains run_generated_app; replace it only for the real typed-startup scenario. + install_startup_initializer(&project); + assert_cargo_command(&project, &["aws-appconfig-agent", "aws-secrets-manager"]); + + let agent = GatedService::bind(); + let secrets = GatedService::bind(); + let document = serialized_envelope(); + let agent_endpoint = agent.endpoint().to_owned(); + let secrets_endpoint = secrets.endpoint().to_owned(); + bindings_file(&bindings, &agent_endpoint); + let app_address = reserve_app_address(); + let initializer_entered = temp.path().join("initializer-entered"); + let initializer_release = temp.path().join("initializer-release"); + let mut host = RunningHost::spawn( + child_command( + &generated_binary(&project), + temp.path(), + &bindings, + app_address, + &secrets_endpoint, + Some((&initializer_entered, &initializer_release)), + ), + app_address, + ); + + // Hold each provider response. Neither listener may accept while provider setup or the + // typed app initializer is still pending. + let agent_request = agent.request(); + assert!(agent_request.headers.starts_with( + "get /applications/native-probe/environments/test/configurations/startup-proof " + )); + assert!( + agent_request + .headers + .contains(&format!("authorization: bearer {AGENT_TOKEN}")) + ); + assert!(agent_request.headers.contains("accept-encoding: identity")); + assert_no_listener(app_address); + agent.reply(HttpReply::ok(document, "application/json")); + + let secret_request = secrets.request(); + assert!( + secret_request + .headers + .contains("x-amz-target: secretsmanager.getsecretvalue") + ); + assert!( + secret_request + .headers + .contains("credential=dummy_generated_access_key/") + ); + let secret_input: Value = + serde_json::from_str(&secret_request.body).expect("actual SDK request JSON"); + assert_eq!(secret_input["SecretId"], SECRET_ARN); + assert!(secret_input.get("VersionStage").is_none()); + assert!(secret_input.get("VersionId").is_none()); + assert_no_listener(app_address); + secrets.reply(secret_reply()); + + // The generated initializer has now parsed and validated the actual envelope and resolved + // the nested signing_key value. It pauses before returning; the generated app must still + // keep its listener closed until the application-owned check completes. + wait_for_file(&initializer_entered); + assert_no_listener(app_address); + fs::write(&initializer_release, "continue").expect("release generated initializer"); + + // AppConfig::from_binding validated the envelope, resolved `api_token = "signing_key"` + // from the AWS secret snapshot, and inserted the typed state before the listener appeared. + let first = host.http("/"); + assert_eq!( + http_body(&first), + "generated-startup-greeting|generated-proof-secret-value" + ); + let second = host.http("/"); + assert_eq!( + http_body(&second), + "generated-startup-greeting|generated-proof-secret-value" + ); + + // Both loopback providers received one startup request. Request-time reads came entirely + // from the process snapshots; an accidental remote read would be captured as an extra. + assert!( + agent.finish().is_empty(), + "no Agent request after preparation" + ); + assert!( + secrets.finish().is_empty(), + "no Secrets Manager request after preparation" + ); + let output = host.stop(); + assert_no_sensitive_output(&output); + } +} diff --git a/crates/edgezero-core/src/app.rs b/crates/edgezero-core/src/app.rs index 3adea0f3..530e990c 100644 --- a/crates/edgezero-core/src/app.rs +++ b/crates/edgezero-core/src/app.rs @@ -141,11 +141,12 @@ impl App { /// Construct an application from hooks after installing target metadata. /// - /// This is the sole adapter-facing assembly path. Application hooks cannot - /// replace construction or observe an app before its platform is installed. + /// This is the sole adapter-facing assembly path. Application hooks observe the installed + /// platform; successful configuration must preserve its complete metadata. /// /// # Errors - /// Returns the typed configuration error without exposing a partially configured app. + /// Returns the typed configuration error, or an internal error when configuration replaces + /// the selected platform metadata, without exposing a partially configured app. #[inline] pub fn build(platform: PlatformMetadata) -> Result where @@ -153,6 +154,11 @@ impl App { { let mut app = Self::with_name_and_platform(A::routes(), A::name(), platform); A::configure(&mut app)?; + if app.platform() != platform { + return Err(EdgeError::internal(anyhow::anyhow!( + "application configuration changed adapter-selected platform metadata" + ))); + } Ok(app) } @@ -283,6 +289,19 @@ impl App { self.ingress_head_limits } + /// Registers typed handler state during startup, before ingress admission or serving. + /// + /// Same-type insertion replaces previous state for this App only. Shared routers + /// fork on write, preserving routes and peer Apps. Previously resolved or admitted + /// dispatch tokens are invalid for this App after insertion. This is not live reload. + #[inline] + pub fn insert_state(&mut self, value: T) + where + T: Clone + Send + Sync + 'static, + { + self.router.insert_state(value); + } + /// Consume the app and return the contained router service. #[must_use] #[inline] @@ -539,6 +558,8 @@ pub struct StoresMetadata { pub trait Hooks { /// Allow implementations to mutate the freshly constructed application before use. /// The default implementation performs no changes. + /// Successful configuration must preserve the adapter-selected [`PlatformMetadata`]. + /// [`App::build`] rejects changed metadata rather than silently restoring it. /// /// # Errors /// Returns a typed startup-policy error when configuration cannot be completed. @@ -571,8 +592,17 @@ pub trait Hooks { App::default_name() } - /// When `true`, an adapter's `run_app` skips its own logger initialization; - /// the app is responsible for installing a `log` backend. Default `false`. + /// When `true`, an adapter's entrypoint skips its own logger initialization + /// and must not reconfigure the application's backend or facade maximum. + /// Default `false`. + /// + /// The application must install its `log` backend before the adapter + /// entrypoint and apply configured levels to both backend filters and the + /// facade maximum. [`crate::logging::resolve_logging_level`] resolves the + /// runtime level without mutating logging state. Startup diagnostics use + /// [`crate::logging::BOOT_LOG_TARGET`]; preserving those warnings requires + /// a separate backend target filter and a facade maximum that admits them. + /// Setting only `log::set_max_level` cannot restore backend-filtered records. #[must_use] #[inline] fn owns_logging() -> bool { @@ -662,6 +692,12 @@ mod tests { struct MemoryAwareHooks; + struct ReplacingHooks; + + struct ReplacingFailingHooks; + + struct MemoryPreservingReplacingHooks; + struct TestHooks; struct CountingMiddleware(Arc); @@ -745,6 +781,59 @@ mod tests { } } + #[expect( + clippy::missing_trait_methods, + reason = "test stub exercises replacement of adapter-selected metadata" + )] + impl Hooks for ReplacingHooks { + fn configure(app: &mut App) -> Result<(), EdgeError> { + *app = App::new(Self::routes()); + Ok(()) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[expect( + clippy::missing_trait_methods, + reason = "test stub exercises error precedence after replacing the application" + )] + impl Hooks for ReplacingFailingHooks { + fn configure(app: &mut App) -> Result<(), EdgeError> { + *app = App::new(Self::routes()); + Err(EdgeError::service_unavailable( + "replacement configuration failed", + )) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[expect( + clippy::missing_trait_methods, + reason = "test stub changes a platform fact other than the memory ceiling" + )] + impl Hooks for MemoryPreservingReplacingHooks { + fn configure(app: &mut App) -> Result<(), EdgeError> { + let platform = app.platform(); + let changed = PlatformMetadata::new( + platform.memory_ceiling(), + PlatformFact::unknown(PlatformUnknownReason::OperatorConfigured), + platform.host_ingress_memory_accounting(), + ); + *app = App::build::(changed)?; + Ok(()) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + #[expect( clippy::missing_trait_methods, reason = "test stub — `build_app` intentionally uses the trait default; other methods are overridden for test coverage" @@ -893,6 +982,12 @@ mod tests { (App::new(router), handler_calls, middleware_calls) } + #[test] + fn app_can_be_retained_in_a_shared_owner() { + fn assert_send_sync() {} + assert_send_sync::(); + } + #[test] fn build_app_invokes_hooks_for_routes_and_configuration() { let app = App::build::(PlatformMetadata::default()).expect("configured app"); @@ -952,6 +1047,60 @@ mod tests { ); } + #[test] + fn build_app_rejects_replaced_platform_metadata() { + let platform = PlatformMetadata::new( + PlatformFact::known( + MemoryCeiling::new(128_000_000, MemoryCeilingScope::PerInstance, None), + PlatformResourceSource::PlatformLimit { + provider: "test-platform", + }, + ), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + ); + let Err(error) = App::build::(platform) else { + panic!("replaced platform metadata must stop application assembly"); + }; + assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + error.to_string(), + "internal error: application configuration changed adapter-selected platform metadata" + ); + } + + #[test] + fn build_app_preserves_hook_failure_after_platform_replacement() { + let platform = PlatformMetadata::new( + PlatformFact::unknown(PlatformUnknownReason::OperatorConfigured), + PlatformFact::unknown(PlatformUnknownReason::OperatorConfigured), + PlatformFact::unknown(PlatformUnknownReason::OperatorConfigured), + ); + let Err(error) = App::build::(platform) else { + panic!("configuration failure must stop application assembly"); + }; + assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(error.message(), "replacement configuration failed"); + } + + #[test] + fn build_app_compares_platform_facts_beyond_the_memory_ceiling() { + let platform = PlatformMetadata::new( + PlatformFact::known( + MemoryCeiling::new(128_000_000, MemoryCeilingScope::PerInstance, None), + PlatformResourceSource::PlatformLimit { + provider: "test-platform", + }, + ), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + ); + let Err(error) = App::build::(platform) else { + panic!("changing only population metadata must stop assembly"); + }; + assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + #[test] fn build_app_accepts_unknown_platform_metadata() { let app = App::build::(PlatformMetadata::default()).expect("default app"); @@ -2434,6 +2583,60 @@ mod tests { assert_eq!(complete_envelope(response).status(), StatusCode::OK); } + #[test] + fn startup_state_forks_router_and_invalidates_old_admission() { + use crate::extractor::State; + + #[crate::action] + async fn state(State(value): State>) -> Result { + Ok(value.to_string()) + } + + let router = RouterService::builder() + .get("/state/{id}", state) + .with_state(Arc::::from("builder")) + .build(); + let peer = App::new(router.clone()); + let mut app = App::new(router); + let head = || { + IngressHeadParts::new( + Method::GET, + "/state/42".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ) + }; + let IngressBeginOutcome::Admitted(old) = app + .begin_ingress(head(), MonotonicInstant::now()) + .expect("admission") + else { + panic!("expected admission") + }; + let request = || { + request_builder() + .uri("/state/42") + .body(Body::empty()) + .expect("request") + }; + app.insert_state(Arc::::from("first")); + app.insert_state(Arc::::from("startup")); + assert_eq!(app.router().routes(), peer.router().routes()); + let rejected = complete_envelope(block_on(app.dispatch_admitted(old, request()))); + assert_eq!(rejected.status(), StatusCode::INTERNAL_SERVER_ERROR); + for _ in 0..2_u8 { + let IngressBeginOutcome::Admitted(new) = app + .begin_ingress(head(), MonotonicInstant::now()) + .expect("admission") + else { + panic!("expected admission") + }; + let response = complete_envelope(block_on(app.dispatch_admitted(new, request()))); + assert_eq!(response.body().as_bytes().expect("body"), b"startup"); + } + let response = block_on(peer.router().oneshot(request())).expect("peer response"); + assert_eq!(response.body().as_bytes().expect("body"), b"builder"); + } + #[test] fn default_hooks_do_not_own_logging() { assert!(!DefaultHooks::owns_logging()); diff --git a/crates/edgezero-core/src/app_config.rs b/crates/edgezero-core/src/app_config.rs index 6cf96235..e96f3882 100644 --- a/crates/edgezero-core/src/app_config.rs +++ b/crates/edgezero-core/src/app_config.rs @@ -1585,3 +1585,30 @@ greeting = "hello" assert_eq!(array.dotted_path(), "partners[*].api_key"); } } + +#[cfg(test)] +mod retained_guard_tests { + use super::{SECRET_FIELDS_DEPTH, SecretFieldsRecursionGuard}; + use std::panic::catch_unwind; + + #[test] + fn recursion_guard_resets_after_scope_and_unwind() { + fn nested_scope() { + let _outer = SecretFieldsRecursionGuard::enter(); + let _inner = SecretFieldsRecursionGuard::enter(); + SECRET_FIELDS_DEPTH.with(|depth| assert_eq!(depth.get(), 2)); + } + nested_scope(); + SECRET_FIELDS_DEPTH.with(|depth| assert_eq!(depth.get(), 0)); + assert!( + catch_unwind(|| { + let _guard = SecretFieldsRecursionGuard::enter(); + panic!("injected initialization failure"); + }) + .is_err() + ); + SECRET_FIELDS_DEPTH.with(|depth| assert_eq!(depth.get(), 0)); + let _fresh = SecretFieldsRecursionGuard::enter(); + SECRET_FIELDS_DEPTH.with(|depth| assert_eq!(depth.get(), 1)); + } +} diff --git a/crates/edgezero-core/src/canonical_form.rs b/crates/edgezero-core/src/canonical_form.rs index a3372110..3dafc4bb 100644 --- a/crates/edgezero-core/src/canonical_form.rs +++ b/crates/edgezero-core/src/canonical_form.rs @@ -17,19 +17,46 @@ use serde_json::Value; use sha2::{Digest as _, Sha256}; -use std::fmt::Write as _; +use std::fmt::{self, Write}; -/// SHA-256 of the canonical form of `data`. See module docs. +struct CanonicalHasher(Sha256); + +impl CanonicalHasher { + fn push(&mut self, character: char) { + let mut bytes = [0_u8; 4]; + self.push_str(character.encode_utf8(&mut bytes)); + } + + fn push_str(&mut self, text: &str) { + self.0.update(text.as_bytes()); + } +} + +impl Write for CanonicalHasher { + fn write_char(&mut self, c: char) -> fmt::Result { + self.push(c); + Ok(()) + } + + fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result { + fmt::write(self, args) + } + + fn write_str(&mut self, s: &str) -> fmt::Result { + self.push_str(s); + Ok(()) + } +} + +/// SHA-256 of the canonical form of `data`, without a serialized output buffer. #[must_use] #[inline] pub fn canonical_data_sha256(data: &Value) -> String { #[cfg(test)] test_hooks::CALL_COUNT.with(|cell| cell.set(cell.get().saturating_add(1))); - let mut buf = String::new(); - write_canonical(&mut buf, data); - let mut hasher = Sha256::new(); - hasher.update(buf.as_bytes()); - format!("{:x}", hasher.finalize()) + let mut sink = CanonicalHasher(Sha256::new()); + write_canonical(&mut sink, data); + format!("{:x}", sink.0.finalize()) } #[cfg(test)] @@ -45,7 +72,7 @@ pub(crate) mod test_hooks { } } -fn write_canonical(out: &mut String, value: &Value) { +fn write_canonical(out: &mut CanonicalHasher, value: &Value) { match value { Value::Null => out.push_str("null"), Value::Bool(true) => out.push_str("true"), @@ -86,9 +113,9 @@ fn write_canonical(out: &mut String, value: &Value) { clippy::panic, reason = "unreachable branch — serde_json::Number always exposes i64/u64/f64" )] -fn write_number(out: &mut String, n: &serde_json::Number) { +fn write_number(out: &mut CanonicalHasher, n: &serde_json::Number) { if let Some(int_val) = n.as_i64() { - // Infallible: writing to a String never fails. + // Infallible: this sink feeds bytes directly into the hasher. write!(out, "{int_val}").unwrap_or_default(); } else if let Some(uint_val) = n.as_u64() { write!(out, "{uint_val}").unwrap_or_default(); @@ -110,7 +137,7 @@ fn write_number(out: &mut String, n: &serde_json::Number) { } } -fn write_string(out: &mut String, raw: &str) { +fn write_string(out: &mut CanonicalHasher, raw: &str) { // Escape table byte-identical to serde_json::to_string(raw)'s // default. Pinned by spec 4.2 — DO NOT change without bumping // BlobEnvelope::version. @@ -141,6 +168,16 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn large_escaped_payload_matches_canonical_wire_hash() { + let value = json!({ "payload": "\n\t\u{0001}\"\\".repeat(0x0001_0000) }); + let wire = serde_json::to_vec(&value).expect("canonical single-key fixture"); + assert_eq!( + canonical_data_sha256(&value), + format!("{:x}", Sha256::digest(&wire)) + ); + } + #[test] fn object_keys_sorted_by_utf8_bytes() { let val_ba = json!({ "b": 1_i32, "a": 2_i32 }); diff --git a/crates/edgezero-core/src/config_store.rs b/crates/edgezero-core/src/config_store.rs index ceb71b0f..3fe47e5e 100644 --- a/crates/edgezero-core/src/config_store.rs +++ b/crates/edgezero-core/src/config_store.rs @@ -5,6 +5,7 @@ //! backends complete synchronously and resolve immediately. use std::fmt; +use std::ops::Deref; use std::sync::Arc; use std::time::Duration; @@ -36,7 +37,7 @@ use crate::time::{DEADLINE_FAR_FUTURE, Deadline, MonotonicClock}; /// AxumConfigStore::from_map([ /// ("contract.key.a".to_owned(), "value_a".to_owned()), /// ("contract.key.b".to_owned(), "value_b".to_owned()), -/// ]) +/// ], ConfigStoreLimits::default()).expect("bounded snapshot") /// }); /// ``` #[macro_export] @@ -56,7 +57,7 @@ macro_rules! config_store_contract_tests { run(async { assert_eq!( store.get("contract.key.a").await.expect("config value"), - Some("value_a".to_owned()) + Some($crate::ConfigValue::from("value_a")) ); }); } @@ -78,11 +79,11 @@ macro_rules! config_store_contract_tests { run(async { assert_eq!( store.get("contract.key.a").await.expect("first config value"), - Some("value_a".to_owned()) + Some($crate::ConfigValue::from("value_a")) ); assert_eq!( store.get("contract.key.b").await.expect("second config value"), - Some("value_b".to_owned()) + Some($crate::ConfigValue::from("value_b")) ); }); } @@ -123,7 +124,7 @@ macro_rules! config_store_contract_tests { run(async { assert_eq!( handle.get("contract.key.a").await.expect("handle value"), - Some("value_a".to_owned()) + Some($crate::ConfigValue::from("value_a")) ); assert_eq!( handle.get("contract.key.missing").await.expect("handle miss"), @@ -292,6 +293,74 @@ pub enum ConfigStoreOpenFailure { Unavailable, } +/// An immutable UTF-8 configuration value. Cloning shares its allocation. +/// +/// Each value owns only its own payload, never a slice retaining a larger store file. +/// Debug output deliberately excludes configuration contents. +#[derive(Clone, Default, Eq, PartialEq)] +pub struct ConfigValue { + value: Arc, +} + +impl ConfigValue { + /// Borrow the UTF-8 payload without allocating. + #[inline] + #[must_use] + pub fn as_str(&self) -> &str { + &self.value + } +} + +impl AsRef for ConfigValue { + #[inline] + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Debug for ConfigValue { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ConfigValue") + .field("bytes", &self.len()) + .finish_non_exhaustive() + } +} + +impl Deref for ConfigValue { + type Target = str; + + #[inline] + fn deref(&self) -> &str { + self.as_str() + } +} + +impl From<&str> for ConfigValue { + #[inline] + fn from(value: &str) -> Self { + Self { + value: Arc::from(value), + } + } +} + +impl From for ConfigValue { + #[inline] + fn from(value: String) -> Self { + Self { + value: Arc::from(value), + } + } +} + +impl serde::Serialize for ConfigValue { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + impl ConfigStoreOpenFailure { fn as_error(self) -> ConfigStoreError { match self { @@ -315,7 +384,7 @@ pub struct BoundedStoreRead { /// Object-safe interface for read-only configuration store backends. /// /// Implementations exist per adapter: -/// - `AxumConfigStore` (axum adapter) — env vars + in-memory defaults for dev +/// - `AxumConfigStore` (axum adapter) — bounded immutable local-file snapshots /// - `FastlyConfigStore` (fastly adapter) — Fastly Config Store /// - `CloudflareConfigStore` (cloudflare adapter) — Cloudflare KV namespace /// - `SpinConfigStore` (spin adapter) — Spin KV (`spin_sdk::key_value::Store`) @@ -325,7 +394,7 @@ pub trait ConfigStore: Send + Sync { /// /// # Errors /// Returns [`ConfigStoreError`] if `key` is invalid or the backend is unavailable. - async fn get(&self, key: &str) -> Result, ConfigStoreError>; + async fn get(&self, key: &str) -> Result, ConfigStoreError>; /// Retrieves one value under an absolute deadline and independent backend/value caps. /// @@ -339,7 +408,7 @@ pub trait ConfigStore: Send + Sync { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError>; + ) -> Result, ConfigStoreError>; } #[cfg(test)] @@ -356,7 +425,7 @@ macro_rules! ready_config_store_bounded_read { Box< dyn ::std::future::Future< Output = Result< - $crate::config_store::BoundedStoreRead, + $crate::config_store::BoundedStoreRead<$crate::ConfigValue>, $crate::config_store::ConfigStoreError, >, > + 'async_trait, @@ -433,7 +502,7 @@ impl ConfigStoreHandle { /// # Errors /// Returns [`ConfigStoreError`] if `key` is invalid or the backend is unavailable. #[inline] - pub async fn get(&self, key: &str) -> Result, ConfigStoreError> { + pub async fn get(&self, key: &str) -> Result, ConfigStoreError> { self.store.get(key).await } @@ -449,7 +518,7 @@ impl ConfigStoreHandle { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { + ) -> Result, ConfigStoreError> { self.store .get_bounded(key, clock, deadline, max_backend_bytes, max_value_bytes) .await @@ -468,7 +537,7 @@ struct FailedOpenConfigStore { #[async_trait(?Send)] impl ConfigStore for FailedOpenConfigStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Err(self.failure.as_error()) } @@ -479,8 +548,8 @@ impl ConfigStore for FailedOpenConfigStore { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { - finish_bounded_config_read( + ) -> Result, ConfigStoreError> { + finish_bounded_config_read::( Err(self.failure.as_error()), clock, deadline, @@ -499,27 +568,34 @@ impl ConfigStore for FailedOpenConfigStore { /// # Errors /// Returns the provider error, [`ConfigStoreError::DeadlineExceeded`], or /// [`ConfigStoreError::ValueTooLarge`] according to post-read precedence. +/// Conversion must preserve the UTF-8 bytes exposed by `AsRef`; `String` +/// becomes shared only after the caps pass. Conversion overlap is adapter-accounted. #[inline] -pub fn finish_bounded_config_read( - result: Result, ConfigStoreError>, +pub fn finish_bounded_config_read + Into>( + result: Result, ConfigStoreError>, clock: &MonotonicClock, deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, -) -> Result, ConfigStoreError> { +) -> Result, ConfigStoreError> { if deadline.is_expired_at(clock.now()) { return Err(ConfigStoreError::DeadlineExceeded); } let value = result?; let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { - u64::try_from(stored_value.len()).map_err(|_length_error| ConfigStoreError::ValueTooLarge) + u64::try_from(stored_value.as_ref().len()) + .map_err(|_length_error| ConfigStoreError::ValueTooLarge) })?; if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { return Err(ConfigStoreError::ValueTooLarge); } + let shared_value = value.map(Into::into); + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } Ok(BoundedStoreRead { backend_bytes, - value, + value: shared_value, }) } @@ -545,12 +621,12 @@ mod tests { struct FailingConfigStore; struct TestConfigStore { - data: HashMap, + data: HashMap, } #[async_trait(?Send)] impl ConfigStore for FailingConfigStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Err(ConfigStoreError::unavailable("backend offline")) } @@ -559,7 +635,7 @@ mod tests { #[async_trait(?Send)] impl ConfigStore for TestConfigStore { - async fn get(&self, key: &str) -> Result, ConfigStoreError> { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { Ok(self.data.get(key).cloned()) } @@ -571,7 +647,7 @@ mod tests { Self { data: entries .iter() - .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .map(|(key, value)| ((*key).to_owned(), ConfigValue::from(*value))) .collect(), } } @@ -581,6 +657,70 @@ mod tests { ConfigStoreHandle::new(Arc::new(TestConfigStore::new(entries))) } + #[test] + fn shared_config_value_clones_payload_without_revealing_it_in_debug() { + let value = ConfigValue::from("credential=private"); + let clone = value.clone(); + assert_eq!(clone.as_ptr(), value.as_ptr()); + assert_eq!(clone.as_str(), "credential=private"); + assert!(!format!("{value:?}").contains("credential=private")); + assert_eq!( + serde_json::to_value(&value).expect("serialize"), + serde_json::json!("credential=private") + ); + } + + #[test] + fn bounded_read_caps_precede_conversion_and_conversion_expiry_wins() { + use std::sync::atomic::{AtomicUsize, Ordering}; + struct ConversionProbe { + calls: Arc, + text: String, + } + impl AsRef for ConversionProbe { + fn as_ref(&self) -> &str { + &self.text + } + } + impl From for ConfigValue { + #[inline] + fn from(value: ConversionProbe) -> Self { + value.calls.fetch_add(1, Ordering::SeqCst); + Self::from(value.text) + } + } + let start = MonotonicInstant::now(); + let end = start.checked_add(Duration::from_secs(1)).expect("end"); + for (cap, expected_conversions) in [(4, 0), (5, 1)] { + let calls = Arc::new(AtomicUsize::new(0)); + let samples = Arc::clone(&calls); + let clock = MonotonicClock::new(move || { + if samples.load(Ordering::SeqCst) == 0 { + start + } else { + end + } + }); + let error = finish_bounded_config_read( + Ok(Some(ConversionProbe { + calls: Arc::clone(&calls), + text: "value".to_owned(), + })), + &clock, + Deadline::at_instant(end), + cap, + cap, + ) + .expect_err("bounded failure"); + if cap == 4 { + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); + } else { + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + } + assert_eq!(calls.load(Ordering::SeqCst), expected_conversions); + } + } + #[test] fn config_store_get_returns_none_for_missing_key() { let store_handle = handle(&[]); @@ -595,7 +735,7 @@ mod tests { let store_handle = handle(&[("feature.checkout", "true")]); assert_eq!( block_on(store_handle.get("feature.checkout")).expect("config value"), - Some("true".to_owned()) + Some(ConfigValue::from("true")) ); } @@ -654,7 +794,10 @@ mod tests { #[async_trait(?Send)] impl ConfigStore for AdvancingErrorStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { *self.now.lock().expect("clock lock") = self.terminal; Err(ConfigStoreError::unavailable("provider failed at expiry")) } @@ -750,7 +893,7 @@ mod tests { let store_handle = ConfigStoreHandle::new(store); assert_eq!( block_on(store_handle.get("a")).expect("arc-backed config"), - Some("1".to_owned()) + Some(ConfigValue::from("1")) ); } @@ -863,7 +1006,7 @@ mod tests { let store_handle = handle(&[("timeout_ms", "1500")]); assert_eq!( block_on(store_handle.get("timeout_ms")).expect("config value"), - Some("1500".to_owned()) + Some(ConfigValue::from("1500")) ); assert_eq!( block_on(store_handle.get("missing")).expect("missing config"), diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index d2570d13..76f84a54 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -1306,8 +1306,11 @@ mod tests { struct FixedStore(&'static str); #[async_trait(?Send)] impl ConfigStore for FixedStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.to_owned())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.into())) } ready_config_store_bounded_read!(); @@ -1346,13 +1349,13 @@ mod tests { let resolved = ctx.config_store("analytics").expect("analytics handle"); assert_eq!( block_on(resolved.get("key")).expect("config value"), - Some("analytics".to_owned()) + Some(crate::ConfigValue::from("analytics")) ); assert!(ctx.config_store("unknown").is_none()); let default = ctx.config_store_default().expect("default handle"); assert_eq!( block_on(default.get("key")).expect("default config value"), - Some("primary".to_owned()) + Some(crate::ConfigValue::from("primary")) ); } @@ -1426,7 +1429,10 @@ mod tests { struct AnyStore; #[async_trait(?Send)] impl ConfigStore for AnyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Ok(None) } @@ -1475,7 +1481,10 @@ mod tests { struct AnyStore; #[async_trait(?Send)] impl ConfigStore for AnyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Ok(None) } diff --git a/crates/edgezero-core/src/env_config.rs b/crates/edgezero-core/src/env_config.rs index 3258401d..b8d172fc 100644 --- a/crates/edgezero-core/src/env_config.rs +++ b/crates/edgezero-core/src/env_config.rs @@ -9,6 +9,8 @@ //! Every segment is lower-cased on parse, and lookup arguments are lower-cased //! before matching — callers pass lower-case logical ids and get a //! case-insensitive match against the upper-case env-var convention. +//! On Windows the prefix also follows the process environment's case-insensitive +//! key identity; Unix prefixes remain case-sensitive. use std::collections::BTreeMap; use std::env; @@ -66,7 +68,7 @@ impl EnvConfig { { let mut entries = BTreeMap::new(); for (key, value) in vars { - let Some(rest) = key.as_ref().strip_prefix(PREFIX) else { + let Some(rest) = strip_config_prefix(key.as_ref(), cfg!(windows)) else { continue; }; let segments: Vec = @@ -165,6 +167,18 @@ fn is_blank_or_control(value: &str) -> bool { || value.chars().any(char::is_control) } +fn strip_config_prefix(key: &str, case_insensitive: bool) -> Option<&str> { + if case_insensitive { + let prefix = key.get(..PREFIX.len())?; + if !prefix.eq_ignore_ascii_case(PREFIX) { + return None; + } + key.get(PREFIX.len()..) + } else { + key.strip_prefix(PREFIX) + } +} + #[cfg(test)] mod tests { use super::*; @@ -180,6 +194,31 @@ mod tests { ]) } + #[test] + fn prefix_matching_preserves_platform_key_identity() { + for (key, exact, folded) in [ + ( + "EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS", + Some("ADAPTER__INGRESS__MAX_CONNECTIONS"), + Some("ADAPTER__INGRESS__MAX_CONNECTIONS"), + ), + ( + "edgezero__ADAPTER__INGRESS__MAX_CONNECTIONS", + None, + Some("ADAPTER__INGRESS__MAX_CONNECTIONS"), + ), + ("EdGeZeRo__adapter__port", None, Some("adapter__port")), + ("EDGEZERO_ADAPTER", None, None), + ("short", None, None), + ("EDGEZERO\u{e9}__adapter", None, None), + ] { + assert_eq!(strip_config_prefix(key, false), exact); + assert_eq!(strip_config_prefix(key, true), folded); + } + let config = EnvConfig::from_vars([("edgezero__ADAPTER__PORT", "9090")]); + assert_eq!(config.adapter_port(), cfg!(windows).then_some("9090")); + } + #[test] fn parses_and_lower_cases_segments() { let cfg = sample(); diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index eaf50aa7..ddf5fb9e 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -844,10 +844,15 @@ where None, ) })?; - let key = binding.default_key.clone(); - extract_from_handle::(ctx, &binding.handle, &key) - .await - .map(AppConfig) + Self::from_binding( + binding, + None, + ctx.extensions().get::(), + ctx.config_extraction_limits(), + ctx.monotonic_clock(), + ) + .await + .map(AppConfig) } } @@ -855,6 +860,33 @@ impl AppConfig where C: DeserializeOwned + AppConfigMeta + Validate + Send + 'static, { + /// Loads one application-selected binding without a request or route dispatch. + /// + /// Uses the same limits, envelope checks, secret resolution, validation and + /// error categories as request extraction. `None` selects the binding's default + /// key. This does not cache config or infer required application schemas. + /// + /// # Errors + /// Returns a typed store-extraction error for backend, budget, envelope, + /// secret or schema failures. Native startup must classify errors before logging. + #[inline] + pub async fn from_binding( + binding: &ConfigStoreBinding, + key: Option<&str>, + secrets: Option<&SecretRegistry>, + limits: ConfigExtractionLimits, + clock: MonotonicClock, + ) -> Result { + extract_from_handle::( + &binding.handle, + key.unwrap_or(&binding.default_key), + secrets, + limits, + clock, + ) + .await + } + /// Read the typed config from a NON-default config store. /// `key = None` falls back to that store's `binding.default_key`. /// Returns the inner `C` directly per spec 6.2.1. @@ -874,8 +906,14 @@ where None, ) })?; - let resolved_key = key.unwrap_or(&binding.default_key).to_owned(); - extract_from_handle::(ctx, &binding.handle, &resolved_key).await + Self::from_binding( + binding, + key, + ctx.extensions().get::(), + ctx.config_extraction_limits(), + ctx.monotonic_clock(), + ) + .await } /// Read the typed config from the default store under an @@ -896,7 +934,14 @@ where None, ) })?; - extract_from_handle::(ctx, &binding.handle, key).await + Self::from_binding( + binding, + Some(key), + ctx.extensions().get::(), + ctx.config_extraction_limits(), + ctx.monotonic_clock(), + ) + .await } } @@ -1201,15 +1246,16 @@ fn future_format_reason(raw: &str) -> Option { /// Returns [`EdgeError::StoreExtraction`] with the stable reason corresponding /// to store, envelope, secret, or schema failure. async fn extract_from_handle( - ctx: &RequestContext, handle: &ConfigStoreHandle, key: &str, + secrets: Option<&SecretRegistry>, + limits: ConfigExtractionLimits, + clock: MonotonicClock, ) -> Result where C: DeserializeOwned + AppConfigMeta + Validate + Send + 'static, { - let mut budget = - ConfigExtractionBudget::start(ctx.config_extraction_limits(), ctx.monotonic_clock())?; + let mut budget = ConfigExtractionBudget::start(limits, clock)?; let read = handle .get_bounded( key, @@ -1222,7 +1268,7 @@ where .map_err(|store_error| map_config_store_error(&store_error))?; budget.accept_read( read.backend_bytes, - read.value.as_ref().map(String::len), + read.value.as_ref().map(|value| value.len()), budget.max_blob_bytes(), )?; let raw = read.value.ok_or_else(|| { @@ -1272,8 +1318,9 @@ where })(); budget.check_deadline()?; let mut data = parsed?; + drop(raw); - let resolved = secret_walk::(ctx, &mut budget, &mut data).await; + let resolved = secret_walk::(secrets, &mut budget, &mut data).await; budget.check_deadline()?; resolved?; @@ -1334,7 +1381,7 @@ fn map_config_store_error(err: &ConfigStoreError) -> EdgeError { /// /// `StoreRef` fields are skipped — their value is a store id, not a key. async fn secret_walk( - ctx: &RequestContext, + secrets: Option<&SecretRegistry>, budget: &mut ConfigExtractionBudget, data: &mut serde_json::Value, ) -> Result<(), EdgeError> @@ -1348,7 +1395,7 @@ where if matches!(field.kind, SecretKind::StoreRef) { continue; } - resolve_secret_field(ctx, budget, data, &field, &field.path, String::new()).await?; + resolve_secret_field(secrets, budget, data, &field, &field.path, String::new()).await?; } Ok(()) } @@ -1357,7 +1404,7 @@ where /// secret leaf(s). `rendered` is the dotted path so far (with concrete `[n]` /// indices) for error hints. fn resolve_secret_field<'walk>( - ctx: &'walk RequestContext, + secrets: Option<&'walk SecretRegistry>, budget: &'walk mut ConfigExtractionBudget, node: &'walk mut serde_json::Value, field: &'walk SecretField, @@ -1368,7 +1415,7 @@ fn resolve_secret_field<'walk>( match remaining.split_first() { // Leaf reached: `node` is the PARENT object; the last field is the key. Some((SecretPathSegment::Field(name), [])) => { - resolve_leaf(ctx, budget, node, field, name.as_ref(), &rendered).await + resolve_leaf(secrets, budget, node, field, name.as_ref(), &rendered).await } Some((SecretPathSegment::OptionalField(name), [])) => { if node.as_object().is_some_and(|parent| { @@ -1379,7 +1426,7 @@ fn resolve_secret_field<'walk>( }) { return Ok(()); } - resolve_leaf(ctx, budget, node, field, name.as_ref(), &rendered).await + resolve_leaf(secrets, budget, node, field, name.as_ref(), &rendered).await } // Required intermediates still reject stale blobs. Optional // intermediates are represented explicitly below rather than by the @@ -1393,7 +1440,8 @@ fn resolve_secret_field<'walk>( Some(next_rendered), )), Some(child) => { - resolve_secret_field(ctx, budget, child, field, rest, next_rendered).await + resolve_secret_field(secrets, budget, child, field, rest, next_rendered) + .await } } } @@ -1409,7 +1457,8 @@ fn resolve_secret_field<'walk>( match parent.get_mut(name.as_ref()) { None | Some(serde_json::Value::Null) => Ok(()), Some(child) => { - resolve_secret_field(ctx, budget, child, field, rest, next_rendered).await + resolve_secret_field(secrets, budget, child, field, rest, next_rendered) + .await } } } @@ -1425,7 +1474,7 @@ fn resolve_secret_field<'walk>( }; for (idx, item) in items.iter_mut().enumerate() { let indexed = format!("{rendered}[{idx}]"); - resolve_secret_field(ctx, budget, item, field, rest, indexed).await?; + resolve_secret_field(secrets, budget, item, field, rest, indexed).await?; } Ok(()) } @@ -1446,7 +1495,7 @@ fn join_field(prefix: &str, name: &str) -> String { /// secret field name; `store_ref_field` (for `KeyInNamedStore`) is a sibling /// within `parent`. async fn resolve_leaf( - ctx: &RequestContext, + secrets: Option<&SecretRegistry>, budget: &mut ConfigExtractionBudget, parent: &mut serde_json::Value, field: &SecretField, @@ -1485,7 +1534,7 @@ async fn resolve_leaf( let (bound, resolved_store_id) = match field.kind { SecretKind::KeyInDefault => { - let bound = ctx.secret_store_default().ok_or_else(|| { + let bound = secrets.and_then(SecretRegistry::default).ok_or_else(|| { store_extraction_error( StoreExtractionReason::MissingRegistry, format!( @@ -1513,19 +1562,21 @@ async fn resolve_leaf( ) })? .to_owned(); - let bound = ctx.secret_store(&store_id_str).ok_or_else(|| { - // `store_id_str` is the blob's store_ref VALUE — config data that - // may be sensitive — and this message reaches the HTTP body. Name - // the field, not the stored id. - store_extraction_error( - StoreExtractionReason::UnknownStore, - format!( - "secret field `{leaf_path}` names a store_ref that is not declared in \ + let bound = secrets + .and_then(|registry| registry.named(&store_id_str)) + .ok_or_else(|| { + // `store_id_str` is the blob's store_ref VALUE — config data that + // may be sensitive — and this message reaches the HTTP body. Name + // the field, not the stored id. + store_extraction_error( + StoreExtractionReason::UnknownStore, + format!( + "secret field `{leaf_path}` names a store_ref that is not declared in \ [stores.secrets] (id redacted)" - ), - Some(leaf_path.clone()), - ) - })?; + ), + Some(leaf_path.clone()), + ) + })?; (bound, store_id_str) } }; @@ -2500,8 +2551,11 @@ mod tests { struct FixedStore(&'static str); #[async_trait(?Send)] impl ConfigStore for FixedStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.to_owned())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.into())) } ready_config_store_bounded_read!(); @@ -2542,7 +2596,7 @@ mod tests { let analytics = config.named("analytics").expect("analytics handle"); assert_eq!( block_on(analytics.get("any")).expect("config value"), - Some("analytics".to_owned()) + Some(crate::ConfigValue::from("analytics")) ); assert!(config.named("missing").is_none()); assert!(config.default().is_some()); @@ -2558,8 +2612,11 @@ mod tests { struct AnyStore; #[async_trait(?Send)] impl ConfigStore for AnyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some("legacy".to_owned())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some("legacy".into())) } ready_config_store_bounded_read!(); @@ -2606,7 +2663,10 @@ mod tests { struct AnyStore; #[async_trait(?Send)] impl ConfigStore for AnyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Ok(None) } @@ -2657,7 +2717,10 @@ mod tests { struct AnyStore; #[async_trait(?Send)] impl ConfigStore for AnyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Ok(None) } @@ -2772,7 +2835,7 @@ mod tests { let mut budget = ConfigExtractionBudget::start(ConfigExtractionLimits::default(), ctx.monotonic_clock()) .expect("test budget"); - secret_walk::(ctx, &mut budget, data).await + secret_walk::(ctx.extensions().get::(), &mut budget, data).await } #[test] @@ -2815,8 +2878,11 @@ mod tests { struct FixedStore(String); #[async_trait(?Send)] impl ConfigStore for FixedStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } ready_config_store_bounded_read!(); @@ -2845,7 +2911,10 @@ mod tests { #[async_trait(?Send)] impl ConfigStore for BoundedOnlyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { panic!("typed extraction must not call the unbounded config-store API"); } @@ -2856,12 +2925,12 @@ mod tests { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { + ) -> Result, ConfigStoreError> { *self.observed.lock().expect("record bounded read") = Some((deadline, max_backend_bytes, max_value_bytes)); Ok(BoundedStoreRead { backend_bytes: u64::try_from(self.blob.len()).expect("fixture length"), - value: Some(self.blob.clone()), + value: Some(self.blob.clone().into()), }) } } @@ -2892,11 +2961,11 @@ mod tests { #[test] fn app_config_extractor_shares_deadline_and_budget_with_secret_reads() { - use std::sync::Mutex; - + use crate::ConfigValue; use crate::config_store::BoundedStoreRead; use crate::time::{Deadline, MonotonicInstant}; use bytes::Bytes; + use std::sync::Mutex; type Observation = (MonotonicInstant, u64, u64); @@ -2907,7 +2976,7 @@ mod tests { #[async_trait(?Send)] impl ConfigStore for RecordingConfigStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { panic!("typed extraction must not call unbounded config reads"); } @@ -2918,7 +2987,7 @@ mod tests { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { + ) -> Result, ConfigStoreError> { self.observed.lock().expect("config observation").push(( deadline.instant(), max_backend_bytes, @@ -2926,7 +2995,7 @@ mod tests { )); Ok(BoundedStoreRead { backend_bytes: u64::try_from(self.blob.len()).expect("fixture length"), - value: Some(self.blob.clone()), + value: Some(self.blob.clone().into()), }) } } @@ -3008,7 +3077,10 @@ mod tests { struct EmptyStore; #[async_trait(?Send)] impl ConfigStore for EmptyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Ok(None) } @@ -3037,7 +3109,10 @@ mod tests { struct DownStore; #[async_trait(?Send)] impl ConfigStore for DownStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Err(ConfigStoreError::unavailable("backend offline")) } @@ -3058,7 +3133,10 @@ mod tests { struct BadKeyStore; #[async_trait(?Send)] impl ConfigStore for BadKeyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Err(ConfigStoreError::invalid_key("key is malformed")) } @@ -3079,7 +3157,10 @@ mod tests { struct BrokenStore; #[async_trait(?Send)] impl ConfigStore for BrokenStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Err(ConfigStoreError::internal(anyhow::anyhow!("disk on fire"))) } @@ -3101,7 +3182,10 @@ mod tests { struct TamperedStore; #[async_trait(?Send)] impl ConfigStore for TamperedStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { // A valid envelope whose stored sha is a SENTINEL secret. The // stored hash is attacker-influenced (it comes from the config // store), and this error becomes the HTTP 500 body — so the @@ -3111,7 +3195,7 @@ mod tests { "2026-01-01T00:00:00Z".into(), ); env.sha256 = SENTINEL.to_owned(); - Ok(Some(serde_json::to_string(&env).unwrap())) + Ok(Some(serde_json::to_string(&env).unwrap().into())) } ready_config_store_bounded_read!(); @@ -3145,7 +3229,10 @@ mod tests { struct FutureVersionStore; #[async_trait(?Send)] impl ConfigStore for FutureVersionStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { // A v1-shaped envelope with the version bumped to 2. let env = BlobEnvelope::new( serde_json::json!({ "greeting": "hi", "timeout_ms": 100_u32 }), @@ -3154,7 +3241,7 @@ mod tests { let mut value: serde_json::Value = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); value["version"] = serde_json::json!(2_u32); - Ok(Some(value.to_string())) + Ok(Some(value.to_string().into())) } ready_config_store_bounded_read!(); @@ -3187,7 +3274,10 @@ mod tests { struct KindTaggedStore; #[async_trait(?Send)] impl ConfigStore for KindTaggedStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { let env = BlobEnvelope::new( serde_json::json!({ "greeting": "hi", "timeout_ms": 100_u32 }), "2026-01-01T00:00:00Z".into(), @@ -3195,7 +3285,7 @@ mod tests { let mut value: serde_json::Value = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); value["edgezero_kind"] = serde_json::json!("new_format"); - Ok(Some(value.to_string())) + Ok(Some(value.to_string().into())) } ready_config_store_bounded_read!(); @@ -3218,16 +3308,19 @@ mod tests { struct EscapedKindTaggedStore; #[async_trait(?Send)] impl ConfigStore for EscapedKindTaggedStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { let env = BlobEnvelope::new( serde_json::json!({ "greeting": "hi", "timeout_ms": 100_u32 }), "2026-01-01T00:00:00Z".into(), ); let serialized = serde_json::to_string(&env).expect("envelope"); let serialized_body = serialized.strip_prefix('{').expect("JSON object"); - Ok(Some(format!( - "{{\"edgezero_\\u006bind\":\"new_format\",{serialized_body}" - ))) + Ok(Some( + format!("{{\"edgezero_\\u006bind\":\"new_format\",{serialized_body}").into(), + )) } ready_config_store_bounded_read!(); @@ -3249,7 +3342,10 @@ mod tests { struct TypeErrorStore; #[async_trait(?Send)] impl ConfigStore for TypeErrorStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { // A VALID envelope (verify passes) whose data has the wrong type // for `timeout_ms` — a string sentinel where u32 is expected. // The deserialize error names that value; it must not reach the @@ -3258,7 +3354,7 @@ mod tests { serde_json::json!({ "greeting": "hi", "timeout_ms": SENTINEL }), "2026-01-01T00:00:00Z".into(), ); - Ok(Some(serde_json::to_string(&env).unwrap())) + Ok(Some(serde_json::to_string(&env).unwrap().into())) } ready_config_store_bounded_read!(); @@ -3292,8 +3388,11 @@ mod tests { struct GarbageStore; #[async_trait(?Send)] impl ConfigStore for GarbageStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some("not-json-at-all".to_owned())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some("not-json-at-all".into())) } ready_config_store_bounded_read!(); @@ -3323,8 +3422,11 @@ mod tests { struct FixedStore(String); #[async_trait(?Send)] impl ConfigStore for FixedStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } ready_config_store_bounded_read!(); @@ -3385,12 +3487,15 @@ mod tests { struct BadDataStore; #[async_trait(?Send)] impl ConfigStore for BadDataStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { let data = serde_json::json!({ "greeting": "hi", "timeout_ms": "not-a-number", }); - Ok(Some(make_envelope(data))) + Ok(Some(make_envelope(data).into())) } ready_config_store_bounded_read!(); @@ -3421,9 +3526,12 @@ mod tests { struct ZeroTimeoutStore; #[async_trait(?Send)] impl ConfigStore for ZeroTimeoutStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { let data = serde_json::json!({ "greeting": "hi", "timeout_ms": 0_u32 }); - Ok(Some(make_envelope(data))) + Ok(Some(make_envelope(data).into())) } ready_config_store_bounded_read!(); @@ -3451,8 +3559,11 @@ mod tests { struct BlobStore(String); #[async_trait(?Send)] impl ConfigStore for BlobStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } ready_config_store_bounded_read!(); @@ -3481,8 +3592,11 @@ mod tests { struct BlobStore(String); #[async_trait(?Send)] impl ConfigStore for BlobStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } ready_config_store_bounded_read!(); @@ -3514,8 +3628,11 @@ mod tests { struct BlobStore(String); #[async_trait(?Send)] impl ConfigStore for BlobStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } ready_config_store_bounded_read!(); @@ -3744,10 +3861,10 @@ mod tests { struct KeyEchoStore; #[async_trait(?Send)] impl ConfigStore for KeyEchoStore { - async fn get(&self, key: &str) -> Result, ConfigStoreError> { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { // Return a blob whose `greeting` equals the key being looked up. let data = serde_json::json!({ "greeting": key, "timeout_ms": 200_u32 }); - Ok(Some(make_envelope(data))) + Ok(Some(make_envelope(data).into())) } ready_config_store_bounded_read!(); @@ -3767,9 +3884,12 @@ mod tests { struct NamedStore(&'static str); #[async_trait(?Send)] impl ConfigStore for NamedStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { let data = serde_json::json!({ "greeting": self.0, "timeout_ms": 300_u32 }); - Ok(Some(make_envelope(data))) + Ok(Some(make_envelope(data).into())) } ready_config_store_bounded_read!(); @@ -3857,8 +3977,11 @@ mod tests { struct BlobStore(String); #[async_trait(?Send)] impl ConfigStore for BlobStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } ready_config_store_bounded_read!(); @@ -3903,8 +4026,11 @@ mod tests { struct BlobStore(String); #[async_trait(?Send)] impl ConfigStore for BlobStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } ready_config_store_bounded_read!(); @@ -3956,8 +4082,11 @@ mod tests { struct BlobStore(String); #[async_trait(?Send)] impl ConfigStore for BlobStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } ready_config_store_bounded_read!(); diff --git a/crates/edgezero-core/src/introspection.rs b/crates/edgezero-core/src/introspection.rs index 41937a44..e991e1e8 100644 --- a/crates/edgezero-core/src/introspection.rs +++ b/crates/edgezero-core/src/introspection.rs @@ -131,10 +131,10 @@ mod tests { // A config store returning a fixed result for `get`, used to drive the // config handler's status-code mapping. Mirrors the pattern in // extractor.rs::config_extractor_resolves_from_registry. - struct StubStore(Result, ConfigStoreError>); + struct StubStore(Result, ConfigStoreError>); #[async_trait(?Send)] impl ConfigStore for StubStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { match &self.0 { Ok(val) => Ok(val.clone()), Err(ConfigStoreError::Unavailable { .. }) => { @@ -265,7 +265,7 @@ mod tests { #[test] fn config_happy_path_returns_envelope_data_secret_safe() { let data = serde_json::json!({ "greeting": "hi", "api_token": "demo_api_token" }); - let resp = run_config(StubStore(Ok(Some(valid_envelope_json(data))))); + let resp = run_config(StubStore(Ok(Some(valid_envelope_json(data).into())))); assert_eq!(resp.status(), StatusCode::OK); // Raw envelope `data` verbatim: the secret field holds the KEY NAME, // never a resolved value. @@ -302,7 +302,7 @@ mod tests { #[test] fn config_malformed_envelope_maps_500() { - let resp = run_config(StubStore(Ok(Some("not json".to_owned())))); + let resp = run_config(StubStore(Ok(Some("not json".into())))); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } @@ -316,7 +316,7 @@ mod tests { "version": 1_u32, }) .to_string(); - let resp = run_config(StubStore(Ok(Some(bad)))); + let resp = run_config(StubStore(Ok(Some(bad.into())))); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } @@ -329,7 +329,7 @@ mod tests { "version": 99_u32, }) .to_string(); - let resp = run_config(StubStore(Ok(Some(bad)))); + let resp = run_config(StubStore(Ok(Some(bad.into())))); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } } diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index cbd7dfc5..407e9cb2 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -30,11 +30,13 @@ pub mod http; pub mod ingress; pub mod introspection; pub mod key_value_store; +pub mod logging; pub mod manifest; pub mod middleware; pub mod outbound; pub mod params; pub mod platform; +pub mod probe; pub mod responder; pub mod response; pub mod response_egress; @@ -56,7 +58,7 @@ pub use compression::{ decode_brotli_stream, decode_deflate_stream, decode_gzip_stream, }; pub use config_store::{ - BoundedStoreRead, ConfigExtractionLimits, DEFAULT_CONFIG_BACKEND_BYTES, + BoundedStoreRead, ConfigExtractionLimits, ConfigValue, DEFAULT_CONFIG_BACKEND_BYTES, DEFAULT_CONFIG_BLOB_BYTES, DEFAULT_CONFIG_EXTRACTION_BYTES, DEFAULT_CONFIG_EXTRACTION_TIMEOUT, DEFAULT_CONFIG_JSON_DEPTH, DEFAULT_CONFIG_JSON_NODES, DEFAULT_CONFIG_SECRET_BYTES, }; @@ -73,6 +75,7 @@ pub use ingress::{ IngressDispatchOutcome, IngressFraming, IngressGrant, IngressHead, IngressHeadAccounting, IngressHeadLimits, IngressHeadParts, PreparedIngress, }; +pub use logging::{BOOT_LOG_LEVEL, BOOT_LOG_TARGET, resolve_logging_level}; pub use manifest::{ AtomicHost, BakedManifest, Capability, CapabilitySupport, HostParseError, HostPat, ManifestCapabilities, ManifestContract, ManifestOutboundCapability, Port, Scheme, @@ -83,11 +86,11 @@ pub use outbound::{ HttpClient, OutboundBatch, OutboundBatchDriverEvent, OutboundBatchFailure, OutboundBatchItem, OutboundBatchNext, OutboundBatchResults, OutboundBatchTermination, OutboundCachePolicy, OutboundHttpClient, OutboundRequest, OutboundRequestParts, OutboundResponse, - OutboundSlotResult, PROXY_HEADER, ResponseBodyDisposition, ResponseHeaderLimiter, ResponseMode, - collect_response_stream, collect_response_stream_until_with_clock, - enforce_payload_content_length, insert_proxy_header, limit_decoded_stream, - limit_encoded_stream, normalize_for_dispatch, normalize_response_headers, rechunk_stream, - validate_for_dispatch, + OutboundSlotResult, PROXY_HEADER, ResponseBodyDisposition, ResponseBodyPolicy, + ResponseHeaderLimiter, ResponseMode, apply_response_body_policy, collect_response_stream, + collect_response_stream_until_with_clock, enforce_payload_content_length, insert_proxy_header, + limit_decoded_stream, limit_encoded_stream, normalize_for_dispatch, normalize_response_headers, + rechunk_stream, validate_for_dispatch, }; pub use platform::{ HostIngressMemoryAccounting, InboundRequestPopulationBound, MemoryCeiling, MemoryCeilingScope, @@ -118,7 +121,7 @@ mod public_config_extraction_contract_tests { #[test] fn bounded_config_types_and_defaults_are_root_exports() { let limits = crate::ConfigExtractionLimits::default(); - let _: crate::BoundedStoreRead = crate::BoundedStoreRead { + let _: crate::BoundedStoreRead = crate::BoundedStoreRead { backend_bytes: 0, value: None, }; diff --git a/crates/edgezero-core/src/logging.rs b/crates/edgezero-core/src/logging.rs new file mode 100644 index 00000000..5b9ce6e3 --- /dev/null +++ b/crates/edgezero-core/src/logging.rs @@ -0,0 +1,76 @@ +//! Shared logging policy for adapter and application-owned backends. + +use std::str::FromStr as _; + +use log::LevelFilter; + +use crate::env_config::EnvConfig; + +/// Default boot filter for managed backends, independent of ordinary logging. +pub const BOOT_LOG_LEVEL: LevelFilter = LevelFilter::Warn; +/// Logical target for startup diagnostics; this is not a platform log endpoint. +pub const BOOT_LOG_TARGET: &str = "edgezero::boot"; + +/// Resolve `EDGEZERO__LOGGING__LEVEL` without installing or reconfiguring a logger. +/// +/// Valid values are case-insensitive `off`, `error`, `warn`, `info`, `debug`, and +/// `trace`. Missing or invalid values fall back to `Info`. The caller supplies +/// platform-resolved environment configuration; this function does not read the +/// process environment or implicitly apply manifest logging settings. +/// +/// Application-owned backends must apply this level to ordinary records, apply +/// their chosen boot filter to [`BOOT_LOG_TARGET`], and set the facade maximum +/// high enough for both. Raising the facade maximum does not override a backend +/// filter. +#[must_use] +#[inline] +pub fn resolve_logging_level(env: &EnvConfig) -> LevelFilter { + env.logging_level() + .and_then(|raw| LevelFilter::from_str(raw).ok()) + .unwrap_or(LevelFilter::Info) +} + +#[cfg(test)] +mod tests { + use log::LevelFilter; + + use crate::env_config::EnvConfig; + + #[test] + fn boot_target_preserves_warnings_without_runtime_verbosity() { + assert_eq!(super::BOOT_LOG_TARGET, "edgezero::boot"); + assert_eq!(super::BOOT_LOG_LEVEL, LevelFilter::Warn); + } + + #[test] + fn resolver_defaults_absent_and_invalid_levels_to_info() { + for raw in [None, Some(""), Some("invalid"), Some(" debug ")] { + let env = EnvConfig::from_vars(raw.map(|level| ("EDGEZERO__LOGGING__LEVEL", level))); + assert_eq!(super::resolve_logging_level(&env), LevelFilter::Info); + } + } + + #[test] + fn resolver_preserves_all_levels_and_case_insensitivity() { + for (raw, expected) in [ + ("off", LevelFilter::Off), + ("error", LevelFilter::Error), + ("warn", LevelFilter::Warn), + ("info", LevelFilter::Info), + ("debug", LevelFilter::Debug), + ("trace", LevelFilter::Trace), + ("DeBuG", LevelFilter::Debug), + ] { + let env = EnvConfig::from_vars([("EDGEZERO__LOGGING__LEVEL", raw)]); + assert_eq!(super::resolve_logging_level(&env), expected); + } + } + + #[test] + fn resolver_does_not_change_global_logging_state() { + let before = log::max_level(); + let env = EnvConfig::from_vars([("EDGEZERO__LOGGING__LEVEL", "trace")]); + assert_eq!(super::resolve_logging_level(&env), LevelFilter::Trace); + assert_eq!(log::max_level(), before); + } +} diff --git a/crates/edgezero-core/src/outbound.rs b/crates/edgezero-core/src/outbound.rs index c5c1f099..4ce47029 100644 --- a/crates/edgezero-core/src/outbound.rs +++ b/crates/edgezero-core/src/outbound.rs @@ -1,21 +1,27 @@ +use std::future::Future; use std::iter; use std::net::IpAddr; use std::num::NonZeroU64; use std::str; use std::sync::Arc; +use std::task::Poll; use std::time::Duration; use async_stream::stream; use async_trait::async_trait; use bytes::Bytes; use futures_util::StreamExt as _; -use futures_util::stream::{LocalBoxStream, Stream, iter}; +use futures_util::future::poll_fn; +use futures_util::stream::{FuturesUnordered, LocalBoxStream, Stream, iter}; use serde::Serialize; use serde::de::DeserializeOwned; use url::Url; use crate::body::{Body, BodyStream}; -use crate::compression::ContentEncoding; +use crate::compression::{ + ContentEncoding, classify_content_encoding, decode_brotli_stream, decode_deflate_stream, + decode_gzip_stream, +}; use crate::error::{BadGatewayDecodeReason, BadGatewayReason, EdgeError, ResponseLimitReason}; use crate::http::header::{ CONNECTION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, HOST, PROXY_AUTHENTICATE, @@ -88,6 +94,18 @@ pub enum ResponseMode { Streamed, } +/// Shared response-body limits and decoding policy applied after adapter header normalization. +/// Transport reads, deadlines, cancellation, and final buffered collection remain adapter-owned. +#[derive(Clone, Copy, Debug)] +pub struct ResponseBodyPolicy { + pub max_brotli_window_bits: u8, + pub max_chunk_bytes: Option, + pub max_decoded_response_bytes: Option, + pub max_decoder_bytes: u64, + pub max_encoded_response_bytes: Option, + pub response_mode: ResponseMode, +} + /// Controls use of an adapter-managed intermediary cache for one request. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum OutboundCachePolicy { @@ -136,7 +154,7 @@ pub struct OutboundBatchFailure { #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum OutboundBatchTermination { - /// Every input slot produced exactly one terminal result. + /// Every input slot produced exactly one terminal result and the driver reached clean EOF. Completed, /// The method-level observation cutoff won while one or more slots remained unresolved. Cutoff, @@ -146,6 +164,7 @@ pub enum OutboundBatchTermination { /// /// After the final [`Self::Item`], callers must poll once more to receive /// `Finished(Completed)`; completion is not inferred from the number of items observed. +/// That poll waits for driver EOF and still validates trailing events or finalization failures. #[derive(Debug)] #[non_exhaustive] pub enum OutboundBatchNext { @@ -161,6 +180,8 @@ pub enum OutboundBatchNext { /// method-level observation cutoff wins, or [`Self::Failed`] when the adapter cannot continue /// without violating an invariant. Stream EOF is valid only after every input index has emitted; /// premature EOF is a core invariant failure. +/// Resolving the last slot does not end validation: trailing failures, duplicate/out-of-range +/// indices, and a cutoff with no unresolved slots are errors, including on zero-slot drivers. #[derive(Debug)] #[non_exhaustive] pub enum OutboundBatchDriverEvent { @@ -234,10 +255,14 @@ impl OutboundBatch { /// /// Adapters use this when the method-level observation cutoff is expired at batch entry. Every /// input index remains unresolved. + /// With zero slots, the driver is empty and normal completion is returned instead. #[must_use] #[inline] pub fn cutoff(slot_count: usize) -> Self { - Self::from_driver(slot_count, iter([OutboundBatchDriverEvent::Cutoff])) + Self::from_driver( + slot_count, + iter((slot_count != 0).then_some(OutboundBatchDriverEvent::Cutoff)), + ) } /// Builds a batch from an adapter-owned driver stream. @@ -246,6 +271,8 @@ impl OutboundBatch { /// must emit each terminal index exactly once unless it first emits [`OutboundBatchDriverEvent::Cutoff`] /// or [`OutboundBatchDriverEvent::Failed`]. Ending the stream with unresolved indices is an /// invariant failure. + /// Core waits for clean EOF even after the last item or when `slot_count` is zero; adapter + /// finalization must not remain pending forever after all slots resolve. #[must_use] #[inline] pub fn from_driver(slot_count: usize, stream: StreamValue) -> Self @@ -255,11 +282,56 @@ impl OutboundBatch { Self { poisoned: false, stream: stream.boxed_local(), - termination: (slot_count == 0).then_some(OutboundBatchTermination::Completed), + termination: None, unresolved: vec![true; slot_count], } } + /// Builds a batch from preflight outcomes and adapter-owned exchange futures. + /// + /// Every outcome carries its original index and already-sampled terminal instant. Ready + /// futures are drained before the first item is delivered, retaining on-time observations + /// across consumer delays. No transport timer or clock sampling is introduced here. + /// The same driver-index, cutoff, and EOF invariants as [`Self::from_driver`] apply. + #[must_use] + #[inline] + pub fn from_futures( + slot_count: usize, + batch_started_at: MonotonicInstant, + observation_cutoff: Deadline, + mut completed: Vec<(usize, MonotonicInstant, Result)>, + pending_futures: Pending, + ) -> Self + where + F: Future)> + + 'static, + Pending: IntoIterator, + { + let mut pending = pending_futures.into_iter().collect::>(); + let completions = stream! { + poll_fn(|context| { + loop { + match pending.poll_next_unpin(context) { + Poll::Ready(Some(item)) => completed.push(item), + Poll::Ready(None) | Poll::Pending => return Poll::Ready(()), + } + } + }).await; + let mut outcomes = iter(completed).chain(pending); + while let Some((index, completed_at, outcome)) = outcomes.next().await { + let Some(item) = finish_batch_item( + index, batch_started_at, completed_at, observation_cutoff, outcome, + ) else { + drop(outcomes); + yield OutboundBatchDriverEvent::Cutoff; + return; + }; + yield OutboundBatchDriverEvent::Item(item); + } + }; + Self::from_driver(slot_count, completions) + } + /// Returns the next newly terminal slot or the typed batch termination reason. /// /// Cancelling the returned future does not consume a ready slot. @@ -293,12 +365,15 @@ impl OutboundBatch { )); } *unresolved = false; - if !self.unresolved.iter().any(|is_unresolved| *is_unresolved) { - self.termination = Some(OutboundBatchTermination::Completed); - } Ok(OutboundBatchNext::Item(item)) } Some(OutboundBatchDriverEvent::Cutoff) => { + if !self.unresolved.iter().any(|is_unresolved| *is_unresolved) { + self.poisoned = true; + return Err(batch_driver_error( + "outbound batch driver emitted cutoff after every slot resolved", + )); + } self.termination = Some(OutboundBatchTermination::Cutoff); Ok(OutboundBatchNext::Finished( OutboundBatchTermination::Cutoff, @@ -309,6 +384,12 @@ impl OutboundBatch { Err(error) } None => { + if !self.unresolved.iter().any(|is_unresolved| *is_unresolved) { + self.termination = Some(OutboundBatchTermination::Completed); + return Ok(OutboundBatchNext::Finished( + OutboundBatchTermination::Completed, + )); + } self.poisoned = true; Err(batch_driver_error( "outbound batch driver ended before every slot resolved", @@ -1085,6 +1166,62 @@ impl OutboundRequest { } } +/// Applies the shared payload policy without polling the native body. +/// +/// Declared lengths are checked first, then streaming encoded limits, recognized decoding, +/// decoded limits, and rechunking. Raw passthrough keeps its encoding/length headers and bypasses +/// only the decoded limit. Adapters must handle bodyless responses before calling this helper. +/// +/// # Errors +/// Returns malformed content-length or declared payload-limit errors before rewriting headers. +#[inline] +pub fn apply_response_body_policy( + native: BodyStream, + headers: &mut HeaderMap, + policy: ResponseBodyPolicy, +) -> Result { + let encoding = classify_content_encoding(headers); + let max_buffered = match policy.response_mode { + ResponseMode::Buffered { max_bytes } => Some(max_bytes), + ResponseMode::Streamed => None, + }; + enforce_payload_content_length( + headers, + &encoding, + max_buffered, + policy.max_decoded_response_bytes, + policy.max_encoded_response_bytes, + )?; + let encoded = limit_encoded_stream(native, policy.max_encoded_response_bytes); + let decoded = match &encoding { + ContentEncoding::Brotli => decode_brotli_stream( + encoded, + policy.max_brotli_window_bits, + policy.max_decoder_bytes, + ), + ContentEncoding::Deflate => decode_deflate_stream(encoded, policy.max_decoder_bytes), + ContentEncoding::Gzip => decode_gzip_stream(encoded, policy.max_decoder_bytes), + ContentEncoding::Identity | ContentEncoding::Passthrough(_) => encoded, + }; + if matches!( + &encoding, + ContentEncoding::Brotli | ContentEncoding::Deflate | ContentEncoding::Gzip + ) { + headers.remove(CONTENT_ENCODING); + headers.remove(CONTENT_LENGTH); + } + let output = match &encoding { + ContentEncoding::Brotli + | ContentEncoding::Deflate + | ContentEncoding::Gzip + | ContentEncoding::Identity => { + limit_decoded_stream(decoded, policy.max_decoded_response_bytes) + } + ContentEncoding::Passthrough(_) => decoded, + }; + Ok(rechunk_stream(output, policy.max_chunk_bytes)) +} + /// Converts one adapter-observed terminal batch outcome into its indexed public result. /// /// Adapters must pass samples from the batch's shared monotonic clock. `None` means the terminal @@ -1681,6 +1818,7 @@ mod tests { use std::cell::Cell; use std::future::Future as _; + use std::io::Write as _; use std::num::NonZeroU64; use std::rc::Rc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -1690,7 +1828,11 @@ mod tests { use async_trait::async_trait; use bytes::Bytes; + use flate2::write::{GzEncoder, ZlibEncoder}; + use futures::FutureExt as _; + use futures::channel::oneshot; use futures::executor::block_on; + use futures::future::{Ready, pending, ready}; use futures::pin_mut; use futures::task::noop_waker_ref; use futures_util::{StreamExt as _, stream}; @@ -1712,9 +1854,9 @@ mod tests { HttpClient, OutboundBatch, OutboundBatchDriverEvent, OutboundBatchItem, OutboundBatchNext, OutboundBatchTermination, OutboundCachePolicy, OutboundHttpClient, OutboundRequest, OutboundResponse, OutboundSlotResult, PROXY_HEADER, ResponseBodyDisposition, - ResponseHeaderLimiter, ResponseMode, collect_response_stream, - enforce_payload_content_length, finish_batch_item, insert_proxy_header, - limit_decoded_stream, limit_encoded_stream, normalize_for_dispatch, + ResponseBodyPolicy, ResponseHeaderLimiter, ResponseMode, apply_response_body_policy, + collect_response_stream, enforce_payload_content_length, finish_batch_item, + insert_proxy_header, limit_decoded_stream, limit_encoded_stream, normalize_for_dispatch, normalize_response_headers, rechunk_stream, validate_for_dispatch, }; @@ -1795,6 +1937,340 @@ mod tests { ) } + #[test] + fn future_batch_retains_ready_timestamps_before_delivering_preflight_failures() { + let start = MonotonicInstant::now(); + let observation_cutoff = Deadline::at_instant(start + Duration::from_secs(1)); + let current = Rc::new(Cell::new(start + Duration::from_millis(5))); + let sampled = Rc::clone(¤t); + let pending = async move { (1, sampled.get(), Err(EdgeError::bad_request("ready slot"))) }; + let mut batch = OutboundBatch::from_futures( + 2, + start, + observation_cutoff, + vec![( + 0, + start + Duration::from_millis(2), + Err(EdgeError::bad_request("preflight")), + )], + [pending], + ); + let OutboundBatchNext::Item(first) = block_on(batch.next()).expect("preflight") else { + panic!("expected preflight item"); + }; + assert_eq!(first.index, 0); + assert_eq!(first.result.elapsed, Duration::from_millis(2)); + current.set(start + Duration::from_secs(2)); + let OutboundBatchNext::Item(second) = block_on(batch.next()).expect("retained result") + else { + panic!("expected retained result"); + }; + assert_eq!(second.index, 1); + assert_eq!(second.result.elapsed, Duration::from_millis(5)); + assert!(matches!( + block_on(batch.next()).expect("EOF"), + OutboundBatchNext::Finished(OutboundBatchTermination::Completed) + )); + } + + #[test] + fn future_batch_cutoff_depends_on_observation_not_timeout_attribution() { + let start = MonotonicInstant::now(); + let observation_cutoff = Deadline::at_instant(start + Duration::from_secs(1)); + let early = ready(( + 1, + start + Duration::from_millis(5), + Err(EdgeError::gateway_timeout_caused( + "phase timeout", + BudgetSource::BatchCutoff, + )), + )); + let at_cutoff = ready(( + 0, + observation_cutoff.instant(), + Err(EdgeError::bad_request("late")), + )); + let batch = OutboundBatch::from_futures( + 2, + start, + observation_cutoff, + Vec::new(), + [early, at_cutoff], + ); + let result = block_on(batch.collect()).expect("observation_cutoff"); + assert_eq!(result.termination, OutboundBatchTermination::Cutoff); + assert!(matches!(result.slots.first(), Some(None))); + let early_result = result + .slots + .get(1) + .and_then(Option::as_ref) + .expect("early slot retained"); + assert!(matches!( + early_result.outcome, + Err(EdgeError::GatewayTimeout { + cause: BudgetSource::BatchCutoff, + .. + }) + )); + } + + #[test] + fn future_batch_orders_controlled_completions_and_drops_cancelled_work() { + let start = MonotonicInstant::now(); + let observation_cutoff = Deadline::at_instant(start + Duration::from_secs(1)); + let drops = Rc::new(Cell::new(0)); + let signal = BodyDropSignal(Rc::clone(&drops)); + let (send_first, receive_first) = oneshot::channel::<()>(); + let (send_second, receive_second) = oneshot::channel::<()>(); + let first = async move { + let _signal = signal; + receive_first.await.expect("first completion"); + ( + 0, + start + Duration::from_millis(8), + Err(EdgeError::bad_request("first")), + ) + } + .boxed_local(); + let second = async move { + receive_second.await.expect("second completion"); + ( + 1, + start + Duration::from_millis(3), + Err(EdgeError::bad_request("second")), + ) + } + .boxed_local(); + let mut batch = + OutboundBatch::from_futures(2, start, observation_cutoff, Vec::new(), [first, second]); + send_second.send(()).expect("second ready"); + let OutboundBatchNext::Item(item) = block_on(batch.next()).expect("second completes first") + else { + panic!("expected completion"); + }; + assert_eq!(item.index, 1); + assert_eq!(batch.cancel(), vec![0]); + assert_eq!(drops.get(), 1); + assert!(send_first.send(()).is_err()); + } + + #[test] + fn future_batch_validates_empty_and_premature_drivers() { + let start = MonotonicInstant::now(); + let observation_cutoff = Deadline::at_instant(start + Duration::from_secs(1)); + let empty = + Vec::)>>::new(); + let completed = block_on( + OutboundBatch::from_futures(0, start, observation_cutoff, Vec::new(), empty).collect(), + ) + .expect("empty completion"); + assert_eq!(completed.termination, OutboundBatchTermination::Completed); + let empty = + Vec::)>>::new(); + let failure = block_on( + OutboundBatch::from_futures( + 2, + start, + observation_cutoff, + vec![(1, start, Err(EdgeError::bad_request("preflight")))], + empty, + ) + .collect(), + ) + .expect_err("missing future"); + assert!(matches!(failure.error, EdgeError::Internal { .. })); + assert!(failure.slots.get(1).is_some_and(Option::is_some)); + } + + #[test] + fn future_batch_cutoff_releases_pending_work_before_reporting_finished() { + let start = MonotonicInstant::now(); + let observation_cutoff = Deadline::at_instant(start + Duration::from_secs(1)); + let drops = Rc::new(Cell::new(0)); + let signal = BodyDropSignal(Rc::clone(&drops)); + let pending = async move { + let _signal = signal; + pending::<()>().await; + ( + 1, + start, + Err(EdgeError::bad_request("unreachable completion")), + ) + }; + let mut batch = OutboundBatch::from_futures( + 2, + start, + observation_cutoff, + vec![( + 0, + observation_cutoff.instant(), + Err(EdgeError::bad_request("late completion")), + )], + [pending], + ); + assert!(matches!( + block_on(batch.next()).expect("cutoff"), + OutboundBatchNext::Finished(OutboundBatchTermination::Cutoff) + )); + assert_eq!( + drops.get(), + 1, + "finished cutoff must release pending work even while batch remains owned" + ); + assert_eq!(batch.cancel(), vec![0, 1]); + assert_eq!(drops.get(), 1); + } + + fn body_policy() -> ResponseBodyPolicy { + ResponseBodyPolicy { + max_brotli_window_bits: 24, + max_chunk_bytes: None, + max_decoded_response_bytes: Some(32), + max_decoder_bytes: super::DEFAULT_MAX_DECODER_BYTES, + max_encoded_response_bytes: Some(256), + response_mode: ResponseMode::Buffered { max_bytes: 32 }, + } + } + + #[test] + fn body_policy_rejects_declared_limits_without_polling_or_rewriting_headers() { + let polls = Rc::new(Cell::new(0_usize)); + let counted = Rc::clone(&polls); + let source = stream::poll_fn(move |_context| { + counted.set(counted.get() + 1); + Poll::Ready(None) + }) + .boxed_local(); + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", HeaderValue::from_static("gzip")); + headers.insert("content-length", HeaderValue::from_static("257")); + let error = apply_response_body_policy(source, &mut headers, body_policy()) + .err() + .expect("encoded preflight"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::EncodedBody, + .. + } + )); + assert_eq!(polls.get(), 0); + assert!(headers.contains_key("content-encoding")); + assert!(headers.contains_key("content-length")); + } + + #[test] + fn body_policy_decodes_recognized_codings_before_rechunking() { + let plain = b"decoded body"; + let mut gzip = GzEncoder::new(Vec::new(), flate2::Compression::default()); + gzip.write_all(plain).expect("gzip"); + let mut deflate = ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + deflate.write_all(plain).expect("deflate"); + let mut brotli = Vec::new(); + let mut compressor = brotli::CompressorWriter::new(&mut brotli, 4096, 5, 21); + compressor.write_all(plain).expect("brotli"); + drop(compressor); + for (coding, encoded) in [ + ("gzip", gzip.finish().expect("gzip finish")), + ("deflate", deflate.finish().expect("deflate finish")), + ("br", brotli), + ] { + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", HeaderValue::from_static(coding)); + headers.insert( + "content-length", + HeaderValue::from_str(&encoded.len().to_string()).expect("length"), + ); + let source = stream::iter([Ok(Bytes::from(encoded))]).boxed_local(); + let mut policy = body_policy(); + policy.max_chunk_bytes = NonZeroU64::new(2); + let output = apply_response_body_policy(source, &mut headers, policy).expect("policy"); + assert!(!headers.contains_key("content-encoding")); + assert!(!headers.contains_key("content-length")); + let chunks = block_on(output.collect::>()); + let mut collected = Vec::new(); + for chunk in chunks { + let bytes = chunk.expect("decoded chunk"); + assert!(bytes.len() <= 2); + collected.extend_from_slice(&bytes); + } + assert_eq!(collected, plain); + } + } + + #[test] + fn body_policy_passthrough_preserves_headers_and_bypasses_decoded_limit_only() { + for coding in ["unknown", "gzip, br", "gzip;"] { + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", HeaderValue::from_static(coding)); + headers.insert("content-length", HeaderValue::from_static("4")); + let source = stream::iter([Ok(Bytes::from_static(b"abcd"))]).boxed_local(); + let mut policy = body_policy(); + policy.max_decoded_response_bytes = Some(1); + let output = apply_response_body_policy(source, &mut headers, policy) + .expect("passthrough policy"); + assert_eq!( + headers.get("content-encoding"), + Some(&HeaderValue::from_static(coding)) + ); + assert!(headers.contains_key("content-length")); + assert_eq!( + block_on(collect_response_stream(output, 4)).expect("passthrough"), + Bytes::from_static(b"abcd") + ); + } + let mut headers = HeaderMap::new(); + let mut policy = body_policy(); + policy.max_decoded_response_bytes = Some(1); + policy.max_chunk_bytes = NonZeroU64::new(1); + let source = stream::iter([Ok(Bytes::from_static(b"ab"))]).boxed_local(); + let mut output = + apply_response_body_policy(source, &mut headers, policy).expect("identity"); + assert!(matches!( + block_on(output.next()), + Some(Err(EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::DecodedBody, + .. + })) + )); + } + + #[test] + fn body_policy_encoded_limit_precedes_decoder_and_preserves_source_errors() { + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", HeaderValue::from_static("gzip")); + let mut policy = body_policy(); + policy.max_encoded_response_bytes = Some(1); + let source = stream::iter([Ok(Bytes::from_static(b"invalid gzip"))]).boxed_local(); + let output = apply_response_body_policy(source, &mut headers, policy).expect("policy"); + assert!(matches!( + block_on(collect_response_stream(output, 32)), + Err(EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::EncodedBody, + .. + }) + )); + + for coding in ["identity", "gzip", "deflate", "br", "unknown"] { + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", HeaderValue::from_static(coding)); + let source = stream::iter([Err(EdgeError::gateway_timeout_caused( + "source timeout", + BudgetSource::RequestDeadline, + ))]) + .boxed_local(); + let output = + apply_response_body_policy(source, &mut headers, body_policy()).expect("policy"); + assert!(matches!( + block_on(collect_response_stream(output, 32)), + Err(EdgeError::GatewayTimeout { + cause: BudgetSource::RequestDeadline, + .. + }) + )); + } + } + #[test] fn finish_batch_item_preserves_an_on_time_terminal_outcome() { let started_at = MonotonicInstant::now(); @@ -2356,6 +2832,134 @@ mod tests { assert!(matches!(failure.slots.get(2), Some(None))); } + #[test] + fn batch_collection_validates_events_after_the_final_slot() { + for (event, diagnostic) in [ + ( + OutboundBatchDriverEvent::Failed(EdgeError::internal(anyhow::anyhow!( + "driver finalization failed" + ))), + "internal error: driver finalization failed", + ), + ( + OutboundBatchDriverEvent::Item(batch_item(0)), + "internal error: outbound batch driver emitted a duplicate slot index", + ), + ( + OutboundBatchDriverEvent::Item(batch_item(1)), + "internal error: outbound batch driver emitted an out-of-range slot index", + ), + ( + OutboundBatchDriverEvent::Cutoff, + "internal error: outbound batch driver emitted cutoff after every slot resolved", + ), + ] { + let batch = OutboundBatch::from_driver( + 1, + stream::iter([OutboundBatchDriverEvent::Item(batch_item(0)), event]), + ); + + let failure = block_on(batch.collect()).expect_err("trailing event must be checked"); + assert_eq!(failure.error.to_string(), diagnostic); + assert_eq!(failure.slots.len(), 1); + assert!(matches!(failure.slots.first(), Some(Some(_)))); + } + } + + #[test] + fn batch_zero_slot_driver_is_validated_before_completion() { + let completed = block_on(OutboundBatch::from_driver(0, stream::empty()).collect()) + .expect("empty driver completes"); + assert_eq!(completed.termination, OutboundBatchTermination::Completed); + let completed = block_on(OutboundBatch::cutoff(0).collect()).expect("no work to cut off"); + assert_eq!(completed.termination, OutboundBatchTermination::Completed); + + for (event, diagnostic) in [ + ( + OutboundBatchDriverEvent::Failed(EdgeError::internal(anyhow::anyhow!( + "empty driver failed" + ))), + "internal error: empty driver failed", + ), + ( + OutboundBatchDriverEvent::Item(batch_item(0)), + "internal error: outbound batch driver emitted an out-of-range slot index", + ), + ( + OutboundBatchDriverEvent::Cutoff, + "internal error: outbound batch driver emitted cutoff after every slot resolved", + ), + ] { + let failure = block_on(OutboundBatch::from_driver(0, stream::iter([event])).collect()) + .expect_err("zero slots do not hide malformed driver events"); + assert_eq!(failure.error.to_string(), diagnostic); + assert!(failure.slots.is_empty()); + } + } + + #[test] + fn batch_finalization_remains_pending_and_cancellation_safe_until_eof() { + let polls = Rc::new(Cell::new(0_usize)); + let source_polls = Rc::clone(&polls); + let source = stream::poll_fn(move |_context| { + let index = source_polls.get(); + source_polls.set(index + 1); + match index { + 0 => Poll::Ready(Some(OutboundBatchDriverEvent::Item(batch_item(0)))), + 1 => Poll::Pending, + _ => Poll::Ready(None), + } + }); + let mut batch = OutboundBatch::from_driver(1, source); + assert!(matches!( + block_on(batch.next()).expect("terminal item"), + OutboundBatchNext::Item(_) + )); + assert!(batch.next().now_or_never().is_none()); + assert!(matches!( + block_on(batch.next()).expect("validated EOF"), + OutboundBatchNext::Finished(OutboundBatchTermination::Completed) + )); + assert_eq!(polls.get(), 3); + assert!(matches!( + block_on(batch.next()).expect("cached termination"), + OutboundBatchNext::Finished(OutboundBatchTermination::Completed) + )); + assert_eq!(polls.get(), 3); + assert!(batch.cancel().is_empty()); + } + + #[test] + fn batch_cancel_releases_pending_finalization_after_every_slot_resolves() { + struct DropProbe(Rc>); + impl Drop for DropProbe { + fn drop(&mut self) { + self.0.set(self.0.get() + 1); + } + } + let drops = Rc::new(Cell::new(0)); + let drop_probe = DropProbe(Rc::clone(&drops)); + let mut emitted = false; + let source = stream::poll_fn(move |_context| { + let _keep_driver_alive = &drop_probe; + if emitted { + Poll::Pending + } else { + emitted = true; + Poll::Ready(Some(OutboundBatchDriverEvent::Item(batch_item(0)))) + } + }); + let mut batch = OutboundBatch::from_driver(1, source); + assert!(matches!( + block_on(batch.next()).expect("slot"), + OutboundBatchNext::Item(_) + )); + assert!(batch.next().now_or_never().is_none()); + assert_eq!(drops.get(), 0); + assert!(batch.cancel().is_empty()); + assert_eq!(drops.get(), 1); + } + #[test] fn batch_collection_rejects_duplicate_driver_indices() { let batch = OutboundBatch::from_driver( diff --git a/crates/edgezero-core/src/probe.rs b/crates/edgezero-core/src/probe.rs new file mode 100644 index 00000000..7f8ec783 --- /dev/null +++ b/crates/edgezero-core/src/probe.rs @@ -0,0 +1,163 @@ +//! App-owned probe routes with read-only live lifecycle observation. +//! +//! Adapters may publish a reader in request extensions. Absence does not imply +//! health or lifecycle support. Applications choose paths and middleware; these +//! helpers register no routes. Do not override framework probe/store extensions +//! through application state or middleware. Native dispatch uses a private gate. + +use std::fmt; +use std::sync::Arc; + +use crate::action; +use crate::body::Body; +use crate::context::RequestContext; +use crate::error::EdgeError; +use crate::http::{Response, StatusCode, response_builder}; + +/// Observed native runner phase. Once draining starts, a run never becomes ready again. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LifecyclePhase { + Draining, + Ready, + Starting, + Stopped, +} + +/// Cloneable read-only observation, sampled when a handler executes. +/// +/// Readiness means the selected entrypoint's startup contract succeeded, not that +/// undisclosed application requirements or ongoing dependencies were checked. +#[derive(Clone)] +pub struct LifecycleReader { + read: Arc Option + Send + Sync>, +} + +impl LifecycleReader { + #[inline] + #[must_use] + pub fn new(read: Read) -> Self + where + Read: Fn() -> Option + Send + Sync + 'static, + { + Self { + read: Arc::new(read), + } + } + + #[inline] + #[must_use] + pub fn phase(&self) -> Option { + (self.read)() + } +} + +impl fmt::Debug for LifecycleReader { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LifecycleReader").finish_non_exhaustive() + } +} + +/// Returns 200 while starting, ready or draining, otherwise 503. +/// +/// This table applies only if the handler executes. Before acceptance and after +/// listener withdrawal, supervisors must also handle refusal, reset or EOF. +/// +/// # Errors +/// Returns an internal error only if the static response cannot be constructed. +#[action] +pub async fn liveness(ctx: RequestContext) -> Result { + match phase(&ctx) { + Some(LifecyclePhase::Starting | LifecyclePhase::Ready | LifecyclePhase::Draining) => { + response(StatusCode::OK, "live") + } + Some(LifecyclePhase::Stopped) => response(StatusCode::SERVICE_UNAVAILABLE, "stopped"), + None => response(StatusCode::SERVICE_UNAVAILABLE, "probe state unavailable"), + } +} + +fn phase(ctx: &RequestContext) -> Option { + ctx.extensions() + .get::() + .and_then(LifecycleReader::phase) +} + +/// Returns 200 only in Ready, otherwise 503. Does not poll dependencies. +/// +/// # Errors +/// Returns an internal error only if the static response cannot be constructed. +#[action] +pub async fn readiness(ctx: RequestContext) -> Result { + match phase(&ctx) { + Some(LifecyclePhase::Ready) => response(StatusCode::OK, "ready"), + Some(_) => response(StatusCode::SERVICE_UNAVAILABLE, "not ready"), + None => response(StatusCode::SERVICE_UNAVAILABLE, "probe state unavailable"), + } +} + +fn response(status: StatusCode, text: &'static str) -> Result { + response_builder() + .status(status) + .header("content-type", "text/plain; charset=utf-8") + .header("cache-control", "no-store") + .body(Body::from(text)) + .map_err(EdgeError::internal) +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use futures::executor::block_on; + + use super::*; + use crate::http::request_builder; + use crate::router::RouterService; + + #[test] + fn ordinary_routes_observe_live_phase_and_missing_state() { + #[action] + async fn health() -> Result<&'static str, EdgeError> { + Ok("app health") + } + let current = Arc::new(Mutex::new(None)); + let observed = Arc::clone(¤t); + let reader = LifecycleReader::new(move || *observed.lock().expect("phase")); + let router = RouterService::builder() + .get("/app-ready", readiness) + .get("/app-live", liveness) + .get("/health", health) + .build(); + for (value, ready, live) in [ + (None, 503, 503), + (Some(LifecyclePhase::Starting), 503, 200), + (Some(LifecyclePhase::Ready), 200, 200), + (Some(LifecyclePhase::Draining), 503, 200), + (Some(LifecyclePhase::Stopped), 503, 503), + ] { + *current.lock().expect("phase") = value; + for (path, expected) in [("/app-ready", ready), ("/app-live", live)] { + let mut request = request_builder() + .uri(path) + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(reader.clone()); + let result = block_on(router.oneshot(request)).expect("probe"); + assert_eq!(result.status().as_u16(), expected); + assert_eq!(result.headers()["cache-control"], "no-store"); + assert_eq!( + result.headers()["content-type"], + "text/plain; charset=utf-8" + ); + } + } + for (path, expected) in [("/app-ready", 503), ("/health", 200), ("/ready", 404)] { + let request = request_builder() + .uri(path) + .body(Body::empty()) + .expect("request"); + let result = block_on(router.oneshot(request)).expect("response"); + assert_eq!(result.status().as_u16(), expected); + } + } +} diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index b690f95d..ea1fd52c 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -345,6 +345,7 @@ impl RouterBuilder { } } +#[derive(Clone)] struct RouterInner { manifest_json: Option>, middlewares: Vec, @@ -528,6 +529,15 @@ impl RouterService { } } + pub(crate) fn insert_state(&mut self, value: T) + where + T: Clone + Send + Sync + 'static, + { + Arc::make_mut(&mut self.inner) + .state_extensions + .insert(value); + } + fn new( routes: HashMap>, middlewares: Vec, @@ -1282,6 +1292,140 @@ mod tests { assert_eq!(response.body().as_bytes().expect("buffered"), b"count=2"); } + #[test] + fn retained_router_separates_overlapping_requests() { + use futures::channel::oneshot; + use std::collections::VecDeque; + use std::future::Future as _; + use std::str::from_utf8; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Clone)] + struct Tag(&'static str); + #[derive(Clone)] + struct RequestToken(&'static str); + struct Barrier(Mutex>>); + #[async_trait::async_trait(?Send)] + impl Middleware for Barrier { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + let receiver = self.0.lock().unwrap().pop_front().unwrap(); + receiver.await.unwrap(); + next.run(ctx).await + } + } + #[crate::action] + async fn probe(ctx: RequestContext) -> Result { + let body = ctx.body_bytes(1024).await?; + let counter = ctx.extensions().get::>().unwrap(); + counter.fetch_add(1, Ordering::SeqCst); + Ok(format!( + "{}:{}:{}:{}:{}", + ctx.path_params().get("id").unwrap(), + ctx.headers()["x-request-token"].to_str().unwrap(), + from_utf8(&body).unwrap(), + ctx.extensions().get::().unwrap().0, + ctx.extensions().get::().unwrap().0 + )) + } + let (send1, recv1) = oneshot::channel(); + let (send2, recv2) = oneshot::channel(); + let counter = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .with_state(Tag("app")) + .with_state(Arc::clone(&counter)) + .middleware(Barrier(Mutex::new([recv1, recv2].into()))) + .get("/probe/{id}", probe) + .build(); + let make_request = |token: &'static str| { + let mut req = request_builder() + .uri(format!("/probe/{token}")) + .header("x-request-token", token) + .body(Body::from(token)) + .unwrap(); + req.extensions_mut().insert(RequestToken(token)); + req.extensions_mut().insert(Tag("request")); + req + }; + let mut first = Box::pin(router.oneshot(make_request("first"))); + let mut second = Box::pin(router.oneshot(make_request("second"))); + let mut cx = Context::from_waker(noop_waker_ref()); + assert!(first.as_mut().poll(&mut cx).is_pending()); + assert!(second.as_mut().poll(&mut cx).is_pending()); + assert_eq!(counter.load(Ordering::SeqCst), 0); + send1.send(()).unwrap(); + send2.send(()).unwrap(); + let first_response = block_on(first).unwrap(); + let second_response = block_on(second).unwrap(); + assert_eq!( + first_response.body().as_bytes().unwrap(), + b"first:first:first:first:app" + ); + assert_eq!( + second_response.body().as_bytes().unwrap(), + b"second:second:second:second:app" + ); + assert_eq!(counter.load(Ordering::SeqCst), 2); + } + + #[test] + fn retained_router_releases_cancelled_request_state() { + use std::future::{Future as _, pending}; + + #[derive(Clone)] + struct RequestResource(Arc<()>); + + #[crate::action] + async fn wait(ctx: RequestContext) -> Result { + // Keep the request alive across suspension, as an awaiting handler does. + pending::<()>().await; + Ok(ctx.path_params().get("id").unwrap().to_owned()) + } + + #[crate::action] + async fn probe(ctx: RequestContext) -> Result { + assert!(ctx.extensions().get::().is_none()); + Ok(ctx.path_params().get("id").unwrap().to_owned()) + } + + let shared = Arc::new(()); + let router = RouterService::builder() + .with_state(Arc::clone(&shared)) + .get("/wait/{id}", wait) + .get("/probe/{id}", probe) + .build(); + let resource = RequestResource(Arc::new(())); + let released = Arc::downgrade(&resource.0); + let mut request = request_builder() + .uri("/wait/cancelled") + .body(Body::from("private request body")) + .unwrap(); + request.extensions_mut().insert(resource); + let mut suspended = Box::pin(router.oneshot(request)); + let mut cx = Context::from_waker(noop_waker_ref()); + assert!(suspended.as_mut().poll(&mut cx).is_pending()); + assert!(released.upgrade().is_some()); + drop(suspended); + assert!(released.upgrade().is_none()); + + let response = block_on( + router.oneshot( + request_builder() + .uri("/probe/fresh") + .body(Body::empty()) + .unwrap(), + ), + ) + .unwrap(); + assert_eq!(response.body().as_bytes().unwrap(), b"fresh"); + assert_eq!(Arc::strong_count(&shared), 2); + drop(router); + assert_eq!(Arc::strong_count(&shared), 1); + } + #[test] fn with_state_no_cross_request_bleed() { use crate::extractor::{FromRequest as _, State}; diff --git a/crates/edgezero-core/tests/fixtures/production_handlers.rs b/crates/edgezero-core/tests/fixtures/production_handlers.rs new file mode 100644 index 00000000..5a3a1e32 --- /dev/null +++ b/crates/edgezero-core/tests/fixtures/production_handlers.rs @@ -0,0 +1,32 @@ +//! Shared native/WASM hosting fixture. No native runtime dependency belongs here. + +use std::sync::Arc; + +use edgezero_core::EdgeError; +use edgezero_core::action; +use edgezero_core::extractor::{AppConfig, State}; +use serde::Deserialize; +use validator::Validate; + +#[derive(Deserialize, Validate, edgezero_core::AppConfig)] +pub struct FixtureConfig { + #[validate(length(min = 4_u64))] + pub greeting: String, + #[secret] + #[validate(length(min = 8_u64))] + pub token: String, +} + +pub struct HostState { + pub greeting: String, +} + +#[action] +pub async fn state(State(state): State>) -> Result { + Ok(state.greeting.clone()) +} + +#[action] +pub async fn typed(AppConfig(config): AppConfig) -> Result { + Ok(config.greeting) +} diff --git a/crates/edgezero-core/tests/production_handlers.rs b/crates/edgezero-core/tests/production_handlers.rs new file mode 100644 index 00000000..afa95fe7 --- /dev/null +++ b/crates/edgezero-core/tests/production_handlers.rs @@ -0,0 +1,41 @@ +//! Compile this same handler fixture for native and every supported WASM target. + +#[path = "fixtures/production_handlers.rs"] +mod fixture; + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use edgezero_core::Body; + use edgezero_core::http::{StatusCode, request_builder}; + use edgezero_core::probe; + use edgezero_core::router::RouterService; + use futures::executor::block_on; + + use super::fixture; + + #[test] + fn shared_state_config_and_probe_actions_use_portable_routes() { + let router = RouterService::builder() + .get("/state", fixture::state) + .get("/typed", fixture::typed) + .get("/ready", probe::readiness) + .with_state(Arc::new(fixture::HostState { + greeting: "retained".to_owned(), + })) + .build(); + for (path, status) in [ + ("/state", StatusCode::OK), + ("/ready", StatusCode::SERVICE_UNAVAILABLE), + ("/typed", StatusCode::INTERNAL_SERVER_ERROR), + ] { + let request = request_builder() + .uri(path) + .body(Body::empty()) + .expect("request"); + let response = block_on(router.oneshot(request)).expect("response"); + assert_eq!(response.status(), status); + } + } +} diff --git a/crates/edgezero-macros/tests/nested_secrets_e2e.rs b/crates/edgezero-macros/tests/nested_secrets_e2e.rs index c4d8040f..e9876948 100644 --- a/crates/edgezero-macros/tests/nested_secrets_e2e.rs +++ b/crates/edgezero-macros/tests/nested_secrets_e2e.rs @@ -83,8 +83,11 @@ struct BlobStore(String); #[async_trait(?Send)] impl ConfigStore for BlobStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } async fn get_bounded( @@ -94,7 +97,7 @@ impl ConfigStore for BlobStore { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { + ) -> Result, ConfigStoreError> { if deadline.is_expired_at(clock.now()) { return Err(ConfigStoreError::DeadlineExceeded); } diff --git a/crates/edgezero-store-aws/Cargo.toml b/crates/edgezero-store-aws/Cargo.toml new file mode 100644 index 00000000..a73ba418 --- /dev/null +++ b/crates/edgezero-store-aws/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "edgezero-store-aws" +description = "Optional native AWS configuration and secret snapshots for EdgeZero" +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lints] +workspace = true + +[features] +default = [] +snapshot-stores = ["dep:edgezero-core", "dep:bytes", "dep:async-trait", "dep:tokio"] +appconfig-agent = ["snapshot-stores", "dep:reqwest", "dep:futures-util"] +secrets-manager = ["snapshot-stores", "dep:aws-config", "dep:aws-sdk-secretsmanager"] +dynamodb-kv = ["snapshot-stores", "dep:aws-config", "dep:aws-sdk-dynamodb", "dep:serde_json"] + +[dependencies] +percent-encoding = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +async-trait = { workspace = true, optional = true } +aws-config = { workspace = true, optional = true } +aws-sdk-secretsmanager = { workspace = true, optional = true } +aws-sdk-dynamodb = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +bytes = { workspace = true, optional = true } +edgezero-core = { workspace = true, optional = true } +futures-util = { workspace = true, optional = true } +reqwest = { workspace = true, optional = true, features = ["stream"] } +tokio = { workspace = true, optional = true } + +[dev-dependencies] +base64 = { workspace = true } +futures = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } +toml = { workspace = true } diff --git a/crates/edgezero-store-aws/src/appconfig_tests.rs b/crates/edgezero-store-aws/src/appconfig_tests.rs new file mode 100644 index 00000000..22452b99 --- /dev/null +++ b/crates/edgezero-store-aws/src/appconfig_tests.rs @@ -0,0 +1,166 @@ +//! Literal-loopback HTTP tests for startup-only `AppConfig` snapshots. + +use std::collections::BTreeMap; +use std::time::Duration; + +use edgezero_core::time::MonotonicClock; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +use crate::settings::{ + AgentAccessToken, AgentDocument, AppConfigAgentSettings, AwsPreparationLimits, +}; +use crate::{AwsPreparation, PreparationError}; + +async fn fixture(headers: &str, body: Vec) -> (AppConfigAgentSettings, JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("literal loopback"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + let response_headers = headers.to_owned(); + let server = tokio::spawn(async move { + let (mut socket, _) = timeout(Duration::from_secs(2), listener.accept()) + .await + .expect("finite accept") + .expect("accept"); + let mut request = Vec::new(); + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let mut chunk = [0_u8; 1024]; + let count = socket.read(&mut chunk).await.expect("request"); + assert_ne!(count, 0, "request did not close before its headers"); + request.extend_from_slice(chunk.get(..count).expect("read buffer bound")); + } + socket + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n{response_headers}\r\n", + body.len() + ) + .as_bytes(), + ) + .await + .expect("response head"); + let _written = socket.write_all(&body).await; + String::from_utf8(request).expect("HTTP request") + }); + let settings = AppConfigAgentSettings { + endpoint, + access_token_env: None, + default_key: "main".to_owned(), + documents: BTreeMap::from([( + "main".to_owned(), + AgentDocument { + application: "app".to_owned(), + environment: "test".to_owned(), + profile: "freeform".to_owned(), + }, + )]), + }; + (settings, server) +} + +#[tokio::test] +async fn raw_empty_and_quoted_utf8_are_preserved_and_successes_are_deduplicated() { + for body in ["", "raw free-form\n", "\"quoted\""] { + let (mut settings, server) = fixture("", body.as_bytes().to_vec()).await; + settings + .documents + .insert("alias".to_owned(), settings.documents["main"].clone()); + let mut session = + AwsPreparation::new(MonotonicClock::default(), AwsPreparationLimits::default()) + .expect("session"); + let handle = session + .prepare_appconfig(&settings, None) + .await + .expect("snapshot"); + let _request = server.await.expect("server"); + for key in ["main", "alias"] { + let value = handle + .get(key) + .await + .expect("lookup") + .expect("mapped value"); + assert_eq!(value.as_ref(), body); + } + // The listener is gone. A second store preparation must reuse the successful target. + let cached = session + .prepare_appconfig(&settings, None) + .await + .expect("cached snapshot"); + assert_eq!( + cached + .get("main") + .await + .expect("read") + .expect("value") + .as_ref(), + body + ); + assert_eq!(session.stats().mappings, 4_u64); + assert_eq!( + session.stats().unique_retained_payload_bytes, + u64::try_from(body.len()).expect("length") + ); + assert!(session.stats().metadata_bytes > 0); + } +} + +#[tokio::test] +async fn bearer_header_and_identity_encoding_are_explicit() { + let (mut settings, server) = fixture("", b"opaque".to_vec()).await; + settings.access_token_env = Some("DUMMY_AGENT_TOKEN".to_owned()); + let token = AgentAccessToken::new("dummy-bearer-token".to_owned()).expect("token"); + let mut session = + AwsPreparation::new(MonotonicClock::default(), AwsPreparationLimits::default()) + .expect("session"); + let _handle = session + .prepare_appconfig(&settings, Some(&token)) + .await + .expect("snapshot"); + let request = server.await.expect("server").to_ascii_lowercase(); + assert!(request.contains("authorization: bearer dummy-bearer-token\r\n")); + assert!(request.contains("accept-encoding: identity\r\n")); +} + +#[tokio::test] +async fn nonidentity_encoding_invalid_utf8_and_payload_caps_fail_closed() { + for (headers, body, cap, expected) in [ + ( + "Content-Encoding: gzip\r\n", + b"raw".to_vec(), + 4_u64, + PreparationError::Malformed, + ), + ( + "Content-Encoding: identity\r\nContent-Encoding: identity\r\n", + b"raw".to_vec(), + 4_u64, + PreparationError::Malformed, + ), + ( + "Content-Encoding: identity, identity\r\n", + b"raw".to_vec(), + 4_u64, + PreparationError::Malformed, + ), + ("", vec![0xff_u8], 4_u64, PreparationError::Malformed), + ("", b"five!".to_vec(), 4_u64, PreparationError::SizeLimit), + ] { + let (settings, server) = fixture(headers, body).await; + let limits = AwsPreparationLimits { + max_config_document_bytes: cap, + ..AwsPreparationLimits::default() + }; + let mut session = AwsPreparation::new(MonotonicClock::default(), limits).expect("session"); + let error = session + .prepare_appconfig(&settings, None) + .await + .map(|_handle| ()) + .expect_err("closed failure"); + assert_eq!(error, expected); + assert_eq!(session.stats().unique_retained_payload_bytes, 0); + let _request = server.await.expect("server"); + } +} diff --git a/crates/edgezero-store-aws/src/dynamodb_kv.rs b/crates/edgezero-store-aws/src/dynamodb_kv.rs new file mode 100644 index 00000000..ecfe8d0b --- /dev/null +++ b/crates/edgezero-store-aws/src/dynamodb_kv.rs @@ -0,0 +1,970 @@ +//! Live native `DynamoDB` KV operations. SDK models never cross the portable boundary. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use aws_config::BehaviorVersion; +use aws_sdk_dynamodb::Client; +use aws_sdk_dynamodb::config::timeout::TimeoutConfig; +use aws_sdk_dynamodb::config::{Builder, Region, retry::RetryConfig}; +use aws_sdk_dynamodb::error::SdkError; +use aws_sdk_dynamodb::primitives::Blob; +use aws_sdk_dynamodb::types::{AttributeValue, TableDescription}; +use bytes::Bytes; +use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; +use edgezero_core::time::{Deadline, MonotonicClock}; +use serde::{Deserialize, Serialize}; +use tokio::time::timeout; + +use crate::PreparationError; +use crate::preparation::AwsPreparation; +use crate::settings::{DynamoDbKvLimits, DynamoDbKvSettings}; + +const MAX_CURSOR_BYTES: usize = 0x4000; +const MAX_ITEM_BYTES: usize = 409_600; +const MAX_KEY_BYTES: usize = 512; +const MAX_PAGE_KEYS: usize = 1_000; + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Cursor { + key: String, + namespace: String, + prefix: String, + region: String, + table: String, + version: u8, +} + +struct DynamoDbStore { + client: Client, + clock: MonotonicClock, + limits: DynamoDbKvLimits, + namespace: String, + region: String, + table: String, +} + +#[expect( + clippy::multiple_inherent_impl, + reason = "keep feature-gated DynamoDB transport separate from other AWS services" +)] +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "keep the supported preparation entry point ahead of its private shared setup path" +)] +impl AwsPreparation { + /// Prepare a live namespace-bound KV handle after bounded table validation. + /// + /// No automatic retries are made. A failed write may already have committed. + /// + /// # Errors + /// Returns a closed error for invalid bindings, deadline expiry, or provider failure. + #[inline] + pub async fn prepare_dynamodb( + &mut self, + settings: &DynamoDbKvSettings, + ) -> Result { + self.validate_dynamodb_mapping(settings)?; + let key = (settings.region.clone(), settings.limits); + let client = if let Some(client) = self.dynamodb_clients.get(&key) { + client.clone() + } else { + let budget = self.dynamodb_budget(settings)?; + let result = timeout(budget, async { + let shared = aws_config::defaults(BehaviorVersion::v2026_01_12()) + .region(Region::new(settings.region.clone())) + .load() + .await; + self.check_deadline()?; + Ok::(Client::from_conf( + aws_sdk_dynamodb::Config::from(&shared) + .to_builder() + .region(Region::new(settings.region.clone())) + .retry_config(RetryConfig::standard().with_max_attempts(1)) + .timeout_config(timeout_policy( + settings.limits, + Duration::from_millis(settings.limits.operation_timeout_ms), + )) + .build(), + )) + }) + .await; + self.check_deadline()?; + let client = result.map_err(|_elapsed| PreparationError::Deadline)??; + self.dynamodb_clients.insert(key, client.clone()); + client + }; + self.finish_dynamodb_preparation(settings, client).await + } + + fn dynamodb_budget(&self, settings: &DynamoDbKvSettings) -> Result { + Ok(self + .operation_budget()? + .min(Duration::from_millis(settings.limits.operation_timeout_ms))) + } + + async fn finish_dynamodb_preparation( + &self, + settings: &DynamoDbKvSettings, + client: Client, + ) -> Result { + let budget = self.dynamodb_budget(settings)?; + let result = timeout( + budget, + client + .describe_table() + .table_name(&settings.table) + .customize() + .config_override( + aws_sdk_dynamodb::Config::builder() + .retry_config(RetryConfig::standard().with_max_attempts(1)) + .timeout_config(timeout_policy(settings.limits, budget)), + ) + .send(), + ) + .await; + self.check_deadline()?; + let output = result + .map_err(|_elapsed| PreparationError::Deadline)? + .map_err(|error| { + if matches!(&error, SdkError::TimeoutError(_)) { + return PreparationError::Deadline; + } + classify_preparation_error( + error + .as_service_error() + .and_then(|service| service.meta().code()), + error + .raw_response() + .map(|response| response.status().as_u16()), + ) + })?; + let validation = output + .table + .as_ref() + .ok_or(PreparationError::Malformed) + .and_then(|table| validate_table(table, &settings.table)); + self.check_deadline()?; + validation?; + let handle = KvHandle::new(Arc::new(DynamoDbStore::new( + settings, + client, + self.clock.clone(), + ))); + self.check_deadline()?; + Ok(handle) + } + + #[cfg(test)] + async fn prepare_dynamodb_with_client( + &mut self, + settings: &DynamoDbKvSettings, + client: Client, + ) -> Result { + self.validate_dynamodb_mapping(settings)?; + self.finish_dynamodb_preparation(settings, client).await + } + + fn validate_dynamodb_mapping( + &mut self, + settings: &DynamoDbKvSettings, + ) -> Result<(), PreparationError> { + settings.validate()?; + self.check_deadline()?; + let metadata = settings + .region + .len() + .checked_add(settings.table.len()) + .ok_or(PreparationError::SizeLimit)?; + self.charge_mapping(0, settings.namespace.len(), metadata) + } +} + +impl DynamoDbStore { + async fn bounded( + &self, + deadline: Deadline, + operation: Operation, + ) -> Result + where + Operation: Future>, + { + let remaining = deadline + .remaining_at(self.clock.now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or(KvError::Unavailable)?; + let result = timeout(remaining, operation).await; + self.check_runtime_deadline(deadline)?; + result.map_err(|_elapsed| KvError::Unavailable)? + } + + fn check_runtime_deadline(&self, deadline: Deadline) -> Result<(), KvError> { + if deadline.is_expired_at(self.clock.now()) { + return Err(KvError::Unavailable); + } + Ok(()) + } + + fn decode_cursor(&self, prefix: &str, encoded: &str) -> Result { + decode_cursor(encoded, prefix, &self.region, &self.table, &self.namespace) + } + + fn encode_cursor(&self, prefix: &str, key: String) -> Result { + let cursor = Cursor { + key, + namespace: self.namespace.clone(), + prefix: prefix.to_owned(), + region: self.region.clone(), + table: self.table.clone(), + version: 1, + }; + let encoded = serde_json::to_string(&cursor).map_err(|_error| KvError::Unavailable)?; + if encoded.len() > MAX_CURSOR_BYTES { + return Err(KvError::Unavailable); + } + Ok(encoded) + } + + fn key_attributes(&self, key: &str) -> HashMap { + HashMap::from([ + ("pk".to_owned(), AttributeValue::S(self.namespace.clone())), + ("sk".to_owned(), AttributeValue::S(key.to_owned())), + ]) + } + + fn new(settings: &DynamoDbKvSettings, client: Client, clock: MonotonicClock) -> Self { + Self { + client, + clock, + limits: settings.limits, + namespace: settings.namespace.clone(), + region: settings.region.clone(), + table: settings.table.clone(), + } + } + + fn operation_policy(&self, deadline: Deadline) -> Result { + let budget = deadline + .remaining_at(self.clock.now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or(KvError::Unavailable)?; + Ok(aws_sdk_dynamodb::Config::builder() + .retry_config(RetryConfig::standard().with_max_attempts(1)) + .timeout_config(timeout_policy(self.limits, budget))) + } + + async fn put(&self, key: &str, value: Bytes, ttl: Option) -> Result<(), KvError> { + let deadline = self.runtime_deadline()?; + if !valid_key(key) || value.len() > self.limits.max_value_bytes { + return Err(invalid_input()); + } + let expiry = ttl + .map(|duration| { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_error| KvError::Unavailable)?; + expiry_from_duration(now, duration) + }) + .transpose()?; + validate_item_size(self.namespace.len(), key.len(), value.len(), expiry)?; + let mut item = self.key_attributes(key); + // The payload cap and complete item accounting are checked before this copy. + item.insert("v".to_owned(), AttributeValue::B(Blob::new(value.to_vec()))); + if let Some(expires_at) = expiry { + item.insert( + "expires_at_ms".to_owned(), + AttributeValue::N(expires_at.to_string()), + ); + item.insert( + "ttl_epoch_s".to_owned(), + AttributeValue::N(expires_at.div_ceil(1_000).to_string()), + ); + } + self.bounded(deadline, async { + self.client + .put_item() + .table_name(&self.table) + .set_item(Some(item)) + .customize() + .config_override(self.operation_policy(deadline)?) + .send() + .await + .map_err(|_error| KvError::Unavailable)?; + Ok(()) + }) + .await + } + + fn runtime_deadline(&self) -> Result { + self.clock + .now() + .checked_add(Duration::from_millis(self.limits.operation_timeout_ms)) + .map(Deadline::at_instant) + .ok_or(KvError::Unavailable) + } +} + +#[async_trait(?Send)] +impl KvStore for DynamoDbStore { + #[inline] + async fn delete(&self, key: &str) -> Result<(), KvError> { + let deadline = self.runtime_deadline()?; + if !valid_key(key) { + return Err(invalid_input()); + } + self.bounded(deadline, async { + self.client + .delete_item() + .table_name(&self.table) + .set_key(Some(self.key_attributes(key))) + .customize() + .config_override(self.operation_policy(deadline)?) + .send() + .await + .map_err(|_error| KvError::Unavailable)?; + Ok(()) + }) + .await + } + + #[inline] + async fn exists(&self, key: &str) -> Result { + Ok(self.get_bytes(key).await?.is_some()) + } + + #[inline] + async fn get_bytes(&self, key: &str) -> Result, KvError> { + let deadline = self.runtime_deadline()?; + if !valid_key(key) { + return Err(invalid_input()); + } + let result = self + .bounded(deadline, async { + let output = self + .client + .get_item() + .table_name(&self.table) + .set_key(Some(self.key_attributes(key))) + .consistent_read(true) + .customize() + .config_override(self.operation_policy(deadline)?) + .send() + .await + .map_err(|_error| KvError::Unavailable)?; + let Some(mut item) = output.item else { + return Ok(None); + }; + if row_key(&item, &self.namespace)? != key { + return Err(KvError::Unavailable); + } + let expiry = read_expiry(&item)?; + let value = match item.remove("v") { + Some(AttributeValue::B(value)) + if value.as_ref().len() <= self.limits.max_value_bytes => + { + Bytes::from(value.into_inner()) + } + _ => return Err(KvError::Unavailable), + }; + Ok(Some((value, expiry))) + }) + .await?; + let now = epoch_ms()?; + let value = result.and_then(|(value, expiry)| (!is_expired(expiry, now)).then_some(value)); + self.check_runtime_deadline(deadline)?; + Ok(value) + } + + #[inline] + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + let deadline = self.runtime_deadline()?; + if prefix.len() > MAX_KEY_BYTES + || prefix.chars().any(char::is_control) + || limit == 0 + || limit > MAX_PAGE_KEYS + { + return Err(invalid_input()); + } + let after = cursor + .map(|encoded| self.decode_cursor(prefix, encoded)) + .transpose()?; + let query_limit = i32::try_from(limit).map_err(|_error| invalid_input())?; + self.bounded(deadline, async { + let mut request = self + .client + .query() + .table_name(&self.table) + .consistent_read(true) + .scan_index_forward(true) + .limit(query_limit) + .key_condition_expression(if prefix.is_empty() { + "#pk = :pk" + } else { + "#pk = :pk AND begins_with(#sk, :prefix)" + }) + .projection_expression("#pk, #sk, #expires, #ttl") + .expression_attribute_names("#pk", "pk") + .expression_attribute_names("#sk", "sk") + .expression_attribute_names("#expires", "expires_at_ms") + .expression_attribute_names("#ttl", "ttl_epoch_s") + .expression_attribute_values(":pk", AttributeValue::S(self.namespace.clone())); + if !prefix.is_empty() { + request = request + .expression_attribute_values(":prefix", AttributeValue::S(prefix.to_owned())); + } + if let Some(key) = &after { + request = request.set_exclusive_start_key(Some(self.key_attributes(key))); + } + let output = request + .customize() + .config_override(self.operation_policy(deadline)?) + .send() + .await + .map_err(|_error| KvError::Unavailable)?; + let items = output.items.unwrap_or_default(); + if items.len() > limit { + return Err(KvError::Unavailable); + } + let mut rows = Vec::with_capacity(items.len()); + let mut previous = after.clone(); + for item in &items { + let key = row_key(item, &self.namespace)?; + if !key.starts_with(prefix) || previous.as_deref().is_some_and(|last| key <= last) { + return Err(KvError::Unavailable); + } + previous = Some(key.to_owned()); + rows.push((key.to_owned(), read_expiry(item)?)); + } + let next = match output.last_evaluated_key { + None => None, + Some(key) if key.is_empty() => None, + Some(key) => { + if key.len() != 2 { + return Err(KvError::Unavailable); + } + let next_key = row_key(&key, &self.namespace)?; + if !next_key.starts_with(prefix) + || after.as_deref().is_some_and(|last| next_key <= last) + || previous.as_deref().is_some_and(|last| next_key < last) + { + return Err(KvError::Unavailable); + } + Some(self.encode_cursor(prefix, next_key.to_owned())?) + } + }; + // Expiry is checked after validation and cursor encoding, not at request start. + let completed = epoch_ms()?; + let keys = rows + .into_iter() + .filter_map(|(key, expiry)| (!is_expired(expiry, completed)).then_some(key)) + .collect(); + Ok(KvPage { cursor: next, keys }) + }) + .await + } + + #[inline] + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.put(key, value, None).await + } + + #[inline] + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + self.put(key, value, Some(ttl)).await + } +} + +fn classify_preparation_error(code: Option<&str>, status: Option) -> PreparationError { + match code { + Some("AccessDeniedException" | "AccessDenied") => PreparationError::AccessDenied, + Some("ExpiredTokenException" | "InvalidClientTokenId" | "UnrecognizedClientException") => { + PreparationError::Credentials + } + Some("ResourceNotFoundException") => PreparationError::Missing, + Some( + "ThrottlingException" + | "ProvisionedThroughputExceededException" + | "RequestLimitExceeded", + ) => PreparationError::Throttled, + Some("ValidationException") => PreparationError::InvalidResource, + _ => match status { + Some(401 | 403) => PreparationError::AccessDenied, + Some(429) => PreparationError::Throttled, + _ => PreparationError::Unavailable, + }, + } +} + +fn decode_cursor( + encoded: &str, + prefix: &str, + region: &str, + table: &str, + namespace: &str, +) -> Result { + if encoded.len() > MAX_CURSOR_BYTES { + return Err(invalid_input()); + } + let cursor: Cursor = serde_json::from_str(encoded).map_err(|_error| invalid_input())?; + if cursor.version != 1 + || cursor.region != region + || cursor.table != table + || cursor.namespace != namespace + || cursor.prefix != prefix + || !valid_key(&cursor.key) + || !cursor.key.starts_with(prefix) + { + return Err(invalid_input()); + } + Ok(cursor.key) +} + +fn epoch_ms() -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_error| KvError::Unavailable)?; + u64::try_from(now.as_millis()).map_err(|_error| KvError::Unavailable) +} + +fn expiry_from_duration(now: Duration, ttl: Duration) -> Result { + if ttl.is_zero() { + return Err(invalid_input()); + } + let expires = now.checked_add(ttl).ok_or_else(invalid_input)?; + let rounded = expires + .as_millis() + .checked_add(u128::from( + !expires.subsec_nanos().is_multiple_of(1_000_000), + )) + .ok_or_else(invalid_input)?; + u64::try_from(rounded).map_err(|_error| invalid_input()) +} + +fn invalid_input() -> KvError { + KvError::Validation("invalid DynamoDB KV input".to_owned()) +} + +fn is_expired(expiry: Option, now: u64) -> bool { + expiry.is_some_and(|expires| now >= expires) +} + +fn read_expiry(item: &HashMap) -> Result, KvError> { + match (item.get("expires_at_ms"), item.get("ttl_epoch_s")) { + (None, None) => Ok(None), + (Some(AttributeValue::N(expiry)), Some(AttributeValue::N(ttl))) => { + let expires = read_unsigned(expiry)?; + if read_unsigned(ttl)? != expires.div_ceil(1_000) { + return Err(KvError::Unavailable); + } + Ok(Some(expires)) + } + _ => Err(KvError::Unavailable), + } +} + +fn read_unsigned(value: &str) -> Result { + if value.is_empty() || value.len() > 20 || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(KvError::Unavailable); + } + let number = value + .parse::() + .map_err(|_error| KvError::Unavailable)?; + if number.to_string() != value { + return Err(KvError::Unavailable); + } + Ok(number) +} + +fn row_key<'row>( + item: &'row HashMap, + namespace: &str, +) -> Result<&'row str, KvError> { + match (item.get("pk"), item.get("sk")) { + (Some(AttributeValue::S(partition)), Some(AttributeValue::S(key))) + if partition == namespace && valid_key(key) => + { + Ok(key) + } + _ => Err(KvError::Unavailable), + } +} + +fn timeout_policy(limits: DynamoDbKvLimits, budget: Duration) -> TimeoutConfig { + TimeoutConfig::builder() + .connect_timeout(budget.min(Duration::from_millis(limits.connect_timeout_ms))) + .operation_attempt_timeout(budget.min(Duration::from_millis(limits.attempt_timeout_ms))) + .operation_timeout(budget) + .build() +} + +fn valid_key(key: &str) -> bool { + !key.is_empty() + && key.len() <= MAX_KEY_BYTES + && key != "." + && key != ".." + && !key.chars().any(char::is_control) +} + +fn validate_item_size( + namespace: usize, + key: usize, + value: usize, + expiry: Option, +) -> Result<(), KvError> { + let expiry_bytes = expiry + .map(|expires| { + "expires_at_ms" + .len() + .checked_add(expires.to_string().len()) + .and_then(|size| size.checked_add("ttl_epoch_s".len())) + .and_then(|size| size.checked_add(expires.div_ceil(1_000).to_string().len())) + .ok_or_else(invalid_input) + }) + .transpose()? + .unwrap_or_default(); + // Decimal text overestimates DynamoDB numeric storage and keeps accounting conservative. + let size = namespace + .checked_add(key) + .and_then(|size| size.checked_add(value)) + .and_then(|size| size.checked_add("pk".len())) + .and_then(|size| size.checked_add("sk".len())) + .and_then(|size| size.checked_add("v".len())) + .and_then(|size| size.checked_add(expiry_bytes)) + .ok_or_else(invalid_input)?; + if namespace > MAX_KEY_BYTES || key > MAX_KEY_BYTES || size > MAX_ITEM_BYTES { + return Err(invalid_input()); + } + Ok(()) +} + +fn validate_table(table: &TableDescription, expected_name: &str) -> Result<(), PreparationError> { + if table.table_name.as_deref() != Some(expected_name) { + return Err(PreparationError::InvalidResource); + } + if table + .table_status + .as_ref() + .is_none_or(|status| status.as_str() != "ACTIVE") + || !table.global_secondary_indexes().is_empty() + || !table.local_secondary_indexes().is_empty() + || table + .vector_indexes + .as_ref() + .is_some_and(|indexes| !indexes.is_empty()) + || !table.replicas().is_empty() + || table.global_table_version.is_some() + { + return Err(PreparationError::InvalidResource); + } + let keys = table.key_schema(); + let attributes = table.attribute_definitions(); + if keys.len() != 2 + || attributes.len() != 2 + || !keys + .iter() + .any(|key| key.attribute_name() == "pk" && key.key_type().as_str() == "HASH") + || !keys + .iter() + .any(|key| key.attribute_name() == "sk" && key.key_type().as_str() == "RANGE") + || !attributes.iter().any(|attribute| { + attribute.attribute_name() == "pk" && attribute.attribute_type().as_str() == "S" + }) + || !attributes.iter().any(|attribute| { + attribute.attribute_name() == "sk" && attribute.attribute_type().as_str() == "S" + }) + { + return Err(PreparationError::InvalidResource); + } + Ok(()) +} + +#[cfg(test)] +#[path = "dynamodb_tests.rs"] +mod sdk_tests; + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::time::Duration; + + use aws_sdk_dynamodb::types::{ + AttributeDefinition, AttributeValue, KeySchemaElement, KeyType, ScalarAttributeType, + TableDescription, TableStatus, + }; + use edgezero_core::key_value_store::KvError; + + use crate::PreparationError; + + use super::{ + Cursor, MAX_CURSOR_BYTES, decode_cursor, expiry_from_duration, is_expired, read_expiry, + read_unsigned, row_key, valid_key, validate_item_size, validate_table, + }; + + fn active_table_description() -> TableDescription { + TableDescription::builder() + .table_name("expected-table") + .table_status(TableStatus::Active) + .key_schema( + KeySchemaElement::builder() + .attribute_name("pk") + .key_type(KeyType::Hash) + .build() + .unwrap(), + ) + .key_schema( + KeySchemaElement::builder() + .attribute_name("sk") + .key_type(KeyType::Range) + .build() + .unwrap(), + ) + .attribute_definitions( + AttributeDefinition::builder() + .attribute_name("pk") + .attribute_type(ScalarAttributeType::S) + .build() + .unwrap(), + ) + .attribute_definitions( + AttributeDefinition::builder() + .attribute_name("sk") + .attribute_type(ScalarAttributeType::S) + .build() + .unwrap(), + ) + .build() + } + + #[test] + fn cursor_codec_rejects_extra_fields_and_versions_are_explicit() { + let cursor = Cursor { + key: "a/b".to_owned(), + namespace: "ns".to_owned(), + prefix: "a/".to_owned(), + region: "us-east-1".to_owned(), + table: "table".to_owned(), + version: 1, + }; + let encoded = serde_json::to_string(&cursor).unwrap(); + let decoded: Cursor = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded.key, "a/b"); + assert_eq!(decoded.version, 1); + let mut object = serde_json::to_value(&cursor).unwrap(); + object + .as_object_mut() + .unwrap() + .insert("unknown".to_owned(), serde_json::json!(1_i32)); + assert!(serde_json::from_value::(object).is_err()); + } + + #[test] + fn cursor_validation_rejects_every_scope_and_invalid_continuations() { + let cursor = Cursor { + key: "a/b".to_owned(), + namespace: "ns".to_owned(), + prefix: "a/".to_owned(), + region: "us-east-1".to_owned(), + table: "expected-table".to_owned(), + version: 1, + }; + let valid_encoded = serde_json::to_string(&cursor).unwrap(); + assert_eq!( + decode_cursor(&valid_encoded, "a/", "us-east-1", "expected-table", "ns").unwrap(), + "a/b" + ); + for field in ["namespace", "prefix", "region", "table"] { + let mut object = serde_json::to_value(&cursor).unwrap(); + object + .as_object_mut() + .unwrap() + .insert(field.to_owned(), serde_json::json!("other")); + let scope_encoded = serde_json::to_string(&object).unwrap(); + assert!(matches!( + decode_cursor(&scope_encoded, "a/", "us-east-1", "expected-table", "ns") + .unwrap_err(), + KvError::Validation(_) + )); + } + for key in ["", ".", "..", "other/key", "a/\n"] { + let mut object = serde_json::to_value(&cursor).unwrap(); + object + .as_object_mut() + .unwrap() + .insert("key".to_owned(), serde_json::json!(key)); + let key_encoded = serde_json::to_string(&object).unwrap(); + assert!(matches!( + decode_cursor(&key_encoded, "a/", "us-east-1", "expected-table", "ns").unwrap_err(), + KvError::Validation(_) + )); + } + let mut object = serde_json::to_value(&cursor).unwrap(); + object + .as_object_mut() + .unwrap() + .insert("version".to_owned(), serde_json::json!(2_u8)); + let version_encoded = serde_json::to_string(&object).unwrap(); + assert!(matches!( + decode_cursor(&version_encoded, "a/", "us-east-1", "expected-table", "ns").unwrap_err(), + KvError::Validation(_) + )); + object.as_object_mut().unwrap().remove("namespace"); + let missing_scope_encoded = serde_json::to_string(&object).unwrap(); + decode_cursor( + &missing_scope_encoded, + "a/", + "us-east-1", + "expected-table", + "ns", + ) + .unwrap_err(); + let oversized = "x".repeat(MAX_CURSOR_BYTES.checked_add(1).unwrap()); + decode_cursor(&oversized, "a/", "us-east-1", "expected-table", "ns").unwrap_err(); + } + + #[test] + fn expiry_metadata_rejects_wrong_types_and_unbounded_numbers() { + let valid = HashMap::from([ + ( + "expires_at_ms".to_owned(), + AttributeValue::N("1001".to_owned()), + ), + ("ttl_epoch_s".to_owned(), AttributeValue::N("2".to_owned())), + ]); + for attribute in ["expires_at_ms", "ttl_epoch_s"] { + for invalid in [ + AttributeValue::S("2".to_owned()), + AttributeValue::Bool(false), + AttributeValue::Null(true), + AttributeValue::N("-1".to_owned()), + AttributeValue::N("NaN".to_owned()), + AttributeValue::N("100000000000000000000".to_owned()), + ] { + let mut row = valid.clone(); + row.insert(attribute.to_owned(), invalid); + assert!(matches!( + read_expiry(&row).unwrap_err(), + KvError::Unavailable + )); + } + let mut row = valid.clone(); + row.remove(attribute); + assert!(matches!( + read_expiry(&row).unwrap_err(), + KvError::Unavailable + )); + } + let mut inconsistent = valid; + inconsistent.insert("ttl_epoch_s".to_owned(), AttributeValue::N("1".to_owned())); + assert!(matches!( + read_expiry(&inconsistent).unwrap_err(), + KvError::Unavailable + )); + let maximum = HashMap::from([ + ( + "expires_at_ms".to_owned(), + AttributeValue::N(u64::MAX.to_string()), + ), + ( + "ttl_epoch_s".to_owned(), + AttributeValue::N(u64::MAX.div_ceil(1_000).to_string()), + ), + ]); + assert_eq!(read_expiry(&maximum).unwrap(), Some(u64::MAX)); + } + + #[test] + fn expiry_pairs_are_checked_and_deadline_is_inclusive() { + let mut row = HashMap::from([ + ( + "expires_at_ms".to_owned(), + AttributeValue::N("1001".to_owned()), + ), + ("ttl_epoch_s".to_owned(), AttributeValue::N("2".to_owned())), + ]); + assert_eq!(read_expiry(&row).unwrap(), Some(1_001)); + assert!(!is_expired(Some(1_001), 1_000)); + assert!(is_expired(Some(1_001), 1_001)); + row.remove("ttl_epoch_s"); + assert!(matches!(read_expiry(&row), Err(KvError::Unavailable))); + row.clear(); + assert_eq!(read_expiry(&row).unwrap(), None); + } + + #[test] + fn expiry_rounding_never_shortens_ttl() { + assert_eq!( + expiry_from_duration(Duration::from_nanos(1), Duration::from_millis(1)).unwrap(), + 2 + ); + assert_eq!( + expiry_from_duration(Duration::from_secs(1), Duration::from_millis(1)).unwrap(), + 1_001 + ); + expiry_from_duration(Duration::ZERO, Duration::ZERO).unwrap_err(); + expiry_from_duration(Duration::MAX, Duration::from_millis(1)).unwrap_err(); + } + + #[test] + fn integer_metadata_is_bounded_and_canonical() { + for value in ["", "-1", "+1", "01", "1.0", "1e3", "18446744073709551616"] { + read_unsigned(value).unwrap_err(); + } + assert_eq!(read_unsigned("18446744073709551615").unwrap(), u64::MAX); + } + + #[test] + fn item_size_accounting_handles_boundaries_and_overflow() { + validate_item_size(512, 512, 393_216, Some(u64::MAX)).unwrap(); + validate_item_size(512, 512, 409_600, None).unwrap_err(); + validate_item_size(512, 512, usize::MAX, None).unwrap_err(); + } + + #[test] + fn keys_preserve_unicode_and_reject_invalid_rows() { + let composed = "\u{00e9}"; + let decomposed = "e\u{0301}"; + assert_ne!(composed, decomposed); + assert!(valid_key(composed)); + assert!(valid_key(decomposed)); + assert!(valid_key(&"x".repeat(512))); + assert!(!valid_key(&"x".repeat(513))); + for key in ["", ".", "..", "a\n"] { + assert!(!valid_key(key)); + } + let row = HashMap::from([ + ("pk".to_owned(), AttributeValue::S("ns".to_owned())), + ("sk".to_owned(), AttributeValue::S(decomposed.to_owned())), + ]); + assert_eq!(row_key(&row, "ns").unwrap(), decomposed); + row_key(&row, "other").unwrap_err(); + } + + #[test] + fn table_name_must_be_present_and_match_the_selected_resource() { + let mut table = active_table_description(); + validate_table(&table, "expected-table").unwrap(); + assert_eq!( + validate_table(&table, "wrong-table").unwrap_err(), + PreparationError::InvalidResource + ); + table.table_name = None; + assert_eq!( + validate_table(&table, "expected-table").unwrap_err(), + PreparationError::InvalidResource + ); + } +} diff --git a/crates/edgezero-store-aws/src/dynamodb_tests.rs b/crates/edgezero-store-aws/src/dynamodb_tests.rs new file mode 100644 index 00000000..d0d575e6 --- /dev/null +++ b/crates/edgezero-store-aws/src/dynamodb_tests.rs @@ -0,0 +1,935 @@ +//! Loopback SDK and portable-contract tests for the real `DynamoDbStore`. +//! +//! These fixtures exercise the actual AWS SDK serializer/deserializer and the +//! actual `DynamoDbStore`; the in-process service is only the HTTP peer. No +//! ambient AWS config is consulted by `loopback_client`. + +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "fixture, protocol, runtime wrapper and contract cases are grouped" +)] + +use super::*; +use crate::preparation::AwsPreparation; +use crate::settings::{AwsPreparationLimits, DynamoDbKvLimits, DynamoDbKvSettings}; +use edgezero_core::time::MonotonicClock; +use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::io::{ErrorKind, Read as _, Write as _}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::str; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use aws_config::BehaviorVersion; +use aws_sdk_dynamodb::Client; +use aws_sdk_dynamodb::config::Credentials; +use aws_sdk_dynamodb::config::timeout::TimeoutConfig; +use aws_sdk_dynamodb::config::{Region, retry::RetryConfig}; +use aws_sdk_dynamodb::operation::get_item::GetItemOutput; +use aws_sdk_dynamodb::primitives::Blob; +use aws_sdk_dynamodb::types::AttributeValue; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use bytes::Bytes; +use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; +use serde_json::{Value, json}; +use tokio::runtime::{Builder as RuntimeBuilder, Runtime}; + +const TABLE: &str = "fixture-kv"; +const NAMESPACE: &str = "sdk-tests"; +const REGION: &str = "us-east-1"; +const MAX_HEADER_BYTES: usize = 0x1_0000; +const MAX_REQUEST_BODY_BYTES: usize = 0x20_0000; +const TEST_KEY: &str = "nfc/\u{e9}"; +const TEST_VALUE: &[u8] = &[0, 0xff, b'\n', b'\'']; + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct RowKey { + pk: String, + sk: String, +} + +#[derive(Clone, Debug)] +struct Row { + expires_at_ms: Option, + ttl_epoch_s: Option, + value: Vec, +} + +#[derive(Default)] +struct FixtureState { + commit_before_error: bool, + describe_response: Option, + fail_target: Option<(String, u16, Value)>, + query_pages: VecDeque, + requests: Vec<(String, Value)>, + rows: BTreeMap, + stall_target: Option, +} + +/// Minimal `DynamoDB` JSON 1.0 endpoint. `Drop` stops accept and joins the thread; +/// every socket read has a deadline, and each response closes the connection. +struct DynamoFixture { + accepted: Arc, + addr: SocketAddr, + state: Arc>, + stopped: Arc, + worker: Option>, +} + +impl DynamoFixture { + fn commit_before_error(&self) { + self.state.lock().unwrap().commit_before_error = true; + } + + fn endpoint(&self) -> String { + format!("http://{}", self.addr) + } + + fn fail_target(&self, target: &str, status: u16, response: Value) { + self.state.lock().unwrap().fail_target = Some((target.to_owned(), status, response)); + } + + fn has_row(&self, key: &str) -> bool { + self.state.lock().unwrap().rows.contains_key(&RowKey { + pk: NAMESPACE.into(), + sk: key.into(), + }) + } + + fn push_query_page(&self, response: Value) { + self.state.lock().unwrap().query_pages.push_back(response); + } + + fn request_count(&self) -> usize { + self.state.lock().unwrap().requests.len() + } + + fn requests(&self) -> Vec<(String, Value)> { + self.state.lock().unwrap().requests.clone() + } + + fn seed(&self, key: &str, row: Row) { + self.state.lock().unwrap().rows.insert( + RowKey { + pk: NAMESPACE.into(), + sk: key.into(), + }, + row, + ); + } + + fn set_describe_response(&self, response: Value) { + self.state.lock().unwrap().describe_response = Some(response); + } + + fn stall_target(&self, target: &str) { + self.state.lock().unwrap().stall_target = Some(target.to_owned()); + } + + fn start() -> Self { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind literal loopback"); + listener.set_nonblocking(true).expect("nonblocking accept"); + let addr = listener.local_addr().unwrap(); + let state = Arc::new(Mutex::new(FixtureState::default())); + let stopped = Arc::new(AtomicBool::new(false)); + let accepted = Arc::new(AtomicUsize::new(0)); + let worker_state = Arc::clone(&state); + let worker_stopped = Arc::clone(&stopped); + let worker_accepted = Arc::clone(&accepted); + let worker = thread::spawn(move || { + while !worker_stopped.load(Ordering::Acquire) { + match listener.accept() { + Ok((mut stream, _)) => { + worker_accepted.fetch_add(1, Ordering::Relaxed); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("set read timeout"); + stream + .set_write_timeout(Some(Duration::from_secs(3))) + .expect("set write timeout"); + serve_one(&mut stream, &worker_state); + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(2)); + } + Err(_) if worker_stopped.load(Ordering::Acquire) => break, + Err(error) => panic!("fixture accept failed: {error}"), + } + } + }); + Self { + accepted, + addr, + state, + stopped, + worker: Some(worker), + } + } +} + +impl Drop for DynamoFixture { + fn drop(&mut self) { + self.stopped.store(true, Ordering::Release); + if let Some(worker) = self.worker.take() { + worker.join().expect("fixture thread joined"); + } + debug_assert!( + self.request_count() + .checked_add(1) + .is_some_and(|requests| self.accepted.load(Ordering::Relaxed) <= requests) + ); + } +} + +fn serve_one(stream: &mut TcpStream, state: &Arc>) { + let Some((target, body)) = read_request(stream) else { + return; + }; + let parsed: Value = serde_json::from_slice(&body).expect("SDK emits valid JSON"); + let mut fixture = state.lock().unwrap(); + fixture.requests.push((target.clone(), parsed.clone())); + if fixture.stall_target.as_deref() == Some(&target) { + // Client operation timeout must bound the wait; fixture's finite upper + // bound also prevents a broken client from hanging test teardown. + drop(fixture); + thread::sleep(Duration::from_secs(3)); + return; + } + let (status, response) = if let Some((failed, status, response)) = fixture.fail_target.clone() { + if failed == target { + if fixture.commit_before_error + && matches!(target.rsplit('.').next(), Some("PutItem" | "DeleteItem")) + { + drop(respond(&mut fixture, &target, &parsed)); + } + (status, response) + } else { + respond(&mut fixture, &target, &parsed) + } + } else { + respond(&mut fixture, &target, &parsed) + }; + drop(fixture); + let encoded = serde_json::to_vec(&response).unwrap(); + let status_text = if status == 200 { "OK" } else { "Bad Request" }; + let head = format!( + "HTTP/1.1 {status} {status_text}\r\ncontent-type: application/x-amz-json-1.0\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + encoded.len() + ); + // Timed-out clients may close the socket before the finite fixture response. + stream + .write_all(head.as_bytes()) + .and_then(|()| stream.write_all(&encoded)) + .unwrap_or_default(); +} + +fn read_request(stream: &mut TcpStream) -> Option<(String, Vec)> { + let mut data = Vec::with_capacity(4096); + let mut chunk = [0_u8; 2048]; + let header_end = loop { + let count = stream.read(&mut chunk).ok()?; + if count == 0 { + return None; + } + data.extend_from_slice(&chunk[..count]); + if let Some(end) = data.windows(4).position(|window| window == b"\r\n\r\n") { + break end.checked_add(4)?; + } + assert!(data.len() < MAX_HEADER_BYTES, "oversized request headers"); + }; + let headers = str::from_utf8(data.get(..header_end)?).ok()?; + let target = headers.lines().find_map(|line| { + line.split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("x-amz-target")) + .map(|(_, value)| value.trim().to_owned()) + })?; + let length = headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("content-length") { + value.trim().parse::().ok() + } else { + None + } + })?; + assert!( + length < MAX_REQUEST_BODY_BYTES, + "fixture request body too large" + ); + let body_end = header_end.checked_add(length)?; + while data.len() < body_end { + let count = stream.read(&mut chunk).ok()?; + if count == 0 { + return None; + } + data.extend_from_slice(&chunk[..count]); + } + Some((target, data.get(header_end..body_end)?.to_vec())) +} + +fn respond(state: &mut FixtureState, target: &str, body: &Value) -> (u16, Value) { + match target.rsplit('.').next().unwrap_or_default() { + "DescribeTable" if state.describe_response.is_some() => { + (200, state.describe_response.take().unwrap()) + } + "DescribeTable" => (200, active_table()), + "PutItem" => { + let item = &body["Item"]; + let key = RowKey { + pk: item["pk"]["S"].as_str().unwrap().into(), + sk: item["sk"]["S"].as_str().unwrap().into(), + }; + assert_eq!( + key.pk, NAMESPACE, + "wire partition must be explicit namespace" + ); + let value = item["v"]["B"].as_str().expect("Binary value on wire"); + let bytes = decode_base64_known(value); + let expires_at_ms = item + .get("expires_at_ms") + .and_then(|attribute| attribute["N"].as_str()) + .and_then(|number| number.parse().ok()); + let ttl_epoch_s = item + .get("ttl_epoch_s") + .and_then(|attribute| attribute["N"].as_str()) + .and_then(|number| number.parse().ok()); + state.rows.insert( + key, + Row { + value: bytes, + expires_at_ms, + ttl_epoch_s, + }, + ); + (200, json!({})) + } + "GetItem" => { + let key = wire_key(body); + let mut response = json!({}); + if let Some(row) = state.rows.get(&key) { + response["Item"] = row_to_item(&key, row); + } + (200, response) + } + "DeleteItem" => { + state.rows.remove(&wire_key(body)); + (200, json!({})) + } + "Query" => { + if let Some(page) = state.query_pages.pop_front() { + return (200, page); + } + let partition = body["ExpressionAttributeValues"][":pk"]["S"] + .as_str() + .unwrap(); + assert_eq!(partition, NAMESPACE, "query is namespace scoped"); + let prefix = body["ExpressionAttributeValues"] + .get(":prefix") + .and_then(|attribute| attribute["S"].as_str()) + .unwrap_or(""); + let after = body + .get("ExclusiveStartKey") + .and_then(|key| key["sk"]["S"].as_str()) + .unwrap_or(""); + let limit = usize::try_from(body["Limit"].as_u64().unwrap_or(100)).unwrap_or(100); + let matching: Vec<_> = state + .rows + .iter() + .filter(|(key, _)| { + key.pk == partition && key.sk.starts_with(prefix) && key.sk.as_str() > after + }) + .collect(); + let selected: Vec<_> = matching.iter().take(limit).copied().collect(); + let rows: Vec = selected + .iter() + .map(|(key, row)| row_to_item(key, row)) + .collect(); + let mut response = json!({"Items": rows}); + if matching.len() > limit { + let (last_key, _) = selected + .last() + .expect("positive query limit has selected row"); + response["LastEvaluatedKey"] = + json!({"pk":{"S":last_key.pk}, "sk":{"S":last_key.sk}}); + } + (200, response) + } + other => panic!("unexpected DynamoDB SDK operation target: {other}"), + } +} + +fn wire_key(body: &Value) -> RowKey { + RowKey { + pk: body["Key"]["pk"]["S"].as_str().unwrap().into(), + sk: body["Key"]["sk"]["S"].as_str().unwrap().into(), + } +} + +fn row_to_item(key: &RowKey, row: &Row) -> Value { + let mut item = + json!({"pk":{"S":key.pk}, "sk":{"S":key.sk}, "v":{"B":encode_base64(&row.value)}}); + if let Some(value) = row.expires_at_ms { + item["expires_at_ms"] = json!({"N":value.to_string()}); + } + if let Some(value) = row.ttl_epoch_s { + item["ttl_epoch_s"] = json!({"N":value.to_string()}); + } + item +} + +fn encode_base64(bytes: &[u8]) -> String { + STANDARD.encode(bytes) +} + +fn decode_base64_known(input: &str) -> Vec { + STANDARD + .decode(input) + .expect("SDK Binary uses standard base64") +} + +fn loopback_client(runtime: &Runtime, endpoint: &str, limits: DynamoDbKvLimits) -> Client { + // Region and static credentials are explicitly injected before config load; + // the service endpoint is explicit in the DynamoDB config. There is no + // profile, web-identity, IMDS, environment-credential, or endpoint lookup. + let shared = runtime.block_on( + aws_config::defaults(BehaviorVersion::v2026_01_12()) + .region(Region::new(REGION)) + .credentials_provider(Credentials::new( + "fixture-access-key", + "fixture-secret-key", + None, + None, + "sdk-tests", + )) + .load(), + ); + let budget = Duration::from_millis(limits.operation_timeout_ms); + let config = aws_sdk_dynamodb::Config::from(&shared) + .to_builder() + .region(Region::new(REGION)) + .endpoint_url(endpoint) + .retry_config(RetryConfig::standard().with_max_attempts(1)) + .timeout_config( + TimeoutConfig::builder() + .connect_timeout(budget.min(Duration::from_millis(limits.connect_timeout_ms))) + .operation_attempt_timeout( + budget.min(Duration::from_millis(limits.attempt_timeout_ms)), + ) + .operation_timeout(budget) + .build(), + ) + .build(); + Client::from_conf(config) +} + +fn active_table() -> Value { + json!({"Table": { + "TableName": TABLE, "TableStatus": "ACTIVE", + "KeySchema": [ + {"AttributeName":"pk", "KeyType":"HASH"}, + {"AttributeName":"sk", "KeyType":"RANGE"} + ], + "AttributeDefinitions": [ + {"AttributeName":"pk", "AttributeType":"S"}, + {"AttributeName":"sk", "AttributeType":"S"} + ], "GlobalSecondaryIndexes": [], "LocalSecondaryIndexes": [], "Replicas": [] + }}) +} + +/// Runtime is created before constructing the actual provider and before +/// invoking the futures-executor contract macro. The wrapper is only an +/// executor bridge; every operation still reaches `DynamoDbStore` + the real SDK. +struct RuntimeStore { + fixture: DynamoFixture, + runtime: Runtime, + store: DynamoDbStore, +} + +impl RuntimeStore { + fn new() -> Self { + let fixture = DynamoFixture::start(); + // Establish the runtime before provider construction/factory use. + let runtime = RuntimeBuilder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let limits = DynamoDbKvLimits::default(); + let client = loopback_client(&runtime, &fixture.endpoint(), limits); + let store = DynamoDbStore::new(&settings(), client, MonotonicClock::default()); + Self { + fixture, + runtime, + store, + } + } +} + +fn settings() -> DynamoDbKvSettings { + DynamoDbKvSettings { + region: REGION.into(), + table: TABLE.into(), + namespace: NAMESPACE.into(), + limits: DynamoDbKvLimits::default(), + } +} + +#[async_trait(?Send)] +impl KvStore for RuntimeStore { + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.runtime.block_on(self.store.delete(key)) + } + async fn exists(&self, key: &str) -> Result { + self.runtime.block_on(self.store.exists(key)) + } + async fn get_bytes(&self, key: &str) -> Result, KvError> { + self.runtime.block_on(self.store.get_bytes(key)) + } + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + self.runtime + .block_on(self.store.list_keys_page(prefix, cursor, limit)) + } + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.runtime.block_on(self.store.put_bytes(key, value)) + } + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + self.runtime + .block_on(self.store.put_bytes_with_ttl(key, value, ttl)) + } +} + +edgezero_core::key_value_store_contract_tests!(dynamodb_real_store_contract, RuntimeStore::new()); + +#[test] +fn sdk_wire_put_preserves_raw_binary_and_explicit_namespace() { + let store = RuntimeStore::new(); + store + .runtime + .block_on( + store + .store + .put_bytes(TEST_KEY, Bytes::from_static(TEST_VALUE)), + ) + .unwrap(); + let requests = store.fixture.requests(); + let (target, body) = requests + .iter() + .find(|(target, _)| target.ends_with("PutItem")) + .unwrap(); + assert!(target.starts_with("DynamoDB_20120810.")); + assert_eq!(body["Item"]["pk"]["S"], NAMESPACE); + assert_eq!(body["Item"]["sk"]["S"], TEST_KEY); + assert_eq!( + decode_base64_known(body["Item"]["v"]["B"].as_str().unwrap()), + TEST_VALUE + ); +} + +#[test] +fn nfc_and_nfd_are_distinct_wire_sort_keys() { + let store = RuntimeStore::new(); + store.runtime.block_on(async { + store + .store + .put_bytes("\u{e9}", Bytes::from_static(b"nfc")) + .await + .unwrap(); + store + .store + .put_bytes("e\u{301}", Bytes::from_static(b"nfd")) + .await + .unwrap(); + assert_eq!( + store.store.get_bytes("\u{e9}").await.unwrap(), + Some(Bytes::from_static(b"nfc")) + ); + assert_eq!( + store.store.get_bytes("e\u{301}").await.unwrap(), + Some(Bytes::from_static(b"nfd")) + ); + }); + let sort_keys: Vec<_> = store + .fixture + .requests() + .iter() + .filter(|(target, _)| target.ends_with("PutItem")) + .map(|(_, body)| body["Item"]["sk"]["S"].as_str().unwrap().to_owned()) + .collect(); + assert_eq!(sort_keys, ["\u{e9}", "e\u{301}"]); +} + +#[test] +fn malformed_and_cross_scope_cursors_fail_before_http() { + let store = RuntimeStore::new(); + let before = store.fixture.request_count(); + let malformed = store + .runtime + .block_on(store.store.list_keys_page("", Some("not-a-cursor"), 2)); + assert!(matches!(malformed, Err(KvError::Validation(_)))); + assert_eq!(store.fixture.request_count(), before); + let cursor = store + .store + .encode_cursor("p/", "p/after".to_owned()) + .unwrap(); + let mut other_settings = settings(); + other_settings.namespace = "another-namespace".to_owned(); + let other_client = loopback_client( + &store.runtime, + &store.fixture.endpoint(), + other_settings.limits, + ); + let other = DynamoDbStore::new(&other_settings, other_client, MonotonicClock::default()); + let cross_scope = store + .runtime + .block_on(other.list_keys_page("p/", Some(&cursor), 2)); + assert!(matches!(cross_scope, Err(KvError::Validation(_)))); + assert_eq!(store.fixture.request_count(), before); +} + +#[test] +fn empty_expired_query_page_with_last_evaluated_key_is_not_filled() { + let store = RuntimeStore::new(); + let continuation = json!({"pk":{"S":NAMESPACE}, "sk":{"S":"p/expired"}}); + store.fixture.push_query_page(json!({ + "Items": [{ + "pk":{"S":NAMESPACE}, "sk":{"S":"p/expired"}, + "expires_at_ms":{"N":"1"}, "ttl_epoch_s":{"N":"1"} + }], + "LastEvaluatedKey": continuation + })); + store.fixture.push_query_page(json!({ + "Items": [{"pk":{"S":NAMESPACE}, "sk":{"S":"p/live"}, "v":{"B":"bGl2ZQ=="}}] + })); + let first = store + .runtime + .block_on(store.store.list_keys_page("p/", None, 1)) + .unwrap(); + assert!(first.keys.is_empty()); + assert!(first.cursor.is_some()); + assert_eq!( + store + .fixture + .requests() + .iter() + .filter(|(target, _)| target.ends_with("Query")) + .count(), + 1, + "provider must not fill an empty expired page with another Query" + ); + let second = store + .runtime + .block_on(store.store.list_keys_page("p/", first.cursor.as_deref(), 1)) + .unwrap(); + assert_eq!(second.keys, ["p/live"]); + let queries: Vec<_> = store + .fixture + .requests() + .into_iter() + .filter(|(target, _)| target.ends_with("Query")) + .collect(); + assert!(queries[1].1.get("ExclusiveStartKey").is_some()); + assert_eq!(queries.len(), 2); +} + +fn run_prepare( + fixture: &DynamoFixture, + settings: &DynamoDbKvSettings, +) -> Result { + let runtime = RuntimeBuilder::new_current_thread() + .enable_all() + .build() + .map_err(|_error| crate::PreparationError::Unavailable)?; + let client = loopback_client(&runtime, &fixture.endpoint(), settings.limits); + let mut preparation = + AwsPreparation::new(MonotonicClock::default(), AwsPreparationLimits::default())?; + runtime.block_on(preparation.prepare_dynamodb_with_client(settings, client)) +} + +#[test] +fn preparation_describes_and_accepts_only_the_supported_active_schema() { + let fixture = DynamoFixture::start(); + let result = run_prepare(&fixture, &settings()); + result.unwrap(); + let requests = fixture.requests(); + assert_eq!(requests.len(), 1); + assert!(requests[0].0.ends_with("DescribeTable")); + assert_eq!(requests[0].1["TableName"], TABLE); +} + +#[test] +fn preparation_rejects_missing_denied_and_unsupported_tables_closed() { + let cases = [ + ( + 400, + json!({"__type":"com.amazonaws.dynamodb.v20120810#ResourceNotFoundException", "message":"fixture-secret-marker"}), + ), + ( + 400, + json!({"__type":"com.amazonaws.dynamodb.v20120810#AccessDeniedException", "message":"fixture-secret-marker"}), + ), + ]; + for (status, response) in cases { + let fixture = DynamoFixture::start(); + fixture.fail_target("DynamoDB_20120810.DescribeTable", status, response); + let result = run_prepare(&fixture, &settings()); + let failure = result.unwrap_err(); + assert_eq!(fixture.request_count(), 1); + assert!(!format!("{failure:?}").contains("fixture-secret-marker")); + } + + let fixture = DynamoFixture::start(); + let mut wrong_schema = active_table(); + wrong_schema["Table"]["KeySchema"][0]["AttributeName"] = json!("wrong-pk"); + fixture.set_describe_response(wrong_schema); + let result = run_prepare(&fixture, &settings()); + assert_eq!(result.err(), Some(crate::PreparationError::InvalidResource)); + assert_eq!(fixture.request_count(), 1); +} + +#[test] +fn preparation_rejects_indexes_and_global_table_metadata() { + let indexed = [ + ("GlobalSecondaryIndexes", json!([{"IndexName":"gsi"}])), + ("LocalSecondaryIndexes", json!([{"IndexName":"lsi"}])), + ("Replicas", json!([{"RegionName":"us-west-2"}])), + ]; + for (field, value) in indexed { + let fixture = DynamoFixture::start(); + let mut table = active_table(); + table["Table"][field] = value; + fixture.set_describe_response(table); + let result = run_prepare(&fixture, &settings()); + assert_eq!( + result.err(), + Some(crate::PreparationError::InvalidResource), + "unexpectedly accepted {field}" + ); + assert_eq!(fixture.request_count(), 1); + } +} + +#[test] +fn provider_enforces_value_and_key_boundaries_before_io() { + let store = RuntimeStore::new(); + let accepted = Bytes::from(vec![0x5a; store.store.limits.max_value_bytes]); + store + .runtime + .block_on(store.store.put_bytes("boundary", accepted)) + .unwrap(); + assert_eq!( + store + .runtime + .block_on(store.store.get_bytes("boundary")) + .unwrap(), + Some(Bytes::from(vec![0x5a; store.store.limits.max_value_bytes])) + ); + let after_valid = store.fixture.request_count(); + assert_eq!(after_valid, 2); + let too_large_size = store + .store + .limits + .max_value_bytes + .checked_add(1) + .expect("test cap has headroom"); + let too_large = Bytes::from(vec![0x5a; too_large_size]); + let oversized_result = store + .runtime + .block_on(store.store.put_bytes("too-large", too_large)); + assert!(matches!(oversized_result, Err(KvError::Validation(_)))); + assert_eq!(store.fixture.request_count(), after_valid); + + store + .runtime + .block_on(store.store.put_bytes("empty-binary", Bytes::new())) + .unwrap(); + assert_eq!( + store + .runtime + .block_on(store.store.get_bytes("empty-binary")) + .unwrap(), + Some(Bytes::new()) + ); + + let key_512 = "k".repeat(512); + store + .runtime + .block_on(store.store.put_bytes(&key_512, Bytes::new())) + .unwrap(); + let before_invalid_key = store.fixture.request_count(); + let invalid_key_result = store + .runtime + .block_on(store.store.get_bytes(&"k".repeat(513))); + assert!(matches!(invalid_key_result, Err(KvError::Validation(_)))); + assert_eq!(store.fixture.request_count(), before_invalid_key); +} + +#[test] +fn malformed_stored_expiry_fails_closed() { + let store = RuntimeStore::new(); + store.fixture.seed( + "malformed", + Row { + expires_at_ms: Some(1_000), + ttl_epoch_s: Some(9), + value: b"secret-row-value".to_vec(), + }, + ); + let result = store.runtime.block_on(store.store.get_bytes("malformed")); + assert!(matches!(&result, Err(KvError::Unavailable))); + assert_eq!(store.fixture.request_count(), 1); + assert!(!format!("{result:?}").contains("secret-row-value")); +} + +#[test] +fn ttl_renewal_survives_and_plain_put_clears_expiry_fields() { + let store = RuntimeStore::new(); + store + .runtime + .block_on(store.store.put_bytes_with_ttl( + "renew", + Bytes::from_static(b"old"), + Duration::from_millis(1), + )) + .unwrap(); + store + .runtime + .block_on(store.store.put_bytes_with_ttl( + "renew", + Bytes::from_static(b"renewed"), + Duration::from_secs(2), + )) + .unwrap(); + thread::sleep(Duration::from_millis(25)); + assert_eq!( + store + .runtime + .block_on(store.store.get_bytes("renew")) + .unwrap(), + Some(Bytes::from_static(b"renewed")) + ); + store + .runtime + .block_on( + store + .store + .put_bytes("renew", Bytes::from_static(b"permanent")), + ) + .unwrap(); + let puts: Vec<_> = store + .fixture + .requests() + .into_iter() + .filter(|(target, _)| target.ends_with("PutItem")) + .collect(); + assert_eq!(puts.len(), 3); + assert!(puts[0].1["Item"].get("expires_at_ms").is_some()); + assert!(puts[0].1["Item"].get("ttl_epoch_s").is_some()); + assert!(puts[2].1["Item"].get("expires_at_ms").is_none()); + assert!(puts[2].1["Item"].get("ttl_epoch_s").is_none()); +} + +#[test] +fn write_failure_is_one_attempt_and_reports_no_secret_material() { + let store = RuntimeStore::new(); + let marker = "private-fixture-value-7fbc"; + store.fixture.commit_before_error(); + store.fixture.fail_target( + "DynamoDB_20120810.PutItem", + 500, + json!({"__type":"InternalServerError", "message":"fixture-secret-marker"}), + ); + let result = store.runtime.block_on( + store + .store + .put_bytes("private-fixture-key", Bytes::from(marker)), + ); + assert!(matches!(&result, Err(KvError::Unavailable))); + let requests = store.fixture.requests(); + assert_eq!( + requests + .iter() + .filter(|(target, _)| target.ends_with("PutItem")) + .count(), + 1 + ); + assert!( + store.fixture.has_row("private-fixture-key"), + "fixture committed before returning 500" + ); + let diagnostic = format!("{result:?}"); + for secret in [ + marker, + "private-fixture-key", + "fixture-secret-key", + "fixture-secret-marker", + ] { + assert!(!diagnostic.contains(secret)); + } +} + +#[test] +fn ambiguous_delete_can_remove_row_before_unavailable_response() { + let store = RuntimeStore::new(); + store.fixture.seed( + "ambiguous-delete", + Row { + expires_at_ms: None, + ttl_epoch_s: None, + value: b"before".to_vec(), + }, + ); + store.fixture.commit_before_error(); + store.fixture.fail_target( + "DynamoDB_20120810.DeleteItem", + 500, + json!({"__type":"InternalServerError", "message":"fixture"}), + ); + let result = store + .runtime + .block_on(store.store.delete("ambiguous-delete")); + assert!(matches!(result, Err(KvError::Unavailable))); + assert_eq!(store.fixture.request_count(), 1); + assert!(!store.fixture.has_row("ambiguous-delete")); +} + +#[test] +fn operation_stall_is_bounded() { + let store = RuntimeStore::new(); + store.fixture.stall_target("DynamoDB_20120810.GetItem"); + let start = Instant::now(); + let result = store.runtime.block_on(store.store.get_bytes("stall")); + assert!(matches!(result, Err(KvError::Unavailable))); + assert!( + start.elapsed() < Duration::from_secs(4), + "operation timeout did not bound stall" + ); + assert_eq!(store.fixture.request_count(), 1); +} + +#[test] +fn sdk_get_output_debug_contains_key_and_binary_hex() { + let key = "debug-key-sentinel-91a2"; + let binary = AttributeValue::B(Blob::new(vec![0xde, 0xad, 0xb3, 0x3f])); + let item = HashMap::from([ + ("sk".to_owned(), AttributeValue::S(key.to_owned())), + ("v".to_owned(), binary), + ]); + let output = GetItemOutput::builder().set_item(Some(item)).build(); + let diagnostic = format!("{output:?}"); + assert!(diagnostic.contains(key)); + assert!(diagnostic.contains("deadb33f")); +} diff --git a/crates/edgezero-store-aws/src/lib.rs b/crates/edgezero-store-aws/src/lib.rs new file mode 100644 index 00000000..160e6b39 --- /dev/null +++ b/crates/edgezero-store-aws/src/lib.rs @@ -0,0 +1,87 @@ +//! Native AWS store preparation, independent of the HTTP adapter. +//! +//! Settings remain available without either service feature. Service failures +//! use closed categories; no SDK, HTTP, settings or payload source chain survives. + +#![cfg_attr( + all( + not(target_arch = "wasm32"), + any( + feature = "appconfig-agent", + feature = "secrets-manager", + feature = "dynamodb-kv" + ) + ), + expect( + clippy::pub_use, + reason = "keep the provider's supported preparation API at the crate root" + ) +)] + +pub mod settings; + +#[cfg(test)] +#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "appconfig-agent")] +mod appconfig_tests; + +#[cfg(all(not(target_arch = "wasm32"), feature = "dynamodb-kv"))] +mod dynamodb_kv; +#[cfg(all( + not(target_arch = "wasm32"), + any( + feature = "appconfig-agent", + feature = "secrets-manager", + feature = "dynamodb-kv" + ) +))] +mod preparation; +#[cfg(all(not(target_arch = "wasm32"), feature = "secrets-manager"))] +mod secrets_manager; +#[cfg(test)] +#[cfg(all(not(target_arch = "wasm32"), feature = "secrets-manager"))] +mod snapshot_tests; +#[cfg(all( + not(target_arch = "wasm32"), + any( + feature = "appconfig-agent", + feature = "secrets-manager", + feature = "dynamodb-kv" + ) +))] +pub use preparation::{AwsPreparation, PreparationStats}; + +/// Sanitized preparation failures. Never attach provider response details. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum PreparationError { + #[error("access denied")] + AccessDenied, + #[error("credential failure")] + Credentials, + #[error("preparation deadline")] + Deadline, + #[error("decryption failure")] + Decryption, + #[error("invalid binding")] + InvalidBinding, + #[error("invalid agent endpoint")] + InvalidEndpoint, + #[error("invalid preparation limits")] + InvalidLimits, + #[error("invalid resource reference")] + InvalidResource, + #[error("invalid version selector")] + InvalidSelector, + #[error("malformed value")] + Malformed, + #[error("missing resource or version")] + Missing, + #[error("size limit")] + SizeLimit, + #[error("throttled")] + Throttled, + #[error("provider unavailable")] + Unavailable, + #[error("provider not compiled")] + UnsupportedProvider, +} diff --git a/crates/edgezero-store-aws/src/preparation.rs b/crates/edgezero-store-aws/src/preparation.rs new file mode 100644 index 00000000..6c2facf6 --- /dev/null +++ b/crates/edgezero-store-aws/src/preparation.rs @@ -0,0 +1,752 @@ +//! Native service preparation session and immutable `AppConfig` snapshots. +//! +//! This module is enabled only when at least one native AWS store service is +//! selected. The session's counters and caches are private to one startup. + +#[cfg(any(feature = "appconfig-agent", feature = "secrets-manager"))] +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::fmt; +#[cfg(feature = "appconfig-agent")] +use std::sync::Arc; +use std::time::Duration; + +#[cfg(any(feature = "appconfig-agent", feature = "secrets-manager"))] +use edgezero_core::config_store::BoundedStoreRead; +#[cfg(feature = "appconfig-agent")] +use edgezero_core::config_store::ConfigStoreHandle; +#[cfg(feature = "appconfig-agent")] +use edgezero_core::config_store::{ + ConfigStore, ConfigStoreError, ConfigValue, finish_bounded_config_read, +}; +#[cfg(test)] +#[cfg(feature = "appconfig-agent")] +use edgezero_core::config_store_contract_tests; +#[cfg(feature = "secrets-manager")] +use edgezero_core::secret_store::{SecretError, SecretStore, finish_bounded_secret_read}; +use edgezero_core::time::{Deadline, MonotonicClock}; +#[cfg(feature = "appconfig-agent")] +use futures_util::StreamExt as _; +#[cfg(feature = "appconfig-agent")] +use reqwest::header::{ACCEPT_ENCODING, AUTHORIZATION, CONTENT_ENCODING, HeaderValue}; +#[cfg(feature = "appconfig-agent")] +use reqwest::{Client, ClientBuilder, redirect::Policy}; +#[cfg(feature = "appconfig-agent")] +use tokio::time::timeout; + +use crate::PreparationError; +use crate::settings::AwsPreparationLimits; +#[cfg(feature = "dynamodb-kv")] +use crate::settings::DynamoDbKvLimits; +#[cfg(feature = "appconfig-agent")] +use crate::settings::{AgentAccessToken, AppConfigAgentSettings}; +#[cfg(feature = "dynamodb-kv")] +use aws_sdk_dynamodb::Client as DynamoDbClient; + +/// Aggregate startup preparation. Never share this object across startup runs. +#[expect( + clippy::field_scoped_visibility_modifiers, + reason = "the session shares selected state with its feature-gated Secrets Manager sibling module" +)] +pub struct AwsPreparation { + accounting: Accounting, + #[cfg(feature = "appconfig-agent")] + appconfig_cache: HashMap, + #[cfg(feature = "appconfig-agent")] + appconfig_client: Option, + pub(crate) clock: MonotonicClock, + pub(crate) deadline: Deadline, + #[cfg(feature = "dynamodb-kv")] + pub(crate) dynamodb_clients: HashMap<(String, DynamoDbKvLimits), DynamoDbClient>, + pub(crate) limits: AwsPreparationLimits, + #[cfg(feature = "secrets-manager")] + pub(crate) secret_cache: HashMap, + #[cfg(feature = "secrets-manager")] + pub(crate) secret_clients: HashMap, +} + +impl fmt::Debug for AwsPreparation { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AwsPreparation") + .field("stats", &self.stats()) + .finish_non_exhaustive() + } +} + +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "keep startup entry points and feature-gated service operations grouped ahead of private helpers" +)] +impl AwsPreparation { + /// Start the aggregate deadline before creating any service client. + /// + /// A timeout bounds observation of async work, not arbitrary blocking provider + /// work or subprocesses: dropping the future is not a hard wall-clock guarantee. + /// + /// # Errors + /// + /// Returns [`PreparationError::InvalidLimits`] when limits are invalid or the + /// aggregate deadline cannot be represented. + #[inline] + pub fn new( + clock: MonotonicClock, + limits: AwsPreparationLimits, + ) -> Result { + limits.validate()?; + let duration = Duration::from_millis(limits.preparation_timeout_ms); + let deadline = clock + .now() + .checked_add(duration) + .map(Deadline::at_instant) + .ok_or(PreparationError::InvalidLimits)?; + Ok(Self { + clock, + deadline, + limits, + accounting: Accounting::default(), + #[cfg(feature = "dynamodb-kv")] + dynamodb_clients: HashMap::new(), + #[cfg(feature = "appconfig-agent")] + appconfig_client: None, + #[cfg(feature = "appconfig-agent")] + appconfig_cache: HashMap::new(), + #[cfg(feature = "secrets-manager")] + secret_cache: HashMap::new(), + #[cfg(feature = "secrets-manager")] + secret_clients: HashMap::new(), + }) + } + + /// Fetch and freeze all mapped `AppConfig` Agent documents into a core handle. + /// + /// # Errors + /// + /// Returns a sanitized [`PreparationError`] when settings, authorization, the + /// deadline, a response, or a configured size limit prevents preparation. + #[inline] + #[cfg(feature = "appconfig-agent")] + pub async fn prepare_appconfig( + &mut self, + settings: &AppConfigAgentSettings, + token: Option<&AgentAccessToken>, + ) -> Result { + settings.validate()?; + if settings.access_token_env.is_some() != token.is_some() { + return Err(PreparationError::InvalidBinding); + } + let mut targets = Vec::new(); + for (key, document) in &settings.documents { + self.check_deadline()?; + let url = settings.document_url(document)?; + let auth = token.map_or_else( + || AuthorizationIdentity::NoBearer { + env_name: settings.access_token_env.clone(), + }, + |value| AuthorizationIdentity::Bearer { + env_name: settings.access_token_env.clone(), + token: value.expose().to_owned(), + }, + ); + let target = AppConfigTargetKey { + url: url.as_str().to_owned(), + auth, + }; + let auth_metadata = match &target.auth { + AuthorizationIdentity::NoBearer { env_name } => { + env_name.as_ref().map_or(0, String::len) + } + AuthorizationIdentity::Bearer { + env_name, + token: bearer_token, + } => env_name + .as_ref() + .map_or(Some(0), |name| Some(name.len())) + .and_then(|bytes| bytes.checked_add(bearer_token.len())) + .ok_or(PreparationError::SizeLimit)?, + }; + let target_metadata = target + .url + .len() + .checked_add(auth_metadata) + .ok_or(PreparationError::SizeLimit)?; + self.accounting + .charge_mapping(&self.limits, key.len(), 0, target_metadata)?; + targets.push((key, url, target)); + } + let mut entries = BTreeMap::new(); + for (key, url, target) in targets { + self.check_deadline()?; + let value = if let Some(value) = self.appconfig_cache.get(&target) { + value.clone() + } else { + let fetched = self.fetch_agent_document(url, token).await?; + self.accounting + .charge_retained(&self.limits, fetched.as_ref().len())?; + self.appconfig_cache.insert(target, fetched.clone()); + fetched + }; + entries.insert(key.clone(), value); + } + self.check_deadline()?; + Ok(ConfigStoreHandle::new(Arc::new(ConfigSnapshot { entries }))) + } + + /// Safe counters only; no identifiers, lengths of individual values, or auth data. + #[must_use] + #[inline] + pub fn stats(&self) -> PreparationStats { + PreparationStats { + mappings: self.accounting.mapping_count, + unique_retained_payload_bytes: self.accounting.unique_retained_payload_bytes, + metadata_bytes: self.accounting.metadata_bytes, + peak_transient_staging_bytes: self.accounting.peak_transient_staging_bytes, + } + } + + pub(crate) fn check_deadline(&self) -> Result { + self.deadline + .remaining_at(self.clock.now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or(PreparationError::Deadline) + } + + /// Current operation allowance, clipped to the shared deadline. + pub(crate) fn operation_budget(&self) -> Result { + Ok(self + .check_deadline()? + .min(Duration::from_millis(self.limits.operation_timeout_ms))) + } + + /// Charge one logical mapping before cache lookup/deduplication. + #[cfg(any(feature = "secrets-manager", feature = "dynamodb-kv"))] + pub(crate) fn charge_mapping( + &mut self, + key_bytes: usize, + namespace_bytes: usize, + target_metadata_bytes: usize, + ) -> Result<(), PreparationError> { + self.accounting.charge_mapping( + &self.limits, + key_bytes, + namespace_bytes, + target_metadata_bytes, + ) + } + + /// Retain one successful unique payload. Call only after all value caps pass. + #[cfg(feature = "secrets-manager")] + pub(crate) fn charge_retained(&mut self, bytes: usize) -> Result<(), PreparationError> { + self.accounting.charge_retained(&self.limits, bytes) + } + + #[cfg(feature = "appconfig-agent")] + async fn fetch_agent_document( + &mut self, + url: url::Url, + token: Option<&AgentAccessToken>, + ) -> Result { + self.check_deadline()?; + let client = if let Some(client) = &self.appconfig_client { + client.clone() + } else { + let client = ClientBuilder::new() + .no_proxy() + .connection_verbose(false) + .redirect(Policy::none()) + .no_gzip() + .no_brotli() + .no_deflate() + .no_zstd() + .connect_timeout( + self.operation_budget()? + .min(Duration::from_millis(self.limits.connect_timeout_ms)), + ) + .build() + .map_err(|_error| PreparationError::Unavailable)?; + self.check_deadline()?; + self.appconfig_client = Some(client.clone()); + client + }; + let budget = self + .operation_budget()? + .min(Duration::from_millis(self.limits.attempt_timeout_ms)); + let mut request = client + .get(url) + .header(ACCEPT_ENCODING, "identity") + .timeout(budget); + if let Some(access_token) = token { + let mut value = HeaderValue::from_str(&format!("Bearer {}", access_token.expose())) + .map_err(|_error| PreparationError::InvalidBinding)?; + value.set_sensitive(true); + request = request.header(AUTHORIZATION, value); + } + let remaining_payload = self + .limits + .max_snapshot_bytes + .checked_sub(self.accounting.unique_retained_payload_bytes) + .ok_or(PreparationError::SizeLimit)?; + let cap = usize::try_from(self.limits.max_config_document_bytes.min(remaining_payload)) + .map_err(|_error| PreparationError::InvalidLimits)?; + let fetched = timeout(budget, async { + let response = request.send().await.map_err(map_http_error)?; + if !response.status().is_success() { + return Err(match response.status().as_u16() { + 401 | 403 => PreparationError::AccessDenied, + 404 => PreparationError::Missing, + 429 => PreparationError::Throttled, + _ => PreparationError::Unavailable, + }); + } + if response.headers().get_all(CONTENT_ENCODING).iter().count() > 1 + || response + .headers() + .get_all(CONTENT_ENCODING) + .iter() + .any(|value| { + value.to_str().map_or(true, |encoding| { + !encoding.trim().eq_ignore_ascii_case("identity") + }) + }) + { + return Err(PreparationError::Malformed); + } + if response + .content_length() + .is_some_and(|length| length > u64::try_from(cap).unwrap_or(u64::MAX)) + { + return Err(PreparationError::SizeLimit); + } + let mut stream = response.bytes_stream(); + let mut body = Vec::with_capacity(cap.min(16 * 1024)); + while let Some(chunk_result) = stream.next().await { + let chunk_bytes = chunk_result.map_err(map_http_error)?; + if chunk_bytes.len() > cap.saturating_sub(body.len()) { + return Err(PreparationError::SizeLimit); + } + self.check_deadline()?; + self.accounting.observe_staging( + body.len() + .checked_add(chunk_bytes.len()) + .ok_or(PreparationError::SizeLimit)?, + )?; + body.extend_from_slice(&chunk_bytes); + } + String::from_utf8(body).map_err(|_error| PreparationError::Malformed) + }) + .await + .map_err(|_elapsed| PreparationError::Deadline)?; + self.check_deadline()?; + let text = fetched?; + // Preserve every UTF-8 byte, including empty/non-JSON free-form documents. + self.accounting.observe_staging(text.len())?; + let value = ConfigValue::from(text); + self.check_deadline()?; + Ok(value) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PreparationStats { + pub mappings: u64, + pub metadata_bytes: u64, + pub peak_transient_staging_bytes: u64, + pub unique_retained_payload_bytes: u64, +} + +#[derive(Default)] +struct Accounting { + mapping_count: u64, + metadata_bytes: u64, + peak_transient_staging_bytes: u64, + unique_retained_payload_bytes: u64, +} + +impl Accounting { + fn charge_mapping( + &mut self, + limits: &AwsPreparationLimits, + key_bytes: usize, + namespace_bytes: usize, + target_metadata_bytes: usize, + ) -> Result<(), PreparationError> { + let mapping_count = self + .mapping_count + .checked_add(1) + .filter(|count| *count <= limits.max_remote_values) + .ok_or(PreparationError::SizeLimit)?; + let metadata = key_bytes + .checked_add(namespace_bytes) + .and_then(|value| value.checked_add(target_metadata_bytes)) + .and_then(|value| u64::try_from(value).ok()) + .and_then(|value| self.metadata_bytes.checked_add(value)) + .ok_or(PreparationError::SizeLimit)?; + // Metadata has no independent configured ceiling. Checked arithmetic keeps + // the diagnostic statistic representable without pretending it is an RSS cap. + self.mapping_count = mapping_count; + self.metadata_bytes = metadata; + Ok(()) + } + + #[cfg(any(feature = "appconfig-agent", feature = "secrets-manager"))] + fn charge_retained( + &mut self, + limits: &AwsPreparationLimits, + bytes: usize, + ) -> Result<(), PreparationError> { + let byte_count = u64::try_from(bytes).map_err(|_error| PreparationError::SizeLimit)?; + let total = self + .unique_retained_payload_bytes + .checked_add(byte_count) + .filter(|total| *total <= limits.max_snapshot_bytes) + .ok_or(PreparationError::SizeLimit)?; + self.unique_retained_payload_bytes = total; + Ok(()) + } + + #[cfg(feature = "appconfig-agent")] + fn observe_staging(&mut self, bytes: usize) -> Result<(), PreparationError> { + let byte_count = u64::try_from(bytes).map_err(|_error| PreparationError::SizeLimit)?; + self.peak_transient_staging_bytes = self.peak_transient_staging_bytes.max(byte_count); + Ok(()) + } +} + +#[cfg(feature = "appconfig-agent")] +#[derive(Clone, Eq, Hash, PartialEq)] +struct AppConfigTargetKey { + auth: AuthorizationIdentity, + url: String, +} + +#[cfg(feature = "appconfig-agent")] +#[derive(Clone, Eq, Hash, PartialEq)] +enum AuthorizationIdentity { + Bearer { + env_name: Option, + token: String, + }, + NoBearer { + env_name: Option, + }, +} + +#[cfg(feature = "appconfig-agent")] +impl fmt::Debug for AuthorizationIdentity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AuthorizationIdentity([redacted])") + } +} + +#[cfg(feature = "appconfig-agent")] +impl fmt::Debug for AppConfigTargetKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AppConfigTargetKey([redacted])") + } +} + +#[cfg(feature = "secrets-manager")] +#[derive(Clone, Eq, Hash, PartialEq)] +#[expect( + clippy::field_scoped_visibility_modifiers, + reason = "the client cache key is shared only with the feature-gated Secrets Manager sibling module" +)] +pub(crate) struct SecretClientKey { + pub(crate) policy: String, + pub(crate) region: String, +} + +#[cfg(feature = "secrets-manager")] +#[expect( + clippy::field_scoped_visibility_modifiers, + reason = "the SDK client is shared only with the feature-gated Secrets Manager sibling module" +)] +pub(crate) struct SecretClient { + pub(crate) client: aws_sdk_secretsmanager::Client, +} + +#[cfg(feature = "secrets-manager")] +#[derive(Clone, Eq, Hash, PartialEq)] +#[expect( + clippy::field_scoped_visibility_modifiers, + reason = "the target cache key is shared only with the feature-gated Secrets Manager sibling module" +)] +pub(crate) struct SecretTargetKey { + pub(crate) client_policy: String, + pub(crate) region: String, + pub(crate) secret_id: String, + /// Exact private selector identity; never include in diagnostics. + pub(crate) selector: String, +} + +#[cfg(feature = "secrets-manager")] +impl fmt::Debug for SecretTargetKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretTargetKey([redacted])") + } +} + +#[cfg(feature = "appconfig-agent")] +struct ConfigSnapshot { + entries: BTreeMap, +} + +#[cfg(feature = "appconfig-agent")] +impl fmt::Debug for ConfigSnapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ConfigSnapshot") + .field("entry_count", &self.entries.len()) + .finish() + } +} + +#[cfg(feature = "appconfig-agent")] +#[async_trait::async_trait(?Send)] +impl ConfigStore for ConfigSnapshot { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.entries.get(key).cloned()) + } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + let result = finish_bounded_config_read( + Ok(self.entries.get(key).cloned()), + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ); + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + result + } +} + +#[cfg(feature = "secrets-manager")] +#[expect( + clippy::field_scoped_visibility_modifiers, + reason = "the immutable snapshot is constructed by the feature-gated Secrets Manager sibling module" +)] +pub(crate) struct SecretSnapshot { + pub(crate) entries: BTreeMap, + pub(crate) namespace: String, +} + +#[cfg(feature = "secrets-manager")] +impl fmt::Debug for SecretSnapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SecretSnapshot") + .field("entry_count", &self.entries.len()) + .finish_non_exhaustive() + } +} + +#[cfg(feature = "secrets-manager")] +#[async_trait::async_trait(?Send)] +impl SecretStore for SecretSnapshot { + async fn get_bytes( + &self, + store_name: &str, + key: &str, + ) -> Result, SecretError> { + Ok((store_name == self.namespace) + .then(|| self.entries.get(key).cloned()) + .flatten()) + } + + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + if deadline.is_expired_at(clock.now()) { + return Err(SecretError::DeadlineExceeded); + } + let value = (store_name == self.namespace) + .then(|| self.entries.get(key).cloned()) + .flatten(); + let result = finish_bounded_secret_read( + Ok(value), + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ); + if deadline.is_expired_at(clock.now()) { + return Err(SecretError::DeadlineExceeded); + } + result + } +} + +#[cfg(feature = "appconfig-agent")] +fn map_http_error(_error: reqwest::Error) -> PreparationError { + PreparationError::Unavailable +} + +#[cfg(test)] +#[cfg(feature = "appconfig-agent")] +mod tests { + use super::*; + use edgezero_core::time::MonotonicInstant; + use futures::executor::block_on; + use std::sync::Mutex; + + fn agent_settings(endpoint: &str) -> AppConfigAgentSettings { + use crate::settings::AgentDocument; + AppConfigAgentSettings { + default_key: "main".to_owned(), + endpoint: endpoint.to_owned(), + access_token_env: Some("EDGE_TOKEN".to_owned()), + documents: BTreeMap::from([( + "main".to_owned(), + AgentDocument { + application: "app".to_owned(), + environment: "test".to_owned(), + profile: "profile".to_owned(), + }, + )]), + } + } + + fn test_clock() -> MonotonicClock { + MonotonicClock::default() + } + + #[test] + fn absolute_deadline_is_checked_and_snapshot_contract_is_ordinary() { + let clock = test_clock(); + let _session = AwsPreparation::new(clock, AwsPreparationLimits::default()) + .expect("default limits produce a valid preparation session"); + let accounting = Accounting::default(); + assert_eq!(accounting.mapping_count, 0); + } + + #[test] + fn accounting_charges_aliases_before_dedup_and_checks_overflow() { + let limits = AwsPreparationLimits { + max_remote_values: 1, + ..AwsPreparationLimits::default() + }; + let mut accounting = Accounting::default(); + accounting + .charge_mapping(&limits, 3, 0, 4) + .expect("first mapping is within the configured limit"); + assert_eq!( + accounting.charge_mapping(&limits, 3, 0, 4), + Err(PreparationError::SizeLimit) + ); + accounting.unique_retained_payload_bytes = u64::MAX; + assert_eq!( + accounting.charge_retained(&limits, 1), + Err(PreparationError::SizeLimit) + ); + } + + #[tokio::test] + async fn appconfig_real_loopback_fetch_and_exact_body_snapshot() { + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind loopback"); + let address = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept"); + let mut request_bytes = vec![0_u8; 4096]; + let count = socket.read(&mut request_bytes).await.expect("read request"); + let request_text = String::from_utf8_lossy(&request_bytes[..count]); + assert!( + request_text + .to_ascii_lowercase() + .contains("accept-encoding: identity") + ); + assert!( + request_text.contains("/applications/app/environments/test/configurations/profile") + ); + let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 12\r\nConnection: close\r\n\r\n{\"x\":\"\u{fc}\"} "; + socket + .write_all(response.as_bytes()) + .await + .expect("write response"); + }); + let mut settings = agent_settings(&format!("http://{address}")); + settings.access_token_env = None; + let mut session = + AwsPreparation::new(test_clock(), AwsPreparationLimits::default()).expect("session"); + let handle = session + .prepare_appconfig(&settings, None) + .await + .expect("prepare"); + let value = handle + .get("main") + .await + .expect("read snapshot") + .expect("entry"); + assert_eq!(value.as_ref(), "{\"x\":\"\u{fc}\"} "); + server.await.expect("server"); + } + + #[test] + fn token_debug_is_redacted_even_when_target_identity_is_debugged() { + let token = AgentAccessToken::new("very-secret-token".to_owned()).expect("token"); + assert!(!format!("{token:?}").contains("very-secret-token")); + let key = AppConfigTargetKey { + url: "http://127.0.0.1".to_owned(), + auth: AuthorizationIdentity::Bearer { + env_name: Some("EDGE_TOKEN".to_owned()), + token: token.expose().to_owned(), + }, + }; + assert!(!format!("{key:?}").contains("very-secret-token")); + } + + #[test] + fn bounded_snapshot_deadline_wins_over_size_error_at_final_check() { + let start = MonotonicInstant::now(); + let now = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&now); + let clock = MonotonicClock::new(move || { + let mut instant = clock_now.lock().expect("clock mutex"); + let sampled = *instant; + *instant = instant + .checked_add(Duration::from_millis(1)) + .expect("test instant advances"); + sampled + }); + let deadline = Deadline::at_instant( + start + .checked_add(Duration::from_millis(2)) + .expect("deadline"), + ); + let snapshot = ConfigSnapshot { + entries: BTreeMap::from([("large".to_owned(), ConfigValue::from("payload"))]), + }; + let result = block_on(snapshot.get_bounded("large", &clock, deadline, 1, 1)); + assert!(matches!(result, Err(ConfigStoreError::DeadlineExceeded))); + } + + config_store_contract_tests!(appconfig_snapshot_contract, { + ConfigSnapshot { + entries: BTreeMap::from([ + ("contract.key.a".to_owned(), ConfigValue::from("value_a")), + ("contract.key.b".to_owned(), ConfigValue::from("value_b")), + ]), + } + }); +} diff --git a/crates/edgezero-store-aws/src/secrets_manager.rs b/crates/edgezero-store-aws/src/secrets_manager.rs new file mode 100644 index 00000000..b215bc10 --- /dev/null +++ b/crates/edgezero-store-aws/src/secrets_manager.rs @@ -0,0 +1,809 @@ +//! Pinned AWS SDK transport and immutable Secrets Manager snapshots. +//! +//! This module is intentionally private to the native provider crate. Public +//! callers receive only a core `SecretHandle`; neither the SDK client nor the +//! remote resource map survives preparation. + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +use aws_config::BehaviorVersion; +use aws_sdk_secretsmanager::Client; +use aws_sdk_secretsmanager::config::timeout::TimeoutConfig; +use aws_sdk_secretsmanager::config::{Region, retry::RetryConfig}; +use aws_sdk_secretsmanager::error::SdkError; +#[cfg(test)] +use aws_sdk_secretsmanager::primitives::Blob; +use bytes::Bytes; +use edgezero_core::secret_store::SecretHandle; +use tokio::time::timeout; + +use crate::PreparationError; +use crate::preparation::{ + AwsPreparation, SecretClient, SecretClientKey, SecretSnapshot, SecretTargetKey, +}; +use crate::settings::{SecretTarget, SecretVersion, SecretsManagerSettings}; + +const MAX_NAMESPACE_BYTES: usize = 512; +const DEFAULT_CLIENT_POLICY: &str = "fixed-default"; + +#[expect( + clippy::multiple_inherent_impl, + reason = "keep the feature-gated Secrets Manager transport in its provider-specific module" +)] +impl AwsPreparation { + /// Load every explicitly mapped secret and return an immutable namespace-bound snapshot. + /// + /// The aggregate budget begins in `AwsPreparation::new`, before credential or SDK + /// initialization. This method adds no outer retry loop; the pinned SDK owns at most + /// three total attempts for each `GetSecretValue` operation. + /// + /// # Errors + /// + /// Returns a sanitized [`PreparationError`] if a binding is invalid, the + /// aggregate deadline expires, the provider fails, or a configured size limit is exceeded. + #[inline] + #[expect( + clippy::too_many_lines, + reason = "the validate-fetch-check-charge-publish sequence is easier to audit as one operation" + )] + pub async fn prepare_secrets( + &mut self, + settings: &SecretsManagerSettings, + namespace: &str, + ) -> Result { + // Validate all portable handle names before constructing a client or doing IO. + if !valid_secret_name(namespace) || namespace.len() > MAX_NAMESPACE_BYTES { + return Err(PreparationError::InvalidBinding); + } + settings.validate()?; + if settings + .entries + .keys() + .any(|key| !valid_secret_name(key) || key.len() > MAX_NAMESPACE_BYTES) + { + return Err(PreparationError::InvalidBinding); + } + + self.check_deadline()?; + for (key, target) in &settings.entries { + self.charge_mapping(key.len(), namespace.len(), target_metadata_bytes(target)?)?; + } + let region = settings.region.clone(); + let client_key = SecretClientKey { + region: region.clone(), + policy: DEFAULT_CLIENT_POLICY.to_owned(), + }; + if !self.secret_clients.contains_key(&client_key) { + let remaining = self.operation_budget()?; + let connect = remaining.min(Duration::from_millis(self.limits.connect_timeout_ms)); + let attempt = remaining.min(Duration::from_millis(self.limits.attempt_timeout_ms)); + let client = timeout(remaining, async { + let shared = aws_config::defaults(BehaviorVersion::v2026_01_12()) + .region(Region::new(region.clone())) + .load() + .await; + self.check_deadline()?; + let config = Client::from_conf( + aws_sdk_secretsmanager::Config::from(&shared) + .to_builder() + .region(Region::new(region.clone())) + .retry_config(RetryConfig::standard().with_max_attempts(3)) + .timeout_config( + TimeoutConfig::builder() + .connect_timeout(connect) + .operation_timeout(remaining) + .operation_attempt_timeout(attempt) + .build(), + ) + .build(), + ); + self.check_deadline()?; + Ok::(config) + }) + .await + .map_err(|_elapsed| PreparationError::Deadline)??; + self.check_deadline()?; + self.secret_clients + .insert(client_key.clone(), SecretClient { client }); + } + + let client = self + .secret_clients + .get(&client_key) + .map(|entry| entry.client.clone()) + .ok_or(PreparationError::Unavailable)?; + let mut entries = BTreeMap::new(); + for (key, target) in &settings.entries { + self.check_deadline()?; + // Charge every logical mapping before target-cache lookup: aliases do not + // bypass the session's configured mapping bound. + let target_key = SecretTargetKey { + region: region.clone(), + client_policy: DEFAULT_CLIENT_POLICY.to_owned(), + secret_id: target.secret_id().to_owned(), + selector: target_selector_identity(target), + }; + let value = if let Some(value) = self.secret_cache.get(&target_key) { + value.clone() + } else { + let operation_budget = self.operation_budget()?; + let attempt_budget = + operation_budget.min(Duration::from_millis(self.limits.attempt_timeout_ms)); + let connect_budget = + operation_budget.min(Duration::from_millis(self.limits.connect_timeout_ms)); + let mut request = client.get_secret_value().secret_id(target.secret_id()); + match target.version() { + SecretVersion::Current => { + // Omission has the service-defined AWSCURRENT meaning. + } + SecretVersion::Stage(stage) => request = request.version_stage(stage), + SecretVersion::VersionId(version) => request = request.version_id(version), + } + let per_operation = TimeoutConfig::builder() + .connect_timeout(connect_budget) + .operation_timeout(operation_budget) + .operation_attempt_timeout(attempt_budget) + .build(); + let result = timeout( + operation_budget, + request + .customize() + .config_override( + aws_sdk_secretsmanager::Config::builder().timeout_config(per_operation), + ) + .send(), + ) + .await + .map_err(|_elapsed| PreparationError::Deadline)?; + self.check_deadline()?; + let output = result.map_err(|error| { + if matches!(&error, SdkError::TimeoutError(_)) { + return PreparationError::Deadline; + } + let service = error.as_service_error().map(|service| { + ( + service.is_decryption_failure(), + service.is_resource_not_found_exception(), + service.is_invalid_request_exception(), + service.is_invalid_parameter_exception(), + service.meta().code().unwrap_or_default().to_owned(), + ) + }); + let status = error + .raw_response() + .map(|response| response.status().as_u16()); + classify_sdk_error(service, status) + })?; + let cap = usize::try_from(self.limits.max_secret_value_bytes) + .map_err(|_error| PreparationError::InvalidLimits)?; + let size = match (output.secret_string(), output.secret_binary()) { + (Some(value), None) => value.len(), + (None, Some(value)) => value.as_ref().len(), + (None, None) | (Some(_), Some(_)) => return Err(PreparationError::Malformed), + }; + self.check_deadline()?; + if size > cap { + return Err(PreparationError::SizeLimit); + } + // SDK response parsing/materialization happens before this check. This + // is a retained-value cap, not an SDK RSS or transfer-allocation bound. + self.check_deadline()?; + self.charge_retained(size)?; + let value = match (output.secret_string, output.secret_binary) { + (Some(value), None) => Bytes::from(value.into_bytes()), + (None, Some(value)) => Bytes::from(value.into_inner()), + (None, None) | (Some(_), Some(_)) => return Err(PreparationError::Malformed), + }; + self.check_deadline()?; + self.secret_cache.insert(target_key, value.clone()); + value + }; + entries.insert(key.clone(), value); + } + + self.check_deadline()?; + Ok(SecretHandle::new(Arc::new(SecretSnapshot { + namespace: namespace.to_owned(), + entries, + }))) + } +} + +fn valid_secret_name(value: &str) -> bool { + !value.is_empty() && value.len() <= MAX_NAMESPACE_BYTES && !value.chars().any(char::is_control) +} + +fn target_selector_identity(target: &SecretTarget) -> String { + match target.version() { + SecretVersion::Current => "stage:AWSCURRENT".to_owned(), + SecretVersion::Stage(value) => format!("stage:{value}"), + SecretVersion::VersionId(value) => format!("version:{value}"), + } +} + +fn target_metadata_bytes(target: &SecretTarget) -> Result { + let selector_bytes = match target.version() { + SecretVersion::Current => "AWSCURRENT".len(), + SecretVersion::Stage(value) | SecretVersion::VersionId(value) => value.len(), + }; + target + .secret_id() + .len() + .checked_add(selector_bytes) + .ok_or(PreparationError::SizeLimit) +} + +#[cfg(test)] +fn materialize_payload( + secret_string: Option<&str>, + secret_binary: Option<&Blob>, +) -> Result { + match (secret_string, secret_binary) { + (Some(value), None) => Ok(Bytes::copy_from_slice(value.as_bytes())), + (None, Some(value)) => Ok(Bytes::copy_from_slice(value.as_ref())), + // Empty strings/blobs are Some(empty) and remain valid. Neither and both + // fields are malformed service responses; never choose a precedence. + (None, None) | (Some(_), Some(_)) => Err(PreparationError::Malformed), + } +} + +fn classify_sdk_error( + service: Option<(bool, bool, bool, bool, String)>, + status: Option, +) -> PreparationError { + if let Some((decryption, not_found, invalid_request, invalid_parameter, code)) = service { + if decryption { + return PreparationError::Decryption; + } + if not_found || invalid_request { + return PreparationError::Missing; + } + if invalid_parameter { + return PreparationError::InvalidResource; + } + match code.as_str() { + "AccessDeniedException" | "AccessDenied" => { + return PreparationError::AccessDenied; + } + "ExpiredTokenException" | "InvalidClientTokenId" | "UnrecognizedClientException" => { + return PreparationError::Credentials; + } + "ThrottlingException" | "Throttling" | "TooManyRequestsException" => { + return PreparationError::Throttled; + } + _ => {} + } + } + if status == Some(403) { + return PreparationError::AccessDenied; + } + if status == Some(429) { + return PreparationError::Throttled; + } + // SDK error/source chains are deliberately consumed and discarded here. + // Credential-process failures are not consistently exposed as a stable + // typed operation error by this SDK version; without an unredacted chain + // they conservatively collapse to the closed unavailable category. + PreparationError::Unavailable +} + +#[cfg(test)] +#[expect( + clippy::absolute_paths, + reason = "explicit std and crate paths keep the isolated child-process fixture auditable" +)] +mod tests { + use super::materialize_payload; + use crate::PreparationError; + use aws_sdk_secretsmanager::operation::get_secret_value::GetSecretValueOutput; + use aws_sdk_secretsmanager::primitives::Blob; + use bytes::Bytes; + use std::io::{Read as _, Write as _}; + use std::net::{TcpListener, TcpStream}; + use std::process::{Command, Stdio}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::thread; + use std::time::{Duration, Instant}; + + const TEST_ARN: &str = "arn:aws:secretsmanager:us-east-1:111122223333:secret:fixture-AbCdEf"; + static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); + + #[derive(Clone)] + enum FixtureResponse { + Decryption, + Delay(Duration), + Denied, + InternalError, + Json(String), + Missing, + Oversize, + Success, + } + + fn assert_sdk_fixture_scope() { + let endpoint = + std::env::var("AWS_ENDPOINT_URL_SECRETS_MANAGER").expect("fixture endpoint required"); + let endpoint_url = url::Url::parse(&endpoint).expect("fixture URL"); + assert!( + endpoint_url.scheme() == "http" + && endpoint_url.host_str() == Some("127.0.0.1") + && endpoint_url.port().is_some(), + "SDK child accepts only the literal-loopback fixture" + ); + assert!( + std::env::var("AWS_ACCESS_KEY_ID").is_ok_and(|value| value.starts_with("DUMMY")), + "SDK child requires dummy credentials" + ); + } + + fn respond(mut stream: TcpStream, response: FixtureResponse) -> (String, String) { + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("request read timeout"); + let mut request = Vec::new(); + let mut chunk = [0_u8; 4096]; + let header_end = loop { + let read = stream.read(&mut chunk).expect("read request headers"); + assert_ne!(read, 0, "SDK closed before request headers"); + request.extend_from_slice(&chunk[..read]); + if let Some(end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") { + break end + .checked_add(4) + .expect("header terminator offset fits request"); + } + assert!(request.len() < 64 * 1024, "bounded fixture request headers"); + }; + let headers = String::from_utf8_lossy(&request[..header_end]).to_ascii_lowercase(); + let content_length = headers + .lines() + .find_map(|line| line.strip_prefix("content-length:").map(str::trim)) + .and_then(|value| value.parse::().ok()) + .expect("SDK content length"); + let body_end = header_end + .checked_add(content_length) + .expect("bounded request body end fits usize"); + while request.len() < body_end { + let read = stream.read(&mut chunk).expect("read request body"); + assert_ne!(read, 0, "SDK closed before request body"); + request.extend_from_slice(&chunk[..read]); + } + let body = String::from_utf8_lossy(&request[header_end..body_end]).into_owned(); + assert!(headers.contains("x-amz-target: secretsmanager.getsecretvalue")); + assert!( + body.contains(TEST_ARN), + "request must contain only the fictional fixture ARN" + ); + if let FixtureResponse::Delay(delay) = response { + thread::sleep(delay); + let payload = b"{\"SecretString\":\"VALUE_SENTINEL\"}"; + let _header_write = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/x-amz-json-1.1\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + payload.len() + ); + let _body_write = stream.write_all(payload); + return (headers, body); + } + let (status, code, payload) = match response { + FixtureResponse::Decryption => (400_u16, "Bad Request", r#"{"__type":"DecryptionFailure","message":"VALUE_SENTINEL"}"#.to_owned()), + FixtureResponse::Denied => (400_u16, "Bad Request", r#"{"__type":"AccessDeniedException","message":"ARN_SENTINEL CREDENTIAL_SENTINEL"}"#.to_owned()), + FixtureResponse::Delay(_) => return (headers, body), + FixtureResponse::InternalError => (500_u16, "Internal Server Error", r#"{"__type":"InternalServiceError","message":"SDK_ERROR_SENTINEL"}"#.to_owned()), + FixtureResponse::Json(payload) => (200_u16, "OK", payload), + FixtureResponse::Missing => (400_u16, "Bad Request", r#"{"__type":"ResourceNotFoundException","message":"ARN_SENTINEL"}"#.to_owned()), + FixtureResponse::Oversize => (200_u16, "OK", format!(r#"{{"ARN":"{TEST_ARN}","Name":"fixture","VersionId":"12345678901234567890123456789012","SecretString":"{}"}}"#, "x".repeat(0x0001_0001))), + FixtureResponse::Success => (200_u16, "OK", format!(r#"{{"ARN":"{TEST_ARN}","Name":"fixture","VersionId":"12345678901234567890123456789012","SecretString":"VALUE_SENTINEL"}}"#)), + }; + let response_body = payload.as_bytes(); + write!(stream, "HTTP/1.1 {status} {code}\r\nContent-Type: application/x-amz-json-1.1\r\nx-amzn-RequestId: fixture-request-id\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", response_body.len()).expect("write response headers"); + stream + .write_all(response_body) + .expect("write response body"); + (headers, body) + } + + fn loopback_fixture( + responses: Vec, + ) -> (String, thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind literal loopback only"); + listener + .set_nonblocking(true) + .expect("nonblocking listener"); + let address = listener.local_addr().expect("loopback address"); + let endpoint = format!("http://{address}"); + let worker = thread::spawn(move || { + let deadline = Instant::now() + .checked_add(Duration::from_secs(12)) + .expect("fixture deadline fits the monotonic clock"); + let mut bodies = Vec::new(); + for response in responses { + let stream = loop { + match listener.accept() { + Ok((stream, peer)) => { + assert!(peer.ip().is_loopback(), "fixture peer must be loopback"); + break stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "SDK did not contact fixture"); + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("fixture accept failed: {error}"), + } + }; + stream + .set_write_timeout(Some(Duration::from_secs(3))) + .expect("response write timeout"); + let (_, body) = respond(stream, response); + bodies.push(body); + } + bodies + }); + (endpoint, worker) + } + + fn isolated_sdk_child(endpoint: &str, scenario: &str) -> std::process::Output { + let directory = std::env::temp_dir().join(format!( + "edgezero-secrets-fixture-{}-{}", + std::process::id(), + NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed), + )); + std::fs::create_dir_all(&directory).expect("create isolated profile directory"); + let config = directory.join("config"); + let credentials = directory.join("credentials"); + std::fs::write(&config, "[profile fixture]\nregion = us-east-1\n") + .expect("write isolated config"); + std::fs::write( + &credentials, + "[fixture]\naws_access_key_id = DUMMY_ACCESS_KEY\naws_secret_access_key = DUMMY_SECRET_KEY\n", + ) + .expect("write dummy credentials"); + let output = Command::new(std::env::current_exe().expect("test binary")) + .args([ + "--exact", + "secrets_manager::tests::sdk_child_entry", + "--nocapture", + ]) + .env_clear() + .env("HOME", &directory) + .env("AWS_CONFIG_FILE", config) + .env("AWS_SHARED_CREDENTIALS_FILE", credentials) + .env("AWS_PROFILE", "fixture") + .env("AWS_ACCESS_KEY_ID", "DUMMY_ACCESS_KEY") + .env("AWS_SECRET_ACCESS_KEY", "DUMMY_SECRET_KEY") + .env("AWS_REGION", "us-east-1") + .env("AWS_EC2_METADATA_DISABLED", "true") + .env("AWS_ENDPOINT_URL_SECRETS_MANAGER", endpoint) + .env("AWS_RETRY_MODE", "standard") + .env("AWS_MAX_ATTEMPTS", "3") + .env("AWS_IGNORE_CONFIGURED_ENDPOINT_URLS", "false") + .env("EDGEZERO_SECRETS_SCENARIO", scenario) + .stdin(Stdio::null()) + .output() + .expect("run isolated SDK child"); + std::fs::remove_dir_all(directory).expect("remove isolated profile directory"); + output + } + + #[test] + fn sdk_blob_bytes_are_preserved_without_a_second_base64_decode() { + let binary = Blob::new(b"YWJjZA==".to_vec()); + assert_eq!( + materialize_payload(None, Some(&binary)), + Ok(Bytes::from_static(b"YWJjZA==")) + ); + assert_eq!(materialize_payload(Some(""), None), Ok(Bytes::new())); + assert_eq!( + materialize_payload(None, Some(&Blob::new(Vec::new()))), + Ok(Bytes::new()) + ); + assert_eq!( + materialize_payload(None, None), + Err(PreparationError::Malformed) + ); + assert_eq!( + materialize_payload(Some("x"), Some(&binary)), + Err(PreparationError::Malformed) + ); + } + + #[test] + fn real_sdk_uses_child_only_service_endpoint_and_capped_retries() { + let (endpoint, fixture) = loopback_fixture(vec![ + FixtureResponse::InternalError, + FixtureResponse::InternalError, + FixtureResponse::Success, + ]); + let child = isolated_sdk_child(&endpoint, "retry"); + let requests = fixture.join().expect("fixture thread"); + assert!( + child.status.success(), + "isolated SDK child failed: {}", + String::from_utf8_lossy(&child.stderr) + ); + assert_eq!( + requests.len(), + 3, + "standard SDK performs at most three total attempts" + ); + assert!(requests.iter().all(|body| body.contains(TEST_ARN))); + } + + #[test] + fn real_sdk_serializes_exact_current_stage_and_version_selectors() { + for (scenario, selector) in [ + ("current", ""), + ("stage", r#""VersionStage":"BLUE""#), + ( + "version", + r#""VersionId":"12345678901234567890123456789012""#, + ), + ] { + let (endpoint, fixture) = loopback_fixture(vec![FixtureResponse::Success]); + let child = isolated_sdk_child(&endpoint, scenario); + let requests = fixture.join().expect("fixture thread"); + assert!( + child.status.success(), + "isolated SDK child failed for {scenario}: {}", + String::from_utf8_lossy(&child.stderr) + ); + assert_eq!(requests.len(), 1); + assert!(requests[0].contains(TEST_ARN)); + if !selector.is_empty() { + assert!( + requests[0].contains(selector), + "selector serialized exactly" + ); + } + if scenario == "current" { + assert!( + !requests[0].contains("VersionStage") && !requests[0].contains("VersionId") + ); + } + } + } + + #[test] + fn real_sdk_service_failures_are_closed_and_non_retryable() { + for (response, scenario, category) in [ + (FixtureResponse::Denied, "denied", "access denied"), + ( + FixtureResponse::Decryption, + "decryption", + "decryption failure", + ), + ( + FixtureResponse::Missing, + "missing", + "missing resource or version", + ), + ] { + let (endpoint, fixture) = loopback_fixture(vec![response]); + let child = isolated_sdk_child(&endpoint, scenario); + let requests = fixture.join().expect("fixture thread"); + assert!( + child.status.success(), + "isolated child rejected expected category: {}", + String::from_utf8_lossy(&child.stderr) + ); + assert_eq!( + requests.len(), + 1, + "non-retryable service failures use one SDK attempt" + ); + let output = format!( + "{}{}", + String::from_utf8_lossy(&child.stdout), + String::from_utf8_lossy(&child.stderr) + ); + assert!( + output.contains(category), + "child reports only its closed category" + ); + for sentinel in [ + "ARN_SENTINEL", + "VALUE_SENTINEL", + "CREDENTIAL_SENTINEL", + "SDK_ERROR_SENTINEL", + ] { + assert!( + !output.contains(sentinel), + "public child output excludes source details" + ); + } + } + } + + #[test] + fn real_sdk_binary_empty_and_exactly_one_payload_fields() { + for (scenario, payload) in [ + ( + "binary", + serde_json::json!({"SecretBinary": "WW1sdVlYSjU="}), + ), + ("empty", serde_json::json!({"SecretString": ""})), + ( + "both", + serde_json::json!({"SecretString": "opaque", "SecretBinary": ""}), + ), + ("neither", serde_json::json!({})), + ( + "dedup", + serde_json::json!({"SecretString": "VALUE_SENTINEL"}), + ), + ] { + let (endpoint, fixture) = + loopback_fixture(vec![FixtureResponse::Json(payload.to_string())]); + let child = isolated_sdk_child(&endpoint, scenario); + let requests = fixture.join().expect("fixture thread"); + assert!( + child.status.success(), + "payload child failed: {}", + String::from_utf8_lossy(&child.stderr) + ); + assert_eq!( + requests.len(), + 1, + "exactly one service request despite aliases/repeated snapshot reads" + ); + } + } + + #[test] + fn real_sdk_payload_cap_rejects_materialized_oversize_value() { + let (endpoint, fixture) = loopback_fixture(vec![FixtureResponse::Oversize]); + let child = isolated_sdk_child(&endpoint, "oversize"); + let requests = fixture.join().expect("fixture thread"); + assert!( + child.status.success(), + "oversize child failed: {}", + String::from_utf8_lossy(&child.stderr) + ); + assert_eq!(requests.len(), 1); + let output = format!( + "{}{}", + String::from_utf8_lossy(&child.stdout), + String::from_utf8_lossy(&child.stderr) + ); + assert!(output.contains("size limit")); + assert!( + !output.contains(&"x".repeat(128)), + "payload is not included in public output" + ); + } + + #[test] + fn real_sdk_attempt_timeout_is_observed_without_an_outer_retry_loop() { + let (endpoint, fixture) = + loopback_fixture(vec![FixtureResponse::Delay(Duration::from_millis(350))]); + let child = isolated_sdk_child(&endpoint, "timeout"); + let requests = fixture.join().expect("fixture thread"); + assert!( + child.status.success(), + "timeout child failed: {}", + String::from_utf8_lossy(&child.stderr) + ); + assert_eq!( + requests.len(), + 1, + "SDK timeout behavior is inside the one provider operation" + ); + } + + /// Invoked only by an env-cleared child process launched by the parent fixture. + #[expect( + clippy::err_expect, + reason = "the opaque SecretHandle success value intentionally has no Debug API" + )] + #[expect( + clippy::print_stdout, + reason = "the child reports its closed error category to the parent fixture" + )] + #[tokio::test] + async fn sdk_child_entry() { + let Ok(scenario) = std::env::var("EDGEZERO_SECRETS_SCENARIO") else { + return; + }; + assert_sdk_fixture_scope(); + let version = match scenario.as_str() { + "stage" => crate::settings::SecretVersion::Stage("BLUE".to_owned()), + "version" => crate::settings::SecretVersion::VersionId( + "12345678901234567890123456789012".to_owned(), + ), + _ => crate::settings::SecretVersion::Current, + }; + let target = crate::settings::SecretTarget::new(TEST_ARN.to_owned(), version) + .expect("valid fictional ARN"); + let settings = crate::settings::SecretsManagerSettings { + region: "us-east-1".to_owned(), + entries: std::collections::BTreeMap::from([("active".to_owned(), target)]), + }; + let limits = if scenario == "timeout" { + crate::settings::AwsPreparationLimits { + connect_timeout_ms: 50, + attempt_timeout_ms: 100, + operation_timeout_ms: 500, + preparation_timeout_ms: 2_000, + ..crate::settings::AwsPreparationLimits::default() + } + } else { + crate::settings::AwsPreparationLimits::default() + }; + let mut session = crate::preparation::AwsPreparation::new( + edgezero_core::time::MonotonicClock::default(), + limits, + ) + .expect("valid session"); + let result = session.prepare_secrets(&settings, "runtime").await; + match scenario.as_str() { + "retry" | "current" | "stage" | "version" | "dedup" => { + let handle = result.expect("fixture-backed mapped secret prepares"); + let bytes = handle + .get_bytes("runtime", "active") + .await + .expect("snapshot read") + .expect("mapped value"); + assert_eq!(bytes.as_ref(), b"VALUE_SENTINEL"); + if scenario == "dedup" { + let cached = session + .prepare_secrets(&settings, "second") + .await + .expect("success cache shared across bound namespaces"); + assert_eq!( + cached + .get_bytes("second", "active") + .await + .expect("read") + .expect("mapped") + .as_ref(), + b"VALUE_SENTINEL" + ); + assert_eq!(session.stats().mappings, 2_u64); + assert_eq!(session.stats().unique_retained_payload_bytes, 14_u64); + } + } + "binary" | "empty" => { + let handle = result.expect("payload snapshot"); + let bytes = handle + .get_bytes("runtime", "active") + .await + .expect("snapshot") + .expect("mapped empty value is not a miss"); + let expected: &[u8] = if scenario == "binary" { + b"YmluYXJ5" + } else { + b"" + }; + assert_eq!(bytes.as_ref(), expected); + } + "both" | "neither" => { + assert_eq!( + result.err().expect("exactly one payload field required"), + crate::PreparationError::Malformed + ); + } + "denied" | "decryption" | "missing" | "timeout" | "oversize" => { + let expected = match scenario.as_str() { + "denied" => crate::PreparationError::AccessDenied, + "decryption" => crate::PreparationError::Decryption, + "missing" => crate::PreparationError::Missing, + "timeout" => crate::PreparationError::Deadline, + "oversize" => crate::PreparationError::SizeLimit, + _ => panic!("unknown failure scenario"), + }; + let error = result.err().expect("failed preparation must not publish"); + assert_eq!(error, expected); + println!("closed-category: {error}"); + } + _ => panic!("unknown child scenario"), + } + } + + #[test] + fn response_builder_exposes_the_pinned_sdk_fields_used_by_transport() { + let output = GetSecretValueOutput::builder().secret_string("").build(); + assert_eq!(output.secret_string(), Some("")); + assert!(output.secret_binary().is_none()); + } +} diff --git a/crates/edgezero-store-aws/src/settings.rs b/crates/edgezero-store-aws/src/settings.rs new file mode 100644 index 00000000..16290222 --- /dev/null +++ b/crates/edgezero-store-aws/src/settings.rs @@ -0,0 +1,998 @@ +//! SDK-free typed settings and validation. Resource values never enter diagnostics. + +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "settings, validators and their tests are grouped by provider" +)] + +use std::collections::BTreeMap; +use std::fmt; +use std::net::IpAddr; +use std::time::{Duration, Instant}; + +use percent_encoding::percent_decode_str; +use serde::de::Error as _; +use serde::{Deserialize, Deserializer}; +use url::Url; + +use crate::PreparationError; + +// Portable SecretHandle limits names to 512 UTF-8 bytes. Keep SDK-free settings +// independent of core; service implementations must use the same core bound. +const MAX_SECRET_KEY_BYTES: usize = 512; + +/// One configuration key maps to one complete free-form Agent document. +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AgentDocument { + pub application: String, + pub environment: String, + pub profile: String, +} + +impl fmt::Debug for AgentDocument { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AgentDocument").finish_non_exhaustive() + } +} + +/// `AppConfig` Agent settings. The token field names a bootstrap variable, not a value. +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AppConfigAgentSettings { + pub default_key: String, + pub endpoint: String, + pub access_token_env: Option, + pub documents: BTreeMap, +} + +impl fmt::Debug for AppConfigAgentSettings { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AppConfigAgentSettings") + .finish_non_exhaustive() + } +} + +impl AppConfigAgentSettings { + /// Builds an exact Agent request URL using checked path segments. + /// + /// # Errors + /// Rejects ambiguous/non-loopback endpoints and unsafe document identifiers. + #[inline] + pub fn document_url(&self, document: &AgentDocument) -> Result { + let mut url = agent_endpoint(&self.endpoint)?; + let expected = [ + "applications", + document.application.as_str(), + "environments", + document.environment.as_str(), + "configurations", + document.profile.as_str(), + ]; + for segment in [ + &document.application, + &document.environment, + &document.profile, + ] { + if !valid_agent_segment(segment) { + return Err(PreparationError::InvalidResource); + } + } + url.path_segments_mut() + .map_err(|_error| PreparationError::InvalidEndpoint)? + .clear() + .extend(expected); + let reparsed = + Url::parse(url.as_str()).map_err(|_error| PreparationError::InvalidEndpoint)?; + if !reparsed.path_segments().is_some_and(|segments| { + segments + .map(|segment| percent_decode_str(segment).decode_utf8()) + .eq(expected.into_iter().map(|segment| Ok(segment.into()))) + }) { + return Err(PreparationError::InvalidResource); + } + Ok(reparsed) + } + + /// Validates every mapping without HTTP or bootstrap environment access. + /// + /// # Errors + /// Returns a closed category for invalid endpoint, mapping, default or token name. + #[inline] + pub fn validate(&self) -> Result<(), PreparationError> { + agent_endpoint(&self.endpoint)?; + if self.documents.is_empty() + || !self.documents.contains_key(&self.default_key) + || self.documents.keys().any(|key| !valid_lookup_key(key)) + || self + .access_token_env + .as_deref() + .is_some_and(|name| !valid_variable_name(name)) + { + return Err(PreparationError::InvalidBinding); + } + for document in self.documents.values() { + self.document_url(document)?; + } + Ok(()) + } +} + +/// A bootstrap token. Debug hides both the token and its length. +#[derive(Clone)] +pub struct AgentAccessToken(String); + +impl AgentAccessToken { + /// Captures an explicit nonempty bearer token without reading the environment. + /// + /// # Errors + /// Rejects missing/empty tokens and bytes that cannot be sent as a bearer value. + #[inline] + pub fn new(value: String) -> Result { + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_graphic()) { + return Err(PreparationError::InvalidBinding); + } + Ok(Self(value)) + } + + /// Exposes the token only to the provider transport. + #[inline] + #[must_use] + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for AgentAccessToken { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AgentAccessToken([redacted])") + } +} + +/// Independent policy for live `DynamoDB` KV operations, not snapshot retention. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct DynamoDbKvLimits { + pub max_value_bytes: usize, + pub operation_timeout_ms: u64, + pub connect_timeout_ms: u64, + pub attempt_timeout_ms: u64, +} + +impl Default for DynamoDbKvLimits { + #[inline] + fn default() -> Self { + Self { + max_value_bytes: 393_216, + operation_timeout_ms: 5_000, + connect_timeout_ms: 1_000, + attempt_timeout_ms: 2_000, + } + } +} + +impl DynamoDbKvLimits { + /// Checks payload bounds and representable, ordered operation budgets. + /// + /// # Errors + /// Returns a closed category for invalid policy. + #[inline] + pub fn validate(&self) -> Result<(), PreparationError> { + if self.max_value_bytes == 0 + || self.max_value_bytes > 393_216 + || self.connect_timeout_ms == 0 + || self.connect_timeout_ms > self.attempt_timeout_ms + || self.attempt_timeout_ms > self.operation_timeout_ms + || Instant::now() + .checked_add(Duration::from_millis(self.operation_timeout_ms)) + .is_none() + { + return Err(PreparationError::InvalidLimits); + } + Ok(()) + } +} + +/// Explicit physical `DynamoDB` address. Debug never exposes resource names. +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DynamoDbKvSettings { + pub region: String, + pub table: String, + pub namespace: String, + #[serde(default)] + pub limits: DynamoDbKvLimits, +} + +impl fmt::Debug for DynamoDbKvSettings { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DynamoDbKvSettings").finish_non_exhaustive() + } +} + +impl DynamoDbKvSettings { + /// Validates an address without credential discovery or service I/O. + /// + /// # Errors + /// Rejects malformed region/table/namespace and invalid limits. + #[inline] + pub fn validate(&self) -> Result<(), PreparationError> { + self.limits.validate()?; + let partition = if self.region.starts_with("cn-") { + "aws-cn" + } else if self.region.starts_with("us-gov-") { + "aws-us-gov" + } else { + "aws" + }; + if self.region.len() > 64 + || !valid_region(partition, &self.region) + || !(3..=255).contains(&self.table.len()) + || !self + .table + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) + || self.namespace.is_empty() + || self.namespace.len() > 512 + || self.namespace.chars().any(char::is_control) + { + return Err(PreparationError::InvalidResource); + } + Ok(()) + } +} + +/// Exactly one version selector. Omission means AWSCURRENT at startup. +/// API lengths count characters; non-control Unicode and embedded spaces retain +/// their exact meaning. Validation does not assert that a version/stage exists. +#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] +pub enum SecretVersion { + Current, + Stage(String), + VersionId(String), +} + +impl SecretVersion { + fn validate(&self) -> Result<(), PreparationError> { + let (value, minimum, maximum) = match self { + Self::Current => return Ok(()), + Self::Stage(value) => (value, 1, 256), + Self::VersionId(value) => (value, 32, 64), + }; + let length = value.chars().count(); + if !(minimum..=maximum).contains(&length) + || value.chars().all(char::is_whitespace) + || value.chars().any(char::is_control) + { + return Err(PreparationError::InvalidSelector); + } + Ok(()) + } +} + +impl fmt::Debug for SecretVersion { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretVersion([redacted])") + } +} + +/// A complete ARN and a validated selector. Construction never discovers credentials. +#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] +pub struct SecretTarget { + secret_id: String, + version: SecretVersion, +} + +impl SecretTarget { + /// Validates a complete ARN and mutually exclusive typed selector. + /// + /// # Errors + /// Rejects incomplete/unsupported resources and invalid selector lengths. + #[inline] + pub fn new(secret_id: String, version: SecretVersion) -> Result { + secret_region(&secret_id)?; + version.validate()?; + let selected = match version { + SecretVersion::Stage(stage) if stage == "AWSCURRENT" => SecretVersion::Current, + other @ (SecretVersion::Current + | SecretVersion::Stage(_) + | SecretVersion::VersionId(_)) => other, + }; + Ok(Self { + secret_id, + version: selected, + }) + } + + /// Complete resource identifier for the service request, never diagnostics. + #[inline] + #[must_use] + pub fn secret_id(&self) -> &str { + &self.secret_id + } + + /// Exact version selection for the service request. + #[inline] + #[must_use] + pub fn version(&self) -> &SecretVersion { + &self.version + } +} + +impl fmt::Debug for SecretTarget { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SecretTarget").finish_non_exhaustive() + } +} + +impl<'de> Deserialize<'de> for SecretTarget { + #[inline] + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Input { + secret_id: String, + version_id: Option, + version_stage: Option, + } + let input = Input::deserialize(deserializer)?; + let version = match (input.version_id, input.version_stage) { + (None, None) => SecretVersion::Current, + (None, Some(stage)) => SecretVersion::Stage(stage), + (Some(id), None) => SecretVersion::VersionId(id), + (Some(_), Some(_)) => { + return Err(D::Error::custom(PreparationError::InvalidSelector)); + } + }; + Self::new(input.secret_id, version).map_err(D::Error::custom) + } + + #[inline] + fn deserialize_in_place>( + deserializer: D, + place: &mut Self, + ) -> Result<(), D::Error> { + *place = Self::deserialize(deserializer)?; + Ok(()) + } +} + +/// Explicit region and finite secret key mappings. Namespaces are supplied by the host. +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SecretsManagerSettings { + pub region: String, + pub entries: BTreeMap, +} + +impl SecretsManagerSettings { + /// Validates explicit region, keys and every ARN's region/partition relationship. + /// + /// # Errors + /// Rejects empty mappings, invalid keys, missing regions and mismatched ARNs. + #[inline] + pub fn validate(&self) -> Result<(), PreparationError> { + // Match the portable SecretHandle byte limit without adding core to the + // settings-only graph. Provider code must also enforce core's bound. + if self.entries.is_empty() + || self + .entries + .keys() + .any(|key| !valid_lookup_key(key) || key.len() > MAX_SECRET_KEY_BYTES) + { + return Err(PreparationError::InvalidBinding); + } + for target in self.entries.values() { + if secret_region(target.secret_id())? != self.region { + return Err(PreparationError::InvalidResource); + } + } + Ok(()) + } +} + +impl fmt::Debug for SecretsManagerSettings { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SecretsManagerSettings") + .finish_non_exhaustive() + } +} + +/// Remote preparation policy for one explicitly shared session, not a process/RSS cap. +/// Omitted fields use documented defaults from this single policy source. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct AwsPreparationLimits { + pub max_remote_values: u64, + pub max_config_document_bytes: u64, + pub max_secret_value_bytes: u64, + pub max_snapshot_bytes: u64, + pub preparation_timeout_ms: u64, + pub operation_timeout_ms: u64, + pub connect_timeout_ms: u64, + pub attempt_timeout_ms: u64, +} + +impl Default for AwsPreparationLimits { + #[inline] + fn default() -> Self { + Self { + max_remote_values: 1_024, + max_config_document_bytes: 1_024 * 1_024, + max_secret_value_bytes: 64 * 1_024, + max_snapshot_bytes: 8 * 1_024 * 1_024, + preparation_timeout_ms: 30_000, + operation_timeout_ms: 5_000, + connect_timeout_ms: 1_000, + attempt_timeout_ms: 2_000, + } + } +} + +impl AwsPreparationLimits { + /// Validates finite representable bounds and consistency, without hardcoding the defaults as maxima. + /// + /// # Errors + /// Rejects zero, unusable timer/counter values and inconsistent limits. + #[inline] + pub fn validate(&self) -> Result<(), PreparationError> { + for value in [ + self.max_remote_values, + self.max_config_document_bytes, + self.max_secret_value_bytes, + self.max_snapshot_bytes, + ] { + if value == 0 || usize::try_from(value).is_err() { + return Err(PreparationError::InvalidLimits); + } + } + for millis in [ + self.preparation_timeout_ms, + self.operation_timeout_ms, + self.connect_timeout_ms, + self.attempt_timeout_ms, + ] { + if millis == 0 + || Instant::now() + .checked_add(Duration::from_millis(millis)) + .is_none() + { + return Err(PreparationError::InvalidLimits); + } + } + if self.max_config_document_bytes > self.max_snapshot_bytes + || self.max_secret_value_bytes > self.max_snapshot_bytes + || self.connect_timeout_ms > self.attempt_timeout_ms + || self.attempt_timeout_ms > self.operation_timeout_ms + || self.operation_timeout_ms > self.preparation_timeout_ms + { + return Err(PreparationError::InvalidLimits); + } + Ok(()) + } +} + +fn valid_lookup_key(key: &str) -> bool { + !key.is_empty() && !key.chars().all(char::is_whitespace) && !key.chars().any(char::is_control) +} + +fn valid_variable_name(name: &str) -> bool { + let mut bytes = name.bytes(); + bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +fn valid_agent_segment(segment: &str) -> bool { + !segment.is_empty() + && segment.len() <= 128 + && !matches!(segment, "." | "..") + && !segment.chars().any(|character| { + character.is_control() || matches!(character, '/' | '\\' | '%' | '?' | '#') + }) +} + +fn agent_endpoint(endpoint: &str) -> Result { + // Check the original authority too: URL parsing otherwise accepts integer/octal + // IPv4, backslashes and dot paths while changing their spelling/meaning. + let rest = endpoint + .strip_prefix("http://") + .ok_or(PreparationError::InvalidEndpoint)?; + let authority = rest.strip_suffix('/').unwrap_or(rest); + if authority.contains(['/', '\\', '@', '?', '#', '%']) + || endpoint + .bytes() + .any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control()) + { + return Err(PreparationError::InvalidEndpoint); + } + let host = if let Some(ipv6) = authority.strip_prefix('[') { + ipv6.split_once(']').map(|(host, _port)| host) + } else { + Some( + authority + .split_once(':') + .map_or(authority, |(host, _port)| host), + ) + } + .ok_or(PreparationError::InvalidEndpoint)?; + let literal = host + .parse::() + .map_err(|_error| PreparationError::InvalidEndpoint)?; + let url = Url::parse(endpoint).map_err(|_error| PreparationError::InvalidEndpoint)?; + if !literal.is_loopback() + || url.scheme() != "http" + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.path() != "/" + || url.port_or_known_default().is_none_or(|port| port == 0) + || url.host_str().is_none() + { + return Err(PreparationError::InvalidEndpoint); + } + Ok(url) +} + +fn secret_region(arn: &str) -> Result<&str, PreparationError> { + if arn.len() > 2_048 { + return Err(PreparationError::InvalidResource); + } + let parts: Vec<&str> = arn.split(':').collect(); + let [prefix, partition, service, region, account, resource, name] = parts.as_slice() else { + return Err(PreparationError::InvalidResource); + }; + let Some((secret_name, suffix)) = name.rsplit_once('-') else { + return Err(PreparationError::InvalidResource); + }; + if *prefix != "arn" + || *service != "secretsmanager" + || *resource != "secret" + || account.len() != 12 + || !account.bytes().all(|byte| byte.is_ascii_digit()) + || secret_name.is_empty() + || secret_name.len() > 512 + || !secret_name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b'/' | b'_' | b'+' | b'=' | b'.' | b'@' | b'-') + }) + || suffix.len() != 6 + || !suffix.bytes().all(|byte| byte.is_ascii_alphanumeric()) + || arn.len() > 2_048 + || !valid_region(partition, region) + { + return Err(PreparationError::InvalidResource); + } + Ok(region) +} + +fn valid_region(partition: &str, region: &str) -> bool { + let Some((family, number)) = region.rsplit_once('-') else { + return false; + }; + if number.is_empty() + || !number.bytes().all(|byte| byte.is_ascii_digit()) + || number.starts_with('0') + || !family + .split('-') + .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_lowercase())) + { + return false; + } + // Supported partition families, not an assertion that an arbitrary matching + // region is currently served. Isolated/sovereign partitions are not v1 support. + match partition { + "aws-cn" => family.starts_with("cn-"), + "aws-us-gov" => family.starts_with("us-gov-"), + "aws" => { + [ + "af-", "ap-", "ca-", "eu-", "il-", "me-", "mx-", "sa-", "us-", + ] + .iter() + .any(|prefix| family.starts_with(prefix)) + && !family.starts_with("us-gov-") + && !family.starts_with("us-iso") + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + #![expect( + clippy::panic_in_result_fn, + reason = "test functions combine checked setup with behavioral assertions" + )] + #![expect( + clippy::assertions_on_result_states, + reason = "malformed input matrix observes parser acceptance without unwrapping" + )] + + #[test] + fn dynamodb_settings_are_strict_bounded_and_redacted() { + let settings: super::DynamoDbKvSettings = toml::from_str( + "region='us-east-1'\ntable='edgezero-kv'\nnamespace='private-namespace'", + ) + .unwrap(); + settings.validate().unwrap(); + assert!(!format!("{settings:?}").contains("private-namespace")); + assert!(toml::from_str::( + "region='us-east-1'\ntable='edgezero-kv'\nnamespace='x'\nendpoint='http://example.com'" + ).is_err()); + let mut invalid = settings; + invalid.limits.max_value_bytes = 393_217; + assert_eq!( + invalid.validate(), + Err(super::PreparationError::InvalidLimits) + ); + invalid.limits = super::DynamoDbKvLimits::default(); + invalid.namespace = "\n".to_owned(); + assert_eq!( + invalid.validate(), + Err(super::PreparationError::InvalidResource) + ); + } + + use std::borrow::Cow; + use std::collections::BTreeMap; + + use percent_encoding::percent_decode_str; + use url::Url; + + use super::{ + AgentAccessToken, AgentDocument, AppConfigAgentSettings, AwsPreparationLimits, + SecretTarget, SecretVersion, SecretsManagerSettings, + }; + use crate::PreparationError; + + fn agent(endpoint: &str) -> AppConfigAgentSettings { + AppConfigAgentSettings { + default_key: "settings".to_owned(), + endpoint: endpoint.to_owned(), + access_token_env: None, + documents: BTreeMap::from([( + "settings".to_owned(), + AgentDocument { + application: "example-app".to_owned(), + environment: "production".to_owned(), + profile: "application-envelope".to_owned(), + }, + )]), + } + } + + #[test] + fn literal_loopback_and_exact_request_path() -> Result<(), PreparationError> { + for endpoint in [ + "http://127.0.0.1:2772", + "http://127.1.2.3:2772/", + "http://[::1]:2772", + ] { + let settings = agent(endpoint); + settings.validate()?; + let document = settings + .documents + .get("settings") + .ok_or(PreparationError::InvalidBinding)?; + assert_eq!( + settings.document_url(document)?.path(), + "/applications/example-app/environments/production/configurations/application-envelope" + ); + } + let mut settings = agent("http://127.0.0.1:2772"); + if let Some(document) = settings.documents.get_mut("settings") { + document.profile = "Profile name \u{fc}".to_owned(); + } + let document = settings + .documents + .get("settings") + .ok_or(PreparationError::InvalidBinding)?; + assert_eq!( + settings.document_url(document)?.path(), + "/applications/example-app/environments/production/configurations/Profile%20name%20%C3%BC" + ); + Ok(()) + } + + #[test] + fn endpoints_and_segments_fail_without_echoing_inputs() { + for endpoint in [ + "http://localhost:2772", + "https://127.0.0.1", + "http://192.0.2.1", + "http://user:sentinel@127.0.0.1", + "http://127.0.0.1?sentinel", + "http://127.0.0.1#sentinel", + "http://127.0.0.1/x/..", + "http://127.0.0.1/%2e", + "http://2130706433", + "http://0177.0.0.1", + "http://127.0.0.1\\sentinel", + "http://[::1%25sentinel]", + "http://127.0.0.1:0", + "http://127.0.0.1:65536", + " http://127.0.0.1", + "http://127.0.0.1//", + ] { + let settings = agent(endpoint); + assert!(settings.validate().is_err(), "accepted unsafe endpoint"); + assert!(!format!("{settings:?}").contains("sentinel")); + } + for profile in ["", ".", "..", "a/b", "a\\b", "a%2Fb", "a?b", "a#b", "a\nb"] { + let mut settings = agent("http://127.0.0.1:2772"); + if let Some(document) = settings.documents.get_mut("settings") { + document.profile = profile.to_owned(); + } + assert!(settings.validate().is_err()); + } + } + + #[test] + fn defaults_tokens_and_mapping_keys_are_strict() { + let mut settings = agent("http://127.0.0.1:2772"); + settings.default_key = "Settings".to_owned(); + assert_eq!(settings.validate(), Err(PreparationError::InvalidBinding)); + settings.default_key = "settings".to_owned(); + settings.access_token_env = Some("TOKEN=sentinel".to_owned()); + assert_eq!(settings.validate(), Err(PreparationError::InvalidBinding)); + assert!(!format!("{settings:?}").contains("sentinel")); + for value in ["", "\n", "a b", "\u{fc}"] { + assert!(AgentAccessToken::new(value.to_owned()).is_err()); + } + let token = AgentAccessToken::new("sentinel-secret".to_owned()); + assert!(token.is_ok()); + assert!(!format!("{token:?}").contains("sentinel")); + } + + #[test] + fn complete_arns_regions_and_cross_account_references() -> Result<(), PreparationError> { + for (partition, region) in [ + ("aws", "us-east-1"), + ("aws-cn", "cn-north-1"), + ("aws-us-gov", "us-gov-west-1"), + ] { + let target = SecretTarget::new( + format!( + "arn:{partition}:secretsmanager:{region}:999988887777:secret:example/key-AbCdEf" + ), + SecretVersion::Current, + )?; + let mut settings = SecretsManagerSettings { + region: region.to_owned(), + entries: BTreeMap::from([("active".to_owned(), target)]), + }; + settings.validate()?; + settings.region = "us-west-2".to_owned(); + assert_eq!(settings.validate(), Err(PreparationError::InvalidResource)); + } + for arn in [ + "name", + "arn:aws:secretsmanager:us-east-1:111122223333:secret:sentinel", + "arn:aws:s3:us-east-1:111122223333:secret:sentinel-AbCdEf", + "arn:aws-cn:secretsmanager:us-east-1:111122223333:secret:sentinel-AbCdEf", + "arn:aws:secretsmanager:us-gov-west-1:111122223333:secret:sentinel-AbCdEf", + "arn:aws:secretsmanager:us-east-0:111122223333:secret:sentinel-AbCdEf", + "arn:aws:secretsmanager::111122223333:secret:sentinel-AbCdEf", + "arn:aws:secretsmanager:us-east-1:123:secret:sentinel-AbCdEf", + "arn:aws:secretsmanager:us-east-1:111122223333:other:sentinel-AbCdEf", + "arn:aws:secretsmanager:us-east-1:111122223333:secret:sentinel*-AbCdEf", + "arn:aws:secretsmanager:us-east-1:111122223333:secret:sentinel-AbCdEf:extra", + ] { + let error = SecretTarget::new(arn.to_owned(), SecretVersion::Current).err(); + assert_eq!(error, Some(PreparationError::InvalidResource)); + assert!(!format!("{error:?}").contains("sentinel")); + } + Ok(()) + } + + #[test] + fn selectors_are_exclusive_and_length_checked() { + let arn = "arn:aws:secretsmanager:us-east-1:111122223333:secret:sentinel-AbCdEf"; + for version in [ + SecretVersion::Stage(String::new()), + SecretVersion::Stage("x".repeat(257)), + SecretVersion::VersionId("x".repeat(31)), + SecretVersion::VersionId("x".repeat(65)), + SecretVersion::Stage("sentinel\n".to_owned()), + ] { + assert_eq!( + SecretTarget::new(arn.to_owned(), version).err(), + Some(PreparationError::InvalidSelector) + ); + } + for selector in [ + "", + "version_stage = 'AWSCURRENT'", + "version_id = '12345678901234567890123456789012'", + ] { + assert!( + toml::from_str::(&format!("secret_id = '{arn}'\n{selector}")).is_ok() + ); + } + for suffix in [ + "version_stage = 'AWSCURRENT'\nversion_id = '12345678901234567890123456789012'", + "unknown = 'sentinel'", + ] { + assert!( + toml::from_str::(&format!("secret_id = '{arn}'\n{suffix}")).is_err() + ); + } + } + + #[test] + fn secret_keys_fit_portable_handle_and_current_selectors_normalize() + -> Result<(), PreparationError> { + let arn = "arn:aws:secretsmanager:us-east-1:111122223333:secret:example-AbCdEf"; + let current = SecretTarget::new(arn.to_owned(), SecretVersion::Current)?; + let explicit = SecretTarget::new( + arn.to_owned(), + SecretVersion::Stage("AWSCURRENT".to_owned()), + )?; + assert_eq!(current, explicit); + let mut settings = SecretsManagerSettings { + region: "us-east-1".to_owned(), + entries: BTreeMap::from([("\u{fc}".repeat(256), current)]), + }; + settings.validate()?; + let target = settings + .entries + .values() + .next() + .cloned() + .ok_or(PreparationError::InvalidBinding)?; + settings.entries = BTreeMap::from([("\u{fc}".repeat(257), target)]); + assert_eq!(settings.validate(), Err(PreparationError::InvalidBinding)); + Ok(()) + } + + #[test] + fn allowed_segments_round_trip_after_serialization_and_decoding() -> Result<(), PreparationError> + { + let mut settings = agent("http://127.0.0.1:2772"); + settings.documents.insert( + "settings".to_owned(), + AgentDocument { + application: "App +@=".to_owned(), + environment: "Env:;name".to_owned(), + profile: "Profile \u{fc}".to_owned(), + }, + ); + let target = settings + .documents + .get("settings") + .ok_or(PreparationError::InvalidBinding)?; + let serialized = settings.document_url(target)?.to_string(); + let reparsed = + Url::parse(&serialized).map_err(|_error| PreparationError::InvalidEndpoint)?; + let decoded: Vec = reparsed + .path_segments() + .ok_or(PreparationError::InvalidEndpoint)? + .map(|segment| { + percent_decode_str(segment) + .decode_utf8() + .map(Cow::into_owned) + .map_err(|_error| PreparationError::InvalidResource) + }) + .collect::>()?; + assert_eq!( + decoded, + [ + "applications", + "App +@=", + "environments", + "Env:;name", + "configurations", + "Profile \u{fc}" + ] + ); + Ok(()) + } + + #[test] + fn selector_boundaries_preserve_unicode_and_embedded_spaces() -> Result<(), PreparationError> { + let arn = "arn:aws:secretsmanager:us-east-1:111122223333:secret:example-AbCdEf"; + for selector in [ + SecretVersion::Stage("x".to_owned()), + SecretVersion::Stage("\u{fc}".repeat(256)), + SecretVersion::Stage("a b".to_owned()), + SecretVersion::VersionId("\u{fc}".repeat(32)), + SecretVersion::VersionId("x".repeat(64)), + ] { + let target = SecretTarget::new(arn.to_owned(), selector.clone())?; + assert_eq!(target.version(), &selector); + } + assert_eq!( + SecretTarget::new(arn.to_owned(), SecretVersion::Stage(" ".repeat(256))).err(), + Some(PreparationError::InvalidSelector) + ); + Ok(()) + } + + #[test] + fn arn_size_name_and_region_shape_boundaries() -> Result<(), PreparationError> { + let valid = format!( + "arn:aws:secretsmanager:us-east-1:111122223333:secret:{}-AbCdEf", + "x".repeat(512) + ); + SecretTarget::new(valid, SecretVersion::Current)?; + let oversized_name = format!( + "arn:aws:secretsmanager:us-east-1:111122223333:secret:{}-AbCdEf", + "x".repeat(513) + ); + assert_eq!( + SecretTarget::new(oversized_name, SecretVersion::Current).err(), + Some(PreparationError::InvalidResource) + ); + // Family shape does not promise an existing/served region. + SecretTarget::new( + "arn:aws:secretsmanager:us-fictional-99:111122223333:secret:x-AbCdEf".to_owned(), + SecretVersion::Current, + )?; + let base = "arn:aws:secretsmanager::111122223333:secret:x-AbCdEf"; + let maximum_region = format!("us-{}-1", "a".repeat(2_048 - base.len() - 5)); + let maximum = + format!("arn:aws:secretsmanager:{maximum_region}:111122223333:secret:x-AbCdEf"); + assert_eq!(maximum.len(), 2_048); + SecretTarget::new(maximum.clone(), SecretVersion::Current)?; + assert_eq!( + SecretTarget::new(format!("{maximum}x"), SecretVersion::Current).err(), + Some(PreparationError::InvalidResource) + ); + for region in [ + "zz-east-1", + "us--east-1", + "us-East-1", + "us-east-01", + "us-east-x", + ] { + assert_eq!( + SecretTarget::new( + format!("arn:aws:secretsmanager:{region}:111122223333:secret:x-AbCdEf"), + SecretVersion::Current + ) + .err(), + Some(PreparationError::InvalidResource) + ); + } + Ok(()) + } + + #[test] + fn configurable_limits_defaults_consistency_and_unknown_fields() -> Result<(), PreparationError> + { + let defaults = AwsPreparationLimits::default(); + defaults.validate()?; + assert_eq!(defaults.max_snapshot_bytes, 8 * 1_024 * 1_024); + let raised: AwsPreparationLimits = toml::from_str("max_snapshot_bytes = 16777216") + .map_err(|_error| PreparationError::InvalidLimits)?; + raised.validate()?; + assert_eq!( + raised.max_config_document_bytes, + defaults.max_config_document_bytes + ); + for input in [ + "max_snapshot_bytes = 0", + "max_snapshot_bytes = 1", + "max_remote_values = -1", + "operation_timeout_ms = 0", + "connect_timeout_ms = 2001", + "attempt_timeout_ms = 5001", + "operation_timeout_ms = 30001", + "unknown = 1", + "max_remote_values = 18446744073709551616", + ] { + let parsed = toml::from_str::(input); + assert!(parsed.is_err() || parsed.is_ok_and(|limits| limits.validate().is_err())); + } + Ok(()) + } +} diff --git a/crates/edgezero-store-aws/src/snapshot_tests.rs b/crates/edgezero-store-aws/src/snapshot_tests.rs new file mode 100644 index 00000000..912e18be --- /dev/null +++ b/crates/edgezero-store-aws/src/snapshot_tests.rs @@ -0,0 +1,80 @@ +//! Private snapshot read contracts, with no service client or credentials. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use edgezero_core::secret_store::{SecretError, SecretStore as _}; +use edgezero_core::secret_store_contract_tests; +use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; +use futures::executor::block_on; + +use crate::preparation::SecretSnapshot; + +fn snapshot() -> SecretSnapshot { + SecretSnapshot { + namespace: "mystore".to_owned(), + entries: BTreeMap::from([ + ( + "contract_key".to_owned(), + Bytes::from_static(b"contract_value"), + ), + ( + "contract_key_2".to_owned(), + Bytes::from_static(b"another_value"), + ), + ]), + } +} + +#[test] +fn final_deadline_check_precedes_size_error() { + let start = MonotonicInstant::now(); + let sampled = Arc::new(Mutex::new(start)); + let clock = MonotonicClock::new(move || { + let mut now = sampled.lock().expect("clock mutex"); + let result = *now; + *now = now + .checked_add(Duration::from_millis(1)) + .expect("clock advance"); + result + }); + let deadline = Deadline::at_instant( + start + .checked_add(Duration::from_millis(2)) + .expect("deadline"), + ); + let result = + block_on(snapshot().get_bytes_bounded("mystore", "contract_key", &clock, deadline, 1, 1)); + assert!(matches!(result, Err(SecretError::DeadlineExceeded))); +} + +#[test] +fn namespace_miss_and_backend_and_value_caps_are_enforced() { + let clock = MonotonicClock::default(); + let deadline = Deadline::at_instant( + clock + .now() + .checked_add(Duration::from_secs(1)) + .expect("deadline"), + ); + let provider = snapshot(); + let miss = + block_on(provider.get_bytes_bounded("other", "contract_key", &clock, deadline, 0, 0)) + .expect("namespace miss"); + assert!(miss.value.is_none()); + for (backend_cap, value_cap) in [(1_u64, 100_u64), (100_u64, 1_u64)] { + let result = block_on(provider.get_bytes_bounded( + "mystore", + "contract_key", + &clock, + deadline, + backend_cap, + value_cap, + )); + assert!(matches!(result, Err(SecretError::ValueTooLarge))); + } +} + +secret_store_contract_tests!(secret_snapshot_contract, snapshot()); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 46f21e82..5d7c6197 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -50,6 +50,8 @@ export default defineConfig({ { text: 'Cloudflare Workers', link: '/guide/adapters/cloudflare' }, { text: 'Fermyon Spin', link: '/guide/adapters/spin' }, { text: 'Axum (Native)', link: '/guide/adapters/axum' }, + { text: 'Native AWS stores', link: '/guide/native-aws-stores' }, + { text: 'DynamoDB KV', link: '/guide/dynamodb-kv' }, ], }, { diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index a68696f5..a96242bb 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -80,6 +80,135 @@ cargo build -p my-app-adapter-axum --release The binary is placed in `target/release/my-app-adapter-axum`. +## Bounded HTTP/1 Ingress + +The standard server entrypoints own a bounded pool of HTTP/1 connections. These limits apply +before application admission, including to sockets that never finish a request head. No custom +listener or router is needed. HTTP/2 is not enabled, and protocol upgrades cannot transfer the +socket out of this pool. + +`run_app` resolves these runtime variables once, before application configuration or binding: + +| Variable (`EDGEZERO__ADAPTER__INGRESS__` prefix) | Default | Supported range | +| ------------------------------------------------ | ------- | --------------- | +| `MAX_CONNECTIONS` | 256 | 1–4096 | +| `MAX_RAW_HEAD_BYTES` | 65536 | 8192–1048576 | +| `MAX_HEADER_COUNT` | 100 | 1–1024 | +| `HEAD_READ_TIMEOUT_MS` | 10000 | 1–120000 | + +Only missing settings use defaults. Explicit values must be unsigned decimal integers within +the supported ranges; empty, signed, malformed, overflowing, and zero values fail startup. +Diagnostics identify the setting, never its supplied value. The effective validated +`AxumIngressConfig` is logged at `Info`. Applications can resolve the same configuration with +`AxumIngressConfig::from_env(&EnvConfig::from_env())` and inspect its getters. Programmatic +servers install the validated value in `AxumDevServerConfig::ingress`: + +```rust +use std::time::Duration; +use edgezero_adapter_axum::{ingress_config::AxumIngressConfig, dev_server::AxumDevServerConfig}; + +let config = AxumDevServerConfig { + ingress: AxumIngressConfig::new(64, 16384, 64, Duration::from_secs(5))?, + ..AxumDevServerConfig::default() +}; +``` + +Manifest defaults can be supplied through adapter-filtered `[[environment.variables]]` entries. +The Axum CLI forwards the selected manifest's defaults to its Cargo child without mutating the +parent environment. An existing parent variable, including an empty one, takes precedence; +duplicate applicable defaults retain declaration order, with the last taking precedence. +Running the binary directly requires setting the runtime variables directly. + +### Connection Ownership And Deadlines + +Let `C` be `max_connections()`. At most `C` connection futures own parser state, sockets, and +application work, including keep-alive connections. There is no per-connection spawned task or +permit-waiter queue. The accept loop may own **one additional transient accepted socket**; if +capacity is exhausted it closes that socket immediately, without a parser, response, or +background task. Completed connections are reaped before accepting more work. Disconnect, +parser failure, timeout, cancellation, and shutdown drop the single connection owner and release +capacity; unwinding handler failures are isolated to their connection. Shutdown closes the +owned connections rather than waiting indefinitely for a graceful drain. + +The first absolute idle-plus-header deadline starts when the accepted socket enters the pool, +before its future is first polled. Each subsequent deadline starts when Hyper first polls the +next request head after the previous exchange permits it. Idle keep-alive time and incomplete-head +time share this budget; a byte arriving does **not** reset it. Pipelined bytes may already be +buffered, so this is not a deadline starting at the network arrival of each pipelined request. +An independent timer wakes without incoming data, and ready I/O and completed heads also check +expiry before admission. Equality is expired. Slow/trickled or idle connections close when the +deadline expires; an HTTP timeout response is not promised. + +After a complete head is accepted, the header clock is disarmed for application work. Existing +application body-read and response-write deadlines remain in force. Infrastructure timers use +the native Tokio monotonic clock, independently of an application's injectable clock, so a +frozen application clock cannot disable pre-admission expiry. These timers are cooperatively +scheduled: blocking application code can delay all work on the connection executor. + +### Wire Limits And Parser Storage + +Let `H = max_raw_head_bytes()` and `N = max_header_count()`. The pinned Hyper 1.12.0 parser +rejects a complete raw head above `H` bytes or above `N` field lines **before** constructing an +admitted request. The byte budget includes the entire request line, field names, colons, +whitespace, line endings, and the final empty line. Duplicate field lines count separately. +Request-line and header bytes share one combined budget, not independent allowances. Exactly +`H` bytes and `N` fields pass the size/count checks; other protocol, type, or application validation +can still reject an in-budget head. Hyper additionally limits request targets to 65534 bytes +(414). Overflow closes the connection and Hyper attempts a bounded 431 response; other malformed +syntax may receive a bounded 400 response. Expiry or disconnect can prevent response delivery. +Application admission and rendering hooks do not run for raw parser rejections. + +`parser_buffer_bytes()` is Hyper's logical threshold `H`, **not** a hard allocation ceiling. +The receive scratch audit pins Hyper 1.12.0, bytes 1.12.1, http 1.4.2, and Rust 1.95.0: + +- `parser_read_allocation_bytes()` reports the conservative requested-capacity bound `R = 4H` + for each receive backing allocation. Adaptive reads expose spare capacity and shared-buffer + splits can cause a larger replacement allocation than the logical threshold. +- `parser_trailer_allocation_bytes()` reports `T = next_power_of_two(H)` for the separate raw + chunked-trailer buffer. Hyper applies its byte budget separately to trailers, whose completed + raw field section must be **strictly smaller** than `H`; this is not a combined head-plus-trailer + allowance. The trailer field count remains at most `N`. +- Conservative simultaneous receive scratch, including an outstanding channel frame, replacement + allocation, raw trailer growth, and copied field bytes, is `3R + 2T + H`, **plus** parser/header + metadata, URI/method storage, and fixed connection state. These are requested capacities, not + allocator-resident bytes or a complete per-request memory certificate. +- `parser_header_map_allocation_bytes()` reports `M`, a conservative per-map requested-capacity + charge covering both ordinary and adversarial collision-driven growth, duplicate-value storage, + and capacity retained across clear/reuse. For `P = max(100, N) + 1`, raw index capacity is bounded + by the largest power of two at most `10P`, bucket capacity by three quarters of that number, + and duplicate capacity by `next_power_of_two(max(4, P - 1))`. The getter uses public header-type + sizes plus conservative machine-word padding for private map nodes. Charge up to `1.5M` for + one map's reallocating peak, or `3.5M` for overlapping request, response/cache, and trailer maps. + An application-retained request map or admission's temporary header clone is an additional + owner, not included in those three maps. +- Raw parse workspaces additionally retain the default 100-field inline arrays and, above that, + bounded heap arrays. On 64-bit native builds a conservative payload charge for these two + workspaces is `64 * max(100, N)` bytes, plus their fixed container state. Parsed URI/method data + and field-name/value backing bytes are separate charges bounded by the raw input sizes. + +Response header maps become reusable request-parser storage in Hyper. Before handoff, EdgeZero +therefore bounds application response fields to `max_response_header_count() = max(100, N)` +and `max_response_header_bytes() = H`. Field-byte accounting is the sum of name bytes, value +bytes, and four bytes per field (`: ` and CRLF), including duplicates. EdgeZero copies the map +and field data into fresh storage, discarding application-supplied spare capacity and large +shared backing buffers; duplicate values and sensitive flags are preserved. Custom Hyper +reason phrases are removed in favor of canonical status phrases. Hyper's own status line, +framing, connection, and date fields need additional fixed overhead. An oversized response head +closes the connection and reports a transport error to any active egress attempt; it does not +allocate a replacement error response. + +The pool bounds retention of receive scratch, parser-generated error responses, and parser +metadata, not arbitrary application allocations. A budget must additionally charge parsed +request/response metadata and copies, application-held body frames, response buffers, handler +state, stores, outbound exchanges, and allocator overhead. Kernel socket buffers, the OS listen +backlog, and resources outside EdgeZero are excluded. The connection bound is not a published +platform memory ceiling or a whole-process RSS guarantee. + +This combined HTTP/1 resource policy does not implement the stricter independent raw-target / +field-section accounting or raw framing-rejection policy. Admission still receives +`IngressHeadAccounting::HostManaged` and `IngressFraming::HostManaged`; both raw-ingress +capabilities remain `Unsupported`. See [Capabilities](/guide/capabilities). + ## Outbound HTTP The Axum adapter injects `AxumOutboundClient`, backed by `reqwest`. Application handlers use the @@ -94,13 +223,40 @@ The connection supervisor enforces the absolute response-write deadline, but fra not proof of socket or client receipt; response-egress capabilities therefore remain BestEffort. See [Capabilities](/guide/capabilities). +For streamed responses with `Content-Length`, Axum retains only the final chunk until source EOF +validates, because Hyper stops polling once the declared length is satisfied. A bad tail is not +sent, and a pending tail remains subject to the write deadline. A declared zero-length stream is +validated before handoff. Suppressed HEAD/status bodies are dropped without polling. Clean source +EOF remains `HostHandoff`, not proof of socket flush or client receipt. + ## Logging The Axum adapter's `run_app` helper installs `simple_logger` at the level read from `EDGEZERO__LOGGING__LEVEL`, falling back to `info` when the variable is unset or unparseable. It does not read `edgezero.toml`, and `echo_stdout` has no effect on the runtime. To install a different logger, set `owns_logging = true` on your `app!` -declaration so `run_app` skips its own logger, then install yours in `main`. Wiring +declaration so `run_app` skips its own logger, then install yours in `main` **before** `run_app`. +Logger setup now precedes application configuration, and setup failures are returned rather than +ignored. For embedding startup validation, `run_app_with_preflight::(check)` runs `check` +after logging is ready and before configuration or listener binding. The bundled `edgezero demo` +uses this for its capability gate instead of preinstalling a second CLI logger. + +Managed boot warnings use `edgezero::boot` at `Warn`, even when runtime logging is `Off`. +Application-owned loggers can reuse the shared resolver and constants: + +```rust +use edgezero_core::{BOOT_LOG_LEVEL, BOOT_LOG_TARGET, resolve_logging_level}; +use edgezero_core::env_config::EnvConfig; + +let env = EnvConfig::from_env(); +simple_logger::SimpleLogger::new() + .with_level(resolve_logging_level(&env)) + .with_module_level(BOOT_LOG_TARGET, BOOT_LOG_LEVEL) + .init()?; +// Then call run_app::() with owns_logging = true. +``` + +Wiring `edgezero_core::app::App::build::(edgezero_adapter_axum::AXUM_PLATFORM)` and `AxumDevServer` by hand remains the fallback if you also need to control the bind address or store setup. @@ -159,23 +315,37 @@ The database file grows over time and does not shrink after deletions. To reclai space, delete the file in `.edgezero/`; the data is lost. See [KV Storage](/guide/kv) for the portable API. -## Secret Store - -A declared `[stores.secrets]` id resolves to an `EnvSecretStore`, which looks up each -secret name verbatim in the process environment. Axum lists `secrets` in its -`single_store_kinds`, so only one secrets id may be declared: - -```bash -API_KEY=mysecret edgezero serve --adapter axum -``` - -## Config Store - -For local development, each declared `[stores.config]` id resolves to a -local-file config store backed by `.edgezero/local-config-.json`. -The portable manifest carries no inline defaults — the -pre-rewrite `[stores.config.defaults]` table is gone (see -[the migration guide](../manifest-store-migration.md)). +## Native store providers + +Axum keeps the existing local defaults, but native applications can select a provider +per logical store ID. The portable manifest still declares IDs and defaults only. A +separate, strict native bindings file selects supported providers; it does not change +store metadata or add AWS dependencies to Fastly, Cloudflare, or Spin builds. + +Without a bindings file, config uses local `.edgezero/local-config-.json` +snapshots, secrets use environment variables, and KV uses the existing local backend. +With explicit AWS bindings, the runner fetches mapped values before its initializer +and before it accepts requests. It does not fall back to a local provider when an AWS +lookup or preparation fails. See the [native AWS stores guide](../native-aws-stores.md) +for the schema, feature selection, permissions, and lifecycle limits. + +The native binding file path must be absolute. `EDGEZERO__STORE_BINDINGS_FILE` is +read only by the environment-loading runner. Explicit `AxumRunOptions` selection +uses only values supplied in code. Programmatic applications may also inject existing +handles or deferred setup through `run_app_with_native_stores`; deferred setup is +polled after source validation, in the current native runtime. Its construction must +not perform provider work. + +A declared `[stores.secrets]` ID still defaults to an environment-backed store when +not explicitly bound. It is no longer limited to one ID by Axum. Existing +`EDGEZERO__STORES__SECRETS____NAME` and config/KV local overrides retain their +meaning for implicit local bindings; they cannot override an explicitly bound ID. + +`config push --adapter axum` remains a local-file writer. It does not publish to +AppConfig. If passed an explicit `--store-bindings` file that binds the selected ID +to AWS, the guard rejects the operation before writing anything. A push +without that explicit file still only updates the local snapshot file; it does not +update an AWS-bound deployment. ```toml [stores.config] @@ -217,12 +387,75 @@ flow — the bundled `edgezero config push` errors), which writes the same `.edgezero/local-config-.json` files the runtime reads — no shell-out, no server to authenticate against. +### Snapshot Allocation Limits + +Set `EDGEZERO__ADAPTER__CONFIG_STORE__` through the normal environment +configuration/startup path. Absent settings use finite defaults; explicit invalid, +zero, overflowing or inconsistent settings fail before listener binding. + +| Setting | Default | Supported Range / Scope | +| -------------------- | ------- | -------------------------------------------------------------------------- | +| `MAX_FILE_BYTES` | 16 MiB | 1 byte to 256 MiB per file, including JSON syntax and escaping | +| `MAX_ENTRIES` | 1024 | 1 to 65536 entries per file, including duplicate-key attempts | +| `MAX_KEY_BYTES` | 1024 | 1 byte to 64 KiB decoded UTF-8 per key, no larger than the file cap | +| `MAX_VALUE_BYTES` | 8 MiB | 1 byte to 256 MiB decoded UTF-8 per value, no larger than the file cap | +| `MAX_RESIDENT_BYTES` | 32 MiB | 1 byte to 256 MiB across every config snapshot in the startup registry | +| `MAX_STARTUP_BYTES` | 112 MiB | 1 byte to 1 GiB across resident snapshots plus one file's reserved staging | + +These are requested-allocation charges, not an RSS ceiling. Stores load sequentially. +For wire cap `W`, staging reserves `5 * max(W, 32)` bytes: input growth, escaped-string +decoder growth (including old/new allocation overlap), and temporary decoded key/value +strings. A separate 4096-byte read buffer lives on the stack. The startup limit must +cover staging; every index/payload reservation also checks residency plus staging. +This decoder allowance is audited against pinned `serde_json 1.0.150` and Rust 1.95.0; +changing those allocation implementations requires repeating the audit. + +The sorted snapshot index reserves `MAX_ENTRIES * size_of::<(String, ConfigValue)>()` +bytes per store. Each entry additionally charges key UTF-8 bytes, value UTF-8 bytes, +two `Arc` reference counts and up to `size_of::() - 1` alignment bytes. Duplicate +keys are rejected. Keys/values are size-checked before their owned copies; actual reads +detect file growth and reject the first excess byte without appending it. Non-string +values are rejected without constructing a JSON tree. Failed loads drop staging and +partial snapshots. Diagnostics never contain file contents. + +`get` and `get_bounded` return `ConfigValue`, an independently owned, shared UTF-8 +payload. Clones allocate no payload and never retain the whole file. Bounded reads +check both extraction caps and the injected deadline before returning an `Arc` clone; +there is no request-time file read. Borrow with `as_str()` or `as_ref()`; `to_string()` +is an explicit allocating boundary. Already-owned `from_map` inputs are caller allocations +outside these startup-reader charges. + +Budget fixed manifest/registry/handle metadata separately, as well as allocator overhead, +environment-backed secret reads, concurrently parsed config trees, typed outputs and +application retention of returned handles. Canonical hashing streams into SHA-256 rather +than staging a second full JSON string. The extractor drops its raw handle before resolving +secrets. Kernel buffers, filesystem page cache, allocator fragmentation and total RSS are +not guaranteed. Local file reads are synchronous; no finite startup I/O interruption is +claimed. The broad `config-read-allocation-bounds` capability remains `Unsupported`; +this bounded local config-snapshot path does not certify every secret/provider allocation. + +## Optional AWS features + +Generated native targets opt into `aws-appconfig-agent` and +`aws-secrets-manager` independently, or use the `aws-stores` alias for both. These +features are separate from the CLI binary's own dependencies. A bindings file cannot +enable a provider that was not compiled into the application. The AWS SDK and service +clients stay out of default native and WASM target graphs. + +The native AWS SDK can emit diagnostic fields at dependency `DEBUG`/`TRACE` levels. +Those events may include secret ARNs, credential-source metadata, or response details +when sensitive-body logging is enabled. EdgeZero redacts its own errors and diagnostics, +but the first version does not suppress dependency events because scoped suppression +can break an application's tracing-to-log fallback. Do not enable dependency +`DEBUG`/`TRACE` in production unless the process logging policy has been reviewed. +This limitation must be disclosed in release and PR review notes. + ## Container Deployment Build and deploy as a standard container: ```dockerfile -FROM rust:1.75 as builder +FROM rust:1.95.0 as builder WORKDIR /app COPY . . RUN cargo build -p my-app-adapter-axum --release @@ -260,19 +493,28 @@ A typical development workflow: ## Differences from Edge Adapters -| Aspect | Axum | Fastly/Cloudflare | -| ----------- | -------------- | ----------------- | -| Compilation | Native | Wasm | -| Cold start | ~0ms | ~0ms (Wasm) | -| Memory | Unlimited | 128MB typical | -| Filesystem | Full access | Sandboxed | -| Network | Direct | Backend/fetch | -| Concurrency | Multi-threaded | Single-threaded | +| Aspect | Axum | Fastly/Cloudflare | +| ----------- | ------------------------------- | ----------------- | +| Compilation | Native | Wasm | +| Cold start | ~0ms | ~0ms (Wasm) | +| Memory | Operator configured | Provider quota | +| Filesystem | Full access | Sandboxed | +| Network | Direct | Backend/fetch | +| Concurrency | Bounded, connection-local async | Runtime dependent | ::: tip Development Parity While Axum provides a convenient development environment, always test on actual edge platforms before deploying. Provider-specific behaviour such as store backends and request context differs on the real targets. ::: +## Application lifetime + +`dev_server::run_app` already constructs one app per server startup and serves +clones of its router. No additional reuse option is needed. Retained application +state must support concurrent requests; each request gets its own metadata, +extensions, and body. Restarting the server creates a fresh app. Native Axum +measurements are a useful ownership reference but do not establish WASM runtime +performance or resource lifetimes. + ## Next Steps - Deploy to [Fastly Compute](/guide/adapters/fastly) for production diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index 56e31a82..a92696fd 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -29,11 +29,19 @@ The Wrangler manifest configures your Worker: name = "my-app" main = "build/worker/shim.mjs" compatibility_date = "2023-05-01" +compatibility_flags = ["enable_request_signal", "enable_weak_ref"] [build] command = "worker-build --release" ``` +Keep both compatibility flags when migrating an existing application. `enable_request_signal` +exposes incoming-request cancellation. Bounded config KV reads require `FinalizationRegistry` +for wasm-bindgen cleanup callbacks and fail before dispatch if it is absent; `enable_weak_ref` +enables that support even with an older compatibility date. GC timing is nondeterministic and +does not provide a certified allocation or teardown bound. See +[Cloudflare's compatibility contract](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#enable-finalizationregistry-and-weakref). + ### Entrypoint The Cloudflare Wasm entrypoint in `src/lib.rs` wires the adapter: @@ -86,9 +94,9 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { This path takes bindings verbatim and does not resolve `EDGEZERO__STORES__*` selectors, so prefer `run_app` unless you are mocking a backend. -`run_app` dispatches through an internal registry-based path; unlike Fastly's -`dispatch_with_registries`, it is not part of the Cloudflare adapter's public -API. +`run_app` delegates to the public `dispatch_app::` registry-aware path. +Use [application retention](#retaining-an-application) with that function when the +caller owns a prebuilt app and wants fresh per-request store bindings. ## Building @@ -153,6 +161,12 @@ EdgeZero does not install a Cloudflare logger by default. Use your preferred log `console_log` or your own `log` implementation), and view output in Wrangler or the Cloudflare dashboard. +Workers console capture does not install a Rust `log` backend. Initialize your backend and +both its filter and facade maximum **before** `run_app`, and set `owns_logging = true`. +`edgezero_core::resolve_logging_level(&env_config)` resolves a supplied platform `EnvConfig`; +`edgezero::boot` is available for separately filtered startup diagnostics. The no-op adapter +initializer cannot guarantee their visibility. + ::: tip Logging status Cloudflare logging is opt-in; install a logger (such as `console_log`) in your entrypoint if you need structured output. @@ -325,6 +339,64 @@ Configure the Cloudflare adapter in `edgezero.toml`. See [Configuration](/guide/ | Logging | `console.log` | Log endpoints | | CLI | Wrangler | Fastly CLI | +## Retaining an application + +For explicit retention, keep a cache owned by one concrete application and +select its `Hooks` type with `dispatch_app::` on every fetch: + +```rust +use edgezero_adapter_cloudflare::CLOUDFLARE_PLATFORM; +use edgezero_core::{app::App, EdgeError}; +use my_app_core::App as MyApp; +use std::sync::OnceLock; + +static APP: OnceLock = OnceLock::new(); + +fn retained_app() -> Result<&'static App, EdgeError> { + if let Some(app) = APP.get() { + return Ok(app); + } + let app = App::build::(CLOUDFLARE_PLATFORM)?; + Ok(APP.get_or_init(|| app)) +} + +#[worker::event(fetch)] +async fn fetch(req: worker::Request, env: worker::Env, ctx: worker::Context) + -> worker::Result +{ + let app = retained_app().map_err(|_| { + worker::Error::RustError("application configuration failed".to_owned()) + })?; + edgezero_adapter_cloudflare::dispatch_app::(app, req, env, ctx).await +} +``` + +Repeated response headers such as multiple `Set-Cookie` values are preserved; +the first application value replaces any body-generated default. This correction +also applies to existing `run_app` users. + +`dispatch_app::` reads `MyApp::stores()` and does not initialize logging +or construct an app. Its signature is +`dispatch_app(&App, Request, Env, Context) -> Result`. +Pass an app built from the same `Hooks` type with `CLOUDFLARE_PLATFORM`; the caller +owns logging setup before construction. `App::build` returns `Result` +and invokes fallible `Hooks::configure` after installing platform metadata. +Only a complete successful app enters the cache; an initialization error leaves it +empty for a later attempt. Concurrent construction attempts may build separate +successful candidates, but only one is retained. + +Each invocation resolves fresh configuration, runtime variables, and store +registries, then runs fresh admission and response egress. Never retain `Env`, +`Context`, request bodies, admission resources, or delivery coordinators in the +app cache. Construction is synchronous and must not recursively access the cache +or block waiting for async initialization. Apps needing async initialization or +refresh own that state machine and publish only a complete successful snapshot. + +Fetch invocations may overlap. Verify isolation while two requests are actually +in flight in the same instance. A serialized test or two separate instances does +not establish this property. Restore the existing `run_app` entry point to remove +explicit retention. The generated default remains unchanged. + ## Next Steps - Learn about [Fastly Compute](/guide/adapters/fastly) as an alternative diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index 0f445f0e..2ec298e9 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -6,7 +6,10 @@ Deploy EdgeZero applications to Fastly's Compute@Edge platform using WebAssembly - [Fastly CLI](https://developer.fastly.com/learning/compute/#install-the-fastly-cli) - Rust `wasm32-wasip1` target: `rustup target add wasm32-wasip1` -- [Viceroy](https://github.com/fastly/Viceroy) for local execution and testing +- [Viceroy](https://github.com/fastly/Viceroy) 0.21.0 for local execution and testing + +The adapter pins the Fastly SDK to 0.13.1. Older Viceroy releases may reject its +host imports before application code executes; use the repository's pinned runtime. ## Project Setup @@ -99,8 +102,8 @@ pub fn main() -> Result<(), fastly::Error> { } ``` -The request closure runs once per routed request; insert whatever typed values -your handlers need, then read them in a handler via a custom extractor or +The request closure runs once before admission for each request that reaches +preparation; insert whatever typed values your handlers need, then read them via a custom extractor or `ctx.request().extensions().get::()`. The optional state returned by `run_app_with_hooks` is available only after EdgeZero reaches its strongest terminal delivery boundary. Detached admission responses skip the response @@ -112,7 +115,7 @@ response-returning dispatch operation. ### Owning your own logging -By default `run_app` initializes the Fastly logger. If your app already installs +When a log endpoint is configured, `run_app` initializes the Fastly logger. If your app installs a `log` backend, opt out with the platform-neutral `Hooks::owns_logging()` flag — via the `app!` macro: @@ -121,7 +124,10 @@ edgezero_core::app!("edgezero.toml", owns_logging = true); ``` or on a hand-written `Hooks` impl (`fn owns_logging() -> bool { true }`). Every -adapter's `run_app` honors it, so the app is responsible for logger setup. +adapter's `run_app` honors it. The app must install its backend **before** entering the runner, +apply the configured runtime level, and set both backend and facade filters. Use +`edgezero_core::resolve_logging_level(&env)` for consistent level resolution; raising only +`log::set_max_level` does not override `log_fastly`'s internal filter. ### Custom entry points @@ -139,10 +145,19 @@ Use `run_app_with_hooks` when custom request preparation or routed-response fina the adapter retains response ownership and returns finalizer state only after terminal delivery. `run_app_with_config` and a hand-built `FastlyService` do **not** apply the env overlay, so staging and overridden `__NAME` / `__KEY` selectors fall back to baked-in defaults. A fully manual -entrypoint must call `runtime_env_config` and use `send_with_registries_and_hooks` for parity with -`run_app`. A hand-written `Hooks` implementation must also override `stores()` or pass explicit +entrypoint must call `runtime_env_config` and use `request::send_with_registries_and_hooks` for parity +with `run_app`. Inside a serving callback, use `request::send_request_with_registries_and_hooks` +with the already-received request; the receiving helper would attempt to receive another request. +A hand-written `Hooks` implementation must also override `stores()` or pass explicit `StoresMetadata`; the trait default declares no stores. +`runtime_env_config` returns `FastlyRuntimeConfig`, not `EnvConfig`. It performs no logging: +use `config.env` for logger and dispatch setup, then call `config.emit_boot_diagnostics()` after +installing the backend. An unavailable runtime Config Store produces one deferred, fixed-message +boot warning, consumed at most once. Without an installed backend it is not observable. +`run_app` and `run_app_with_hooks` do this automatically, including when application configuration +fails. `run_app_with_config` does not collect the runtime-env diagnostic. + `FastlyLogging::from(&EnvConfig)` derives `use_fastly_logger` from `endpoint.is_some()`, which is what keeps a local Viceroy run off the reserved `stdout` endpoint when no endpoint is configured. @@ -189,7 +204,7 @@ fastly compute deploy -C crates/my-app-adapter-fastly ## Backends -`FastlyOutboundClient` uses deterministic **dynamic backends** derived from the canonical target, +Outbound HTTP uses deterministic **dynamic backends** derived from the canonical target, TLS identity, and provider timer budget. You do not predeclare those destinations in `fastly.toml`, but dynamic backends must be enabled on the deployed Fastly service. The local CLI cannot prove that service entitlement, so `outbound-http` is BestEffort. A disabled service @@ -214,14 +229,19 @@ applied on this path: stdout echo is always on, and logger use is derived from `ENDPOINT` alone. An `[adapters.fastly.logging]` table in `edgezero.toml` is consumed only by `edgezero new` when scaffolding. -To initialize logging manually, call `init_logger` with explicit settings: +Managed Fastly logging keeps the `edgezero::boot` target at `Warn` while ordinary records use +the configured level. Both the SDK and facade allow boot warnings even at ordinary `Off`; +raising the facade maximum alone is insufficient. `edgezero::boot` is a logical target, not a +Fastly endpoint name. Initialize logging before application configuration. + +To initialize logging manually, call `init_logger` with a provisioned endpoint: ```rust use edgezero_adapter_fastly::init_logger; use log::LevelFilter; fn main() { - init_logger("stdout", LevelFilter::Info, true).expect("init logger"); + init_logger("edgezero-logs", LevelFilter::Info, true).expect("init logger"); } ``` @@ -285,7 +305,9 @@ async fn handler(config: Config) -> Result { ``` If a configured store link is missing, the adapter logs a one-time warning -and drops that id from the registry. Migrating from `name`/`adapters.*`? +and retains a typed failing declared binding, rather than reporting intentional absence. +Raw values are shared `ConfigValue` UTF-8 payloads; use `as_str()` to borrow them. +Migrating from `name`/`adapters.*`? See [the migration guide](../manifest-store-migration.md). ## Context Access @@ -325,7 +347,7 @@ See the [Streaming guide](/guide/streaming) and Run contract tests for the Fastly adapter: ```bash -cargo install viceroy --locked +cargo install viceroy --version 0.21.0 --locked export CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" # Run tests @@ -344,6 +366,253 @@ locally and rely on Linux CI for execution. Configure the Fastly adapter in `edgezero.toml`. See [Configuration](/guide/configuration) for the full manifest reference. +## Reusing a sandbox and retaining an app + +Opt in with an ordinary `main` in place of the single-request `run_app` call: + +```rust +use edgezero_adapter_fastly::{Serve, serve_app}; +use std::time::Duration; + +fn main() -> Result<(), fastly::Error> { + serve_app::( + Serve::new() + .with_max_requests(10) + .with_timeout(Duration::from_millis(500)), + ).into_result() +} +``` + +`serve_app_with_hooks` additionally accepts a preparation hook +`FnMut(&mut fastly::Request, &mut Extensions)` and a finalization hook +`FnMut(&mut edgezero_core::Response) -> State`. Preparation runs before admission; +finalization runs only for routed responses, before egress policy and framing. +Detached admission responses skip finalization. The retained helper discards the +finalizer's state after delivery; use the request-level send helper in a custom +callback when post-send work needs that state. + +The first request initializes logging and builds the app through +`App::build::(FASTLY_PLATFORM) -> Result`. Each request reads +runtime configuration and constructs new store registries. Successful app construction +and `configure` execute once per sandbox; explicit work elsewhere still executes +whenever the application calls it. Both hooks and their captures are retained for +the whole serving loop; the supplied native request, extensions, and response are +request-local. Create request-specific mutable data inside the preparation hook +or store it in those extensions. + +Logging takes the first configuration snapshot, unless the app owns logging. +An unavailable optional runtime configuration store disables logging for that +sandbox, even if subsequent reads recover store selectors. It does not silently +retry or reconfigure logging. The current overlay enables logging when an +endpoint exists and uses `echo_stdout: true`. Applications requiring another +initialization policy should use `lifecycle::serve_custom` and `setup_once`. +`runtime_env_config` returns `FastlyRuntimeConfig` with its public `env` and deferred +diagnostics. The standard helper emits those diagnostics after the logging setup +decision. Custom callbacks must likewise call `emit_boot_diagnostics()` after +installing their logger. Each configuration value consumes its pending warning at +most once; without a backend it is not observable. Absence of a warning does not +establish a healthy configuration read. + +SDK 0.13.1 exposes `with_max_requests`, `with_timeout`, `with_max_lifetime`, and +`with_max_memory`. A value of zero disables the request-count or memory limit; +it does not request zero callbacks or zero memory use. The lifetime limit is +measured from `Serve` construction, including time before the first callback. +`with_timeout` bounds only the idle wait for the next request; it does not bound +handler or backend time. If your service is billed by memory × wall-clock time, +idle waiting is billed too. Limits do not guarantee reuse; any request may start a +fresh sandbox. The fixtures pin standalone Viceroy 0.21.0; any observed per-guest +request ceiling belongs to that runtime and run, not to the SDK request limit. +`edgezero serve` runs `fastly compute serve`, whose bundled Viceroy can differ. +Record the executable and observed reuse rather than assuming a local ceiling +establishes deployed behavior. +Lifetime and memory checks occur between callbacks and cannot interrupt +blocked application work. Before using a local memory limit, verify that the +guest's memory-snapshot call succeeds: an unsupported snapshot conservatively +ends the SDK loop. CPU clock observations are not reliable cross-run benchmarks. + +`ServeSummary::requests()` counts attempted callbacks, including a failed one; +a crash can prevent a final summary. Handler `EdgeError`s render as responses +and serving continues when adapter delivery succeeds. A returned setup, required-store, +admission-abort, or terminal-delivery error ends the loop and remains in its summary. +The lifecycle wrapper records the callback result without asking the SDK to send a +synthetic 500, including when a response was already committed. Ingress and egress +policies may produce their own error responses; an escaping error is not proof that +any response was sent. Constructor panics remain sandbox failures. + +### Custom dispatch and streaming + +The standard helpers preserve core response streams without whole-body collection. +For custom initialization or post-send work, use `lifecycle::serve_custom` with a +configured `Serve` builder and a callback +`FnMut(fastly::Request, &mut lifecycle::Sandbox) -> Result<(), E>`. EdgeZero owns +the retained slot and successful-only initialization; your callback chooses the +application-owned `T`. Initialize lazily so health checks can bypass expensive +construction. + +For an EdgeZero response, call `request::send_request_with_registries_and_hooks` +with the app, store metadata, received request, `&runtime.env`, preparation hook, +and finalization hook. It runs canonical admission, injects fresh `Kv`, `Config`, +`Secrets`, and `AppConfig` registries, then owns framing, streaming, deadlines, and +terminal delivery. Preparation borrows a mutable native request and extension bag; +finalization borrows a mutable routed core response. Neither hook returns a +response for the callback to send separately. The result is `Result, +fastly::Error>`: `Some` carries routed finalizer state only after terminal delivery, +while a delivered detached admission response returns `None`. + +The manual response lifetime remains adapter-owned: append every header value, +commit once through `stream_to_client`, account accepted body bytes, and finish or +abandon the body handle exactly once. A native response constructed directly by +your callback, such as a health reply, must instead be explicitly sent there; +manual native streams must be pumped, flushed, and finished before returning. +The lifecycle wrapper never supplies an extra response. Returning `Err` terminates +serving without a synthetic send, even after commitment. Return `Ok(())` only when +your policy permits continued serving after cleanup. Complete pending backend and +post-send operations before returning. Do not cache native store handles with the app. + +Use `Request::get_client_request_id()` for request correlation. `FASTLY_TRACE_ID` +describes the sandbox and must not be treated as a unique request ID. If a native +ID is unavailable, generate a request-local fallback and label its source. Do +not retain correlation fields in app state or global logger configuration. + +### Custom lifecycle contract + +The custom path uses public APIs: `lifecycle::{Sandbox, serve_custom, run_custom}` +and `request::send_request_with_registries_and_hooks`. EdgeZero owns the +successful-only retained slot and delegates request reception and limits to the +SDK. The callback owns delivery: it either explicitly sends a native response or +calls the adapter's closed send helper. The serving wrapper passes only the +terminal `Result<(), E>` to the SDK without a synthetic response. Applications +own setup, state, refresh policy, hooks, and post-send work. There is no +response-returning conversion or whole-body compatibility path. + +Follow this order inside a callback: + +1. Explicitly send lightweight health responses before expensive initialization. +2. Read fresh `runtime_env_config(MyApp::stores())` and use `runtime.env` for logger + and store setup. Use `sandbox.setup_once` to guard successful logger setup independently from + app construction. Failed setup leaves the guard unset; partial side effects + are not rolled back, so retries must be safe. Emit boot diagnostics after the + logger is installed, not while reading configuration. +3. Call `sandbox.initialize` with `App::build::(FASTLY_PLATFORM)` for a core + app, mapping its typed error at the adapter boundary. Success fills the slot; + failure leaves it empty and returns the error unchanged. Read the value + with `state()`. A handled failure may send a 503 and return `Ok(())` to permit + a later callback to retry. Propagating it with `?` ends the loop without a synthetic send. +4. Pass the received request to `send_request_with_registries_and_hooks`. Its + `FnOnce` preparation hook may mutate the native request and portable extensions + before admission; its `FnOnce` finalizer may inspect response extensions and + mutate a routed response before egress. Keep bodies, extensions, registries, + and native handles request-local. Registered app state still overwrites + extensions of the same type. +5. Wait for the send helper's terminal result before using finalizer state or + starting post-send work. `None` means a detached response skipped finalization. + Never send the core response again; a returned delivery error may follow commitment. +6. Complete pending backend and post-send work before returning. Constructors + and callbacks that panic remain sandbox failures. + +For example, after health checks, this policy permits an in-sandbox setup retry: + +```rust +if let Err(error) = sandbox.setup_once(|| install_application_logger()) { + eprintln!("logger setup failed: {error}"); + fastly::Response::from_status(503).send_to_client(); + return Ok(()); +} +sandbox.initialize(|| App::build::(FASTLY_PLATFORM).map_err(fastly::Error::from))?; +// Read sandbox.state() and call the closed send helper; do not send its response again. +// The ? above deliberately terminates this sandbox if application construction fails. +``` + +A complete callback looks like this. `serve_custom` returns a `ServeSummary` +that you inspect or convert with `into_result()`. `App::build` is fallible and +installs `FASTLY_PLATFORM` before application configuration: + +```rust +use edgezero_adapter_fastly::lifecycle::{self, Sandbox}; +use edgezero_adapter_fastly::{ + FASTLY_PLATFORM, FastlyLogging, Serve, init_logger, request, runtime_env_config, +}; +use edgezero_core::app::{App, Hooks}; +use my_app_core::App as MyApp; + +fn main() -> Result<(), fastly::Error> { + lifecycle::serve_custom(Serve::new().with_max_requests(10), handle).into_result() +} + +fn handle(req: fastly::Request, sandbox: &mut Sandbox) -> Result<(), fastly::Error> { + if req.get_path() == "/health" { + fastly::Response::from_status(200).send_to_client(); + return Ok(()); + } + let stores = MyApp::stores(); + let mut runtime = runtime_env_config(stores); + sandbox.setup_once(|| { + let logging = FastlyLogging::from(&runtime.env); + if logging.use_fastly_logger && !MyApp::owns_logging() { + init_logger( + logging.endpoint.as_deref().expect("configured endpoint"), + logging.level, + logging.echo_stdout, + )?; + } + Ok::<(), fastly::Error>(()) + })?; + runtime.emit_boot_diagnostics(); + sandbox.initialize(|| App::build::(FASTLY_PLATFORM).map_err(fastly::Error::from))?; + let app = sandbox.state().expect("initialized above"); + let _post_send_state = request::send_request_with_registries_and_hooks( + app, + stores, + req, + &runtime.env, + |_raw, _extensions| {}, + |response| response.status(), + )?; + // Delivery is terminal here; complete any post-send work before returning. + Ok(()) +} +``` + +The fixture's [`custom_dispatch`](https://github.com/stackpop/edgezero/blob/main/tests/fixtures/reusable-app/crates/fixture-fastly/src/lib.rs) +is a fuller reference covering logger setup, handled initialization failure, +response finalization, adapter-owned progressive streaming and post-send work. + +`Sandbox::requests()` counts attempted callbacks, including early health responses +and failed ones. `initialization_attempts()` counts only actual builder calls. +These counters describe this sandbox and are part of the public lifecycle API. +They do not establish client completion or provider reuse. + +Use `lifecycle::run_custom(Request::from_client(), callback)` for the single-request +branch, initializing the Fastly ABI before receiving that request. It creates +fresh state without entering `Serve` and returns the callback's `Result<(), E>` +directly, without asking the SDK to render an error. Use an ordinary `fn main`; +the response-returning SDK entrypoint attribute does not own this delivery lifecycle. + +Keep adapter, core and SDK versions aligned with the pinned checkout. Run the +lifecycle fixtures against the checkout being adopted: + +```sh +./scripts/smoke_test_reusable_app.sh --adapter fastly --suite smoke +``` + +See the [fixture README](https://github.com/stackpop/edgezero/tree/main/tests/fixtures/reusable-app) +for assertions, runtime requirements and evidence files. An unavailable runtime +or missing reuse is unverified and exits 2. Local fixtures do not guarantee +provider reuse, deployed resource lifetimes or application workload gains. +Applications still own refresh, key rotation and bounded caches. + +Warning caches persist too. Their bounded recent-name sets can evict entries, +so warnings can recur; the number of warnings logged is not a failure count. +Dynamic-backend capacity is service-wide, and registrations may wait for capacity +(see Fastly's [dynamic-backend limit behavior](https://www.fastly.com/documentation/reference/compute/errors)). +A sandbox request limit alone does not bound origin diversity or request fan-out. + +The local fixtures are in `tests/fixtures/reusable-app`. Local reuse and streaming +results do not establish deployed eviction frequency, endpoint-handle validity, +resource accounting, or performance. Named endpoint delivery must be verified +separately from echoed stdout. Roll back by restoring the original single-request +entry point and removing retained state, not merely setting a request limit of one. + ## Next Steps - Learn about [Cloudflare Workers](/guide/adapters/cloudflare) as an alternative deployment target diff --git a/docs/guide/adapters/overview.md b/docs/guide/adapters/overview.md index 772318bf..e2c014ac 100644 --- a/docs/guide/adapters/overview.md +++ b/docs/guide/adapters/overview.md @@ -13,12 +13,15 @@ Adapters translate provider-specific HTTP primitives into the portable `App` in ## Request Conversion -Each adapter exposes an `into_core_request` helper that accepts the provider's request type and returns `edgezero_core::http::Request`. The conversion must: +Canonical adapter dispatch normalizes the provider request head, performs admission, +and only then consumes the body under the admitted limits and deadline. Public raw +conversion helpers are not a common adapter contract or a substitute for this lifecycle. +The admitted conversion must: - **Preserve the HTTP method** exactly (`GET`, `POST`, etc.) - **Parse the full URI** (path and query string) into an `http::Uri`. Reject invalid URIs with `EdgeError::bad_request` - **Copy all headers** into the core request. Provider-specific headers may be filtered only when they clash with platform defaults -- **Consume the request body** into an `edgezero_core::body::Body`. Adapters may buffer inbound bodies today; streaming input should be preserved where available +- **Consume the request body** into an `edgezero_core::body::Body` only after admission, enforcing the selected size limit and absolute read deadline - **Insert a provider context struct** (e.g., `FastlyRequestContext`) into the request extensions. The context should expose metadata such as client IP addresses or environment handles so handlers can reach platform APIs ## Response Egress @@ -39,8 +42,8 @@ transport boundary available. Implementations must: Adapters surface a send-owning runner that bridges from the provider event loop into the shared router. It should: -1. Convert the incoming provider request with `into_core_request` -2. Await the router future +1. Normalize the incoming provider request head and run the app's admission policy +2. Consume the admitted body and await canonical app dispatch, or deliver the detached admission response 3. Carry the returned egress envelope into the adapter coordinator 4. Own body delivery, deadline/abort handling, and terminal observation 5. Map precommit failures to the bounded fallback and expose only category-safe diagnostics @@ -82,17 +85,28 @@ exclusions. Consumers upgrading platform resource or response construction APIs ## Logging Initialisation -Each adapter exports an `init_logger` helper for platform-specific logging backends. Fastly wires -`log_fastly`, Cloudflare currently no-ops, Spin no-ops because Spin manages its own logging -internally, and Axum uses `simple_logger` in its `run_app` helper. -New adapters should provide a comparable helper so apps consistently opt into logging. +Fastly wires `log_fastly`, and Axum uses `simple_logger`. Their runners initialize logging +before application configuration. Cloudflare and Spin `init_logger` helpers are no-ops: host +console or stdout/stderr capture does not install a Rust `log` backend. + +`owns_logging = true` skips adapter logger setup completely. Install your backend **before** +entering the adapter runner, apply the configured runtime filter to that backend, and set the +facade maximum high enough for all enabled targets. `edgezero_core::resolve_logging_level(&env)` +resolves the supplied platform environment's runtime level (invalid or absent means `Info`); +it neither installs a logger nor reads manifest defaults implicitly. + +Managed Fastly and Axum backends filter `edgezero_core::BOOT_LOG_TARGET` (`edgezero::boot`) at +`BOOT_LOG_LEVEL` (`Warn`) independently of ordinary records. Consequently boot warnings and +errors still appear with ordinary logging set to `Error` or `Off`. Boot `Info`/`Debug` messages +do not appear. The target is not a physical Fastly endpoint. Application-owned loggers choose +their own boot policy; raising `log::set_max_level` alone cannot restore backend-filtered records. ## Contract Tests To keep the contract enforceable, each adapter includes tests that validate request conversion, response framing, and the owned delivery coordinator: -- `into_core_request` for method, URI, header, body, and context propagation +- canonical admission and request conversion for method, URI, header, body, and context propagation - response-head commit and lazy streamed body writes - absolute deadlines, accepted-byte accounting, abort/close, and exactly-once terminal reports - routing, admission refusal, fallback drain, handler, middleware, and fallback response paths @@ -141,7 +155,7 @@ The `wasmtime` version CI uses is pinned in `.tool-versions`. When bringing up another adapter: 1. **Implement request conversion and an owned response coordinator** that follow the rules above -2. **Provide a context type** exposing the adapter's metadata and insert it in `into_core_request` +2. **Provide a context type** exposing the adapter's metadata and insert it during admitted request conversion 3. **Implement a send-owning runner** plus logging helper 4. **Wire up an `OutboundHttpClient`** with limits, deadlines, batching, and typed errors 5. **Copy the contract test suite**, swapping in the new adapter types. Ensure the tests are gated to the target architecture if the adapter SDK does not compile for native hosts @@ -174,3 +188,72 @@ logical id under both is a collision that `config validate` rejects. Fastly is the only adapter implementing `gc_config_entries` and the staging lifecycle actions (`DeployStaging`, `EmitVersion`, `Healthcheck`, `Rollback`); the others return an unsupported error for those. + +## Opt-in application retention + +Use retention when measurements show that constructing the app is expensive. +Keep per-request construction when `configure` or `app_state` reads values that +can change and no refresh policy has been defined. + +Application ownership and request scheduling are separate. An `App` can be +retained by its caller; each adapter still controls how requests arrive: + +| Adapter | Existing default | Explicit retention | +| ---------- | ---------------------------------------- | ------------------------------------------------------------------------------------------ | +| Fastly | Build for each single-request invocation | `serve_app` / `serve_app_with_hooks` with SDK `Serve`, backed by `lifecycle::Sandbox` | +| Cloudflare | Build on each fetch | Concrete application-owned cache and `dispatch_app` | +| Spin | Build on each invocation | Concrete cache and `dispatch_app` on a compatible host | +| Axum | Build once at server startup | Already retains the app and router | + +The canonical assembly path is `App::build::(PLATFORM) -> Result`. +It installs platform metadata before calling fallible `Hooks::configure` and rejects +successful configuration that changes that metadata. Retain only a successfully +configured app; never cache an initialization error or publish a partial app. +Cloudflare uses `dispatch_app::(&app, req, env, ctx)`; Spin uses +`dispatch_app::(&app, req)` and returns a raw `SpinResponse`. Neither builds an +app nor initializes logging. The caller must pair the app with its construction +`Hooks` type and selected platform. + +Retain owned settings, parsed objects, and bounded caches only when their +staleness policy is acceptable. Keep native request handles, bodies, store +registries, pending operations, and response effects request-local. `Send + Sync` +and cloning do not prove host-resource validity. Shared `Arc` interiors remain +shared; the framework does not reset them between requests. Registered app state +continues to overwrite request extensions of the same type. + +Every dispatch creates fresh admission state and response-egress ownership in +addition to fresh registries. Fastly's preparation/finalization hooks borrow +request-local values; the adapter owns sending and lazy streaming. Retention does +not expose a response-returning Fastly dispatch path or add response buffering. + +Applications own refresh, invalidation, key rotation, initialization-failure +policy, and workload benchmarks. Publish complete snapshots; overlapping requests +keep the snapshot they acquired. Never put an unkeyed static in a generic cache +function: that static would be shared between application types. Existing macros, +manifest settings, and generated entry points retain their lifecycle defaults. +The Cloudflare response-header correction applies to both default and retained apps. + +### Preparing an application for retention + +Audit the values captured by handlers, middleware, and registered state before +opting in. The framework creates fresh request resources but cannot inspect or +clear application-owned interiors. + +| Risk | Application mitigation | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Request data survives into another request | Store identity, authorization results, bodies, and correlation IDs in request-local values or extensions. Use distinct types for request data and registered state; registered state wins on a type collision. | +| Settings or parsed keys become stale | Keep them request-scoped until a refresh policy is defined. For retained values, build and validate a complete replacement snapshot, then publish it atomically. Decide whether refresh failure keeps the last valid snapshot or rejects requests. | +| Caches grow without bound | Limit entries and retained bytes, expire or evict entries, and bound origin diversity. A sandbox request limit cannot bound allocation within one request. | +| Concurrent requests mutate shared state | Synchronize mutations and acquire an immutable snapshot per request. Release locks before awaiting provider work. Rust's `Send + Sync` bounds do not establish application-level isolation. | +| Initialization or required bindings fail | Surface the error and monitor repeated fresh-instance failures. In custom dispatch, convert a recoverable failure into one response only when the application defines that recovery policy. Bound retries, e.g. with Fastly `Serve` limits. | +| Logging is installed twice | Choose one owner. With the Fastly helper, set `Hooks::owns_logging()` when application code installs the logger. First-snapshot logging is intentionally fixed; use custom dispatch for another policy. | +| A stream fails after commitment | Finish or drop the streaming writer and settle request-owned pending work. Log the partial-response failure; do not attempt a replacement response after sending headers. | + +Exercise cancellation as well as successful requests. The core regression tests +drop a suspended request, verify its extension resources are released, and serve +a fresh request through the same router. This does not cancel application-spawned +background tasks or prove cleanup after a terminating provider trap. + +Roll out retention only after the application's isolation, refresh, and bounded +memory workload checks pass. Provider eviction can force initialization on any +request, so correctness must not depend on reaching the configured reuse limit. diff --git a/docs/guide/adapters/spin.md b/docs/guide/adapters/spin.md index 815c0e1d..b4a42300 100644 --- a/docs/guide/adapters/spin.md +++ b/docs/guide/adapters/spin.md @@ -275,14 +275,16 @@ surfaces before `provision` / `config push` run. ## Logging -`edgezero_adapter_spin::init_logger()` is a no-op today because Spin manages its own -logging internally; `run_app` calls it unless your app sets `owns_logging = true`. An +`edgezero_adapter_spin::init_logger()` is a no-op; `run_app` calls it before application +configuration unless your app sets `owns_logging = true`. An `[adapters.spin.logging]` table in `edgezero.toml` is not consumed by the Spin runtime. View output through `spin up` or your Spin host's log files. ::: tip Logging status -Spin logging is handled by the runtime; install your own `log` implementation in the -entrypoint if you need structured output. +Host stdout/stderr capture does not install a Rust `log` backend. Install yours before the +adapter entrypoint and set `owns_logging = true` if you need facade output. Resolve ordinary +levels with `edgezero_core::resolve_logging_level(&EnvConfig)` and apply both backend and facade +filters; the adapter does not promise boot-target visibility without that backend. ::: ## Context Access @@ -325,6 +327,64 @@ The tests execute the adapter's real `wasm32-wasip2` request path under Wasmtime Configure the Spin adapter in `edgezero.toml`. See [Configuration](/guide/configuration) for the full manifest reference. +## Retaining an application + +Use a concrete application-owned `OnceLock` and call +`edgezero_adapter_spin::dispatch_app::(app, req).await` on each +invocation. This resolves fresh request resources from `MyApp::stores()` and does +not install logging, construct an app, or cache native resources. Initialize any +application logging before the cache's synchronous app constructor. Do not use a +generic unkeyed static or hold a lock across an await. + +```rust +use edgezero_adapter_spin::{SPIN_PLATFORM, SpinResponse}; +use edgezero_core::{app::App, EdgeError}; +use my_app_core::App as MyApp; +use spin_sdk::{http::Request, http_service}; +use std::sync::OnceLock; + +static APP: OnceLock = OnceLock::new(); + +fn retained_app() -> Result<&'static App, EdgeError> { + if let Some(app) = APP.get() { + return Ok(app); + } + let app = App::build::(SPIN_PLATFORM)?; + Ok(APP.get_or_init(|| app)) +} + +#[http_service] +async fn handle(req: Request) -> anyhow::Result { + let app = retained_app()?; + edgezero_adapter_spin::dispatch_app::(app, req).await +} +``` + +`App::build::(SPIN_PLATFORM)` returns `Result` and invokes +fallible `Hooks::configure` after installing platform metadata. For Akamai Functions, +select `AKAMAI_FUNCTIONS_PLATFORM`; other hosts can supply metadata matching their +configured limits. Only a successful app is cached, so a failed build can be retried. +Concurrent construction attempts may build multiple candidates; only one is retained. +The app must be built from the same `Hooks` type used by dispatch. + +`dispatch_app(&App, SpinRequest) -> anyhow::Result` returns +the raw WASI response, not a collected SDK response. Each invocation resolves fresh +registries and runs admission and response egress; the adapter's coordinator owns +the body writer and transmission-result observation through terminal delivery. + +The pinned SDK 7 macro exports a P3 HTTP interface. The `wasm32-wasip2` Rust target +name does not establish the component's HTTP lifecycle. Verify the emitted +interface and host together. See Spin's +[instance-reuse documentation](https://spinframework.dev/v4/http-trigger#controlling-instance-reuse) +for the reuse and concurrency controls your host version supports. + +Test both sequential reuse and overlapping invocations in the same instance. +Different guest instances are not evidence of concurrent isolation. Keep +request/response bodies, admission resources, delivery coordinators, and pending +operations invocation-local. Standard response delivery remains lazy under host +backpressure; retention adds no whole-response buffer or collection cap. Restore `run_app` +and remove the cache to roll back; default generated entry points are unchanged. + ## Next Steps - [Migration guide](/guide/manifest-store-migration) — moving from the diff --git a/docs/guide/blob-app-config-migration.md b/docs/guide/blob-app-config-migration.md index fdd25684..6b927a7f 100644 --- a/docs/guide/blob-app-config-migration.md +++ b/docs/guide/blob-app-config-migration.md @@ -99,7 +99,11 @@ with the same data produce blobs with identical `sha256` and different The push writes to `.edgezero/local-config-.json` next to your `edgezero.toml`. The file is a JSON map of `{ "": "" }`; -the dev server reads the envelope through `AxumConfigStore::get`. There +the dev server reads the envelope through a shared `ConfigValue` snapshot loaded +before listener bind. Push before startup or restart the server afterward. The +raw `ConfigStore` API now returns `ConfigValue`; custom store implementations must +migrate both read methods atomically. Borrow its UTF-8 bytes with `as_str()` and +reserve `to_string()` for deliberate ownership copies. There is no `--local` flag because Axum's push IS always local. ### Cloudflare diff --git a/docs/guide/capabilities.md b/docs/guide/capabilities.md index 6705c6e7..aede975b 100644 --- a/docs/guide/capabilities.md +++ b/docs/guide/capabilities.md @@ -101,6 +101,13 @@ information they receive, but those checks do not prove parser-boundary accounti ambiguous HTTP/1 framing rejection. Neither raw-ingress capability is currently available on any adapter. +Axum additionally enforces a finite adapter-owned HTTP/1 connection pool, combined raw-head +byte and field-count limits, and absolute idle-plus-header timeouts before admission. Its +[bounded HTTP/1 ingress configuration](/guide/adapters/axum#bounded-http1-ingress) publishes the +effective limits, accept-loop overshoot, parser storage assumptions, and response-header cache +safety policy. These controls do not supply independent raw-target/field-section accounting or +the strict framing policy, so they do not promote either raw-ingress capability above. + Admission is body-blind. An application may opt into `ReadBodyBeforeFallback` for pre-resolved 404/405 requests when it needs body-limit precedence: clean EOF at or below the selected cap retains the canonical 404/405, the first byte over returns the application-selected buffered @@ -126,6 +133,54 @@ retains or processes a returned value. That does not bound provider-side materia parser allocation, SDK copies, or other work performed before the value reaches guest code, so `config-read-allocation-bounds` remains `Unsupported` everywhere. +Axum's local config-store path now has [bounded startup snapshots](/guide/adapters/axum#snapshot-allocation-limits): +actual file reads, flat-string parsing, per-key/value and entry caps, aggregate snapshot +residency and startup staging are enforced before listener binding. Request reads clone +shared `ConfigValue` handles without copying payloads. This narrower guarantee does not +certify environment-backed secrets, fixed registry metadata, allocator overhead or all +concurrent extraction allocations, so the broad capability is not promoted. + +Cloudflare bounded config reads request KV byte streams and use reusable 16 KiB Rust/JS +BYOB buffers. With `L = min(max_backend_bytes, max_value_bytes)`, the requested payload +storage is bounded conservatively by `2L + 32 KiB`, including accumulator relocation or +the final shared-string copy. Invalid UTF-8 is rejected, not lossily expanded. There is no +text/array-buffer fallback. Overflow, timeout and caller cancellation cancel an acquired +reader; cancellation while waiting for KV acquisition cannot cancel the provider promise. +An acquisition owner cancels any stream that arrives after abandonment. Cancel-promise +rejections are consumed without publishing provider diagnostics. Ready reads check the +application clock before and after each pull and yield to the task queue every 16 chunks. +Bounded KV reads require `FinalizationRegistry` and fail before provider dispatch without it. +Generated/demo Workers explicitly enable `enable_weak_ref`; GC timing remains nondeterministic, +not part of the payload charge. See [Workers compatibility flags](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#enable-finalizationregistry-and-weakref). +Opaque KV buffering, JS wrapper/GC retention and host teardown remain unqualified. + +Fastly bounded config reads use the pinned host ABI with one initialized caller-sized +buffer, never retrying a `BUFLEN` result with the provider's requested length. Physical +reads have an adapter ceiling of 32,000 bytes. Root reads allow at most +`R = min(max_backend_bytes, max(max_value_bytes, 8000), 32000)`; pointer JSON is limited to +8,000 bytes and chunk reads to 7,000 bytes and both remaining allowances. Validated +declared totals must fit before any chunk fetch or reassembly allocation. The bounded +resolver verifies chunk/transport hashes and layout without constructing an envelope +`Value`; the extractor performs inner-envelope verification after its structural scan. +A conservative requested guest-payload charge is `2V + R + 16P + 7000` bytes, with +`V = max_value_bytes`, `P = 8000`, plus fixed objects and allocator overhead. This includes +pointer/schema/index scratch, one chunk, reassembly and the shared-string copy; provider +storage and synchronous-call interruption are excluded. Raw `get` and CLI integrity +operations are not allocation-certified. + +Spin's list-valued host get still materializes a complete value. Stock Spin 4.1.0 has no +configurable read-size cap in its KV interface; Akamai's published 1 MB default value quota +does not establish an exact read-allocation or overridden-quota contract. Neither is used +as a guessed allocation guarantee. A bounded host/backend interface or verified hosted +enforcement is still required. + +The response-egress specification's evidence matrix +(`docs/superpowers/specs/2026-09-08-response-egress-design.md`, section 8) +distinguishes local ownership proofs from deployed lifecycle qualification. Opt-in fixtures and +`scripts/qualify_response_egress.mjs` collect correlated terminal/source/resource evidence; +they cannot certify peer delivery, same-instance recovery or provider allocation. No deployed +artifact has been supplied for the current hosted adapters, so their guarantees are not promoted. + Config reads use one absolute extraction deadline in the application's monotonic clock domain. Core passes that same clock through every root, Fastly chunk, and secret read. Adapters sample it before provider dispatch and after readiness, before propagating either success or provider @@ -143,8 +198,9 @@ defines the normative accounting, cancellation, error, and promotion requirement Application assembly is fallible. `Hooks::configure(&mut App) -> Result<(), EdgeError>` runs after the runtime adapter installs `PlatformMetadata` and before EdgeZero request conversion, body polling, or dispatch. The hard-cut, core-owned `App::build::(platform)` constructor requires -every caller to choose target metadata explicitly, cannot be replaced by application hooks, and -propagates the configuration result. Axum also completes assembly before binding its listener. +every caller to choose target metadata explicitly and propagates the configuration result. After +successful configuration, core rejects changed platform facts, provenance, or unknown reasons +rather than silently restoring them. Axum also completes assembly before binding its listener. ## Response Egress Matrix @@ -324,6 +380,8 @@ guarantees all slots are present. Only `Cutoff` permits `None` in a successful r adapter failure, premature EOF, duplicate indices, and out-of-range indices return `OutboundBatchFailure`: its precise batch-level error remains separate from cutoff, and its index-aligned slots preserve sibling outcomes observed before the failure. +Normal completion requires clean driver EOF after the last item, not merely the expected item +count. Trailing driver failures and malformed events remain visible, including on empty batches. `elapsed` starts at the single method-entry monotonic snapshot and ends when that slot becomes terminal, including preflight validation, adapter setup, provider queueing, upload, headers, buffered body drain, and any delayed guest observation. It is not pure transport RTT. diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 55f01979..849a8afb 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -274,6 +274,10 @@ non-zero with a one-line diagnostic on error. ### edgezero config validate +`config validate` checks every adapter declared in the manifest and compiled +into this CLI. With `--strict`, it checks that adapter set's capability limits. +Spin secret-reference validation reports fields and rules without printing values. + Validate `edgezero.toml` together with the typed `.toml` app config (see [Application config](/guide/configuration#application-config)). @@ -324,7 +328,13 @@ flags and exits `2` with a pointer to the typed CLI — it cannot push (see **Arguments:** -- `--adapter ` — target adapter (`axum`, `cloudflare`, `fastly`, `spin`). +- `--adapter ` — target adapter (`axum`, `cloudflare`, `fastly`, `spin`), + matched case-insensitively. Adapter-specific checks run only for ``: + app-config key syntax, adapter-manifest validation, merged store-id + collisions, `#[secret]` reference rules, and the `--strict` capability checks + (push always runs in strict mode). Shared schema, secret-presence and strict handler-path checks still apply. A + passing push does not prove portability; run `config validate --strict` in CI + for that. - `--manifest ` — manifest path (default: `edgezero.toml`). - `--app-config ` — typed app-config path (default: `.toml` next to the manifest). - `--store ` — logical config-store id to push to. Defaults to `[stores.config].default` (or the only declared id when `[stores.config].ids` has length 1). @@ -397,7 +407,12 @@ stub that absorbs its flags and exits `2` with a pointer to the typed CLI. **Arguments:** -- `--adapter ` — target adapter (`axum`, `cloudflare`, `fastly`, `spin`). +- `--adapter ` — target adapter (`axum`, `cloudflare`, `fastly`, `spin`), + matched case-insensitively. Adapter-specific checks run only for ``: + app-config key syntax, adapter-manifest validation, merged store-id + collisions, and `#[secret]` reference rules. Shared schema and secret-presence + checks still apply. `diff` runs no `--strict` capability checks; a clean diff + does not prove portability. Run `config validate --strict` in CI for that. - `--manifest ` — manifest path (default: `edgezero.toml`). - `--app-config ` — typed app-config path (default: `.toml` next to the manifest). - `--store ` — logical config-store id to diff against. Defaults to `[stores.config].default` (or the only declared id when `[stores.config].ids` has length 1). diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index d3ccd9f5..6fb94807 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -253,6 +253,13 @@ handlers can call `ctx.config_store("app_config")` (or Treat config-store keys like API surface: validate or allowlist any user-controlled lookup before calling `ctx.config_store_default()?.get(...)`. +Raw `ConfigStore::get` and `get_bounded` return shared `ConfigValue` UTF-8 payloads, +not `String`. Cloning shares one value's allocation; borrow with `as_str()` or +`as_ref()`, or explicitly allocate with `to_string()` at an ownership boundary. +`Debug` output is redacted. Typed `AppConfig` fields are unchanged. Axum loads +[bounded immutable snapshots](/guide/adapters/axum#snapshot-allocation-limits) +before listener bind; push updated local config before startup or restart afterward. + ## Application config `edgezero.toml` describes the _shape_ of the app — routes, adapters, @@ -533,7 +540,15 @@ Logging can be configured per adapter under `[adapters..logging]` or via a | `level` | All | Log level: `trace`, `debug`, `info`, `warn`, `error`, `off` | | `echo_stdout` | Fastly, Axum | Mirror logs to stdout | -Note: Cloudflare logging is not wired to a built-in logger yet. +Manifest logging settings and runtime `EDGEZERO__LOGGING__*` overrides are separate; consult +the adapter guide for which source its runner consumes. Cloudflare and Spin do not install a +Rust `log` backend. `owns_logging = true` transfers installation **and filter configuration** +to the application, before entering the runner. The pure +`edgezero_core::resolve_logging_level(&EnvConfig)` helper resolves runtime levels without +implicitly applying manifest defaults or changing global state. + +Managed Fastly/Axum loggers retain `edgezero::boot` errors and warnings at `Warn` even when +ordinary runtime logging is `Off`. See [Logging Initialisation](/guide/adapters/overview#logging-initialisation). ## Full Example @@ -691,8 +706,8 @@ edgezero_core::app!( ``` The `state` expression is emitted inside the generated `build_router()`, which -runs once per request on Fastly Compute, so keep it cheap. Build the heavy value -once and hand out clones. +runs once per request on the default Fastly, Cloudflare and Spin entry points, so +keep it cheap. Build the heavy value once and hand out clones. See [Sharing app state](/guide/handlers#sharing-app-state) for how handlers read `state`, and [Owning your own logging](/guide/adapters/fastly#owning-your-own-logging) diff --git a/docs/guide/dynamodb-kv.md b/docs/guide/dynamodb-kv.md new file mode 100644 index 00000000..f4a1603d --- /dev/null +++ b/docs/guide/dynamodb-kv.md @@ -0,0 +1,141 @@ +# Native DynamoDB KV + +DynamoDB is an optional live KV provider for native Axum apps. It uses the existing +`Kv` extractor, `KvHandle` and per-ID registry. Config and Secrets Manager stores +remain independent startup snapshots. DynamoDB calls happen during KV operations, +not only at startup. + +## Select a store + +Enable `aws-dynamodb` on the generated Axum package, for example in +`edgezero.toml`: + +```toml +[adapters.axum.build] +features = ["aws-dynamodb"] +``` + +`aws-stores` retains its existing AppConfig Agent and Secrets Manager membership. +Use `aws-stores,aws-dynamodb` to enable all three. DynamoDB is off by default and +its SDK is absent from settings-only, ordinary CLI/Axum, Agent-only and WASM +normal dependency graphs. + +Declare a portable KV ID in the application as usual, then select its native +provider in an absolute bindings file: + +```toml +version = 1 + +[kv.state] +provider = "aws-dynamodb" +region = "us-east-1" +table = "edgezero-kv-prod" +namespace = "checkout-prod/state" +``` + +Set `EDGEZERO__STORE_BINDINGS_FILE` to that file. Selection is explicit. A missing +feature, invalid binding or unavailable table fails before the listener opens. +There is no fallback to redb or another provider. A deployment with no local KV +IDs does not need `DATA_DIR`. Existing supplied/ready/deferred KV handles still +work and must not conflict with a binding for the same ID. + +The SDK uses its supported operator-owned credential chain, including workload +roles and configured developer profiles. Do not put credentials in bindings, +images or application config. Region/table/namespace are explicit, and the +binding schema has no endpoint field. SDK profile/environment endpoint settings +remain operator-owned. Structural CLI validation makes no AWS request and reports +provider availability as unknown, not an offline Cargo proof. + +## Operator-owned table + +Create the table outside EdgeZero, in the selected region. The first version +requires an ACTIVE, single-region table with no secondary/vector indexes or +replicas, and exactly these String keys: + +| Attribute | Role | +| --------------- | ---------------------------------------------------------- | +| `pk` | Partition key, the configured physical namespace | +| `sk` | Sort key, the exact portable UTF-8 key | +| `v` | Non-key Binary value, including empty values | +| `expires_at_ms` | Optional Number, logical Unix epoch millisecond expiration | +| `ttl_epoch_s` | Optional Number, background cleanup timestamp | + +Enable DynamoDB TTL on `ttl_epoch_s` for garbage collection. The provider does not +create or mutate the table or its TTL setting. Disabled cleanup retains expired +records and storage costs, although reads/listings still treat them as missing. + +Grant the runtime role only `dynamodb:DescribeTable`, `dynamodb:GetItem`, +`dynamodb:PutItem`, `dynamodb:DeleteItem` and `dynamodb:Query` on the reviewed table +ARN. Restrict `dynamodb:LeadingKeys` to approved namespaces where appropriate. +Table creation, schema/TTL configuration and other publication permissions belong +to the operator role, not the application. The bounded startup DescribeTable +checks shape/status, not write permissions, workload capacity or deployed IAM +correctness. + +Physical namespaces must be nonempty, control-free and at most 512 UTF-8 bytes. +Different namespaces isolate data in a shared table. Two bindings to the same +region/table/namespace deliberately share data. Choose an app/environment-specific +namespace rather than a bare common logical ID. One namespace can become a hot +partition; this provider does not shard or certify throughput. + +## Semantics and limits + +- Keys retain their exact UTF-8 bytes and portable 512-byte limit. No normalization + or case folding occurs. Prefix listing uses Query, never a whole-table Scan. +- Payloads are limited to **384 KiB**, below DynamoDB's 400 KiB whole-item ceiling. + The portable 25 MiB validation ceiling does not override this backend limit. + Excess input returns a fixed validation error before I/O. There is no chunking, + S3 fallback or truncation. +- Put replaces the whole item. Put without TTL removes prior expiration metadata. + TTL uses millisecond logical expiry and rounded-up seconds for service cleanup. + Get/Exists/List check logical expiry after the response. Reads do not delete an + expired item, avoiding a race with renewal. Synchronize application host clocks. +- Get and Query request strong consistency in the configured region. Pages are + not snapshots across calls. Concurrent mutations can affect later pages, and + `KvHandle::read_modify_write` remains non-atomic. There is no CAS, lock, atomic + counter or distributed identity-graph guarantee. +- Each list call makes one Query with an evaluated-item limit. DynamoDB's 1 MiB + evaluated-data boundary and expired rows can produce short or empty pages with + a cursor. Continue until `cursor` is absent, not until `keys` is empty. Cursors + are bounded and scoped to region/table/namespace/prefix. They are not secret or + authenticated tokens and must not be logged. +- One SDK attempt is made per operation, with no application retry loop. A timeout + or error can follow a committed Put/Delete. Retrying from application code can + overwrite or delete a concurrent writer's newer state. No exactly-once claim is + made. Missing/expired items alone are successful misses; provider failures are + errors, never misses. +- Default budgets are 5 seconds per operation, 1 second connect and 2 seconds per + attempt. A binding's strict `[kv.state.limits]` table can lower the payload cap + and set ordered positive timeout values. Runtime calls receive fresh deadlines; + the startup deadline is not reused after readiness. Dropping a future does not + guarantee termination of credential subprocesses, DNS or socket work. + +```mermaid +flowchart TD + Binding[Validate every source and compiled capability] --> Setup[Bounded client and table setup] + Setup --> Registry[Complete registry and application initializer] + Registry --> Listener[Open listener] + Listener --> KV[Handler uses portable KvHandle] + KV --> SDK[One bounded live DynamoDB operation] +``` + +## Diagnostics and verification scope + +EdgeZero-owned errors/debug output do not retain SDK response details or raw +keys, values, credentials or cursors. **Dependency DEBUG/TRACE is not suppressed.** +DynamoDB SDK model Debug can expose item keys and values, including binary bytes, +without Secrets Manager's sensitive-output protections. Review application logging +filters before enabling SDK diagnostics. The provider does not install subscriber +suppression or change global logging. Do not assume `LOG_SENSITIVE_BODIES=false` +redacts DynamoDB model Debug. + +Default verification uses deterministic fixtures and the actual SDK wire path +with explicit dummy credentials and literal-loopback endpoints. CI also runs a +required [independent local-service suite](./native-aws-stores#account-free-integration-tests) +against DynamoDB Local, including base-table Query pagination across the 1 MiB +evaluation boundary. Neither suite proves AWS IAM, capacity, deployed service +behavior or global-table correctness. Real AWS acceptance requires separate +authorization and disposable-resource ownership. + +See [native AWS stores](./native-aws-stores) for captured bootstrap and startup +composition. diff --git a/docs/guide/handlers.md b/docs/guide/handlers.md index 7ab05520..bb7ca82f 100644 --- a/docs/guide/handlers.md +++ b/docs/guide/handlers.md @@ -326,8 +326,11 @@ app-state struct and register that. > **Make `app_state()` cheap.** The macro emits the `state` expression inside the > generated `build_router()`, which each adapter's `run_app` calls through -> `EdgeZeroApp::build::(platform)` — **once at startup** for long-lived runtimes (Axum), but -> **once per request** on Fastly Compute (each request is a fresh Wasm instance). +> `App::build::(platform)` — **once at startup** for Axum, but **once per +> invocation** for the default Fastly, Cloudflare, and Spin entry points. +> Opt-in retained paths build only until an app is successfully retained; host +> eviction can require fresh initialization on any request. A reused Fastly +> sandbox does not imply that the app is retained unless the entry point chooses it. > So the `state` expression runs on that cadence: build heavy state **once** and > hand out clones (e.g. a `OnceLock>` as above, or a `static`), and > let `T = Arc` so each call is just a refcount bump. Do **not** `Arc::new(HeavyThing::build())` directly in the `state` expression. @@ -371,6 +374,14 @@ callback cheap for the same adapter-lifecycle reasons as `state = ` above. error prevents EdgeZero request conversion, body polling, and dispatch; Axum also fails before binding its listener. +`App::build::(PLATFORM) -> Result` is the canonical assembly +path. It installs the selected platform before calling `Hooks::configure` and +rejects a successful configuration that replaces that metadata. Publish or retain +only the completed successful app, never an initialization error. Retaining the app +does not retain request admission grants, registries, bodies, or response-egress +resources; those are fresh for every dispatch. See +[application retention](/guide/adapters/overview#opt-in-application-retention). + Use the synchronous error renderer when framework and handler errors must use an application-owned, fixed-size wire body: diff --git a/docs/guide/native-aws-stores.md b/docs/guide/native-aws-stores.md new file mode 100644 index 00000000..10253c9e --- /dev/null +++ b/docs/guide/native-aws-stores.md @@ -0,0 +1,286 @@ +# Native AWS stores + +This guide is a draft for the first native implementation. It covers two opt-in providers: +AppConfig Agent for complete configuration documents and Secrets Manager for complete secret +values. They can be selected independently per logical store ID. They do not apply to Fastly, +Cloudflare, or Spin builds, and they do not add write, publish, provision, or rotation commands. + +A separate [DynamoDB KV provider](./dynamodb-kv) adds live mutable KV operations through the +independent `aws-dynamodb` feature. It does not change either snapshot provider or the +existing `aws-stores` alias. + +These two providers read at process startup and keep immutable in-memory snapshots. Restart the +application to adopt a new config document, secret value, or moved version stage. A running +process keeps secrets already loaded even after remote deletion or access revocation. + +## Build the providers you use + +Generated native targets can opt into either service: + +```toml +[adapters.axum.build] +features = ["aws-appconfig-agent", "aws-secrets-manager"] +``` + +`aws-stores` is a convenience alias for both. Config-only apps need not include the Secrets +Manager SDK. Secret-only apps need not include the AppConfig Agent HTTP client. All features +are off by default. A bindings file cannot enable a service that the application binary did +not compile. + +CLI bindings validation is structural and offline: + +```sh +edgezero config validate \ + --adapter axum \ + --store-bindings /etc/my-app/native-stores.toml \ + --target-features aws-appconfig-agent,aws-secrets-manager +``` + +The bindings path must be absolute. Target controls include +`--target-all-features`, `--target-no-default-features`, and `--target `. If Cargo's +resolved target feature graph is unavailable or ambiguous offline, the command reports +structural validation only and says provider availability is unknown. This check does not +load credentials, contact AWS or the Agent, prove resource access, or validate remote +contents. Runtime startup validates the actual selected inputs again. Set +`EDGEZERO__STORE_BINDINGS_FILE=/etc/my-app/native-stores.toml` before starting the generated +native target. Its entrypoint selects strict startup when this locator is present. Without +it, the generated target keeps its existing local development behavior. An empty or invalid +selected locator fails startup rather than selecting local stores. + +## Declare portable stores + +Keep provider resources out of `edgezero.toml`. The application declares logical IDs and +defaults, not AWS resources: + +```toml +[stores.config] +ids = ["application"] +default = "application" + +[stores.secrets] +ids = ["runtime"] +default = "runtime" +``` + +A deployment selects providers in a separate native file. This example uses fictional +resource names and account `111122223333`: + +```toml +version = 1 + +[config.application] +provider = "aws-appconfig-agent" +default_key = "settings" +endpoint = "http://127.0.0.1:2772" +access_token_env = "APPCONFIG_AGENT_TOKEN" + +[config.application.documents.settings] +application = "checkout" +environment = "production" +profile = "checkout-envelope" + +[secrets.runtime] +provider = "aws-secrets-manager" +region = "us-east-1" + +[secrets.runtime.entries.signing_key] +secret_id = "arn:aws:secretsmanager:us-east-1:111122223333:secret:checkout-signing-AbCdEf" +version_stage = "AWSCURRENT" +``` + +The schema rejects unknown fields, unsupported provider tags, empty mappings, undeclared IDs, +and conflicting selectors. The names are case-sensitive. Every configured mapping is required +during preparation. A missing mapped document or secret fails startup. There is no fallback to +`.edgezero/` or an environment secret. An unmapped lookup returns a normal miss and never +triggers another AWS request. Runtime errors use closed, fixed categories; they do not retain +SDK/HTTP error chains or raw resource values in EdgeZero-owned diagnostics. + +Use exactly one selector for each secret: no selector means `AWSCURRENT`; set `version_stage` +or `version_id` to pin another version. Do not set both. Supply a complete secret ARN and an +explicit region. The file contains identifiers and environment variable names, never +credentials, tokens, or secret values. Protect it as deployment configuration nonetheless. + +## Start the AppConfig Agent + +EdgeZero talks to a local Agent HTTP endpoint. It does not start or supervise the Agent, poll +AWS AppConfig, or run AppConfig Data session-token logic. The Agent owns its AWS role, region, +AWS polling, and cache. The application makes one GET per distinct mapped profile at startup. + +Run the Agent in the same network namespace as the application and configure it to bind to +`127.0.0.1`. The provider accepts only literal loopback IP endpoints, so endpoint hostnames do +not trigger DNS. It rejects redirects, proxy use, and non-identity content encoding. It sends +`Accept-Encoding: identity`. When the binding names a token variable, startup reads it once and +sends `Authorization: Bearer `. A missing or empty configured token fails startup. +Configure the Agent's `ACCESS_TOKEN` to the same value. Never put the token in a URL or the +bindings file. + +Pin the operator-reviewed Agent image by digest in deployment configuration, for example +`public.ecr.aws/aws-appconfig/aws-appconfig-agent@sha256:`. The +placeholder is not a usable image reference. Before publishing this guide, the operator must +select the exact Agent release, verify its image digest through the approved artifact process, +and record both in deployment documentation. Do not replace the placeholder with an +unverified or invented digest. Disable Agent disk backup/preload for this setup; this does not +make its in-memory cache fresh or prove that AWS was reachable when the application starts. A +successful read proves only that the Agent supplied acceptable bytes. + +Do not expose the Agent endpoint to untrusted processes. If processes share its network +namespace, require token authentication. A token does not isolate the endpoint from a +compromised application process. + +## Preserve complete values + +An AppConfig mapping returns the profile's entire UTF-8 document. It does not select a JSON +field, parse or reserialize a document, unquote a JSON string, or resolve secret references. +A raw profile containing `hello` returns those exact characters. A profile containing a +serialized EdgeZero `BlobEnvelope` returns that serialization unchanged; core validates the +envelope when the app loads typed config. + +For example, a free-form profile can contain one serialized envelope: + +```json +{ + "version": 1, + "generated_at": "2026-10-07T12:00:00Z", + "sha256": "", + "data": { "message": "hello", "api_token": "signing_key" } +} +``` + +The `api_token` field in this example is a secret reference name, not a token value. The +application's typed config must declare the appropriate `#[secret]` field and must explicitly +check required config and secret references during its startup initializer before readiness. +The provider does not know which values the application requires. It does not claim that +startup checks or application-specific validation have run merely because snapshots loaded. + +Secrets Manager returns each configured value in full. `SecretString` is kept as its exact +UTF-8 bytes; the SDK's `SecretBinary` is already decoded bytes and is not base64-decoded a +second time. JSON secrets remain whole JSON byte values. Typed string references still need +UTF-8; byte-oriented consumers can retain binary values. + +## Startup flow + +```mermaid +flowchart TD + Bindings[Capture explicit bindings and named token values] --> Validate[Validate every source and actual compiled providers] + Validate --> Prepare[Prepare config and secret snapshots sequentially in one session] + Prepare --> Registries[Publish the complete registries] + Registries --> Initialize[Run the application-owned startup initializer] + Initialize --> Listen[Bind listener and report readiness] + Listen --> Requests[Serve from immutable snapshots without provider requests] +``` + +The generated entrypoint prepares provider snapshots but does not invent application-specific +startup checks. To require typed config and nested secrets before listening, replace its native +main with `run_production_app_with_initializer::(...)`. The callback receives +`&mut App` and `&PreparedStores`; use core `AppConfig::::from_binding` with the selected +config binding, secret registry, extraction limits and app clock. Insert validated state with +`app.insert_state(...)`. The callback may borrow both arguments and need not be `Send`. The +framework waits for it before binding. The generated-consumer integration test exercises this +public callback, a complete envelope and nested secret resolution against loopback fixtures. + +## Limits and lifecycle + +The shared AWS preparation session defaults to an 8 MiB cap over unique retained config and +secret payload bytes. The limit is per standard runner preparation session, not process-wide +and not a bound on RSS. Separate sessions, caller-supplied handles, local providers, SDK +response allocations, credential providers, TLS buffers, typed application state, and allocator +overhead are outside this cap. Config documents and secret entries also have per-value and +mapping-count limits. Treat the 8 MiB value as a configurable provisional default, not an AWS +service limit or a measured workload requirement. + +Preparation is sequential in the initial implementation. It has finite operation and aggregate +budgets, but dropping a future does not prove that credential discovery, a subprocess, or every +socket operation stopped. A startup failure drops the runner's staged snapshot and stops before +readiness. Use the process supervisor for restart policy and backoff. + +Config values are snapshots by mapped document. The snapshots do not promise a coordinated +revision across profiles or across config and secrets. If multiple documents must change +atomically, place them in one envelope or have application-owned release validation reject +mismatched revisions. Restart to adopt changes. + +A running process retains loaded secrets until it exits. Remote deletion, rotation, or revoked +access does not erase those bytes. Restart to adopt the selected version or remove a secret. +`Bytes`/`Arc` ownership does not promise memory zeroization. Do not log values, ARNs, tokens, +credential sources, or response bodies. + +**Dependency logging limitation.** EdgeZero-owned startup errors and diagnostics use redacted +categories, but the first version does not suppress DEBUG/TRACE events emitted by the AWS SDK +and its dependencies. Those events can expose secret ARNs, credential-source metadata, and, +when `LOG_SENSITIVE_BODIES=true`, response details. Scoped suppression was not adopted because +it can permanently disable an application's tracing-to-log fallback. Keep AWS dependency +`DEBUG`/`TRACE` off in production unless operators have reviewed their logger, sinks, and +sensitive-body settings. This limitation must be called out in reviews of the first version. + +## Account-free integration tests + +CI runs the existing SDK loopback fixtures and a required independent-service +suite. Run the latter locally with Docker, Node.js and the repository Rust +toolchain, after fetching the locked Cargo dependencies: + +```sh +node scripts/run_native_aws_integration.mjs +``` + +The Node runner starts digest-pinned DynamoDB Local, Moto and the actual AWS +AppConfig Agent in local development mode. It creates disposable resources, +builds one CLI-generated Axum application with all three providers, and removes +its containers, network and temporary files afterward. Missing Docker, service +startup failures and zero executed tests are errors, not skips. Fixture +administration uses a dependency-free Node.js helper against the published +loopback endpoints. No Python helper or npm installation is needed. + +The suite checks exact binary/config/secret bytes, per-ID namespace isolation, +base-table Query continuation across the 1 MiB evaluation boundary, logically +expired rows that remain stored, TTL renewal/reset, startup rejection before +listening, typed initialization, and persistence/snapshot behavior across +application restarts. It waits until the real Agent independently serves updated +files before checking that the running app retains its old snapshot. + +Image acquisition requires network access, but the suite does not require an AWS +account. Generated application children use cleared environments, empty AWS +profile files, dummy credentials, disabled metadata discovery and validated +literal-loopback endpoints. Published container ports bind only to `127.0.0.1`. +The Docker bridge is not a hard outbound-network sandbox. These tests do not +establish deployed IAM/KMS permissions, capacity, production consistency or AWS +TTL cleanup timing. Agent local development mode does not exercise AWS polling, +deployment validation or rollback. + +Moto 5.2.2 has a verified limitation: manually moving `AWSCURRENT` updates its +labels but not its no-selector `GetSecretValue` result. The suite therefore tests +default-version adoption through normal `PutSecretValue`, and independently tests +explicit named-stage movement and pinned version IDs. It does not change the +provider to accommodate Moto or claim manual `AWSCURRENT` move parity. Test image +pins are recorded in the runner; they are not production deployment approval. + +## Credentials and permissions + +For the application, use the AWS SDK's supported credential chain. In production, prefer the +workload role attached to the service. For local development, use a named authenticated AWS +profile or another supported developer credential source. The bindings file and container +image must not contain static access keys. Secret-only applications initialize the Secrets +Manager client; AppConfig-only applications leave AWS credentials to the Agent. + +Grant the Agent role only the AppConfig Data read actions it needs: + +- `appconfig:StartConfigurationSession` for the selected application, environment, and profile. +- `appconfig:GetLatestConfiguration` for that configuration session/resource scope. + +Grant the application role `secretsmanager:GetSecretValue` for the explicitly mapped secret +ARNs, and `kms:Decrypt` only when a selected customer-managed key requires it. Runtime roles +do not need resource creation, AppConfig deployment, secret mutation, staging-label changes, +or rotation-Lambda management. Review the exact IAM resource scoping for the deployed AWS +service and account before deployment. This guide is not an IAM acceptance result. + +AWS charges and service quotas depend on the selected AWS resources, Agent polling, region, and +traffic. Estimate them from the deployment configuration. Keep a named operator responsible +for stopping the Agent and removing disposable AppConfig/Secrets Manager resources after +experiments; the runtime and CLI do not clean them up. + +## Push remains local + +`edgezero config push --adapter axum` writes local config snapshot files. It does not publish +AppConfig data. The `--store-bindings /absolute/path` push guard rejects a selection +that binds the chosen logical ID to AWS, before diffing, prompting, or writing. Without that +explicit file, a local push is still only a local write; it makes no claim about a separate +AWS deployment. There are no AWS publish, provision, secret-mutation, or cleanup commands in +this feature. diff --git a/docs/guide/proxying.md b/docs/guide/proxying.md index 86d6f57f..d8e1dd2c 100644 --- a/docs/guide/proxying.md +++ b/docs/guide/proxying.md @@ -136,6 +136,11 @@ process default clock and is intended only for code that also uses the default c observation cutoff stops batch observation regardless of any remaining per-request budget or later per-request deadline. Passing an earlier observation cutoff intentionally leaves unresolved slots. +After the last item, continue calling `next()` to await clean driver EOF and receive +`Finished(Completed)`. Item count alone is not completion: a trailing driver failure, duplicate +index, or out-of-range index still fails collection while retaining the previously collected +slots. Custom drivers must finish after their last item; zero-slot drivers are validated too. + `client.send_all_until(requests, observation_cutoff).await` collects the same driver into an index-aligned `Vec>` plus `results.termination`. `Completed` guarantees every slot is present. Only `Cutoff` permits `None` in a successful result, meaning that slot was diff --git a/docs/guide/streaming.md b/docs/guide/streaming.md index e3616cd4..651c27f2 100644 --- a/docs/guide/streaming.md +++ b/docs/guide/streaming.md @@ -46,6 +46,20 @@ Axum uses a connection-local Hyper body, Cloudflare a JavaScript stream writer, `stream_to_client`, and Spin a WASI body writer. See [Capabilities](/guide/capabilities) for the exact acceptance and completion boundary on each target. +Application retention does not change this ownership. Every dispatch creates +fresh admission and response-egress state, even when the same successfully built +`App` is reused. Keep body sources, native writers, completion resources, and +pending work request-local rather than storing them in the app cache. + +Fastly's `serve_app` and `serve_app_with_hooks` retain an app through +`lifecycle::Sandbox` while the adapter owns sending and streaming. The hooks +borrow request/response values; they do not return a native response for the SDK +to send again. Cloudflare's `dispatch_app::(&app, req, env, ctx)` returns a +Worker response backed by its request-local coordinator. Spin's +`dispatch_app::(&app, req)` returns a raw `SpinResponse` whose body and +transmission lifetime remain owned by the adapter coordinator. None of these +paths collect the complete response before handoff. + ## Server-Sent Events Stream events to clients with SSE: @@ -100,7 +114,7 @@ body-mode = "buffered" # or "stream" ## Outbound Response Decompression -The outbound client decodes a single bare `gzip` or `br` content coding through shared +The outbound client decodes a single bare `gzip`, `deflate`, or `br` content coding through shared `edgezero-core` decoders. Unknown, parameterized, or stacked codings pass through unchanged. Encoded transport bytes, decoded output, and final buffered bytes have independent limits; see [Capabilities](/guide/capabilities#limits-and-accounting). diff --git a/docs/superpowers/plans/2026-06-17-blob-app-config.md b/docs/superpowers/plans/2026-06-17-blob-app-config.md index 10518960..3f8971d7 100644 --- a/docs/superpowers/plans/2026-06-17-blob-app-config.md +++ b/docs/superpowers/plans/2026-06-17-blob-app-config.md @@ -10,6 +10,25 @@ ## Global Constraints +PR #275 follow-up supersedes this plan's older owned-string store signatures and +per-request Axum file-reading assumptions. The current normative contract is spec +§6.3 and §9.1 plus the Axum guide's snapshot-allocation limits. + +- [x] Hard-cut raw config reads to shared `ConfigValue` across all adapters and fixtures. +- [x] Validate finite Axum startup/snapshot budgets and load before listener binding. +- [x] Bound actual reads, flat decoder staging, entry/key/value allocations and all-store residency. +- [x] Preserve failed declared bindings, reject duplicate keys and invalid registry metadata. +- [x] Stream canonical hashing and drop the extraction-owned raw handle before secrets. +- [x] Align demo, generator, public guides and CHANGELOG with snapshot/restart semantics. +- [x] Stream Cloudflare bounded KV reads through capped BYOB buffers; cancel acquired readers on termination. +- [x] Use caller-sized Fastly ABI buffers without growth/retry, preflight aggregate chunk limits and avoid early envelope DOM allocation. +- [ ] Establish a bounded Spin host/backend read interface or verified hosted allocation ceiling; stock list-valued reads are not sufficient. +- [ ] Qualify hosted provider allocation/cancellation evidence; do not promote capability cells without it. + +These follow-ups supersede older tasks below that describe Cloudflare text materialization, +Fastly SDK auto-growing reads or inner-envelope DOM verification in the bounded resolver. +See spec §9.2/§9.3 and the public capability guide for the current allocation charges. + - **Source of truth:** [`docs/superpowers/specs/2026-06-16-blob-app-config.md`](../specs/2026-06-16-blob-app-config.md). Every task numbered below cites the spec section it implements. - **Hard cutoff:** no `#[serde(default)]` compat fallback for missing legacy state, no runtime fallback to the old per-leaf app-config shape, no platform-side bridge for legacy state. Fastly's direct-or-pointer storage is a new v1 physical representation for the same blob envelope, not legacy compatibility. Per spec §1 / §10.1. - **Atomic cutover:** spec §10.1 + §10.2 require runtime extractor + per-adapter writers + app-demo migration + scaffold templates + CI gate to land in ONE commit (Phase C below). Intermediate trees with new runtime / old writer do NOT compile cleanly because the read-trait-driven inline diff and the gate cross-check would fail. **Bisect-safe annotations** appear in each task heading. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase4-axum-cloudflare.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase4-axum-cloudflare.md index 0208037f..d849402b 100644 --- a/docs/superpowers/plans/2026-09-06-outbound-http-phase4-axum-cloudflare.md +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase4-axum-cloudflare.md @@ -5,6 +5,11 @@ > **Status:** Deterministic implementation is complete on PR 275. Protected deployed-provider evidence remains an external promotion gate, so the affected capability cells remain `BestEffort`. The unchecked steps below are retained as the original implementation record. > **Superseded batch/lifecycle API:** The 2026-09-15 consumer-alignment plan replaces this > plan's `send_all` API and test sentinels. Retain the text only as implementation history. +> **Shared implementation follow-up (R5/R6):** Axum, Cloudflare, and Spin now use +> `OutboundBatch::from_futures`. All four adapters use `ResponseBodyPolicy` / +> `apply_response_body_policy` for the payload-limit/decoding/rechunking composition. +> Transport reads, clocks, timers, cancellation, bodyless handling, and final collection remain +> adapter-owned. These extractions do not change the normative outbound contract. **Goal:** Implement the full outbound contract for Axum and Cloudflare, including batch behavior, typed cleanup, adapter scheduling, and executable native/WASM/host contracts. diff --git a/docs/superpowers/plans/2026-09-08-inbound-ingress-admission.md b/docs/superpowers/plans/2026-09-08-inbound-ingress-admission.md index a5ffdab4..bb069360 100644 --- a/docs/superpowers/plans/2026-09-08-inbound-ingress-admission.md +++ b/docs/superpowers/plans/2026-09-08-inbound-ingress-admission.md @@ -117,8 +117,16 @@ work, plus the Phase 2 `ResponseLimitReason` work, must be present before the to setup/request conversion, raw-socket integration tests. Current Axum ingress uses `IngressHeadAccounting::HostManaged` and -`IngressFraming::HostManaged`. Every item in this task is future promotion evidence; none is a -claim about the current adapter. +`IngressFraming::HostManaged`. Unchecked raw-accounting/framing items in this task are future +promotion evidence, not claims about the current adapter. + +The separate bounded HTTP/1 resource boundary is implemented: validated `AxumIngressConfig`, a +finite connection-owning accept pool, pinned Hyper 1.12.0 combined raw-head/field limits, waking +absolute idle-plus-header timers, and response-head cache compaction. TCP tests cover saturation, +initial/subsequent trickling, idle expiry, exact/over-limit heads, disconnect recovery, and shutdown. +See the [Axum guide](../../guide/adapters/axum.md#bounded-http1-ingress) for its effective configuration +and allocation assumptions. This evidence does not complete the independent raw-accounting or +strict framing items below; HTTP/2 and upgrades remain outside the current server contract. - [ ] Add red raw-socket tests for exact/over-limit request-target bytes, raw header bytes, and field-line count; checked accounting overflow; CL+TE in both orders; duplicate @@ -129,14 +137,15 @@ claim about the current adapter. Exact limits pass and expose the exact `IngressHeadAccounting::RawValidated` totals. - [ ] Assert rejection is 400, closes HTTP/1 or resets only the multiplexed stream, invokes no admission policy, and polls no body. -- [ ] Characterize pinned Hyper 1.10.1 first: preserve source references and tests proving a +- [ ] Characterize pinned Hyper 1.12.0 first: preserve source references and tests proving a `Content-Length` after `Transfer-Encoding` is skipped and equal duplicate lengths are accepted. These are the red cases the patch must change. -- [ ] Replace `axum::serve` with a connection-owning Hyper accept path while preserving - connect metadata, HTTP/1 keepalive and pipelining, HTTP/2 behavior, upgrades, graceful - shutdown, and the existing service contract. Configure `max_headers`, `max_buf_size`, and - `header_read_timeout` as defense in depth, but do not treat those coarse controls as exact - target/header accounting or framing evidence. +- [x] Replace `axum::serve` with a bounded connection-owning Hyper HTTP/1 accept path while + preserving connect metadata, keep-alive, the existing service/body/egress contract, and + shutdown cleanup. Configure `max_headers`, `max_header_size`, `max_buf_size`, and waking + `header_read_timeout` as resource defense in depth, not strict raw-accounting/framing evidence. +- [ ] Define separate resource models before enabling HTTP/2, socket upgrades, or a graceful + shutdown drain beyond the current bounded close-on-shutdown behavior. - [ ] Add the smallest audited parser patch (or consume an upstream equivalent) in Hyper's existing request-line/ordered `httparse` header path. Bound parser reads so the raw head is rejected rather than accumulated past the installed target/header policy; count duplicate diff --git a/docs/superpowers/plans/2026-09-08-response-egress-implementation.md b/docs/superpowers/plans/2026-09-08-response-egress-implementation.md index c8b3d337..3e7d7160 100644 --- a/docs/superpowers/plans/2026-09-08-response-egress-implementation.md +++ b/docs/superpowers/plans/2026-09-08-response-egress-implementation.md @@ -1,5 +1,11 @@ # Owned Response Egress Implementation Plan +PR #275 follow-up expands local Axum TCP qualification: suppressed-body completion +resources, pending producers, non-reading clients, disconnect cleanup, native deadline +wakeup under a frozen injected clock, stopped source polling and single-connection recovery. +Hosted Cloudflare/Fastly/Spin evidence remains a separate unfulfilled qualification gate; +local tests and provider emulators must not be presented as deployed runtime proof. + > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to > implement this plan task by task. Apply `superpowers:test-driven-development` to every > behavioral change and `superpowers:verification-before-completion` before claiming success. @@ -413,6 +419,21 @@ cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin ## Decision And Evidence Record +### Hosted Qualification Follow-up + +- [x] Add a default-off synthetic qualification application through the four standard adapter + entrypoints, with bounded source/terminal/resource counters and compiled build identity. +- [x] Add allowlisted, finite HTTPS client probes and a fail-closed evidence validator; keep + `HostHandoff` distinct from peer delivery and record missing instance identity honestly. +- [x] Exercise validator corruption cases and compile every qualification target in CI. +- [ ] Run against designated nonproduction hosted targets and export complete unsampled telemetry + pinned to the committed source, SDK/runtime, compatibility and deployed binary identity. +- [ ] Prove same-instance recovery and actual transport failure/cancellation boundaries where + observable; do not infer these from a healthy subsequent invocation or promote capabilities. + +No hosted deployment was run for this follow-up. The fixture/validator is qualification +infrastructure, not evidence that the remaining provider lifetime guarantees have been met. + - **Architecture:** Retained one core lifecycle/framing contract and four private runtime-specific pumps. Rejected a shared sink abstraction and every response-object or bounded-buffer fallback. - **API migration:** Removed the old Fastly response-returning entrypoint, Spin full-response @@ -431,3 +452,33 @@ cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin clock/deadline races, accepted-prefix accounting, cancellation ownership, provider diagnostics, cfg/feature combinations, unsafe boundaries, dependency reachability, retired APIs, demos, and generated projects. + +## Review Follow-Up: Axum EOF And Assembly Invariants + +- [x] Add Hyper/socket regressions for exact and zero declared-length streams, excess tails, + late source errors, and deadline-bounded EOF validation. Check exactly-once reports and source + release, not only received bytes. +- [x] Stage only the final declared-length chunk until source EOF validates; validate zero-length + streams before Hyper handoff and retain the application clock and response completion owner. +- [x] Add a deterministic expired-fallback-registration regression for GET and HEAD. Determine + EOF from source exhaustion, exhaust failed bodies, and preserve selected finite fallback bytes. +- [x] Add a replacement-hook regression with nondefault platform facts; reject changed metadata + after successful configuration while propagating hook errors unchanged. +- [x] Run scoped Axum/core tests, workspace gates, provider WASM checks, demo tests, and docs + contracts; update the spec/guide evidence without promoting BestEffort guarantees. + +**Decision and evidence (2026-10-06):** Investigation/proposal agents Pasteur and Plato recommended +core-owned driver EOF validation and a complete platform-metadata comparison. Axum reviewer +Noether identified a registration-time deadline race; its deterministic regression failed before +the fix and now selects 504 rather than 500. Core reviewer Beauvoir requested population-only +metadata and cancellation-during-finalization coverage; both are included. Build-validator +Lagrange checked combined native features, strict clippy on all three WASM targets, and the demo. +The chosen fix retains the private Axum pump and adds only final-frame lookahead, rather than +the optional shared-driver/pump refactors. + +Local evidence: workspace tests and strict clippy pass; core has 720 passing unit tests; Axum's +standard-service socket regressions cover clean/empty EOF, excess/error/pending tails, a frozen +application clock, and zero-length registration expiry. Demo tests (54), demo logging tests (2), +formatting, docs lint/build, and docs/dependency contracts pass. These are local proofs, not +deployed-provider certification, and no capability was promoted. Delivery and review-thread +replies are recorded in PR 275's commit and discussion history. diff --git a/docs/superpowers/plans/2026-09-15-reusable-app-lifecycle.md b/docs/superpowers/plans/2026-09-15-reusable-app-lifecycle.md new file mode 100644 index 00000000..d06db94b --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-reusable-app-lifecycle.md @@ -0,0 +1,451 @@ +# Reusable Application Lifecycle Implementation Plan + +> Historical implementation plan. The private-helper design and custom callback +> choices are superseded by [the custom serving lifecycle plan](2026-09-17-custom-serving-lifecycle.md). +> PR #275's outbound hard-cut also supersedes the original infallible assembly, +> public raw conversion, response-returning dispatch, and buffering choices. +> Current APIs are reconciled below; the [outbound HTTP design](../specs/2026-05-21-outbound-http-design.md) +> remains authoritative for admission, outbound execution, response egress, and +> capabilities. The updated [lifecycle spec](../specs/2026-09-15-reusable-app-lifecycle-design.md) +> documents successful-only retention. Historical checkmarks and verification +> counts do not verify the reconciled implementation. + +> This is a historical decision record, not authorization to restore superseded +> APIs. Any new implementation requires a current approved plan. + +**Goal:** Add explicit application retention for Fastly, Cloudflare, and Spin while preserving existing defaults and Axum's retained-router behavior. + +**Architecture:** Keep ownership in core `App`, built through fallible `App::build::(PLATFORM)`. Fastly uses public, host-testable `Sandbox` state with `serve_app` / `serve_app_with_hooks`; Cloudflare/Spin use `Hooks`-typed prebuilt dispatch. Retain successful apps only and create fresh registries, admission, and response-egress resources per request. No common scheduler or generic singleton is introduced. + +**Tech Stack:** Rust, Fastly SDK 0.13.1/Viceroy 0.21.0, worker 0.8.5/worker-build/Wrangler, Spin SDK 7/P3, Axum, shell, VitePress/Prettier. + +--- + +## Authority and constraints + +- Source baseline: `593fc9282a1c56e12bae15f91eef2162f4b6a1b7`. +- Governing design: [Reusable application lifecycle spec](../specs/2026-09-15-reusable-app-lifecycle-design.md), including the host/WASM test split and attempted-request accounting added during review. +- This document records historical implementation. New code work requires approval under `CLAUDE.md`; this record does not authorize deployment, publishing, external comments, or unrelated commits. +- Preserve lifecycle defaults, macro grammar, and logging ownership while following PR #275's canonical fallible assembly and lazy delivery. Do not restore historical response buffering, collection caps, or removed APIs through compatibility aliases. No new Tokio dependency in core/WASM adapters, provider-independent scheduler, or external consumer identifiers. +- Re-read current `CLAUDE.md` and compare source with the baseline before execution. Use `superpowers:using-git-worktrees` when starting approved code work. This planning-only document remains in the current workspace. +- Run scoped `cargo test` after each code increment. Provider fixture changes also need the real-target check/build. No host stub proves provider behavior. +- The commit messages below are implementation checkpoints, not commands to commit during planning. Commit only focused, verified changes within the approved execution scope. + +## Delivery structure + +| Stage | Tasks | Exit evidence | +| ------------------------------- | ----- | ----------------------------------------------------- | +| Core contracts and adapter APIs | 1–4 | Host lifecycle tests and provider type checks | +| Provider fixtures and harness | 5–9 | Real HTTP assertions and explicit unsupported results | +| Runtime evidence | 10 | A/B/C accounting, performance, and isolation outcomes | +| Guides and final verification | 11–12 | Checked examples, repository gates, review | + +Tasks 3 and 4 can run independently after Task 2. Provider fixture work can run independently once Task 5 fixes the common contract. Assign each file to one worker; do not concurrently edit Fastly `lib.rs`. + +## File map + +Production and existing tests: + +- `crates/edgezero-core/src/app.rs`: retained-object bounds test. +- `crates/edgezero-core/src/router.rs`: forced interleaving/isolation test. +- `crates/edgezero-core/src/app_config.rs`: audit recursion-guard cleanup and extend colocated tests only where coverage is missing. +- `crates/edgezero-adapter-fastly/src/request.rs`: audit bounded warning-cache behavior; preserve production behavior. +- `crates/edgezero-adapter-fastly/src/lib.rs` and `src/lifecycle.rs`: successful-only `Sandbox`, host tests, `serve_app` / `serve_app_with_hooks`, and SDK exports. +- `crates/edgezero-adapter-cloudflare/src/lib.rs`: additive `dispatch_app`. +- `crates/edgezero-adapter-spin/src/lib.rs`: additive `dispatch_app`. +- `.github/workflows/test.yml`: native harness tests and Fastly HTTP smoke using existing Viceroy setup. +- `scripts/smoke_test_reusable_app.sh`: explicit build/tool/run orchestration. + +New standalone fixture workspace: + +```text +tests/fixtures/reusable-app/ + Cargo.toml + Cargo.lock + .gitignore + README.md + crates/ + fixture-harness/{Cargo.toml,src/{main,evidence,net,runners}.rs} + fixture-core/{Cargo.toml,src/lib.rs} + fixture-fastly/ + Cargo.toml + fastly.toml + src/lib.rs + src/bin/single_request.rs + src/bin/rebuild_per_request.rs + src/bin/retained_app.rs + src/bin/custom_single_request.rs + src/bin/custom_rebuild_per_request.rs + src/bin/custom_retained_app.rs + src/bin/logger_negative.rs + fixture-cloudflare/{Cargo.toml,wrangler.toml,src/lib.rs} + fixture-spin/{Cargo.toml,spin.toml,runtime-config.toml,src/lib.rs} + fixture-axum/{Cargo.toml,src/main.rs} +``` + +Documentation: `docs/guide/adapters/{overview,fastly,cloudflare,spin,axum}.md`, fixture README, and the fresh-Fastly-instance comment in `examples/app-demo/crates/app-demo-core/src/lib.rs`. + +The original retention work minimized request/response changes. PR #275 now owns +canonical admission and response egress in those modules; do not restore the +historical conversion or collection paths. No core lifecycle module or reuse +manifest setting is introduced. + +## Task 1: Protect retained ownership and request separation + +**Files:** core `src/app.rs`, `src/router.rs`, and, where needed, `src/app_config.rs` test modules. Audit Fastly `src/request.rs` warning caches; record findings in the fixture README from Task 11. + +- [x] **1.1 Read the existing tests** near `router.rs:865` and injection at `:251`. The current two-future test does not force both handlers to remain pending; extend coverage rather than copying it. +- [x] **1.2 Add the bounds assertion** to the app test module: + +```rust +#[test] +fn app_can_be_retained_in_a_shared_owner() { + fn assert_send_sync() {} + assert_send_sync::(); +} +``` + +- [x] **1.3 Run `cargo test -p edgezero-core app_can_be_retained_in_a_shared_owner`.** Expected: pass against existing code. This protects an existing property; no production edit is justified. +- [x] **1.4 Add `retained_router_separates_overlapping_requests`.** Use test middleware with two `futures::channel::oneshot` receivers. Take a receiver under a mutex, release the guard, then await it. Poll both router futures and assert both are pending before releasing either sender. +- [x] **1.5 Give each request distinct** `/probe/{id}` path values, body, `x-request-token`, and a request-only extension. Also inject a conflicting value of the same type as registered app state; assert existing app-state precedence. Keep a separate `Arc` counter as intentionally shared state. +- [x] **1.6 Add a `#[action]` probe** that reads its request-local values after the barrier and returns them plus shared state. Assert two distinct correct outputs and shared count two. Never put request tokens in app state. +- [x] **1.6a Audit persistent process/thread state.** Search the core and Fastly runtime modules for `static`, `OnceLock`, and `thread_local!`. Classify each value as intentionally retained, bounded suppression/cache state, or request-scoped state needing cleanup. Inspect `SecretFieldsRecursionGuard` in `crates/edgezero-core/src/app_config.rs:96–145` and the warning caches in `crates/edgezero-adapter-fastly/src/request.rs:563–586`. Do not infer leakage merely from a static or guard. +- [x] **1.6b Verify supported cleanup paths.** Reuse existing tests where sufficient; otherwise add colocated guard tests for normal scope exit and host panic unwinding, followed by a fresh successful invocation proving depth reset. Do not claim host unwinding proves cleanup after a terminating WASM trap; that case requires the fresh-instance fixture in Task 7.8. Confirm warning caches remain bounded and document that eviction permits repeated warnings, while suppression means warning counts are not failure counts. Run scoped tests after any test-code increment. +- [x] **1.7 Run `cargo test -p edgezero-core`.** Expected: pass without router production changes. Checkpoint: `test(core): cover retained app ownership and request isolation`. + +## Task 2: Host-testable Fastly production initialization + +**Files:** `crates/edgezero-adapter-fastly/src/lib.rs`, `src/lifecycle.rs`. + +The original private, infallible initialization sketch is superseded by the +custom lifecycle work and PR #275. Current production uses `Sandbox` with +independent successful setup and fallible construction guards. + +Native `cargo test --features fastly` has a reproduced pre-existing unresolved-hostcall linker failure. Compile shared initialization logic under `cfg(any(feature = "fastly", test))`, as this file already does for `FastlyLogging`/`EnvConfig`. Do not introduce fake hostcalls. + +- [x] **2.1 Protect successful-only retention.** Use the production `Sandbox` state with counted closures and real core app construction. Initialization failure leaves the slot empty; setup succeeds independently of app construction. +- [x] **2.2 Use the current production lifecycle:** + +```rust +use edgezero_adapter_fastly::{FASTLY_PLATFORM, lifecycle::Sandbox}; +use edgezero_core::app::App; + +let mut retained = Sandbox::::default(); +retained.initialize(|| App::build::(FASTLY_PLATFORM))?; +let app = retained.state().expect("successful initialization"); +``` + +`Hooks::configure` returns `Result<(), EdgeError>`; `App::build` installs and +validates platform metadata. No partial app or initialization error is retained. +Custom callbacks decide whether a failure is recoverable; standard serving +propagates it and stops without another send. + +- [x] **2.3 Preserve the degraded first logging snapshot.** Exercise production `setup_once` with an empty initial environment and later successful selector reads. Logging is not reconfigured after successful setup, while runtime/store resolution remains per request. The earlier private-helper test sketch is superseded. + +- [x] **2.4 Complete these cases:** + +| Case | Assertion | +| ------------------ | ---------------------------------------------------------------------------------------------- | +| Configured logging | `logger`, then `build`, once across two calls; endpoint/level from first env; stdout true | +| Owned logging | Zero adapter installs, one build | +| Logger error | Exact injected error, zero builds, no stored app; custom retries require safe setup side effects | +| Fresh owner | Two state values initialize independently, no shared static | +| Retained app | Two dispatches use the same configured router/state | + +- [x] **2.5 Run `cargo test -p edgezero-adapter-fastly --lib` after each increment.** Expected: default-feature tests pass without SDK linkage. Checkpoint: `feat(fastly): separate retained initialization from host calls`. + +## Task 3: Fastly opt-in serving APIs + +**File:** `crates/edgezero-adapter-fastly/src/lib.rs`. + +- [x] **3.1 Add these public APIs:** + +```rust +#[cfg(feature = "fastly")] +pub use fastly::http::serve::{Serve, ServeSummary}; + +#[cfg(feature = "fastly")] +#[inline] +pub fn serve_app(serve: Serve) -> ServeSummary; + +#[cfg(feature = "fastly")] +#[inline] +pub fn serve_app_with_hooks( + serve: Serve, + prepare: Prepare, + finalize: Finalize, +) -> ServeSummary +where + A: Hooks, + Prepare: FnMut(&mut fastly::Request, &mut Extensions), + Finalize: FnMut(&mut edgezero_core::Response) -> State; +``` + +- [x] **3.2 Add API docs** for ordinary `main`, successful-only `Sandbox` retention, degraded logging snapshot, fresh registries/admission/egress, `owns_logging`, limits, terminal errors, attempted counts, and `.into_result()`. Preparation borrows the request before admission; finalization borrows only routed responses before policy/framing. The adapter owns lazy delivery; no native response is returned to the SDK for another send. +- [x] **3.3 Verify:** + +```sh +cargo test -p edgezero-adapter-fastly --lib +cargo check -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 +cargo check -p edgezero-adapter-fastly --features fastly --all-targets +``` + +Expected: host logic passes and SDK wrappers type-check. HTTP execution remains Task 7. + +- [x] **3.4 Preserve lifecycle defaults and logging ownership.** PR #275 supersedes the earlier unchanged-helper guarantee; do not restore removed APIs or buffering. Checkpoint: `feat(fastly): add opt-in retained app serving`. + +## Task 4: Cloudflare and Spin prebuilt dispatch + +**Files:** `crates/edgezero-adapter-cloudflare/src/lib.rs`, `crates/edgezero-adapter-spin/src/lib.rs`. + +- [x] **4.1 Add the Cloudflare wrapper:** + +```rust +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[inline] +pub async fn dispatch_app( + app: &edgezero_core::app::App, + req: Request, + env: Env, + ctx: Context, +) -> Result; +``` + +- [x] **4.2 Run `cargo test -p edgezero-adapter-cloudflare` and `cargo check -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown`.** Binding/runtime assertions come in Task 8. +- [x] **4.3 Add the Spin wrapper:** + +```rust +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[inline] +pub async fn dispatch_app( + app: &App, + req: SpinRequest, +) -> anyhow::Result; +``` + +- [x] **4.4 Run `cargo test -p edgezero-adapter-spin` and `cargo check -p edgezero-adapter-spin --features spin --target wasm32-wasip2`.** Document `A::stores()` selection, caller-owned successful app/logging, fresh registries/admission/egress, raw Spin response ownership, and overlapping invocations. +- [x] **4.5 Current `run_app` delegates to `dispatch_app::`.** Logging precedes fallible `App::build::(PLATFORM)`; configuration, runtime variables, and registries are resolved for every dispatch. The original nondelegating wrapper choice is superseded by PR #275. Callers pair retained apps with their construction `Hooks` type and selected platform. Checkpoint: `feat(adapters): dispatch prebuilt Cloudflare and Spin apps`. + +## Task 5: Build the portable probe fixture + +**Files:** `tests/fixtures/reusable-app/{Cargo.toml,Cargo.lock,.gitignore}` and `crates/fixture-core/{Cargo.toml,src/lib.rs}` beneath that directory. + +- [x] **5.1 Create an isolated workspace.** Use resolver 2, edition 2024, `publish = false`, and `default-members = ["crates/fixture-core"]`. Workspace EdgeZero paths are `../../../crates/`. Provider packages enable their own features; core uses `default-features = false`. Match the repository's SDK versions. Keep Tokio confined to the native Axum fixture. Ignore `target`, `.runs`, `.wrangler`, `.spin`, and generated worker output. Generate and commit the fixture lockfile; all subsequent builds use `--locked`. +- [x] **5.2 Add counted `FixtureApp` and a distinct `OtherApp`.** Count route construction and fallible `configure` independently through `App::build::(PLATFORM)`. Supply explicit config/KV/secret metadata with fixture-only names. Issue a fresh unique synthetic candidate boot token on every incoming request; each guest latches only its first candidate as its instance marker and increments a guest-local request ordinal. Never share a candidate token across a run or runtime process: fresh guests must report different markers. This test marker must never contain real request data or appear as a production caching example. +- [x] **5.3 Add a failing probe contract test.** Two requests must return their own path, header, body, and request-extension tokens; an intentionally shared `Arc` counter must increment. Run `cargo test --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-core`; expect the missing probe behavior to fail. +- [x] **5.4 Implement the probe with `#[action]` handlers and core HTTP imports.** Keep shared state limited to counters and the controlled fixture identity. Return JSON records containing instance, ordinal, build/configure counts, and echoed synthetic request fields. +- [x] **5.5 Add `/cookies` and `/rendered-error`.** Append two distinct `Set-Cookie` values; return an ordinary renderable application error on the latter route. Add assertions for both and rerun the scoped tests. +- [x] **5.6 Add `/stream`, `/stream-error`, and `/overlap/{id}`.** Use a controlled loopback backend through the injected outbound HTTP interface. The overlap route captures request values before awaiting a barrier and returns them afterward. An in-flight guard increments/decrements atomics without keeping a mutex guard across an await. Return the observed maximum in-flight count. +- [x] **5.7 Add `/bindings` and `/origin/{id}`.** Report only binding presence and nonsecret fixture markers. Origins come from a finite harness-owned allowlist. Test unknown-origin rejection and both concrete apps' different route identities. +- [x] **5.8 Run the locked core fixture tests.** Expected: all portable assertions pass without provider calls. Checkpoint: `test(lifecycle): add portable retained-app probes`. + +## Task 6: Build the local HTTP evidence harness + +**Files:** `tests/fixtures/reusable-app/crates/fixture-harness/src/main.rs` and `scripts/smoke_test_reusable_app.sh`. + +- [x] **6.1 Define the runner interface.** Accept `--adapter fastly|cloudflare|spin|axum|all`, `--suite smoke|benchmark`, and `--output ` (a `--require-runtime` flag was planned here and later removed; unverified or unsupported evidence without a failure always exits 2). Resolve `VICEROY_BIN`, `WORKER_BUILD_BIN`, `WRANGLER_BIN`, and `SPIN_BIN` explicitly to absolute executables and record versions. Never install or deploy automatically. +- [x] **6.2 Write failing Rust tests for evidence accounting.** Cover a failed attempted request, a missing final summary, two duplicate headers, unavailable CPU measurements, and invalid overlap evidence. Run `cargo test --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-harness`; expect failures before implementing the parser/accounting. +- [x] **6.3 Implement JSONL accounting.** Record run/tool/build identity, request start, response commitment, guest completion, client completion, errors, SDK summary, metrics, and capability/skip events. Attempted requests and successful completions are different fields. Missing crash summaries are unknown, not zero. Every metric has source, units, and observed/injected/unsupported/unverified status. +- [x] **6.4 Implement a Rust loopback backend using `TcpListener` and scoped worker threads.** Provide a bounded two-arrival barrier, delayed chunked response with an explicit release signal, and abrupt connection closure. Flush the first chunk before waiting. Bind only loopback addresses and use allocated ports. +- [x] **6.5 Implement a bounded Rust HTTP client for the loopback fixtures.** Preserve response headers as a list; do not collapse duplicate cookies into a dictionary. Capture header/first-byte/full-response timing with `Instant`, partial-body failures, and concurrent requests using Rust threads. +- [x] **6.6 Test the streaming and overlap oracles.** Equal final bodies alone must fail the progressive-stream assertion. Two different instance markers or a maximum in-flight count of one must fail same-instance overlap. First-byte delivery must precede the controlled backend's final-chunk release. +- [x] **6.7 Implement process orchestration.** Copy configs into unique ignored `.runs/` directories, resolve copied paths, enforce deadlines, and clean up only child processes started by this invocation. Exit 0 means all requested assertions passed; 1 means build/assertion failure; 2 means required runtime/evidence is unavailable. Optional skips remain an explicitly incomplete result and must never hide assertion failures. +- [x] **6.8 Run the Rust harness tests and `bash -n scripts/smoke_test_reusable_app.sh`.** Expected: passing tests and valid shell syntax. Checkpoint: `test(lifecycle): add local HTTP evidence harness`. + +## Task 7: Exercise Fastly default, rebuilt, and retained applications + +**Files:** `tests/fixtures/reusable-app/crates/fixture-fastly/{Cargo.toml,fastly.toml,src/lib.rs,src/bin/single_request.rs,src/bin/rebuild_per_request.rs,src/bin/retained_app.rs,src/bin/custom_single_request.rs,src/bin/custom_rebuild_per_request.rs,src/bin/custom_retained_app.rs,src/bin/logger_negative.rs}`; extend harness Fastly orchestration. + +- [x] **7.1 Declare explicit binary names.** Use `fixture-fastly-a`, `fixture-fastly-b`, `fixture-fastly-c`, `fixture-fastly-custom-a`, `fixture-fastly-custom-b`, `fixture-fastly-custom-c`, and `fixture-fastly-logger-negative`. Pin SDK 0.12.1 and build on `wasm32-wasip1`. +- [x] **7.2 Implement standard A with the single-request entry point.** Use `run_app_with_hooks`, adding fixture observations through preparation and routed-response finalization. Preserve per-invocation construction and canonical adapter-owned delivery. +- [x] **7.3 Implement standard B using `lifecycle::serve_custom`.** Read runtime configuration each callback, complete logging setup once from the first snapshot, call `App::build::(FASTLY_PLATFORM)` each callback, and use `request::send_request_with_registries_and_hooks`. Use the same logging policy as C, including a degraded first snapshot. Do not call retaining `serve_app` for B. +- [x] **7.4 Implement standard C with `serve_app_with_hooks`.** Start with a finite request limit of 10 and a bounded idle wait. Record SDK attempted-request summaries separately from completed delivery. Compare A/B/C with identical payloads, canonical dispatch, and instrumentation. +- [x] **7.5 Implement custom A/B/C through the canonical send-owning path.** A uses `run_custom` for one native request; B builds each callback; C initializes a `Sandbox` lazily and retains only success. Use request preparation for synthetic native metadata and routed-response finalization for extension observations. A health callback before C's first application request must leave its initialization count at zero. The original manual-conversion design is superseded by PR #275. +- [x] **7.5a Verify request correlation independently of sandbox identity.** Capture `Request::get_client_request_id()` on each callback and record `FASTLY_TRACE_ID` only as sandbox metadata. Never use the latter as a unique request ID. When the native ID is unavailable, use the harness-issued unique per-request token as an explicitly labeled fixture fallback; record availability and do not present fallback evidence as native-ID support. Across callbacks in the same observed guest, verify that correlation is derived afresh from each request and does not persist in the global logger or retained app. Keep correlation in request-local extensions or explicit per-event log fields. Exercise the fallback through a supported runtime case or a labeled injected accessor result. +- [x] **7.6 Verify canonical progressive response delivery.** Preserve every header value and delayed-chunk production through the adapter's owned stream writer. Terminal failure after commitment cleans up without replacing the response. Lifecycle wrappers record escaping errors without an SDK resend. Complete request-owned post-delivery work before the next callback; do not duplicate transport ownership in a fixture. +- [x] **7.7 Configure local backends and stores.** Derive runtime configuration's service-scoped keys from the guest's actual service ID, following `scripts/smoke_test_config_key_override.sh`. Keep config, KV, and secret fixtures isolated. Capture a named logging endpoint distinctly from echoed stdout; if the selected runtime cannot expose endpoint-specific evidence, report that assertion unavailable. +- [x] **7.8 Add terminal and continuing cases.** Separate rendered handler errors, admission refusals/aborts, bounded precommit fallbacks, and escaping delivery failures according to the outbound design. Test controlled reachable failures; label injected/unavailable cases honestly. Add configuration failure without caching, constructor panic, post-commit stream failure, and a fresh-owner initialization case. Historical collection-error behavior is superseded. +- [x] **7.9 Add the logger negative control.** Naively wrapping enabled-logger `run_app` in `Serve` should expose the second-installation failure. Keep this out of B's performance samples. Add the degraded-first-read/recovered-selector case where runtime controls permit; otherwise retain host injection evidence and mark the provider scenario unverified. +- [x] **7.10 Build and run the fixtures.** From the fixture workspace run `cargo build --locked --release -p fixture-fastly --bins --target wasm32-wasip1`. Launch the selected executable with `viceroy serve --addr 127.0.0.1: --config `. Account for readiness callbacks or restart before measuring. Run `./scripts/smoke_test_reusable_app.sh --adapter fastly --suite smoke`. +- [x] **7.11 Assert actual reuse before accepting comparisons.** Stable guest marker plus increasing ordinal is required. A builds once per fresh invocation; B builds each reused callback; C retains the first successful app. Check cookies, canonical progressive streaming, request isolation, limit 1/10, idle exit, and restart. Historical buffered comparisons must be rerun against matched lazy delivery. +- [x] **7.12 Run existing Fastly tests on their proper runners.** Run default host `cargo test -p edgezero-adapter-fastly --lib`. With the selected Viceroy on PATH, run `CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --test contract`, and repeat with `--lib`. Native feature-enabled linking is not a required test route. Checkpoint: `test(fastly): verify reusable sandbox lifecycle over HTTP`. + +## Task 8: Exercise Cloudflare and Spin retained ownership + +**Files:** fixture Cloudflare and Spin package files listed in the file map; provider sections of the harness. + +- [x] **8.1 Add the Cloudflare fetch fixture.** Use a `cdylib`/`rlib` and `#[event(fetch)]`. A launch-time fixture mode selects `run_app` or a concrete `OnceLock` populated only after `App::build::(CLOUDFLARE_PLATFORM)` succeeds, with `dispatch_app::(&app, req, env, ctx)`. Add a separate concrete `OTHER_APP`; never place an unkeyed static inside a generic function. Do not retain initialization errors, `Env`, `Context`, bodies, bindings, admission, or egress resources. +- [x] **8.2 Configure local Worker builds and bindings.** Use `main = "build/worker/shim.mjs"`, record a fixed compatibility date, and build with `worker-build --release . -- --locked` from the package. Seed only local KV using the same persistence directory supplied to Wrangler. No remote commands or account credentials are needed. +- [x] **8.3 Add the Spin HTTP fixture.** Use the pinned SDK 7's `#[http_service]`, successful-only concrete app statics built with the selected platform, and `dispatch_app::(&app, req) -> anyhow::Result`. This is a raw response, not a collected SDK response. Configure component permissions, fixture variables, and a local KV store in `runtime-config.toml`. Keep bodies, admission/egress, and pending futures request-owned. +- [x] **8.4 Build and boot Spin.** Run `cargo build --locked --release -p fixture-spin --target wasm32-wasip2` from the fixture workspace. Resolve the component source to an absolute path in copied run manifests. Use the selected `spin up --listen
--runtime-config-file --from `. Reverify the current SDK7/P3 interface and host together; historical host boot does not establish current compatibility. Record independent inspection as unavailable if no inspection tool is installed. The target name alone is insufficient. +- [x] **8.5 Detect Spin controls from the selected runtime.** The investigated Spin 4.0 help does not expose newer reuse/concurrency controls. Exercise controls only when advertised, record their values, and otherwise observe default behavior while marking those controls unavailable. Never blindly pass flags copied from newer documentation. +- [x] **8.6 Run sequential ownership and binding assertions on both providers.** Compare per-request versus retained builds, verify different concrete apps return different routes, and check named/default metadata, cookies, bodies, and rendered errors. Restart to prove cold initialization remains correct. +- [ ] **8.7 Force overlapping requests on the same guest.** Hold two distinct requests at the backend barrier, verify both arrivals, release them, and assert stable guest identity, maximum in-flight count at least two, and unchanged per-request fields after awaiting. Different instances or serialized delivery are incomplete evidence, not a pass; retry only within a bounded deadline. Retained Spin and both Cloudflare modes passed; the latest Spin per-request control selected separate guests in all three attempts, so this full matrix criterion remains unverified. +- [x] **8.8 Verify recoverable binding failures where controllable.** A relaunch with changed bindings proves fresh initialization, not same-instance refresh. Record injected versus observed transitions and unavailable host failure modes separately. +- [x] **8.9 Run scoped host tests, actual-target checks, and each HTTP smoke command.** Use `cargo test -p edgezero-adapter-cloudflare`, `cargo test -p edgezero-adapter-spin`, and the Task 12 target checks. Run the script once with `--adapter cloudflare` and once with `--adapter spin`, both `--suite smoke`. Checkpoint: `test(adapters): verify retained app isolation on Workers and Spin`. + +## Task 9: Verify Axum and integrate runnable CI evidence + +**Files:** `tests/fixtures/reusable-app/crates/fixture-axum/{Cargo.toml,src/main.rs}`, harness Axum orchestration, `.github/workflows/test.yml`. + +- [x] **9.1 Add the native reference fixture.** Use existing `dev_server::run_app::()` with an allocated host/port and isolated local-store configuration. Add no new Axum lifecycle API. Run the same probe and overlap workload, expecting one build per server start and another after restart. +- [x] **9.2 Run the native fixture.** Build `fixture-axum` with the fixture manifest and `--locked`; run `cargo test -p edgezero-adapter-axum` and `./scripts/smoke_test_reusable_app.sh --adapter axum --suite smoke`. Axum timings do not stand in for WASM results. +- [x] **9.3 Add Rust harness unit tests and portable fixture tests to CI.** The root workspace excludes this fixture workspace, so explicitly invoke its locked `fixture-core` tests. Preserve all existing root tests. +- [x] **9.4 Add the Fastly HTTP smoke step to the existing Fastly WASM matrix job.** Reuse its Viceroy installation and version from `.tool-versions`; do not add a competing download. Require the runtime and actual multi-request evidence. Keep existing WASM contract and library tests: they cover different behavior from a live multi-request fixture. +- [x] **9.5 Check CI command parity locally.** Cloudflare/Spin HTTP runtime provisioning is not currently supplied by the existing contract matrix; retain explicit manual commands and report unavailable evidence. Do not label their WASM contract success as overlapping live-request validation. Checkpoint: `ci: run reusable Fastly HTTP smoke fixtures`. + +## Task 10: Produce controlled lifecycle measurements + +**Files:** benchmark/accounting sections of `tests/fixtures/reusable-app/crates/fixture-harness/src/main.rs` and fixture instrumentation; reports go under ignored `.runs/`. + +- [x] **10.1 Add measurement validation tests.** Reject missing metrics represented as zero, negative phase deltas, mismatched tool/build identities, and SDK wall time mislabeled as CPU. Run the Rust harness tests before and after implementing the report functions. +- [x] **10.2 Preflight guest CPU and memory capabilities.** Before enabling `with_max_memory`, require a successful guest heap snapshot. If unsupported, omit that optional limit in the comparison and report the metric unsupported. Separately test conservative SDK termination only if the selected host can produce the unsupported-snapshot failure naturally or exposes a documented hostcall fault-injection facility. Record that mechanism and label injected evidence. Otherwise mark this runtime case unsupported/unverified; do not patch the SDK or substitute a decision-model test as provider evidence. Record precision and source for every memory/CPU observation. +- [x] **10.2a Exercise all four independent SDK limits.** Test `with_max_requests` with 1 and 10, `with_timeout` with a controlled idle gap, `with_max_lifetime` by crossing a positive elapsed deadline during controlled initialization before the next callback, and `with_max_memory` after successful heap preflight with a threshold below the measured fixture baseline. Check summary/next-instance behavior without assuming exact eviction timing or guaranteed reuse. Keep limit-boundary tests separate from performance runs so one limit cannot mask another. +- [x] **10.3 Instrument phases.** Capture before/after initialization and request cleanup memory; construction/configure counts; initialization and handler wall time; supported SDK CPU phase deltas; commitment, guest completion, and peer completion. Current Fastly callbacks include adapter-owned terminal delivery, not a returned-response sender outside the callback. Keep SDK attempted counts separate from completions. +- [x] **10.4 Run repeated matched A/B/C workloads.** Default to three repetitions of 100 sequential requests per variant, finite limit 10, randomized variant order, release builds, and identical instrumentation/backend/payloads. Record configurable sample counts and seeds. Include cheap and deliberately expensive construction, delayed/large streams, failures, idle gaps, and a longer bounded run with a larger finite limit for memory growth. +- [x] **10.5 Exercise finite proxy-origin pressure.** Compare one repeated origin with many distinct loopback origins owned by the harness. Bound count and deadlines, and record waits/failures/completions. Local behavior cannot establish the deployed service-wide dynamic-backend capacity limit, which may block registrations pending capacity. +- [x] **10.6 Exercise malformed input and interrupted bodies.** Distinguish host rejection before dispatch from observed adapter conversion errors; label fault injection explicitly. Do not infer a demonstrated exploit from a hypothetical request. +- [x] **10.7 Summarize raw evidence.** Report sample counts and documented p50/p95/p99 calculations for cold/reused first-byte and completion latency; attempts/completions per guest; build/configure counts; supported CPU observations; memory peak, slope, and plateau over actual ordinals. Distinguish guest linear-memory high-water marks, host-inclusive snapshots, and process RSS. Rounded MiB observations cannot rule out small leaks. +- [x] **10.8 Classify each result.** Use pass/fail/unsupported/unverified. Require a reproducible workload benefit and bounded retained growth before recommending adoption; set no invented speedup target. SDK vCPU readings alone do not justify cross-run performance claims. Deployed reuse frequency, eviction, logging endpoint validity, resource accounting, and performance remain unverified until separately authorized measurements. +- [x] **10.9 Rerun Rust harness and affected scoped tests.** Keep raw generated reports ignored unless explicitly approved for publication and scrubbed. Checkpoint: `test(lifecycle): measure initialization and reuse separately`. + +## Task 11: Document supported ownership and adoption + +**Files:** `docs/guide/adapters/{overview,fastly,cloudflare,spin,axum}.md`, `tests/fixtures/reusable-app/README.md`, and the relevant comment in `examples/app-demo/crates/app-demo-core/src/lib.rs`. + +- [x] **11.1 Update the adapter overview.** Explain portable ownership versus provider scheduling; safe retained app/configuration values versus request-owned handles/extensions/bodies/pending work; intentional shared state; and caller-owned refresh, rotation, isolation checks, and benchmarks. +- [x] **11.2 Update Fastly's guide.** Show the compiled standard helper and raw callback examples. Cover all four SDK limits, first-snapshot logging, `FnMut` reborrowing, explicit sends/streams, post-send completion, attempted summaries, terminal errors including failed error rendering, memory preflight, and possible fresh initialization at any request. State that retained apps do not automatically remove explicitly repeated construction or parsing elsewhere. Explicitly distinguish sandbox `FASTLY_TRACE_ID` from per-request `Request::get_client_request_id()`. Document an application-generated per-request correlation fallback when native IDs are unavailable, identify its source, and keep it out of retained logger/app state. Explain persistent warning suppression, bounded-cache eviction, and why warning frequency does not measure failure frequency. +- [x] **11.3 Update Cloudflare and Spin guides.** Show concrete app-owned caches with explicit metadata and request-local dispatch. Explain caller logging before initialization, concurrent access, P3 interface verification, and runtime-specific capability detection. Avoid generic-static examples. +- [x] **11.4 Update Axum's guide and qualify the demo comment.** Document its existing retained router; describe the Fastly comment as applying to the default entry point. Keep default generated templates and macro settings unchanged. +- [x] **11.5 Write fixture instructions from commands actually exercised.** Include tool selection, build targets, local store setup, smoke and benchmark commands, evidence statuses, and default-entry-point rollback. Restore A to test rollback; limit one alone does not recreate all original initialization behavior. Mark unmeasured results explicitly. +- [x] **11.6 Compile every example through the matching fixture target.** Keep guide snippets synchronized with the compiled fixtures rather than adding a second untested example family. Run scoped Cargo tests if the demo source comment changes, per repository convention. +- [x] **11.7 Run docs checks.** From `docs`, run `npm run format`, `npm run lint`, and `npm run build`. Check all new documentation for prohibited external consumer identifiers and local machine paths. Checkpoint: `docs: explain opt-in application retention across adapters`. + +## Task 12: Final verification and review + +**Files:** no planned new implementation files; fix only findings in the files above. + +- [x] **12.1 Review the final diff against current authority.** Preserve lifecycle defaults, macro grammar, and header duplication while using fallible platform-aware assembly, canonical admission, and lazy owned egress. PR #275 supersedes the original unchanged-conversion/stream-cap claims. Production surfaces include the public Fastly lifecycle and borrowing hooks, `Hooks`-typed retained dispatch, and request-local resources; no compatibility alias restores removed paths. +- [x] **12.2 Run all repository gates.** Expected: each command exits successfully; record unavailable targets/tools separately rather than claiming success. + +```sh +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-fastly --target wasm32-wasip1 --features fastly +cargo check -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +``` + +- [x] **12.3 Verify the standalone workspace explicitly.** Run its format check, locked core tests, each provider's target build, native Axum build, Rust harness tests, and all available HTTP smoke suites. Do not run native all-feature tests across its Fastly members. Root Cargo success does not cover this nested workspace. +- [x] **12.4 Request independent review using `superpowers:requesting-code-review`.** Supply this plan, the governing spec, the diff, and actual test/evidence results. Review architecture/default compatibility and runtime/build evidence as separate scopes. Use `superpowers:verification-before-completion` before claiming implementation completion. +- [x] **12.5 Resolve findings and rerun affected checks.** Broaden repeat testing only when changes or unresolved concerns justify it. Do not silently weaken an evidence assertion because a local runtime cannot satisfy it. +- [x] **12.6 Report delivery status.** List APIs shipped, default compatibility, passed gates, observed provider behavior, unsupported/unverified acceptance items, and required application-owned adoption work. No deployment, PR/issue comment, or performance claim beyond the evidence is authorized by this plan. + +## Coverage and decision record + +| Requirement | Implementation / evidence | +| -------------------------------------------------------------- | ----------------------------------------------- | +| Portable ownership, fresh request state | Tasks 1, 4, 5, 8, 9 | +| Fastly opt-in lifecycle, all supported limits | Tasks 2, 3, 7, 10, 11 | +| Host-testable production initialization | Task 2; provider assertions remain in Tasks 7–8 | +| Custom mutable dispatch, streaming, finalization, pending work | Task 7 | +| Metadata and logging snapshots/recovery | Tasks 2–4, 7–8 | +| Attempted versus completed requests and terminal failures | Tasks 6, 7, 10 | +| Same-instance overlapping-request isolation | Tasks 1, 6, 8, 9 | +| A/B/C cost separation and bounded memory | Task 10 | +| Generated/default compatibility and rollback | Tasks 3–4, 7, 11–12 | +| Persistent-state audit and supported cleanup | Tasks 1.6a–1.6b, 7.8, 11.2 | +| Sandbox identity versus request correlation | Tasks 7.5a, 11.2 | +| Local versus deployed evidence | Tasks 6–12 | + +The planning review compared two focused proposals: a private Fastly state helper with thin wrappers, and a standalone provider fixture workspace that reuses existing CI runtime setup. The selected approach keeps SDK calls out of host unit tests while testing the same production initialization state. It preserves existing dispatcher ordering and originally avoided a public helper. That private-helper choice is superseded by [the custom serving lifecycle plan](2026-09-17-custom-serving-lifecycle.md). Independent complete-plan review approved this plan after two corrections: unique candidate instance tokens on every request, and explicit unsupported/unverified status when no SDK heap-hostcall failure mechanism is available. Implementation outcomes are checked below; these marks do not assert that a suggested commit was made or that unavailable provider evidence passed. + +A subsequent independent alignment review found two verification omissions from spec §6.3. Tasks 1.6a–1.6b now schedule the persistent-state audit and supported cleanup checks; Tasks 7.5a and 11.2 explicitly cover request correlation and its fallback. These are verification/documentation additions, not evidence of a production defect or a change to lifecycle ownership. + +## Historical execution checkpoint + +The results in this section predate reconciliation with PR #275. They record the +original lifecycle implementation, not current API compatibility or a rerun of +the authoritative outbound contracts. Reverify the current fixtures and gates +before making a new passing claim. + +Implementation is on `feat/reusable-app-lifecycle` in the original checkout. +The implementation is committed on this branch. Existing default entry points, generated templates, +macro grammar, and lifecycle error policy are unchanged. One targeted compatibility +fix is included: Cloudflare now preserves repeated application response headers, +including `Set-Cookie`, while still replacing generated defaults with the first +application value. Live tests reproduced the original loss and pass with the fix. + +Tasks 1–9 and 11–12 have implementation and local verification, subject to the +explicit full-matrix exception in 8.7. Task 10 has the measurement implementation +and historical controlled-run observations recorded in this plan and the PR. +The fixture README describes reproducible commands and assertions. Checkmarks denote completed +implementation/verification work, including an explicit capability disposition; +they do not turn unsupported behavior into a passing provider assertion. + +The final Rust driver has 17 unit tests; the portable fixture has two. Root +workspace tests, strict all-feature Clippy, formatting, feature checks, and +Fastly WASM library/contract tests pass. Provider target builds and live HTTP +checks exercise all four adapters. Fastly's expanded fault cases, Cloudflare's +both-mode overlap, and Cloudflare/Spin retained binding recovery pass. Spin's +retained overlap passes; the latest all-provider invocation exits 2 because +its per-request control did not observe same-guest overlap after three attempts. +No assertion failure is hidden by that status. + +Remaining evidence is environmental or outside this implementation: + +- No local control was found for transient failure of the fixed optional runtime + store or an unavailable heap hostcall. Host snapshot injection and real required + binding recovery are separate, labeled checks. +- No reachable public input triggers an error in the current error renderer. + A trap is not evidence of a returned rendering error. +- No component-inspection executable was installed; the historical Spin component + boot supplies evidence only for that tested SDK/host combination. +- Historical conversion-only phase observations do not measure the current + adapter-owned delivery path. Unavailable CPU/cleanup phases remain unknown. +- Deployed reuse frequency, resource accounting, capacity, and application workload + benefit require separately authorized deployment and application-owned adoption. +- The 300-probe run with limit 100 still observed at most six requests per guest. + Rounded heap samples were stable at 2 MiB; longer per-instance growth remains + unverified because increasing the configured limit did not extend host reuse. + Cached Viceroy 0.17.0 source confirms a hardcoded five additional requests + (`NEXT_REQ_ACCEPT_MAX`); the installed CLI has no override. + +The bounded lifetime test crosses a 50-ms deadline during a 100-ms initialization; +it proves the next callback is not admitted after expiration, not interruption of +an active handler. The memory-limit test uses a threshold below the observed +fixture baseline after successful preflight. Neither requires an unbounded load. + +The HTTP driver is Rust. Source files use descriptive names; A/B/C are experiment +labels only. Raw evidence stays under ignored `.runs/` directories. Independent +measurement and diff reviews led to explicit unknown metrics, consistent request +correlation, and phase-specific CPU/memory reporting; the final review found no +further actionable issue. That validation checkpoint made no deployment or external comment. The implementation was subsequently committed and submitted as PR #379. + +The follow-up risk review added a core cancellation regression: dropping a +suspended request releases its extension resources while retaining the router, +and a subsequent request has no stale request extension. The adapter overview +now gives concrete mitigations for stale snapshots, cache growth, concurrent +mutation, logging ownership, failures, and partial streams. These protect and +explain existing behavior; application refresh policies and provider lifecycle +limits remain outside the framework's ownership. + +## PR #275 Reconciliation Evidence + +The main merge retains opt-in successful application reuse but hard-cuts historical assembly +and conversion paths to the authoritative outbound contract. Prebuilt Cloudflare/Spin dispatch +and Fastly retained/custom serving use canonical admission and adapter-owned lazy egress. +Every request resolves fresh registries and lifecycle resources. Custom Fastly callbacks return +terminal `Result<(), E>` without allowing the SDK to send a replacement response. + +Verified locally with the current pins: 733 core tests and the full workspace suite; native +and all provider-WASM lint; provider runtime contracts; 5 fixture-core and 26 harness tests; +Fastly fixture finalization and Viceroy 0.21.0 HTTP smoke; demo host tests/lint/WASM builds; +fresh generated-project tests/provider builds; docs format/lint/build and contract guards. +The truncated-upstream proof gates its abort on client observation of the partial body. +The immediate zero-byte source-failure proof requires one correlated terminal completion, +no fallback, and terminal guest eviction; only EOF/reset before a status is accepted, never +a timeout or malformed response. Deployed reuse, teardown, and resource guarantees remain +outside this local evidence. diff --git a/docs/superpowers/plans/2026-09-16-outbound-batch-termination.md b/docs/superpowers/plans/2026-09-16-outbound-batch-termination.md index eca03417..f361ee3a 100644 --- a/docs/superpowers/plans/2026-09-16-outbound-batch-termination.md +++ b/docs/superpowers/plans/2026-09-16-outbound-batch-termination.md @@ -4,7 +4,7 @@ **Goal:** Make normal completion, method-cutoff termination, and malformed adapter-driver termination unambiguous in the public outbound batch API. -**Architecture:** Replace the stream-like `Option` terminal signal with an explicit `OutboundBatchNext::{Item, Finished}` result and add `OutboundBatchTermination::{Completed, Cutoff}` to ordered results. Adapter streams emit private-contract driver events for slots, cutoff, or failure; core validates index range, uniqueness, and premature EOF and returns `OutboundBatchFailure` with the precise `EdgeError::Internal` and previously collected slots instead of silently converting invariant violations into unresolved slots. +**Architecture:** Replace the stream-like `Option` terminal signal with an explicit `OutboundBatchNext::{Item, Finished}` result and add `OutboundBatchTermination::{Completed, Cutoff}` to ordered results. Adapter streams emit public driver events for slots, cutoff, or failure; core validates index range, uniqueness, and premature EOF and returns `OutboundBatchFailure` with the precise `EdgeError::Internal` and previously collected slots instead of silently converting invariant violations into unresolved slots. **Tech Stack:** Rust 2024, `futures`, `async-stream`, EdgeZero `EdgeError`, provider adapter contract tests, VitePress documentation checks. @@ -50,7 +50,7 @@ pub enum OutboundBatchNext { Change `OutboundBatch::next` to return `Result`, `collect` to return `Result`, and `HttpClient::send_all_until` to return `Result`. Add `termination` to `OutboundBatchResults`. -Use the public adapter driver event with `Item`, `Cutoff`, and `Failed` variants. Mark completion only after every index resolves. Return a fixed category-safe internal error for core-detected premature EOF, duplicate indices, out-of-range indices, or impossible terminal transitions; preserve adapter-supplied failures exactly. Ordered collection retains every slot observed before either class of failure. The event and constructors were promoted from doc-hidden adapter support by the round-6 hardening pass. +Use the public adapter driver event with `Item`, `Cutoff`, and `Failed` variants. Mark completion only at driver EOF after every index resolves. Return a fixed category-safe internal error for core-detected premature EOF, duplicate indices, out-of-range indices, or impossible terminal transitions; preserve adapter-supplied failures exactly. Ordered collection retains every slot observed before either class of failure. The event and constructors were promoted from doc-hidden adapter support by the round-6 hardening pass. - [x] **Step 4: Run core tests and verify GREEN** @@ -200,3 +200,37 @@ Run: `git diff --check`, search for removed API shapes, and confirm only intende Commit message: `fix(outbound): type batch termination` Update the PR description and wait for hosted checks to complete. + +### Review Follow-Up: Driver Finalization + +- [x] Add failing tests for trailing failure, duplicate/out-of-range indices, pending finalization, + zero-slot drivers, and cutoff after every slot resolves. Preserve collected slots on failure. +- [x] Remove completion inference from item count. Accept driver EOF only after every slot + resolves; reject malformed intervening events and retain poisoned-repoll behavior. +- [x] Run `cargo test -p edgezero-core outbound::tests::batch_` and all adapter batch contracts. +- [x] Align the specification, public driver docs, and implementation evidence before delivery. + +**Evidence (2026-10-06):** The trailing-event, zero-slot, and pending-finalization regressions +failed against item-count completion before the fix. Core now validates clean EOF, retains +completed slots on driver failure, and releases a pending finalization driver on cancellation. +The full workspace suite passes, including 720 core unit tests. Explicit nonzero native +contract runs pass for Axum (13), Cloudflare (3), Fastly (1), and Spin (2); strict WASM clippy +passes for all three provider targets. Specs/guides and the documentation checker reflect the +EOF requirement. This follow-up does not claim new deployed-host evidence. + +### Review Follow-Up: Shared Future Driver (R5) + +- [x] Extract `OutboundBatch::from_futures` for Axum, Cloudflare, and Spin, preserving original + indices and preflight/terminal timestamps. Keep Fastly's provider-handle driver separate. +- [x] Drain ready exchange futures before the first yield, retain clean-EOF validation, and + introduce no core clock sampling or timer. Keep transport cancellation inside each adapter. +- [x] Add deterministic timestamp-retention, cutoff-attribution, cancellation, empty/premature + driver, and cutoff-release tests. Release pending futures before delivering `Finished(Cutoff)`. +- [x] Complete merge-wide native/WASM, reusable-fixture, demo/generator, and documentation gates. + +Verified during the main merge: 733 core unit tests and the full workspace suite pass; strict +native and provider-WASM Clippy pass; Cloudflare, Fastly, and Spin runtime contract suites pass. +The reusable fixture suite (5 core and 26 harness tests), Fastly owned-finalization test and +Viceroy 0.21.0 HTTP smoke pass. Demo host tests/lint and all three provider WASM builds pass, +as does a fresh generated workspace. Documentation format/lint/build, dependency contracts, +and the expanded legacy guard pass. This is local evidence, not deployed-provider promotion. diff --git a/docs/superpowers/plans/2026-09-17-custom-serving-lifecycle.md b/docs/superpowers/plans/2026-09-17-custom-serving-lifecycle.md new file mode 100644 index 00000000..d5030ff1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-custom-serving-lifecycle.md @@ -0,0 +1,76 @@ +# Custom serving lifecycle + +> Historical implementation record, reconciled with PR #275's outbound hard-cut. +> Its former SDK response-sending and manual conversion choices are superseded. +> The [outbound HTTP design](../specs/2026-05-21-outbound-http-design.md) remains +> authoritative for admission, owned response egress, and capabilities; no +> compatibility aliases restore removed APIs. Verification below predates this +> reconciliation and is not evidence of a current rerun. + +## Objective + +Provide reusable lifecycle mechanics for custom Fastly dispatch without taking +over application-specific initialization, response finalization, or streaming. +Keep the default entry points unchanged. + +## Implementation + +1. Add feature-independent `lifecycle::Sandbox` with successful-only lazy + initialization, attempted-callback and build counters, and a separate setup guard. + Preserve unconstrained initialization errors, including fallback routers. +2. Add Fastly-gated `serve_custom` and `run_custom`. Delegate receive limits and + terminal result accounting to the SDK, not response sending. Callbacks return + `Result<(), E>` after owned delivery; escaping errors stop serving without an + SDK-generated response or resend. Single mode does not enter the serving loop. +3. Replace the custom fixture's local state slot and retry bookkeeping with this + API. Use `App::build::(FASTLY_PLATFORM) -> Result` and retain + only successful construction. Route preparation/finalization through + `serve_app_with_hooks` or canonical send-owning request hooks, preserving lazy + streaming, duplicate headers, fresh registries/admission/egress, and post-send work. +4. Document ownership and migration, including request-scoped resources and + pre-commit versus post-commit errors. +5. Run host state tests, workspace checks, WASM fixture checks, and the local + Fastly smoke suite. Independently review the implementation and consumer fit. + +## Other adapters + +Cloudflare and Spin expose `dispatch_app::` with metadata from `A::stores()` +and freshly resolved request resources. Their callers own retention compatible +with host concurrency and invocation lifetime. Axum constructs its app once for +the running server. Fastly's bounded receive loop is not portable to these hosts; +this extension does not introduce shared mutable singleton state into them. + +Build with `App::build::(PLATFORM)`; `Hooks::configure` is fallible after target +metadata is installed. Cloudflare dispatch takes `(&App, Request, Env, Context)` +and returns `Result`. Spin dispatch takes `(&App, SpinRequest)` +and returns `anyhow::Result`, the raw WASI response backed by owned +egress. Neither dispatcher builds an app or installs logging. Never cache an +initialization failure or retain request registries, admission resources, or +delivery coordinators. Pair the retained app with its construction `Hooks` type +and selected platform. + +## Acceptance + +Health callbacks count but skip initialization. A failed build is returned and +not retained; the next attempt can succeed and subsequent requests reuse it. +Successful setup survives application retries. The custom runtime fixture must +observe recovery and reuse in the same guest, with progressive streaming, +duplicate cookies, finalization metadata and post-commit errors preserved. +Local evidence does not establish deployed eviction or long-lived memory bounds. + +## Historical verification + +Implemented and independently reviewed against the SDK sending contract and a +custom consumer's ownership requirements. Workspace tests: 1,435 passed, one +existing ignored test. Fixture unit tests: 19 passed. Workspace and fixture +Clippy, Fastly WASM compilation, adapter feature checks, Spin WASM check, Rust +formatting, and documentation format/lint passed. + +The Fastly smoke suite passed under Viceroy 0.17.0. Its recovery sequence observed +five callbacks in one instance: statuses 200/503/200/200/200 and initialization +attempts 0/1/1/2/2. Only the fourth callback built the retained app successfully; +the fifth reused it. Separate client records and the initialization runtime log +establish that sequence; it is not included in the aggregate guest phase summary. +Progressive streaming, duplicate headers, response finalization, and post-commit +error fixtures also passed. These are local compatibility observations, not +deployed lifecycle or comparative performance evidence. diff --git a/docs/superpowers/plans/2026-09-17-lifecycle-contract-completion.md b/docs/superpowers/plans/2026-09-17-lifecycle-contract-completion.md new file mode 100644 index 00000000..8d299342 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-lifecycle-contract-completion.md @@ -0,0 +1,59 @@ +# Lifecycle contract completion + +> Historical implementation record, reconciled with PR #275's outbound hard-cut. +> Raw conversion, response-returning dispatch, and collected response paths are +> superseded by canonical admission and adapter-owned lazy egress. The +> [outbound HTTP design](../specs/2026-05-21-outbound-http-design.md) is authoritative; +> no compatibility aliases restore removed APIs. Recorded checkmarks and test +> counts below predate reconciliation and require current verification. + +**Goal:** Make the supported custom lifecycle executable and keep target-specific +configuration validation scoped to its destination without disclosing secret references. + +**Architecture:** Keep provider scheduling separate from successful-only app +retention. Assemble with `App::build::(PLATFORM) -> Result` and +fallible `Hooks::configure`. Fastly uses `Sandbox`, `serve_app`, and +`serve_app_with_hooks` with borrowing preparation/finalization and owned sending; +no raw conversion or response-returning helper bypasses admission or egress. +Cloudflare dispatch takes `(&App, Request, Env, Context)` and returns a Worker +response; Spin takes `(&App, SpinRequest)` and returns raw `SpinResponse`. Both +select `A::stores()` and resolve fresh registries/admission/egress per invocation, +without building an app or installing logging. Applications own retry policy and +never cache initialization errors. Standalone config validation remains the +portability check; push and diff validate only their selected adapter. + +## Lifecycle acceptance + +- [x] Extend the custom Fastly fixture and smoke runner with health bypass, + failed initialization, successful retry, and subsequent retained reuse. +- [x] Produce request-specific finalization metadata inside the router and verify + its value after dispatch, including across reused callbacks. +- [x] Document the provider/application boundary, retry policy, version pinning, + and the existing repeatable compatibility command in the Fastly guide. +- [x] Run fixture unit tests and the actual Fastly smoke suite. Missing observed + reuse must remain unverified, never a passing recovery assertion. + +## Configuration correctness + +- [x] Add regression tests for redaction of invalid and colliding Spin secret + references; verify failure before changing diagnostics. +- [x] Add selected-adapter push/diff regressions with unrelated invalid Spin + configuration, preserving standalone validation and selected-Spin failures. +- [x] Scope shared, typed, and strict capability validation to the selected + adapter for push/diff, retaining global schema and handler checks. +- [x] Keep field paths and naming rules in errors; omit raw and normalized values. + +## Historical verification + +- [x] Run scoped tests after code changes, then workspace tests, format, Clippy, + all-adapter feature checks, and Spin WASM compilation. +- [x] Run documentation format/lint and independent diff review. +- [x] Report local runtime evidence separately from deployed behavior. Do not + publish, tag, deploy, or change release pins as part of this work. + +Verified locally: 1,432 workspace tests passed (one existing generated-project +test ignored); 19 fixture tests passed; workspace and fixture Clippy passed, +including the Fastly WASM fixture; all-adapter checks and Spin WASM check passed; +documentation format/lint passed. Viceroy 0.17.0 smoke passed with all five +initialization callbacks in one guest. Independent lifecycle and CLI reviews +found no blocking defects. Deployed behavior remains outside this evidence. diff --git a/docs/superpowers/plans/2026-10-05-logging-ownership-boot-diagnostics.md b/docs/superpowers/plans/2026-10-05-logging-ownership-boot-diagnostics.md new file mode 100644 index 00000000..2b0dd7d5 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-logging-ownership-boot-diagnostics.md @@ -0,0 +1,59 @@ +# Logging Ownership and Boot Diagnostics Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans to implement this approved plan with test-first changes and verification checkpoints. + +**Goal:** Share log-level resolution and preserve boot warnings without raising ordinary logging verbosity. + +**Architecture:** Use the existing `log` facade, one pure core resolver, and a fixed boot target. Reuse backend target filtering, initialize logging before application configuration, and defer configuration-read diagnostics as bounded state. + +**Tech Stack:** Rust, log, fern/log-fastly, simple_logger, Cargo, WASM contract tests. + +--- + +## Task 1: Shared Policy and Ownership + +Files: `crates/edgezero-core/src/{logging,lib,app}.rs`. + +- [x] Add resolver and boot-target tests; run `cargo test -p edgezero-core logging` and observe the missing-feature failure. +- [x] Implement `resolve_logging_level(&EnvConfig) -> LevelFilter`, `BOOT_LOG_TARGET`, and `BOOT_LOG_LEVEL`; document application filter/initialization ownership. +- [x] Rerun core tests and verify the resolver leaves the global maximum unchanged. + +## Task 2: Runtime Filtering and Startup Order + +Files: Fastly `src/{logger,lib}.rs`, Axum `src/dev_server.rs`, Cloudflare/Spin `src/lib.rs`, adapter contract tests. + +- [x] Add failing filter/order tests before changing runtime initialization. +- [x] Share level parsing; preserve boot warnings at every Fastly filtering layer and in Axum target filtering. +- [x] Initialize logging before application configuration and defer early setup warnings with bounded state. Hard-cut Fastly resolution to `FastlyRuntimeConfig` with explicit one-shot emission. +- [x] Preserve application-owned logging state and explicitly retain Cloudflare/Spin no-op initialization. +- [x] Run affected adapter tests and WASM compilation/lint gates. + +## Task 3: Demo, Generator, and Documentation + +Files: CLI `src/{main,demo_server,adapter}.rs`, CLI tests/templates/README, adapter/configuration guides, `CHANGELOG.md`. + +- [x] Add a failing bundled-demo logger-selection regression; avoid preinstalling the CLI backend for `demo`. Retain the capability gate using Axum's reusable logger-first preflight runner. +- [x] Update ownership/filter examples, no-op platform limitations, and boot `Warn` versus ordinary `Off` semantics. +- [x] Keep generated entrypoints free of duplicated logging implementation. +- [x] Run CLI/generator and demo checks. The bundled logging contract, excluded demo workspace tests, and full generated native/WASM workspace check pass. + +## Task 4: Verification and Delivery + +- [x] Review the complete diff with a read-only reviewer and resolve findings. Fastly and Axum/CLI reviewers found no production defect; harden SDK capture, public one-shot emission, conflicting-logger startup, and actual demo boot-warning assertions. Keep provider startup guarantees source-reviewed rather than claiming deployed evidence. +- [x] Run workspace tests, formatting, strict workspace clippy, feature checks, three WASM targets, docs lint/format, and generated/demo checks. + +Delivery record: [PR #275](https://github.com/stackpop/edgezero/pull/275) commit history and hosted check results. + +## Verification Evidence + +- Required native workspace test gate: pass, including 713 core tests and the new startup probes. +- Strict all-feature workspace clippy, Fastly CLI-only/no-feature clippy, and feature compilation: pass. +- Cloudflare, Fastly, and Spin WASM clippy with runtime/test-utils features: pass. +- Excluded app-demo tests, strict clippy, formatting, and all three WASM builds: pass. +- Full generated-workspace native/strict-clippy/core-test/WASM check: pass. +- Bundled demo CI sentinel: pass; runtime `Off` retains a real boot warning and terminal stderr. +- Strict public Rustdoc, docs lint/format/build, outbound docs, codec pins, and JSON-map audits: pass. + +Native `cargo test --all-features` is not the repository's test gate: it cannot link Fastly +runtime host imports on macOS. Runtime features are compile/lint checked and exercised by +provider-hosted CI. This change does not claim new deployed logger evidence. diff --git a/docs/superpowers/plans/2026-10-07-native-store-providers.md b/docs/superpowers/plans/2026-10-07-native-store-providers.md new file mode 100644 index 00000000..f323eb3d --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-native-store-providers.md @@ -0,0 +1,618 @@ +# Native store providers implementation plan + +- Status: Implementation complete through offline Stages 1-8. Native provider combinations, generated-consumer startup, workspace/native/WASM gates and documentation checks passed. Draft [PR #414](https://github.com/stackpop/edgezero/pull/414) was published via `gh stack` after rebasing and rerunning gates. Provider availability validation deliberately remains structural-only/unknown. Real AWS acceptance is declined and excluded. +- Date: 2026-10-07. +- Owning issue: [EdgeZero #410](https://github.com/stackpop/edgezero/issues/410). +- Governing design: [Native store providers independent of HTTP adapters](../specs/2026-10-07-native-store-providers-design.md), including the R1 Cargo feature-resolution correction. +- Inspected main: `683202c66948146ec360f490126f1d60f920288b`. +- Prerequisite evidence: PR #275 at `d1a81579afb96f200e7fe8fd294600f0b446b080`; PR #406 at `3ab22096fd117f7506cf437b5ba128e0789e059c`. Both remain open at planning time. Their inspected APIs are not APIs available on main. +- Evidence level: Executed combined-prerequisite tests, native service feature combinations, real SDK literal-loopback fixtures and actual CLI-generated consumer startup. No real AWS, IAM, image or deployment acceptance. + +## 1. Goal and scope + +Allow native applications to bind KV, configuration, and secrets independently of their HTTP runtime and independently for each logical store ID. Ship AppConfig Agent whole-document configuration and Secrets Manager whole-value secrets as optional native providers. Keep ordinary local providers, portable declarations, typed envelopes, nested secret references, and per-ID registries. + +Success means a generated native application can start with mixed local, custom, and AWS bindings, validate required typed application inputs before readiness, serve requests from the same completed registries, and adopt remote changes only on restart. A failed mapped remote fetch must stop startup without falling back to files or environment values. + +Do not implement DynamoDB, live reload, secret refresh, publishing, provisioning, a provider plugin registry, a new HTTP runtime, or application-specific schemas. Do not extract existing local providers into new crates. Do not change portable trait future bounds, store declarations, macro metadata, or core errors to expose AWS details. + +The user's subsequent instruction to implement authorizes execution of this plan and rebasing onto the open prerequisites. Stage proof gates remain; do not skip verification between stages. Real AWS acceptance is excluded. Subsequent approval authorizes #410 commits and a draft via `gh stack`, but not merging, deployment, or changes to existing prerequisite PRs. Rewriting the prerequisite commit as part of the expressly authorized rebase is recorded below. + +## 2. Decisions and approval status + +The configurable 8 MiB default, shared-session quota scope, and caller-owned preparation boundary were approved in conversation on 2026-10-07. After the initial Stage 0 audit, the user explicitly instructed: "Those pull requests are not blockers. Go ahead and implement. If you need changes from those pull requests, rebase on top of them." This authorizes implementation of the proposed design and stages, including use of open prerequisite heads. Remaining policy proposals are the implementation contract unless the user changes them. This original instruction did not authorize AWS service access or publishing. Later approval authorizes draft publication; real AWS acceptance remains excluded. + +The implementation follows these choices: + +1. Use a move-only `NativeStoreOverrides` argument separate from cloneable `AxumRunOptions`. Each supplied ID has either an existing handle or a deferred, owned, non-`Send` future. Validate the complete source plan before polling futures. Require deferred future construction to be side-effect-free, with provider work inside its async body. The runner guarantees no polling before validation; it cannot undo caller-side work during future construction. +2. Keep a cloneable `NativeStoreBindingsInput` selection inside `AxumRunOptions`: local defaults, an absolute bindings-file path, or an inline typed `NativeStoreBindings` value. File and inline settings are mutually exclusive; selection methods reject a second explicit source rather than overwriting it. Both normalize into the same validator and preparation path. Noncolliding supplied handles/futures may coexist as the separate overrides argument; same-ID collisions are errors. +3. Use one explicit `AwsPreparation` session for all built-in AWS bindings prepared by a runner invocation. It owns budgets, successful target deduplication, and client reuse. Do not provide convenience constructors that silently create an independent budget per binding. +4. Apply declared native build features to both standard native build and serve commands. Normalize manifest and explicit feature inputs once; use the same selection when offline validation can resolve the actual target. +5. Add a bindings-only mode to `config validate` when `--adapter axum --store-bindings ` is supplied. It does not require a local app-config TOML file or claim to validate remote contents. Preserve the existing validation mode without these arguments. +6. Use a Cargo-resolution parity spike before shipping provider-availability verdicts. A failed or ambiguous offline resolution produces an explicit structural-only result, never an inferred enabled/disabled result. +7. Keep provider failures as closed, sanitized native categories. The coordinator attaches only the baked store kind and logical ID. Do not retain or format SDK, HTTP, TOML, credential, or arbitrary custom-provider error chains. + +Approved accounting and ordering policy: + +- The standard runner uses one shared session to enforce combined quotas across all its built-in AWS bindings. Prebuilt opaque handles, local providers, and separately created sessions remain caller-owned and are not included. Independent sessions may together exceed 8 MiB; this is not a process-wide limit. +- Validate sources before opening runner-created providers or polling deferred supplied futures. Require side-effect-free deferred construction. A caller can already have opened a `Ready` handle or done other work before supplying it; the runner cannot undo that work. + +Do not introduce process-global accounting or mandatory introspection of portable handles. The former accepted-only prerequisite gate is superseded by the user's authorization to use open PRs. Re-audit and test the exact combined stack; never claim those contracts are accepted on main. Runtime proof, feature isolation, and separate AWS acceptance gates remain. + +## 3. Delivery order + +```mermaid +flowchart TD + Audit[Stage 0: approve policies and re-audit prerequisites] --> Compose[Stage 1: generic native composition] + Compose --> Settings[Stage 2: strict bootstrap and AWS settings] + Settings --> Session[Stage 3: shared preparation and snapshot contracts] + Session --> Agent[Stage 4: AppConfig Agent] + Session --> Secrets[Stage 5: Secrets Manager] + Agent --> Tooling[Stage 6: generated features and offline CLI] + Secrets --> Tooling + Tooling --> Runtime[Stage 7: generated production startup proof] + Runtime --> Gates[Stage 8: docs and native/WASM CI] + Gates --> Acceptance[Stage 9: separately authorized AWS acceptance] +``` + +Stages 4 and 5 may be investigated in parallel. Keep edits to shared settings, session state, Cargo manifests, and lockfiles under one owner. Serialize Cargo commands sharing a target directory. Stage 6's Cargo-resolution spike can be investigated earlier, but do not publish a capability verdict until it passes. + +## 4. File map and ownership + +Existing files to change narrowly: + +| Area | Files | Intended change | +| --------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Workspace | `Cargo.toml`, `Cargo.lock` | Add optional native provider member/dependencies and coherent SDK pins | +| Native runtime | `crates/edgezero-adapter-axum/src/dev_server.rs` | Source planning, sequential setup, completed registry publication, additive runner API | +| Accepted production options | `crates/edgezero-adapter-axum/src/run_options.rs` | Bootstrap selection and local-root validation after source selection; exists at inspected #406, not main | +| Axum features | `crates/edgezero-adapter-axum/{Cargo.toml,src/lib.rs}` | Native-bindings/settings support and independent AWS feature forwarding | +| Axum CLI/scaffolding | `crates/edgezero-adapter-axum/src/cli.rs`, `src/templates/Cargo.toml.hbs` | Standard native Cargo invocation and generated service features | +| CLI inputs | `crates/edgezero-cli/src/args.rs` | Bindings path, selected adapter, target feature/default/all controls, push guard input | +| CLI validation/push | `crates/edgezero-cli/src/config.rs` | Bindings-only validation and explicit remote publication refusal | +| CLI build routing | `crates/edgezero-cli/src/{lib,adapter}.rs` | Normalize standard Axum feature selection before both generated-command and fallback dispatch | +| Generator | `crates/edgezero-cli/src/generator.rs` | Structural assertions and any needed dependency/feature template context | +| Generated integration | `crates/edgezero-cli/tests/generated_project_builds.rs` | Native provider combinations and preserved generated WASM builds | +| CI | `.github/workflows/{test,format}.yml` | Enabled-native tests, isolated feature lint, generated proof, dependency-graph assertions | +| Guides | `docs/guide/adapters/{overview,axum}.md`, `docs/guide/{configuration,cli-reference}.md` | Setup, support matrix, CLI modes, bounds, restart and stale-value policies | + +New files, names proposed: + +```text +crates/edgezero-adapter-axum/src/ + native_bindings.rs # SDK-free parsing, source validation, local-root needs + native_build.rs # SDK-free standard native Cargo selection, if extraction is needed + +crates/edgezero-store-aws/ + Cargo.toml + src/ + lib.rs + settings.rs # Strict types, selectors, endpoint/ARN validation + preparation.rs # Shared session, budgets, dedup, sanitized errors + appconfig_agent.rs # Restricted HTTP reader and immutable config provider + secrets_manager.rs # SDK boundary and immutable secret provider + +crates/edgezero-cli/tests/ + native_target_features.rs # Cargo-resolution versus compiled-fixture parity + +scripts/ + check_native_store_dependency_graph.sh + smoke_test_native_stores.sh + accept_native_aws_stores.sh # Only if an opt-in harness is useful; never default CI +``` + +Keep unit tests colocated. Add standalone fixture files only when the real generated/runtime path needs them. Prefer generating a temporary application through the existing scaffold over maintaining a second hand-written application. Keep fixture lockfiles current where fixtures are committed separately. + +Core and macro production changes are not expected after prerequisites are accepted. If new work appears necessary there, stop and explain the missing contract instead of expanding this plan silently. + +## 5. Stage 0: prerequisites, policy approval, and baseline + +### Work + +- [x] Read current `CLAUDE.md`, governing spec, #410/#324, and exact selected #275/#406 revisions. +- [x] Record selected stack commit IDs and integration differences. Open-PR use is explicitly authorized; do not label them accepted on main or implement an alternate bounded-read API. +- [x] Confirm `ConfigValue`, `BoundedStoreRead`, required bounded config/secret methods, application monotonic clock, fallible `App::build`, `PreparedStores`, initializer state insertion, and readiness/shutdown behavior on the combined stack. +- [x] Record the approved configurable 8 MiB session quota and caller-owned preparation boundary in both spec and plan. +- [x] Record the user's authorization to implement the plan and use open prerequisites, superseding the original accepted-only gate. Separate AWS acceptance remains unapproved. +- [x] Inventory and reuse the selected prerequisites' tests. Add only missing native composition/provider behavior. +- [x] Record a baseline for relevant native tests, existing workspace gates, and installed WASM targets. Classify pre-existing failures separately. All eight Stage 0 main checks passed; see the execution record below. +- [ ] Select exact compatible `aws-config` and `aws-sdk-secretsmanager` versions for the repository Rust toolchain. Add direct Smithy dependencies only when a shipped API or test connector needs them. Record the SDK behavior version, TLS/runtime feature choices, and Agent image/version to use later. + +### Exit gate + +The implementation plan is authorized. Use the exact user-approved prerequisite stack, source/test inventory, and passing combined-contract tests. Both PRs remain open; they are not a blocker under the user's instruction. Do not modify main or claim these contracts have landed. SDK graph/image selection still needs evidence before the corresponding provider stages. + +## 6. Stage 1: generic per-ID native composition + +### API shape + +Use the existing `KvHandle`, `ConfigStoreBinding`, and `BoundSecretStore` output types. A proposed native-only builder owns maps keyed by logical ID: + +```rust,ignore +// Exact exported names are settled after the accepted prerequisite re-audit. +// No Send bound on this future. Existing provider objects remain Send + Sync. +pub type NativeStoreFuture = Pin> + 'static +>>; + +enum NativeStoreSource { + Ready(H), + Prepare(NativeStoreFuture), +} + +pub struct NativeStoreOverrides { + // private keyed maps for KV, config, and secrets +} +``` + +Expose small typed insertion methods for each kind and for ready/deferred inputs. Insertions reject duplicates instead of overwriting a map entry. Keep map/source internals private. Require all deferred provider opening to happen inside the future body when polled, not during construction; this is a caller contract, not something the type system can prove. Tests establish zero polls and zero runner-owned opens on validation failure, not absence of earlier caller-side side effects. Do not require another public provider-factory trait. + +Add one runner entrypoint accepting options, overrides, stop, and the accepted app initializer. The options carry `NativeStoreBindingsInput`; Stage 1 starts with its local-default case, and Stage 2 adds file/inline normalization. The validator compares the selected settings IDs with the separate override maps before any preparation. Existing public entrypoints delegate with empty overrides. Keep `AxumRunOptions` cloneable; do not put futures into it. Owned deferred futures can capture `Rc` and bootstrap data. They cannot borrow options or the app initializer's higher-ranked inputs. + +The validated source plan has separate local, supplied-ready, supplied-deferred, and built-in-settings variants. `NativeStoreFuture` is only for opaque custom providers. File or inline settings become built-in variants, not stored futures: the runner matches each typed settings variant and directly awaits the matching method on its one mutable `AwsPreparation` session. This avoids trying to borrow that session into a stored `'static` future. For secrets, pass the baked logical namespace into snapshot construction and wrap the returned `SecretHandle` in `BoundSecretStore` with the same namespace. Config construction returns a `ConfigStoreHandle` paired with the validated settings default key. File and inline inputs cannot coexist; either selected input can coexist with noncolliding custom overrides. Custom-provider quota enforcement belongs to that provider, not to introspection of its eventual handle. + +### Work + +- [x] Compile-probe the proposed API with an `Rc`-capturing setup future that returns a valid handle on the existing current-thread `LocalSet`. +- [x] Preserve the initializer's accepted higher-ranked borrowing signature. Prove it still permits borrowing stores across await and capturing non-`Send` application state during setup. +- [x] Separate options/scalar validation from local-root requirements. Determine required roots from the resolved per-ID sources, not just declared store kinds. +- [x] Build one source plan for all kinds. Validate defaults, declared IDs, duplicate supplied inputs, explicit scalar-overlay conflicts, and local-root needs before preparing any runner-owned provider. Stage 2 adds file/inline settings and cross-source collision coverage. +- [x] Preserve config default keys, secret bound namespaces, and local KV NAME-derived paths. Explicit sources retain supplied semantics; implicit local sources keep existing env behavior. +- [x] Assemble the app in the selected prerequisite order, then sequentially prepare sources under the existing runtime. No additional runtime or initializer-side provider selection. +- [x] Stage every handle, finalize registries once after all sources succeed, preserve declared defaults, and share the completed `PreparedStores` between initializer and request service. +- [x] Drop staged handles and preparation futures on failure or startup stop. No initializer, readiness, or listener after incomplete preparation. + +### Proof + +Add colocated tests for: + +- ready and deferred custom providers, mixed same-kind bindings, and the default ID; +- config default-key preservation and wrong-secret-namespace isolation; +- undeclared IDs, duplicate supplied IDs, and explicit NAME/KEY conflicts; supplied/file and supplied/inline collision tests follow in Stage 2; +- zero deferred polls and zero runner-owned provider opens on every source-validation failure; +- remote/custom-only config without CONFIG_DIR, custom-only KV without DATA_DIR, and roots required only for remaining local IDs; +- sequential preparation, failure of a later binding, partial-state drop, and no initializer/readiness on failure; +- initializer and request service observing the same prepared handles; +- existing local development and strict production defaults with empty overrides; +- stop during a pending preparation without claiming all external work is forcibly canceled. + +Run scoped Axum tests after each code increment. Exit when fake providers prove generic composition without importing AWS. + +## 7. Stage 2: strict bootstrap parsing and settings-only AWS crate + +### Ownership and feature shape + +Native `native_bindings.rs` owns the top-level versioned schema, kinds/IDs, tagged dispatch, source collisions, overlays, local-root needs, and file limits. `edgezero-store-aws::settings` owns AWS settings and resource-shape validation. Never put provider fields into portable declarations or generated metadata. + +Add a settings-only dependency on the provider crate to the native bindings feature. This does not enable either service. The runtime and CLI can both use native-bindings without enabling each other's runtime/registry features. + +Provider features: + +```text +edgezero-store-aws + default = [] + appconfig-agent -> HTTP client, streaming support, native timers + secrets-manager -> AWS config/service SDK and native runtime support + +edgezero-adapter-axum + native-bindings -> native parser + SDK-free AWS settings + axum -> existing runtime + native-bindings + cli -> existing CLI + native-bindings + aws-appconfig-agent -> provider appconfig-agent + aws-secrets-manager -> provider secrets-manager + aws-stores -> both explicit service features +``` + +Use native target dependency declarations and module gates. Do not add another Tokio dependency to an adapter or any AWS/native dependency to portable core or WASM adapters. Service-disabled settings parsing must work without the service clients. All-feature builds must not conceal missing single-feature cfgs. + +### Work + +- [x] Add the provider workspace member, `lib.rs`, strict settings structs, version selector enum, limits, and closed redacted preparation error categories. +- [x] Add native-only version 1 tagged parsing with unknown/irrelevant fields, unsupported KV/provider tags, duplicate tables, empty mappings and undeclared IDs rejected. +- [x] Require mapped config defaults and case-sensitive IDs/lookup keys, without parsing/projecting remote values. +- [x] Validate complete Secrets Manager ARNs, supported partition/region-family relationships, explicit matching region, exclusive selectors and API length bounds. Full cross-account ARNs remain explicit. +- [x] Validate literal-loopback HTTP endpoints and original authority/path spelling; construct escaped segments and independently test decoded round-trip equality. +- [x] Add mutually exclusive absolute file and typed inline selection. Explicit APIs use only supplied bootstrap values; `from_env` alone resolves ambient locator/token variables. +- [x] Read selected regular files once with cap+1 probing, redacted fixed errors and Unix nonblocking FIFO rejection. +- [x] Capture locator/scalar inputs once, reject relevant normalized duplicates, capture named tokens once per name, and redact Debug/errors. Preserve unrelated EnvConfig duplicate semantics; ambiguous explicitly supplied token values fail closed. +- [x] Reject disabled providers before deferred polling/local provider construction. Services are not implemented/enabled at this settings-only checkpoint; actual compiled capabilities are added with Stages 3-5. + +### Limits schema + +Use a trusted bootstrap `NativeBindingsLimits` policy for the file byte cap and total explicit-ID cap. Defaults are 256 KiB and 128 IDs. A bindings file cannot raise its own pre-parse byte limit. Programmatic overrides of bootstrap limits are explicit; offline validation reports the policy it used. + +Propose an optional `[limits.aws]` table for remote preparation settings: + +| Field | Default | +| --------------------------- | ------------------------------------------------------------- | +| `max_remote_values` | 1,024 mapped config documents plus secret entries | +| `max_config_document_bytes` | 1 MiB | +| `max_secret_value_bytes` | 64 KiB | +| `max_snapshot_bytes` | 8 MiB combined unique retained AWS payload per shared session | +| `preparation_timeout_ms` | 30,000 | +| `operation_timeout_ms` | 5,000 | +| `connect_timeout_ms` | 1,000 | +| `attempt_timeout_ms` | 2,000 | + +The approved 8 MiB default is provisional and not derived from AWS limits or workload measurements. Keep an aggregate cap because many values can each satisfy per-value limits while collectively retaining excessive payload. Document its rationale and scope; revisit the default with representative application payload sizes plus headroom. Explicitly raising or lowering it must remain possible. + +These are configurable defaults, not immutable hard ceilings. Explicit file/typed policies may change service limits subject to finite/nonzero checks, checked conversions/arithmetic, and consistency of per-operation/aggregate and per-value/combined limits. The file-byte/explicit-ID limits change only through trusted native bootstrap options. Keep retry count and concurrency fixed initially. Count mappings before deduplication; deduplication must not let unlimited aliases bypass entry limits. Reject simultaneous explicit bootstrap-supplied and file-supplied AWS limit policies. Otherwise use the one supplied policy, filling omitted fields from documented defaults, or use all defaults when both policies are omitted. Do not fieldwise merge two supplied policies. The selected policy applies to built-in settings prepared through the shared session, not opaque custom handles/futures or separately prepared AWS handles. + +### Proof + +Settings-only tests cover every rejection rule, blank/non-Unicode bootstrap inputs, token absence/emptiness, env versus explicit options, file overflow, unsupported features, selectors, limits, and source conflicts. Verify parser errors do not contain sentinel ARNs, tokens, credentials, or raw TOML values. + +Run settings-only provider tests and Axum parser tests in runtime-only and CLI-only configurations. Inspect reachable normal dependencies to confirm the SDK and HTTP client are absent from settings-only builds. + +## 8. Stage 3: shared AWS preparation and bounded snapshot reads + +### Work + +- [ ] Implement an explicit, per-startup `AwsPreparation` session with sequential `&mut self` preparation methods. No process-global client/cache/session. +- [ ] Start one aggregate deadline before credential/client initialization. Derive operation, attempt, and connect budgets from remaining aggregate time and configured caps. Credential/setup elapsed time consumes this budget; check expiry before and after awaits and discard results completed after expiry. Classify credential/blocking-work cancellation as best-effort or unsupported under the accepted capability contract. Do not promise a 30-second wall-clock completion bound or that dropping a future stops a credential process or socket. Audit whether the pinned SDK operation timeout includes each supported credential provider; do not assume it does. +- [ ] Track mapping counts, unique retained payload bytes, metadata, and transient staging separately. Use checked accounting and reject overflow before retaining the next payload. +- [ ] Cache only successful targets. Reuse immutable payload allocations between identical targets while retaining separate logical-key and namespace mappings. +- [ ] Include normalized endpoint, application/environment/profile, and authorization policy identity in AppConfig dedup keys. Different token policies must not merge. Keep token identity internal and redacted. +- [ ] Include region/client-policy identity, full ARN, and exact normalized selector in secret dedup keys. Normalize omitted/current selection consistently; never collapse pinned versions or distinct stages. +- [ ] Reuse one Secrets Manager client per region/policy in the shared session. Config-only sessions must not initialize credentials or an SDK client. +- [ ] Implement immutable config and secret snapshot providers under accepted #275 methods. They do not keep cloud clients or issue request-time cloud calls. +- [ ] Ordinary config reads return shared `ConfigValue`; secret reads return exact `Bytes`. Unknown keys or wrong secret namespaces return successful misses without probing a service. +- [ ] Bounded reads reject an expired deadline before lookup, honor independent backend/value caps, report hit payload bytes and zero bytes on misses, and recheck deadline precedence before returning. Reuse accepted finishing helpers where suitable. +- [ ] Preserve accepted conservative deadline/allocation classification. Document that SDK parsing, credential work, allocator overhead, and the Agent are outside retained-payload accounting. + +### Proof + +Use a deterministic injected monotonic clock and production accounting paths. Test exact limits and limit+1, explicit higher/lower quota settings, aggregate bytes across both services and IDs, mapping-count limits before dedup, checked overflow, auth/selector-sensitive dedup, preparation deadline exhaustion, sanitized Debug/display, and independent sessions without global state. Prove one runner shares the same quota across all built-in AWS bindings and that a quota breach drops staged state before readiness without eviction or local fallback. Independently prepared opaque handles are not included in that accounting. Add a delayed credential-provider fixture that completes after the aggregate deadline and prove no value is retained or published. Separately test a pending credential future under supported timers. Neither fixture proves forcible cancellation of a real credential process or blocking provider; keep that fact unproven with best-effort wording. + +Run existing core config/secret contract macros against preseeded snapshot providers. Add bounded-read tests for both ordinary hits and misses, empty values, namespace isolation, backend/value allowances, and deadline precedence. These tests are separate from startup transfer tests. + +Atomic publication is the native coordinator's responsibility. A programmatic caller that retains a handle returned by one successful preparation cannot expect the session to erase that handle after a later failure. The standard runner drops all staged handles on failure; prove that path in Stages 1 and 7. + +## 9. Stage 4: AppConfig Agent whole-document preparation + +### Work + +- [ ] Build a dedicated native HTTP client. Disable ambient proxies, redirects, and transparent decompression. Do not reuse the generic outbound proxy client. +- [ ] Construct GET `/applications/{application}/environments/{environment}/configurations/{profile}` using validated, escaped segments. +- [ ] Send `Accept-Encoding: identity`. Send `Authorization: Bearer ` only when explicitly configured. Do not enable request/header/body diagnostics that reveal bootstrap data. +- [ ] Perform one bounded request per distinct complete target, including streamed body consumption. Reject non-success/protocol responses and non-identity content encoding. +- [ ] Reject oversize declared content lengths early when available; always enforce actual streamed bytes before accumulation exceeds the allowed limit. Detect truncated/erroring streams. +- [ ] Validate UTF-8 and retain the exact text, including whitespace, JSON quoting, empty documents, and serialized envelopes. Do not parse, unquote, reserialize, or resolve secret references. +- [ ] Make every mapped profile mandatory during preparation. A missing/unavailable mapped profile is a startup error, never a snapshot miss or local fallback. +- [ ] Drop response bodies and source errors before producing categorized diagnostics. Publish no partial binding. + +### Proof + +Use authorized loopback-only fixtures with bounded lifetimes and isolated ports. Cover IPv4/IPv6 validation, exact escaped paths, auth/no-auth, absent/empty tokens, proxy/redirect refusal, encoding rejection, status errors, malformed UTF-8, empty/raw/envelope fidelity, content-length and streamed cap edges, truncation, connect/body deadline behavior, dedup across IDs, auth-policy separation, and immutable reads after the fixture changes. + +Test generated envelope extraction and nested secret resolution later through the app initializer. A fake Agent proves reader behavior, not AWS reachability, deployment freshness, or warm-cache policy. + +Run AppConfig-only provider tests. Exit with no Secrets Manager SDK in the config-only normal dependency graph. + +## 10. Stage 5: Secrets Manager preparation and real SDK behavior + +### Work + +- [ ] Load the pinned SDK using explicit region, fixed behavior defaults, supported credential discovery, capped standard retries, and explicit operation/attempt/connect budgets. +- [ ] Count credential/client initialization against the shared aggregate budget. Use workload roles in documented production setup and a documented authenticated developer path. Do not require static keys in bindings or images. +- [ ] Make exact `GetSecretValue` requests for mapped ARNs/selectors only. Load every mapped entry before completing that binding. +- [ ] Accept exactly one value field. Return `SecretString` UTF-8 bytes or the Rust SDK's already-decoded `SecretBinary` bytes. Preserve empty/binary values where allowed. Do not extract JSON fields or base64-decode a Blob again. +- [ ] Check payload size after SDK materialization and before retaining it. Describe this as a retained-value cap, not a bound on every SDK allocation. +- [ ] Classify service/credential/transport failures before dropping source details. Keep missing resource/version, denied, decryption, throttled, deadline, malformed, and size categories where exposed. +- [ ] Let the SDK own transient retries, at most three total attempts and bounded by remaining budgets. Add no outer retry loop. Verify denied/decryption/validation errors are not repeatedly retried under the pinned policy. +- [ ] Spike the real generated-child test path against the pinned SDK now: child-only dummy credentials, metadata disabled, and supported service endpoint override to a loopback scripted HTTP fixture. Verify client configuration honors that override without a production bindings field or public mock API before Stage 7 depends on it. +- [ ] Drop clients/session staging after preparation; snapshot handles contain only successful mapped payloads. Never persist snapshots to disk or claim zeroization. + +### Test layers + +1. A narrow private service-call seam exercises production snapshot building, mappings, selectors, payload fidelity, errors, and cleanup with deterministic responses. Do not expose a public mock/factory hierarchy. +2. The pinned SDK with a scripted connector exercises real serialization, error classification, retry attempts, and timeout settings. Supply fixture credentials explicitly to avoid ambient discovery or IMDS. A fake service-call method cannot prove SDK retry behavior. +3. Separately authorized AWS acceptance proves deployed credentials, IAM, and service version behavior. + +Cover binary data that resembles base64 text, neither/both fields, empty values, default/current versus pinned versions, selectors, wrong namespaces, unknown keys without calls, ARN/policy-sensitive dedup, missing version, expired credentials, denied/decryption/throttle/transport errors, capped attempts, exhausted budgets, and partial-startup cleanup. + +Capture EdgeZero-owned logs and returned errors with sentinel values. Assert they contain no secrets, ARNs, token/credential sources, response bodies, or raw SDK messages. Dependency DEBUG/TRACE output is outside that guarantee; the executed diagnostic spike documents its disclosure risk. For the approved first version, retain redacted EdgeZero-owned diagnostics and explicitly disclose dependency DEBUG/TRACE output to operators and draft reviewers. Do not install global or scoped subscribers to silence dependency events. The user approved this limitation after the diagnostic spike; hardening dependency diagnostics remains a review item. The separately operated Agent's own logging policy remains operator-owned. + +Run Secrets Manager-only and combined provider tests. Exit with actual SDK retry/classification evidence, not just fake-seam assertions. + +## 11. Stage 6: generated features, Cargo selection, offline validation, and push guard + +### Generated/native feature enablement + +- [ ] Add generated native `[features]` entries for `aws-appconfig-agent`, `aws-secrets-manager`, and the proposed `aws-stores` alias. Use `default = []` for these target service choices and forward each to the matching adapter feature. +- [ ] Keep default native and generated Fastly/Cloudflare/Spin targets AWS-free. Keep CLI service capabilities independent from the binary being validated. +- [ ] Introduce one standard native Cargo selection helper shared by build/serve and offline resolution. Requested names combine declared build features and explicit additions; provider availability comes from resolved capabilities, not those names. +- [ ] Handle both real dispatch paths. Generated Axum manifests currently use shell `cargo build -p ` / `cargo run -p ` commands through `edgezero-cli::adapter::execute`; the registered Axum `run_cargo` fallback is not the sole build path. +- [ ] Route the exact standard generated Cargo command forms and registered fallback through the shared normalization. Do not parse or rewrite arbitrary operator shell programs as Cargo commands. Preserve custom-command ownership and mark capability selection unknown when its build behavior cannot be represented. +- [ ] Normalize repeated/comma/space `--features`, `--features=...`, `--all-features`, and `--no-default-features` before the Cargo/run `--` separator. Runtime args after that separator must not affect compile features. Preserve unrelated passthrough arguments. +- [ ] Identify the exact native package/manifest and target. Treat manifest target `native` as host selection, not literal `cargo --target native`. Do not silently override operator package/target selections. +- [ ] Recognize equivalent supported target flags in bindings validation, proposed `--target-features`, `--target-all-features`, `--target-no-default-features`, and `--target`. Document defaults and reject target-control flags outside bindings validation. + +### Cargo-resolution correctness spike + +Candidate resolution is `cargo tree --offline --locked` for the exact native package, normal/build edges, the same target and feature controls, and narrowly parsed package/feature records. Use metadata for package identity/topology only when it can be obtained offline without changing the lockfile. Do not assume metadata's workspace-wide graph or tree's "pretty close" build view is exact. + +- [ ] Build local-only resolver-2 fixtures and compare predicted provider availability with the same-selection compiled fixture's cfg result. +- [ ] Cover default/no-default/all/explicit/alias selection and dependency feature propagation. +- [ ] Cover sibling workspace members, own/sibling dev dependencies, build dependencies, target-specific edges, renamed/duplicate package identities, and host versus explicitly selected native targets. +- [ ] Prove an unrelated member or dev dependency cannot falsely enable a normal-runtime provider. Check the Axum adapter service feature and reachable provider feature, not just a same-named feature on another crate. +- [ ] Classify unsupported package overrides, custom shell commands, `--all-targets`, `--workspace`, `--config`, ambiguous contexts, unexpected Cargo output, missing lock/cache, or failed offline resolution as unknown unless a parity test proves support. +- [ ] Emit structural-only when resolution is unknown. Never edit the lockfile, fetch dependencies, build application code, execute build scripts, or discover credentials during the user-facing offline check. Fixture compilation belongs to tests, not validation. + +Exit this spike with a documented supported invocation subset and tests. If the candidate cannot prove a case, keep that case structural-only rather than building a second approximate feature resolver. + +### Bindings-only validation + +- [ ] Add `--adapter` and `--store-bindings` inputs plus target controls to `ConfigValidateArgs` and its defaults/parser tests. +- [ ] Branch before `load_validation_context` for the new bindings-only mode. Load portable manifest declarations and explicit native bindings; do not require `.toml` or run typed remote-content checks. +- [ ] Require Axum selection and an absolute explicit bindings path. Reject irrelevant adapter/target combinations. Preserve existing raw/typed validation paths without the new mode. +- [ ] Use the SDK-free native source validator with only supplied deployment inputs. Never read ambient NAME/KEY overrides, the ambient bindings locator, or Agent token/credential values here. Startup validates actual deployment inputs again. +- [ ] Report either structural validation plus known target-provider compatibility, or structural validation only with provider availability unknown. Neither result proves resource existence, token availability, local-root availability, IAM, typed config validity, or build success. +- [ ] Permit programmatic validation to supply explicit overlay/override information through the same pure validator; CLI file validation does not invent future supplied handles. + +### Local publication guard + +- [ ] Add explicit `--store-bindings` to downstream typed `ConfigPushArgs` and its parser/default construction sites. +- [ ] Resolve the selected logical config ID, then parse and check the explicit bindings file before remote read-back/diff, prompts, adapter publication, or local writes. +- [ ] Reject an AWS-bound selected ID with a fixed unsupported-publication message, including dry-run and `--local`. Unrelated AWS IDs do not block a local selected ID. +- [ ] Do not consult the ambient deployment locator or claim a bindings-free local push updates an AWS-bound deployment. Keep the adapter trait unchanged and the local writer intact. +- [ ] Preserve ordinary local push, sibling-key retention, dry-run no-write, and all other adapters' behavior. Do not add AWS diff/gc/publication/mutation workflows. + +### Proof + +Extend generator structural tests and the real generated-project integration test. Test CLI/target feature mismatches in both directions, structural-only degradation without local app TOML, flags/aliases/default suppression, generated-command versus fallback feature forwarding, custom-command preservation, no lockfile mutation/network, and explicit push refusal with no file writes or provider calls. + +## 12. Stage 7: production startup and generated consumer proof + +### Work + +- [ ] Generate a native fixture through the actual CLI. Configure portable IDs, native binding settings, provider features, and an app-owned initializer under accepted lifecycle contracts. +- [ ] Use `#[action]` handlers and `edgezero_core` HTTP types. Load a real serialized envelope through accepted `AppConfig::from_binding` or its accepted equivalent, resolve nested secret references, validate typed configuration, and insert typed handler state before readiness. +- [ ] Exercise these combinations: AWS config plus environment secrets; local config plus AWS secrets; both AWS providers plus redb; same-kind local/custom/AWS bindings; remote-only config and injected KV without local roots. +- [ ] Use loopback Agent and scripted Secrets Manager HTTP fixtures through the real pinned SDK in the generated child process. Stage 5 must prove a supported SDK endpoint override plus explicit fixture credentials can select that loopback fixture in child-only bootstrap without ambient discovery or external calls. Clear inherited child bootstrap, supply only required fixture settings and dummy credentials/region, disable metadata credential lookup, and select only the loopback Secrets Manager endpoint. Keep this endpoint out of the production bindings schema. Assert request targets, bodies, selectors, and attempts without logging Authorization/signatures or payload values; unexpected destinations fail the test. A downstream binary cannot use a dependency's private `cfg(test)` service/connector seam; reserve that seam for provider unit tests. If the pinned SDK cannot support the generated fixture this way, stop and approve another test strategy rather than adding a public mock registry. Run real AWS through the same generated runtime only in Stage 9. +- [ ] Make a downstream fake KV use only generic handle injection. It must not import AWS or depend on a built-in tag. +- [ ] Change fixture versions while the process runs. Prove snapshot reads and retained typed state do not change; restart and prove new values are adopted. Test pinned/current secret selection separately. +- [ ] Fail a mapped profile, secret fetch, envelope validation, nested required secret lookup, and app initializer. Prove no readiness/listener acceptance and no fallback in each case. +- [ ] Stop while AWS preparation is pending. Reuse accepted shutdown semantics and observe staged handle drop without overstating credential/socket cancellation. +- [ ] Capture child process output and returned startup diagnostics. Assert sentinel secrets, tokens, ARNs, raw bindings, config documents, response bodies, and SDK chains never appear. + +### Exit gate + +A generated native application proves the preparation -> shared registries -> app initializer -> typed handler path. Record commit, feature set, fixture policy, commands, and results. Mock evidence is not IAM/Agent freshness evidence. + +## 13. Stage 8: docs, feature isolation, and CI + +### Documentation + +- [ ] Update existing local-only claims in the Axum guide. Document native binding selection, local defaults, strict production roots, explicit options versus env loading, generic supplied handles/deferred setup, and SDK-free CLI validation. +- [ ] Document Agent process/network ownership, literal loopback setup, explicit loopback bind, bearer token policy, pinned Agent version, backup/preload disabled, and warm-cache staleness. +- [ ] Document exact raw/envelope and string/binary semantics, required mappings, startup-only adoption, non-atomic cross-document snapshots, secret retention after remote deletion/revocation, and no zeroization guarantee. +- [ ] Document bounds and their scope, sequential startup costs, best-effort cancellation, service/credential failure categories, and supervisor-owned restart backoff. +- [ ] Document generated opt-in features, supported Cargo-resolution subset, structural-only results, and local-only `config push`. No publication/provisioning claims. +- [ ] Include fictional bindings, role permissions, developer authentication, native-only support, costs, and named cleanup ownership. Keep application schemas/required secret names out of framework examples. + +### Required repository checks + +Run these against a stable combined state after scoped tests have passed: + +```sh +cargo test --workspace --all-targets +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly +cargo check -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +``` + +Add isolated native-provider gates, proposed commands: + +```sh +cargo test -p edgezero-store-aws --no-default-features +cargo test -p edgezero-store-aws --no-default-features --features appconfig-agent +cargo test -p edgezero-store-aws --no-default-features --features secrets-manager +cargo test -p edgezero-store-aws --no-default-features --features "appconfig-agent secrets-manager" +cargo test -p edgezero-adapter-axum --no-default-features --features cli +cargo test -p edgezero-adapter-axum --no-default-features --features axum +cargo test -p edgezero-adapter-axum --no-default-features --features "axum aws-stores" +cargo test -p edgezero-cli --test native_target_features +cargo test -p edgezero-cli --test generated_project_builds -- --ignored +``` + +Also lint settings-only, AppConfig-only, Secrets-only, Axum runtime-only, and CLI-only modes so all-feature unification cannot hide cfg mistakes. Tests needing the native runtime stay in native crates. Keep portable contract suites on `futures::executor::block_on`. + +- [ ] Wire enabled native provider tests and resolver/generated fixture checks into CI. Default provider tests require no credentials and no external network. +- [ ] Add target-filtered reachable normal/build graph assertions for all three generated WASM adapters and the existing Fastly `fastly cli` combination. Assert no `edgezero-store-aws`, AWS service SDK/config/credential crates, or AWS Smithy runtime dependencies are reachable there. Match actual SDK package identities, not a blanket `aws-*` prefix that would also ban unrelated TLS cryptography such as `aws-lc-rs`. +- [ ] Assert default native has no service clients; config-only has no Secrets Manager SDK; secret-only has no Agent HTTP-client dependency introduced by the provider feature. Compare dependency causes rather than banning unrelated existing HTTP dependencies. +- [ ] Inspect the selected graph, not every package listed in Cargo metadata, the lockfile, or host workspace all-feature builds. Optional AWS packages in Cargo.lock are not evidence of WASM reachability. +- [ ] Preserve existing adapter WASM contract/runtime tests, generated target checks, and docs gates. Run excluded app-demo/fixture checks when their manifests or lockfiles change. +- [ ] Run `npm run format`, `npm run lint`, and `npm run build` in `docs` after approved guide changes. Internal `docs/superpowers` documents are excluded from Prettier/site inputs; do not reformat them globally. + +### Exit gate + +All applicable host, generated-native, isolated-feature, docs, and WASM gates pass, or each unavailable/pre-existing failure has explicit evidence and a named blocker. Do not call unrun targets passing. + +## 14. Stage 9: disposable AWS acceptance, separately authorized + +Before any AWS action, obtain approval naming account/region, temporary workload/developer identity, maximum cost/request count, finite runtime, resources, and cleanup owner. Do not put this into ordinary CI or launch it merely because local tests passed. + +### Work + +- [ ] Pin and record commit, Rust/SDK behavior version, Agent version/image, native feature set, deployment environment, and bindings policy. +- [ ] Have the authorized operator create/review disposable AppConfig application/environment/free-form profiles and Secrets Manager string/binary versions. Runtime code creates nothing. +- [ ] Use an Agent role limited to AppConfig Data `StartConfigurationSession` and `GetLatestConfiguration`, and an application role limited to exact secret `GetSecretValue` plus required customer-managed-key `kms:Decrypt`. +- [ ] Start a cold Agent without disk backups. Retrieve exact raw/envelope documents through the generated application and complete required typed/secret checks before readiness. +- [ ] Verify denied cold-Agent access fails startup; separately observe and record documented warm-cache behavior without presenting it as fail-on-revocation or latest-deployment proof. +- [ ] Verify current/pinned secret versions, binary fidelity, restart adoption, missing resources/versions, denied secret access, and denial/no-fallback startup diagnostics. +- [ ] Change config/secret versions and stage labels. Prove the running process retains its snapshots and a new process adopts the selected new values. +- [ ] Stop the application and Agent, remove disposable resources through operator tooling, and verify cleanup. Record incomplete cleanup and owner explicitly. + +Record resource identifiers only in an authorized private acceptance record. Public/runtime logs and checked-in examples remain redacted. Acceptance failure blocks claims about the deployed behavior it exercised; local fixtures cannot substitute for it. + +## 15. Evidence, review, rollout, and rollback + +For each stage record `check | exact revision or relevant file hashes | environment/features | result | evidence location`. Run scoped tests after every code change as required by the repository. Run broad checks at stable integration checkpoints. Reuse only evidence whose relevant inputs still match. + +Review two high-risk facts before release: + +1. Provider/bootstrap/service errors cannot expose values through EdgeZero-controlled output. Push from source/path inspection to captured generated-process output in Stage 7, including failure and diagnostic levels. +2. Required AWS values and app-owned typed checks finish before listener acceptance/readiness, with no local fallback. Push from source order to failing and successful generated startup tests, then authorized AWS acceptance. + +Source-only claims remain unproven. Timeout observation does not prove credential-process/socket cancellation; payload accounting does not certify RSS; warmed Agent reads do not certify AWS freshness. + +Roll out additively. Existing applications without bindings inputs keep current defaults and AWS-free builds. Compile provider features before selecting a remote binding. Migrate one ID at a time with operator-reviewed resource mappings and app-owned startup validation. No framework data migration or remote write is included. + +Rollback means select a reviewed local/custom binding and restart with the needed local inputs, or deploy the previous reviewed binary/bindings pair. Never automate fallback after a selected AWS binding fails. Restart-based rollback may still require app-owned config/secret version coordination. + +## 16. Planning investigation and chosen approach + +Three read-only workers investigated native composition, AWS providers, and CLI/build/CI. Each then proposed a minimal approach; no worker edited code or ran Cargo/AWS checks. + +- Native composition confirmed existing handles and registries suffice. Choose keyed ready/deferred inputs instead of a general factory registry or an async initializer that selects stores too late. +- AWS investigation confirmed an explicit shared preparation session can own quotas, dedup, and clients without globals. Choose it over independent hidden per-binding sessions; retain caller responsibility for opaque prebuilt handles. +- CLI investigation found generated manifest commands bypass the Axum fallback and that metadata alone cannot be assumed equivalent to selected-package compilation. Cover both standard dispatch paths and gate offline verdicts on Cargo parity fixtures, with explicit structural-only degradation. + +The chosen plan limits production changes to native composition, one optional provider crate, and necessary generated/CLI integration. Existing local implementations remain in place; core/macros and unrelated adapter behavior stay unchanged. + +## 17. Stage 0 execution record + +### Gate result + +Historical initial audit on 2026-10-07, before the user's subsequent implementation/rebase authorization. At that point the original accepted-only exit gate was not satisfied and no runtime implementation had started. The later authorization and execution record below supersede that gate result. + +Read `CLAUDE.md`, the handoff completely, this worktree's complete spec and plan, the referenced reusable lifecycle design, and current #410/#324 issue bodies. Both issues remain open. Read-only GitHub queries confirmed: + +| Prerequisite | Exact current head | Status | Accepted revision | +| ------------ | ------------------------------------------ | ------------------------------------ | --------------------- | +| #275 | `d1a81579afb96f200e7fe8fd294600f0b446b080` | OPEN, draft; unchanged from planning | None; no merge commit | +| #406 | `3ab22096fd117f7506cf437b5ba128e0789e059c` | OPEN, draft; unchanged from planning | None; no merge commit | + +#406's current PR body also records outstanding integration with newer #275 contracts. The #406 head is not a descendant of this worktree's main revision. Therefore there are no accepted revisions to re-audit or accepted-head API differences to record. Do not transplant either draft or implement against main's old contracts. Repeat the audit against the exact accepted commits after prerequisite acceptance and an approved worktree integration step. + +The remaining approvals are still pending: Agent staleness, startup-only secret retention after deletion/revocation, whole-document mappings, initial service set, external bindings and generic injection choices, remaining limits, generated feature enablement, and the Stage 1 implementation plan. The approved configurable 8 MiB shared-session quota and caller-owned boundary remain settled. AWS acceptance still requires its separate authorization. + +### Contract and test inventory + +These are source observations, not executed draft-contract proof: + +- Main `crates/edgezero-core/src/config_store.rs:214-220` still returns `Option`. Main has ordinary secret reads, no core `time.rs`, and infallible `Hooks::build_app`. Main Axum `dev_server.rs:333-383` has development startup only, binds before building registries, and has no production `PreparedStores` initializer. +- Draft #275 adds shared `ConfigValue`, `BoundedStoreRead`, mandatory `get_bounded` and `get_bytes_bounded` methods with injected `MonotonicClock`, absolute `Deadline`, and independent backend/value caps. See draft core `config_store.rs:295-412`, `secret_store.rs:385-402`, `time.rs:27-130`, and fallible `App::build` in `app.rs:143-168`. +- Draft #406 exposes borrowed registries through `PreparedStores`, with the initializer `for<'startup> FnOnce(&'startup mut App, &'startup PreparedStores) -> LocalStartupFuture<'startup>`. The boxed future has no `Send` bound. See draft Axum `dev_server.rs:63-94, 782-834`; `initializer_borrows_inputs_across_await_and_captures_rc` at `1111-1145`. +- Draft #406 `App::insert_state` retains `Clone + Send + Sync + 'static` handler state and uses router copy-on-write. See draft core `app.rs:285-297`, `router.rs:532-538`, and `startup_state_forks_router_and_invalidates_old_admission` at `app.rs:2459-2505`. Non-`Send` setup does not imply non-`Send` retained handler state. +- Draft #406 prepares stores and runs the initializer before binding/readiness. Its stop/drain path gates dispatch and releases service/registry ownership. Existing `production_hosting.rs` tests cover signals, finite/forced drain, startup stop without readiness, redacted failures, typed startup validation, embedding, persistence, aliases and restart snapshots. Reuse them after acceptance; they were not run in this worktree. +- Draft root validation remains kind-wide at `run_options.rs:144-189`. Stage 1 must derive required roots from resolved per-ID local sources, without replacing accepted lifecycle behavior. +- Exported `config_store_contract_tests!` and `secret_store_contract_tests!` exercise ordinary reads and are reusable downstream. The separate `ready_*_bounded_read!` helpers are private `cfg(test)` fixtures, not exported bounded contracts. Snapshot providers need their own bounded-read tests in addition to the exported ordinary suites. + +Full exact-head source/test inventories and conditional proposals are local artifacts at `/tmp/native-store-stage0-core/{report,proposal}.md` and `/tmp/native-store-stage0-startup/{report,proposal}.md`. Draft #275 text was retrieved through the read-only contents API because its Git object is absent locally; draft #406 text came from the existing Git object. All extracts remained under `/tmp`; no branches were copied. + +### Executed main baseline + +Tested code revision: `683202c66948146ec360f490126f1d60f920288b`, with no tracked code changes. Environment: Rust/Cargo 1.95.0, native `x86_64-unknown-linux-gnu`, `CARGO_NET_OFFLINE=true`, `CARGO_BUILD_JOBS=2`, worktree-local target directory. All three WASM targets were already installed. No dependencies or toolchains were installed. Cargo manifest and lockfile SHA-256 values were identical before and after: + +```text +Cargo.toml acbdefe5e403b9faa1c09b095811927cb4c16faa549ec287a43f2e307d9df844 +Cargo.lock ccc5cbeda40fab3f2334b9846c565ea6699753162c508998a891f6037a4cee6d +``` + +All Cargo commands sharing the target directory ran sequentially. Evidence directory: `/tmp/native-store-stage0-baseline/`, with exact `.command` files, full `.log` files, `results.txt`, environment, revision, and before/after hashes. + +| Check | Result | Seconds | Log | +| --------------------------------------------------------------------------------------------------------------------- | ---------------------------- | ------- | ------------------------ | +| `cargo test --offline --locked -p edgezero-adapter-axum --all-targets --all-features` | PASS, 149 tests | 64 | `axum-tests.log` | +| `cargo test --offline --locked --workspace --all-targets` | PASS, 1,440 tests; 1 ignored | 118 | `workspace-tests.log` | +| `cargo fmt --all -- --check` | PASS | 1 | `format.log` | +| `cargo clippy --offline --locked --workspace --all-targets --all-features -- -D warnings` | PASS | 90 | `clippy.log` | +| `cargo check --offline --locked --workspace --all-targets --features "fastly cloudflare spin"` | PASS | 8 | `workspace-features.log` | +| `cargo check --offline --locked -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly` | PASS | 28 | `fastly-wasm.log` | +| `cargo check --offline --locked -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare` | PASS | 19 | `cloudflare-wasm.log` | +| `cargo check --offline --locked -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` | PASS | 19 | `spin-wasm.log` | + +No baseline failures were observed. The existing ignored `generated_workspace_compiles` test was not forced because it may fetch dependencies. WASM results are compilation evidence, not hosted execution. These baseline checks prove no new AWS provider behavior and do not validate the draft PR combination. + +### Dependency investigation and pending work + +Read-only registry evidence identifies candidate direct pins `aws-config = "=1.12.0"` and `aws-sdk-secretsmanager = "=1.120.0"`; both declare Rust 1.94.1 and are non-yanked. Their declared direct MSRVs fit installed Rust 1.95.0, but no complete transitive graph was resolved or compiled. Candidate fixed behavior is `BehaviorVersion::v2026_01_12()`, with explicit native runtime/current HTTPS-client features and reviewed developer credential features rather than `behavior-version-latest`. The documented default connect timeout is 3.1 seconds, so the provider must configure its selected budget explicitly. + +Final coherent SDK pins/features, transitive MSRV proof, diagnostic/timeout/retry audits, and the exact Agent image/version pin remain unfinished Stage 0 work. No container was pulled. Do not treat a floating Agent `2.x` tag or a Lambda-extension release as an exact acceptance pin. Research sources and limitations are recorded in `/tmp/native-store-stage0-baseline/sdk-research.md`. + +### Worker decisions and next step + +Two read-only repository explorers audited core contracts and production startup separately. Two read-only architecture workers then compared minimal conditional Stage 1 approaches. A read-only build validator checked the eight command/log/results artifacts, revision, environment, and unchanged hashes; its report is `/tmp/native-store-stage0-baseline/verification.md`. The coordinator inspected the source excerpts and actual logs directly. The chosen approach remains the existing plan: private per-ID ready/deferred overrides, full source validation, sequential temporary staging, and one complete registry publication. Complete-prebuilt-registry injection would shift mixed-source preparation onto callers; direct mutation of published registries would complicate failure cleanup. No production edits were needed or authorized for this audit. + +The initial audit recommended waiting for acceptance/approval. The user's later instruction superseded that recommendation; the current execution record follows. + +## 18. Authorized prerequisite integration and Stage 1 + +The user explicitly authorized implementation and rebasing onto the open PRs. No GitHub state changed. A local backup branch `backup/issue-410-main-stage0` retains the original main base. + +Selected #275 head: `d1a81579afb96f200e7fe8fd294600f0b446b080`. Selected #406 head: `3ab22096fd117f7506cf437b5ba128e0789e059c`, one unique commit after shared ancestor `5527b58e2a7cb6307d5c7a8926cd5dfc6f4f8b4a`. The #406 commit was rebased onto #275, producing combined base `1499062`. Both source PRs remain open; this is a local implementation stack, not an accepted main revision. + +Two read-only workers investigated and proposed integration choices, recorded at `/tmp/native-store-integration/{native,contracts}-proposal.md`. Central resolution preserved #275's required serde dependency, bounded local config snapshot parser/residency, HTTP head/deadline limits, connection capacity and panic isolation, logging/preflight ordering, and asynchronous egress. It added #406's HRTB initializer, state insertion, readiness/stop/signals and bounded production drain. The production fixture was migrated from String to shared ConfigValue. A bounded required-file loader replaces #406's older unbounded loader. + +Scoped integration tests passed: core all-targets, Axum all-targets/all-features, including 13 production-hosting subprocess tests. Initial integration runs exposed fixture value-type and category mismatches, plus regressions in connection panic/cancellation tests; these were fixed before the rebase completed. The combined owner keeps #275's bounded FuturesUnordered connection ownership and adds phase-aware graceful drain, rather than using #406's older parser or spawning unbounded tasks. + +Stage 1 adds `native_stores.rs`, private per-kind source maps, closed preparation categories, move-only ready/deferred overrides, and `run_app_with_native_stores`. Hosting options remain cloneable with local-default bootstrap selection. Complete source validation precedes local provider construction and deferred polling. Roots are required only for IDs still resolved locally. Sources are prepared sequentially, then all registries are finalized once before the initializer and request service. + +New colocated tests exercise non-Send Rc setup on the real runner, default/key preservation, mixed local/custom roots, ready/deferred KV and secrets, namespace misses, duplicates and redacted Debug, undeclared sources/overlay/root/metadata errors with zero deferred polls and no database files, later failure dropping staged handles and unpolled futures, startup stop without binding, and the initializer/request path using the same provider and inserted state. Existing dev and strict-production regression suites remain passing. + +Stage 1 exit gate passed. Evidence is `/tmp/native-store-integration/stage1-tests.log` and `stage1-clippy.log`, plus all twelve serialized workspace/native/WASM/isolation checks passing in `check-results.txt`. Runtime-only non-test `cargo check --offline --locked -p edgezero-adapter-axum --no-default-features --features axum --lib` also passed, closing the build review's dev-dependency-unification proof gap. Exact commands/logs and before/after hashes are retained there; current relevant source hashes still match `stage1-inputs.sha256`. + +Read-only runtime and build reviewers reported no confirmed findings in `stage1-{runtime,build}-review.md`. Their source conclusions were checked against executed logs and unchanged input fingerprints. The reviewer noted that cross-kind behavior was tested in separate fixtures rather than one combined invocation; later production/provider tests will exercise the combined path. Review workers and the broader check runner have finished and were released. Baseline main evidence is kept separate from this combined-stack proof. + +Stage 2 read-only investigations/proposals are `stage2-{bootstrap,aws-settings}-proposal.md`. Chosen approach is the shared native parser feature plus SDK-free AWS settings crate, with typed source decisions rather than unvalidated option bags. AWS resource/URL validation belongs in the provider crate; schema/collisions/bootstrap ownership stay native-side. Public API docs confirm Secrets Manager selector lengths and complete-ARN/name constraints. No AWS resource or credential access is needed for this unit. + +No #410 commit was created. No deployment, publishing, image pull, credential discovery, or AWS service call was performed. Further implementation is authorized. The user has since explicitly declined real AWS acceptance, so Stage 9 is excluded from the currently authorized task and will not be run or requested. + +## 19. Stage 2 execution and proof + +Added `edgezero-store-aws` as a default-off, settings-only member. Its normal graph has only serde, URL/percent encoding, and closed error formatting. The shared Axum `native-bindings` feature supports parser use without either runtime or CLI registration. AWS service feature bodies are intentionally empty until their provider stages; the native runner currently rejects all built-in AWS selections before setup. Do not describe this checkpoint as operational AWS support. + +The pure source validator now owns declarations/defaults, supplied/file/inline collisions, raw NAME/KEY conflicts and target capability checks. The runtime translates those decisions to its existing sources, checks local-root needs and retains Stage 1 failure/drop ordering. File contents are captured during selection into cloneable options, not reopened during preparation. Explicit constructors retain only supplied bootstrap values. `from_env` captures the locator and scalar inputs and resolves each selected token name once. Relevant normalized duplicate settings fail; unrelated core environment semantics remain unchanged. Ambiguous explicitly supplied token values cannot be used by a provider. + +ARN validation supports `aws`, `aws-cn`, and `aws-us-gov` region families, matching explicit region, full account/resource shape, names of 1-512 documented CreateSecret characters and the six-character ARN suffix. This is shape validation, not a served-region/resource existence claim. Selectors retain non-control Unicode and embedded spaces under the API's character-count lengths, with all-whitespace/control values rejected. Agent identifiers support escaped spaces/Unicode and other safe segment characters up to 128 bytes, while slash/backslash/percent/query/fragment/control and standalone dot segments fail. Endpoint validation rejects normalized integer/octal IPv4 and other path/authority ambiguities. + +Both Stage 2 reviewers found no confirmed defect. Their concrete test gaps were addressed: malformed UTF-8 and duplicate tables, independent decoded path assertions, selector/ARN boundaries, exact file/explicit policy selection, plus a real isolated subprocess through `AxumRunOptions::from_env` for named token capture and missing/empty/non-Unicode token/locator errors. File and inline failures run through the real runner and assert zero future polls, no initializer/database files, dropped supplied state and redacted output. + +Evidence is `/tmp/native-store-integration/stage2-check-results.txt`, exact command files and logs, and before/after hashes. Provider settings: 9 passed. Axum all features: 260 unit, 13 outbound, 13 production-hosting passed. CLI-only: 55 passed; runtime-only: 219 passed. Workspace tests, fmt, native feature check, three WASM builds, normal non-test CLI/runtime builds and SDK/HTTP-free settings/parser dependency assertions passed. One initial workspace Clippy run found two new test-only style errors; these were fixed and scoped settings tests plus full workspace strict Clippy and formatting passed. Final hashes differ from the broad-check hashes only in those test-only changes to `settings.rs`; `stage2-final-inputs.sha256` records the final state. Runtime/CLI sources and production settings validation remained unchanged. + +The shared-session and SDK diagnostic audits are the next read-only proposals. No AWS resource access, credential discovery, image pull, deployment, mutation or publishing occurred. Real AWS acceptance remains excluded by the user's instruction. + +## 20. Follow-up validation and diagnostic approval gate + +Stage 2 follow-up made source decisions borrow declaration IDs. The same validator now consumes macro-baked static metadata or strings owned by a CLI manifest, without leaking strings or changing core. Manifest adapters reject legacy fields and an omitted multi-ID default. Secret lookup keys and selected non-opaque namespaces are checked against core's 512-byte bound before preparation; supplied opaque namespace semantics remain caller-owned. Omitted/current and explicit `AWSCURRENT` selectors normalize to the same target identity. + +Executed evidence is `/tmp/native-store-integration/stage2-boundaries-*.log`: settings 10 passed, Axum all-features library 262 passed, workspace tests, strict workspace Clippy, formatting and combined native feature checks passed. Initial Clippy issues in the follow-up were corrected before the final successful run. The only subsequent source delta is comments, followed by settings tests and formatting again. No #410 commit was created. + +The isolated SDK spike is outside the repository. It resolves/builds Secrets Manager 1.120.0 with runtime 1.16.0 / HTTP client 1.5.0 on Rust 1.95.0; it does **not** establish the complete aws-config developer-chain graph. Explicit fictional credentials and literal-loopback fixtures were used with cleared process environments. Scoped full client construction/send suppresses sentinel ARN/value/provider metadata under a process-global TRACE collector, even with `LOG_SENSITIVE_BODIES=true`. Two non-sensitive throughput events escape on HTTP success. Source-only pooled-driver findings must be qualified by the selected path/features; that driver path was not active in the tested configuration. + +A separate executed tracing/log fixture establishes a policy blocker. With no global tracing subscriber, `WithSubscriber(NoSubscriber)` suppresses its inner event but permanently disables unrelated application tracing-to-log fallback after the scope (`before=true hidden=false after=false facade_after=true`). With additive `tracing/log-always`, it does not suppress the mirrored inner event (`hidden=true`). A scoped mute is therefore not a logging-preserving, feature-independent SDK diagnostic strategy. + +**Historical pause, superseded by explicit first-version approval.** The user authorized continuing with redacted/dropped EdgeZero diagnostics and a disclosed dependency DEBUG/TRACE limitation before reviews. Do not use scoped NoSubscriber or change global application logging. Keep EdgeZero-owned errors closed and payload-free; dependency diagnostic output remains caller/operator-owned and must be called out in docs and the draft PR. The user also authorized commits/publication to open a draft with `gh stack` after implementation and checks. Stages 3-8 are now in progress; Stage 9 remains declined/excluded and is not requested. Detailed sources, fixture boundaries and logs: `/tmp/native-store-integration/sdk-diagnostics-{proposal,execution}.md`. + +## 21. Offline implementation and verification checkpoint + +The coordinator integrated the common session, private snapshots, literal-loopback Agent transport and pinned Secrets Manager SDK. Config remains arbitrary whole UTF-8, including empty and quoted values. Secret payloads preserve SDK-decoded bytes, require exactly one payload field and deduplicate successful targets across namespaces. The native runner uses one shared session and completes all registries before the application initializer/listener. + +CLI build/serve dispatch shares SDK-free Cargo argument normalization. Only exact generated Axum commands are intercepted as direct argv; custom shell commands remain unchanged. Pinned dispatch reads selected manifest features, not a rediscovered manifest. Bindings-only validation normalizes manifest/explicit target controls and reports structural-only validity with provider availability unknown. The unproven Cargo feature resolver is deliberately not shipped. The public typed push API rejects selected AWS config bindings before app TOML, prompts or writes, including local/dry-run flags. + +Generated Axum entrypoints now use `run_generated_app`: no locator preserves existing local development behavior; any selected locator enters strict native startup and compiled-out providers fail closed. The ignored generated-consumer test invokes real `edgezero new` and `edgezero build`, compiles default-off/Agent-only/Secrets-only/both, rejects a compiled-out selection, then exercises a generated app's public HRTB initializer with gated provider responses, a serialized envelope, nested secret resolution and repeated snapshot-backed requests. This is separate from the five static production-consumer tests. + +Executed coordinator gates: `/tmp/native-store-integration/final-checks.sh`, exact commands/results/logs and stable before/after source fingerprint under `/tmp/native-store-integration/final-checks/`. All 24 gates passed. Workspace tests: 1,988 passed, two ignored generated tests; both ignored tests were separately executed successfully. Agent-only: 25; Secrets-only: 25; both providers: 40. Axum all-feature tests including subprocesses/contracts: 299. Demo tests: 54; lifecycle fixture tests: 31. Strict workspace and isolated Clippy, combined native features, all three WASM adapters, SDK-free normal graphs and strict rustdoc passed. The existing generated workspace test also passed its host/strict-Clippy/core-tests/typed-CLI/three-WASM checks (`generated-full-final.log`). Docs lint, full formatting and site build passed. + +A machine-local `/tmp` quota caused one interrupted test run. Checks use a disk-backed `TMPDIR=/home/pav/.cache/native-store-checks/tmp`; no unrelated temporary files were removed. CLI unit tests with every adapter disabled assume registered adapters and are not a supported test scope; CLI-only compilation/strict Clippy passes, and the default registered CLI suite passes 227 tests. + +Read-only final provider and CLI reviews found no confirmed boundary defects (`/tmp/native-store-final-review-{provider,cli}.md`). These reviews did not execute Cargo and do not replace coordinator gates. Generated `serve` CLI end-to-end behavior is not separately demonstrated; shared argument selection/action gating is source/unit-tested, and generated build plus production startup is executed. Remaining review items are dependency DEBUG/TRACE disclosure, operator-reviewed Agent release/digest and explicitly excluded real AWS acceptance. + +Publication completed with one draft stack member, [PR #414](https://github.com/stackpop/edgezero/pull/414), assigned to ChristianPavilonis. Trunk is `docs/outbound-http-spec` / #275 at `ea66eff045512cccc60646d6ec339e83ff44fed7`. The compatible lifecycle bridge is `a08f54c`; tested implementation checkpoint is `ab402f4fbdaa5c7c244ec16c11cfacec65123064`, tree `d6097c9c8947e8828338b3340287d6df0650380e`. Existing #275/#406 were not modified. The clean rebase required one lockfile dependency disambiguation (`wasm-streams 0.6.0`), followed by all 24 gates passing again and a successful existing generated workspace/WASM test. Exact commands, fingerprints and logs remain under `final-checks/`; `generated-full-rebased.log` records the additional consumer gate. The subsequent publication-record commit changes only documentation. No merge or deployment was performed. + +## Links + +- [Governing design](../specs/2026-10-07-native-store-providers-design.md). +- [Owning issue #410](https://github.com/stackpop/edgezero/issues/410). +- [Bounded config and lifecycle prerequisite PR #275](https://github.com/stackpop/edgezero/pull/275). +- [Production startup prerequisite PR #406](https://github.com/stackpop/edgezero/pull/406). +- [Raw configuration semantics #324](https://github.com/stackpop/edgezero/issues/324). +- [AWS Agent configuration](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-integration-containers-agent-configuring.html). +- [Secrets Manager GetSecretValue](https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html). +- [Rust SDK timeouts](https://docs.aws.amazon.com/sdk-for-rust/latest/dg/timeouts.html) and [retries](https://docs.aws.amazon.com/sdk-for-rust/latest/dg/retries.html). +- [Cargo features](https://doc.rust-lang.org/cargo/reference/features.html), [metadata](https://doc.rust-lang.org/cargo/commands/cargo-metadata.html), and [tree](https://doc.rust-lang.org/cargo/commands/cargo-tree.html). diff --git a/docs/superpowers/plans/2026-10-14-dynamodb-kv.md b/docs/superpowers/plans/2026-10-14-dynamodb-kv.md new file mode 100644 index 00000000..b22929a7 --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-dynamodb-kv.md @@ -0,0 +1,148 @@ +# Native DynamoDB KV implementation plan + +- Status: Implemented, verified and pushed to existing draft [PR #414](https://github.com/stackpop/edgezero/pull/414) on `issue-410-native-store-providers`. The eight pre-existing files' unrelated hunks remain preserved and uncommitted. +- Requested outcome: Native apps can select DynamoDB independently per declared KV store ID, through the existing portable KV interface. +- Base: Draft [PR #414](https://github.com/stackpop/edgezero/pull/414), published head `b764a883ce57a323250d9d7a20787dc5937ef90e` at research time. Re-check the published head before implementation. +- Safety: No AWS calls, ambient credential discovery, infrastructure changes, deployments or image pulls during research or default checks. Literal-loopback SDK fixtures use dummy credentials and cleared environments. Real AWS acceptance requires separate authorization. + +## Research findings + +Core `KvStore` supports raw-byte get, overwrite put, delete, exists, TTL and lexicographic prefix pages. `KvHandle` validates 512-byte UTF-8 keys, up to 1,000 keys per page and a 25 MiB value ceiling. That ceiling does not guarantee every provider can accept that size. `read_modify_write` is explicitly non-atomic. There is no portable CAS, conditional-create, transaction, consistency selector or snapshot-list interface. + +DynamoDB can implement the ordinary interface with a namespace partition key and the unchanged user key as sort key. Its item ceiling is 400 KiB including attribute names and values. Base-table Get/Query support strong reads, and Query sorts strings by UTF-8 bytes. Query evaluates its limit before filtering, stops at its 1 MiB data boundary, and can return an empty page with `LastEvaluatedKey`. Core already allows short/empty pages and terminates on cursor absence. + +DynamoDB TTL is asynchronous garbage collection, not an expiry guarantee. The provider must check logical expiration on reads and listing. The shared raw-trait contract includes a 1 ms TTL test, despite the public handle's 60-second minimum. + +A lost response or HTTP 500 can follow a committed write. Replaying an unconditional Put/Delete can overwrite or delete a concurrent writer's newer state. The first version will not automatically retry these calls or claim exactly-once effects. + +SDK generated `AttributeValue`, `PutItemInput` and `GetItemOutput` derive Debug without redacting item contents. The locked Smithy Blob implementation formats bytes as hex. Do not assume DynamoDB values have Secrets Manager's sensitive-model protection. EdgeZero-owned diagnostics will be closed/redacted. Dependency DEBUG/TRACE exposure must be disclosed, without installing subscriber suppression or changing application logging. + +Two read-only workers independently mapped the contracts/integration and researched service/SDK semantics, then proposed implementation alternatives. Reports and proposals are `/tmp/dynamodb-kv-{contract,service}-{research,proposal}.md`. No code, Cargo or AWS checks were run during research. The current checkout has unrelated ongoing modifications. Do not overwrite, stash, commit or migrate them as part of this task. + +## Approved design decisions + +### Scope and compatibility + +- Implement in `edgezero-store-aws`, not a new HTTP adapter and not portable core. +- Add default-off provider feature `dynamodb-kv` and Axum/generated alias `aws-dynamodb`. Preserve the existing `aws-stores` alias membership so existing config/secrets users do not acquire DynamoDB unexpectedly. All three can be selected explicitly with `aws-stores,aws-dynamodb`. +- Add strict v1 `[kv.]` binding dispatch with provider `aws-dynamodb`. No provider-specific fields in portable `edgezero.toml`. +- Preserve existing `NativeStoreBindings::new(config, secrets)` callers through an additive KV builder/accessor. Preserve ready/deferred KV overrides and local redb defaults. +- Leave CLI validation structural-only with provider availability unknown. Actual native runtime capabilities reject a selected but uncompiled provider before setup, local opens or deferred polling. +- DynamoDB is live mutable storage. Preparing a client is not a startup snapshot. Runtime KV calls perform remote I/O and are not covered by the config/secret retained-payload quota. + +```toml +version = 1 + +[kv.sessions] +provider = "aws-dynamodb" +region = "us-east-1" +table = "edgezero-kv-prod" +namespace = "checkout-prod/sessions" +``` + +### Table and key schema + +Use an operator-owned, single-region, unindexed table with String partition key `pk` and String sort key `sk`. This first version does not provision or migrate tables and does not support global tables or secondary indexes. A bounded startup `DescribeTable` verifies the schema/status before the handle is published. This does not certify write permissions or capacity. + +- `pk` is the explicitly configured physical namespace, nonempty, at most 512 UTF-8 bytes and without control characters. It is not derived from a bare logical ID. Two bindings with the same region/table/namespace intentionally share data. +- `sk` is the exact portable user key, without normalization, hashing, case folding or delimiter concatenation. Prefix matching remains literal. +- `v` is a Binary attribute containing the complete original bytes, including empty values. +- Optional `expires_at_ms` stores logical expiry. Optional `ttl_epoch_s` is the garbage-collection hint. +- Fixed attribute names avoid a configurable mapping/index framework. All namespaces in the table follow this schema. A high-traffic namespace can become a hot partition; no sharding or throughput certification is claimed. + +### Values, expiration and listing + +- Cap payloads at **384 KiB, 393,216 bytes**. Bound namespace/key/metadata and check item accounting before I/O. Return a fixed `KvError::Validation` for excessive inputs. Keep the portable 25 MiB ceiling unchanged. No chunking, S3 fallback or silent truncation. +- A normal Put replaces the whole item and clears both expiry fields. TTL Put computes a checked millisecond deadline without rounding expiration earlier than requested, plus `ttl_epoch_s = ceil(expires_at_ms / 1000)`. +- Get and the default Exists path enforce expiry at completion. Expired reads do not delete records, avoiding a race with concurrent renewal. DynamoDB TTL handles eventual cleanup if operators enable the documented attribute. Disabled cleanup can retain expired records and cost storage. +- List uses one ascending, strongly consistent base-table Query, constrained to configured `pk`, and `begins_with(sk, prefix)` for nonempty prefixes. Omit the sort condition for an empty prefix. Project keys/expiry, not values, and check expiration after receipt. This keeps malformed expiry handling explicit rather than trusting a filter to hide malformed records. +- Query's evaluated-item limit is the requested page size. A portable page may be short or empty with a continuation cursor. Do not loop to fill pages. Strong per-request reads do not make the page sequence a snapshot. +- Encode a bounded, versioned cursor containing region/table/namespace/prefix and the exact continuation key. Reject malformed/cross-scope cursors before provider I/O. Validate returned keys/order/continuation progress. Cursors are opaque, not secret or tamper-proof; every request remains constrained to the configured namespace and prefix. Never log them. + +### SDK, deadlines and errors + +- Exact pin `aws-sdk-dynamodb = "=1.120.0"`, defaults off with native HTTPS/Tokio support. Its manifest MSRV is 1.94.1. The selected graph compiled and passed isolated and combined tests without upgrading the existing AWS pins. The lockfile adds the SDK and its `http` 0.2 compatibility dependency; existing `http` 1.4 references are disambiguated. +- Reuse the existing fixed AWS behavior version and supported operator-owned credential chain. Settings remain SDK-free and contain no credentials or endpoint overrides. Tests inject only literal-loopback endpoints and dummy credentials. +- One retained client per compatible region/policy, with `max_attempts = 1` and no outer retry loop. This intentionally disables read retries too, avoiding dual-client policy complexity in the first slice. +- Start with a 5-second operation budget, 1-second connect timeout and 2-second attempt timeout. Validate finite positive policy values and ordering. Runtime deadlines are fresh per call, never the expired startup preparation deadline. +- Bound client/DescribeTable setup by the native startup preparation deadline. Reuse the existing sequential staging and publish only complete registries. Extend preparation accounting without pretending to retain live database contents. +- Missing and expired items alone return None/false. Access, credential, table/schema, throttling, transport and timeout failures remain closed errors. Discard SDK Display/Debug/source chains. Document that write failures may leave the effect unknown. +- No global/subscriber logging changes and no claim that dropping a future forcibly terminates credential subprocesses, DNS or socket work. + +```mermaid +flowchart TD + Capture[Capture selected KV bindings] --> Validate[Validate IDs conflicts settings and compiled capability] + Validate --> Prepare[Create bounded DynamoDB client and verify table] + Prepare --> Registry[Publish complete local and remote store registries] + Registry --> Initialize[Run application-owned initializer] + Initialize --> Listen[Bind listener] + Listen --> Handler[Handler obtains existing KvHandle] + Handler --> DynamoDB[Live bounded DynamoDB Get Put Delete or Query] +``` + +## Implementation sequence + +- [x] **0. Isolate the work and establish the focused baseline.** Use the existing branch and PR #414 as explicitly requested. Record and preserve the eight pre-existing modified files; stage only DynamoDB-owned changes. Keep current unrelated changes untouched. Reuse existing evidence only when exact relevant inputs still match. Read-only worker reports are maps, not executed proof. +- [x] **1. Validate settings and dependency boundaries.** Add strict DynamoDB settings/policy and exact optional native dependency. Prove settings-only/default/Agent-only/Secrets-only graphs exclude DynamoDB, and Dynamo-only excludes the Secrets SDK. Demonstrate generated SDK model Debug disclosure with dummy data, and closed EdgeZero errors through loopback failures. Disclose dependency leakage rather than muting application logging; do not claim an ambient TRACE capture or universal redaction. +- [x] **2. Implement the real KV provider.** Add checked row codec/size/expiry/cursor logic and SDK Get/Put/Delete/Query. Use core `KvStore`, `KvHandle` and `KvError`; no new portable methods or Tokio/core dependencies. Expose preparation returning a portable handle and keep SDK models private. +- [x] **3. Prove provider behavior.** Run the shared KV contract against the actual provider using deterministic fixtures, not a replacement mock implementation. Drive the real SDK request/response path against a loopback fixture with cleared environments. Cover Unicode NFC/NFD distinction, 512-byte keys, arbitrary/empty binary, overwrite/delete/missing, millisecond expiry and renewal, no-TTL reset, size bounds, malformed records, short/empty pages, 1 MiB continuation shape, cursor scope/progress, denial/missing table, stalls and one-attempt ambiguous writes. Assert no owned diagnostic contains fixture keys/values/credentials. +- [x] **4. Wire per-ID native selection.** Extend KV binding schema, declared-ID/limit/conflict checks, capability/source resolution, startup session selection and existing KV staging loop. Test local+Dynamo+supplied mixtures, intentional shared namespaces, zero opens/polls on invalid sources, no fallback and no partial registry/initializer/listener on setup failure. All-Dynamo KV must not require or create a local DATA_DIR. +- [x] **5. Integrate CLI and actual generated consumers.** Add the generated opt-in alias and structural parser coverage. Reuse the generic Cargo normalizer rather than another resolver or shell interceptor. Prove generated default-off rejection and Dynamo-enabled CRUD/list/TTL through the public `Kv` extractor, with real SDK loopback traffic. Restart the generated consumer against the same fixture to prove data is not local process state. Preserve existing config push rules and config/secrets feature combinations. +- [x] **6. Document and run affected final gates.** Document schema creation/TTL cleanup/IAM as operator instructions, credentials, cost/hot partitions, smaller values, non-atomic read-modify-write, non-snapshot pages, write ambiguity and dependency logging caveat. Run focused tests after each coherent code unit, then affected workspace/native/feature/WASM, fmt, strict Clippy, SDK graph isolation and generated-consumer gates at a stable checkpoint. Do not repeat unaffected successful checks without input changes. Record commands, checked state and limitations. Commit and push the completed DynamoDB work to existing PR #414 after checks. Merges and infrastructure acceptance remain unauthorized. + +## Likely affected files + +| Area | Files | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| Provider | Root `Cargo.toml`/lock; AWS crate manifest, `src/settings.rs`, `src/lib.rs`, `src/preparation.rs`; new focused DynamoDB module/tests | +| Native integration | Axum manifest, `src/native_bindings.rs`, `src/native_stores.rs`, existing KV loop in `src/dev_server.rs`, relevant source/startup tests | +| Generated app / CLI | Axum `src/templates/Cargo.toml.hbs`; CLI generator/structural validation tests; a dedicated generated DynamoDB consumer proof | +| Evidence / docs | SDK isolation script and applicable CI feature jobs; focused DynamoDB guide linked from the existing native AWS guide | + +Avoid unrelated HTTP/runtime refactors, portable manifest changes, generic provider registration, hot reload, cache layers or provisioning code. + +## Approval and unverified work + +Approval should settle the ordinary-CRUD scope, 384 KiB payload limit, operator-owned table schema, single-attempt policy and dependency DEBUG/TRACE limitation. CAS or 25 MiB transparent values would require a different design, not an incidental addition to this plan. + +Implementation and offline verification are complete. No real DynamoDB/IAM/capacity/global-table acceptance is claimed or scheduled. The fixture verifies continuation wire shape, not the deployed service's 1 MiB evaluation boundary. SDK model Debug disclosure is executed evidence; no new ambient dependency TRACE capture or universal logging-safety claim is made. Generic native staging tests prove drop/atomicity behavior; mixed DynamoDB/local/ready/non-Send-deferred selection is directly tested, not presented as a deployed mixed-backend acceptance test. + +## Executed verification + +The exact staged source tree `2fa76fb26377cf41203c255f924d988d6b657002` was archived and checked independently of the eight dirty baseline files. Both CLI capability literals in the published source receive the new flag; the unrelated working-copy CLI refactor is excluded. Before/after SHA-256 manifests match. Subsequent publication-record edits are Markdown-only. + +- Workspace default tests: **1,999 passed, 0 failed, 16 ignored**. Required SDK/native feature scopes and the two native generated consumers were executed separately; this is not a claim that every unrelated ignored test ran. +- Provider library scopes: DynamoDB **44**, Agent **26**, Secrets **26**, all providers **74**. The all-provider suite used one test thread after an inherited Secrets timeout test transiently returned `Unavailable` instead of `Deadline` under parallel load. That test passed alone, then the complete serial suite passed. The initial failure log is retained; no production or unrelated test was changed to hide it. +- Native DynamoDB-only library: **229 passed**; native all-feature test/doc suites: **302 passed**. The publishable tree excludes six native tests added by the pre-existing unrelated changes. +- Actual CLI-generated DynamoDB consumer: **1 passed**, including default-off rejection before SDK I/O, enabled put/get/list/TTL-write/read, restart persistence and delete. Public-handle TTL is 60 seconds; elapsed millisecond expiry is exercised by the provider contract, not this subprocess proof. +- Existing CLI-generated AppConfig/Secrets consumer: **1 passed** again. +- Strict workspace all-target/all-feature Clippy, independent provider Clippy scopes, native default Clippy, strict provider/native rustdoc, formatting, dependency isolation and Cloudflare/Fastly/Spin WASM checks passed. +- Docs lint, formatting and production build passed after removing a link to process-only documents excluded from the documentation site. +- Loopback fixtures explicitly configure dummy credentials and endpoints. Put and Delete tests apply the mutation before a scripted 500, then assert `Unavailable`, one attempt and the retained/removed row. SDK GetItemOutput Debug contains a sentinel key and binary hex; owned error assertions remain source-free. + +Commands, logs and fingerprints: `/tmp/dynamodb-baseline/final-checks.sh`, `/tmp/dynamodb-baseline/final/`, `snapshot-{before,after}.sha256`. Read-only final provider review: `/tmp/dynamodb-provider-final-review.md`; no confirmed findings, and no execution claims from that review. + +## Publication + +Implementation commit `883354f98dfd4437eb60faaac12d4469f65aa158` was pushed to the existing branch and verified as PR #414's remote head. Only owned DynamoDB changes were staged, including narrowly projected edits in the two overlapping baseline files. The eight unrelated working-copy modifications remain unstaged; no stash, prerequisite rewrite, merge, deployment or AWS acceptance occurred. This final publication record changes only this Markdown plan, so the passing code gates remain applicable. + +## Sources + +- Portable implementation and shared tests: `crates/edgezero-core/src/key_value_store.rs`, especially `KvError`, `KvHandle`, `KvPage`, `KvStore` and `key_value_store_contract_tests!`. +- Native selection and staging: `crates/edgezero-adapter-axum/src/{native_bindings,native_stores,dev_server}.rs`. +- [DynamoDB constraints and item limits](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Constraints.html). +- [Query request/response and pagination](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Query.html). +- [Read consistency](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.ReadConsistency.html). +- [Expired TTL records](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ttl-expired-items.html). +- [Error handling and ambiguous writes](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html). +- [SDK 1.120.0 manifest](https://docs.rs/crate/aws-sdk-dynamodb/1.120.0/source/Cargo.toml). +- [AttributeValue source](https://docs.rs/aws-sdk-dynamodb/1.120.0/src/aws_sdk_dynamodb/types/_attribute_value.rs.html), [PutItemInput source](https://docs.rs/aws-sdk-dynamodb/1.120.0/src/aws_sdk_dynamodb/operation/put_item/_put_item_input.rs.html), [GetItemOutput source](https://docs.rs/aws-sdk-dynamodb/1.120.0/src/aws_sdk_dynamodb/operation/get_item/_get_item_output.rs.html), [Smithy Blob source](https://docs.rs/aws-smithy-types/1.8.1/src/aws_smithy_types/blob.rs.html). +- [SDK logging controls](https://docs.aws.amazon.com/sdk-for-rust/latest/dg/logging.html). +- [Current SDK registry metadata](https://crates.io/api/v1/crates/aws-sdk-dynamodb). + +## Related + +- [Native store composition design](../specs/2026-10-07-native-store-providers-design.md). +- [Native provider implementation and publication record](2026-10-07-native-store-providers.md). +- [Native AWS guide](../../guide/native-aws-stores.md). +- [Owning self-hosted support epic #391](https://github.com/stackpop/edgezero/issues/391). diff --git a/docs/superpowers/specs/2026-05-21-outbound-http-design.md b/docs/superpowers/specs/2026-05-21-outbound-http-design.md index 8e665b75..ae6caea7 100644 --- a/docs/superpowers/specs/2026-05-21-outbound-http-design.md +++ b/docs/superpowers/specs/2026-05-21-outbound-http-design.md @@ -179,7 +179,7 @@ pub struct OutboundBatchFailure { #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum OutboundBatchTermination { - /// Every input slot produced exactly one terminal result. + /// Every input slot produced exactly one terminal result and the driver reached clean EOF. Completed, /// The observation cutoff won while one or more slots remained unresolved. Cutoff, @@ -189,6 +189,7 @@ pub enum OutboundBatchTermination { /// /// After the final `Item`, callers poll once more to receive `Finished(Completed)`; /// completion is not inferred from the number of items observed. +/// The final poll waits for driver EOF and still validates trailing events or finalization failures. #[derive(Debug)] #[non_exhaustive] pub enum OutboundBatchNext { @@ -225,6 +226,20 @@ impl OutboundBatch { where StreamValue: Stream + 'static; + /// Shared future-based driver. Inputs retain original indices and terminal samples. + /// Drain immediately ready futures before delivering the first item; introduce no timer. + pub fn from_futures( + slot_count: usize, + batch_started_at: MonotonicInstant, + observation_cutoff: Deadline, + completed: Vec<(usize, MonotonicInstant, Result)>, + pending_futures: Pending, + ) -> Self + where + F: Future)> + + 'static, + Pending: IntoIterator; + /// Returns the next observed terminal item or the explicit termination reason. /// Cancelling this future does not consume or lose a ready item. pub async fn next(&mut self) -> Result; @@ -408,6 +423,13 @@ unresolved-index report discarded. Cancellation stops observation and triggers strongest-available teardown, but does not claim that already-issued network side effects were undone. +Core reports normal completion only at driver EOF after every index has resolved. Trailing +`Failed`, duplicate or out-of-range index events remain failures even after the last slot item; +ordered collection retains every result already observed. A driver cutoff with no unresolved +slots is an invariant failure. Zero-slot drivers follow the same validation protocol; +`OutboundBatch::cutoff(0)` supplies an empty driver and completes normally. Custom drivers must +finish after their final item rather than leave finalization pending indefinitely. + #### 3.1.2 App-facing handle ```rust @@ -911,6 +933,24 @@ impl From for Body { // Shared transport-independent response pipeline. These helpers are public because // adapters are separate crates. Every branch accepts and returns the same `BodyStream`, // preserving exact typed source errors and making the pipeline skeleton buildable. +// All four adapters compose payload policy through `edgezero_core::outbound`: +#[derive(Clone, Copy, Debug)] +pub struct ResponseBodyPolicy { + pub max_brotli_window_bits: u8, + pub max_chunk_bytes: Option, + pub max_decoded_response_bytes: Option, + pub max_decoder_bytes: u64, + pub max_encoded_response_bytes: Option, + pub response_mode: ResponseMode, +} +pub fn apply_response_body_policy( + native: BodyStream, + headers: &mut HeaderMap, + policy: ResponseBodyPolicy, +) -> Result; +// Checks declared lengths without polling, then composes encoded limits, decoding and header +// rewriting, decoded limits (not passthrough), and rechunking, in that order. Adapter-specific +// bodyless handling, transport reads, timers, cancellation, and final collection stay outside. // `edgezero_core::compression` items, in module order: pub const BROTLI_DECODER_FIXED_CHARGE_BYTES: u64 = 16_777_216; pub const FLATE_DECODER_FIXED_CHARGE_BYTES: u64 = 1_048_576; // 1 MiB; see the flate memory audit @@ -1698,7 +1738,7 @@ samples the same injected clock used for request budgets. Equality is expired. W wins, the driver stops yielding new terminal results, tears down or abandons unresolved work, and emits an explicit cutoff event. A response that was provider-ready but not observable before a blocking provider primitive returned remains unresolved; capability rows make that limitation -explicit. Core infers normal completion only after every input index emits exactly once. A +explicit. Core reports normal completion only at driver EOF after every input index emits exactly once. A premature driver EOF and duplicate or out-of-range indices are invariant failures returned as `EdgeError::Internal`; they never become `Cutoff`. An adapter that detects its own invariant failure emits `OutboundBatchDriverEvent::Failed` carrying that exact `EdgeError` rather than ending diff --git a/docs/superpowers/specs/2026-06-16-blob-app-config.md b/docs/superpowers/specs/2026-06-16-blob-app-config.md index 96dc8759..a307e59d 100644 --- a/docs/superpowers/specs/2026-06-16-blob-app-config.md +++ b/docs/superpowers/specs/2026-06-16-blob-app-config.md @@ -132,12 +132,12 @@ framework-resolved:** distinguish "deploy is incomplete" from "backend is flaky". - **§9.1 Axum reader uses `ConfigStore::get` returning - `Option`.** Previous draft made the extractor parse + `Option`.** Previous draft made the extractor parse the Axum file-map shape directly, bypassing the provider-neutral trait contract. Fixed: the file holds per-key envelope JSON STRINGS, the `AxumConfigStore::get` impl returns the matching string, and the extractor sees - the same `Option` every adapter exposes. + the same `Option` every adapter exposes. - **§3.3.2 secret existence claim dropped.** The earlier draft claimed ` config validate --strict` could verify the named secret exists; the typed validator @@ -398,7 +398,7 @@ ValidationErrorsKind>`. Rewrote the sketch to call example showed envelope OBJECTS as map values; the normative text said each value is an escaped JSON STRING. Picked the string form (matches - `ConfigStore::get -> Option`'s contract across + `ConfigStore::get -> Option`'s contract across all four adapters) and rewrote the §9.1 example to show escaped JSON strings only. The object form was removed. @@ -4590,10 +4590,10 @@ pub trait ConfigStore: Send + Sync { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError>; + ) -> Result, ConfigStoreError>; // Preserved for hand-managed reads; it makes no extraction-bound claim. - async fn get(&self, key: &str) -> Result, ConfigStoreError>; + async fn get(&self, key: &str) -> Result, ConfigStoreError>; } #[async_trait(?Send)] @@ -4625,7 +4625,7 @@ impl ConfigStoreHandle { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError>; + ) -> Result, ConfigStoreError>; } impl SecretHandle { @@ -4652,6 +4652,14 @@ impl BoundSecretStore { } ``` +`ConfigValue` is independently owned shared UTF-8 (`Arc`), never a slice retaining +the entire store file. Cloning shares the payload; `as_str()`/`as_ref()` borrow it, +`to_string()` deliberately copies it, and `Debug` is content-redacted. This is a hard +trait migration across all adapters, fixtures and consumers, not a compatibility layer. +Provider `String` to shared-payload conversion overlaps both allocations and must be +accounted separately. Cloudflare/Spin check size before conversion and expiry afterward; +Fastly preserves cumulative root/chunk accounting through the same terminal check. + The bounded methods add `DeadlineExceeded` and `ValueTooLarge` to both lower-level store error enums. Those variants are produced at the adapter boundary without formatting away the cause and map directly to the same-named extraction reasons (`ValueTooLarge` maps to @@ -4664,7 +4672,7 @@ depth 1. A streaming `serde_json` visitor enforces both caps before either full without retaining a `Value` tree. Exact-cap structures are accepted. Depth must be in `1..=127` (below the parser's recursion ceiling), and node count must be nonzero. These caps bound the parsed tree's structural amplification; they do not bound transient -string decoding, the provider-owned read buffer, canonical serialization, or total process RSS. +string decoding, the provider-owned read buffer, sorted-key scratch, or total process RSS. Canonical hashing streams directly into SHA-256 without a full output-string buffer. `App` owns one config-extraction-limits value, set through the fallible `Hooks::configure(&mut App) -> Result<(), EdgeError>` callback; the core-owned @@ -4733,8 +4741,8 @@ The capability ladder therefore gains two config-owned cells: | Capability | Axum | Cloudflare | Fastly | Spin | | --- | --- | --- | --- | --- | -| `config-read-allocation-bounds` | Unsupported while startup JSON is preloaded; promote only after the local-file reader caps before allocation | Unsupported until the SDK exposes/proves a pre-materialization bound | Unsupported until host API documentation and a probe prove it | Unsupported until host API documentation and a probe prove it | -| `config-read-deadlines` | BestEffort while request-time reads are synchronous map clones with pre/post checks | BestEffort: guest timer race returns promptly, but host cancellation is unproved | BestEffort; synchronous host reads are not guest-preemptible | BestEffort: guest timer race returns promptly, but host cancellation is unproved | +| `config-read-allocation-bounds` | Unsupported overall; bounded local snapshots do not certify secret/environment/allocator overhead | Unsupported overall; capped guest byte streams do not certify opaque KV/JS retention or secret reads | Unsupported overall; caller-sized ABI buffers do not certify provider storage or secret reads | Unsupported until host API documentation and a probe prove it | +| `config-read-deadlines` | BestEffort while request-time reads are synchronous shared-handle lookups with pre/post checks | BestEffort: guest timer race returns promptly, but host cancellation is unproved | BestEffort; synchronous host reads are not guest-preemptible | BestEffort: guest timer race returns promptly, but host cancellation is unproved | Apps that require a strict RSS or elapsed-time guarantee declare the corresponding Native capability and fail before startup/deploy on weaker targets. Capability documentation must @@ -5316,8 +5324,8 @@ coexist in one file. A flat `{ key: envelope_json_string }` map. Each value is the envelope serialised to JSON and stored as an ESCAPED STRING (not a nested JSON object), so the file deserialises into -`BTreeMap` and Axum's `ConfigStore::get(key)` -returns the string directly without any envelope-aware +a bounded sorted index of independently owned shared `ConfigValue` payloads. +Axum's `ConfigStore::get(key)` returns the shared UTF-8 value without any envelope-aware parsing inside the adapter. ```json @@ -5333,14 +5341,18 @@ parsing inside the adapter. to `.tmp`, fsync, rename). The entry value is the envelope serialised then `serde_json::to_string`-ed and inserted as a string value. -- Runtime: the extractor stays adapter-neutral and calls only `get_bounded`; raw - hand-managed callers may still call `get`. Axum's production bounded path reads the - selected map entry incrementally under the supplied deadline and refuses the first byte - beyond the smaller of the per-value and remaining backend allowances. It must not first - deserialize/clone the whole file map. The existing in-memory `from_map` test path checks - length before cloning the selected value. The unbounded compatibility `get` method - remains for hand-managed callers and delegates to the same selected-entry reader without - extraction limits; it is never called by `AppConfig`. +- Runtime: the standard runner loads immutable bounded flat-string snapshots before + listener binding, sequentially across the registry. It caps actual file reads, entry + count, decoded key/value bytes, all-store resident allocations and startup staging; + duplicate keys fail. No intermediate `Value` tree is created. Size violations fail + startup; other failed declarations retain typed failing handles. Missing files remain + empty declared stores. Push changes require restart. Request-time `get_bounded` checks + both extraction caps and the injected deadline before cloning one shared payload handle; + raw `get` also shares that payload but makes no extraction-bound claim. + `from_path`, `from_local_file` and fallible `from_map` require `ConfigStoreLimits`. + Caller-owned map inputs are outside startup-reader charges. The complete defaults, + supported ranges, requested-allocation formula, decoder pin and exclusions are normative + in [the Axum snapshot limits guide](../../guide/adapters/axum.md#snapshot-allocation-limits). The string form keeps the per-adapter `ConfigStore` logical value contract intact across all four adapters — @@ -5380,13 +5392,25 @@ bulk put --namespace-id= --remote` `wrangler kv key get --binding --remote` (Wrangler 4.x's four-segment subcommand path; the older three-segment `wrangler kv get` is deprecated). -- Runtime: `CloudflareConfigStore::get_bounded` returns the same JSON string and races the - provider future against a Worker timer under the absolute deadline before applying the - guest-visible byte checks from §6.3.2. It verifies each timer wake against the injected clock and - re-arms after an early wake. The timer race bounds EdgeZero's return but does not prove host-side - cancellation; the host may also materialize the value before guest code can reject it, so - deadline support remains BestEffort and allocation support remains Unsupported. The - `AppConfig` extractor parses and verifies only after charging the bounded read. +- Runtime: `CloudflareConfigStore::get_bounded` requests a KV byte stream, not `.text()` + or a materialized array buffer. A BYOB reader uses reusable 16 KiB Rust/JS buffers and + rejects the first excess byte before append. Accumulator capacity never intentionally + exceeds `L = min(max_backend_bytes, max_value_bytes)`; requested payload staging is + conservatively `2L + 32 KiB`, covering relocation/shared-string conversion and scratch. + Bounded reads require valid UTF-8, without lossy expansion. Failure to acquire a byte + reader fails closed; there is no unbounded fallback. An acquired reader is cancelled on + overflow, timeout or dropped work. The timer covers acquisition, drain and conversion, + rechecking the injected clock after each wake and before/after every pull, with a task-queue + yield every 16 chunks for frozen-clock hosts. An acquisition owner cancels late-arriving + streams after abandonment; cancellation-promise rejections are consumed without publishing + provider diagnostics. The SDK exposes no acquisition-promise + cancellation. Transferred wasm-bindgen callbacks require `FinalizationRegistry`; absent + runtime support fails before provider dispatch. Demo/generated Workers explicitly set + `enable_weak_ref`, without claiming deterministic GC cleanup. + KV host buffering, JS wrapper/GC retention and finite host teardown remain + unqualified. Deadlines remain BestEffort and broad allocation support Unsupported. + `AppConfig` parses/verifies only after charging the bounded read. Raw `get` remains + explicitly unbounded. - `--local` push: `wrangler kv bulk put --binding --local` — lands in `.wrangler/state`. Local push deliberately addresses by @@ -5426,8 +5450,19 @@ bulk put --namespace-id= --remote` - Runtime: `FastlyConfigStore::get_bounded` performs the same direct-or-pointer resolution, passes one absolute deadline/remaining allowance to every read, and returns the normal envelope JSON value. Its `backend_bytes` includes the pointer and every fetched chunk; - the core extractor does not know which physical form Fastly used. The preserved unbounded - `get` follows the same resolver for hand-managed callers. + the core extractor does not know which physical form Fastly used. Reads use one initialized + buffer through `fastly-sys 0.13.1`, never SDK auto-growth on `BUFLEN`. A physical entry has + an adapter ceiling of 32,000 bytes; roots are capped by + `R = min(max_backend_bytes, max(max_value_bytes, 8000), 32000)`, pointers by 8,000 bytes, + and chunks by 7,000 bytes and both remaining allowances. Validated declared totals must + fit both aggregate caps before chunk dispatch or exact-size reassembly reservation. + Root discriminators borrow raw tokens instead of materializing a JSON `Value` tree. + The bounded resolver verifies transport hashes/layout, then delegates inner-envelope + materialization and integrity to the extractor's structural preflight, matching direct + values. Raw `get`/CLI paths retain their explicit inner-envelope integrity checks and + are not allocation-certified. A conservative requested guest-payload charge is + `2V + R + 16P + 7000` bytes (`V = max_value_bytes`, `P = 8000`), plus fixed objects and + allocator overhead; host memory and synchronous-call interruption remain excluded. - Local server: `[local_server.config_stores.]` now has ONE active root entry — the blob key. PR #269 F6 already moved local- server seeding to `config push --local`. Local seeding must mirror @@ -5540,9 +5575,9 @@ maintenance tax. 2. The runtime `AppConfig` extractor replaces direct `ctx.config_store_default()?.get(key)` calls against the typed config's store. Downstream apps with custom `ConfigStore` - consumers see a deprecation note in `CHANGELOG.md`; the - compile-time surface (the `ConfigStore` trait itself) doesn't - change. + consumers must hard-migrate `get`/`get_bounded` and matching handle APIs to + shared `ConfigValue` instead of `String`, as recorded in `CHANGELOG.md`. + No deprecated aliases or dual trait signatures remain. 3. Pre-blob blobs (the flattened per-leaf state) become unreadable. The runtime errors loudly on the first request pointing at the migration guide. We do NOT silently treat diff --git a/docs/superpowers/specs/2026-08-22-inbound-body-design.md b/docs/superpowers/specs/2026-08-22-inbound-body-design.md index 2e47462c..063b8139 100644 --- a/docs/superpowers/specs/2026-08-22-inbound-body-design.md +++ b/docs/superpowers/specs/2026-08-22-inbound-body-design.md @@ -528,15 +528,15 @@ reset only the affected stream. EdgeZero does not drain an ambiguously framed bo not invoke the admission callback. This strict duplicate-`Content-Length` policy deliberately chooses one parser interpretation instead of accepting RFC-permitted identical duplicates. -Axum is the first required raw-boundary implementation. Hyper 1.10.1 can discard a +Axum is the first required strict raw-boundary implementation. Hyper can discard a `Content-Length` encountered after `Transfer-Encoding`, so checking the resulting `Request`/`HeaderMap` is insufficient and must not be described as smuggling protection. The Axum adapter needs an audited pinned Hyper parser patch or an upstream parser hook that rejects the strict policy while Hyper still has ordered `httparse` field lines. -The current `axum::serve` boundary hands EdgeZero an already-parsed request, so this also -requires a connection-owning accept path based on Hyper's lower-level connection builders. -`http1::Builder::{max_headers,max_buf_size,header_read_timeout}` may provide defense in depth, -but they do not expose exact request-target/raw-header accounting and do not preserve every +The adapter now owns its HTTP/1 accept path and pinned Hyper 1.12.0 connection futures. +`http1::Builder::{max_headers,max_header_size,max_buf_size,header_read_timeout}` provide bounded +pre-admission resource controls, but they do not expose exact request-target/raw-header accounting +and do not preserve every ambiguous field shape required by this contract; configuring those knobs alone cannot promote either raw capability. After that parser has rejected ambiguity, the adapter may derive `IngressFraming` from the @@ -547,6 +547,31 @@ also owning HTTP body framing. Raw-socket tests in §4 pin the parser behavior. only normalized requests cannot claim Native framing validation based on assumed host behavior; they remain Unsupported until a documented/testable raw rejection seam exists. +#### Implemented Axum HTTP/1 resource boundary + +`AxumIngressConfig` installs finite validated connection, combined raw-head byte, raw field-count, +and absolute idle-plus-header limits through the standard server startup path. The connection +pool owns at most the configured number of futures plus one transient accepted socket on refusal; +refusal creates no parser, response, task, or capacity waiter. The first deadline is stamped before +scheduling, subsequent deadlines start when Hyper first polls the next eligible keep-alive head, +and byte trickling does not reset them. Independent native timer wakes, I/O checks, and a completed +head check enforce expiry before application admission without replacing application body/egress +deadlines. Shutdown and cancellation drop all connection-owned state. + +The raw-head byte bound includes the request line and all field syntax together. Exact inclusive +head and field-count limits are tested through real TCP; these are not the independent +`IngressHeadLimits` measurements above. Hyper remains the sole HTTP/1 parser. HTTP/2 and socket +upgrade ownership are not enabled by this server path. + +Receive allocation bounds are audited separately from Hyper's logical buffer threshold. Response +heads are capped and copied into compact storage before Hyper caches them for later request parsing; +oversized heads close the connection without another response allocation. Configuration defaults, +ranges, precise wire definitions, trailer behavior, allocation assumptions, and exclusions are in +the [Axum adapter guide](../../guide/adapters/axum.md#bounded-http1-ingress). Real TCP acceptance +tests are colocated in the adapter's `connection.rs` and `dev_server.rs`. This implementation does +not claim strict ambiguous-framing rejection, exact independent target/header totals, kernel-buffer +bounds, or complete RSS certification. Raw capability promotion remains a future parser task. + ### 1.3 Absolute body-read deadline and cancellation The admitted `read_deadline` and its paired `MonotonicClock` are carried with the native diff --git a/docs/superpowers/specs/2026-09-08-response-egress-design.md b/docs/superpowers/specs/2026-09-08-response-egress-design.md index 66413b8a..25441de6 100644 --- a/docs/superpowers/specs/2026-09-08-response-egress-design.md +++ b/docs/superpowers/specs/2026-09-08-response-egress-design.md @@ -395,14 +395,20 @@ or attach provider capability declarations to individual reports. ### 3.4 Fallible application assembly Application configuration is startup policy and must not require `expect` or panic to reject -invalid limits. Construction is core-owned so hooks cannot discard target metadata or bypass -configuration: +invalid limits. Construction is core-owned and installs target metadata before configuration. +After successful configuration, core rejects any changed platform facts, provenance, or unknown +reasons rather than silently restoring them. A hook failure propagates unchanged: ```rust impl App { pub fn build(platform: PlatformMetadata) -> Result { let mut app = Self::with_name_and_platform(A::routes(), A::name(), platform); A::configure(&mut app)?; + if app.platform() != platform { + return Err(EdgeError::internal(anyhow::anyhow!( + "application configuration changed adapter-selected platform metadata" + ))); + } Ok(app) } } @@ -558,6 +564,25 @@ framing/flush boundary has accepted all response bytes into the OS socket. It ne receipt. A raw-socket test must prove that the selected boundary occurs once per response; until then the adapter reports `HostHandoff` and completion remains `BestEffort`. +Hyper stops polling when its declared-length encoder reaches zero, even without an exact size +hint. For a transmitted stream with `Content-Length`, Axum stages only the final declared-length +chunk and validates source EOF before handing that frame to Hyper. Empty tail chunks yield +cooperatively; excess bytes or a late source error abort without sending the staged frame. The +source is marked exhausted on clean EOF so subsequent body drop settles `HostHandoff` unless a +connection failure has already won. Lookahead remains under the original absolute write deadline +and injected application clock, with the connection supervisor's host timer covering pending +reads. It does not collect the preceding body. + +A transmitted zero-length stream is validated asynchronously before Hyper receives the response, +since Hyper may never poll such a body. The original attempt retains its completion during this +bounded validation. Source failure selects the finite conversion fallback; deadline expiry +selects the timeout fallback. HEAD/status suppression still drops the source without polling. + +The detached finite fallback used after fallback registration fails determines EOF from its +payload source, not from absence of an active attempt. GET retains the selected error bytes; HEAD +sends no payload. Consuming the final frame exhausts the source, and terminal failure explicitly +drops both the source and any staged frame before returning an error. + HTTP/1 post-commit timeout or source failure closes the connection to preserve framing. This may cancel pipelined responses on that connection. The supervisor never mutates a core attempt; it submits an ordered connection event to each registered coordinator. The coordinator whose @@ -735,7 +760,7 @@ Every adapter gets deterministic lifecycle tests before provider integration: | Completion | Empty, buffered, streamed EOF, source failure, transport failure, timeout, disconnect, conversion failure, never-polled body, and guard drop each notify once. Competing terminal events still produce one report. The exact decision-owned completion survives normal dispatch, handler/routing errors, canonical 404/405, bounded fallback outcomes, refusal, policy/framing failure, adapter fallback, and guard drop; it runs before the global observer. Joined completions receive the same borrowed report at most once in left-to-right order and release both resources if abandoned. Child panics remain independently isolated only on unwind-capable targets; panic-abort may terminate before the right callback. A dropped pre-begin envelope releases its resource without fabricating a report. Normalized pre-admission validation invokes the detached-egress decision factory exactly once: `Send { completion, deadline }` supplies exactly one completion and one mandatory deadline, with an empty completion by default, while `Abort` constructs no response or attempt and invokes no completion, observer, handler, middleware, or body poll. Raw parser failures remain outside the lifecycle contract. Explicit ingress abort likewise starts no attempt and invokes no callback. Compile-time tests prove the completion is non-clone and cannot be stored in response extensions. | | Accounting | Exact payload totals, zero-byte response, checked overflow, short/partial writes, cancellation prefixes, and no headers/framing in the count. | | Commit | Pre-commit failure may synthesize a bounded fallback; post-commit failure aborts without rewriting status or appending a body. | -| Backpressure | Holding one platform write prevents the next source poll and bounds EdgeZero staging to one chunk. | +| Backpressure | Holding one platform write prevents the next source poll and bounds EdgeZero staging to one chunk. Axum's final declared-length frame has the explicit bounded EOF-lookahead exception above. | | Teardown | Source drop and native abort/reset/error/close occur on every supported failure path. A terminal stream error releases its source/native state before yielding `Err`, without a repoll or wrapper drop. A losing future cannot mutate terminal state. | | Headers | Repeated fields, including `Set-Cookie`, survive the new low-level conversion paths. | | Framing | `HEAD`, body-forbidden statuses, content-length exact/short/long bodies, removed transfer encoding, unsupported upgrades, and early EOF behave identically across adapters. | @@ -743,6 +768,67 @@ Every adapter gets deterministic lifecycle tests before provider integration: Target evidence adds: +Current local Axum evidence uses Rust 1.95.0 and the locked Hyper dependency. +`connection::tests::bodyless_tcp_responses_release_the_response_resource_exactly_once` +covers HEAD/204/304 suppression without source polling and one completion/resource release. +`dev_server::bounded_ingress_tests::stalled_egress_termination_releases_capacity_and_recovers` +covers disconnect, a pending producer, and a non-reading client with an always-ready source; +all settle once, stop polling, release resources and recover a one-connection server. +It includes a frozen injected clock and an independently waking native deadline timer. +Existing connection/shutdown tests cover EOF, malformed declared lengths, source/conversion +errors and cancellation. These are native TCP/guest ownership proofs, not confirmed client +delivery or hosted-provider evidence; no capability promotion follows from this test expansion. + +| Target | Tested SDK / Native Revision | Hosted Runtime Revision / Evidence | Egress Qualification | +| --- | --- | --- | --- | +| Axum | Hyper 1.12.0, Rust 1.95.0, local TCP tests | Operator deployment not qualified by local tests | BestEffort; HostHandoff is not peer delivery | +| Cloudflare | worker 0.8.5; guest/WASM contract tests | Unknown; no deployed lifecycle artifact supplied | BestEffort; hosted disconnect/close boundary remains unproved | +| Fastly | fastly 0.13.1; scripted guest sink tests | Unknown; Viceroy is not hosted evidence | BestEffort; synchronous operations cannot be guest-preempted | +| Spin | spin-sdk 7.0.0; scripted guest/WASI tests | Unknown; local Spin is not hosted evidence | BestEffort; finite host teardown remains unproved | + +No normalized guest test qualifies raw-ingress parser guarantees. A future hosted +artifact must identify its source SHA, SDK/runtime revision (or typed unknown), +compatibility settings, scenario, terminal count, resource recovery and ownership boundary. + +### 8.1 Opt-in hosted ownership qualification + +The reusable-app fixture workspace has a default-off `qualification` feature. Its synthetic +application uses the standard entrypoints on all four adapters, including the manual Fastly +send-owning lifecycle. It emits four bounded, correlated records per valid probe: source drop, +terminal report, response-owned resource drop and final counters. Build identity is compiled in; +request headers can select only bounded run/probe tokens and a fixed scenario, never a destination. +No normal demo or generated application installs these probe routes. + +`scripts/qualify_response_egress.mjs --execute-hosted` requires an explicitly allowlisted isolated +nonproduction HTTPS origin, a finite per-probe time budget, source SHA, SDK and runtime identity +(a documented unknown is allowed), compatibility settings and independent deployment/binary +identity. It runs normal, HEAD/204/205/304, slow/non-reading client, disconnect, cancellation, +pending production, expired deadline, source failure and conversion failure probes. Each is +followed by a normal recovery request. Redirects and retries are disabled. Fastly pending probes +additionally require an independently enforced finite execution ceiling and wait for that ceiling +before recovery. A guest deadline cannot preempt an indefinitely pending synchronous source poll. + +The runner caps aggregate observed payload at 64 MiB, plus at most one delivered Node stream chunk +on a cap crossing; input files are read in 16 KiB chunks and rejected before retaining more than +4 MiB. Each evidence-input read can additionally hold one 16 KiB chunk and a final concatenation +copy. These are client-driver budgets, not provider allocation guarantees. + +`--validate` requires complete unsampled telemetry and exactly one terminal/source/resource/final +record for every scheduled probe, with matching build identities and ordered sequence numbers. +It rejects duplicates, missing records, unexpected scenarios, post-terminal source polls, +misclassified failures and unsuccessful normal/bodyless/recovery observations. Its successful +result is only `qualified-ownership` for those scheduled probes, never general delivery or +resource certification. Transport failures qualify only when independently observed; they are +not synthesized by a response object. A run terminated before telemetry export remains unverified. + +The compiled `artifact` identity is a build-input digest (for example the lockfile SHA-256), not +the final binary's self-referential hash. `binary_sha256` and deployment identity are recorded +independently after build. The fixture does not identify provider instances (`unobserved`), so +successful subsequent requests do not prove same-isolate capacity recovery. Hosted teardown, +actual transport interruption, provider allocation and raw parser behavior still require their +own evidence. See the fixture README for build and invocation commands. No hosted run is claimed +by adding or compiling these tools. + - Axum `tokio::io::duplex` and raw TCP tests for no reader, slow reader, disconnect before first byte, disconnect mid-body, blocked-socket deadline, normal EOF, keep-alive, pipelining collateral cancellation, and per-response flush association. diff --git a/docs/superpowers/specs/2026-09-15-reusable-app-lifecycle-design.md b/docs/superpowers/specs/2026-09-15-reusable-app-lifecycle-design.md new file mode 100644 index 00000000..c4395517 --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-reusable-app-lifecycle-design.md @@ -0,0 +1,551 @@ +# Opt-in reusable application lifecycle + +- **Status:** Historical lifecycle design; reconciled with PR #275's outbound hard-cut. +- **Date:** 2026-09-15 +- **Source baseline:** EdgeZero `593fc9282a1c56e12bae15f91eef2162f4b6a1b7`. +- **Scope:** Core application ownership and the Fastly, Cloudflare, Spin, and Axum adapter surfaces, including custom dispatch, examples, documentation, and validation. +- **Evidence:** Host contracts and local provider fixtures; deployed lifetimes and application performance remain unverified. + +The original private-helper design in §§8/9.1 is superseded by +[the custom serving lifecycle plan](../plans/2026-09-17-custom-serving-lifecycle.md). +The implemented `lifecycle::{Sandbox, serve_custom, run_custom}` surface, including +its observation counters, is public. No provider-independent scheduler is added. + +PR #275 supersedes the original assembly, raw-conversion, response-returning, and +buffered-delivery choices below. The +[outbound HTTP design](2026-05-21-outbound-http-design.md) is authoritative for +admission, outbound execution, response egress, and capability semantics. The API +sections here describe the current production interfaces; historical verification +records do not establish that the reconciled implementation has been reverified. + +### Included compatibility and CLI changes + +The following focused changes accompany retention and apply to existing users: + +- Cloudflare preserves repeated response headers, including every `Set-Cookie`; + its first application value replaces a body-generated default. +- `config push` and `config diff` limit adapter-specific checks to the selected + adapter, case-insensitively. Shared schema and secret-presence checks remain; + `config validate --strict` checks portability across declared registered adapters. +- Spin CLI secret-reference validation reports fields and rules without including + secret-reference values. This is one of the CLI diagnostic scope exceptions + listed under Non-goals (§3) and does not change runtime error policy. + +## 1. Objective + +Allow applications to amortize app/router construction across requests when the host supports reuse. Separate retained initialization from request setup without changing existing entry-point defaults or moving application policy into the framework. + +There are two independent operations: + +1. A host permits an instance to receive another request. +2. Application code retains initialized objects rather than rebuilding them when that request arrives. + +Enabling the first does not automatically implement the second. Retaining the router also does not eliminate configuration reads, request conversion, registry construction, signing, parsing, or other work that application handlers still perform explicitly. + +Correctness must tolerate fresh initialization on any request. Retention is an optimization within one process, sandbox, isolate, or component instance, not durable storage or a routing-affinity guarantee. + +## 2. Verified baseline + +The source baseline above records the historical investigation. These API descriptions +are reconciled with the current production implementations rather than that baseline. + +| Surface | Current behavior | Reference | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | +| Core construction | `App::build::(platform)` installs platform metadata, constructs routes, and invokes fallible `Hooks::configure`; only success yields an app. | `crates/edgezero-core/src/app.rs` | +| Core dispatch | `RouterService` owns `Arc` and supports repeated `oneshot(&self, request)`. | `crates/edgezero-core/src/router.rs:298`, `:349` | +| State | Registered state is cloned into each request; shared interiors remain shared. App state overwrites extensions of the same type. | `crates/edgezero-core/src/router.rs:212`, `:251` | +| Macro | `app!` generates `Hooks` and router construction. Its state expression executes when the router is built. It does not generate the provider lifecycle. | `crates/edgezero-macros/src/app.rs:214`, `:229` | +| Fastly | `run_app_with_hooks` builds per invocation; `serve_app_with_hooks` retains only successful construction through `Sandbox`. | `crates/edgezero-adapter-fastly/src/lib.rs` | +| Fastly logger | Installing the process-global logger a second time returns an error. | `crates/edgezero-adapter-fastly/src/logger.rs:52` | +| Fastly prebuilt dispatch | `request::send_request_with_registries_and_hooks` performs canonical admission and owned egress; no native response is returned for another sender. | `crates/edgezero-adapter-fastly/src/request.rs` | +| Cloudflare | `run_app` builds per fetch and delegates to public `dispatch_app::(&app, req, env, ctx)`. | `crates/edgezero-adapter-cloudflare/src/lib.rs` | +| Spin | `run_app` builds per invocation and delegates to public `dispatch_app::(&app, req)`, returning raw `SpinResponse`. | `crates/edgezero-adapter-spin/src/lib.rs` | +| Cloudflare/Spin logging | Both adapter logger initializers are currently no-ops. Their existing entry points honor `owns_logging`. | Cloudflare `src/lib.rs:30`; Spin `src/lib.rs:65` | +| Axum | `run_app` builds the app once before starting the server, then shares the router through cloned services. | `crates/edgezero-adapter-axum/src/dev_server.rs:352`, `:302`; `src/service.rs:132` | + +### 2.1 Host lifecycle distinctions + +- **Fastly:** reuse is opt-in through an ordinary `main` and SDK `Serve`. A sandbox processes requests sequentially. Lifecycle limits are checked between requests; the platform may end a sandbox earlier. +- **Cloudflare:** an isolate can handle overlapping requests on its event loop. No application loop requests the next fetch. Retention must tolerate eviction and must not associate the first request's bindings or context with subsequent requests. +- **Spin:** the current pinned SDK 7 HTTP macro exports WASI P3. The Rust `wasm32-wasip2` build target does not establish a P2 HTTP lifecycle. Verify the emitted interface and host together; retained state must support overlapping invocations when the host permits them. +- **Axum:** the existing long-running service already retains its router and permits concurrent requests. + +Provider references: + +- [Fastly sandbox lifecycle](https://www.fastly.com/documentation/guides/compute/developer-guides/sandbox-lifecycle/) +- [Fastly 0.12.1 serving API](https://docs.rs/fastly/0.12.1/fastly/http/serve/index.html) +- [Cloudflare runtime model](https://developers.cloudflare.com/workers/reference/how-workers-works/) +- [Spin instance reuse](https://spinframework.dev/v4/http-trigger#controlling-instance-reuse) + +These distinctions prohibit a universal sequential serving loop in core. + +## 3. Scope and ownership + +### Framework responsibilities + +- Support dispatch against a prebuilt application with complete store-metadata wiring. +- Provide the Fastly opt-in lifecycle and retained standard-app convenience. +- Document concrete application-owned retention for Cloudflare and Spin. +- Preserve fresh request conversion, contexts, extensions, and registry resolution. +- Preserve canonical admission and adapter-owned lazy response egress for both default and retained dispatch. +- Document lifecycle, concurrency, failure, refresh, and resource boundaries. +- Provide framework contract tests and local lifecycle fixtures. + +### Application responsibilities + +- Decide which settings, parsed objects, registries, clients, middleware caches, and other objects to retain. +- Define refresh, invalidation, key rotation, bounded cache growth, and initialization-failure policies. +- Keep request-derived data out of shared initialization unless explicitly designed for safe sharing. +- Own raw-request mutation, application response finalization, and post-send application work. +- Prove application-specific isolation and measure representative workloads. + +### Non-goals + +- No automatic caching inside existing `run_app` functions. +- No new core `Hooks` methods, macro arguments, manifest keys, CLI reuse switch, or provider-independent scheduler. +- No framework singleton registry keyed by application type. +- No SDK upgrade solely for this feature; Fastly 0.12.1 already provides `Serve`. +- Retention introduces no response buffer or stream-collection cap. Standard and custom admitted dispatch use the authoritative outbound design's lazy response-egress contract. The header-preservation and CLI diagnostic corrections listed above remain historical scope exceptions. +- No durable cache, automatic configuration refresh, parsed-key cache, or application-specific finalization hook. +- No deployment, publishing, or external issue/PR activity as part of implementing this spec. + +## 4. API design + +### 4.1 Preserve core ownership + +Use `edgezero_core::app::{App, Hooks, StoresMetadata}` and core `Extensions`. +`App::build::(PLATFORM) -> Result` is the canonical assembly +path. It installs target metadata before fallible `Hooks::configure`, propagates +its typed failure, and rejects a successful configuration that changes platform +metadata. Retain only a completed successful app; initialization errors are never +cached. Lifecycle counters and store metadata do not belong in `App`. + +`App` is structurally `Send + Sync`: handlers and middleware require these bounds, registered state requires them, and the router is reference-counted. Dispatch futures may remain non-`Send`. Add a compile-time assertion protecting the retained object's bounds without imposing `Send` on WASM futures. + +### 4.2 Fastly standard lifecycle + +Add these exports in `edgezero-adapter-fastly`, under its existing `fastly` gate: + +```rust,ignore +pub use fastly::http::serve::{Serve, ServeSummary}; + +pub fn serve_app(serve: Serve) + -> ServeSummary; + +pub fn serve_app_with_hooks( + serve: Serve, + prepare: Prepare, + finalize: Finalize, +) -> ServeSummary +where + A: Hooks, + Prepare: FnMut(&mut fastly::Request, &mut Extensions), + Finalize: FnMut(&mut edgezero_core::Response) -> State; +``` + +`serve_app` delegates with empty preparation and finalization callbacks. Both helpers: + +1. Capture static `A::stores()` metadata. +2. Enter the SDK callback before performing host-dependent initialization. +3. Read runtime configuration for the current request. +4. Complete first-snapshot logging setup through `Sandbox::setup_once`, honoring `A::owns_logging()`, then initialize with `App::build::(FASTLY_PLATFORM)` through `Sandbox::initialize`. +5. Retain the successfully constructed app for subsequent callbacks. +6. Use the first runtime configuration read for the first dispatch; do not read it twice. +7. On every request, use `request::send_request_with_registries_and_hooks` with fresh registries, admission, and response-egress resources. Preparation borrows the native request before admission; finalization borrows routed responses before egress policy and framing. Detached responses skip finalization. +8. Return the SDK summary unchanged. Do not hide handler errors or synthesize a guaranteed request count. + +The logger's enablement, level, endpoint, and stdout policy are a first-initialization snapshot, even if runtime selectors later change. Store selector reads remain per request. Preserve the existing `FastlyLogging::from(&EnvConfig)` mapping: it derives enablement from endpoint presence and currently fixes `echo_stdout` to true rather than applying the corresponding runtime override. This feature does not change that mapping. `owns_logging` retains its existing meaning: skip adapter logger installation. An application adopting retained construction must arrange its own logger installation before construction if construction needs logging. + +**Degraded-first-read policy:** preserve the existing optional-store fallback and freeze the resulting logging decision for this sandbox. `runtime_env_config` does not distinguish an absent optional store from another store-open failure; both produce empty configuration. That disables named-endpoint logging for the sandbox even if a later runtime read succeeds. The `echo_stdout` value does not provide a fallback when no logger is installed. Later reads can restore store selectors but must not silently reconfigure the global logger. Test this explicitly. Applications requiring stricter logging availability must use the custom lifecycle with their own configuration-read and initialization policy. Adding a distinguishable read status, deferred logging initialization, or retry policy would require a separately reviewed API/ordering change; it is not implied by these helpers. + +Capture preparation and finalization callbacks in the serving closure and pass +fresh mutable reborrows on every request. Do not move either callback out on the +first invocation or retain request-owned values in its captures. + +An initialization `Err` leaves `Sandbox` empty. Standard serving propagates that +error and terminates; custom callbacks may handle a recoverable failure and retry +on a later request. Configuration is fallible, and panics remain sandbox failures. +Partial global setup side effects are not rolled back, so custom retry policy must +make setup safe to repeat. + +Example entry point after implementation: + +```rust,ignore +use edgezero_adapter_fastly::{Serve, serve_app}; + +fn main() -> Result<(), fastly::Error> { + serve_app::(Serve::new().with_max_requests(10)).into_result() +} +``` + +The example deliberately uses ordinary `main`, not `#[fastly::main]`. Existing generated entry points remain unchanged. + +### 4.3 Fastly custom lifecycle + +Use `lifecycle::serve_custom(Serve, callback)` with +`FnMut(fastly::Request, &mut Sandbox) -> Result<(), E>`. The callback owns +terminal delivery. The wrapper records that result through the SDK without asking +the SDK to send a response or synthesize an error response. + +A callback receives an owned native request and may use +`request::send_request_with_registries_and_hooks` with a retained app, current +runtime configuration, and request-local hooks. Preparation can mutate the native +request and populate extensions; finalization can inspect or mutate routed core +responses before policy/framing. The adapter retains transmission ownership and +streams lazily through terminal delivery; no public raw-conversion or +response-returning dispatch helper is part of this path. + +Applications may capture retained state in a closure or pass it through `run_with_context`. Host-dependent or fallible initialization belongs inside the callback. A lightweight health response may bypass expensive initialization; retaining state must not require eager construction before every callback can run. + +Resolve `runtime_env_config` per callback, initialize logging before emitting its +deferred boot diagnostics, and pass fresh store registries through the canonical +send-owning path. Do not manually route a converted request and collect its body; +that would bypass admission or duplicate response-egress ownership. + +The custom lifecycle uses `lifecycle::Sandbox`: an application-owned payload in a framework-owned successful-only slot, attempted-callback and initialization counters, and an independent successful setup guard. `initialize` returns errors unchanged and leaves the slot empty so a later call can retry; applications decide whether to respond and continue. `run_custom` creates fresh state for a single received request without entering `Serve`. Both wrappers record terminal delivery exactly once without SDK resends. State decision logic is feature-independent for host tests; SDK wrappers require `fastly`. Default generated entry points remain unchanged. + +### 4.4 Cloudflare and Spin prebuilt dispatch + +Public root-level dispatch helpers select metadata from the caller's `Hooks` type: + +```rust,ignore +// edgezero-adapter-cloudflare +pub async fn dispatch_app( + app: &App, + req: worker::Request, + env: worker::Env, + ctx: worker::Context, +) -> Result; + +// edgezero-adapter-spin +pub async fn dispatch_app( + app: &App, + req: spin_sdk::http::Request, +) -> anyhow::Result; +``` + +Use the same feature and target gates as each adapter's existing `run_app`. + +These functions do not build an app, install logging, or cache anything. They resolve runtime configuration for that invocation, build registries using `A::stores()`, and run fresh admission and response egress through the internal registry-aware dispatcher. Callers must supply an app constructed from `A` with `CLOUDFLARE_PLATFORM` or the selected Spin platform; `App` erases its type, so the function cannot verify provenance. Spin returns the raw WASI response; its coordinator retains the body writer and transmission-result observation. + +For Cloudflare, derive `EnvConfig` before moving `env` into the dispatcher and keep the configuration alive through the awaited call that borrows it in `RegistryInputs`. For Spin, unpack `stores.config`, `stores.kv`, and `stores.secrets` into the existing dispatcher's separate metadata arguments. These are adapter wrappers, not changes to internal dispatcher signatures. + +The explicit `Hooks` type avoids silently losing store wiring without pretending `&App` contains its construction metadata. Logging is excluded intentionally: a dispatcher must be safe to call repeatedly, and the caller controls initialization before constructing the app. + +Existing `run_app` delegates its dispatch portion to the new function, preserving logger-before-build ordering, environment-resolution behavior, error behavior, and exactly one build per call. + +Do not route these helpers through the manual Cloudflare builder or Spin `AppExt::dispatch`: those paths do not preserve all metadata-aware behavior. + +### 4.5 Concrete retention on Cloudflare and Spin + +Document a static owned by the concrete application entry-point module: + +```rust,ignore +use edgezero_adapter_cloudflare::CLOUDFLARE_PLATFORM; +use edgezero_core::{app::App, EdgeError}; +use std::sync::OnceLock; + +static APP: OnceLock = OnceLock::new(); + +fn retained_app() -> Result<&'static App, EdgeError> { + if let Some(app) = APP.get() { + return Ok(app); + } + let app = App::build::(CLOUDFLARE_PLATFORM)?; + Ok(APP.get_or_init(|| app)) +} +``` + +The fetch callback obtains this reference synchronously, maps initialization +failure to a category-safe Worker error, and passes the successful app and current +request arguments to `dispatch_app::`. For Spin, use `SPIN_PLATFORM` or +metadata for the selected host and return `anyhow::Result` from the +HTTP callback. A failed build leaves the cache empty. Concurrent attempts may +construct multiple successful candidates; only one is retained. + +Requirements for the example and API documentation: + +- The cache belongs to one concrete application. Never put an unkeyed static inside a generic `cached()`; such statics are shared across type instantiations. +- Perform any application-owned logging initialization before `App::build` if construction logs. Adapter logging is a no-op on these two targets; do not invent a new logger implementation in this change. +- The initializer is synchronous and must not recursively access the same cache. Do not block the event loop waiting on an async initializer or hold mutex/borrow guards across dispatch awaits. +- Retain only the app, not a future, native request, Worker `Env`/`Context`, or store registry. +- Binding/open failures occur during dispatch and are retried on a later request through normal request setup; they must not poison the app cache. +- The example propagates fallible synchronous construction without caching errors. Applications needing asynchronous initialization, serialized construction attempts, or refresh manage that owner and publish a complete successful snapshot before sharing it. No generic framework failure cache is added. +- App middleware and shared state now survive across requests and can be accessed by overlapping invocations. The application must consent to that changed lifetime by explicitly adopting the example. + +### 4.6 Axum + +Keep the existing construction and service ownership. Include Axum in the shared-state contract tests and documentation matrix, but add no reuse flag or alternate lifecycle helper. + +This does not claim that all Axum per-request costs have already been optimized; +request-local outbound wiring and admission/egress resources remain necessary. + +## 5. Fastly limits and termination + +Expose SDK limit configuration without translating or duplicating it: + +| SDK setting | Semantics to document | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `with_max_requests(usize)` | Positive values bound requests; zero means no SDK request-count limit. The example uses an explicit finite value. | +| `with_max_lifetime(Duration)` | Checked between requests, measured from `Serve` construction. Not an interrupt deadline for a running handler. | +| `with_max_memory(u32)` | MiB snapshot threshold checked between requests; zero disables this SDK threshold. An unavailable measurement stops further reuse conservatively when configured. | +| `with_timeout(Duration)` | Bounds waiting for another request, subject to platform limits. Does not set application/backend request timeout. | + +The implementation must follow the pinned SDK's exact comparisons rather than introducing alternative threshold semantics. The platform may enforce additional limits, and request CPU/runtime limits still apply per request. + +The pinned SDK stops after a handler error. EdgeZero's serving wrappers use a +delivery-result adapter that records `Result<(), E>` without invoking another +send; an escaping error terminates serving without an SDK-generated 500. Direct +SDK result handlers have a different sending contract and are not the canonical +EdgeZero entry path. + +Distinguish a terminal delivery error from an application error rendered as an +HTTP response. Canonical app dispatch renders routing/handler errors through the +configured error renderer, and an HTTP 4xx/5xx alone does not stop reuse. Admission +refusals and bounded precommit fallbacks follow the authoritative outbound design; +they are not escaping SDK errors merely because they report failure status. An +error that escapes owned delivery terminates standard serving without a second +response attempt. Test rendered errors, admission aborts, and terminal delivery +failures separately. Custom callbacks may handle a recoverable initialization +failure before commitment; recovery after an escaping error requires a fresh +sandbox. Cloudflare/Spin resolve bindings again on later requests without caching +initialization failures. + +`ServeSummary::requests()` counts attempted callback invocations, not successfully completed responses: the SDK increments it before invoking the handler, including the callback that returns an error. A panic may prevent a summary from being returned at all. `ServeSummary` also reports handler wall time, wait wall time, and handler error. It does not expose all reasons for termination: a next-request wait error can end the loop with no summary handler error, and promise registration can panic. `into_result() == Ok(())` alone is not proof of healthy reuse or reaching the requested limit. + +## 6. Response and resource contract + +### 6.1 Preserve response behavior + +Implementation review identified one focused compatibility correction: Cloudflare's +converter previously overwrote repeated application headers. Preserve each value, +including duplicate `Set-Cookie`, by replacing the generated default with the first +application value and appending the rest. This correction applies to both existing +and retained entry points; stream translation and response ownership stay unchanged. + +| Adapter/path | Existing behavior retained | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Fastly standard | Duplicate headers are appended; the adapter owns commitment, lazy chunk writes, terminal observation, and cleanup. | +| Fastly custom | Request preparation and routed-response finalization remain possible through borrowing hooks; canonical delivery stays adapter-owned. | +| Cloudflare | A request-local JavaScript stream coordinator registered with `Context::wait_until` owns backpressure, deadline/abort handling, and close. | +| Spin | A raw `SpinResponse` is backed by an owned WASI body/transmission coordinator; no whole-response collection or historical 16 MiB stream cap. | +| Axum | A connection-local Hyper body preserves lazy production and terminal response-egress observation. | + +Inbound bodies are consumed only after canonical admission, under the selected +limits and absolute deadline. Lazy downstream delivery does not imply unbounded +uploads, remove provider queues, or eliminate per-request body costs. + +### 6.2 Explicit-send error ordering + +Custom Fastly callbacks return terminal `Result<(), E>` after owned delivery. +They must finish or abandon guest-owned streaming resources before returning. +The lifecycle wrapper records that result without another send. + +Canonical response egress decides the bounded fallback before commitment and +never replaces a committed response. After commitment, a body failure follows +adapter-owned cleanup and terminal reporting. An escaping `Err` from an EdgeZero +lifecycle callback terminates serving without an SDK resend. Applications must +not hand a native response to a second SDK sender or bypass this path with raw +conversion/manual body collection. + +Complete required guest-owned post-send work within the Fastly callback. Finishing a guest response does not mean the peer has received every network byte; do not wait for network delivery acknowledgements. Cloudflare's documented request `wait_until` work remains tied to its own context, not to the retained app. + +### 6.3 Retained versus request-owned state + +| Retain only by deliberate policy | Keep fresh and request-owned | +| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| App/router/routes, middleware instances, static store metadata | Raw/core requests and responses, headers, URI, route parameters | +| Immutable settings snapshots, parsed ordinary-memory objects | Client metadata, authentication/session data, request IDs, request extension bags | +| Bounded caches with explicit keys, eviction, and invalidation | Native store wrappers/registries, body streams, pending backend operations, response effects | + +Cloning a wrapper or satisfying `Send + Sync` is not proof of host-resource validity or request isolation. Persistent `Arc` interiors are intentionally shared; the framework does not clear their contents between requests. Existing same-type extension overwrite behavior must be tested and documented, not changed here. + +Keep runtime configuration reads and store registry construction per request. Retained app state is a separate snapshot and will not automatically follow selector changes. A refresh must publish a coherent snapshot; in-flight requests keep the snapshot they acquired. Applications may choose not to refresh within an instance, but must document the resulting staleness policy. + +Audit all process-global and thread-local state, including `OnceLock` caches, warning suppression, and recursion guards. Static warning suppression can reduce log volume across requests; warning rate must not be interpreted as failure rate. The current bounded recent-name set can evict entries, so suppression is not an absolute once-per-sandbox guarantee. Verify guard cleanup on supported exit/error paths; the presence of a thread-local guard alone does not demonstrate leakage. + +Audit result: core's recursion depth is thread-local and guarded by `SecretFieldsRecursionGuard`. Tests verify normal scope cleanup and host unwinding followed by fresh entry; host unwinding does not prove cleanup after a terminating WASM trap. Canonical-form instrumentation and environment locks are test-only. Fastly's missing-store warning sets are bounded, and their eviction tests cover repeated names. No request data belongs in these caches. + +**Dynamic backends:** Fastly outbound execution registers backends using +URI-derived identity and adapter policy; the outbound design owns naming, +timeout, and dispatch rules. Distinct origins can increase registration +diversity. Do not model service-wide capacity as a counter reset by app or +sandbox reconstruction. Use bounded authorized origin sets and measure repeated +and distinct origins, registration failures/waits, and memory. A between-request +lifecycle limit cannot preempt a blocked hostcall or bound fan-out within one +request. See [registration scope](https://www.fastly.com/documentation/guides/integrations/non-fastly-services/developer-guide-backends/) +and [dynamic-backend limit behavior](https://www.fastly.com/documentation/reference/compute/errors). + +Fastly environment variables describe the sandbox: `FASTLY_TRACE_ID` must not be treated as a unique request identifier in reusable mode. Capture `Request::get_client_request_id()` separately for request correlation, with a documented fallback when unavailable. Do not retain request correlation fields in the global logger or app state. + +## 7. Compatibility and unresolved platform evidence + +- Existing Fastly, Cloudflare, and Spin `run_app` calls continue building each invocation; generated templates continue calling them. +- PR #275 supersedes historical raw conversion and response-returning custom dispatch. Fastly uses `Sandbox`, `serve_app`, `serve_app_with_hooks`, and canonical send-owning request hooks; no compatibility aliases restore removed APIs. +- `Hooks::configure` is fallible and `App::build::(PLATFORM)` is the canonical constructor. Retention does not change macro syntax, registered-state precedence, or provider feature gates. +- No Tokio dependency is added to core or WASM adapters. Examples import portable HTTP types from core and use `#[action]` for any new handlers. +- Reuse changes app-state lifetime only on explicit adoption. It must never silently become the default through a template or macro change. +- Adopting retention changes the frequency of construction-owned logging setup. `owns_logging` still only disables framework installation; applications that refresh logging policy during each construction must move that refresh to explicit request setup or accept an initialization snapshot. The framework does not invoke a new application logging hook. + +### Platform verification gates + +1. **Fastly logging endpoints:** the current logger retains native endpoint handles. Viceroy retains its endpoint table, but this is not a blanket deployed lifetime guarantee. Verify named-endpoint logging through multiple reused requests and obtain authoritative SDK/provider evidence for deployed validity before declaring full support. Assert actual receipt of distinct request-correlated records at the named endpoint after the first request; absence of returned errors or stdout output is insufficient. `log-fastly` ignores endpoint write errors, so invalid handles may manifest as missing logs. If invalid, a separately reviewed logger adjustment must retain configuration while reacquiring request-valid endpoints; do not simply ignore logger errors. +2. **Native handles:** the spec deliberately avoids caching store handles. No guarantee that all native resources survive request boundaries is assumed. +3. **Spin host/interface compatibility:** verify the emitted P3 component and installed runtime together, not only the Rust target triple or CLI version. +4. **Overlap:** verify Cloudflare and Spin callbacks actually overlap in the test, then prove isolation. A serialized test cannot establish concurrent safety. + +These are validation gates, not permission to silently broaden implementation scope. Unsupported environments must be reported as unsupported or unverified rather than counted as passing. + +## 8. Files and documentation surfaces + +Expected production changes: + +- `crates/edgezero-adapter-fastly/src/lib.rs`: SDK exports and opt-in standard lifecycle helpers; public feature-independent `lifecycle::Sandbox` plus `serve_custom`/`run_custom` wrappers live in `src/lifecycle.rs` with host tests (superseding the original private helper). +- `crates/edgezero-adapter-cloudflare/src/lib.rs`: `Hooks`-typed prebuilt dispatcher shared with `run_app`. +- `crates/edgezero-adapter-cloudflare/src/response.rs`: the duplicate-header correction described in §6.1. +- `crates/edgezero-adapter-spin/src/lib.rs`: `Hooks`-typed prebuilt dispatcher shared with `run_app`. +- Fastly `src/request.rs`: canonical admitted, send-owning dispatch with request preparation and routed-response finalization hooks. +- CLI `src/config.rs` and Spin `src/cli.rs`: selected-adapter validation and diagnostic redaction listed above. +- `crates/edgezero-core/src/app.rs`: fallible platform-aware assembly and canonical admission/egress; retained-object bounds and shared-state contract coverage. + +Documentation changes during implementation: + +- `docs/guide/adapters/overview.md`: lifecycle matrix, retained versus request state, common opt-in contract. +- `docs/guide/adapters/fastly.md`: default and reusable `main`, SDK limits, summary/error handling, standard and custom streaming paths, evidence limitations. +- `docs/guide/adapters/cloudflare.md`: concrete app cache, fresh `Env`/`Context`, concurrent fetch isolation, cold initialization. +- `docs/guide/adapters/spin.md`: SDK7/P3 distinction, successful-only concrete app cache, host reuse controls and concurrency, raw response-egress ownership. +- `docs/guide/adapters/axum.md`: existing retained-router behavior and concurrent shared state. +- `examples/app-demo/crates/app-demo-core/src/lib.rs`: qualify fresh-Fastly-instance language as default behavior. + +Keep generated templates and default demo entry points unchanged. Add opt-in examples as dedicated fixtures under `tests/fixtures/reusable-app/` with a standalone Cargo workspace, per-provider packages/configuration, and no production credentials. Add `scripts/smoke_test_reusable_app.sh` to build/run selected local providers, collect results, and report explicit skips. Record exact fixture paths in the implementation plan after confirming provider build-tool requirements. + +The listed surfaces are implemented; the original design-step authorization checkpoint has been superseded by implementation approval. + +## 9. Validation design + +### 9.1 Framework tests + +#### Host logic versus provider execution + +The current Fastly crate does not link native host tests when its `fastly` feature is enabled. Verification with `cargo test -p edgezero-adapter-fastly --features fastly --lib --offline` failed on arm64 with unresolved Fastly hostcalls, including `_uri_get`. This is pre-existing: the default workspace test gate omits that feature, while the feature-enabled `cargo check` gate type-checks without linking. Do not require native feature-enabled tests or add fake hostcall symbols to make them pass. + +The original private-helper plan is superseded by [the custom serving lifecycle plan](../plans/2026-09-17-custom-serving-lifecycle.md) and PR #275's outbound hard-cut. Public `lifecycle::Sandbox` contains feature-independent retention state with no Fastly SDK types or hostcalls; standard serving uses its independent setup and successful-app guards. The production wrapper supplies fresh runtime configuration, fallible platform-aware assembly, and canonical send-owning dispatch. Host tests must exercise those same transitions, not a duplicate model. Keep these mechanics local to Fastly; no cross-provider lifecycle abstraction is introduced. + +Assign verification explicitly: + +- **Default host tests:** retained build/configure counts, first-initialization decisions, logger ownership/order through injected operations, degraded-then-successful configuration inputs preserving the first logging snapshot, terminal initialization errors, and fresh-owner reset. Pure core tests cover extension/state behavior without provider resources. Run `cargo test -p edgezero-adapter-fastly --lib` for the adapter logic. +- **Feature-enabled checks:** type-check SDK wrapper integration and callback bounds, including target-specific checks. These checks do not prove runtime behavior. +- **WASM/local provider fixtures (§9.2):** actual callback/build counts in reused instances, runtime-store failure/recovery where supported by controlled fixtures, real named-endpoint log delivery, native metadata/handles, stream completion, SDK summary counts/termination, and all other hostcall behavior. Fault injection proves only the injected case; unavailable provider failure modes must be reported separately. + +The requirements below span these layers. They do not require provider calls inside native colocated tests. The warning about feature-disabled no-op stubs excludes claims about provider behavior, not valid tests of shared feature-independent production logic. + +- Count builds/configure invocations: existing default paths build every invocation; retained paths build once per owner/instance. +- Prove a concrete cache cannot return another application's router. No generic unkeyed static may appear in production or examples. +- Verify fresh extensions, route parameters, headers, bodies, request IDs, and native metadata on successive requests. +- Include same-type app/request extension collisions and intentionally shared `Arc` mutation, distinguishing intended sharing from leakage. +- Verify store metadata, named/default bindings, runtime selectors, missing-store behavior, and recovery after a request-specific binding failure. +- Verify logger ownership, ordering, first-initialization policy, and Fastly named-endpoint output on subsequent requests. Include a degraded first runtime-store read followed by a successful read: selectors recover while the logging snapshot remains disabled, as specified in §4.2. +- Verify standard errors and custom send outcomes; initialization errors, constructor panic, pre-commit errors, post-commit stream errors, and later fresh initialization are separate cases. +- Keep existing duplicate `Set-Cookie` and body tests. Add actual delayed-chunk/first-byte tests for paths that support progressive client streaming. +- Compile examples against the locked SDKs on their actual targets. Host tests with feature-disabled no-op stubs do not validate provider behavior. + +### 9.2 Local runtime matrix + +At investigation time PATH Viceroy is 0.17.0; Fastly CLI 15.1.0 reports Viceroy 0.21.0; Spin is 4.0.0. Record the exact executable used, SDK lockfile, build target, emitted HTTP interface, and configuration for every result. + +The fixture runner must select and report its Viceroy executable explicitly. `edgezero serve --adapter fastly` invokes `fastly compute serve`, which may select a different Viceroy. Record the CLI-managed runtime separately; do not attribute standalone fixture results to the CLI path without running it. Different versions are valid separate test environments, not interchangeable evidence. + +Before enabling `with_max_memory` in a local comparison, verify that the actual guest can obtain a successful memory snapshot. Both investigated Viceroy versions implement `get_heap_mib`, but source support does not replace this runtime preflight. If unavailable, report the metric and memory-limit test as unsupported and omit that optional limit from the reuse-performance comparison. Otherwise the SDK may stop after request one while `into_result()` remains successful. Keep a separate unsupported-measurement test to verify the SDK's conservative termination behavior. + +Viceroy's versioned upstream tests demonstrate a multi-request implementation: [0.17.0](https://github.com/fastly/Viceroy/blob/v0.17.0/cli/tests/integration/reusable_sessions.rs#L7) and [0.21.0](https://github.com/fastly/Viceroy/blob/v0.21.0/cli/tests/integration/reusable_sandboxes.rs#L7). This makes local experiments plausible; it is not evidence that these EdgeZero changes have run successfully. + +Observed with the pinned standalone Viceroy 0.17.0: a guest admits at most six requests (one initial request plus five `next_request` accepts), so raising the SDK request limit or the fixture's `--max-requests` cannot extend that local ceiling. + +- **Fastly:** run A/B/C below, stream completion, duplicate cookies, repeated logging, limit/idle termination, and restart isolation. +- **Cloudflare:** use a local Workers runtime to compare per-fetch build versus retained concrete app; force overlapping requests with distinct inputs and a controlled await barrier. Restart to validate cold initialization. Record availability/version rather than assuming the runtime exists. +- **Spin:** run the SDK7/P3 fixture on a compatible local host, with both sequential and concurrent instance reuse. Exercise supported reuse-count/concurrency/idle controls and record chosen values. +- **Axum:** verify one build per server start and independent overlapping requests against the shared router. Use it as a retained-app reference, not as a proxy for WASM performance. + +A sequential request sequence alone does not prove reuse: observe a stable instance identifier and an increasing request ordinal. A runtime process ID alone is insufficient because one host process can create many guest instances. + +### 9.3 Fastly A/B/C experiment + +| Variant | Serving lifecycle | Initialization | +| ------- | ----------------------------------- | ----------------------------------------------------------------- | +| A | Existing single-request entry point | Current per-request app construction and logging policy | +| B | SDK `Serve` | App construction on every callback; global logging installed once | +| C | SDK `Serve` | App construction and global logging once per sandbox | + +B's logging qualification is mandatory: blindly wrapping the enabled-logger `run_app` would fail on its second installation and confound the comparison. Test that failure separately as a negative control. Keep dispatch, configuration, lazy delivery, and workloads identical between variants. Do not compare historical buffered helpers against current streamed delivery. + +Implement B through `lifecycle::serve_custom`: a successful once-only logging +decision, per-callback `runtime_env_config` and `App::build::(FASTLY_PLATFORM)`, +then `request::send_request_with_registries_and_hooks`. Do not use retaining +`serve_app` for B. Apply the same first-read logging policy in B and C so degraded +configuration does not introduce a second experimental variable. + +Collect: + +- Sandbox start count, request ordinal, app/configure initialization count, and separately instrumented application initialization phases. +- Attempted requests per sandbox, including one-request sandboxes and early exits. Record response commitment, guest-side completion, errors, and client-observed completion separately; do not equate the SDK attempt count with successful responses. Emit per-request records because crashes may prevent a final summary. +- Client time to first byte and completion latency, with cold versus reused distributions and p50/p95/p99 plus sample counts. +- Initialization/handler wall time and CPU observations. SDK summary wall time is not CPU time; current Fastly callbacks include adapter-owned terminal delivery rather than handing a response to another sender. +- SDK CPU phase deltas where available. The pinned SDK warns against cross-run benchmarking with its vCPU clock; corroborate comparisons with controlled profiling or platform telemetry and report precision/unsupported metrics. +- Memory before initialization, after initialization, and after each request's cleanup; request-ordinal slope, peak, and plateau. Distinguish guest linear-memory high-water marks, SDK host-inclusive snapshots, and host process RSS. MiB rounding cannot prove absence of small leaks. + +Use fixed release builds, deterministic payloads, controlled local backends, identical instrumentation, repeated runs, and randomized variant order where practical. Include small requests, expensive construction, delayed/large streams, failures, long sequences, idle gaps, and concurrency. Do not set a performance percentage target without baseline data. Adoption requires a reproducible benefit for the target workload with no correctness regression and bounded retained-state growth. + +Include a repeated-origin control and a many-distinct-authorized-origin proxy workload, recording latency, registration failures or waits, attempted requests per sandbox, completion outcomes, and memory. Local runtime behavior does not establish deployed service-wide capacity behavior. Include malformed-request attempts and injected inbound/body-conversion failures; distinguish requests rejected by the host before dispatch from errors actually reaching the adapter. Verify the specified termination/recovery behavior without labelling a hypothetical malformed request as a demonstrated exploit. + +### 9.4 Deployed evidence + +Local results establish only the tested local runtime behavior. Platform reuse frequency, eviction, endpoint-handle validity, resource accounting, and performance require separately authorized deployed validation. Record these as unverified until measured. No configured request limit is a guaranteed reuse count. + +### 9.5 Repository checks + +After implementation, run scoped `cargo test` after code changes and all required gates: + +```sh +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-fastly --target wasm32-wasip1 --features fastly +cargo check -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +``` + +Also build and run the dedicated provider fixtures. Run documentation formatting/lint and the VitePress build when public guide changes are made. Implementation verification is recorded in the plans and PR; unsupported runtime evidence is kept explicit. + +## 10. Acceptance and delivery + +The implementation is acceptable when: + +- Existing generated/default entry points retain their lifecycle defaults, with the separately listed compatibility and CLI corrections. +- Fastly standard apps can opt into a bounded SDK serving lifecycle with exactly one successful app initialization per sandbox. +- Custom Fastly callbacks can retain successful app state, prepare raw requests, inspect/finalize routed response extensions through hooks, and use canonical lazy delivery without another sender. +- Cloudflare and Spin can use concrete retained apps with full metadata-aware per-request dispatch and tested overlapping-request isolation. +- Axum's existing retained-router behavior remains intact. +- Fresh initialization, failure recovery, bounded state, duplicate headers, and existing response semantics are validated. +- Native handle and logger support claims are limited to authoritative evidence and tested environments. +- A/B/C measurements distinguish lifecycle reuse from retained application initialization and do not claim automatic removal of every per-request cost. +- All required build/test/documentation checks pass, or unavailable runtime evidence is explicitly reported without claiming that acceptance criterion passed. + +Suggested implementation sequence: public prebuilt dispatch and core contracts; Fastly lifecycle; provider fixtures and experiments; guides and examples. Each stage preserves defaults. No cross-provider cache abstraction is required to deliver this design. + +Rollback is an entry-point choice: return to the existing `run_app` path and remove the concrete retained-app cache. On Fastly, also restore the single-request entry point. The default Fastly A fixture exercises the original path; test it rather than assuming a lower request limit recreates every aspect of the old initialization path. No persisted data migration is part of this feature. + +## 11. Review decisions + +Independent reviews checked the adapter/core design and a custom-dispatch integration. The selected approach incorporates these corrections: + +- App ownership is portable, but scheduling and concurrency remain provider-specific. +- Prebuilt dispatch derives store metadata from an explicit `Hooks` type because `App` does not contain it. +- Concrete application-owned caches avoid generic-static cross-application contamination. +- Mutable native-request handling and response-extension finalization require the raw callback path. +- Progressive client streaming differs from collecting a stream into a response body. +- The current Spin SDK's exported HTTP interface determines reuse semantics, not the Rust target name. + +A subsequent source review clarified degraded logging snapshots, terminal adapter errors versus rendered application errors, service-wide backend pressure, callback reborrowing, and runtime measurement preflights. These clarifications retain the original opt-in and error-policy boundaries; no blanket error swallowing or automatic logging retry was added. + +Rejected alternatives: documentation-only Fastly lifecycle repeats fragile logger ordering in every standard app; a universal server builder duplicates provider controls; adding metadata/cache management to core expands scope; silently caching inside existing helpers breaks lifetime compatibility. diff --git a/docs/superpowers/specs/2026-10-05-axum-production-startup-lifecycle-design.md b/docs/superpowers/specs/2026-10-05-axum-production-startup-lifecycle-design.md new file mode 100644 index 00000000..77f43ee4 --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-axum-production-startup-lifecycle-design.md @@ -0,0 +1,427 @@ +# Axum startup, storage, and bounded shutdown + +Date: 2026-10-05 +Status: initial implementation for review. The worktree implements the combined lifecycle and production-startup APIs below. Deployment guidance and the remaining acceptance checks are still open. + +Issues: [#392 lifecycle](https://github.com/stackpop/edgezero/issues/392) and [#396 configuration/storage](https://github.com/stackpop/edgezero/issues/396), under [Epic #391](https://github.com/stackpop/edgezero/issues/391). + +Base: [PR #275](https://github.com/stackpop/edgezero/pull/275), inspected at `5527b58e2a7cb6307d5c7a8926cd5dfc6f4f8b4a`. + +Worktree: `/home/pav/worktrees/issue-392-396-production-hosting-spec`. + +## Goal + +A production Axum application validates its runtime settings and initializes required stores before accepting traffic. Applications with additional configuration/bootstrap requirements validate them through the initializer variant before readiness. Its handlers can reuse startup-loaded state. On shutdown, readiness is revoked, new work stops, and dispatched work gets one finite drain budget. + +Preserve the existing permissive development entrypoint and local defaults. Strict production startup is an explicit API choice over the same runtime owner, service, and connection implementation. Do not infer production policy from release builds, containers, or the presence of path settings. + +Keep #275's fallible app assembly, owned HTTP/1 connections, local non-Send execution, and response-egress semantics. This work extends that runner rather than introducing another HTTP server or portable lifecycle framework. + +## Scope and ownership + +| Owner | Responsibility | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| #392 | Native runtime ownership, signals/caller shutdown, lifecycle observation, dispatch gating, tracked draining, exit outcomes, and portable probe helpers | +| #396 | Explicit strict production settings/store preparation, development compatibility, app-owned initializer, shared typed-config loading, startup-only typed state, persistence/permissions/backup evidence, and production adoption | +| Application | Required schemas, config keys, extra secrets, signing/bootstrap policy, registered handler state, probe paths, and optional ongoing dependency checks | +| #395 | Residual outbound transport/wire acceptance; coordinate fallible transport startup here without building another client | +| #398/#399 | Container packaging and native architecture validation; consume this runtime contract for later integration checks | +| #397/#400 | Wider diagnostics/trusted-proxy work and deployment recipes, respectively | + +No live reload, filesystem watcher, online backup service, new backup CLI, distributed storage, automatic migrations, retry framework, tracing vendor, health configuration section, management listener, or public EdgeZero application image is included. No blanket Send bounds are added to portable futures or bodies. + +Trusted Server supplies concrete consumer requirements, but this spec does not implement its migration or certify its application image. + +## Current implementation and evidence + +All findings below are source inspection at the base above, not executed proof. Paths are repository-relative unless noted. + +| Area | Current behavior and source | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| App assembly | `crates/edgezero-core/src/app.rs`, `App::build` and `Hooks::configure`: install platform metadata and configure once. Configure receives no prepared native stores. | +| Standard entrypoint | `crates/edgezero-adapter-axum/src/dev_server.rs`, `run_app`: build app, resolve settings, bind, initialize stores, then serve. Generated main calls its no-argument form. | +| Local serving | The same file, `serve_local`: current-thread runtime/LocalSet, detached local connection tasks, Ctrl-C or internal oneshot exit. No SIGTERM or tracked graceful-drain phase. | +| Connections | `crates/edgezero-adapter-axum/src/connection.rs`, `serve_http1`: owned Hyper HTTP/1 keep-alive and response deadline enforcement. Extend this owner for draining. | +| Config files | `crates/edgezero-adapter-axum/src/config_store.rs`, `from_path` and `local_path`: flat string-map snapshots, missing-file empty stores, ancestor/cwd discovery. The runner drops malformed bindings with warnings. | +| KV identity | `dev_server.rs`, `kv_store_path` and `build_kv_registry`: name-derived slug/hash basenames under `.edgezero`. Required declared KV failures are already fatal. Each id currently attempts its own open. | +| Documented aliases | `docs/guide/kv.md:122`: ids with the same resolved platform name share a file. Per-id opens must be reconciled with this documented behavior. | +| Typed config | `crates/edgezero-core/src/extractor.rs`, `AppConfig` and `extract_from_handle`: bounded reads, envelope/integrity checks, secret resolution, schema validation, and redaction. This algorithm currently takes RequestContext. | +| Handler state | `crates/edgezero-core/src/router.rs`, `RouterBuilder::with_state`: typed state copied into requests, same-type last-write-wins. `App` currently has no post-build state setter. | +| Outbound startup | `crates/edgezero-adapter-axum/src/service.rs`, `AxumServiceState::from_app`: `try_transport().ok()` hides initialization failure; admitted requests subsequently fail. | +| Completion | `response.rs` and `crates/edgezero-core/src/response_egress.rs`: HostHandoff is not client receipt. Pre-egress cancellation can abandon callbacks while releasing captured resources. | +| Existing proof gap | `key_value_store.rs`, `data_persists_across_reopens`, tests reopen in one process, not subprocess restart, competing-writer rejection, or backup/restore. Existing server tests do not prove signal-driven draining. | + +Trusted Server at `666953a0de0dbec514b0f0bd5a8b03074f13e62b` was also inspected. Its Fastly adapter loads settings into Arc and captures that state in handlers, but its current entrypoint builds the app for each request. Native startup should retain loaded state for the native run, not assume Fastly cross-request retention. + +## Agreed runtime settings + +Use the existing `EDGEZERO__ADAPTER__*` convention. The table defines strict production options, not replacement defaults for the development runner. + +| Setting | Contract | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `HOST` | Omitted defaults to `127.0.0.1`. Supplied invalid values fail, rather than warn/fallback. Preserve current IP-literal support. | +| `PORT` | Omitted defaults to `8787`. Supplied malformed/out-of-range/zero values fail. Containers supply explicit binding values. | +| `CONFIG_DIR` | Explicit absolute, existing directory when local config stores are declared in production. | +| `DATA_DIR` | Explicit absolute, existing writable directory when embedded KV stores are declared in production. | +| `SHUTDOWN_GRACE_SECONDS` | Omitted defaults to 10. Supplied values are positive integer seconds; invalid, zero, overflow, or unusable timer values fail startup. No silent clamp/fallback. | + +Directory values are not required for undeclared store kinds. Validate supplied recognized settings instead of using invalid input as omission. Preserve native defaults for genuinely omitted optional settings. Do not add a global scanner for unrelated provider variables. + +The production standalone entrypoint reads environment settings once into native options. Embedding accepts explicit options and does not silently merge ambient hosting settings. An embedding caller can deliberately use the environment-loading constructor. Store-name/key overrides belong to that selected configuration snapshot too; this does not remove the environment-backed secret backend. + +Strictness includes recognized supplied native settings that previously fell back, such as logging level or declared-store name/key overrides. Keep shared EnvConfig fallback semantics unchanged for other adapters; perform strict native validation before using normalized values. Preserve `Hooks::owns_logging` and avoid installing a competing logger. + +There is no new CLI flag or runtime configuration file for these settings. Preserve development bind warnings/fallbacks, local config discovery, missing-file empty stores, malformed-binding warnings/omission, and cwd-relative KV defaults. Declared KV initialization failure remains fatal. Explicit production options never use those fallbacks. The new shutdown duration has no legacy permissive behavior to preserve; a supplied invalid duration fails in either entrypoint. + +The Axum CLI's existing development serve path keeps its permissive behavior. A wrapper launching a production entrypoint must pass supplied hosting settings through without replacing invalid input with defaults. Keep existing development wrapper normalization out of that production path; do not add a second CLI configuration policy. + +## Startup and initialization + +Proposed production ordering for the agreed invariant. Development uses the same lifecycle owner with its existing store/default policy and no additional initializer requirement: + +```mermaid +flowchart TD + A[Parse and validate native settings] --> B[Create owned local runtime and stop observation] + B --> C[App::build installs platform and configures once] + C --> D[Prepare required registries and shared transport] + D --> E[Await app-owned initializer] + E --> F[Register validated typed handler state] + F --> G[Bind and adopt listener] + G --> H[Ready and accepting] + C -->|Failure| X[Release resources and return non-success] + D -->|Failure| X + E -->|Failure| X + G -->|Failure| X +``` + +No acceptance or Ready transition can precede successful initialization. Binding after validation avoids a misleading open port during production standalone startup. An existing embedding path with a prebound listener can preserve that listener, but still cannot accept or become Ready early. + +Register standalone signals early enough to observe stop during initialization. Embedding supplies its stop source without installing process-wide handlers. A stop before Ready cancels cooperative initialization, releases prepared resources, and never enters Ready. Completed intentional stop exits zero. Initialization failure remains non-success. Cleanup uses the same shutdown budget; expiry or a second signal is forced stop. + +Run startup and connection serving on the same owned current-thread runtime/LocalSet. The initializer and its future need not be Send. Do not capture them in the existing spawn_blocking bridge. This design is a blocking native runner; it does not add another public async hosting framework. + +A supplied production initializer runs exactly once, after `App::build`/`Hooks::configure` and strict store preparation, before App is shared with connections. It receives mutable App and borrowed access to the same registry handles/bindings that requests will receive. It cannot turn failed declarations into absent bindings or replace prepared registries. Borrowed registry access does not make the underlying KV backend read-only; any app-owned bootstrap writes remain application policy. + +The application explicitly selects every required typed blob/schema and additional required secret or startup invariant. Store metadata contains ids/defaults, not schemas or required-key lists. A successful callback is evidence that the app's selected checks passed, not proof that the framework discovered every application requirement. + +Apps without application-specific startup requirements can use a production convenience entrypoint without an initializer argument. It still validates framework settings, declared stores, transport, and fallible app assembly before Ready. An explicit initializer variant adds app-selected blob/schema, secret, and bootstrap checks. A convenience entrypoint must not claim those application-specific checks occurred. + +Preserve the existing no-argument development entrypoint. Newly generated no-store apps remain runnable without a source migration or explicit no-op callback; a generated config Rust type alone does not declare a mandatory stored blob. Ready means the selected entrypoint's startup contract completed, not that the framework discovered every application requirement. Production applications with additional requirements must select the initializer variant. + +### Proposed native API shape + +These sketches describe the boundary, not compiled APIs or final Rust lifetime ergonomics. Public names are proposed. Declarations are API outline notation, not implementation code. + +```text +pub struct AxumRunOptions { + // Validated bind, roots, shutdown duration and selected store overrides. +} + +impl AxumRunOptions { + pub fn from_env() -> anyhow::Result; + // Explicit construction validates supplied native settings as well. +} + +pub struct PreparedStores { + // Private registry fields, None only for an undeclared/unattached kind. +} + +impl PreparedStores { + pub fn config(&self) -> Option<&ConfigRegistry>; + pub fn kv(&self) -> Option<&KvRegistry>; + pub fn secrets(&self) -> Option<&SecretRegistry>; +} + +pub type LocalStartupFuture<'a> = + Pin> + 'a>>; + +// Existing development convenience API and permissive local defaults. +pub fn run_app() -> anyhow::Result<()>; + +// Production convenience: strict environment options and SIGINT/Unix SIGTERM. +// Completes framework startup checks without claiming app-specific validation. +pub fn run_production_app() -> anyhow::Result<()>; + +// Production standalone with additional app-owned initialization. +pub fn run_production_app_with_initializer( + initialize: Initialize, +) -> anyhow::Result<()> +where + A: Hooks, + Initialize: for<'a> FnOnce(&'a mut App, &'a PreparedStores) + -> LocalStartupFuture<'a>; + +// Production embedding convenience: explicit options and caller-owned stop. +// Neither embedding variant installs global signal handlers. +pub fn run_app_with_options( + options: AxumRunOptions, + stop: Stop, +) -> anyhow::Result<()> +where + A: Hooks, + Stop: Future; + +// Production embedding with additional app-owned initialization. +pub fn run_app_with_options_and_initializer( + options: AxumRunOptions, + stop: Stop, + initialize: Initialize, +) -> anyhow::Result<()> +where + A: Hooks, + Stop: Future, + Initialize: for<'a> FnOnce(&'a mut App, &'a PreparedStores) + -> LocalStartupFuture<'a>; +``` + +Caller-owned stop can use an existing oneshot/watch/notification future. No new portable shutdown-token framework is needed. The native owner handles stop idempotently; repeat caller requests do not shorten the budget. The standalone signal source separately observes the second signal for forced teardown. + +These entrypoints are thin policy/initializer selections over one native runtime owner, not separate servers. Convenience variants supply an internal successful initializer; callers need not write a no-op. Retain the existing module location and existing development signatures unless an accepted prerequisite changes them. + +Router-only AxumDevServer callers retain their configuration and handle-attachment conveniences over that same owner; do not require an unrelated initializer migration. Normalize bare handles into the existing registry representation before any selected initialization. Tests may keep a private prebound-listener entrypoint through that same owner. + +## Shared typed-config loading and state retention + +Expose a context-free entrypoint to the existing typed loader, proposed as: + +```text +AppConfig::from_binding( + binding: &ConfigStoreBinding, + key: Option<&str>, + secrets: Option<&SecretRegistry>, + limits: ConfigExtractionLimits, + clock: MonotonicClock, +) -> Result; +``` + +This is signature notation, not literal Rust syntax. Implement it as an async inherent method with the existing `C: DeserializeOwned + AppConfigMeta + Validate + Send + 'static` bounds. A missing explicit key uses the binding's resolved default key. The app chooses default or named bindings; do not apply one guessed type to all declared stores. + +Factor only the RequestContext dependencies out of `extract_from_handle` and secret traversal. Request extraction and startup then share one parser, budget, envelope/version/hash checks, secret walk, optional-secret behavior, validation, and redacted error mapping. No fake request, route dispatch, second parser, operator TOML loader, or implicit cache is involved. + +Existing extraction limits apply per selected blob and its referenced secrets. They are not a deadline for the entire initializer or arbitrary signing/bootstrap work. They also do not bound the initial allocation of the outer local-file map. Do not add a separate startup-timeout setting or new numerical file-size policy in this slice. Operator-supplied files are trusted deployment inputs, and the supervisor owns the ultimate startup/stop bound. + +Add an explicit post-build method, proposed as: + +```text +App::insert_state(&mut self, value: T) +where + T: Clone + Send + Sync + 'static; +``` + +It uses the existing router state-extension mechanism. Same-type insertion replaces earlier state, including builder-registered state, for this App. Typical usage registers Arc and handlers extract State>. + +A router shared with another App must not be mutated globally. Use copy-on-write/forked router internals for state insertion; clone existing handler/middleware references and retain route metadata and matching behavior. Do not rebuild the app/routes, require Arc uniqueness, introduce a second state overlay with competing precedence, or use a global/deferred cell. Prove shared-router isolation in a test. + +State insertion is a startup operation and must finish before ingress admission or serving begins. Existing resolved-dispatch/admission tokens retain the prior router owner; copy-on-write insertion invalidates those tokens for the modified App. Do not promise that outstanding tokens survive insertion or introduce a new route-owner identity model. Document this boundary and test that a pre-insertion token is rejected while requests admitted after insertion dispatch normally. This API is not live state replacement. + +A non-Send initializer does not relax the existing Clone/Send/Sync bounds on registered handler state. Applications own state contents and any intentional interior mutability. Retention is explicit; ordinary AppConfig request extraction does not become a cache automatically. + +## Store and filesystem contract + +The requirements below define strict production preparation. Development retains its current local config discovery, empty/malformed binding policy, and cwd-relative data paths. File identities, alias handling, ownership limitations, snapshots, and safe recovery guidance apply in both paths. + +### Config + +- Read `/local-config-.json`, retaining the existing flat string-to-string format and logical-id identity. +- Every declared file must exist, be readable, and parse successfully. No empty fallback or dropped binding. +- Permit symlinks used by projected ConfigMap/Secret mounts. The opened target must be a readable regular file. Operator-supplied roots are trusted; no generic filesystem sandbox or source-tree containment restriction is added. +- A valid outer map is not proof of a valid typed blob or required secrets. Those checks belong to the initializer. +- Config stores remain startup snapshots. Updates require process restart; no watcher/reload path. + +### KV + +- Read/create `/kv--.redb`, preserving the existing basename derived from the resolved platform NAME. Config filenames still use logical ids. +- The root must already exist and be writable. The runner may create a new database file inside it, but must not fabricate a missing mount/root directory. +- Resolve aliases before opening: two logical ids with the same resolved NAME share one database handle and keyspace in this process. Different names select different files. Do not change file identity or silently move existing data. +- The same key through either alias refers to the same value. Alias ids are not isolated namespaces. Shared handles do not change operation guarantees or make the documented read-modify-write helper atomic. +- redb remains one owning process per database. A second process fails rather than silently sharing. Shared volumes do not establish multi-writer or multi-replica safety. +- Keep runner-owned handles alive until connection tasks settle, then relinquish them on completed startup failure, orderly stop, or cooperative forced cleanup. A database lock is released only when the last handle owner drops it. Embedding callers may retain an attached handle, and initializers may clone one outside the App; those owners must release their handles before reopening or backing up the database. The runner does not revoke caller-owned handles. + +### Permissions, updates, and recovery + +Operators provision roots, ownership, and permissions for the runtime UID, including the Docker recipe's numeric UID. Config may be read-only; data must support real database writes. No startup chown, chmod 777, automatic lock repair, database deletion, or corruption recovery is added. + +Externally supplied secret updates use process restart. The existing environment-backed secret backend remains unchanged; this is not a new secret reload/rotation system. + +Initial backup support is a documented stopped-writer procedure: stop the writer, release every database handle including caller-owned clones, copy the closed database, restore into a prepared directory with correct permissions, reopen, and read stored values back. Returning from an embedded runner alone does not prove the database is closed. Online copying of an active database is not a supported backup claim. Remove ordinary recovery advice that recommends deleting production databases for lock errors or capacity maintenance; do not invent a maintenance subsystem. + +The storage guide must include these operational limits and procedures: + +- TTL expiry is lazy. Reads and paginated listing clean up encountered expired entries; unvisited expired entries can remain. An application-owned maintenance pass can traverse the existing paginated listing API through the owning process, following cursors to exhaustion even when a page is empty. There is no automatic sweeper or new maintenance CLI. +- Deletes and expiry cleanup do not automatically shrink the database file. Monitor database size and available mount capacity, provision headroom for writes and backups, and do not treat TTL as a disk quota. This slice does not expose or certify compaction; unsupported file shrinking must be stated explicitly. +- For lock conflicts, identify and stop the other owning process or release retained embedding handles before retrying. Do not remove the database or bypass its lock. Prevent overlapping writers during restart/rollout. +- For capacity exhaustion, stop the writer if needed, preserve the database, and restore usable capacity by enlarging the mount or removing unrelated disposable files. Reopen and verify stored values once capacity is available; backup/restore copying alone is not a compaction procedure. +- For suspected corruption, stop all owners, preserve the damaged file for investigation, and restore a known-good stopped-writer backup into a prepared directory. Verify stored values before resuming traffic. Automated repair and untested salvage procedures remain unsupported. + +## Lifecycle, shutdown, and results + +One native owner controls Starting, Ready, Draining, and Stopped. Acceptance and phase observation derive from that owner, not separately maintained booleans or response reports. A run never returns to Ready after draining begins. + +```mermaid +flowchart TD + A[Starting] -->|Validated service and listener| B[Ready] + A -->|Stop before ready| C[Cleanup without readiness] + A -->|Initialization failure| G[Stopped with non-success] + B -->|First stop request| D[Draining: revoke ready and block new work] + D --> E[Graceful connection completion] + E -->|All tasks settled| F[Stopped with zero exit] + C -->|Cleanup completes| F + D -->|Budget expires or second signal| H[Force cancellation and settle tasks] + C -->|Budget expires or second signal| H + H --> G +``` + +Standalone SIGINT and Unix SIGTERM feed the same first-stop transition. Registration failure is a startup error. A second termination signal of either kind requests immediate forced teardown. Embedding has no implicit global handlers. + +On first stop: + +1. Revoke readiness and stop accepting new connections. +2. Gate new request dispatch on existing keep-alive connections too. Define existing work at the service-dispatch boundary, not merely an accepted socket or queued pipelined bytes. +3. Ask owned Hyper connections to stop keep-alive/new work and finish dispatched requests. Close idle connections promptly. +4. Keep polling tracked local tasks and request/response work until completion or the single process drain deadline. +5. On expiry or second signal, cancel remaining work and settle its task ownership before returning. + +Use native monotonic runtime time for the process budget. Do not depend on an application's injected request clock for supervisor termination. Earlier accepted request/response deadlines still win. Long-lived/SSE responses share the shutdown budget; this does not impose a new ordinary stream-lifetime or handler deadline. + +Collect connection task results instead of detaching them. Preserve ordinary connection-scoped failure/disconnect behavior. Terminal listener/runtime/runner failures revoke readiness and return non-success; do not add a general restart/retry policy here. + +Completed graceful drain or completed intentional stop during startup exits zero. Startup failure, runner failure, grace exhaustion, and signal-requested forced teardown report non-success. Retain enough internal outcome distinction for focused tests and safe diagnostics, without introducing a detailed numeric exit-code taxonomy. + +The budget is cooperative. Synchronous startup code, blocking handlers, callbacks, destructors, or a stalled runtime can prevent timer/cancellation progress. The process supervisor supplies the final hard-kill boundary. Deployment examples must set a longer supervisor interval, such as 30 seconds for a 20-second drain, rather than rely on Docker's default timeout. + +Preserve #275 completion semantics. HostHandoff does not prove client receipt. Dropping unfinished egress uses its existing terminal outcome. Pre-egress cancellation may release captured resources without a completion callback/report. Do not promise a terminal report for every accepted request, SIGKILL, or process abort; connection ownership determines drain completion. + +## App-owned probe routes + +Add a small portable `edgezero_core::probe` module with: + +- A Starting/Ready/Draining/Stopped observation enum. +- A cloneable read-only live reader, following the existing MonotonicClock Arc/callback pattern. +- Ordinary `#[action]` handlers for readiness and liveness. + +The native runner publishes its reader through existing request extensions. Core contains no lifecycle writer, shutdown token, listener, task, timer, Axum/Tokio dependency, new action capability, or portable async Hooks method. Other adapters initially publish no native lifecycle observation. + +Handlers read the current phase when executed, not a copied admission-time snapshot. + +| Observed phase | Readiness | Liveness | +| --------------------- | ---------------------------- | ---------------------------- | +| Starting | 503 | 200 | +| Ready | 200 | 200 | +| Draining | 503 | 200 | +| Stopped | 503 | 503 | +| Missing/unknown state | 503, probe state unavailable | 503, probe state unavailable | + +Use short non-sensitive text, plain-text content type, and Cache-Control: no-store. Missing state must not invent healthy behavior or lifecycle support on a provider. Apps can retain an independent portable health handler when that is their desired policy. Ready reflects the selected runner's contract: development readiness is not strict production validation, and production convenience readiness does not imply application-specific initialization. Native readiness does not periodically revalidate dependencies. Applications can add ongoing checks; the framework does not poll upstreams or trigger automatic outage restarts. + +Applications bind helpers through ordinary Rust or manifest routes. No automatic paths, reserved namespace, health flag, health config section, path environment variable, management listener, or admission exemption. Existing middleware/auth remains application-owned. Do not replace framework-owned probe/store metadata through app state or middleware; the native dispatch gate must use its private owner rather than an overrideable request extension. + +There is no reachable-probe guarantee before startup acceptance or after listener withdrawal. The response table describes a helper that executes. During drain, a previously dispatched probe can observe Draining; a newly attempted request may receive refusal/reset/EOF instead of 503. No successful readiness response means not ready. Supervisor liveness policy must account for intentional listener shutdown. + +Trusted Server already owns `/health`. Its Axum handler returns 200 on a successfully built router, while startup fallback returns errors even for `/health`. Fastly answers `/health` before app construction, so that route is liveness rather than readiness. Keep those downstream routes application-owned. Generated EdgeZero root/introspection paths are editable trigger rows, not reserved paths; the old automatic `/__edgezero/routes` mechanism was removed. + +## Failure diagnostics + +Startup failures identify the stage, recognized setting/logical store id, and safe failure category. Never print config content, resolved secrets, raw invalid settings, parser input fragments, or callback/provider source strings. A malformed config must not leak through a serde error wrapped in an ordinary log or anyhow termination chain. + +Return safe classified errors at the standard runner boundary. Do not retain an unredacted source chain where ordinary main termination/debug formatting will print it. Broader permitted internal diagnostics belong to #397; this spec does not add a logging framework or debug disclosure switch. + +Use the existing public HTTP error contract for request failures. Probe bodies contain no app configuration or infrastructure details. Preserve custom application logger ownership. These guarantees apply to framework-owned error conversion/logging; arbitrary app logging and panic payloads remain application responsibilities. + +## Compatibility and entrypoint migration + +Preserve #396's permissive development requirement and the existing no-argument `run_app` signature. This review decision supersedes the earlier interview's strict-everywhere and mandatory-initializer policies. No GitHub issue amendment is needed. + +Inventory Axum main templates, app-demo's native entrypoint, CLI demo runner, AxumDevServer/test embedding callers, and downstream consumers. Do not migrate development callers merely to make them pass a no-op initializer. Add production adoption examples that select the strict convenience entrypoint or, for application-required checks, its initializer variant. Both use the existing server implementation. Document their different readiness guarantees and any prerequisite-driven source migration. + +Generated no-store apps retain their runnable development entrypoint. Show the explicit production entrypoint choice and how required stored config adds typed loading and state registration. Container recipes under #398 must select that production contract rather than certify a permissive development binary from a passing HTTP probe. Do not add schema inference, a new app! keyword, optional-store policy machinery, or a native dependency in the shared app crate. + +The native CLI's serve/push/provision recipes retain local preparation and path conventions. Production adoption documentation supplies explicit absolute roots; development does not acquire a mandatory-root requirement. `config push` retains its current project-local output convention. No automatic production push/deployment mechanism is added. + +Post-build state registration remains portable and preserves router state semantics. The context-free config helper preserves all existing request-extraction outcomes on native/WASM targets. Coordinate #379's retained-provider-app work where APIs overlap, without making it a required native hosting dependency or assuming retained provider apps imply process readiness. + +## Originally proposed PR split + +The original plan below separated the issue boundaries. This worktree delivers the shared runtime and startup/storage changes together in one PR stacked on #275, not old main. The issue ownership and acceptance boundaries still apply. + +1. **#392 lifecycle PR, first on #275.** Establish the single local runtime owner, explicit initialization barrier, caller/signal stop handling, tracked connections, dispatch gating, drain/result behavior, and core probe observation/helpers. Its initialization barrier can accept a local future without knowing application schemas. Do not claim strict config/value certification before #396 integrates. +2. **#396 startup/storage PR, stacked on #392.** Supply explicit strict production preparation and optional app initialization to that barrier; implement root/settings validation, alias handle reuse, shared typed loader, startup-only post-build state, production adoption examples, development compatibility, and persistence/permissions/backup checks. +3. **Integrated acceptance on the second PR.** Exercise failed initialization, phase transitions, signals, pending handlers/streams, durable restart, mounted permissions, and probe wiring against the combined series. Close overlapping issue acceptance only with that evidence. + +These are behavioral review boundaries, not permission for two independent agents to rewrite the same runner concurrently. Preserve existing development signatures and keep any prerequisite-required caller changes at a coherent boundary. Add production entrypoints and real required-config initialization under #396 without forcing a development migration. Do not leave a broken intermediate commit or invent a compatibility server. + +#393/#394/#395/#397 then add only residual work/evidence against the accepted foundation. #398/#399 can prepare packaging independently; final container production checks consume the integrated result. #400 documents proven behavior. + +## Likely affected files + +- `crates/edgezero-adapter-axum/src/dev_server.rs`, `connection.rs`, `service.rs`, `config_store.rs`, and `key_value_store.rs`, with colocated tests. +- `crates/edgezero-adapter-axum/src/cli.rs` and its native main template where needed to preserve development behavior and document explicit production adoption. Strict launch wrappers must not normalize invalid production inputs. +- `crates/edgezero-core/src/app.rs`, `router.rs`, and `extractor.rs` for state registration and shared typed loading; new `probe.rs` plus exports in `lib.rs`. +- CLI demo runner, root README/core-config templates where preparation guidance is stale, generated-project tests, and app-demo's Axum main. +- Axum/config/KV guides and small native/portable fixtures needed for the acceptance checks. Do not pull Trusted Server sources or operator configs into framework fixtures. + +Keep this list bounded. No general generator/dependency resolver, provider lifecycle, portable body, or distributed store rewrite is implied. + +## Acceptance and planned validation + +The table below is the full acceptance plan, not a claim that every item is complete. The initial implementation has passed workspace tests, strict workspace Clippy, formatting, the Fastly/Cloudflare/Spin feature check, and the Spin WASM compilation check. The Axum all-features suite also passed all 13 subprocess hosting tests, including signals, finite drain, forced cancellation, startup failures, embedding, persistence, aliasing, stopped-writer backup/restore, and read-only config permissions. + +Before choosing implementation changes, re-audit the accepted #275 integration revision and record its exact commit, existing contracts/tests reused, checks already covered, and residual implementation/evidence needed for each issue. Repeat the inventory if prerequisite contracts change. The draft-head source inspection above is not this acceptance gate or executed production proof. + +| Owner | Focused evidence required | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Both | Accepted-#275 revision and covered/residual inventory are recorded; add only residual changes and missing acceptance evidence. | +| #392 | Subprocess SIGINT and Unix SIGTERM each cause graceful stop; second signal forces non-success. Embedding stop installs no global handlers. | +| #392 | In a running subprocess, dispatch a delayed finite response before sending process-wide SIGTERM; observe readiness withdrawal, full response completion, and zero exit. Repeat SIGTERM with a never-ending handler and open-ended stream to prove forced non-zero exit within the configured cooperative budget. Reuse these scenarios for final-image integration; separate signal and unit drain tests do not replace them. | +| Both | Production subprocess failures cover an occupied bind port, explicit initializer failure, invalid recognized settings, missing roots/declared config files, unreadable/non-regular/malformed config, and unusable/locked KV data. Each exits non-zero without Ready or request acceptance and identifies a safe stage/setting or logical id/category; sentinel values and unredacted source chains never appear in ordinary stdout/stderr diagnostics. | +| #392 | Never Ready during startup/failure; startup stop never accepts; Ready only after successful barrier and listener setup; drain revokes it immediately. | +| #392 | Delayed dispatched work finishes; idle keep-alive closes; new keep-alive/pipelined work does not dispatch after drain. | +| #392 | Never-ending handler/stream and zero-read peer reach controlled forced teardown within the configured cooperative budget. No detached work remains when the runner returns. | +| #392 | One budget is not restarted by repeat programmatic stop. Existing earlier response deadlines/outcomes remain intact. | +| #392 | Probe helpers cover every phase and missing state through ordinary router dispatch, preserve app route ownership, and observe a live transition rather than a stale snapshot. New drain probes need not reach HTTP. | +| #396 | Production entrypoints reject invalid supplied host/port/logging/known overrides/duration without normalization into defaults. Omitted bind/duration values use documented defaults. Production launch wrappers preserve this behavior. | +| #396 | Existing development entrypoints remain runnable without new root settings or initializer arguments. Preserve missing-config empty stores, malformed-binding warning/omission, local path discovery/cwd defaults, existing bind fallbacks, and fatal declared KV failures. Diagnostics remain redacted. Selecting production explicitly enforces strict checks regardless of cwd or build profile. | +| #396 | A standalone release subprocess using the production entrypoint starts from unrelated cwd with only explicit roots; missing roots, unreadable/non-regular/malformed config, and unusable/locked data fail before acceptance. | +| #396 | Valid flat map containing malformed/missing required typed blob, integrity failure, invalid required fields, or missing referenced secret fails the initializer before Ready. Sentinel values never appear in ordinary logs/returned diagnostics. | +| #396 | A supplied initializer runs once with exactly the bindings attached to requests. Production convenience entrypoints require no callback but still enforce framework checks and never claim app-specific validation. Native production option construction does not silently merge environment settings. | +| #396 | Shared typed loader and request extraction agree on default KEY overrides, named stores/secrets, optional secrets, redaction, byte/structure/deadline limits, and existing error categories. | +| #396 | Startup-registered Arc state reaches multiple requests; last same-type insertion wins; another App sharing the original router retains its own state. Pre-insertion resolved/admission tokens are rejected for the changed App, while tokens created after insertion dispatch normally. | +| #396 | Distinct names stay distinct; same-name aliases share one handle/keyspace; fresh database in prepared root works; second process cannot own the same file. | +| #396 | Value survives actual process stop/restart; stopped-writer backup restores readable values; runtime UID works with read-only config and writable data; startup never changes root ownership or deletes locked files. An embedding owner retaining a handle keeps the file locked after runner return; dropping the last owner permits reopening. | +| #396 | Storage guidance covers lazy expiry, paginated cleanup, non-shrinking files, capacity monitoring/headroom, safe lock/capacity/corruption recovery, and unsupported compaction/repair. Backup instructions require all handle owners to release the database. | +| #396 | Config snapshot stays unchanged after file update until restart. Document restart-based secret deployment updates without unsafe process-environment mutation tests. | +| Both | Non-Send local initializer fixture compiles/runs; same shared probe/typed-state handlers compile for supported WASM targets without native dependencies. | + +Tests use disposable data, bounded waits, explicit cleanup, and no platform credentials. Ordinary unit/compile tests do not require Docker or external services. Signal/filesystem subprocess tests use local fixtures. UID/container evidence can reuse #398's opt-in runner after integration with its template PR; unavailable Docker/architecture evidence remains a reported gap. + +At implementation checkpoints, use the repository's required checks: + +```sh +cargo test -p edgezero-core --all-targets +cargo test -p edgezero-adapter-axum --all-targets --all-features +cargo test -p edgezero-cli --all-targets +cargo test --workspace --all-targets +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +cargo test -p edgezero-cli --test generated_project_builds -- --ignored +``` + +Also run docs lint/format/build and the existing example/provider gates relevant to changed callers. Installed-target or container gaps must be disclosed, not presented as passes. Existing Fastly/Cloudflare/Spin gates must stay green. Container UID, standalone release, unreadable/unusable mount, retained-handle lock, and production-guide acceptance remain open. The accepted-prerequisite re-audit must also be repeated after #275 merges. + +## Risks and review checks + +- Compile the proposed higher-ranked initializer signature with borrowed inputs and Rc-capturing non-Send futures before committing to its ergonomics. No runtime ownership workaround may add blanket Send bounds. +- Verify copy-on-write router state insertion against the locked matchit/http types and existing Clone/Send/Sync state bounds. No uniqueness assumption or cross-App mutation is acceptable. +- Production strictness is opt-in. Preserve existing development signatures, fallbacks, and data filenames. Make production adoption and readiness guarantees explicit; do not silently certify a development binary or force unrelated callers to migrate. +- App-owned initialization/state can execute arbitrary code. Cooperative cancellation does not bound blocking code/destructors or prevent unsafe app overrides of request metadata. The supervisor and trusted app-composition contract remain explicit. +- Existing typed limits do not prove startup-file or whole-process memory bounds. This spec adds no concurrency/handler-deadline policy and does not relabel BestEffort/Unsupported guarantees from #275. +- A successfully sampled readiness response does not promise the server remains ready through transmission or prove client receipt. + +Read-only audits and alternative proposals were used for lifecycle, configuration, native startup API, and probe portability. Their useful findings are incorporated above. Rejected alternatives were a second server, inferred environment modes, native dependencies in shared probe handlers, a portable async startup hook, synthetic request validation, implicit settings caches, per-id path machinery, and immediate-stop-only hosting. This review preserves permissive development and makes strict production an explicit entrypoint choice, superseding the earlier strict-everywhere and mandatory-no-op proposals. + +## Review boundary + +The interview's policy decisions and subsequent review revisions are recorded here. The initial source and test implementation covers development compatibility, optional application initialization, startup-only state insertion, explicit embedding options, restart-based config updates, and the native lifecycle owner. API and behavioral review remain necessary before merge. + +The implementation is based on #275 at `5527b58e2a7cb6307d5c7a8926cd5dfc6f4f8b4a`. Its branch has since advanced to `dfd439c613af4f6ed295e71c032881b15bf7247c`, including overlapping Axum preflight and logging changes. Integration with that newer prerequisite and its accepted-revision re-audit remain open. This work does not certify a deployment or published container image, and it does not complete all acceptance criteria for #392 or #396. diff --git a/docs/superpowers/specs/2026-10-05-logging-ownership-boot-diagnostics-design.md b/docs/superpowers/specs/2026-10-05-logging-ownership-boot-diagnostics-design.md new file mode 100644 index 00000000..98ab33ab --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-logging-ownership-boot-diagnostics-design.md @@ -0,0 +1,63 @@ +# Logging Ownership and Boot Diagnostics + +## Contract + +`Hooks::owns_logging() == true` means the application owns backend installation, +backend filters, and the `log` facade maximum. Adapters must not replace or +reconfigure that logger. Applications install it before the adapter entrypoint. + +Core exposes a pure `resolve_logging_level(&EnvConfig) -> log::LevelFilter` +helper for `EDGEZERO__LOGGING__LEVEL`. It preserves the existing case-insensitive +parser and `Info` fallback for missing or invalid values; it does not install a +logger, read process environment, or change global filters. Manifest logging +resolution remains separate and is not implicitly applied by this helper. + +`BOOT_LOG_TARGET` names `edgezero::boot`; `BOOT_LOG_LEVEL` is `Warn`. Adapter-owned +Fastly and Axum logging preserves errors and warnings on that target even when +ordinary logging is `Error` or `Off`. Each backend and the facade must admit the +maximum of the ordinary and boot levels, while the outer target-aware filter +continues to suppress ordinary records above their configured level. The boot +target is a category, not a new Fastly log endpoint. + +Fastly `runtime_env_config` returns `FastlyRuntimeConfig { env, ... }`, hard-cutting +the former direct `EnvConfig` return. An unavailable optional runtime store sets +one boolean diagnostic flag. `emit_boot_diagnostics()` consumes its fixed warning +once after backend installation; missing backends do not gain implicit output. +`run_app` and `run_app_with_hooks` emit it even when application assembly returns +an error. The explicit-config runner does not read that runtime store. + +Install adapter-owned logging before application configuration. Diagnostics +generated while resolving logger configuration must be retained as bounded +typed/static state and emitted after installation, not as an unbounded log +buffer. When an application owns logging, the same boot records use its backend +and filters; visibility depends on the application's policy. + +Cloudflare and Spin logger initializers remain explicitly documented no-ops. +Their platform consoles do not automatically install a Rust `log` backend. +This change adds neither a logger dependency nor a new capability there. + +The bundled CLI must not install its CLI logger before running the Axum demo. +Axum's `run_app_with_preflight` runs reusable startup checks after logging setup +and before application configuration or listener binding. The demo uses that +boundary for capability validation. Axum returns logger-setup errors rather than +silently using a different backend. CLI terminal errors use stderr regardless of +the runtime filter. +Generated adapter entrypoints continue delegating to reusable adapter code; +logging policy must not be duplicated in templates. + +## Acceptance + +- Resolver coverage includes every level, absent/invalid values, and no global + logging side effects. +- Ordinary records retain their configured filter; boot `Error`/`Warn` survive + ordinary `Error`/`Off`; boot `Info`/`Debug`/`Trace` remain suppressed. +- Fastly backend filtering and the facade ceiling cannot contradict the boot + target policy. +- Configuration executes after adapter-owned logger setup; application-owned + logger/filter state is not changed. +- Startup warning deferral is bounded and emits each diagnostic once. +- Direct Axum and bundled demo use the same runtime logging configuration. +- Documentation, generator output, and demo describe ownership accurately. + +This is a logging API/documentation improvement, not an outbound correctness or +provider-certification prerequisite. diff --git a/docs/superpowers/specs/2026-10-07-native-store-providers-design.md b/docs/superpowers/specs/2026-10-07-native-store-providers-design.md new file mode 100644 index 00000000..f08dd1a9 --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-native-store-providers-design.md @@ -0,0 +1,425 @@ +# Native store providers independent of HTTP adapters + +- Status: Offline implementation verified and published as draft [PR #414](https://github.com/stackpop/edgezero/pull/414) via `gh stack`, based on open #275 with the compatible #406 lifecycle bridge. Real AWS service acceptance is declined and excluded. +- Date: 2026-10-07 +- Owning issue: [EdgeZero #410](https://github.com/stackpop/edgezero/issues/410). +- Audited main: `683202c66948146ec360f490126f1d60f920288b`. +- Initial host: Axum. The provider implementations must not depend on Axum. + +**2026-10-14 scope extension:** the separately approved [DynamoDB KV plan](../plans/2026-10-14-dynamodb-kv.md) adds live ordinary KV through the default-off `aws-dynamodb` feature and binding tag on the same branch/PR. DynamoDB deferral statements below describe the original AppConfig/Secrets snapshot phase, not the extended implementation. DynamoDB configuration, CAS/transactions and deployed AWS acceptance remain excluded. + +## 1. Outcome and invariants + +A native application chooses its HTTP runtime and its KV, configuration, and secret providers independently. It can also choose different providers for different logical IDs of the same store kind. + +For example, Axum can serve an application with redb application state, AppConfig configuration, and environment secrets. Another deployment of the same application can use local configuration and Secrets Manager. A future DynamoDB KV provider replaces a KV binding, not the HTTP runtime or configuration provider. + +The application continues to use `KvStore`, `ConfigStore`, `SecretStore`, their handles, and the existing per-ID registries. AWS resource names, credential discovery, protocols, and service errors belong behind that boundary. + +Required invariants: + +- Provider selection never changes the meaning of a logical store ID or its default. +- Selecting an AWS binding never silently falls back to local files or environment secrets. +- Raw configuration, serialized `BlobEnvelope` values, and nested secret references retain their meanings. +- AWS and native runtime dependencies stay outside portable core and provider-WASM dependency graphs. +- Preparing a provider does not claim application hot reload, safe signing-key rotation, or distributed KV guarantees. +- Provider failure and an absent lookup key remain distinguishable. + +## 2. Audited baseline and integration prerequisites + +These are current main behaviors, not proposed contracts. + +| Area | Current behavior | Source | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Store declarations | Only logical IDs and defaults are portable. Provider fields in `[stores]` and per-adapter store tuning are rejected. | `crates/edgezero-core/src/manifest.rs:350-372`, `:469-505` | +| Macro metadata | `StoreMetadata` carries IDs/default only. No provider initialization is generated. | `crates/edgezero-core/src/app.rs:77-102`; `crates/edgezero-macros/src/app.rs:91-124` | +| Config | Read-only async key lookup returning `Option`. | `crates/edgezero-core/src/config_store.rs:206-214` | +| Secrets | Read-only named-store/key lookup returning bytes. | `crates/edgezero-core/src/secret_store.rs`, `SecretStore` | +| KV | Mutable bytes, TTL, existence, deletion, and prefix pagination. | `crates/edgezero-core/src/key_value_store.rs:880` | +| Composition | Registries already bind a provider handle per ID. Config adds a default key; secrets add a bound namespace. | `crates/edgezero-core/src/store_registry.rs:35-84`, `:115-230` | +| Native injection | Service and dev-server builders accept existing registries. Generated `run_app` hard-wires redb, local JSON, and environment secrets. | `crates/edgezero-adapter-axum/src/service.rs:49-123`; `src/dev_server.rs:142-192`, `:333-509` | +| Typed configuration | Request extraction reads and verifies an envelope, resolves nested secret references, then deserializes and validates. It is not automatically a startup check. | `crates/edgezero-core/src/extractor.rs:748-813`, `:970-1195` | + +Two open PRs supply the prerequisite contracts. After the initial audit, the user explicitly authorized implementation and rebasing onto them. The worktree now uses #275 at `d1a81579afb96f200e7fe8fd294600f0b446b080` with #406 rebased onto it as combined base `1499062`. Neither source PR is accepted on main. References below to accepted prerequisite contracts mean the exact selected and tested stack for this implementation, not a claim about main or GitHub approval. + +Initially inspected heads: + +- [PR #275](https://github.com/stackpop/edgezero/pull/275), inspected at `d1a81579afb96f200e7fe8fd294600f0b446b080`, introduces fallible `App::build::(platform)`, shared `ConfigValue`, and required `ConfigStore::get_bounded` and `SecretStore::get_bytes_bounded` methods with a clock, absolute deadline, and separate backend/value allowances. Do not implement against main's old `String`-only contract or invent a parallel bounded-read API. +- [PR #406](https://github.com/stackpop/edgezero/pull/406), inspected at `3ab22096fd117f7506cf437b5ba128e0789e059c`, implements #392/#396 production startup and an app initializer over `PreparedStores`. At that head, `PreparedStores` exposes read access, not provider override construction. `AxumRunOptions::validate_stores` requires config/data roots for every declared config/KV kind. `build_production_stores` constructs local providers directly. + +Re-audit and test the exact selected stack before runtime integration. Open status is not a blocker under the user's subsequent instruction. In particular, remote configuration must not require `CONFIG_DIR`, and an injected remote KV must not require `DATA_DIR`. Mixed deployments require roots only for bindings still using local defaults. Reuse the accepted production initializer, readiness, shutdown, and configuration extraction contracts. + +Coordinate raw-value semantics with [#324](https://github.com/stackpop/edgezero/issues/324). Do not copy Fastly's current envelope-only raw lookup behavior into a new provider. + +## 3. Proposed decisions + +| Decision | Proposal | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| Application API | Keep the existing store traits, handles, and registries. No unified `Store` trait. | +| Provider unit | A binding for one store kind and logical ID. Same-kind local and AWS bindings can coexist. | +| AWS packaging | One optional native `edgezero-store-aws` crate, independent of any HTTP adapter. | +| General extension | Application-supplied per-ID handles at native startup. No dynamic plugin registry or provider discovery. | +| Bootstrap | An explicitly located native bindings file, or programmatic bindings. Do not bake AWS references into portable declarations. | +| Initial config provider | AppConfig Agent, free-form whole-document reads. No direct AppConfig Data API implementation initially. | +| Initial secret provider | Secrets Manager through the AWS SDK for Rust. Whole-value reads with current or pinned version selection. | +| Config and secret adoption | Load all explicitly mapped AWS values at startup and publish immutable process snapshots. Restart to adopt updates. | +| DynamoDB config | Evaluated and deferred. AppConfig plus Secrets Manager is the initial #410 service set. | +| DynamoDB KV | A separately approved provider implementing the ordinary KV contract. The composition boundary supports it; #410 does not implement it. | +| Publication/provisioning | Runtime readers only. Existing operator tooling owns AWS resource creation and publication. | + +The user's implementation instruction authorizes these choices. Direct polling, live secrets, or DynamoDB configuration would change the approved protocol and scope. + +## 4. Architecture and ownership + +```mermaid +flowchart TD + App[Portable application and logical store IDs] --> Traits[Core store traits and handles] + Traits --> Registries[Per-ID KV, config, and secret registries] + Bootstrap[Native bootstrap settings and supplied handles] --> Prepare[Native startup composition] + Prepare --> Local[Existing redb, JSON, and environment providers] + Prepare --> AWS[Optional edgezero-store-aws factories] + Prepare --> Custom[Other providers implementing core traits] + AWS --> Agent[AppConfig Agent HTTP endpoint] + AWS --> SM[AWS Secrets Manager SDK] + Local --> Registries + AWS --> Registries + Custom --> Registries + Registries --> Startup[App-owned typed configuration and secret checks] + Startup --> Runtime[Axum initially, other native HTTP runtimes later] +``` + +### Core + +Own store contracts, opaque handles, logical metadata, registries, envelope validation, and typed extraction. Do not add AWS enums, credentials, resource references, remote scheduling, or native executor requirements. + +Traits retain `#[async_trait(?Send)]`. Store implementations remain `Send + Sync`; changing the future bound to `Send` would break the portable contract. + +AWS snapshot implementations must implement both ordinary and bounded reads under the accepted #275 contracts. Bounded reads reject an already-expired deadline, apply independent backend/value allowances, report the snapshot payload bytes exposed to the caller, and recheck deadline precedence before returning. A successful missing lookup reports zero payload bytes. No bounded method may delegate to an unbounded cloud operation; cloud access happens only during preparation. + +### AWS provider crate + +Expose typed settings and async preparation methods producing config/secret handles through an explicit shared `AwsPreparation` session. The standard runner owns one session for all its built-in AWS bindings; programmatic callers explicitly create and reuse a session. It owns aggregate budgets, successful-target deduplication, and client reuse. Do not hide independent per-binding sessions behind convenience constructors. Preparation methods own AWS access, strict resource mapping, sanitized error classification, and immutable snapshot construction. They do not serve HTTP requests, discover application secret names, create another runtime, or initialize global logging. + +Suggested independent features are `appconfig-agent` and `secrets-manager`, with no default features. Use target-specific native dependencies. AWS SDK and any Tokio timer dependency belong in this native provider crate, never core or the WASM adapters. Reuse the existing native runtime; do not add another Tokio dependency to an adapter. + +The AWS crate must not depend on Axum, the CLI adapter registry, or downstream application crates. Future provider crates can implement the same contracts without depending on it. + +### Native composition + +Extend the accepted production store-preparation step with per-kind, per-ID override inputs using existing `KvHandle`, `ConfigStoreBinding`, and `BoundSecretStore` types. Permit async setup inside the native runtime before registries are finalized. The application initializer consumes the completed registries; it is too late to select providers there. + +For each declared ID, choose exactly one source: + +1. An application-supplied binding. +2. An explicit bindings-file entry. +3. The existing local default for that kind. + +Reject duplicate supplied/file bindings for the same ID rather than silently choosing precedence. Reject undeclared IDs. Build each registry once and retain its declared default. Validate local root requirements after determining which bindings need those roots. Complete source validation before opening runner-owned providers or polling supplied deferred futures. Constructing a deferred future must be side-effect-free, with provider work inside its async body. This ordering guarantee cannot undo caller-side work or prevent a supplied ready handle from already being open. + +The standard Axum runner can dispatch the small set of built-in binding tags to constructors. A custom provider needs only a supplied handle, not a new tag in a global registry. Share these handles between startup checks and requests. + +Keep existing redb/file/env implementations in their current crate initially. Extracting all local providers into new crates would add unrelated migration work. A later native runtime can adopt or move them separately. + +## 5. Bootstrap and binding shape + +Keep `edgezero.toml` portable: + +```toml +[stores.kv] +ids = ["state"] + +[stores.config] +ids = ["app", "routing"] +default = "app" + +[stores.secrets] +ids = ["signing", "notifications"] +default = "signing" +``` + +The proposed native input is an absolute `EDGEZERO__STORE_BINDINGS_FILE` path, or an explicit options method. Read it once during startup. With no input, preserve existing development defaults and the accepted production defaults. A missing explicitly selected file is an error, not an empty binding set. + +Proposed file, with fictional resources: + +```toml +version = 1 + +[config.app] +provider = "aws-appconfig-agent" +default_key = "settings" +endpoint = "http://127.0.0.1:2772" +# Optional local agent authorization, read from the bootstrap environment. +access_token_env = "APPCONFIG_AGENT_TOKEN" + +[config.app.documents.settings] +application = "example-app" +environment = "production" +profile = "application-envelope" + +[config.routing] +provider = "aws-appconfig-agent" +default_key = "allowlist" +endpoint = "http://127.0.0.1:2772" + +[config.routing.documents.allowlist] +application = "example-app" +environment = "production" +profile = "routing-allowlist" + +[secrets.signing] +provider = "aws-secrets-manager" +region = "us-east-1" + +[secrets.signing.entries.active_key] +secret_id = "arn:aws:secretsmanager:us-east-1:111122223333:secret:example-signing-AbCdEf" +version_stage = "AWSCURRENT" +``` + +`state` remains redb and `notifications` remains environment-backed. Replacing either is an independent operation. The schema may later add a `dynamodb` KV tag, but v1 must reject it as unsupported rather than accepting a nonfunctional placeholder. + +Validation rules: + +- Require schema version 1. Reject unknown kinds, fields, provider tags, duplicate tables, empty mappings, and undeclared IDs. IDs and lookup keys are case-sensitive. +- Parse provider settings into tagged types. Reject irrelevant provider fields instead of retaining an unvalidated option bag. +- Require the config `default_key` to name a document mapping. Multiple lookup keys require explicit entries. +- Require an explicit Secrets Manager region and complete ARN for each secret. Validate ARN service, region, and resource shape. Cross-account reads are allowed only with explicit full ARNs and operator-managed permissions. +- Allow one version selector per secret: omitted/current stage, an explicit stage, or a pinned version ID. Reject simultaneous `version_id` and `version_stage`. Although AWS can accept a matching pair, v1 does not need that extra state. +- Existing `EDGEZERO__STORES______NAME` and `__KEY` behavior remains unchanged for implicit local bindings. Reject those supplied overrides for an explicitly bound ID. Do not repurpose a platform name as an AWS ARN. +- Explicit options and supplied handles do not silently merge ambient environment settings. The env-loading entry point is the only path that reads the file locator from the environment. +- The file contains resource references and environment variable names, never credential or secret values. No remote configuration can supply its own bootstrap credentials, endpoint, region, or binding file. + +Keep parsing native-side. The native bindings module owns the versioned top-level file, store kinds/IDs, source collisions, provider-tag dispatch, local defaults, scalar-overlay conflicts, and root requirements. The AWS crate owns strict AWS settings types and resource-shape validation. It must not require changes to `StoreDeclaration`, `StoreMetadata`, or macro-generated resource metadata. Programmatic callers use typed constructors and existing handles without a bindings file. + +The env-loading entry point captures the file locator and scalar overlays once. Explicit options use only supplied bootstrap settings. Both use the same source validator. Offline CLI validation does not treat its own ambient environment as the future process's deployment configuration; conflicts with actual deployment overlays are always checked again at startup. + +## 6. AppConfig configuration + +### Access path + +Use AWS AppConfig Agent initially. AWS recommends it for local caching, polling interval handling, and configuration token management. EdgeZero uses its local HTTP GET endpoint; it does not implement session tokens or a polling loop. + +The operator owns the agent process/container, its AWS role and region, its pinned version, and its network policy. EdgeZero neither launches nor supervises it. The binding identifies application, environment, and profile; the agent's AWS bootstrap is separate from the application binding. + +V1 permits only an HTTP endpoint with a literal loopback IP address. Reject URL userinfo, query strings, fragments, unexpected base paths, redirects, and non-loopback destinations. Construct path segments with proper escaping. Disable ambient HTTP proxies for this client. A token, when configured, comes from the named bootstrap environment variable and never enters diagnostics. Separate-pod or remote-agent access requires a later explicit transport/security design. + +When token authentication is configured, the operator sets Agent `ACCESS_TOKEN` to the same value as the provider's named bootstrap environment variable. The provider sends `Authorization: Bearer ` on its GET request. When unset, it sends no authorization header. A configured but absent/empty token variable fails startup. Never put the token in a URL or emit request-header diagnostics. + +Container deployments must share an appropriate network namespace. Configure the agent to bind loopback explicitly; AWS documents different binding defaults for ECS/EKS and EC2/on-premises. Require token authentication when untrusted processes share that namespace. This does not isolate the endpoint from a compromised application process. + +### Key mapping + +Each configured EdgeZero key maps to the entire deployed free-form document in one profile. The key selects a document, never a field inside it. Multiple independent values require explicit profile mappings: + +- `get("settings")` returns the profile's exact UTF-8 document bytes as `ConfigValue`. +- A profile containing a serialized `BlobEnvelope` returns that serialization unchanged. Core extraction validates it. +- A profile containing a raw string returns the raw string, including whitespace or an empty payload. Do not parse or flatten arbitrary JSON, unquote it, reserialize it, or resolve secret references inside the provider. +- Unknown lookup keys return `Ok(None)`. An unavailable explicitly mapped profile fails startup. +- Invalid UTF-8, response/protocol errors, and oversized documents are errors. + +Request identity content encoding and disable transparent HTTP decompression. V1 rejects responses with a non-identity `Content-Encoding`; compressed responses require a later explicit wire/decoded-limit contract. Bound streamed body bytes before retaining them. The retained UTF-8 body is the original profile text, not compressed transport bytes. + +V1 implements neither key/value document projection nor feature-flag evaluation. They need explicit contracts if later required. + +### Snapshot and freshness + +Fetch each distinct mapped profile once during startup, validate its bytes/limits, and retain an immutable snapshot. The agent can continue polling, but EdgeZero does not reread it after startup. There is no hot reload or app-state mutation. + +The snapshot is coherent per document, not an atomic revision across profiles, secret resources, or providers. Applications needing a coordinated release must use a single configuration envelope with explicit pinned secret versions or their own release validation. + +An agent can successfully return cached data while AWS is unavailable or access has changed. Therefore startup proves that the configured agent supplied an acceptable document, not that AWS was reachable at that instant or that the document is the latest deployment. Do not call this a freshness guarantee. + +Disable agent disk backup/preload for the initial documented setup. Even without disk backup, its in-memory cache can be stale. This proposal explicitly accepts that upstream agent policy; EdgeZero never adds another stale fallback after a failed fetch. If #410 consumers require a maximum config age or fail-on-revocation startup, this access-path choice needs revision before approval. + +Direct Data API polling is deferred. Choosing it later requires single-owner session tokens, one-use tokens, server polling intervals, unchanged empty-response handling, session recovery, ambiguous failure handling, and separate tests. Those requirements are not implemented by this spec's agent reader. + +## 7. Secrets Manager + +Bind the exact logical namespace and key to an explicit secret ARN/version selector. The resulting `BoundSecretStore` must preserve namespace isolation; a wrong store or unmapped key returns `Ok(None)` and never probes arbitrary AWS names. + +At startup, retrieve every explicitly mapped entry. All such entries are required to exist. This keeps a finite, reviewable access set and avoids per-request cloud calls. If an application does not need a secret in this deployment, omit its entry. Required app references to an omitted entry fail in the app-owned startup check. + +Return complete values: + +- `SecretString` becomes its exact UTF-8 bytes. +- The Rust SDK's `SecretBinary` blob is already decoded bytes. Do not base64-decode it again. +- Reject malformed responses with neither or both value fields. Preserve valid empty values where the service/client permits them; app validation decides whether empty is acceptable. +- Do not extract JSON fields. A JSON secret is one complete byte value. +- Typed string secret references still require UTF-8. Direct byte consumers retain binary fidelity. + +Omitted version selection means `AWSCURRENT` at startup. A pinned version ID remains pinned. Restart reads the selector again and constructs a new process snapshot; moving a staging label does not alter the running snapshot. + +There is no TTL cache, negative cache, or serve-stale-on-refresh path. Snapshot memory contains only configured successful values and remains bounded until shutdown. Deleting a secret or revoking AWS access does not erase an already loaded secret from a running process. Operators must stop/restart that process when immediate removal is required. This policy needs explicit approval for rotation-sensitive consumers. + +A failed subsequent restart does not reuse a prior process snapshot. Do not persist secret snapshots to disk. Avoid unnecessary byte/string copies and exclude values from `Debug`, logs, traces, and errors. Standard `Bytes`/`Arc` ownership does not promise memory zeroization; any future such guarantee requires a separate API review. + +## 8. Startup, budgets, and failures + +```mermaid +flowchart TD + Start[Start native process] --> Resolve[Validate bootstrap settings and per-ID sources] + Resolve --> Assemble[Accepted fallible framework assembly] + Assemble --> Load[Prepare local bindings and bounded AWS snapshots] + Load --> Registries[Finalize shared registries] + Registries --> Checks[App initializer loads typed config and required secrets] + Checks --> Ready[Start accepting traffic and expose readiness] + Resolve -->|Failure| Stop[Redacted startup failure, non-zero exit] + Assemble -->|Failure| Stop + Load -->|Failure| Stop + Checks -->|Failure| Stop +``` + +Reuse #392/#396 rather than introducing another readiness or mandatory-secret API. The application owns schema validation, required secret references, signing initialization, and publication of typed handler state. Framework assembly keeps its accepted order; provider preparation precedes the app initializer and listener acceptance. + +At the inspected #275 head, `AppConfig::from_binding` provides context-free typed loading with the shared envelope/secret algorithm. Use its accepted form in the production initializer, then retain validated typed state using #406's startup state insertion. Request extraction remains available and can repeat parsing against immutable snapshots; snapshot providers alone do not cache typed application objects. + +### Proposed v1 defaults + +All limits are validated finite, nonzero settings. Reuse accepted core snapshot/extraction limits where they apply; provider transfer and bootstrap limits are additional checks, not replacements. + +| Limit | Default | +| -------------------------------------------- | --------------------------------------------------------------------- | +| Bindings file bytes | 256 KiB | +| Explicit bindings | 128 total IDs | +| Remote mapped values | 1,024 config document and secret entry mappings per shared session | +| AppConfig document bytes | 1 MiB | +| Secret payload bytes | 64 KiB | +| Combined retained AWS snapshot payload bytes | 8 MiB unique retained payload per shared preparation session | +| Aggregate AWS preparation budget | 30 seconds per session, including credential/client setup and fetches | +| Individual service operation budget | At most 5 seconds, also bounded by remaining aggregate budget | +| Connect / attempt budgets | At most 1 second / 2 seconds, clipped to remaining operation budget | +| Concurrent remote preparation | One operation at a time initially | + +The 8 MiB limit is an approved provisional, configurable default, not an AWS service limit or a number justified by workload measurements. An aggregate cap prevents many individually valid documents and secrets from accumulating an unexpectedly large snapshot. Applications with reviewed larger inputs may explicitly raise it. Revisit the default using representative deployment payload sizes and headroom, rather than presenting it as a validated requirement. + +The standard runner's one shared session accounts for all built-in AWS bindings it prepares, across config/secrets and logical IDs. Programmatic callers use the same accounting by reusing an explicit session. Opaque supplied handles, local providers, and separately created sessions are outside that session's quota. Independent sessions can together exceed 8 MiB; no process-wide cap or global accounting is promised. Callers own the budget for those additional resources. + +Count metadata, mapping entries, retained payload, and transient staging separately. Count every mapping before deduplication, but count a shared immutable payload allocation only once against retained bytes. Bound agent body reads before accumulating the whole document. Reject duplicates before constructing snapshots. Deduplicate identical profile or ARN/version requests only when their complete normalized target and authorization/client policy match; do not accidentally merge namespaces. Check aggregate residency before retaining each payload. A quota breach fails preparation and the runner drops all staged state before readiness; it does not evict values or fall back to local sources. + +The SDK may materialize secret responses and allocate before EdgeZero checks payload size. Metadata, transient buffers, credential providers, TLS, SDK parsing, allocator overhead, local-provider payloads, typed application state, and the external agent are not covered by the retained-payload cap. Do not describe these limits as a certified RSS bound. Preserve #275's conservative allocation/deadline capability classification. + +### Retry ownership + +Reuse one session-owned Secrets Manager client per region/client policy during preparation, not one client per key or request. Configure supported temporary-credential discovery and pin SDK versions and behavior defaults. Production uses workload roles; local developers use a named authenticated profile or supported developer chain. Never require static keys in the bindings file or image. + +The SDK owns Secrets Manager retries, capped at three total attempts within the operation and aggregate budgets. EdgeZero adds no outer retry loop. Retry transient transport/service/throttling failures only; do not repeatedly retry validation, access denial, or decryption failures. Test the pinned SDK's actual classifications. + +The agent HTTP reader makes one bounded request per distinct profile. Agent AWS polling/retry policy is upstream. A failed preparation exits; an operator/supervisor owns any restart backoff. No retry across partially published snapshots is possible because publication occurs only after success. + +Use the accepted application monotonic clock for caller deadlines. Configure provider timers and SDK operation budgets, but do not claim forcible cancellation of credential-process discovery, blocking provider work, or all socket activity. Timeout observation is not proof that every underlying operation stopped. Retain best-effort wording until runtime evidence proves a stronger guarantee. + +### Error boundary + +Classify failures before dropping provider response/source details. Use a native sanitized preparation error with store kind, baked logical ID, and a fixed category. Suggested categories include invalid binding, missing resource/version, credential failure, access denied, decryption failure, throttled, deadline, transport unavailable, malformed value, and size limit. Keep these distinctions where the access path exposes them; the Agent may expose only an upstream-unavailable response, not AWS's original cause. + +Do not extend core errors merely to publish AWS service codes. Snapshot reads use the accepted portable errors for invalid keys, deadlines, and bounds. A missing local lookup is `Ok(None)`; a failed mapped AWS fetch is a startup error, never a successful miss. Agent HTTP authorization denial and missing resource responses remain failures even if detailed cloud causes are unavailable. + +Log only fixed categories and validated application logical IDs. Drop SDK/HTTP error chains before startup formatting. Never log credential sources, ARNs, endpoint tokens, response bodies, raw bootstrap values, config contents, or secrets. Keep service request IDs only if a separately reviewed safe diagnostic policy allows them. + +## 9. Why DynamoDB is separate + +DynamoDB is a good candidate for both config and ordinary KV, but those are separate implementations and permissions. + +For configuration it fits `get(key)` directly. A later config provider must specify table/key/value attributes, namespace encoding, string versus binary handling, version selection, consistency, and missing-item behavior. A DynamoDB item is limited to 400 KiB including attribute names and values, which can exclude complete application envelopes. AppConfig better matches the initial managed configuration deployment requirement; supporting both now would multiply acceptance work without a confirmed second requirement. + +A later DynamoDB KV adapter can implement `KvStore` and return a `KvHandle` to the same native override path. Its owning spec must settle: + +- Table and namespace/key schema, collision-free encoding, and per-binding isolation. +- Byte fidelity, item overhead, and backend value limits. Core's current maximum is not a promise that every provider accepts that size. +- TTL visibility and expiration during reads and listing, rather than assuming background deletion is immediate. +- Prefix pagination, ordering, cursors, consistency, and behavior under concurrent updates. +- Operation budgets, retry policy, permissions, and disposable AWS acceptance. + +Passing ordinary KV contracts would not certify CAS, atomic multi-key operations, strongly consistent prefix snapshots, EC identity-graph behavior, or safe distributed rate limiting. Do not advertise those capabilities without their owning contract and tests. + +Parameter Store remains a documented alternative, not an initial implementation. Valkey, PostgreSQL, S3, and non-AWS provider implementations are also separate tasks. Secrets stored as ordinary KV bytes do not acquire Secrets Manager's lifecycle. + +## 10. Generated applications, CLI, and permissions + +Offer the standard native runner independent opt-in `aws-appconfig-agent` and `aws-secrets-manager` features forwarding to the corresponding `edgezero-store-aws` service features. An optional `aws-stores` alias may enable both, but must not be the only path. Config-only builds need no Secrets Manager SDK; secret-only builds need no Agent HTTP client. The CLI and generated Axum Cargo target must make those dependency choices explicit. A bindings file does not add dependencies to an existing binary. A selected provider that was not compiled in fails safely at startup. + +Make forwarding `[adapters.axum.build].features` an explicit build deliverable. Main's Axum `run_cargo` forwards explicit extra args, while its blueprint has no provider features. Declared native build features and explicit Cargo `--features` additions form the requested feature names, not the resolved provider capabilities. Determine provider availability from Cargo's resolved feature graph for the selected native package and target, including dependency declarations, defaults, `--no-default-features`, `--all-features`, and alias/dependency feature expansion. Build and offline validation must use the same target-selection and feature-resolution inputs. Map generated target features to adapter service features. Add generated-native compile tests for config-only, secret-only, both, and disabled providers. Keep default-generated native and WASM targets AWS-free. + +Put the bindings-file parser and per-ID source validation in a native bindings module in `edgezero-adapter-axum`, available to its `axum` and `cli` features. Share TOML/settings dependencies through a native-bindings feature, rather than enabling `cli` from the runtime or `axum` from CLI-only validation. Main currently enables the TOML dependency only under `cli`. + +The AWS crate owns strict deserialization/validation of its settings types; those types must be available without linking the service SDK or HTTP client. This allows the existing CLI dependency on the Axum adapter to reuse the parser without another crate or portable-core changes. + +Propose `config validate --adapter axum --store-bindings /absolute/native-stores.toml`, with optional `--target-features` additions and target controls equivalent to Cargo's `--all-features` and `--no-default-features`, for offline structural validation against declared IDs, explicitly supplied source conflicts, and limit settings. Target-provider compatibility uses Cargo-resolved features under the same inputs as native build, not a union of feature names or the running CLI binary's compiled services. If target build information is incomplete or its feature graph cannot be resolved offline, explicitly report structural validation only, without a provider-availability verdict. Runtime validation remains authoritative for a prebuilt binary. Test both directions of a CLI/target feature mismatch, default-enabled providers, suppression through `--no-default-features`, `--all-features`, the `aws-stores` alias when offered, and dependency feature propagation. Compare each verdict with the corresponding compiled native target. Never resolve AWS credentials, probe resources, or fetch configuration/secret values during this check. + +No new config publishing, secret mutation, or AWS provisioning behavior is included. Existing `config push --adapter axum` remains a local-file writer and does not update AppConfig. Document that it does not affect an explicitly AWS-bound ID. If the command is given an explicit native bindings file selecting that ID, reject unsupported remote publication rather than report a successful AWS update. Secret-management commands remain under #284. + +Read permissions are independent of operator writes: + +- Agent role needs AppConfig Data `StartConfigurationSession` and `GetLatestConfiguration` for reviewed configuration resources. +- Application role needs Secrets Manager `GetSecretValue` for explicit ARNs and `kms:Decrypt` when the selected customer-managed key requires it. +- Runtime roles do not need resource creation, config deployment, secret mutation, staging-label mutation, or rotation-Lambda management. +- Config-only applications need no Secrets Manager client or application AWS credentials. The agent owns its role. Secret-only applications need no agent. + +Document native-only support, exact setup, restart adoption, upstream agent staleness, secret snapshot retention, least privilege, costs, and cleanup. Downstream apps own binding adoption and app-specific requirements; no Trusted Server or Axon schema belongs in these crates. + +## 11. Delivery sequence and proof + +The user's subsequent instruction to go ahead authorizes execution of the staged implementation plan. Stage proof gates remain; AWS acceptance still requires separate explicit authorization. + +1. Re-audit and integrate accepted #275/#406 contracts. Record existing coverage and add only missing native composition behavior. Test injected fake providers, mixed bindings, defaults, duplicate rejection, and remote-only startup without local roots. +2. Add typed bootstrap parsing and the optional native AWS crate skeleton. Test strict parsing, source conflicts, feature-disabled selection, namespace mappings, and budget validation without network access. +3. Add AppConfig Agent whole-document snapshot preparation. Use a deterministic local HTTP fixture for exact paths, bearer authorization and token redaction, redirects/proxy rejection, content-encoding rejection, raw UTF-8, envelope preservation, empty values, invalid UTF-8, response errors, truncation, deadlines, size limits, and snapshot immutability. Test bounded snapshot reads independently of startup transfer limits. +4. Add Secrets Manager snapshot preparation. Put a narrow test seam at the service-call boundary, not a public provider factory hierarchy. Test string/binary fidelity, no double base64 decoding, namespace isolation, selectors, deduplication, missing resource/version, expired credentials, denied/decryption/throttling/transport errors, and partial-startup cleanup. Test bounded snapshot reads for expired deadlines, separate backend/value allowances, hit byte accounting, zero-byte missing results, and deadline precedence before return. Test startup fetch limits separately; bounded lookup does not bound SDK response allocation during construction. +5. Wire generated native feature enablement and offline validation. Test AWS config plus env secrets, local config plus AWS secrets, both AWS providers plus redb, and same-kind local/custom/AWS bindings. Prove a downstream fake KV can use the generic injection path without importing AWS. +6. Exercise production startup through a generated native application. Required envelope validation and nested secret resolution must finish before readiness. Capture logs/public errors and assert fixture values, tokens, ARNs, and provider bodies never appear. Version changes affect a new process, not the running one. +7. Run an explicitly authorized disposable AWS acceptance using pinned Agent/SDK versions, least-privilege roles, finite cost/request limits, and named cleanup ownership. Record commit, environment, resources, result, and cleanup. Verify cold-agent retrieval, denied cold-agent access, documented warm-cache behavior, current/pinned secret versions, restart adoption, and denied secret access. + +Default tests require neither AWS credentials nor network access. Provider fixtures prove EdgeZero behavior, not deployed IAM, credential discovery, or Agent behavior. Local HTTP fixtures are authorized loopback tests, not external-network tests. + +Implementation gates include: + +```sh +cargo test --workspace --all-targets +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +``` + +Also add enabled native-provider tests/builds, generated application compilation, and existing Fastly/Cloudflare/Spin WASM target checks with AWS absent from their reachable dependency graph. Deterministic provider tests use `futures::executor::block_on` where possible. Native SDK/HTTP fixture tests may use the provider crate's native runtime; they must not move that requirement into portable contract suites. + +Stages 1 and 2 now have executed generic composition, strict bootstrap/settings, isolated-feature and native/WASM build proof in the maintained plan's execution records. Cloud providers and shared-session accounting remain unimplemented at this checkpoint. No deployed AWS behavior or forcible cancellation is proven. The user has explicitly declined real AWS acceptance; it is excluded from the currently authorized task. + +## 12. Alternatives and review questions + +Rejected for the initial design: + +- AWS services inside Axum implementation modules. Smaller at first, but prevents independent provider use by another native runtime. +- Provider settings embedded in portable `[stores]` metadata. Mixes application declarations with deployment references and changes macros unnecessarily. +- A general plugin registry. Existing traits and handle injection already solve extension; no dynamic discovery is required. +- Environment-only nested resource mappings. Scalar NAME/KEY overrides are useful, but finite key-to-profile/ARN maps are easier to validate in a typed file. +- A JSON string-map inside each AppConfig profile. It could pack several lookup keys into one document, but adds projection and envelope-string wrapping. Whole-document bindings preserve raw bytes with fewer decoding rules, at the cost of more profiles for independently deployed keys. +- Direct AppConfig Data API polling. Removes a sidecar dependency but makes EdgeZero own the token protocol, polling, cache, and recovery. Agent plus startup snapshots is the smaller initial implementation. +- Live Secrets Manager reads with a TTL cache. Requires refresh ownership, stale-on-error policy, and rotation adoption semantics. Immutable startup snapshots are simpler and avoid per-request SDK work. + +The approved implementation follows these policies: + +- AppConfig Agent deployment and its lack of a provider-visible maximum-staleness guarantee. +- Startup-only secrets, including retention after remote deletion/revocation until process shutdown. +- Whole-document key mappings rather than JSON projection. +- AppConfig and Secrets Manager as the initial service set, with DynamoDB config/KV deferred to separately scoped work. +- The external bindings file, generic per-ID injection, remaining proposed bounds, and generated feature-enable path. The configurable 8 MiB shared-session quota and caller-owned preparation boundary were approved on 2026-10-07. + +If the application requires live rotation or enforced config freshness, obtain approval to change these contracts. Do not add them implicitly during implementation. The user's implementation/rebase instruction supersedes the original remaining-design and accepted-only prerequisite gates; later approval authorizes draft publication via `gh stack`. Real AWS acceptance remains excluded. + +### SDK diagnostic policy: approved first-version limitation + +An isolated native spike resolves/builds the Secrets Manager-only candidate graph and verifies direct scoped suppression against fictional loopback data; it does not prove the complete credential-chain graph. A separate executed tracing/log probe finds that scoped `NoSubscriber` permanently disables unrelated application tracing-to-log fallback when no global tracing subscriber is installed. Additive `tracing/log-always` instead bypasses that suppression. This violates the logging-preservation/diagnostic contracts; do not silently install a global logger/subscriber or impose new caller requirements. + +The user has explicitly approved continuing the first version with redacted/dropped EdgeZero-owned diagnostics and a dependency DEBUG/TRACE caveat disclosed before reviews. Do not scope NoSubscriber or alter application logging. Dependency diagnostic hardening is a review item, not an implementation blocker. Offline Stages 1-8 are implemented and verified; provider availability validation remains explicitly structural-only/unknown. Real AWS acceptance remains declined and is not requested. Evidence and exact boundaries are recorded in the maintained plan's section 20 and `/tmp/native-store-integration/sdk-diagnostics-execution.md`. + +## References + +- [Issue #410 and ownership boundaries](https://github.com/stackpop/edgezero/issues/410). +- [Production local stores #396](https://github.com/stackpop/edgezero/issues/396) and [startup/readiness #392](https://github.com/stackpop/edgezero/issues/392). +- [Reusable application lifecycle](2026-09-15-reusable-app-lifecycle-design.md). Retaining an app does not refresh its typed state. +- [AWS AppConfig Agent](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-agent.html). +- [Agent container settings, network binding, and backups](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-integration-containers-agent-configuring.html). +- [AppConfig Data polling protocol](https://docs.aws.amazon.com/appconfig/2019-10-09/APIReference/API_appconfigdata_GetLatestConfiguration.html). +- [Secrets Manager GetSecretValue](https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html). +- [AWS SDK credential providers](https://docs.aws.amazon.com/sdk-for-rust/latest/dg/credproviders.html), [timeouts](https://docs.aws.amazon.com/sdk-for-rust/latest/dg/timeouts.html), and [retry configuration](https://docs.aws.amazon.com/sdk-for-rust/latest/dg/retries.html). +- [DynamoDB constraints](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Constraints.html). diff --git a/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index 5f4bb13d..eabfb430 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -780,15 +780,18 @@ dependencies = [ "ctor", "edgezero-adapter", "edgezero-core", + "edgezero-store-aws", "futures", "futures-util", "http", "http-body", "hyper", "hyper-util", + "libc", "log", "redb", "reqwest", + "serde", "serde_json", "simple_logger 5.2.0", "thiserror 2.0.21", @@ -819,6 +822,7 @@ dependencies = [ "tempfile", "toml_edit", "walkdir", + "wasm-streams", "web-sys", "worker", ] @@ -958,6 +962,16 @@ dependencies = [ "validator", ] +[[package]] +name = "edgezero-store-aws" +version = "0.1.0" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.21", + "url", +] + [[package]] name = "either" version = "1.18.0" @@ -1297,9 +1311,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.5.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1342,13 +1356,12 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", - "futures-channel", "futures-core", "http", "http-body", @@ -2383,9 +2396,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -3073,9 +3086,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] diff --git a/examples/app-demo/crates/app-demo-adapter-axum/axum.toml b/examples/app-demo/crates/app-demo-adapter-axum/axum.toml index 364f8e07..1b40fab9 100644 --- a/examples/app-demo/crates/app-demo-adapter-axum/axum.toml +++ b/examples/app-demo/crates/app-demo-adapter-axum/axum.toml @@ -2,3 +2,12 @@ crate = "app-demo-adapter-axum" crate_dir = "." port = 8787 + +# Local config snapshots load before listener bind. Push config before startup; +# restart after an update. Override allocation budgets with: +# EDGEZERO__ADAPTER__CONFIG_STORE__MAX_FILE_BYTES=16777216 +# EDGEZERO__ADAPTER__CONFIG_STORE__MAX_ENTRIES=1024 +# EDGEZERO__ADAPTER__CONFIG_STORE__MAX_KEY_BYTES=1024 +# EDGEZERO__ADAPTER__CONFIG_STORE__MAX_VALUE_BYTES=8388608 +# EDGEZERO__ADAPTER__CONFIG_STORE__MAX_RESIDENT_BYTES=33554432 +# EDGEZERO__ADAPTER__CONFIG_STORE__MAX_STARTUP_BYTES=117440512 diff --git a/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml b/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml index 91f27cc0..9a00b286 100644 --- a/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml +++ b/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml @@ -1,7 +1,7 @@ name = "app-demo-adapter-cloudflare" main = "build/worker/shim.mjs" compatibility_date = "2023-05-01" -compatibility_flags = ["enable_request_signal"] +compatibility_flags = ["enable_request_signal", "enable_weak_ref"] [build] command = "worker-build --release" diff --git a/examples/app-demo/crates/app-demo-cli/tests/config_flow.rs b/examples/app-demo/crates/app-demo-cli/tests/config_flow.rs index 0f2cdc6f..279cf449 100644 --- a/examples/app-demo/crates/app-demo-cli/tests/config_flow.rs +++ b/examples/app-demo/crates/app-demo-cli/tests/config_flow.rs @@ -12,6 +12,7 @@ use app_demo_core::config::AppDemoConfig; use edgezero_adapter::cli_support::validate_spin_outbound_host_contract; +use edgezero_adapter_axum::config_store_limits::ConfigStoreLimits; use edgezero_cli::args::{ConfigPushArgs, ConfigValidateArgs}; use edgezero_core::manifest::ManifestLoader; use edgezero_core::Capability; @@ -255,7 +256,11 @@ fn config_typed_handler_deserialises_blob_envelope_to_greeting() { }); let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned()); let blob_str = serde_json::to_string(&envelope).expect("envelope JSON"); - let store = AxumConfigStore::from_map([("app_config".to_owned(), blob_str)]); + let store = AxumConfigStore::from_map( + [("app_config".to_owned(), blob_str)], + ConfigStoreLimits::default(), + ) + .expect("bounded snapshot"); let config_registry: ConfigRegistry = StoreRegistry::single_id( "app_config".to_owned(), diff --git a/examples/app-demo/crates/app-demo-core/src/handlers.rs b/examples/app-demo/crates/app-demo-core/src/handlers.rs index b74e0d17..aa384dd5 100644 --- a/examples/app-demo/crates/app-demo-core/src/handlers.rs +++ b/examples/app-demo/crates/app-demo-core/src/handlers.rs @@ -359,7 +359,7 @@ pub async fn config_get(RequestContext(ctx): RequestContext) -> Result text_response(StatusCode::OK, value), + Some(value) => text_response(StatusCode::OK, value.as_str()), None => text_response( StatusCode::NOT_FOUND, format!("config key '{}' not found", params.name), @@ -554,8 +554,11 @@ mod tests { #[async_trait::async_trait(?Send)] impl ConfigStore for MapConfigStore { - async fn get(&self, key: &str) -> Result, ConfigStoreError> { - Ok(self.0.get(key).cloned()) + async fn get( + &self, + key: &str, + ) -> Result, ConfigStoreError> { + Ok(self.0.get(key).cloned().map(Into::into)) } async fn get_bounded( @@ -565,12 +568,12 @@ mod tests { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { + ) -> Result, ConfigStoreError> { if deadline.is_expired_at(clock.now()) { return Err(ConfigStoreError::DeadlineExceeded); } finish_bounded_config_read( - self.get(key).await, + Ok(self.0.get(key).cloned()), clock, deadline, max_backend_bytes, @@ -692,7 +695,10 @@ mod tests { #[async_trait::async_trait(?Send)] impl ConfigStore for UnavailableConfigStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Err(ConfigStoreError::unavailable("backend offline")) } @@ -703,7 +709,7 @@ mod tests { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { + ) -> Result, ConfigStoreError> { if deadline.is_expired_at(clock.now()) { return Err(ConfigStoreError::DeadlineExceeded); } @@ -721,8 +727,11 @@ mod tests { #[async_trait(?Send)] impl ConfigStore for FixedStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } async fn get_bounded( @@ -732,7 +741,7 @@ mod tests { deadline: Deadline, max_backend_bytes: u64, max_value_bytes: u64, - ) -> Result, ConfigStoreError> { + ) -> Result, ConfigStoreError> { if deadline.is_expired_at(clock.now()) { return Err(ConfigStoreError::DeadlineExceeded); } diff --git a/examples/app-demo/crates/app-demo-core/src/lib.rs b/examples/app-demo/crates/app-demo-core/src/lib.rs index ea79701d..683fc09c 100644 --- a/examples/app-demo/crates/app-demo-core/src/lib.rs +++ b/examples/app-demo/crates/app-demo-core/src/lib.rs @@ -277,10 +277,11 @@ fn response_lifecycle( /// `build_router()`, which every adapter's `run_app` invokes via /// `EdgeZeroApp::build::(platform)` /// — once at startup for long-lived runtimes (Axum), but **once per request** on -/// Fastly Compute (each request is a fresh Wasm instance). So `app_state()` must -/// be **cheap**: build the heavy state once and hand out clones. Here a -/// `OnceLock>` builds it lazily and every call just bumps the -/// `Arc` refcount — do NOT `Arc::new(..)` a heavy object on each call. +/// default Fastly, Cloudflare and Spin entry points. Fastly starts a fresh Wasm +/// instance by default; Cloudflare and Spin also rebuild the app per request. +/// So `app_state()` must be **cheap**: build the heavy state once and hand out +/// clones. Here a `OnceLock>` builds it lazily and every call just +/// bumps the `Arc` refcount — do NOT `Arc::new(..)` a heavy object on each call. #[must_use] #[inline] pub fn app_state() -> Arc { diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index 4e37dcfe..a5d4893c 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -198,6 +198,27 @@ name = "API_BASE_URL" description = "Base URL for upstream API calls" env = "API_BASE_URL" +# Native pre-admission HTTP/1 resource limits; parent environment values win. +[[environment.variables]] +name = "EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS" +value = "256" +adapters = ["axum"] + +[[environment.variables]] +name = "EDGEZERO__ADAPTER__INGRESS__MAX_RAW_HEAD_BYTES" +value = "65536" +adapters = ["axum"] + +[[environment.variables]] +name = "EDGEZERO__ADAPTER__INGRESS__MAX_HEADER_COUNT" +value = "100" +adapters = ["axum"] + +[[environment.variables]] +name = "EDGEZERO__ADAPTER__INGRESS__HEAD_READ_TIMEOUT_MS" +value = "10000" +adapters = ["axum"] + # [[environment.secrets]] # name = "API_TOKEN" # description = "Secret token used when calling the upstream API" diff --git a/scripts/check_native_store_dependency_contract.sh b/scripts/check_native_store_dependency_contract.sh new file mode 100755 index 00000000..a5b10578 --- /dev/null +++ b/scripts/check_native_store_dependency_contract.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Independent normal dependency graphs; workspace --all-features is not isolation proof. +set -euo pipefail + +assert_no_sdk() { + local graph + graph="$(cargo tree --offline --locked --edges normal --prefix none "$@")" + if grep -Eq '^aws-(config|sdk-[^ ]+) v' <<< "$graph"; then + printf 'AWS SDK leaked into isolated graph: %s\n' "$*" >&2 + exit 1 + fi +} + +assert_no_sdk -p edgezero-cli --no-default-features --features cli,edgezero-adapter-axum +assert_no_sdk -p edgezero-store-aws --no-default-features +assert_no_sdk -p edgezero-store-aws --no-default-features --features appconfig-agent +assert_no_sdk -p edgezero-adapter-axum --no-default-features --features axum +assert_no_sdk -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare +assert_no_sdk -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly +assert_no_sdk -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +dynamodb_graph="$(cargo tree --offline --locked --edges normal --prefix none -p edgezero-store-aws --no-default-features --features dynamodb-kv)" +secrets_graph="$(cargo tree --offline --locked --edges normal --prefix none -p edgezero-store-aws --no-default-features --features secrets-manager)" +stores_graph="$(cargo tree --offline --locked --edges normal --prefix none -p edgezero-adapter-axum --no-default-features --features aws-stores)" +if ! grep -Eq '^aws-sdk-dynamodb v' <<< "$dynamodb_graph" || ! grep -Eq '^aws-sdk-secretsmanager v' <<< "$secrets_graph"; then + printf 'Selected AWS service SDK is missing.\n' >&2 + exit 1 +fi +if grep -Eq '^aws-sdk-secretsmanager v' <<< "$dynamodb_graph" || grep -Eq '^aws-sdk-dynamodb v' <<< "$secrets_graph" || grep -Eq '^aws-sdk-dynamodb v' <<< "$stores_graph"; then + printf 'AWS service SDK leaked into an independently selected provider.\n' >&2 + exit 1 +fi +printf 'Native store SDK isolation graphs passed.\n' diff --git a/scripts/check_outbound_docs_contract.mjs b/scripts/check_outbound_docs_contract.mjs index 0f1fd75f..a43e906b 100644 --- a/scripts/check_outbound_docs_contract.mjs +++ b/scripts/check_outbound_docs_contract.mjs @@ -1219,6 +1219,7 @@ for (const requiredFragment of [ 'Result', 'premature driver EOF', 'duplicate or out-of-range', + 'Core reports normal completion only at driver EOF', ]) { if (!outboundSpecSource.includes(requiredFragment)) { fail( @@ -1506,6 +1507,9 @@ for (const requiredFragment of [ 'not attached to a `Response` and never enters `http::Extensions`', 'ResponseEgressHead::application_deadline()', 'queue-through-egress', + 'application configuration changed adapter-selected platform metadata', + 'validates source EOF before handing that frame to Hyper', + 'A transmitted zero-length stream is validated asynchronously before Hyper receives the response', ]) { if (!responseEgressSpecSource.includes(requiredFragment)) { fail( diff --git a/scripts/check_outbound_legacy_api.sh b/scripts/check_outbound_legacy_api.sh index 8a8b7725..fdaf08c6 100755 --- a/scripts/check_outbound_legacy_api.sh +++ b/scripts/check_outbound_legacy_api.sh @@ -927,6 +927,7 @@ NODE active_surfaces=( crates examples/app-demo + tests/fixtures/reusable-app .github/actions .github/workflows docs/.vitepress @@ -968,7 +969,7 @@ failed=0 scan \ 'Proxy(Client|Handle|Request|Response|Service)|proxy_handle|edgezero_core::proxy|crate::proxy|pub mod proxy' \ - crates examples/app-demo docs/guide README.md CLAUDE.md TODO.md Cargo.toml \ + crates examples/app-demo tests/fixtures/reusable-app docs/guide README.md CLAUDE.md TODO.md Cargo.toml \ .claude/agents/code-architect.md || failed=1 # Downstream response delivery is now adapter-owned. Keep the outbound-fetch @@ -976,7 +977,7 @@ scan \ # response egress. scan \ 'ResponseReturned|begin_owned|[A-Z]+_RESPONSE_STREAM_BUFFER_BYTES|SpinFullResponse|Captured(Cloudflare|Spin)Response|EdgeZeroAxumService|dispatch_for_test|pub (async )?fn (from_core_response|into_axum_response)|buffered (fallback|passthrough)|16 MiB downstream conversion fallback|Response -> Workers Response \(buffered bodies\)' \ - crates examples/app-demo docs/guide README.md TODO.md Cargo.toml \ + crates examples/app-demo tests/fixtures/reusable-app docs/guide README.md TODO.md Cargo.toml \ ':(exclude)crates/edgezero-cli/src/generator.rs' || failed=1 # The generator keeps a negative assertion containing this spelling. Scan the @@ -990,15 +991,15 @@ scan \ # request-extension-only Fastly lifecycle. The generator's negative assertion # intentionally contains `.send_all(` and is excluded here. scan \ - '\.send_all\(|SendAllSlotIsolation|send-all-slot-isolation|run_app_with_request_extensions' \ - crates examples/app-demo docs/guide README.md CLAUDE.md TODO.md \ + '\.send_all\(|SendAllSlotIsolation|send-all-slot-isolation|(run|serve)_app_with_request_extensions' \ + crates examples/app-demo tests/fixtures/reusable-app docs/guide README.md CLAUDE.md TODO.md \ ':(exclude)crates/edgezero-cli/src/generator.rs' || failed=1 # Batch termination is explicit. Adapter drivers cannot use stream EOF as a # cutoff signal, and callers cannot interpret every missing item as timeout. scan \ 'OutboundBatch::from_stream|while[[:space:]]+let[[:space:]]+Some\([^)]*\)[[:space:]]*=[[:space:]]*batch\.next\(\)\.await|send_all_until\(.*\)\.await\.slots|Result' \ - crates examples/app-demo docs/guide README.md CLAUDE.md TODO.md || failed=1 + crates examples/app-demo tests/fixtures/reusable-app docs/guide README.md CLAUDE.md TODO.md || failed=1 # Generated/demo Fastly entrypoints must delegate transmission to EdgeZero. scan \ @@ -1011,22 +1012,22 @@ scan \ # extensions or an envelope escape hatch. scan \ 'fn configure(_app)?\([^)]*&mut (EdgeZeroApp|edgezero_core::app::App|App)[^)]*\)[[:space:]]*\{' \ - crates examples/app-demo docs/guide README.md || failed=1 + crates examples/app-demo tests/fixtures/reusable-app docs/guide README.md || failed=1 scan \ 'fn build_app\(\)[[:space:]]*->[[:space:]]*App|AdmissionDecision::Refuse[[:space:]]*\(|ResponseEgressEnvelope[^[:space:]]*\.into_response|envelope\.into_response\(\)' \ - crates examples/app-demo docs/guide README.md || failed=1 + crates examples/app-demo tests/fixtures/reusable-app docs/guide README.md || failed=1 scan \ '(extensions(_mut)?\(\)|extensions)\.insert\(ResponseEgressCompletion' \ - crates examples/app-demo docs/guide README.md \ + crates examples/app-demo tests/fixtures/reusable-app docs/guide README.md \ ':(exclude)crates/edgezero-core/src/response_egress.rs' || failed=1 # Normalized ingress errors use one typed send-or-abort decision. The generator # keeps the retired setter spelling only in a negative assertion. scan \ 'DetachedResponseEgressCompletionFactory|set_detached_response_egress_completion_factory' \ - crates examples/app-demo docs/guide docs/superpowers/specs README.md \ + crates examples/app-demo tests/fixtures/reusable-app docs/guide docs/superpowers/specs README.md \ ':(exclude)crates/edgezero-cli/src/generator.rs' || failed=1 # Detached response egress uses named fields only. This scanner spans comments diff --git a/scripts/native_aws_test_admin.mjs b/scripts/native_aws_test_admin.mjs new file mode 100644 index 00000000..8cb66e2d --- /dev/null +++ b/scripts/native_aws_test_admin.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +// Local emulator fixture administration, never an application AWS client. +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import http from "node:http"; +import { pathToFileURL } from "node:url"; + +const TABLE = "edgezero-emulator-kv"; +const TEXT_SECRET = "edgezero-emulator-text"; +const VERSION_A = "a".repeat(32); +const VERSION_B = "b".repeat(32); + +function client(endpoint, service) { + const match = /^http:\/\/127\.0\.0\.1:([0-9]+)$/.exec(endpoint ?? ""); + if (!match || Number(match[1]) < 1 || Number(match[1]) > 65535) { + throw new Error(`Fixture endpoint must be literal http://127.0.0.1:, got: ${endpoint}`); + } + return (operation, payload = {}) => new Promise((resolve, reject) => { + const body = JSON.stringify(payload); + const date = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ""); + // These emulators need a credential scope but do not validate signatures. + // Fixed dummy authorization is safe only because endpoints are strictly local. + const request = http.request(endpoint, { + method: "POST", + headers: { + "Content-Type": service === "dynamodb" ? "application/x-amz-json-1.0" : "application/x-amz-json-1.1", + "Content-Length": Buffer.byteLength(body), + "X-Amz-Target": `${service === "dynamodb" ? "DynamoDB_20120810" : "secretsmanager"}.${operation}`, + "X-Amz-Date": date, + Authorization: `AWS4-HMAC-SHA256 Credential=DUMMYLOCAL/${date.slice(0, 8)}/us-east-1/${service}/aws4_request, SignedHeaders=host;x-amz-date, Signature=${"0".repeat(64)}`, + }, + }, (response) => { + const chunks = []; + let size = 0; + response.on("data", (chunk) => { + size += chunk.length; + if (size > 1024 * 1024) request.destroy(new Error("Oversized fixture response")); + else chunks.push(chunk); + }); + response.on("error", reject); + response.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + if (response.statusCode !== 200) { + reject(new Error(`${service}.${operation} failed: HTTP ${response.statusCode} ${text}`)); + return; + } + try { resolve(JSON.parse(text)); } catch (error) { reject(error); } + }); + }); + // No environment proxy discovery, redirects, credential lookup or retries. + const timer = setTimeout(() => request.destroy(new Error("Fixture request timeout")), 3000); + request.on("error", reject); + request.on("close", () => clearTimeout(timer)); + request.end(body); + }); +} + +async function seed(dynamo, secrets) { + const keys = [ + { AttributeName: "pk", KeyType: "HASH" }, + { AttributeName: "sk", KeyType: "RANGE" }, + ]; + const attributes = [ + { AttributeName: "pk", AttributeType: "S" }, + { AttributeName: "sk", AttributeType: "S" }, + ]; + await dynamo("CreateTable", { + TableName: TABLE, KeySchema: keys, AttributeDefinitions: attributes, BillingMode: "PAY_PER_REQUEST", + }); + const wrong = "edgezero-emulator-wrong"; + await dynamo("CreateTable", { + TableName: wrong, KeySchema: keys.slice(0, 1), AttributeDefinitions: attributes.slice(0, 1), + BillingMode: "PAY_PER_REQUEST", + }); + const indexed = "edgezero-emulator-indexed"; + await dynamo("CreateTable", { + TableName: indexed, KeySchema: keys, AttributeDefinitions: attributes, BillingMode: "PAY_PER_REQUEST", + GlobalSecondaryIndexes: [{ + IndexName: "unsupported-index", KeySchema: [{ AttributeName: "sk", KeyType: "HASH" }], + Projection: { ProjectionType: "KEYS_ONLY" }, + }], + }); + for (const [key, value, expired] of [ + ["expired/0", "expired", true], ["expired/1", "live", false], ["renew/item", "old", true], + ]) { + const item = { pk: { S: "primary" }, sk: { S: key }, v: { B: Buffer.from(value).toString("base64") } }; + if (expired) Object.assign(item, { expires_at_ms: { N: "1" }, ttl_epoch_s: { N: "1" } }); + await dynamo("PutItem", { TableName: TABLE, Item: item }); + } + const text = await secrets("CreateSecret", { + Name: TEXT_SECRET, SecretString: "secret-A", ClientRequestToken: VERSION_A, + }); + const versionB = await secrets("PutSecretValue", { + SecretId: text.ARN, SecretString: "secret-B", ClientRequestToken: VERSION_B, VersionStages: ["CANDIDATE"], + }); + await secrets("UpdateSecretVersionStage", { + SecretId: text.ARN, VersionStage: "PINNED", MoveToVersionId: text.VersionId, + }); + const wholeJson = await secrets("CreateSecret", { + Name: "edgezero-emulator-json", SecretString: ' { "answer": 42 }\n', ClientRequestToken: randomUUID(), + }); + const binary = await secrets("CreateSecret", { + Name: "edgezero-emulator-binary", SecretBinary: Buffer.from([0, 255, 10, 34]).toString("base64"), + ClientRequestToken: randomUUID(), + }); + return { + table: TABLE, wrong_table: wrong, indexed_table: indexed, missing_table: "edgezero-emulator-missing", + secrets: { + text: { arn: text.ARN, version_a: text.VersionId, version_b: versionB.VersionId }, + json: { arn: wholeJson.ARN }, binary: { arn: binary.ARN }, + }, + }; +} + +async function rotate(secrets) { + // Moto 5.2.2 does not update no-selector reads after a manual AWSCURRENT move. + // Move PINNED first: PutSecretValue also resets the old current version's stages. + // The application snapshots stay A while independent reads confirm adoption of B. + await secrets("UpdateSecretVersionStage", { + SecretId: TEXT_SECRET, VersionStage: "PINNED", MoveToVersionId: VERSION_B, RemoveFromVersionId: VERSION_A, + }); + const current = await secrets("PutSecretValue", { + SecretId: TEXT_SECRET, SecretString: "secret-B", ClientRequestToken: "c".repeat(32), + }); + for (const selector of [{}, { VersionStage: "PINNED" }, { VersionId: VERSION_A }]) { + const actual = await secrets("GetSecretValue", { SecretId: TEXT_SECRET, ...selector }); + assert.equal(actual.SecretString, selector.VersionId ? "secret-A" : "secret-B"); + } + return { version_current: current.VersionId }; +} + +export async function fixtureAction(action, payload, endpoints = { + dynamo: process.env.EDGEZERO_AWS_TEST_DYNAMODB_ENDPOINT, + moto: process.env.EDGEZERO_AWS_TEST_SECRETS_ENDPOINT, +}) { + const dynamo = client(endpoints.dynamo, "dynamodb"); + const secrets = client(endpoints.moto, "secretsmanager"); + switch (action) { + case "ready": + await dynamo("ListTables"); + await secrets("ListSecrets"); + return {}; + case "seed": return seed(dynamo, secrets); + case "rotate": return rotate(secrets); + case "delete_secret": + await secrets("DeleteSecret", { SecretId: TEXT_SECRET, ForceDeleteWithoutRecovery: true }); + return {}; + case "item": { + const { Item: item } = await dynamo("GetItem", { + TableName: TABLE, Key: { pk: { S: payload.namespace }, sk: { S: payload.key } }, ConsistentRead: true, + }); + if (!item) return { present: false }; + return { + present: true, value: [...Buffer.from(item.v.B, "base64")], + expires_at_ms: item.expires_at_ms ? Number(item.expires_at_ms.N) : null, + ttl_epoch_s: item.ttl_epoch_s ? Number(item.ttl_epoch_s.N) : null, + }; + } + default: throw new Error(`unknown fixture action: ${action}`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + Promise.resolve().then(() => fixtureAction(process.argv[2], JSON.parse(process.argv[3]))) + .then((result) => console.log(JSON.stringify(result))) + .catch((error) => { console.error(error); process.exitCode = 1; }); +} diff --git a/scripts/native_aws_test_admin.test.mjs b/scripts/native_aws_test_admin.test.mjs new file mode 100644 index 00000000..92273236 --- /dev/null +++ b/scripts/native_aws_test_admin.test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import http from "node:http"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { fixtureAction } from "./native_aws_test_admin.mjs"; + +const exec = promisify(execFile); +const script = fileURLToPath(new URL("./native_aws_test_admin.mjs", import.meta.url)); + +async function emulator(t, reply = () => ({})) { + const calls = []; + const server = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const call = { + target: request.headers["x-amz-target"], headers: request.headers, + payload: JSON.parse(Buffer.concat(chunks)), method: request.method, + }; + calls.push(call); + const result = reply(call); + response.writeHead(result.status ?? 200); + response.end(result.raw ?? JSON.stringify(result)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + const endpoint = `http://127.0.0.1:${server.address().port}`; + return { calls, endpoints: { dynamo: endpoint, moto: endpoint } }; +} + +test("fixture administration rejects non-loopback endpoints before sending requests", async (t) => { + const setup = await emulator(t); + for (const endpoint of [ + undefined, "https://127.0.0.1:8000", "http://localhost:8000", "http://127.0.0.1:0", + "http://127.0.0.1:65536", "http://127.0.0.1:8000/", "http://127.0.0.1:8000@aws.example", + ]) { + for (const service of ["dynamo", "moto"]) { + await assert.rejects(fixtureAction("ready", {}, { ...setup.endpoints, [service]: endpoint }), /literal/); + } + } + assert.deepEqual(setup.calls, []); +}); + +test("readiness checks both emulator JSON APIs with dummy credentials", async (t) => { + const setup = await emulator(t); + assert.deepEqual(await fixtureAction("ready", {}, setup.endpoints), {}); + assert.deepEqual(setup.calls.map((call) => call.target), ["DynamoDB_20120810.ListTables", "secretsmanager.ListSecrets"]); + for (const call of setup.calls) { + assert.equal(call.method, "POST"); + assert.deepEqual(call.payload, {}); + assert.match(call.headers.authorization, /Credential=DUMMYLOCAL\/\d{8}\/us-east-1\//); + } +}); + +test("seed preserves table schemas, expiry rows, secret bytes and version IDs", async (t) => { + const setup = await emulator(t, ({ payload }) => ({ ARN: payload.Name ?? payload.SecretId, VersionId: payload.ClientRequestToken })); + const result = await fixtureAction("seed", {}, setup.endpoints); + const tables = setup.calls.filter((call) => call.target.endsWith(".CreateTable")).map((call) => call.payload); + assert.deepEqual(tables.map((table) => table.KeySchema.length), [2, 1, 2]); + assert.equal(tables[2].GlobalSecondaryIndexes[0].IndexName, "unsupported-index"); + assert.equal(result.table, tables[0].TableName); + assert.equal(result.wrong_table, tables[1].TableName); + assert.equal(result.indexed_table, tables[2].TableName); + const rows = setup.calls.filter((call) => call.target.endsWith(".PutItem")).map((call) => call.payload.Item); + assert.deepEqual(rows.map((row) => Buffer.from(row.v.B, "base64").toString()), ["expired", "live", "old"]); + assert.deepEqual(rows.map((row) => row.expires_at_ms?.N), ["1", undefined, "1"]); + assert.deepEqual(rows.map((row) => row.ttl_epoch_s?.N), ["1", undefined, "1"]); + const secrets = setup.calls.filter((call) => call.target.endsWith(".CreateSecret")).map((call) => call.payload); + assert.equal(secrets[1].SecretString, ' { "answer": 42 }\n'); + assert.deepEqual([...Buffer.from(secrets[2].SecretBinary, "base64")], [0, 255, 10, 34]); + for (const secret of secrets) assert.ok(secret.ClientRequestToken.length >= 32); + assert.equal(result.secrets.text.version_a, "a".repeat(32)); + assert.equal(result.secrets.text.version_b, "b".repeat(32)); +}); + +test("rotation moves PINNED before default adoption and independently checks selectors", async (t) => { + const setup = await emulator(t, ({ payload }) => ({ + VersionId: payload.ClientRequestToken, + SecretString: payload.VersionId ? "secret-A" : "secret-B", + })); + assert.deepEqual(await fixtureAction("rotate", {}, setup.endpoints), { version_current: "c".repeat(32) }); + assert.deepEqual(setup.calls.map((call) => call.target), [ + "secretsmanager.UpdateSecretVersionStage", "secretsmanager.PutSecretValue", + "secretsmanager.GetSecretValue", "secretsmanager.GetSecretValue", "secretsmanager.GetSecretValue", + ]); + assert.equal(setup.calls[0].payload.MoveToVersionId, "b".repeat(32)); + assert.equal(setup.calls[0].payload.RemoveFromVersionId, "a".repeat(32)); + assert.equal(setup.calls[3].payload.VersionStage, "PINNED"); + assert.equal(setup.calls[4].payload.VersionId, "a".repeat(32)); +}); + +test("rotation fails if independent reads do not confirm adoption", async (t) => { + const setup = await emulator(t, () => ({ SecretString: "secret-A" })); + await assert.rejects(fixtureAction("rotate", {}, setup.endpoints), /secret-A.*secret-B/s); +}); + +test("item readback decodes binary values and preserves TTL and absence output", async (t) => { + let item; + const setup = await emulator(t, () => ({ Item: item })); + const read = () => fixtureAction("item", { namespace: "primary", key: "key" }, setup.endpoints); + assert.deepEqual(await read(), { present: false }); + item = { v: { B: Buffer.from([0, 255, 10, 34]).toString("base64") }, expires_at_ms: { N: "1234" }, ttl_epoch_s: { N: "2" } }; + assert.deepEqual(await read(), { present: true, value: [0, 255, 10, 34], expires_at_ms: 1234, ttl_epoch_s: 2 }); + delete item.expires_at_ms; + delete item.ttl_epoch_s; + assert.deepEqual(await read(), { present: true, value: [0, 255, 10, 34], expires_at_ms: null, ttl_epoch_s: null }); + assert.deepEqual(setup.calls[0].payload.Key, { pk: { S: "primary" }, sk: { S: "key" } }); + assert.equal(setup.calls[0].payload.ConsistentRead, true); +}); + +test("CLI returns JSON only and rejects unknown actions and malformed input", async (t) => { + const setup = await emulator(t); + const options = { env: { + ...process.env, + EDGEZERO_AWS_TEST_DYNAMODB_ENDPOINT: setup.endpoints.dynamo, + EDGEZERO_AWS_TEST_SECRETS_ENDPOINT: setup.endpoints.moto, + } }; + const { stdout, stderr } = await exec(process.execPath, [script, "delete_secret", "{}"], options); + assert.equal(stdout, "{}\n"); + assert.equal(stderr, ""); + assert.deepEqual(setup.calls[0].payload, { SecretId: "edgezero-emulator-text", ForceDeleteWithoutRecovery: true }); + await assert.rejects(exec(process.execPath, [script, "unknown", "{}"], options), /unknown fixture action/); + await assert.rejects(exec(process.execPath, [script, "seed", "not JSON"], options)); + assert.equal(setup.calls.length, 1); +}); + +test("HTTP failures, redirects and malformed responses fail without retries", async (t) => { + for (const result of [{ status: 500 }, { status: 302 }, { raw: "not JSON" }]) { + const setup = await emulator(t, () => result); + await assert.rejects(fixtureAction("ready", {}, setup.endpoints)); + assert.equal(setup.calls.length, 1); + } +}); diff --git a/scripts/qualify_response_egress.mjs b/scripts/qualify_response_egress.mjs new file mode 100644 index 00000000..db4c2af7 --- /dev/null +++ b/scripts/qualify_response_egress.mjs @@ -0,0 +1,241 @@ +import { writeFile } from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import { request } from "node:https"; +import { pathToFileURL } from "node:url"; + +export const CASES = ["normal", "head", "204", "205", "304", "slow", "nonread", + "disconnect", "cancel", "pending", "expired", "source-error", "conversion-error"]; +const TOKEN = /^[a-z0-9-]{1,64}$/; +const MAX_INPUT = 4 * 1024 * 1024; +const MAX_TRANSFER = 64 * 1024 * 1024; + +function token(value) { + return typeof value === "string" && TOKEN.test(value); +} + +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function hex(value, length) { + return typeof value === "string" && value.length === length && /^[a-f0-9]+$/.test(value); +} + +function clientAction(name) { + return ["nonread", "disconnect", "cancel", "slow"].includes(name) ? name : "read"; +} + +function validIdentity(spec) { + return token(spec.run) && hex(spec.revision, 40) && + hex(spec.artifact, 64) && hex(spec.binary_sha256, 64) && + ["axum", "cloudflare", "fastly", "spin"].includes(spec.adapter) && + typeof spec.sdk === "string" && spec.sdk.length > 0 && spec.sdk.length < 80 && + object(spec.runtime) && + ((typeof spec.runtime.revision === "string" && spec.runtime.revision.length > 0 && spec.runtime.revision.length <= 100) || + ["provider-unpublished", "operator-unavailable"].includes(spec.runtime.unknown)) && + object(spec.compatibility) && Array.isArray(spec.compatibility.flags) && spec.compatibility.flags.length <= 32 && + spec.compatibility.flags.every(flag => typeof flag === "string" && /^[a-zA-Z0-9_-]{1,80}$/.test(flag)) && + (spec.adapter !== "cloudflare" || (typeof spec.compatibility.date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(spec.compatibility.date))) && + typeof spec.deployment === "string" && + spec.deployment.length > 0 && spec.deployment.length <= 200; +} + +function requireFact(condition, reason) { + if (!condition) throw new Error(reason); +} + +export function validateTarget(spec) { + const fields = new Set(["origin", "allowed_origin", "isolated_nonproduction", "run", "adapter", "revision", + "artifact", "binary_sha256", "sdk", "runtime", "compatibility", "deployment", "max_probe_ms", "execution_ceiling_ms"]); + requireFact(Object.keys(spec).every(key => fields.has(key)), "Unexpected target field"); + const url = new URL(spec.origin); + requireFact(url.protocol === "https:" && !url.username && !url.password && + url.pathname === "/" && !url.search && !url.hash && url.origin === spec.allowed_origin, + "An explicitly allowlisted HTTPS origin without credentials or a path is required"); + requireFact(validIdentity(spec), + "SDK, runtime, compatibility and independently recorded deployment identity are required"); + requireFact(Number.isInteger(spec.max_probe_ms) && spec.max_probe_ms >= 1000 && + spec.max_probe_ms <= 10_000, "Probe time budget must be 1000..10000 ms"); + requireFact(spec.isolated_nonproduction === true, "A designated isolated nonproduction target is required"); + requireFact(spec.adapter !== "fastly" || (Number.isInteger(spec.execution_ceiling_ms) && + spec.execution_ceiling_ms > 0 && spec.execution_ceiling_ms <= 60_000), + "Fastly pending-source probes require an independently enforced finite execution ceiling"); + return url; +} + +export function validateEvidence(manifest, clients, events) { + try { + requireFact(manifest.schema === 1 && validIdentity(manifest), + "Revision-pinned deployment metadata is incomplete"); + requireFact(manifest.telemetry_complete === true, "Complete unsampled telemetry export is required"); + requireFact(Array.isArray(manifest.expected) && manifest.expected.length > 0 && + manifest.expected.length <= 2 * CASES.length, "Invalid expected probe set"); + const expected = new Map(); + for (const probe of manifest.expected) { + requireFact(token(probe.probe) && CASES.includes(probe.case) && !expected.has(probe.probe), + "Invalid or duplicate scheduled probe"); + expected.set(probe.probe, probe.case); + } + requireFact(clients.length === expected.size, "Missing or extra client observations"); + const observations = new Map(); + for (const client of clients) { + requireFact(expected.get(client.probe) === client.case && !observations.has(client.probe) && + client.action === clientAction(client.case) && + Number.isSafeInteger(client.bytes) && client.bytes >= 0 && client.bytes <= MAX_TRANSFER, + "Invalid client observation"); + observations.set(client.probe, client); + } + requireFact(events.length <= 4 * expected.size, "Unexpected or duplicate telemetry"); + for (const event of events) { + requireFact(event.schema === 1 && event.run === manifest.run && event.revision === manifest.revision && + event.artifact === manifest.artifact && expected.get(event.probe) === event.case && + token(event.instance), "Telemetry identity mismatch"); + } + for (const [probe, name] of expected) { + const trace = events.filter(event => event.probe === probe).sort((a, b) => a.sequence - b.sequence); + requireFact(trace.length === 4 && trace.every((event, index) => + event.sequence === index && event.instance === trace[0].instance), "Incomplete or conflicting trace sequence"); + const one = kind => { + const selected = trace.filter(event => event.event === kind); + requireFact(selected.length === 1, "Missing or duplicate lifecycle event"); + return selected[0]; + }; + one("source-drop"); + const terminal = one("terminal"); + const resource = one("resource-drop"); + const closed = one("trace-closed"); + requireFact(closed.sequence === 3 && closed.terminals === 1 && closed.source_drops === 1 && + closed.resource_drops === 1 && closed.polls_after_terminal === 0 && + Number.isSafeInteger(closed.polls) && closed.polls >= 0 && terminal.sequence < resource.sequence, + "Ownership counters or release ordering disagree"); + requireFact(Number.isSafeInteger(terminal.bytes_written) && terminal.bytes_written >= 0 && + terminal.bytes_written <= MAX_TRANSFER && ["Application", "Fallback"].includes(terminal.body_kind), + "Invalid terminal byte accounting"); + requireFact(["Completed", "HostHandoff", "ClientDisconnected", "ConversionError", "DeadlineExceeded", + "RequestCancelled", "SourceError", "TransportError"].includes(terminal.outcome), "Unknown terminal outcome"); + const bodyless = ["head", "204", "205", "304"].includes(name); + const client = observations.get(probe); + if (["normal", "head", "204", "205", "304"].includes(name)) { + requireFact(["Completed", "HostHandoff"].includes(terminal.outcome) && + terminal.body_kind === "Application" && terminal.fallback === null && client.ended === true && + client.bytes === (bodyless ? 0 : 3072) && client.status === (bodyless && name !== "head" ? Number(name) : 200), + "Normal/bodyless client observation or terminal outcome disagrees"); + } + if (bodyless) requireFact(closed.polls === 0 && terminal.bytes_written === 0, "Suppressed body was polled or written"); + if (name === "normal") requireFact(closed.polls >= 4 && terminal.bytes_written === 3072, + "Normal response lacks source/accepted-byte evidence"); + if (name === "pending") requireFact(closed.polls >= 1, "Pending producer was never polled"); + if (name === "expired") requireFact(closed.polls === 0 && terminal.body_kind === "Fallback" && + (client.status === null || client.status === 504), "Expired precommit response was not suppressed"); + if (["nonread", "disconnect", "cancel"].includes(name)) requireFact(client.ended === false && + client.result === (name === "disconnect" ? "client-close" : "client-deadline"), + "Client did not exercise the scheduled interruption"); + if (name === "slow") requireFact(Number.isSafeInteger(client.pauses) && client.pauses > 0, + "Slow-client backpressure was not exercised"); + if (["expired", "pending"].includes(name)) requireFact(terminal.outcome === "DeadlineExceeded", "Deadline was not observed"); + if (name === "source-error") requireFact(terminal.outcome === "SourceError", "Source failure was misclassified"); + if (name === "conversion-error") requireFact(terminal.outcome === "ConversionError", "Conversion failure was misclassified"); + if (terminal.body_kind === "Fallback") requireFact(["Completed", "Aborted"].includes(terminal.fallback) && + ["DeadlineExceeded", "ConversionError", "SourceError"].includes(terminal.outcome) && terminal.bytes_written <= 1024, + "Fallback cause or bounded disposition is invalid"); + else requireFact(terminal.fallback === null, "Application report cannot have fallback disposition"); + } + return { status: "qualified-ownership", scope: "scheduled probes only; not confirmed client delivery or provider allocation", + probes: expected.size, capabilities_changed: false }; + } catch (error) { + return { status: "unverified", reason: error.message }; + } +} + +async function readJson(path) { + const chunks = []; + let length = 0; + for await (const chunk of createReadStream(path, { highWaterMark: 16 * 1024 })) { + length += chunk.length; + requireFact(length <= MAX_INPUT, "Evidence input exceeds 4 MiB"); + chunks.push(chunk); + } + const bytes = Buffer.concat(chunks, length); + return JSON.parse(bytes.toString("utf8")); +} + +function runClient(origin, spec, probe, remaining) { + return new Promise(resolve => { + let bytes = 0; + let status = null; + let settled = false; + let pauseTimer; + let pauses = 0; + const action = clientAction(probe.case); + const started = performance.now(); + const finish = (ended, result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(pauseTimer); + req.destroy(); + resolve({ ...probe, action, ended, result, status, bytes, pauses, elapsed_ms: Math.ceil(performance.now() - started) }); + }; + const req = request(new URL(`/qualification/${probe.case}`, origin), { + method: probe.case === "head" ? "HEAD" : "GET", agent: false, + headers: { "x-qualification-run": spec.run, "x-qualification-probe": probe.probe, "accept-encoding": "identity" }, + }, response => { + status = response.statusCode; + response.on("error", () => finish(false, "response-error")); + response.on("end", () => finish(true, "end")); + if (action === "nonread") { response.pause(); return; } + if (action === "disconnect") { finish(false, "client-close"); return; } + response.on("data", chunk => { + bytes += chunk.length; + if (bytes > remaining) { finish(false, "transfer-cap"); return; } + if (action === "slow") { + pauses++; + response.pause(); + pauseTimer = setTimeout(() => response.resume(), 50); + } + }); + }); + const timer = setTimeout(() => finish(false, "client-deadline"), action === "cancel" ? 100 : spec.max_probe_ms); + req.on("error", () => finish(false, "request-error")); + req.end(); + }); +} + +async function main(args) { + if (args[0] === "--execute-hosted" && args.length === 3) { + const spec = await readJson(args[1]); + const origin = validateTarget(spec); + const expected = CASES.flatMap((name, index) => [ + { probe: `case-${index}`, case: name }, { probe: `recovery-${index}`, case: "normal" }, + ]); + const clients = []; + let remaining = MAX_TRANSFER; + for (const probe of expected) { + requireFact(remaining > 0, "Run transfer budget exhausted"); + const observed = await runClient(origin, spec, probe, remaining); + clients.push(observed); + remaining -= observed.bytes; + if (spec.adapter === "fastly" && ["pending", "cancel", "nonread", "disconnect"].includes(probe.case)) { + const teardown = Math.max(0, spec.execution_ceiling_ms - observed.elapsed_ms) + 100; + await new Promise(resolve => setTimeout(resolve, teardown)); + } + } + await writeFile(args[2], JSON.stringify({ manifest: { ...spec, schema: 1, expected, telemetry_complete: false }, clients }, null, 2), { flag: "wx", mode: 0o600 }); + process.stdout.write("Client observations recorded; hosted qualification requires a complete correlated telemetry export.\n"); + return 2; + } + requireFact(args[0] === "--validate" && args.length === 3, + "Use --execute-hosted TARGET.json CLIENTS.json or --validate CLIENTS.json EVENTS.json"); + const { manifest, clients } = await readJson(args[1]); + const events = await readJson(args[2]); + const result = validateEvidence(manifest, clients, events); + process.stdout.write(`${JSON.stringify(result)}\n`); + return result.status === "qualified-ownership" ? 0 : 2; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).then(code => { process.exitCode = code; }).catch(() => { + process.stderr.write("Hosted lifecycle qualification failed; check the bounded target/evidence inputs.\n"); + process.exitCode = 1; + }); +} diff --git a/scripts/qualify_response_egress.test.mjs b/scripts/qualify_response_egress.test.mjs new file mode 100644 index 00000000..565df13c --- /dev/null +++ b/scripts/qualify_response_egress.test.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validateEvidence, validateTarget } from "./qualify_response_egress.mjs"; + +function fixture() { + const manifest = { + schema: 1, run: "run-1", revision: "a".repeat(40), artifact: "b".repeat(64), + adapter: "cloudflare", sdk: "worker 0.8.5", runtime: { unknown: "provider-unpublished" }, + compatibility: { date: "2026-10-07", flags: [] }, deployment: "isolated-test-deployment", + binary_sha256: "c".repeat(64), + telemetry_complete: true, expected: [{ probe: "normal-1", case: "normal" }], + }; + const clients = [{ probe: "normal-1", case: "normal", action: "read", ended: true, bytes: 3072, status: 200 }]; + const common = { schema: 1, run: manifest.run, revision: manifest.revision, artifact: manifest.artifact, + probe: "normal-1", case: "normal", instance: "instance-1" }; + const events = [ + { ...common, sequence: 0, event: "source-drop" }, + { ...common, sequence: 1, event: "terminal", outcome: "HostHandoff", body_kind: "Application", + bytes_written: 3072, fallback: null }, + { ...common, sequence: 2, event: "resource-drop" }, + { ...common, sequence: 3, event: "trace-closed", source_drops: 1, resource_drops: 1, + terminals: 1, polls: 4, polls_after_terminal: 0 }, + ]; + return { manifest, clients, events }; +} + +test("complete correlated ownership evidence is accepted without claiming client delivery", () => { + const { manifest, clients, events } = fixture(); + assert.equal(validateEvidence(manifest, clients, events).status, "qualified-ownership"); +}); + +test("missing, duplicated, truncated and mismatched telemetry fails closed", () => { + for (const mutate of [ + ({ events }) => events.splice(0, 1), + ({ events }) => events.push({ ...events[1] }), + ({ events }) => events.pop(), + ({ events }) => { events[1].revision = "c".repeat(40); }, + ({ events }) => { events[3].polls_after_terminal = 1; }, + ({ events }) => { events[3].source_drops = 2; }, + ({ manifest }) => { manifest.telemetry_complete = false; }, + ({ clients }) => clients.pop(), + ({ clients }) => { clients[0].action = "nonread"; }, + ({ manifest }) => { manifest.adapter = "invented-target"; }, + ({ manifest }) => { delete manifest.binary_sha256; }, + ({ manifest }) => manifest.expected.push({ probe: "recovery-1", case: "normal" }), + ({ events }) => { events[1].body_kind = "Fallback"; }, + ({ events }) => { events[1].bytes_written = 0; }, + ({ events }) => { events[3].polls = 0; }, + ({ manifest }) => { manifest.runtime = true; }, + ({ manifest }) => { manifest.compatibility = true; }, + ({ manifest }) => { manifest.binary_sha256 = [manifest.binary_sha256]; }, + ({ manifest, events }) => { delete manifest.run; for (const event of events) delete event.run; }, + ({ manifest, events, clients }) => { + delete manifest.expected[0].probe; delete clients[0].probe; + for (const event of events) delete event.probe; + }, + ]) { + const value = fixture(); + mutate(value); + assert.notEqual(validateEvidence(value.manifest, value.clients, value.events).status, "qualified-ownership"); + } +}); + +test("target requires allowlisting, finite budgets and no unexpected private fields", () => { + const { schema, expected, telemetry_complete, ...identity } = fixture().manifest; + const target = { ...identity, origin: "https://isolated.example.com", allowed_origin: "https://isolated.example.com", + isolated_nonproduction: true, max_probe_ms: 2000 }; + assert.equal(validateTarget(target).origin, target.origin); + for (const changed of [ + { ...target, origin: "http://isolated.example.com" }, + { ...target, origin: "https://token@isolated.example.com" }, + { ...target, allowed_origin: "https://another.example.com" }, + { ...target, isolated_nonproduction: false }, + { ...target, max_probe_ms: 0 }, + { ...target, private_token: "do-not-persist" }, + { ...target, adapter: "fastly" }, + ]) assert.throws(() => validateTarget(changed)); +}); + +test("bodyless and failure cases enforce source activity and terminal classifications", () => { + for (const name of ["head", "204", "205", "304", "pending", "expired", "source-error", "conversion-error"]) { + const value = fixture(); + value.manifest.expected[0].case = name; + value.clients[0].case = name; + for (const event of value.events) event.case = name; + const terminal = value.events[1]; + const closed = value.events[3]; + const client = value.clients[0]; + const bodyless = ["head", "204", "205", "304"].includes(name); + terminal.bytes_written = name === "source-error" ? 1024 : 0; + closed.polls = bodyless || ["expired", "conversion-error"].includes(name) ? 0 : 2; + client.bytes = 0; + client.status = bodyless ? (name === "head" ? 200 : Number(name)) : null; + if (!bodyless) { + terminal.outcome = ({pending: "DeadlineExceeded", expired: "DeadlineExceeded", + "source-error": "SourceError", "conversion-error": "ConversionError"})[name]; + client.ended = false; + } + if (["expired", "conversion-error"].includes(name)) { + terminal.body_kind = "Fallback"; + terminal.fallback = "Completed"; + } + assert.equal(validateEvidence(value.manifest, value.clients, value.events).status, "qualified-ownership", name); + terminal.outcome = "InventedOutcome"; + assert.equal(validateEvidence(value.manifest, value.clients, value.events).status, "unverified", name); + } +}); + +test("reordered complete telemetry is accepted; records from another instance are not", () => { + const value = fixture(); + value.events.reverse(); + assert.equal(validateEvidence(value.manifest, value.clients, value.events).status, "qualified-ownership"); + value.events[0].instance = "another-instance"; + assert.notEqual(validateEvidence(value.manifest, value.clients, value.events).status, "qualified-ownership"); +}); diff --git a/scripts/run_native_aws_integration.mjs b/scripts/run_native_aws_integration.mjs new file mode 100755 index 00000000..42c79702 --- /dev/null +++ b/scripts/run_native_aws_integration.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node +// Required CI proof against independent local services, without an AWS account. +import { execFile, spawn } from "node:child_process"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import http from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { fixtureAction } from "./native_aws_test_admin.mjs"; + +const exec = promisify(execFile); +const root = fileURLToPath(new URL("../", import.meta.url)); +// Multi-architecture index digests checked against the upstream registries. +// DynamoDB Local 3.3.1; Moto 5.2.2; AppConfig Agent 2.0.224095. +const images = { + dynamo: "amazon/dynamodb-local@sha256:ff89bd48ff32cd8d9be5fee8873b65b8854dc408f1afe881be6eb00247bc0dab", + moto: "ghcr.io/getmoto/motoserver@sha256:d8ae5edc2bf080e7e4c13f9bd4b29b53ac3b4427e92956318db3dbe23ec43eb7", + agent: "public.ecr.aws/aws-appconfig/aws-appconfig-agent@sha256:cd604064652c56d9d6dc8435aa0d4bf3e90aa348830fa3b8a7b8650e3500ec3e", +}; + +export function publishedEndpoint(address) { + const match = /^127\.0\.0\.1:([0-9]+)$/.exec(address.trim()); + if (!match || Number(match[1]) < 1 || Number(match[1]) > 65535) { + throw new Error(`Expected one exclusively loopback-published port, got: ${address}`); + } + return `http://${address.trim()}`; +} + +async function docker(args, timeout = 30_000) { + const { stdout, stderr } = await exec("docker", args, { + timeout, + killSignal: "SIGKILL", + maxBuffer: 10 * 1024 * 1024, + }); + return (args[0] === "logs" ? `${stdout}${stderr}` : stdout).trim(); +} + +function agentReady(endpoint) { + return new Promise((resolve, reject) => { + // http.request does not discover environment proxies or follow redirects. + const request = http.get( + `${endpoint}/applications/probe/environments/test/configurations/ready`, + { headers: { Authorization: "Bearer local-agent-token" } }, + (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + body += chunk; + if (body.length > 4096) request.destroy(new Error("Oversized readiness response")); + }); + response.on("error", reject); + response.on("end", () => resolve(response.statusCode === 200 && body === "agent-ready\n")); + }, + ); + const timer = setTimeout(() => request.destroy(new Error("Agent readiness timeout")), 3000); + request.on("error", reject); + request.on("close", () => clearTimeout(timer)); + }); +} + +export async function main() { + await docker(["info"], 20_000); + const work = await mkdtemp(join(tmpdir(), "edgezero-aws-test-")); + const prefix = `edgezero-aws-${work.split("/").at(-1)}`; + const network = `${prefix}-network`; + const names = { dynamo: `${prefix}-dynamo`, moto: `${prefix}-moto`, agent: `${prefix}-agent` }; + const configs = join(work, "configs"); + let testProcess; + let testGroup; + let interruptTimer; + let interrupted; + let failure; + const signalTest = (signal) => { + if (testGroup) { + try { process.kill(-testGroup, signal); } catch (error) { + if (error.code !== "ESRCH") throw error; + } + } + }; + const onInterrupt = (signal) => { + interrupted = signal; + signalTest("SIGTERM"); + if (!interruptTimer) interruptTimer = setTimeout(() => signalTest("SIGKILL"), 5000); + }; + const onSigint = () => onInterrupt("SIGINT"); + const onSigterm = () => onInterrupt("SIGTERM"); + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + const checkInterrupted = () => { + if (interrupted) throw new Error(`Interrupted by ${interrupted}`); + }; + + try { + for (const image of Object.values(images)) { + checkInterrupted(); + console.log(`Pulling pinned service ${image}`); + await docker(["pull", image], 180_000); + } + checkInterrupted(); + await mkdir(configs); + await writeFile(join(configs, "probe:test:ready"), "agent-ready\n"); + // A normal bridge supports published ports. It is not an egress-deny sandbox. + await docker(["network", "create", network]); + await docker([ + "run", "-d", "--name", names.dynamo, "--network", network, + "--network-alias", "dynamodb", "--publish", "127.0.0.1::8000", + images.dynamo, "-jar", "DynamoDBLocal.jar", "-inMemory", "-sharedDb", "-disableTelemetry", + ]); + checkInterrupted(); + await docker([ + "run", "-d", "--name", names.moto, "--network", network, + "--publish", "127.0.0.1::5000", images.moto, + ]); + await docker([ + "run", "-d", "--name", names.agent, "--network", network, + "--publish", "127.0.0.1::2772", "--mount", `type=bind,source=${configs},target=/configs,readonly`, + "--env", "LOCAL_DEVELOPMENT_DIRECTORY=/configs", "--env", "SERVICE_REGION=us-east-1", + "--env", "AWS_ACCESS_KEY_ID=DUMMYLOCAL", "--env", "AWS_SECRET_ACCESS_KEY=DUMMYLOCAL", + "--env", "AWS_EC2_METADATA_DISABLED=true", "--env", "ACCESS_TOKEN=local-agent-token", + "--env", "HTTP_HOST=0.0.0.0", "--env", "POLL_INTERVAL=1s", images.agent, + ]); + const endpoints = {}; + for (const [service, port] of [["dynamo", 8000], ["moto", 5000], ["agent", 2772]]) { + endpoints[service] = publishedEndpoint(await docker(["port", names[service], `${port}/tcp`])); + } + const deadline = Date.now() + 60_000; + let ready = false; + let readinessError; + while (Date.now() < deadline) { + checkInterrupted(); + try { + await fixtureAction("ready", {}, endpoints); + ready = await agentReady(endpoints.agent); + if (ready) break; + } catch (error) { readinessError = error; } + await delay(200); + } + if (!ready) throw new Error("Local AWS services did not become ready within 60 seconds", { cause: readinessError }); + checkInterrupted(); + console.log("Pinned local AWS services ready; running generated consumer contracts"); + const env = { + ...process.env, + TMPDIR: work, + EDGEZERO_AWS_TEST_DYNAMODB_ENDPOINT: endpoints.dynamo, + EDGEZERO_AWS_TEST_SECRETS_ENDPOINT: endpoints.moto, + EDGEZERO_AWS_TEST_AGENT_ENDPOINT: endpoints.agent, + EDGEZERO_AWS_TEST_AGENT_DIRECTORY: configs, + EDGEZERO_AWS_TEST_NODE: process.execPath, + EDGEZERO_AWS_TEST_AGENT_TOKEN: "local-agent-token", + }; + // The nonzero runner rejects misspelled filters and accidental cfg exclusion. + await new Promise((resolve, reject) => { + testProcess = spawn(join(root, "scripts/run_test_nonzero.sh"), [ + "--ignored", "generated_native_aws_emulator_contracts", "cargo", "test", + "--offline", "--locked", "-p", "edgezero-cli", "--test", "native_aws_emulator_proof", + ], { cwd: root, env, stdio: "inherit", detached: true }); + testGroup = testProcess.pid; + let timedOut = false; + const timer = setTimeout(() => { timedOut = true; signalTest("SIGKILL"); }, 20 * 60_000); + testProcess.once("error", (error) => { clearTimeout(timer); reject(error); }); + testProcess.once("close", (code, signal) => { + clearTimeout(timer); + testProcess = undefined; + if (code === 0 && !timedOut && !interrupted) resolve(); + else reject(new Error(`Native AWS integration failed: ${timedOut ? "20 minute timeout" : interrupted ?? signal ?? `exit ${code}`}`)); + }); + }); + } catch (error) { + failure = error; + for (const container of Object.values(names)) { + try { console.error(`--- ${container} logs ---\n${await docker(["logs", container], 10_000)}`); } + catch (logError) { console.error(logError.message); } + } + } finally { + clearTimeout(interruptTimer); + signalTest("SIGKILL"); + for (const container of Object.values(names)) { + try { await docker(["rm", "-f", container], 10_000); } + catch (error) { if (!failure) failure = error; } + } + try { await docker(["network", "rm", network], 20_000); } + catch (error) { if (!failure) failure = error; } + await rm(work, { recursive: true, force: true }); + clearTimeout(interruptTimer); + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + } + if (interrupted && !failure) failure = new Error(`Interrupted by ${interrupted} during cleanup`); + if (failure) throw failure; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { console.error(error); process.exitCode = 1; }); +} diff --git a/scripts/run_native_aws_integration.test.mjs b/scripts/run_native_aws_integration.test.mjs new file mode 100644 index 00000000..75b0ace6 --- /dev/null +++ b/scripts/run_native_aws_integration.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { execFile, spawn } from "node:child_process"; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import http from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { publishedEndpoint } from "./run_native_aws_integration.mjs"; + +const exec = promisify(execFile); +const runner = fileURLToPath(new URL("./run_native_aws_integration.mjs", import.meta.url)); + +test("published service ports must be one literal IPv4 loopback address", () => { + assert.equal(publishedEndpoint("127.0.0.1:8000\n"), "http://127.0.0.1:8000"); + for (const address of [ + "0.0.0.0:8000", "localhost:8000", "[::1]:8000", "127.0.0.1:0", + "127.0.0.1:65536", "127.0.0.1:8000\n0.0.0.0:8000", "127.0.0.1:8000/path", "", + ]) { + assert.throws(() => publishedEndpoint(address), /exclusively loopback/); + } +}); + +test("missing Docker fails instead of silently skipping the suite", async () => { + await assert.rejects( + exec(process.execPath, [runner], { env: { PATH: "/nonexistent" }, timeout: 10_000 }), + (error) => error.code !== 0 && error.stderr.includes("ENOENT"), + ); +}); + +async function fixture(t, { + failPull = false, port = 8000, emptySuite = false, hang = false, + suiteCode = 17, slowCleanup = false, +} = {}) { + const directory = await mkdtemp(join(tmpdir(), "edgezero-runner-unit-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const temporary = join(directory, "temporary"); + await mkdir(temporary); + const log = join(directory, "docker.log"); + // These commands only record runner decisions. Real-service coverage lives in Rust. + await writeFile(join(directory, "docker"), `#!/bin/sh +printf '%s\\n' "$*" >> "$MOCK_DOCKER_LOG" +case "$1" in + pull) exit ${failPull ? 1 : 0} ;; + port) printf '127.0.0.1:${port}\\n' ;; + logs) printf 'synthetic-service-log\\n' >&2 ;; + rm) ${slowCleanup ? "printf 'cleanup-start\\n' >> \"$MOCK_DOCKER_LOG\"; sleep 0.2" : ":"} ;; + *) exit 0 ;; +esac +`, { mode: 0o755 }); + await writeFile(join(directory, "cargo"), `#!/bin/sh +case "$*" in + *--list*) ${emptySuite ? ":" : "printf 'generated_native_aws_emulator_contracts: test\\n'"} ;; + *) ${hang ? "mkdir \"$TMPDIR/generated-project\"; printf log > \"$TMPDIR/generated-log\"; printf 'runtime-start\\n' >> \"$MOCK_DOCKER_LOG\"; sleep 60" : `exit ${suiteCode}`} ;; +esac +`, { mode: 0o755 }); + return { + log, temporary, + options: { + env: { ...process.env, PATH: `${directory}:/usr/bin:/bin`, TMPDIR: temporary, MOCK_DOCKER_LOG: log }, + timeout: 15_000, + }, + }; +} + +test("failed pinned image acquisition still cleans containers, network and temp files", async (t) => { + const setup = await fixture(t, { failPull: true }); + await assert.rejects(exec(process.execPath, [runner], setup.options)); + const log = await readFile(setup.log, "utf8"); + assert.match(log, /pull amazon\/dynamodb-local@sha256:[a-f0-9]{64}/); + assert.match(log, /rm -f edgezero-aws-/); + assert.match(log, /network rm edgezero-aws-/); + assert.deepEqual(await readdir(setup.temporary), []); +}); + +async function readyServer(t) { + const server = http.createServer((request, response) => { + request.resume(); + response.end(request.method === "POST" ? "{}" : "agent-ready\n"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + return server.address().port; +} + +test("suite failure is propagated and service stderr logs survive cleanup", async (t) => { + const setup = await fixture(t, { port: await readyServer(t) }); + await assert.rejects(exec(process.execPath, [runner], setup.options), (error) => { + assert.match(error.stderr, /Native AWS integration failed: exit 17/); + assert.match(error.stderr, /synthetic-service-log/); + return true; + }); + const log = await readFile(setup.log, "utf8"); + assert.match(log, /network rm edgezero-aws-/); + assert.doesNotMatch(log, /admin\.py|python|exec /); + assert.deepEqual(await readdir(setup.temporary), []); +}); + +test("a zero-test suite fails the nonzero guard", async (t) => { + const setup = await fixture(t, { port: await readyServer(t), emptySuite: true }); + await assert.rejects(exec(process.execPath, [runner], setup.options), (error) => { + assert.match(error.stderr, /selected test suite contains zero tests/); + return true; + }); + assert.deepEqual(await readdir(setup.temporary), []); +}); + +async function interruptAt(setup, marker) { + const child = spawn(process.execPath, [runner], { env: setup.options.env, stdio: "pipe" }); + child.stdout.resume(); + child.stderr.resume(); + const closed = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => resolve(code)); + }); + const timer = setTimeout(() => child.kill("SIGKILL"), 12_000); + try { + const deadline = Date.now() + 5000; + while (!(await readFile(setup.log, "utf8").catch(() => "")).includes(marker)) { + assert.ok(Date.now() < deadline, `synthetic runner must reach ${marker}`); + await delay(20); + } + child.kill("SIGTERM"); + assert.notEqual(await closed, 0); + assert.match(await readFile(setup.log, "utf8"), /network rm edgezero-aws-/); + assert.deepEqual(await readdir(setup.temporary), []); + } finally { + clearTimeout(timer); + child.kill("SIGKILL"); + await closed; + } +} + +test("interrupting an active suite kills its process group and cleans generated files", async (t) => { + const setup = await fixture(t, { port: await readyServer(t), hang: true }); + await interruptAt(setup, "runtime-start"); +}); + +test("an interrupt during successful teardown cannot produce a green run", async (t) => { + const setup = await fixture(t, { port: await readyServer(t), suiteCode: 0, slowCleanup: true }); + await interruptAt(setup, "cleanup-start"); +}); diff --git a/scripts/smoke_test_reusable_app.sh b/scripts/smoke_test_reusable_app.sh new file mode 100755 index 00000000..0dacc8fa --- /dev/null +++ b/scripts/smoke_test_reusable_app.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec "${CARGO:-cargo}" run --quiet --locked --manifest-path "$REPO_DIR/tests/fixtures/reusable-app/Cargo.toml" -p fixture-harness -- "$@" diff --git a/tests/fixtures/reusable-app/.gitignore b/tests/fixtures/reusable-app/.gitignore new file mode 100644 index 00000000..6008c8bc --- /dev/null +++ b/tests/fixtures/reusable-app/.gitignore @@ -0,0 +1,5 @@ +/target/ +/.runs/ +**/build/ +**/.wrangler/ +**/.spin/ diff --git a/tests/fixtures/reusable-app/Cargo.lock b/tests/fixtures/reusable-app/Cargo.lock new file mode 100644 index 00000000..1058143d --- /dev/null +++ b/tests/fixtures/reusable-app/Cargo.lock @@ -0,0 +1,3450 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "edgezero-adapter-axum" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "axum", + "bytes", + "edgezero-core", + "edgezero-store-aws", + "futures", + "futures-util", + "http", + "http-body", + "hyper", + "hyper-util", + "libc", + "log", + "redb", + "reqwest", + "serde", + "serde_json", + "simple_logger", + "thiserror 2.0.20", + "tokio", + "toml", + "tower", + "tracing", +] + +[[package]] +name = "edgezero-adapter-cloudflare" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "brotli", + "bytes", + "edgezero-core", + "flate2", + "futures", + "futures-util", + "log", + "serde_json", + "wasm-streams", + "web-sys", + "worker", +] + +[[package]] +name = "edgezero-adapter-fastly" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "brotli", + "bytes", + "chrono", + "edgezero-core", + "fastly", + "fastly-shared", + "fastly-sys", + "fern", + "flate2", + "futures", + "futures-util", + "log", + "log-fastly", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", +] + +[[package]] +name = "edgezero-adapter-spin" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "bytes", + "edgezero-core", + "futures", + "futures-util", + "log", + "serde", + "serde_json", + "spin-sdk", + "subtle", + "thiserror 2.0.20", +] + +[[package]] +name = "edgezero-core" +version = "0.1.0" +dependencies = [ + "adler2", + "alloc-no-stdlib", + "alloc-stdlib", + "anyhow", + "async-compression", + "async-stream", + "async-trait", + "brotli", + "brotli-decompressor", + "bytes", + "compression-codecs", + "compression-core", + "crc32fast", + "edgezero-macros", + "flate2", + "futures", + "futures-util", + "http", + "http-body", + "log", + "matchit 0.9.2", + "miniz_oxide", + "ryu", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha2 0.10.9", + "simd-adler32", + "thiserror 2.0.20", + "toml", + "tower-service", + "tracing", + "url", + "validator", + "web-time", +] + +[[package]] +name = "edgezero-macros" +version = "0.1.0" +dependencies = [ + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 3.0.3", + "toml", + "validator", +] + +[[package]] +name = "edgezero-store-aws" +version = "0.1.0" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.20", + "url", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elsa" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9abf33c656a7256451ebb7d0082c5a471820c31269e49d807c538c252352186e" +dependencies = [ + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastly" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5c07c0c67d0b5826d1cb2311e563a17860b50e2ae89e1c703d6400d904d1e2" +dependencies = [ + "anyhow", + "bytes", + "downcast-rs", + "elsa", + "fastly-macros", + "fastly-shared", + "fastly-sys", + "http", + "itertools", + "mime", + "serde", + "serde_json", + "serde_repr", + "serde_urlencoded", + "sha2 0.9.9", + "smallvec", + "thiserror 1.0.69", + "time", + "url", +] + +[[package]] +name = "fastly-macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "698025eab5612adab20df48963a5126650893014e8505af02a0af20939ee6a05" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "fastly-shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "191c2cf082f5d17f4ca0685cedaa0642ad4ade949fac8d1dbdd62ff756b13500" +dependencies = [ + "bitflags 1.3.2", + "http", +] + +[[package]] +name = "fastly-sys" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239c77706cf9110b423a89b6b03b8011729b144390d2a7f641d95ef66b2768b8" +dependencies = [ + "bitflags 1.3.2", + "fastly-shared", + "http", + "wasip2", + "wit-bindgen 0.51.0", +] + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixture-axum" +version = "0.1.0" +dependencies = [ + "anyhow", + "edgezero-adapter-axum", + "edgezero-core", + "fixture-core", +] + +[[package]] +name = "fixture-cloudflare" +version = "0.1.0" +dependencies = [ + "edgezero-adapter-cloudflare", + "edgezero-core", + "fixture-core", + "serde_json", + "worker", +] + +[[package]] +name = "fixture-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "edgezero-core", + "futures", + "log", + "serde_json", +] + +[[package]] +name = "fixture-fastly" +version = "0.1.0" +dependencies = [ + "edgezero-adapter-fastly", + "edgezero-core", + "fastly", + "fixture-core", + "futures", + "log", + "serde_json", +] + +[[package]] +name = "fixture-harness" +version = "0.1.0" +dependencies = [ + "ctrlc", + "serde_json", +] + +[[package]] +name = "fixture-spin" +version = "0.1.0" +dependencies = [ + "anyhow", + "edgezero-adapter-spin", + "edgezero-core", + "fixture-core", + "serde_json", + "spin-sdk", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasip2", + "wasip3 0.4.0+wasi-0.3.0-rc-2026-01-06", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "log-fastly" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433713b6191ad3e1d5248db9536f3e2d99a12d7ff998afd272a98d96e6dbc2ed" +dependencies = [ + "fastly", + "log", + "regex", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "matchit" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redb" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e925444704b5f17d32bf42f5b6e2df050bceebc3dcd6e71cc73dafe8092e839" +dependencies = [ + "libc", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_logger" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7038d0e96661bf9ce647e1a6f6ef6d6f3663f66d9bf741abf14ba4876071c17" +dependencies = [ + "colored", + "log", + "time", + "windows-sys 0.61.2", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin-macro" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac8a641da857d4e7cada31a853c40180eeb10485a0f2f0282d0a7e5a2a4105d4" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "spin-sdk" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b7a3d9a6805ccdb4d404e91ccf7198ac5e486b6199dc9387aeebaa368272112" +dependencies = [ + "anyhow", + "bytes", + "futures", + "http", + "http-body", + "http-body-util", + "spin-macro", + "thiserror 2.0.20", + "wasip3 0.7.1+wasi-0.3.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasip3" +version = "0.7.1+wasi-0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e9ed91ee5a995b6fe16c89d4511a8c12eb76967be20b57557618e02e781bcc" +dependencies = [ + "bytes", + "http", + "http-body", + "thiserror 2.0.20", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.3", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" +dependencies = [ + "leb128fmt", + "wasmparser 0.247.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.247.0", + "wasmparser 0.247.0", +] + +[[package]] +name = "wasm-streams" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7d3be8814f5ba5f074491a469eed3d73c273ffad955f25ed1635efac4b0d269" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wasmparser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.17.1", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "bitflags 2.11.1", + "wit-bindgen-rust-macro 0.51.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags 2.11.1", + "futures", + "wit-bindgen-rust-macro 0.57.1", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.247.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata 0.244.0", + "wit-bindgen-core 0.51.0", + "wit-component 0.244.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5007dae772945b7a5003d69d90a3a4a78929d41f19d004e980c4259a6af4484" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata 0.247.0", + "wit-bindgen-core 0.57.1", + "wit-component 0.247.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core 0.51.0", + "wit-bindgen-rust 0.51.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9237d678e3513ad24e96fe98beacdc0db6405284ba2a2400418cf0d42caa89" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core 0.57.1", + "wit-bindgen-rust 0.57.1", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.244.0", + "wasm-metadata 0.244.0", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-component" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.247.0", + "wasm-metadata 0.247.0", + "wasmparser 0.247.0", + "wit-parser 0.247.0", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ffe4064318cdf3c08cb99343b44c039fcefe61ccdf58aa9975285f13d74d1fc" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.247.0", +] + +[[package]] +name = "worker" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f8adbf6c9ae45b665dee995c5e3a342c2bd7d58a2e8ca5c75b50ce8b1b8bfd9" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-util", + "http", + "http-body", + "js-sys", + "matchit 0.7.3", + "pin-project", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_urlencoded", + "strum 0.27.2", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "worker-macros", + "worker-sys", +] + +[[package]] +name = "worker-macros" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d7389670b55aab89760be149007aeff773cdad7b650d807f7fb556d04bb304" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "strum 0.28.0", + "syn 3.0.3", + "wasm-bindgen", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a440092e7ae7c5feb5507fbdc6aee0cc241d132757c83e1729766a24cddff82" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tests/fixtures/reusable-app/Cargo.toml b/tests/fixtures/reusable-app/Cargo.toml new file mode 100644 index 00000000..3ebfe6f9 --- /dev/null +++ b/tests/fixtures/reusable-app/Cargo.toml @@ -0,0 +1,26 @@ +[workspace] +resolver = "2" +members = ["crates/*"] +default-members = ["crates/fixture-core"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +publish = false + +[workspace.dependencies] +edgezero-core = { path = "../../../crates/edgezero-core", default-features = false } +edgezero-adapter-fastly = { path = "../../../crates/edgezero-adapter-fastly", features = ["fastly"] } +edgezero-adapter-cloudflare = { path = "../../../crates/edgezero-adapter-cloudflare", features = ["cloudflare"] } +edgezero-adapter-spin = { path = "../../../crates/edgezero-adapter-spin", features = ["spin"] } +edgezero-adapter-axum = { path = "../../../crates/edgezero-adapter-axum" } +fixture-core = { path = "crates/fixture-core" } +fastly = "=0.13.1" +worker = { version = "=0.8.5", default-features = false, features = ["http"] } +spin-sdk = { version = "=7.0.0", default-features = false } +futures = "0.3" +serde_json = "1" +log = "0.4" + +[workspace.lints.rust] +unsafe_code = "deny" diff --git a/tests/fixtures/reusable-app/README.md b/tests/fixtures/reusable-app/README.md new file mode 100644 index 00000000..45fca2c0 --- /dev/null +++ b/tests/fixtures/reusable-app/README.md @@ -0,0 +1,342 @@ +# Reusable application lifecycle fixtures + +This standalone Cargo workspace tests provider HTTP behavior without changing +normal generated entry points. Run from the repository root: + +```sh +cargo test --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-harness -p fixture-core +./scripts/smoke_test_reusable_app.sh --adapter fastly --suite smoke +./scripts/smoke_test_reusable_app.sh --adapter cloudflare --suite smoke +./scripts/smoke_test_reusable_app.sh --adapter spin --suite smoke +./scripts/smoke_test_reusable_app.sh --adapter axum --suite smoke +``` + +The driver is the native Rust `fixture-harness` crate. Its HTTP clients, controlled +loopback backend, process management, and evidence checks run outside the WASM +applications. The shell script only launches it through Cargo. + +## Opt-in Hosted Response Qualification + +The default-off `qualification` feature selects a synthetic application through the standard +entrypoints. Use only a designated isolated nonproduction service with complete unsampled logging +at `Info` or above, including the `edgezero_qualification` target. No production stores, traffic, +credentials or log exports should be reused. Existing smoke profiles remain unchanged by default. + +Compile from a clean committed checkout with recorded build identity: + +```sh +export EDGEZERO_QUALIFICATION_REVISION="$(git rev-parse HEAD)" +export EDGEZERO_QUALIFICATION_ARTIFACT="$(shasum -a 256 tests/fixtures/reusable-app/Cargo.lock | awk '{print $1}')" +cargo build --locked --release --manifest-path tests/fixtures/reusable-app/Cargo.toml \ + -p fixture-cloudflare --features qualification --target wasm32-unknown-unknown +cargo build --locked --release --manifest-path tests/fixtures/reusable-app/Cargo.toml \ + -p fixture-fastly --features qualification --bin fixture-fastly-qualification --target wasm32-wasip1 +cargo build --locked --release --manifest-path tests/fixtures/reusable-app/Cargo.toml \ + -p fixture-spin --features qualification --target wasm32-wasip2 +cargo build --locked --release --manifest-path tests/fixtures/reusable-app/Cargo.toml \ + -p fixture-axum --features qualification +``` + +Deploy the selected artifact through the platform's normal isolated-service path. Record the +actual binary SHA-256 and deployment identity after build. `artifact` below is the compiled +lockfile/build-input digest, not the binary hash: a binary cannot embed its own hash. Record an +unavailable runtime revision as a reasoned unknown, not an invented version. + +Prepare a target JSON object using actual recorded identities: + +```json +{ + "origin": "https://isolated-qualification.example.com", + "allowed_origin": "https://isolated-qualification.example.com", + "isolated_nonproduction": true, + "run": "qualification-1", + "adapter": "cloudflare", + "revision": "<40-character committed source SHA>", + "artifact": "<64-character compiled build-input SHA-256>", + "binary_sha256": "<64-character deployed artifact SHA-256>", + "sdk": "worker 0.8.5", + "runtime": { "unknown": "provider-unpublished" }, + "compatibility": { "date": "", "flags": ["enable_request_signal", "enable_weak_ref"] }, + "deployment": "", + "max_probe_ms": 2000 +} +``` + +Other adapters use their actual SDK name/version and a compatibility object with a `flags` +array. Fastly additionally requires `execution_ceiling_ms` (1..60000), enforced independently by +the host: guest checks cannot wake a permanently pending synchronous source. The runner waits +for that ceiling before recovery where necessary. The server response budget is 500 ms; the +client budget must be 1000..10000 ms. Thirteen scenarios plus a healthy request after each run +sequentially, without redirects/retries, with a 64 MiB observed-transfer cap (plus one Node stream +chunk on crossing). No outbound backend is contacted. + +```sh +node scripts/qualify_response_egress.mjs --execute-hosted TARGET.json CLIENTS.json +node scripts/qualify_response_egress.mjs --validate CLIENTS.json EVENTS.json +``` + +Client collection intentionally exits 2: it is not qualification by itself. Export the complete +unsampled events as a JSON array in `EVENTS.json`, preserving all records/sequences and excluding +other runs; independently confirm completeness before setting the recorded manifest's +`telemetry_complete` to true. Missing/duplicate/conflicting records remain unverified. Evidence +files are capped at 4 MiB and output uses private file permissions. The validator does not +authenticate fabricated telemetry or prove the supplied deployment identity. + +Each probe emits source-drop, terminal, resource-drop and final counters for one late-bound +completion resource. `HostHandoff` remains distinct from delivery. Success qualifies ownership +only for the scheduled probes, not full Q4: instance identity is `unobserved`, so a healthy next +request does not prove same-isolate capacity recovery. Actual transport failures, host teardown, +raw parser behavior and provider memory need separate evidence. No capability is promoted by +these tools, local tests or successful compilation. The response-egress specification maintains +the per-target evidence matrix and limitations. + +## Understand the change in five minutes + +Think of the app as the route table plus any state you attach to it. A request +still brings its own headers, body, extensions, and provider resources. + +```text +Existing Fastly entry point: + request 1 → initialize → build app → dispatch → sandbox ends + request 2 → initialize → build app → dispatch → sandbox ends + +Opt-in retained Fastly app, when the host reuses the sandbox: + request 1 → initialize → build app → fresh request setup → dispatch + request 2 → fresh request setup → dispatch + request 3 → fresh request setup → dispatch +``` + +Responses should behave the same. The visible difference is fewer construction +calls, which can reduce latency when initialization is expensive. Reuse is a host +decision: the next request may always need to initialize again. + +Start with these entry points in `crates/fixture-fastly/src/bin/`: + +| Experiment | Source | What survives between callbacks? | +| ---------- | ------------------------ | ---------------------------------- | +| A | `single_request.rs` | Nothing from the previous sandbox | +| B | `rebuild_per_request.rs` | Sandbox globals; app is rebuilt | +| C | `retained_app.rs` | Sandbox globals and the app/router | + +The `custom_` versions use the same three lifetimes with request preparation, +response finalization, adapter-owned streaming, and post-send work. A/B/C remain +short labels in experiment reports only. + +With the pinned Viceroy installed, run a small synthetic comparison: + +```sh +./scripts/smoke_test_reusable_app.sh --adapter fastly --suite benchmark \ + --requests 20 --repetitions 1 --construction-rounds 10000 +``` + +The reported evidence directory contains `events.jsonl` and `summary.json`. +For matched probe responses, inspect `guest.instance`, `guest.ordinal`, +`guest.builds`, and `guest.shared`: + +- A: a new instance, ordinal 1, and one build on every request. +- B: the same instance can have ordinal 2 and two builds. +- C: the same instance can have ordinal 2 while builds stays at one. + +Compare B and C's reused-request latency to see the construction cost being +removed. The extra JSON parsing deliberately makes that cost visible; it is not +a prediction of an application's production performance. Token, path, and body +checks confirm that each request still sees its own data. Shared state equals the +ordinal in retained probe sequences and one in per-request modes. + +For production code, read `serve_app` and `serve_app_with_hooks` in the Fastly +adapter: they retain one successfully configured app and perform fresh request +setup inside each SDK callback. Construction uses +`App::build::(FASTLY_PLATFORM) -> Result`, not an infallible hook builder. +Cloudflare and Spin instead expose `dispatch_app` and let the caller retain a +concrete app. Axum already retains its app. Existing entry points keep their +lifecycle defaults until an application explicitly adopts retention. + +Builds use `$CARGO` (falling back to `cargo`) and pin `CARGO_TARGET_DIR` to this +workspace's `target/`, regardless of external Cargo target-dir settings. Update +this workspace's lockfile alongside the root and app-demo lockfiles when bumping +shared dependencies, and keep its Fastly SDK requirement aligned with the root. + +Select executables with `VICEROY_BIN`, `WORKER_BUILD_BIN`, `WRANGLER_BIN`, and +`SPIN_BIN`. Install tools explicitly; the runner never deploys or installs them. +The PR pins Fastly SDK 0.13.1, Viceroy 0.21.0, `worker` 0.8.5, and `spin-sdk` 7.0.0 +using its `wasip3` re-export; the repository pins Spin CLI 4.1.0. Use a worker-build +version compatible with the locked worker/wasm-bindgen graph rather than assuming +an older builder remains compatible. The runner builds +once with the selected `WORKER_BUILD_BIN`, then starts Wrangler with a snapshot +that disables automatic rebuilding. Axum's executable path is read from Cargo's +artifact output, so configured build targets cannot select a stale default binary. + +The runner binds loopback servers, uses synthetic values, seeds only local stores, +and creates an ignored `.runs/` directory. An explicit `--output` must +be empty to prevent evidence loss. Generated provider artifacts are ignored. Do +not expose these diagnostic applications publicly. Their synthetic request tokens +are fixture identities, not a production request-ID generation scheme. + +Exit 0 means the requested implemented assertions passed; 1 means a build or +assertion failed; 2 means required runtime evidence is unsupported/unverified. +Raw JSONL, runtime logs, and a summary are retained. The summary includes only +matched probe samples, separated into cold/reused cohorts. SDK attempts, guest +completion, and client completion are distinct. Missing final summaries after a +crash are not zero attempted requests. + +## Fastly comparisons + +The provider/application boundary is documented in +[`Custom lifecycle contract`](../../../docs/guide/adapters/fastly.md#custom-lifecycle-contract). +This workspace uses path dependencies on the checkout under test. The Fastly +smoke suite additionally runs a fresh custom guest through health, a handled +initialization failure, another health request, successful initialization, and +retained reuse. The sequence must share one guest to establish recovery; early +guest retirement is reported as unverified. Router-produced finalization metadata +carries each request's token, so finalization also checks response-extension +preservation and isolation rather than merely setting a constant header. + +- A: original single-request helper. +- B: `Serve`, once-only logging, app construction on each callback. +- C: production retained-app helper. +- Custom A/B/C: EdgeZero `lifecycle::run_custom` / `serve_custom` and `Sandbox` + own the serving boundary and successful-only initialization. Per-request mode uses a fresh + state slot per callback; C uses the retained slot. Callbacks return `Result<(), E>` + and own delivery by explicitly sending native replies or invoking + `send_request_with_registries_and_hooks(app, stores, req, &runtime.env, prepare, finalize)`. + The preparation hook mutates the native request and extensions; the routed-response + finalizer runs before egress policy and framing. The adapter owns appended headers, + progressive body writes, and terminal close/abandon without whole-body collection. + Routed finalizer state is available as `Some` only after terminal delivery; + detached admission responses skip finalization and return `None`. Complete + post-send work before returning. Neither wrapper requests a synthetic SDK send; + `run_custom` returns the callback result directly. + +Callbacks that dispatch the app read `runtime_env_config` as a `FastlyRuntimeConfig`, use its +`env` for logger and registry setup, and emits deferred boot diagnostics after +the logging setup decision. Native request bodies, extensions, store handles, +and pending work are not retained with the app. + +```sh +./scripts/smoke_test_reusable_app.sh --adapter fastly --suite benchmark +./scripts/smoke_test_reusable_app.sh --adapter fastly --suite benchmark --construction-rounds 10000 +``` + +Defaults are three repetitions of 100 probes per variant, with randomized order +and a recorded seed. `--construction-rounds` adds deterministic JSON parsing +inside app construction; it models expensive initialization without embedding +application business logic. This synthetic workload cannot predict adoption gains. +Initialization events record wall time and supported SDK CPU/heap observations. +SDK CPU phase readings are not valid cross-run benchmarks. Heap snapshots include +host resources and are rounded MiB values; flat readings do not rule out small +leaks. Latency quantiles use nearest rank and retain sample counts. + +The smoke checks stable guest identity, increasing ordinals, build counts, +request-local values, named/default binding reads, duplicate cookies, ordinary +rendered errors, idle reinitialization, supported request/lifetime/memory limits, +progressive chunks, interrupted streams, finite distinct loopback origins, and +exclusion of the terminated guest after an injected panic. Logging checks run on +B, C, custom C and the negative control. Custom C installs its logger with +`Sandbox::setup_once`, so they also imply setup runs once per guest across +retained callbacks. They derive service-scoped keys from an observed guest service +ID and distinguish `fixture-logs ::` endpoint records from echoed stdout. The +negative control verifies that naively wrapping `run_app` with an enabled global +logger fails on its second installation. + +The interrupted-upstream case waits until the client observes its partial body before +releasing the backend abort, proving physical progressive delivery. The immediate +zero-byte source-failure case instead requires exactly one response-scoped terminal +report (`SourceError`, application body, zero accepted bytes, no fallback) and terminal +guest eviction. The host may reset before its head reaches the client; only EOF/reset +with no observed status is accepted, not timeouts, malformed responses, or replacement +error responses. Neither proof upgrades the documented egress capability guarantees. + +## Runtime and measurement limits + +Raising `--max-requests` does not guarantee a longer guest lifetime. Any observed +Viceroy per-guest ceiling is local evidence, not a deployed guarantee or a promise +that Viceroy 0.21.0 reproduces an older runtime's ceiling. Verify deployed eviction +and long-lived memory behavior separately. The runner checks the Viceroy version against +`.tool-versions` and reports a mismatch as unverified. + +Building with `spin-sdk` 7.0.0 emits the SDK's `wasip3` WASI HTTP interface. A +successful build does not prove the selected Spin CLI 4.1.0 host can execute that +component. Retained dispatch also depends on provider-specific runtime support; +an unavailable host, interface mismatch, or missing HTTP/reuse evidence cannot +count as a passing lifecycle assertion. Do not substitute older SDKs or response +converters to make a provider check appear supported. Build/startup failures remain +failures; tools or runtime evidence the runner explicitly marks unsupported or +unverified remain exit 2. + +Cloudflare and Spin may select different guests during overlap or recovery tests; +those observations are unverified. A success requires the same instance and the +specific injected failure. Overlap checks use each request's `before.inflight` +observation for the tested pair. + +Spin's `/bindings` config check reads a value the same request seeds, so it proves +only that the store label resolves. Cloudflare (`wrangler kv key put --local`) and +Axum (a seeded file) read values written before the request. + +Quantiles summarize successful matched probes only. Health replies and injected +errors are excluded. Each cohort includes per-repetition sample counts and +quantiles so changes between repetitions remain visible. Pooled quantiles alone +are not a variance estimate or evidence of production gains. Rounded memory +snapshots over short guest lifetimes cannot establish absence of small leaks. + +## Failure and measurement evidence + +The smoke suite exercises actual adapter failures with controlled inputs: + +These cases describe fixture coverage, not a recorded pass for the current pinned +SDK/runtime graph. Only correlated evidence from a run of this checkout establishes +an assertion; results from older runtimes do not certify the migrated entry points. + +- Panic inside `MeasuredApp::configure` during `App::build`, followed by a request + in another guest. +- A truncated upstream body used as the inbound body, exercising the canonical + ingress read/error-response path rather than a public conversion helper. +- An erroring core body stream, exercising terminal response-egress failure and + close/abandon without collecting the whole response first. +- A missing required KV selector terminating the SDK loop, with an attempted + request summary. A separate custom policy catches that error and proves + successful bindings and retained app state on the following request. +- Invalid HTTP framing rejected by the host before dispatch, distinguished from + the observed adapter conversion failure. +- A response with headers and partial body followed by stream interruption and a + correlated guest error. Connection failure alone cannot satisfy this check. +- Progressive 256-KiB streams, correlated post-send work, repeated and distinct + loopback origins, and an elapsed lifetime limit during initialization. + +`summary.json` groups observations by variant, repetition, and guest. It reports +initialization phases, SDK attempts, recorded client completion, CPU deltas, +and memory change/peak/slope/last-three-sample plateau over actual ordinals. +Missing phase events remain unknown. Historical event labels such as +`conversion_completed` identify only the instrumented resource lifetime; they +are not evidence of whole-body collection, exact response commitment, or complete +cleanup. Custom callbacks additionally record terminal delivery and guest completion +where instrumented. These are different observation points. A standard request-start sample occurs after +initialization, while the custom callback sample occurs before it. + +Each run saves its lockfile, configuration, compiler/runtime identity, and binary +fingerprints. Built artifacts are copied into `/artifacts/` right after the +build; runtimes serve and fingerprint those copies. Fingerprints identify artifacts; they are not security checksums. +A larger, still bounded observation run can use: + +```sh +./scripts/smoke_test_reusable_app.sh --adapter fastly --suite benchmark \ + --requests 300 --repetitions 1 --max-requests 100 +``` + +`--max-requests` defaults to 10 and accepts 1–1000. Host eviction can shorten any +observed guest lifetime. Rounded MiB snapshots and a short plateau cannot prove +absence of small leaks or bounded growth for an arbitrary application. + +## Evidence requiring a different environment or API + +These are explicitly unverified, not assertions counted as passed: + +| Case | Available evidence / limitation | +| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Optional runtime-store open fails, then recovers in the same guest | Host tests inject degraded then successful configuration snapshots; required-registry recovery is exercised above. Local runtimes cannot make the fixed optional store fail transiently. | +| Spin WASI HTTP interface or provider runtime unavailable | SDK compilation alone does not establish host support or reuse. Preserve build/startup failures and explicitly unsupported/unverified records; no older-component compatibility path is used. | +| `EdgeError` itself fails to render | The renderer builds fixed valid statuses/headers from JSON values; no public input or injection hook triggers a returned rendering error. | +| Unavailable heap hostcall | Only the preflight is implemented. Under a memory limit, a failed heap snapshot makes the SDK stop after the first request. Local runtimes supply the hostcall and cannot disable it. | +| Full standard callback/send/cleanup timing | The public retained helper has no post-send callback; its finalizer runs before delivery and its state is discarded. Instrumented resource observations and SDK summary timing are labeled separately; unavailable phases stay unknown. | +| Deployed eviction, endpoint handles, resource accounting, capacity, and workload gains | Require separately authorized deployment and application telemetry. Local CPU samples are not cross-run CPU benchmarks; finite origin tests do not establish service-wide capacity. | + +No deployment is performed by these fixtures. diff --git a/tests/fixtures/reusable-app/crates/fixture-axum/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-axum/Cargo.toml new file mode 100644 index 00000000..91c3c86d --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-axum/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "fixture-axum" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[features] +qualification = ["fixture-core/qualification"] + +[dependencies] +fixture-core.workspace = true +edgezero-core.workspace = true +edgezero-adapter-axum = { workspace = true, features = ["axum"] } +anyhow = "1" + +[lints] +workspace = true diff --git a/tests/fixtures/reusable-app/crates/fixture-axum/src/main.rs b/tests/fixtures/reusable-app/crates/fixture-axum/src/main.rs new file mode 100644 index 00000000..bbf1deb2 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-axum/src/main.rs @@ -0,0 +1,8 @@ +fn main() -> anyhow::Result<()> { + edgezero_adapter_axum::dev_server::run_app::() +} + +#[cfg(not(feature = "qualification"))] +use fixture_core::FixtureApp as ActiveApp; +#[cfg(feature = "qualification")] +use fixture_core::qualification::QualificationApp as ActiveApp; diff --git a/tests/fixtures/reusable-app/crates/fixture-cloudflare/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-cloudflare/Cargo.toml new file mode 100644 index 00000000..3b3c02cc --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-cloudflare/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "fixture-cloudflare" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[features] +qualification = ["fixture-core/qualification"] + +[dependencies] +serde_json.workspace = true +fixture-core.workspace = true +edgezero-core.workspace = true +edgezero-adapter-cloudflare.workspace = true +worker.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[lints] +workspace = true diff --git a/tests/fixtures/reusable-app/crates/fixture-cloudflare/src/lib.rs b/tests/fixtures/reusable-app/crates/fixture-cloudflare/src/lib.rs new file mode 100644 index 00000000..9d272c7a --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-cloudflare/src/lib.rs @@ -0,0 +1,64 @@ +#![cfg(target_arch = "wasm32")] + +use edgezero_core::app::{App, Hooks}; +#[cfg(not(feature = "qualification"))] +use fixture_core::FixtureApp; +#[cfg(feature = "qualification")] +use fixture_core::qualification::QualificationApp as FixtureApp; +use fixture_core::{MissingBindingApp, OtherApp, retained_app}; +use std::sync::OnceLock; +use worker::{Context, Env, Request, Response, event}; +static APP: OnceLock = OnceLock::new(); +static OTHER_APP: OnceLock = OnceLock::new(); + +fn retained_app_for_dispatch(cell: &OnceLock) -> worker::Result<&App> { + retained_app::(cell, edgezero_adapter_cloudflare::CLOUDFLARE_PLATFORM) + .map_err(|_error| worker::Error::RustError("application configuration failed".to_owned())) +} + +#[event(fetch)] +async fn fetch(req: Request, env: Env, ctx: Context) -> worker::Result { + if !FixtureApp::owns_logging() { + drop(edgezero_adapter_cloudflare::init_logger()); + } + let retained = env + .var("FIXTURE_MODE") + .map(|v| v.to_string() == "retained") + .unwrap_or(false); + if retained && req.path() == "/binding-failure" { + let app = App::build::(edgezero_adapter_cloudflare::CLOUDFLARE_PLATFORM) + .map_err(|_error| { + worker::Error::RustError("application configuration failed".to_owned()) + })?; + let result = + edgezero_adapter_cloudflare::dispatch_app::(&app, req, env, ctx) + .await; + return match result { + Err(error) => Response::from_json(&serde_json::json!({"instance":fixture_core::instance_id(),"source":"injected_required_binding","error":error.to_string()})).map(|r|r.with_status(503)), + Ok(response) => Ok(response), + }; + } + if req.path() == "/other" { + if !retained { + return edgezero_adapter_cloudflare::run_app::(req, env, ctx).await; + } + return edgezero_adapter_cloudflare::dispatch_app::( + retained_app_for_dispatch::(&OTHER_APP)?, + req, + env, + ctx, + ) + .await; + } + if retained { + edgezero_adapter_cloudflare::dispatch_app::( + retained_app_for_dispatch::(&APP)?, + req, + env, + ctx, + ) + .await + } else { + edgezero_adapter_cloudflare::run_app::(req, env, ctx).await + } +} diff --git a/tests/fixtures/reusable-app/crates/fixture-cloudflare/wrangler.toml b/tests/fixtures/reusable-app/crates/fixture-cloudflare/wrangler.toml new file mode 100644 index 00000000..a67631ef --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-cloudflare/wrangler.toml @@ -0,0 +1,16 @@ +name = "reusable-app-fixture" +main = "build/worker/shim.mjs" +compatibility_date = "2026-04-22" +compatibility_flags = ["enable_request_signal", "enable_weak_ref"] +[build] +command = "worker-build --release . -- --locked" +[vars] +FIXTURE_MODE = "retained" +fixture_marker = "fixture-only-value" + +[[kv_namespaces]] +binding = "fixture_config" +id = "00000000000000000000000000000001" +[[kv_namespaces]] +binding = "fixture_kv" +id = "00000000000000000000000000000002" diff --git a/tests/fixtures/reusable-app/crates/fixture-core/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-core/Cargo.toml new file mode 100644 index 00000000..af18ca45 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "fixture-core" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[features] +qualification = [] + +[dependencies] +edgezero-core.workspace = true +futures.workspace = true +serde_json.workspace = true +async-trait = "0.1" +bytes = "1" +log.workspace = true + +[lints] +workspace = true diff --git a/tests/fixtures/reusable-app/crates/fixture-core/src/lib.rs b/tests/fixtures/reusable-app/crates/fixture-core/src/lib.rs new file mode 100644 index 00000000..346eb71b --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-core/src/lib.rs @@ -0,0 +1,437 @@ +#[cfg(feature = "qualification")] +pub mod qualification; + +use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; +use edgezero_core::http::{Method, Response, response_builder}; +use edgezero_core::outbound::OutboundRequest; +use edgezero_core::router::RouterService; +use edgezero_core::{action, body::Body, context::RequestContext, error::EdgeError}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, OnceLock}; + +static INSTANCE: OnceLock = OnceLock::new(); +static BUILDS: AtomicUsize = AtomicUsize::new(0); +static ORDINAL: AtomicUsize = AtomicUsize::new(0); +static INFLIGHT: AtomicUsize = AtomicUsize::new(0); +const MAX_BODY_BYTES: usize = 1024 * 1024; + +pub fn instance_id() -> Option<&'static str> { + INSTANCE.get().map(String::as_str) +} + +pub fn retained_app( + cell: &OnceLock, + platform: edgezero_core::PlatformMetadata, +) -> Result<&App, EdgeError> { + if let Some(app) = cell.get() { + return Ok(app); + } + let app = App::build::(platform)?; + // Keep an already-installed winner; configuration errors never enter the cell. + let _ = cell.set(app); + cell.get() + .ok_or_else(|| EdgeError::internal(std::io::Error::other("application retention failed"))) +} + +/// Carry a fixture-owned lifetime observer through request and response conversion. +#[derive(Clone)] +pub struct ResponseLifetime( + pub Arc, + pub Arc, +); + +/// Request-specific finalization data produced inside the router. +#[derive(Clone)] +pub struct Finalize(pub String); + +#[derive(Clone)] +pub struct Observation { + pub instance: String, + pub ordinal: usize, + pub correlation: String, + pub native_id: bool, +} + +pub fn observe(candidate: &str, correlation: Option<&str>) -> Observation { + Observation { + instance: INSTANCE.get_or_init(|| candidate.to_owned()).clone(), + ordinal: ORDINAL.fetch_add(1, Ordering::SeqCst) + 1, + correlation: correlation.unwrap_or(candidate).to_owned(), + native_id: correlation.is_some(), + } +} + +pub struct FixtureApp; +pub struct OtherApp; + +impl Hooks for FixtureApp { + fn stores() -> StoresMetadata { + StoresMetadata { + config: Some(StoreMetadata { + default: "fixture_config", + ids: &["fixture_config"], + }), + kv: Some(StoreMetadata { + default: "fixture_kv", + ids: &["fixture_kv"], + }), + secrets: Some(StoreMetadata { + default: "fixture_secrets", + ids: &["fixture_secrets"], + }), + } + } + fn configure(app: &mut App) -> Result<(), EdgeError> { + BUILDS.fetch_add(1, Ordering::SeqCst); + app.set_name("fixture"); + Ok(()) + } + fn routes() -> RouterService { + RouterService::builder() + .middleware(ObserveRequest) + .with_state(Arc::new(AtomicUsize::new(0))) + .get("/probe/{id}", probe) + .post("/probe/{id}", probe) + .get("/bindings", bindings) + .get("/cookies", cookies) + .get("/rendered-error", rendered_error) + .get("/stream", backend) + .get("/stream-error", backend) + .get("/overlap/{id}", overlap) + .get("/origin/{id}", backend) + .build() + } +} +/// Swaps only the KV binding metadata to inject a required-binding failure. +/// +/// Build and dispatch this app independently. Its default configuration leaves +/// the healthy fixture's build counter unchanged. +pub struct MissingBindingApp; +impl Hooks for MissingBindingApp { + fn routes() -> RouterService { + FixtureApp::routes() + } + fn stores() -> StoresMetadata { + let mut stores = FixtureApp::stores(); + stores.kv = Some(StoreMetadata { + default: "missing_fixture_kv", + ids: &["missing_fixture_kv"], + }); + stores + } +} +impl Hooks for OtherApp { + fn routes() -> RouterService { + RouterService::builder().get("/other", other).build() + } +} + +#[action] +async fn other(_ctx: RequestContext) -> Result { + Ok("other-app".into()) +} + +async fn record(ctx: &RequestContext) -> Result { + let body = ctx.body_bytes(MAX_BODY_BYTES).await?; + let token = ctx + .headers() + .get("x-request-token") + .and_then(|v| v.to_str().ok()) + .unwrap_or("missing"); + let observation = ctx + .extensions() + .get::() + .cloned() + .ok_or_else(|| { + EdgeError::internal(std::io::Error::other("observation middleware absent")) + })?; + let shared = ctx + .extensions() + .get::>() + .unwrap() + .fetch_add(1, Ordering::SeqCst) + + 1; + Ok(serde_json::json!({ + "instance": observation.instance, "ordinal": observation.ordinal, + "correlation": observation.correlation, "native_id": observation.native_id, + "builds": BUILDS.load(Ordering::SeqCst), + "path": ctx.path_params().get("id"), "token": token, + "mutated": ctx.headers().get("x-fixture-mutated").is_some(), + "body": String::from_utf8_lossy(&body), + "shared": shared, "inflight": INFLIGHT.load(Ordering::SeqCst), + })) +} + +#[action] +async fn probe(ctx: RequestContext) -> Result { + Ok(record(&ctx).await?.to_string()) +} + +#[action] +async fn cookies(_ctx: RequestContext) -> Result { + let mut response = response_builder().body(Body::from("cookies")).unwrap(); + response + .headers_mut() + .append("set-cookie", "first=1; Path=/".parse().unwrap()); + response + .headers_mut() + .append("set-cookie", "second=2; Path=/".parse().unwrap()); + Ok(response) +} + +#[action] +async fn rendered_error(_ctx: RequestContext) -> Result { + Err(EdgeError::bad_request("fixture error")) +} + +/// Fixtures accept only harness-controlled local origins, never arbitrary Internet targets. +pub fn is_loopback_http(uri: &edgezero_core::http::Uri) -> bool { + uri.scheme_str() == Some("http") && matches!(uri.host(), Some("127.0.0.1" | "localhost")) +} + +async fn fetch_backend(ctx: &RequestContext) -> Result { + let uri = ctx + .headers() + .get("x-fixture-backend") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| EdgeError::bad_request("missing fixture backend"))?; + let uri: edgezero_core::http::Uri = uri + .parse() + .map_err(|_| EdgeError::bad_request("invalid backend URI"))?; + if !is_loopback_http(&uri) { + return Err(EdgeError::bad_request("backend must be loopback HTTP")); + } + let mut request = OutboundRequest::new(Method::GET, uri)?; + if matches!(ctx.uri().path(), "/stream" | "/stream-error") { + request = request.stream_response(); + } + ctx.http_client() + .ok_or_else(|| EdgeError::internal(std::io::Error::other("missing HTTP client")))? + .send(request) + .await? + .into_response() +} + +#[action] +async fn backend(ctx: RequestContext) -> Result { + fetch_backend(&ctx).await +} + +struct InFlight; +impl Drop for InFlight { + fn drop(&mut self) { + INFLIGHT.fetch_sub(1, Ordering::SeqCst); + } +} +#[action] +async fn overlap(ctx: RequestContext) -> Result { + INFLIGHT.fetch_add(1, Ordering::SeqCst); + let _guard = InFlight; + let before = record(&ctx).await?; + let _response = fetch_backend(&ctx).await?; + let mut after = record(&ctx).await?; + after["before"] = before; + Ok(after.to_string()) +} + +/// A missing server-side binding is a deployment fault, not a client error. +fn missing_binding(message: &'static str) -> EdgeError { + EdgeError::internal(std::io::Error::other(message)) +} + +#[action] +async fn bindings(ctx: RequestContext) -> Result { + let config = ctx + .config_store("fixture_config") + .ok_or_else(|| missing_binding("config handle absent"))?; + let marker = config.get("marker").await.map_err(EdgeError::internal)?; + let default_marker = ctx + .config_store_default() + .ok_or_else(|| missing_binding("default config handle absent"))? + .get("marker") + .await + .map_err(EdgeError::internal)?; + let kv = ctx + .kv_store("fixture_kv") + .ok_or_else(|| missing_binding("KV handle absent"))?; + kv.put("fixture-marker", &"local-fixture") + .await + .map_err(EdgeError::internal)?; + let kv_marker: Option = ctx + .kv_store_default() + .ok_or_else(|| missing_binding("default kv handle absent"))? + .get("fixture-marker") + .await + .map_err(EdgeError::internal)?; + let secret = ctx + .secret_store("fixture_secrets") + .ok_or_else(|| missing_binding("secret handle absent"))?; + let named_secret = secret + .get_bytes("fixture_marker") + .await + .map_err(EdgeError::internal)?; + let default_secret = ctx + .secret_store_default() + .ok_or_else(|| missing_binding("default secret handle absent"))? + .get_bytes("fixture_marker") + .await + .map_err(EdgeError::internal)?; + Ok(serde_json::json!({ + "config": marker.as_deref() == Some("local-fixture") && marker == default_marker, + "kv": kv_marker.as_deref() == Some("local-fixture"), + "secrets": named_secret.is_some() && named_secret == default_secret, + "unknown": ctx.kv_store("unknown").is_some(), + }) + .to_string()) +} + +struct ObserveRequest; +#[async_trait::async_trait(?Send)] +impl edgezero_core::middleware::Middleware for ObserveRequest { + async fn handle( + &self, + mut ctx: RequestContext, + next: edgezero_core::middleware::Next<'_>, + ) -> Result { + if ctx.extensions().get::().is_none() { + let candidate = ctx + .headers() + .get("x-request-token") + .and_then(|value| value.to_str().ok()) + .unwrap_or("missing"); + let observation = observe(candidate, None); + ctx.extensions_mut().insert(observation); + } + let lifetime = ctx.extensions().get::().cloned(); + let finalization = Finalize( + ctx.headers() + .get("x-request-token") + .and_then(|value| value.to_str().ok()) + .unwrap_or("missing") + .to_owned(), + ); + let mut response = next.run(ctx).await?; + response.extensions_mut().insert(finalization); + if let Some(lifetime) = lifetime { + lifetime.1.store(true, Ordering::SeqCst); + response.extensions_mut().insert(lifetime); + } + Ok(response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::app::Hooks; + use edgezero_core::body::Body; + use edgezero_core::http::request_builder; + use futures::executor::block_on; + + #[test] + fn retained_app_retries_failed_configuration_and_reuses_success() { + static ATTEMPTS: AtomicUsize = AtomicUsize::new(0); + struct RetryApp; + impl Hooks for RetryApp { + fn configure(_app: &mut App) -> Result<(), EdgeError> { + if ATTEMPTS.fetch_add(1, Ordering::SeqCst) == 0 { + Err(EdgeError::service_unavailable( + "injected configuration failure", + )) + } else { + Ok(()) + } + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + let cell = OnceLock::new(); + let platform = edgezero_core::PlatformMetadata::default(); + assert!(retained_app::(&cell, platform).is_err()); + assert!(cell.get().is_none()); + let first = retained_app::(&cell, platform).unwrap(); + let second = retained_app::(&cell, platform).unwrap(); + assert!(std::ptr::eq(first, second)); + assert_eq!(ATTEMPTS.load(Ordering::SeqCst), 2); + } + + #[test] + fn retained_app_returns_an_already_installed_winner() { + static CELL: OnceLock = OnceLock::new(); + struct RacingApp; + impl Hooks for RacingApp { + fn configure(app: &mut App) -> Result<(), EdgeError> { + let winner = App::with_name(RouterService::builder().build(), "winner"); + assert!(CELL.set(winner).is_ok()); + app.set_name("discarded"); + Ok(()) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + let app = + retained_app::(&CELL, edgezero_core::PlatformMetadata::default()).unwrap(); + assert_eq!(app.name(), "winner"); + } + + #[test] + fn probe_accepts_exact_body_bound_and_rejects_overflow() { + let app = App::build::(edgezero_core::PlatformMetadata::default()).unwrap(); + for (size, status) in [(1024 * 1024, 200), (1024 * 1024 + 1, 400)] { + let req = request_builder() + .uri("/probe/bounded") + .header("x-request-token", "bounded") + .body(Body::from(vec![b'x'; size])) + .unwrap(); + let response = block_on(app.router().oneshot(req)).unwrap(); + assert_eq!(response.status().as_u16(), status); + } + } + + #[test] + fn retained_probe_echoes_each_request_and_preserves_cookies() { + let app = App::build::(edgezero_core::PlatformMetadata::default()).unwrap(); + for (index, token) in ["one", "two"].into_iter().enumerate() { + let req = request_builder() + .uri(format!("/probe/{token}")) + .header("x-request-token", token) + .body(Body::from(token)) + .unwrap(); + let response = block_on(app.router().oneshot(req)).unwrap(); + let value: serde_json::Value = + serde_json::from_slice(response.body().as_bytes().unwrap()).unwrap(); + assert_eq!(value["token"], token); + assert_eq!(value["path"], token); + assert_eq!(value["body"], token); + assert_eq!(value["shared"], index + 1); + } + let req = request_builder() + .uri("/cookies") + .body(Body::empty()) + .unwrap(); + let response = block_on(app.router().oneshot(req)).unwrap(); + assert_eq!(response.headers().get_all("set-cookie").iter().count(), 2); + } + + #[test] + fn rejects_remote_origins_and_keeps_concrete_apps_separate() { + let app = App::build::(edgezero_core::PlatformMetadata::default()).unwrap(); + let other = App::build::(edgezero_core::PlatformMetadata::default()).unwrap(); + let req = request_builder() + .uri("/origin/x") + .header("x-fixture-backend", "https://example.com/") + .body(Body::empty()) + .unwrap(); + let response = block_on(app.router().oneshot(req)).unwrap(); + assert_eq!(response.status().as_u16(), 400); + let req = request_builder().uri("/other").body(Body::empty()).unwrap(); + let response = block_on(other.router().oneshot(req)).unwrap(); + assert_eq!(response.body().as_bytes().unwrap(), b"other-app"); + } +} diff --git a/tests/fixtures/reusable-app/crates/fixture-core/src/qualification.rs b/tests/fixtures/reusable-app/crates/fixture-core/src/qualification.rs new file mode 100644 index 00000000..68e0c105 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-core/src/qualification.rs @@ -0,0 +1,329 @@ +//! Opt-in, synthetic hosted probes through standard application/adaptor lifecycles. + +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use bytes::Bytes; +use edgezero_core::app::{App, Hooks}; +use edgezero_core::context::RequestContext; +use edgezero_core::http::{Method, Response, StatusCode, response_builder}; +use edgezero_core::ingress::{AdmissionDecision, IngressGrant}; +use edgezero_core::response_egress::{ + ResponseEgressCompletion, ResponseEgressDeadline, ResponseEgressResource, +}; +use edgezero_core::router::RouterService; +use edgezero_core::{Body, EdgeError, action}; +use futures::Stream; +use serde_json::{Value, json}; + +const CASES: &[&str] = &[ + "normal", + "head", + "204", + "205", + "304", + "slow", + "nonread", + "disconnect", + "cancel", + "pending", + "expired", + "source-error", + "conversion-error", +]; + +pub struct QualificationApp; + +#[derive(Default)] +struct Counters { + polls: u64, + polls_after_terminal: u64, + resource_drops: u64, + sequence: u32, + source_drops: u64, + terminals: u64, +} + +struct ProbeTrace { + artifact: String, + case: String, + counters: Mutex, + probe: String, + revision: String, + run: String, +} + +struct ResourceDropProbe(Arc); + +struct ProbeGrant { + resource: ResponseEgressResource, + trace: Arc, +} + +struct ProbeSource { + chunk: Bytes, + remaining: u32, + trace: Arc, +} + +impl Hooks for QualificationApp { + fn configure(app: &mut App) -> Result<(), EdgeError> { + let revision = option_env!("EDGEZERO_QUALIFICATION_REVISION").unwrap_or(""); + let artifact = option_env!("EDGEZERO_QUALIFICATION_ARTIFACT").unwrap_or(""); + if !hex_identity(revision, 40) || !hex_identity(artifact, 64) { + return Err(EdgeError::internal(std::io::Error::other( + "qualification build identity is absent", + ))); + } + app.set_name("lifecycle-qualification"); + app.set_ingress_admission_policy(move |head| { + let token = |name| head.headers().get(name).and_then(|value| value.to_str().ok()) + .filter(|value| valid_token(value)).map(str::to_owned); + let case = head.target().path().strip_prefix("/qualification/") + .filter(|name| CASES.contains(name)); + let (Some(run), Some(probe), Some(case)) = (token("x-qualification-run"), token("x-qualification-probe"), case) else { + let mut response = Response::new(Body::from("invalid qualification probe\n")); + *response.status_mut() = StatusCode::BAD_REQUEST; + return AdmissionDecision::Refuse { completion: ResponseEgressCompletion::empty(), + response }; + }; + let trace = Arc::new(ProbeTrace { artifact: artifact.to_owned(), case: case.to_owned(), + counters: Mutex::new(Counters::default()), probe, revision: revision.to_owned(), run }); + let (resource, ownership) = ResponseEgressCompletion::late_bound(); + let terminal_trace = Arc::clone(&trace); + let terminal = ResponseEgressCompletion::new(move |report| { + terminal_trace.record("terminal", |state| state.terminals += 1, json!({ + "outcome": format!("{:?}", report.outcome), "body_kind": format!("{:?}", report.body_kind), + "bytes_written": report.bytes_written, + "fallback": report.fallback_disposition.map(|value| format!("{value:?}")), + })); + }); + AdmissionDecision::Admit { + completion: terminal.join(ownership), grant: IngressGrant::new(ProbeGrant { resource, trace }), + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + } + }); + Ok(()) + } + + fn routes() -> RouterService { + RouterService::builder() + .get("/qualification/{case}", response_probe) + .route("/qualification/{case}", Method::HEAD, response_probe) + .build() + } +} + +impl ProbeTrace { + fn event(&self, kind: &str, sequence: u32, fields: Value) -> Value { + let mut event = json!({ "schema": 1, "run": self.run, "probe": self.probe, "case": self.case, + "revision": self.revision, "artifact": self.artifact, "instance": "unobserved", + "event": kind, "sequence": sequence }); + if let (Some(object), Some(fields)) = (event.as_object_mut(), fields.as_object()) { + object.extend( + fields + .iter() + .map(|(name, value)| (name.clone(), value.clone())), + ); + } + event + } + + fn record(&self, kind: &str, update: impl FnOnce(&mut Counters), fields: Value) { + let mut state = self + .counters + .lock() + .unwrap_or_else(|error| error.into_inner()); + update(&mut state); + let event = self.event(kind, state.sequence, fields); + state.sequence += 1; + log::info!(target: "edgezero_qualification", "{event}"); + } +} + +impl Drop for ProbeTrace { + fn drop(&mut self) { + let state = self + .counters + .lock() + .unwrap_or_else(|error| error.into_inner()); + let event = self.event( + "trace-closed", + state.sequence, + json!({ "terminals": state.terminals, + "source_drops": state.source_drops, "resource_drops": state.resource_drops, + "polls": state.polls, "polls_after_terminal": state.polls_after_terminal }), + ); + log::info!(target: "edgezero_qualification", "{event}"); + } +} + +impl Drop for ResourceDropProbe { + fn drop(&mut self) { + self.0.record( + "resource-drop", + |state| state.resource_drops += 1, + json!({}), + ); + } +} + +impl ProbeSource { + fn new(trace: Arc) -> Self { + let large = matches!( + trace.case.as_str(), + "slow" | "nonread" | "disconnect" | "cancel" + ); + Self { + chunk: Bytes::from(vec![b'x'; if large { 16 * 1024 } else { 1024 }]), + remaining: if large { 512 } else { 3 }, + trace, + } + } +} + +impl Stream for ProbeSource { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + { + let mut state = self + .trace + .counters + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.polls += 1; + if state.terminals != 0 { + state.polls_after_terminal += 1; + } + } + if self.trace.case == "pending" { + return Poll::Pending; + } + if self.remaining == 0 { + return Poll::Ready(None); + } + if self.trace.case == "source-error" && self.remaining == 2 { + self.remaining = 0; + return Poll::Ready(Some(Err(EdgeError::bad_gateway( + "synthetic qualification source error", + )))); + } + self.remaining -= 1; + Poll::Ready(Some(Ok(self.chunk.clone()))) + } +} + +impl Drop for ProbeSource { + fn drop(&mut self) { + self.trace + .record("source-drop", |state| state.source_drops += 1, json!({})); + } +} + +fn hex_identity(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +fn valid_token(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +#[action] +async fn response_probe(ctx: RequestContext) -> Result { + let grant = ctx + .take_ingress_grant() + .and_then(|grant| grant.downcast::().ok()) + .ok_or_else(|| { + EdgeError::internal(std::io::Error::other("qualification grant is absent")) + })?; + grant + .resource + .install(ResourceDropProbe(Arc::clone(&grant.trace))) + .map_err(|_error| { + EdgeError::internal(std::io::Error::other("qualification resource is closed")) + })?; + let case = grant.trace.case.clone(); + let status = match case.as_str() { + "204" => StatusCode::NO_CONTENT, + "205" => StatusCode::RESET_CONTENT, + "304" => StatusCode::NOT_MODIFIED, + _ => StatusCode::OK, + }; + let mut response = response_builder() + .status(status) + .header("content-type", "application/octet-stream") + .body(Body::from_stream(ProbeSource::new(grant.trace))) + .map_err(EdgeError::internal)?; + if case == "conversion-error" { + response.headers_mut().insert( + "content-length", + "not-a-length".parse().map_err(EdgeError::internal)?, + ); + } + response + .extensions_mut() + .insert(ResponseEgressDeadline::after(if case == "expired" { + Duration::ZERO + } else { + Duration::from_millis(500) + })); + Ok(response) +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::StreamExt as _; + + #[test] + fn finite_source_releases_once_and_never_polls_after_terminal() { + let trace = test_trace("normal"); + let mut source = ProbeSource::new(Arc::clone(&trace)); + let mut bytes = 0; + futures::executor::block_on(async { + while let Some(chunk) = source.next().await { + bytes += chunk.expect("source chunk").len(); + } + }); + assert_eq!(bytes, 3072); + drop(source); + let counters = trace.counters.lock().expect("counters"); + assert_eq!(counters.source_drops, 1); + assert_eq!(counters.polls, 4); + assert_eq!(counters.polls_after_terminal, 0); + } + + #[test] + fn pending_source_is_pending_and_source_failure_is_real() { + let trace = test_trace("pending"); + let mut source = Box::pin(ProbeSource::new(trace)); + let mut cx = Context::from_waker(futures::task::noop_waker_ref()); + assert!(source.as_mut().poll_next(&mut cx).is_pending()); + let trace = test_trace("source-error"); + let source = ProbeSource::new(trace); + let chunks = futures::executor::block_on(source.collect::>()); + assert_eq!(chunks.len(), 2); + assert!(chunks.last().expect("source error").is_err()); + } + + fn test_trace(case: &str) -> Arc { + Arc::new(ProbeTrace { + artifact: "b".repeat(64), + case: case.to_owned(), + counters: Mutex::new(Counters::default()), + probe: "test-1".to_owned(), + revision: "a".repeat(40), + run: "test-run".to_owned(), + }) + } +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-fastly/Cargo.toml new file mode 100644 index 00000000..34e42b28 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/Cargo.toml @@ -0,0 +1,69 @@ +[package] +name = "fixture-fastly" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[features] +qualification = ["fixture-core/qualification"] + +[dependencies] +edgezero-core.workspace = true +edgezero-adapter-fastly.workspace = true +fixture-core.workspace = true +fastly.workspace = true +futures.workspace = true +serde_json.workspace = true +log.workspace = true + +[[bin]] +name = "fixture-fastly-single-request" +path = "src/bin/single_request.rs" + +[[bin]] +name = "fixture-fastly-qualification" +path = "src/bin/qualification.rs" +required-features = ["qualification"] + +[[bin]] +name = "fixture-fastly-rebuild-per-request" +path = "src/bin/rebuild_per_request.rs" + +[[bin]] +name = "fixture-fastly-retained-app" +path = "src/bin/retained_app.rs" + +[[bin]] +name = "fixture-fastly-custom-single-request" +path = "src/bin/custom_single_request.rs" + +[[bin]] +name = "fixture-fastly-custom-rebuild-per-request" +path = "src/bin/custom_rebuild_per_request.rs" + +[[bin]] +name = "fixture-fastly-custom-retained-app" +path = "src/bin/custom_retained_app.rs" + +[[bin]] +name = "fixture-fastly-logger-negative" +path = "src/bin/logger_negative.rs" + +[[bin]] +name = "fixture-fastly-limit-requests" +path = "src/bin/limit_requests.rs" + +[[bin]] +name = "fixture-fastly-limit-lifetime" +path = "src/bin/limit_lifetime.rs" + +[[bin]] +name = "fixture-fastly-limit-memory" +path = "src/bin/limit_memory.rs" + +[[bin]] +name = "fixture-fastly-faults" +path = "src/bin/faults.rs" + +[lints] +workspace = true diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/fastly.toml b/tests/fixtures/reusable-app/crates/fixture-fastly/fastly.toml new file mode 100644 index 00000000..7165a154 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/fastly.toml @@ -0,0 +1,18 @@ +manifest_version = 3 +name = "reusable-app-fixture" +description = "Local lifecycle verification" +language = "rust" +[local_server] +[local_server.backends] + +[local_server.config_stores.fixture_config] +format = "inline-toml" +[local_server.config_stores.fixture_config.contents] +marker = "local-fixture" +[[local_server.kv_stores.fixture_kv]] +key = "__init__" +data = "" + +[[local_server.secret_stores.fixture_secrets]] +key = "fixture_marker" +data = "fixture-only-value" diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_rebuild_per_request.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_rebuild_per_request.rs new file mode 100644 index 00000000..068345d7 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_rebuild_per_request.rs @@ -0,0 +1,3 @@ +fn main() -> Result<(), fastly::Error> { + fixture_fastly::custom(true, false) +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_retained_app.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_retained_app.rs new file mode 100644 index 00000000..79745c9e --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_retained_app.rs @@ -0,0 +1,3 @@ +fn main() -> Result<(), fastly::Error> { + fixture_fastly::custom(true, true) +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_single_request.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_single_request.rs new file mode 100644 index 00000000..6fad8ff8 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_single_request.rs @@ -0,0 +1,3 @@ +fn main() -> Result<(), fastly::Error> { + fixture_fastly::custom(false, false) +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/faults.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/faults.rs new file mode 100644 index 00000000..d39c4030 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/faults.rs @@ -0,0 +1,3 @@ +fn main() -> Result<(), fastly::Error> { + fixture_fastly::faults() +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_lifetime.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_lifetime.rs new file mode 100644 index 00000000..1d4f037f --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_lifetime.rs @@ -0,0 +1,34 @@ +use edgezero_core::app::{App, Hooks, StoresMetadata}; +use edgezero_core::error::EdgeError; +use edgezero_core::router::RouterService; +use std::time::Duration; + +struct SlowInitialization; +impl Hooks for SlowInitialization { + fn routes() -> RouterService { + fixture_fastly::MeasuredApp::routes() + } + fn stores() -> StoresMetadata { + fixture_fastly::MeasuredApp::stores() + } + fn configure(app: &mut App) -> Result<(), EdgeError> { + // A real elapsed deadline, not only the SDK's zero-limit boundary. + std::thread::sleep(Duration::from_millis(100)); + fixture_fastly::MeasuredApp::configure(app) + } +} +fn main() -> Result<(), fastly::Error> { + let summary = edgezero_adapter_fastly::serve_app_with_hooks::( + fixture_fastly::serving().with_max_lifetime(Duration::from_millis(50)), + fixture_fastly::extend, + |_response| (), + ); + let elapsed = summary.time_handler() >= Duration::from_millis(50); + fixture_fastly::finish(summary)?; + if !elapsed { + return Err(fastly::Error::msg( + "lifetime fixture did not reach its elapsed bound", + )); + } + Ok(()) +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_memory.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_memory.rs new file mode 100644 index 00000000..4eaedba6 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_memory.rs @@ -0,0 +1,19 @@ +fn main() -> Result<(), fastly::Error> { + fastly::init(); + println!( + "{}", + serde_json::json!({"event":"heap_preflight", "heap_mib":fastly::compute_runtime::heap_memory_snapshot_mib().ok()}) + ); + // A one-MiB threshold is below this fixture's observed baseline. If the heap + // snapshot fails, the SDK treats usage as u32::MAX and also stops after one request. + fixture_fastly::finish(edgezero_adapter_fastly::serve_app_with_hooks::< + fixture_fastly::MeasuredApp, + _, + _, + _, + >( + fixture_fastly::serving().with_max_memory(1), + fixture_fastly::extend, + |_response| (), + )) +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_requests.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_requests.rs new file mode 100644 index 00000000..c15c294f --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_requests.rs @@ -0,0 +1,9 @@ +fn main() -> Result<(), fastly::Error> { + let serve = fixture_fastly::serving().with_max_requests(1); + fixture_fastly::finish(edgezero_adapter_fastly::serve_app_with_hooks::< + fixture_fastly::MeasuredApp, + _, + _, + _, + >(serve, fixture_fastly::extend, |_response| ())) +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/logger_negative.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/logger_negative.rs new file mode 100644 index 00000000..a648d299 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/logger_negative.rs @@ -0,0 +1,3 @@ +fn main() -> Result<(), fastly::Error> { + fixture_fastly::logger_negative() +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/qualification.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/qualification.rs new file mode 100644 index 00000000..dbbef3ec --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/qualification.rs @@ -0,0 +1,6 @@ +fn main() -> Result<(), fastly::Error> { + edgezero_adapter_fastly::serve_app::( + edgezero_adapter_fastly::Serve::new(), + ) + .into_result() +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/rebuild_per_request.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/rebuild_per_request.rs new file mode 100644 index 00000000..f52a3f10 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/rebuild_per_request.rs @@ -0,0 +1,3 @@ +fn main() -> Result<(), fastly::Error> { + fixture_fastly::rebuilt() +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/retained_app.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/retained_app.rs new file mode 100644 index 00000000..3bbee3ab --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/retained_app.rs @@ -0,0 +1,12 @@ +fn main() -> Result<(), fastly::Error> { + fixture_fastly::finish(edgezero_adapter_fastly::serve_app_with_hooks::< + fixture_fastly::MeasuredApp, + _, + _, + _, + >( + fixture_fastly::serving(), + fixture_fastly::extend, + |_response| (), + )) +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/single_request.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/single_request.rs new file mode 100644 index 00000000..b8748dda --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/single_request.rs @@ -0,0 +1,7 @@ +fn main() -> Result<(), fastly::Error> { + edgezero_adapter_fastly::run_app_with_hooks::( + fixture_fastly::extend, + |_response| (), + ) + .map(|_state| ()) +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/lib.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/lib.rs new file mode 100644 index 00000000..f2f4ea29 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/lib.rs @@ -0,0 +1,485 @@ +use edgezero_adapter_fastly::lifecycle::{Sandbox, serve_custom}; +use edgezero_adapter_fastly::outbound::FastlyOutboundClient; +use edgezero_adapter_fastly::request::send_request_with_registries_and_hooks; +use edgezero_adapter_fastly::{ + FASTLY_PLATFORM, FastlyLogging, Serve, ServeSummary, init_logger, runtime_env_config, +}; +use edgezero_core::app::{App, Hooks}; +use edgezero_core::body::Body; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{Extensions, Response as CoreResponse, StatusCode, Uri}; +use edgezero_core::outbound::{HttpClient, OutboundRequest}; +use edgezero_core::{ + AdmissionDecision, DEFAULT_INBOUND_READ_BUDGET, IngressGrant, ResponseEgressCompletion, +}; +use fastly::{Error, Request, Response}; +use fixture_core::{FixtureApp, Observation, observe}; +use futures::executor::block_on; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +static INITIALIZATIONS: Mutex> = Mutex::new(Vec::new()); +static PANIC_IN_CONSTRUCTOR: AtomicBool = AtomicBool::new(false); + +fn lifecycle(event: &str, observation: &Observation, token: &str) { + println!( + "{}", + serde_json::json!({"event":event,"instance":observation.instance,"ordinal":observation.ordinal,"token":token,"cpu_ms":fastly::compute_runtime::elapsed_vcpu_ms().ok(),"heap_mib":fastly::compute_runtime::heap_memory_snapshot_mib().ok()}) + ); +} +fn initialized(observation: &Observation, token: &str) { + for mut event in INITIALIZATIONS.lock().unwrap().drain(..) { + event["instance"] = observation.instance.clone().into(); + event["ordinal"] = observation.ordinal.into(); + event["token"] = token.into(); + println!("{event}"); + } +} +struct ConversionCompletion { + armed: Arc, + observation: Observation, + token: String, +} +impl Drop for ConversionCompletion { + fn drop(&mut self) { + if self.armed.load(Ordering::SeqCst) { + lifecycle("conversion_completed", &self.observation, &self.token); + } + } +} + +pub fn serving() -> Serve { + Serve::new() + .with_max_requests( + option_env!("FIXTURE_MAX_REQUESTS") + .unwrap_or("10") + .parse() + .unwrap(), + ) + .with_timeout(Duration::from_millis(500)) +} +pub fn observation(req: &Request) -> Observation { + let token = req.get_header_str("x-request-token").unwrap_or("missing"); + let value = observe(token, req.get_client_request_id()); + println!( + "{}", + serde_json::json!({"event":"request_start", "instance":value.instance,"token":token, + "service_id":fastly::compute_runtime::service_id(),"ordinal":value.ordinal,"correlation":value.correlation,"native_id":value.native_id, + "cpu_ms":fastly::compute_runtime::elapsed_vcpu_ms().ok(), + "heap_mib":fastly::compute_runtime::heap_memory_snapshot_mib().ok()}) + ); + value +} +pub fn extend(req: &mut Request, extensions: &mut Extensions) { + let value = observation(req); + let token = req.get_header_str("x-request-token").unwrap_or("missing"); + initialized(&value, token); + let armed = Arc::new(AtomicBool::new(false)); + extensions.insert(fixture_core::ResponseLifetime( + Arc::new(ConversionCompletion { + armed: armed.clone(), + observation: value.clone(), + token: token.to_owned(), + }), + armed, + )); + log::info!("fixture request {}", value.correlation); + extensions.insert(value); +} +pub fn finish(summary: ServeSummary) -> Result<(), Error> { + println!( + "{}", + serde_json::json!({"event":"sdk_summary", "instance":fixture_core::instance_id(), "attempted":summary.requests(), + "handler_wall_ns":summary.time_handler().as_nanos(), "wait_wall_ns":summary.time_waited().as_nanos()}) + ); + summary.into_result() +} +fn install_logger(env: &edgezero_core::env_config::EnvConfig) -> Result<(), Error> { + let logging = FastlyLogging::from(env); + if logging.use_fastly_logger { + init_logger( + logging.endpoint.as_deref().unwrap(), + logging.level, + logging.echo_stdout, + )?; + } + Ok(()) +} +fn post_commit_error(obs: &Observation, token: &str, error: &dyn std::fmt::Display) { + println!( + "{}", + serde_json::json!({"event":"post_commit_error","instance":obs.instance,"ordinal":obs.ordinal,"token":token,"error":error.to_string()}) + ); +} +pub fn rebuilt() -> Result<(), Error> { + finish(serve_custom(serving(), |req, setup: &mut Sandbox<()>| { + let stores = MeasuredApp::stores(); + let mut runtime = runtime_env_config(stores); + setup.setup_once(|| install_logger(&runtime.env))?; + runtime.emit_boot_diagnostics(); + let app = App::build::(FASTLY_PLATFORM)?; + send_request_with_registries_and_hooks( + &app, + stores, + req, + &runtime.env, + extend, + |_response| (), + ) + .map(|_state| ()) + })) +} + +pub fn logger_negative() -> Result<(), Error> { + finish(serve_custom(serving(), |req, _state: &mut Sandbox<()>| { + let obs = observation(&req); + let stores = MeasuredApp::stores(); + let mut runtime = runtime_env_config(stores); + if let Err(error) = install_logger(&runtime.env) { + Response::from_status(500) + .with_body(error.to_string()) + .send_to_client(); + return Err(error); + } + runtime.emit_boot_diagnostics(); + let app = App::build::(FASTLY_PLATFORM)?; + initialized( + &obs, + req.get_header_str("x-request-token").unwrap_or("missing"), + ); + send_request_with_registries_and_hooks( + &app, + stores, + req, + &runtime.env, + |_, extensions| { + extensions.insert(obs); + }, + |_response| (), + ) + .map(|_state| ()) + })) +} + +fn finalize_response(response: &mut CoreResponse, attempts: u64) { + if let Some(fixture_core::Finalize(value)) = + response.extensions_mut().remove::() + { + response + .headers_mut() + .append("x-fixture-finalized", value.parse().unwrap()); + } + response + .headers_mut() + .insert("x-fixture-attempts", attempts.to_string().parse().unwrap()); +} + +fn custom_dispatch(req: Request, sandbox: &mut Sandbox, reuse_app: bool) -> Result<(), Error> { + let token = req + .get_header_str("x-request-token") + .unwrap_or("missing") + .to_owned(); + let obs = observation(&req); + assert_eq!(sandbox.requests(), obs.ordinal as u64); + if req.get_path() == "/panic" { + panic!("injected callback panic"); + } + if req.get_path() == "/health" { + Response::from_status(200) + .with_header( + "x-fixture-attempts", + sandbox.initialization_attempts().to_string(), + ) + .with_body_json(&serde_json::json!({"instance":obs.instance, + "ordinal":obs.ordinal,"retained":sandbox.state().is_some()}))? + .send_to_client(); + return Ok(()); + } + let stores = MeasuredApp::stores(); + let mut runtime = runtime_env_config(stores); + if let Err(error) = sandbox.setup_once(|| install_logger(&runtime.env)) { + eprintln!("logger setup failed: {error}"); + Response::from_status(503).send_to_client(); + return Ok(()); + } + runtime.emit_boot_diagnostics(); + log::info!("fixture request {}", obs.correlation); + // Only the streaming route schedules post-send work. + let post_send = req + .get_header_str("x-fixture-backend") + .filter(|uri| uri.contains("/chunks")) + .map(|uri| uri.replace("/chunks", "/post-send")); + // Per-request mode scopes application state to the current callback. + let mut per_request = Sandbox::default(); + let state = if reuse_app { sandbox } else { &mut per_request }; + let result = state.initialize(|| { + if req.get_path() == "/initialization-error" { + Err(Error::msg("injected initialization failure")) + } else { + App::build::(FASTLY_PLATFORM).map_err(Error::from) + } + }); + if result.is_err() { + Response::from_status(503) + .with_header( + "x-fixture-attempts", + state.initialization_attempts().to_string(), + ) + .with_body_json(&serde_json::json!({"instance":obs.instance, + "ordinal":obs.ordinal,"retained":state.state().is_some()}))? + .send_to_client(); + return Ok(()); + } + initialized(&obs, &token); + let app = state.state().unwrap(); + let attempts = state.initialization_attempts(); + let delivered = send_request_with_registries_and_hooks( + app, + stores, + req, + &runtime.env, + |raw, extensions| { + raw.set_header("x-fixture-mutated", "true"); + extensions.insert(obs.clone()); + }, + |response| finalize_response(response, attempts), + ); + match delivered { + Err(error) => { + post_commit_error(&obs, &token, &error); + lifecycle("guest_completed", &obs, &token); + return Ok(()); + } + Ok(None) => { + lifecycle("guest_completed", &obs, &token); + return Ok(()); + } + Ok(Some(())) => lifecycle("delivery_completed", &obs, &token), + } + if let Some(uri) = post_send { + let outcome = block_on(async { + let uri: Uri = uri.parse().map_err(EdgeError::internal)?; + if !fixture_core::is_loopback_http(&uri) { + return Err(EdgeError::bad_request("backend must be loopback HTTP")); + } + let client = + HttpClient::with_client(FastlyOutboundClient::with_clock(app.monotonic_clock())); + client + .send(OutboundRequest::get(uri.to_string())?.stream_response()) + .await? + .into_bytes_bounded(1024 * 1024) + .await?; + Ok::<(), EdgeError>(()) + }); + if let Err(error) = outcome { + post_commit_error(&obs, &token, &error); + } + } + println!( + "{}", + serde_json::json!({"event":"guest_completed", "instance":obs.instance,"token":token, + "ordinal":obs.ordinal,"cpu_ms":fastly::compute_runtime::elapsed_vcpu_ms().ok(), + "heap_mib":fastly::compute_runtime::heap_memory_snapshot_mib().ok()}) + ); + Ok(()) +} +pub fn custom(reuse_sandbox: bool, reuse_app: bool) -> Result<(), Error> { + if reuse_sandbox { + finish(edgezero_adapter_fastly::lifecycle::serve_custom( + serving(), + move |req, state| custom_dispatch(req, state, reuse_app), + )) + } else { + fastly::init(); + edgezero_adapter_fastly::lifecycle::run_custom(Request::from_client(), |req, state| { + custom_dispatch(req, state, false) + }) + } +} + +pub struct MeasuredApp; +impl Hooks for MeasuredApp { + fn routes() -> edgezero_core::router::RouterService { + FixtureApp::routes() + } + fn stores() -> edgezero_core::app::StoresMetadata { + FixtureApp::stores() + } + fn configure(app: &mut App) -> Result<(), EdgeError> { + if PANIC_IN_CONSTRUCTOR.swap(false, Ordering::SeqCst) { + println!( + "{}", + serde_json::json!({"event":"constructor_panic","instance":fixture_core::instance_id(),"source":"injected_inside_configure"}) + ); + panic!("injected failure inside MeasuredApp::configure"); + } + let cpu_before = fastly::compute_runtime::elapsed_vcpu_ms().ok(); + let heap_before = fastly::compute_runtime::heap_memory_snapshot_mib().ok(); + let start = std::time::Instant::now(); + let rounds: usize = option_env!("FIXTURE_BUILD_ROUNDS") + .unwrap_or("0") + .parse() + .unwrap(); + let source = + serde_json::json!({"routes": ["one", "two"], "cache": {"capacity": 128}}).to_string(); + for _ in 0..rounds { + let parsed: serde_json::Value = serde_json::from_str(&source).unwrap(); + std::hint::black_box(parsed); + } + FixtureApp::configure(app)?; + app.set_ingress_admission_policy(|head| { + let token = head + .headers() + .get("x-request-token") + .and_then(|value| value.to_str().ok()) + .unwrap_or("missing") + .to_owned(); + AdmissionDecision::Admit { + completion: ResponseEgressCompletion::new(move |report| { + println!( + "{}", + serde_json::json!({"event":"egress_terminal","instance":fixture_core::instance_id(), + "token":token,"outcome":format!("{:?}",report.outcome), + "body_kind":format!("{:?}",report.body_kind),"bytes_written":report.bytes_written, + "fallback":report.fallback_disposition.map(|value|format!("{value:?}"))}) + ); + }), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(DEFAULT_INBOUND_READ_BUDGET), + } + }); + INITIALIZATIONS.lock().unwrap().push(serde_json::json!({"event":"initialization", "wall_ns":start.elapsed().as_nanos(), + "cpu_before_ms":cpu_before,"cpu_after_ms":fastly::compute_runtime::elapsed_vcpu_ms().ok(), + "heap_before_mib":heap_before,"heap_after_mib":fastly::compute_runtime::heap_memory_snapshot_mib().ok(), + "construction_rounds":rounds})); + + Ok(()) + } +} + +/// Controlled fixture inputs exercise real adapter boundaries, not provider outages. +pub fn faults() -> Result<(), Error> { + use edgezero_core::env_config::EnvConfig; + finish(serve_custom( + serving(), + |mut req: Request, retained: &mut Sandbox| { + let obs = observation(&req); + let token = req + .get_header_str("x-request-token") + .unwrap_or("missing") + .to_owned(); + let path = req.get_path().to_owned(); + if path == "/constructor-panic" { + PANIC_IN_CONSTRUCTOR.store(true, Ordering::SeqCst); + } + retained.initialize(|| App::build::(FASTLY_PLATFORM))?; + let app = retained.state().unwrap(); + initialized(&obs, &token); + if path == "/inbound-error" { + let mut upstream = + Request::get("http://fault-origin/abort").send("fault-origin")?; + req.set_body(upstream.take_body()); + } + let stores = MeasuredApp::stores(); + let mut runtime = runtime_env_config(stores); + runtime.emit_boot_diagnostics(); + let env = if path == "/recoverable-store-error" || path == "/terminal-store-error" { + EnvConfig::from_vars([( + "EDGEZERO__STORES__KV__FIXTURE_KV__NAME", + "missing_fixture_store", + )]) + } else { + runtime.env + }; + let result = send_request_with_registries_and_hooks( + app, + stores, + req, + &env, + |raw, extensions| { + if path == "/collect-error" || path == "/inbound-error" { + raw.set_url(format!("http://example.com/probe{}", path)); + } + extensions.insert(obs.clone()); + }, + |response| { + if path == "/collect-error" { + *response.status_mut() = StatusCode::OK; + *response.body_mut() = Body::from_stream(futures::stream::once(async { + Err(EdgeError::internal(std::io::Error::other( + "injected core stream failure", + ))) + })); + } + response.status() + }, + ); + match result { + Ok(status) => { + if path == "/inbound-error" { + assert_eq!(status, Some(StatusCode::INTERNAL_SERVER_ERROR)); + lifecycle("ingress_error_response", &obs, &token); + } + lifecycle("delivery_completed", &obs, &token); + Ok(()) + } + Err(error) => { + println!( + "{}", + serde_json::json!({"event":"adapter_error","instance":obs.instance,"ordinal":obs.ordinal,"token":token,"path":path,"error":error.to_string(),"source":"controlled_fixture_input"}) + ); + if path == "/recoverable-store-error" { + // Explicit custom policy permits another callback; standard helpers propagate. + Response::from_status(503) + .with_body("injected selector failed") + .send_to_client(); + Ok(()) + } else { + if path == "/terminal-store-error" { + Response::from_status(500) + .with_body("injected selector failed") + .send_to_client(); + } else if path == "/collect-error" { + post_commit_error(&obs, &token, &error); + } + lifecycle("terminal_error", &obs, &token); + Err(error) + } + } + } + }, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::http::Response as CoreResponse; + + #[test] + fn finalization_preserves_streaming_body_and_request_local_headers() { + for (attempts, token) in [(1, "first"), (2, "second")] { + let mut response = CoreResponse::new(Body::stream(futures::stream::once(async { + Body::from("chunk").into_bytes().unwrap() + }))); + response + .extensions_mut() + .insert(fixture_core::Finalize(token.to_owned())); + + finalize_response(&mut response, attempts); + + assert_eq!(response.headers()["x-fixture-finalized"], token); + assert_eq!( + response.headers()["x-fixture-attempts"], + attempts.to_string() + ); + assert!( + response + .extensions() + .get::() + .is_none() + ); + assert!(matches!(response.body(), Body::Stream(_))); + } + } +} diff --git a/tests/fixtures/reusable-app/crates/fixture-harness/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-harness/Cargo.toml new file mode 100644 index 00000000..3438d4cf --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-harness/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fixture-harness" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +ctrlc = { version = "3.5.2", features = ["termination"] } +serde_json.workspace = true + +[lints] +workspace = true diff --git a/tests/fixtures/reusable-app/crates/fixture-harness/src/evidence.rs b/tests/fixtures/reusable-app/crates/fixture-harness/src/evidence.rs new file mode 100644 index 00000000..a139f9b8 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-harness/src/evidence.rs @@ -0,0 +1,648 @@ +use serde_json::{Value, json}; +use std::collections::{BTreeMap, HashMap, HashSet}; + +pub type Result = std::result::Result>; +pub fn require(condition: bool, message: impl Into) -> Result<()> { + if condition { + Ok(()) + } else { + Err(message.into().into()) + } +} +pub fn validate_shared_state(guest: &Value, retained: bool) -> Result<()> { + let expected = if retained { + guest["ordinal"].as_u64() + } else { + Some(1) + }; + require( + expected.is_some() && guest["shared"].as_u64() == expected, + format!("shared app state does not match its lifetime: {guest}"), + ) +} +pub fn validate_logging(guests: &[Value], delivered: &[String]) -> Result<&'static str> { + let mut seen = HashMap::new(); + let mut reused = false; + for guest in guests { + if let Some(previous) = seen.insert(guest["instance"].to_string(), guest) { + require( + guest["ordinal"].as_u64() > previous["ordinal"].as_u64(), + "logging ordinal did not increase", + )?; + require( + guest["correlation"] != previous["correlation"], + "logging correlation repeated", + )?; + reused = true; + } + } + if !reused { + return Ok("unverified"); + } + require( + guests.iter().all(|g| { + delivered + .iter() + .any(|d| g["correlation"].as_str() == Some(d)) + }), + "missing correlated endpoint receipt", + )?; + Ok("pass") +} +pub fn validate_negative_logging(replies: &[Value], starts: &[Value]) -> Result<&'static str> { + let guests: HashMap<_, _> = starts + .iter() + .map(|v| (v["token"].to_string(), v["instance"].to_string())) + .collect(); + let mut seen = HashSet::new(); + let mut reused = false; + for reply in replies { + let guest = guests + .get(&reply["token"].to_string()) + .ok_or("missing negative logger request_start")?; + let repeated = !seen.insert(guest); + require( + reply["status"] == if repeated { 500 } else { 200 }, + "unexpected negative logger status", + )?; + if repeated { + require( + reply["body"] + .as_str() + .is_some_and(|body| body.contains("already initialized")), + "missing repeated logger initialization error", + )?; + } + reused |= repeated; + } + Ok(if reused { "pass" } else { "unverified" }) +} +pub fn validate_post_send(token: &str, receipts: &[String], events: &[Value]) -> Result<()> { + require( + receipts.contains(&format!("/post-send?token={token}")), + "missing post-send backend receipt", + )?; + require( + events + .iter() + .any(|e| e["event"] == "guest_completed" && e["token"] == token), + "missing guest completion", + ) +} +pub fn validate_limit_summaries(events: &[Value]) -> Result<()> { + let mut attempts = HashMap::new(); + for event in events.iter().filter(|e| e["event"] == "request_start") { + *attempts + .entry(event["instance"].to_string()) + .or_insert(0u64) += 1; + } + let summaries: Vec<_> = events + .iter() + .filter(|e| e["event"] == "sdk_summary") + .collect(); + require(!attempts.is_empty(), "missing attempts")?; + require( + summaries.len() == attempts.len(), + "missing or duplicate limit summaries", + )?; + let mut seen = HashSet::new(); + for summary in summaries { + let instance = summary["instance"].to_string(); + require(seen.insert(instance.clone()), "duplicate summary instance")?; + require( + attempts.get(&instance) == Some(&1) && summary["attempted"] == 1, + "limit summary attempts do not match", + )?; + } + Ok(()) +} +pub fn validate_overlap(left: &Value, right: &Value) -> Result<()> { + require( + left["instance"] == right["instance"], + "different guests do not prove overlap", + )?; + require( + left["before"]["inflight"] + .as_u64() + .unwrap_or(0) + .max(right["before"]["inflight"].as_u64().unwrap_or(0)) + >= 2, + "this pair did not observe overlapping callbacks", + ) +} +pub fn cookie_values(headers: &Value) -> Vec<&str> { + headers + .as_array() + .into_iter() + .flatten() + .filter(|h| { + h[0].as_str() + .is_some_and(|s| s.eq_ignore_ascii_case("set-cookie")) + }) + .filter_map(|h| h[1].as_str()) + .collect() +} +pub fn attempt_counts(events: &[Value]) -> (usize, usize) { + ( + events + .iter() + .filter(|e| e["event"] == "request_start") + .count(), + events + .iter() + .filter(|e| e["event"] == "client_completed") + .count(), + ) +} +fn quantiles(mut values: Vec) -> Value { + values.sort_unstable(); + let mut result = json!({}); + for p in [50usize, 95, 99] { + result[format!("p{p}")] = json!(values[(p * values.len()).div_ceil(100).saturating_sub(1)]); + } + result +} +/// Completion and first-byte quantiles for one cohort, or `None` without +/// completion samples. +fn cohort_stats(rows: &[&Value]) -> Option { + let samples = |key: &str| { + rows.iter() + .filter_map(|r| r[key].as_u64()) + .collect::>() + }; + let completion = samples("complete_ns"); + if completion.is_empty() { + return None; + } + let mut stats = json!({"samples":completion.len(), "completion_ns":quantiles(completion)}); + let first_bytes = samples("first_byte_ns"); + if !first_bytes.is_empty() { + stats["first_byte_ns"] = quantiles(first_bytes); + } + Some(stats) +} +fn phase_delta(before: Option, after: Option) -> Value { + match (before, after) { + (Some(a), Some(b)) if a >= 0.0 && b >= a => { + json!({"status":"observed", "value":b-a}) + } + (Some(_), Some(_)) => json!({"status":"invalid", "value":null}), + _ => json!({"status":"unknown", "value":null}), + } +} +fn variant(record: &Value) -> String { + record["variant"] + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| { + format!( + "{}:{}", + record["adapter"].as_str().unwrap_or("unknown"), + record["mode"].as_str().unwrap_or("unknown") + ) + }) +} +fn memory_analysis(events: &[&Value], phase: &str) -> Value { + let snapshots = events + .iter() + .flat_map(|e| { + ["heap_mib", "heap_before_mib", "heap_after_mib"] + .into_iter() + .filter_map(|key| e[key].as_f64().filter(|v| *v >= 0.0)) + }) + .collect::>(); + let mut cleanup = BTreeMap::new(); + let mut invalid_cleanup = false; + for event in events.iter().filter(|e| e["event"] == phase) { + match (event["ordinal"].as_u64(), event["heap_mib"].as_f64()) { + (Some(ordinal), Some(heap)) if heap >= 0.0 => { + invalid_cleanup |= cleanup.insert(ordinal, heap).is_some(); + } + _ => invalid_cleanup = true, + } + } + let points = cleanup.into_iter().collect::>(); + let slope = if points.len() >= 2 && !invalid_cleanup { + let n = points.len() as f64; + let x = points.iter().map(|(x, _)| *x as f64).sum::() / n; + let y = points.iter().map(|(_, y)| y).sum::() / n; + Some( + points + .iter() + .map(|(a, b)| (*a as f64 - x) * (b - y)) + .sum::() + / points + .iter() + .map(|(a, _)| (*a as f64 - x).powi(2)) + .sum::(), + ) + } else { + None + }; + let change = if points.len() >= 2 && !invalid_cleanup { + Some(points.last().unwrap().1 - points[0].1) + } else { + None + }; + let mut plateau = json!({"status":"unknown", "window_samples":3}); + if points.len() >= 3 && !invalid_cleanup { + let tail = &points[points.len() - 3..]; + plateau = json!({"status":if tail.iter().all(|(_, heap)| *heap == tail[0].1) { + "observed" } else { "not_observed" }, "window_samples":3, + "first_ordinal":tail[0].0, "last_ordinal":tail[2].0}); + } + json!({"sample_phase":phase,"source":"SDK host-inclusive rounded MiB snapshots", + "scope":"observed request span only; rounded samples cannot establish absence of leaks; not guest linear-memory high-water or host RSS", + "snapshot_samples":snapshots.len(), + "peak_observed_mib":snapshots.into_iter().reduce(f64::max), + "samples":points.len(), "samples_invalid_or_missing":invalid_cleanup, + "samples_by_ordinal":points.iter().map(|(ordinal,heap)| json!({"ordinal":ordinal,"heap_mib":heap})).collect::>(), + "slope_mib_per_ordinal":slope, "slope_method":"least squares over observed phase ordinals", + "change_mib":change, "plateau":plateau}) +} +fn guest_analysis(records: &[Value]) -> Vec { + let mut guests: BTreeMap<(String, String, String), Vec<&Value>> = BTreeMap::new(); + for event in records { + if !event["instance"].is_null() { + guests + .entry(( + variant(event), + event["repetition"].to_string(), + event["instance"].to_string(), + )) + .or_default() + .push(event); + } + } + guests.into_iter().map(|((variant, _, _), events)| { + let count = |name: &str| events.iter().filter(|e| e["event"] == name).count(); + let summaries = events.iter().filter(|e| e["event"] == "sdk_summary").collect::>(); + let attempted = if summaries.len() == 1 { summaries[0]["attempted"].as_u64() } else { None }; + let initializations = events.iter().filter(|e| e["event"] == "initialization") + .map(|e| json!({"ordinal":e["ordinal"],"token":e["token"],"wall_ns":e["wall_ns"], + "cpu_ms":phase_delta(e["cpu_before_ms"].as_f64(),e["cpu_after_ms"].as_f64()), + "heap_before_mib":e["heap_before_mib"],"heap_after_mib":e["heap_after_mib"]})) + .collect::>(); + let mut requests: BTreeMap<(u64,String),Vec<&Value>> = BTreeMap::new(); + for event in &events { + if let (Some(ordinal), Some(token)) = (event["ordinal"].as_u64(),event["token"].as_str()) { + requests.entry((ordinal,token.to_owned())).or_default().push(event); + } + } + let requests = requests.into_iter().map(|((ordinal,token),rows)| { + let phase = |name: &str| { + let matches = rows.iter().filter(|e| e["event"] == name).collect::>(); + if matches.len() == 1 { matches[0]["cpu_ms"].as_f64() } else { None } + }; + json!({"ordinal":ordinal,"token":token, + "cpu_to_conversion_ms":phase_delta(phase("request_start"),phase("conversion_completed")), + "cpu_to_commit_ms":phase_delta(phase("request_start"),phase("response_committed")), + "cpu_after_commit_ms":phase_delta(phase("response_committed"),phase("guest_completed")), + "cpu_request_ms":phase_delta(phase("request_start"),phase("guest_completed")), + "response_committed":if rows.iter().any(|e| e["event"] == "response_committed") {json!(true)} else {Value::Null}, + "guest_completed":if rows.iter().any(|e| e["event"] == "guest_completed") {json!(true)} else {Value::Null}, + "terminal_error":rows.iter().any(|e| e["event"] == "terminal_error")}) + }).collect::>(); + let client_completions = records.iter().filter(|r| r["event"] == "client_completed" + && crate::evidence::variant(r) == variant && r["repetition"] == events[0]["repetition"] + && (r["instance"] == events[0]["instance"] || r["guest"]["instance"] == events[0]["instance"] + || requests.iter().any(|request| r["token"] == request["token"]))).count(); + json!({"variant":variant,"repetition":events[0]["repetition"],"instance":events[0]["instance"], + "request_starts":count("request_start"),"response_commitments":count("response_committed"), + "guest_completions":count("guest_completed"),"terminal_errors":count("terminal_error"), + "client_completions":client_completions,"sdk_summary_records":summaries.len(),"sdk_attempted":attempted, + "attempt_reconciliation":match attempted {Some(n) if n == count("request_start") as u64 => "match", Some(_) => "mismatch", None => "unknown"}, + "initialization_count":initializations.len(),"initialization":initializations,"requests":requests, + "cpu_scope":"SDK samples between named observation points: standard request_start is after initialization; custom request_start is before initialization. Concurrent work may contribute. Not total request CPU or cross-run performance evidence.", + "memory":memory_analysis(&events,"guest_completed"), + "conversion_memory":memory_analysis(&events,"conversion_completed")}) + }).collect() +} +pub fn summarize(records: &[Value]) -> Value { + let mut builds = BTreeMap::new(); + let mut mixed_builds = false; + for record in records.iter().filter(|r| r["event"] == "build") { + let key = variant(record); + if let Some(previous) = builds.insert(key, record["artifact"]["fnv1a64"].clone()) { + mixed_builds |= previous != record["artifact"]["fnv1a64"]; + } + } + let mut groups: BTreeMap> = BTreeMap::new(); + let mut memory: BTreeMap> = BTreeMap::new(); + for r in records { + if r["event"] == "client_completed" + && r["workload"] == "probe" + && r["status"] == 200 + && r.get("guest").is_some() + { + let cohort = if r["guest"]["ordinal"] == 1 { + "cold" + } else { + "reused" + }; + groups + .entry(format!("{}:probe:{cohort}", variant(r))) + .or_default() + .push(r); + } + if let Some(heap) = r["heap_mib"].as_u64() { + memory + .entry(r["variant"].as_str().unwrap_or("unknown").to_owned()) + .or_default() + .push(heap); + } + } + let mut result = json!({"quantile_method":"nearest rank", "variants":{}, "memory_snapshots":{}, "cpu_comparison":"SDK phase readings only; cross-run performance unverified"}); + for (key, rows) in groups { + let Some(mut stats) = cohort_stats(&rows) else { + continue; + }; + let mut repetitions: BTreeMap> = BTreeMap::new(); + for row in &rows { + repetitions + .entry(row["repetition"].to_string()) + .or_default() + .push(row); + } + for (repetition, samples) in repetitions { + if let Some(repetition_stats) = cohort_stats(&samples) { + stats["repetitions"][&repetition] = repetition_stats; + } + } + result["variants"][&key] = stats; + } + for (key, values) in memory { + result["memory_snapshots"][key] = json!({"samples":values.len(),"min_mib":values.iter().min(),"max_mib":values.iter().max(),"source":"SDK host-inclusive rounded snapshot"}); + } + result["build_identity"] = json!({"status":if mixed_builds {"invalid"} else if builds.is_empty() {"unknown"} else {"consistent"}}); + let (attempts, completions) = attempt_counts(records); + result["guests"] = json!(guest_analysis(records)); + result["recorded_request_starts"] = json!(attempts); + result["recorded_client_completions"] = json!(completions); + result +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn missing_observation_is_unknown_and_mixed_builds_are_rejected() { + let summary = summarize(&[ + json!({"event":"request_start","variant":"c","instance":"one","ordinal":1,"token":"a"}), + json!({"event":"build","variant":"c","repetition":0,"artifact":{"fnv1a64":"a"}}), + json!({"event":"build","variant":"c","repetition":1,"artifact":{"fnv1a64":"b"}}), + ]); + assert!(summary["guests"][0]["requests"][0]["response_committed"].is_null()); + assert_eq!(summary["build_identity"]["status"], "invalid"); + } + #[test] + fn equal_build_fingerprints_across_repetitions_stay_consistent() { + let summary = summarize(&[ + json!({"event":"build","variant":"c","repetition":0,"artifact":{"fnv1a64":"a"}}), + json!({"event":"build","variant":"c","repetition":1,"artifact":{"fnv1a64":"a"}}), + json!({"event":"build","variant":"b","repetition":0,"artifact":{"fnv1a64":"b"}}), + ]); + assert_eq!(summary["build_identity"]["status"], "consistent"); + } + fn measured(event: &str, ordinal: u64, cpu: u64, heap: u64) -> Value { + json!({"event":event,"variant":"C","repetition":1,"instance":"same", + "ordinal":ordinal,"token":format!("t{ordinal}"),"cpu_ms":cpu,"heap_mib":heap}) + } + #[test] + fn per_guest_phases_attempts_and_memory_are_bounded_observations() { + let mut events = vec![ + json!({"event":"initialization","variant":"C","repetition":1, + "instance":"same","ordinal":1,"token":"t1","cpu_before_ms":1,"cpu_after_ms":4, + "heap_before_mib":5,"heap_after_mib":8}), + ]; + for ordinal in 1..=4 { + events.push(measured("request_start", ordinal, ordinal * 10, 8)); + events.push(measured( + "response_committed", + ordinal, + ordinal * 10 + 2, + 10, + )); + events.push(measured("guest_completed", ordinal, ordinal * 10 + 5, 9)); + } + events.push(json!({"event":"sdk_summary","variant":"C","repetition":1,"instance":"same","attempted":5})); + let summary = summarize(&events); + let guest = &summary["guests"][0]; + assert_eq!(guest["request_starts"], 4); + assert_eq!(guest["guest_completions"], 4); + assert_eq!(guest["sdk_attempted"], 5); + assert_eq!(guest["attempt_reconciliation"], "mismatch"); + assert_eq!(guest["initialization"][0]["cpu_ms"]["value"], 3.0); + assert_eq!(guest["requests"][0]["cpu_to_commit_ms"]["value"], 2.0); + assert_eq!(guest["requests"][0]["cpu_after_commit_ms"]["value"], 3.0); + assert_eq!(guest["memory"]["slope_mib_per_ordinal"], 0.0); + assert_eq!(guest["memory"]["peak_observed_mib"], 10.0); + assert_eq!(guest["memory"]["change_mib"], 0.0); + assert_eq!(guest["memory"]["plateau"]["status"], "observed"); + assert_eq!(guest["memory"]["plateau"]["first_ordinal"], 2); + assert_eq!(guest["memory"]["plateau"]["last_ordinal"], 4); + } + #[test] + fn missing_and_decreasing_cpu_are_not_zero_or_success() { + let mut events = vec![ + measured("request_start", 1, 10, 8), + measured("response_committed", 1, 9, 8), + ]; + events.push(measured("terminal_error", 1, 11, 8)); + let summary = summarize(&events); + let guest = &summary["guests"][0]; + assert!(guest["sdk_attempted"].is_null()); + assert_eq!(guest["attempt_reconciliation"], "unknown"); + assert_eq!(guest["terminal_errors"], 1); + assert_eq!(guest["guest_completions"], 0); + assert_eq!( + guest["requests"][0]["cpu_to_commit_ms"]["status"], + "invalid" + ); + assert_eq!( + guest["requests"][0]["cpu_after_commit_ms"]["status"], + "unknown" + ); + assert!(guest["requests"][0]["cpu_to_commit_ms"]["value"].is_null()); + assert_eq!(guest["memory"]["plateau"]["status"], "unknown"); + } + #[test] + fn guest_identity_includes_variant_and_repetition_and_slope_uses_ordinals() { + let mut events = vec![ + measured("guest_completed", 1, 1, 10), + measured("guest_completed", 3, 2, 14), + ]; + let mut other = events[0].clone(); + other["repetition"] = json!(2); + events.push(other.clone()); + other["variant"] = json!("B"); + events.push(other); + let summary = summarize(&events); + let guests = summary["guests"].as_array().unwrap(); + assert_eq!(guests.len(), 3); + let guest = guests + .iter() + .find(|g| g["variant"] == "C" && g["repetition"] == 1) + .unwrap(); + assert_eq!(guest["memory"]["slope_mib_per_ordinal"], 2.0); + assert_eq!(guest["memory"]["change_mib"], 4.0); + assert_eq!(guest["memory"]["plateau"]["status"], "unknown"); + } + #[test] + fn conversion_samples_do_not_claim_guest_cleanup_or_commitment() { + let events = vec![ + measured("conversion_completed", 1, 5, 8), + measured("conversion_completed", 2, 6, 10), + ]; + let summary = summarize(&events); + let guest = &summary["guests"][0]; + assert_eq!( + guest["conversion_memory"]["sample_phase"], + "conversion_completed" + ); + assert_eq!(guest["conversion_memory"]["slope_mib_per_ordinal"], 2.0); + assert_eq!(guest["memory"]["samples"], 0); + assert_eq!(guest["response_commitments"], 0); + assert_eq!(guest["guest_completions"], 0); + assert_eq!(guest["requests"][0]["cpu_request_ms"]["status"], "unknown"); + } + #[test] + fn failed_attempt_is_not_completion() { + assert_eq!( + attempt_counts(&[json!({"event":"request_start"}), json!({"event":"error"})]), + (1, 0) + ); + } + #[test] + fn duplicate_cookies() { + assert_eq!( + cookie_values(&json!([["Set-Cookie", "a=1"], ["Set-Cookie", "b=2"]])), + vec!["a=1", "b=2"] + ); + } + #[test] + fn overlap_requires_same_guest_and_concurrency() { + let left = json!({"instance":"a","before":{"inflight":1}}); + for right in [ + json!({"instance":"b","before":{"inflight":2}}), + json!({"instance":"a","before":{"inflight":1}}), + ] { + assert!(validate_overlap(&left, &right).is_err()); + } + validate_overlap(&left, &json!({"instance":"a","before":{"inflight":2}})).unwrap(); + } + #[test] + fn unmatched_workloads_excluded() { + let s = summarize(&[ + json!({"event":"client_completed","variant":"c","workload":"probe","status":200,"guest":{"ordinal":1},"complete_ns":10}), + json!({"event":"client_completed","variant":"c","workload":"probe","status":200,"guest":{"ordinal":2},"complete_ns":20}), + json!({"event":"client_completed","variant":"c","assertion":"idle","complete_ns":999}), + json!({"event":"client_completed","variant":"c","guest":{"ordinal":2},"status":200,"complete_ns":999}), + json!({"event":"client_completed","variant":"c","workload":"probe","guest":{"ordinal":2},"status":503,"complete_ns":999}), + ]); + assert_eq!(s["variants"]["c:probe:reused"]["samples"], 1); + assert_eq!(s["variants"]["c:probe:cold"]["completion_ns"]["p50"], 10); + assert_eq!(s["variants"]["c:probe:reused"]["completion_ns"]["p50"], 20); + } + #[test] + fn provider_modes_separate() { + let records=["retained","per-request"].map(|mode|json!({"event":"client_completed","adapter":"spin","mode":mode,"workload":"probe","status":200,"guest":{"ordinal":2},"complete_ns":10})); + assert_eq!( + summarize(&records)["variants"].as_object().unwrap().len(), + 2 + ); + } + #[test] + fn probe_quantiles_keep_repetitions_separate() { + let records = [10, 100] + .into_iter() + .enumerate() + .map(|(repetition, latency)| { + json!({"event":"client_completed","variant":"c","workload":"probe","status":200, + "guest":{"ordinal":2},"repetition":repetition,"complete_ns":latency}) + }) + .collect::>(); + let summary = summarize(&records); + let repetitions = &summary["variants"]["c:probe:reused"]["repetitions"]; + assert_eq!(repetitions["0"]["completion_ns"]["p50"], 10); + assert_eq!(repetitions["1"]["completion_ns"]["p50"], 100); + } + #[test] + fn shared_state_matches_retained_and_per_request_lifetimes() { + let value = json!({"ordinal":3,"shared":3}); + validate_shared_state(&value, true).unwrap(); + assert!(validate_shared_state(&value, false).is_err()); + let fresh = json!({"ordinal":3,"shared":1}); + validate_shared_state(&fresh, false).unwrap(); + assert!(validate_shared_state(&fresh, true).is_err()); + assert!(validate_shared_state(&json!({}), true).is_err()); + } + #[test] + fn logging_requires_reuse_and_correlated_delivery() { + let cold = (0..3) + .map(|i| json!({"instance":i.to_string(),"ordinal":1,"correlation":i.to_string()})) + .collect::>(); + let delivered = (0..3).map(|i| i.to_string()).collect::>(); + assert_eq!(validate_logging(&cold, &delivered).unwrap(), "unverified"); + let warm = (0..3) + .map(|i| json!({"instance":"same","ordinal":i+1,"correlation":i.to_string()})) + .collect::>(); + assert!(validate_logging(&warm, &["0".into(), "0".into(), "0".into()]).is_err()); + assert_eq!(validate_logging(&warm, &delivered).unwrap(), "pass"); + } + #[test] + fn negative_logger_eviction() { + let mut starts = vec![ + json!({"instance":"one","token":"a"}), + json!({"instance":"two","token":"b"}), + ]; + let mut replies = vec![ + json!({"token":"a","status":200}), + json!({"token":"b","status":200}), + ]; + assert_eq!( + validate_negative_logging(&replies, &starts).unwrap(), + "unverified" + ); + starts[1]["instance"] = json!("one"); + assert!(validate_negative_logging(&replies, &starts).is_err()); + replies[1]["status"] = json!(500); + replies[1]["body"] = json!("unrelated KV registry failure"); + assert!(validate_negative_logging(&replies, &starts).is_err()); + replies[1]["body"] = json!("logging system was already initialized"); + assert_eq!( + validate_negative_logging(&replies, &starts).unwrap(), + "pass" + ); + } + #[test] + fn post_send_requires_both_receipts() { + assert!(validate_post_send("x", &[], &[]).is_err()); + let receipts = vec!["/post-send?token=x".into()]; + assert!(validate_post_send("x", &receipts, &[]).is_err()); + validate_post_send( + "x", + &receipts, + &[json!({"event":"guest_completed","token":"x"})], + ) + .unwrap(); + } + #[test] + fn limit_summaries_match_attempts() { + let mut events = vec![json!({"event":"request_start","instance":"one"})]; + assert!(validate_limit_summaries(&events).is_err()); + for count in [0, 2] { + events.truncate(1); + events.push(json!({"event":"sdk_summary","instance":"one","attempted":count})); + assert!(validate_limit_summaries(&events).is_err()); + } + events[1]["attempted"] = json!(1); + validate_limit_summaries(&events).unwrap(); + events[1]["instance"] = json!("other"); + assert!(validate_limit_summaries(&events).is_err()); + events[1]["instance"] = json!("one"); + events.push(events[1].clone()); + assert!(validate_limit_summaries(&events).is_err()); + events.push(json!({"event":"request_start","instance":"two"})); + assert!(validate_limit_summaries(&events).is_err()); + } +} diff --git a/tests/fixtures/reusable-app/crates/fixture-harness/src/main.rs b/tests/fixtures/reusable-app/crates/fixture-harness/src/main.rs new file mode 100644 index 00000000..acc293e7 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-harness/src/main.rs @@ -0,0 +1,382 @@ +mod evidence; +mod net; +mod runners; +use evidence::{Result, require}; +use serde_json::{Value, json}; +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::{ + Mutex, PoisonError, + atomic::{AtomicU64, Ordering}, + }, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +pub struct Args { + adapter: String, + suite: String, + output: Option, + requests: usize, + repetitions: usize, + seed: u64, + construction_rounds: u64, + max_requests: usize, +} +impl Args { + fn parse() -> Result { + let mut a = Self { + adapter: "fastly".into(), + suite: "smoke".into(), + output: None, + requests: 100, + repetitions: 3, + seed: 856, + construction_rounds: 0, + max_requests: 10, + }; + let mut args = std::env::args().skip(1); + while let Some(key) = args.next() { + if key == "--help" || key == "-h" { + println!( + "fixture-harness [--adapter fastly|cloudflare|spin|axum|all] [--suite smoke|benchmark] [--output EMPTY_DIR] [--requests 100] [--repetitions 3] [--seed 856] [--construction-rounds 0] [--max-requests 10]" + ); + std::process::exit(0); + } + let value = args.next().ok_or(format!("missing value for {key}"))?; + match key.as_str() { + "--adapter" => a.adapter = value, + "--suite" => a.suite = value, + "--output" => a.output = Some(value.into()), + "--requests" => a.requests = value.parse()?, + "--repetitions" => a.repetitions = value.parse()?, + "--seed" => a.seed = value.parse()?, + "--construction-rounds" => a.construction_rounds = value.parse()?, + "--max-requests" => a.max_requests = value.parse()?, + _ => return Err(format!("unknown option {key}").into()), + } + } + require( + ["fastly", "cloudflare", "spin", "axum", "all"].contains(&a.adapter.as_str()), + "unknown adapter", + )?; + require( + ["smoke", "benchmark"].contains(&a.suite.as_str()), + "unknown suite", + )?; + require( + a.requests > 0 && a.repetitions > 0, + "requests and repetitions must be positive", + )?; + require( + (1..=1000).contains(&a.max_requests), + "max-requests must be 1..1000", + )?; + Ok(a) + } +} +pub fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("fixture workspace root") +} +pub fn token() -> String { + static NEXT: AtomicU64 = AtomicU64::new(0); + format!( + "{:x}-{:x}-{:x}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + ) +} +pub struct Records { + path: PathBuf, + values: Vec, +} +impl Records { + fn collect_clients(&mut self, fields: Value) -> Result<()> { + for event in net::take_client_events() { + if !self.values.iter().any(|existing| { + existing["event"] == "client_completed" && existing["token"] == event["token"] + }) { + self.push(annotate(event, fields.clone()))?; + } + } + Ok(()) + } + fn push(&mut self, v: Value) -> Result<()> { + if matches!( + v["status"].as_str(), + Some("fail" | "unverified" | "unsupported") + ) { + eprintln!("fixture-harness: {v}"); + } + writeln!( + OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?, + "{v}" + )?; + self.values.push(v); + Ok(()) + } +} +pub fn logs(path: &Path) -> Vec { + fs::read_to_string(path) + .unwrap_or_default() + .lines() + .filter(|l| l.starts_with('{')) + .filter_map(|l| serde_json::from_str(l).ok()) + .collect() +} +pub fn await_evidence(mut check: impl FnMut() -> Result<()>) -> Result<()> { + let start = Instant::now(); + loop { + match check() { + Ok(()) => return Ok(()), + Err(e) if start.elapsed() >= Duration::from_secs(5) => return Err(e), + Err(_) => thread::sleep(Duration::from_millis(50)), + } + } +} +pub fn executable(variable: &str, default: &str) -> Option { + let value = std::env::var(variable).unwrap_or_else(|_| default.into()); + let path = PathBuf::from(&value); + if path.components().count() > 1 { + return path.canonicalize().ok(); + } + std::env::split_paths(&std::env::var_os("PATH")?) + .map(|p| p.join(&value)) + .find(|p| p.is_file()) + .and_then(|p| p.canonicalize().ok()) +} +pub fn checked(command: &mut Command) -> Result<()> { + let status = command.status()?; + require( + status.success(), + format!("command {command:?} failed: {status}"), + ) +} +/// Process groups of live runtimes, killed by the Ctrl-C handler because a +/// terminal SIGINT reaches only the harness's own foreground group. +static LIVE_GROUPS: Mutex> = Mutex::new(Vec::new()); +fn signal_group(id: u32, signal: &str) { + #[cfg(unix)] + let target = format!("-{id}"); + #[cfg(not(unix))] + let target = id.to_string(); + let _ = Command::new("kill") + .args([signal, "--", &target]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} +fn kill_live_groups_and_exit() { + for id in LIVE_GROUPS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .iter() + { + signal_group(*id, "-KILL"); + } + std::process::exit(130); +} +pub struct Process(Child); +impl Process { + pub fn start(command: &mut Command, log: &Path, port: u16) -> Result { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let file = fs::File::create(log)?; + let mut p = Self( + command + .stdout(Stdio::from(file.try_clone()?)) + .stderr(Stdio::from(file)) + .spawn()?, + ); + LIVE_GROUPS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(p.0.id()); + let start = Instant::now(); + loop { + if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() { + return Ok(p); + } + require( + p.0.try_wait()?.is_none(), + format!("runtime exited; see {}", log.display()), + )?; + require( + start.elapsed() < Duration::from_secs(45), + "runtime did not listen", + )?; + thread::sleep(Duration::from_millis(50)); + } + } +} +impl Drop for Process { + fn drop(&mut self) { + let id = self.0.id(); + signal_group(id, "-TERM"); + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + if matches!(self.0.try_wait(), Ok(Some(_))) { + break; + } + thread::sleep(Duration::from_millis(50)); + } + // The parent may have exited while a descendant still holds sockets. + #[cfg(unix)] + signal_group(id, "-KILL"); + #[cfg(not(unix))] + let _ = self.0.kill(); + let _ = self.0.wait(); + LIVE_GROUPS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|live| *live != id); + } +} +pub fn req(port: u16, path: &str) -> Result { + net::request(port, path, &token(), None, None) +} +pub fn guest(reply: &Value) -> Result { + require( + reply["status"] == 200, + format!("unexpected response: {reply}"), + )?; + Ok(serde_json::from_str( + reply["body"].as_str().ok_or("missing body")?, + )?) +} +pub fn annotate(mut value: Value, fields: Value) -> Value { + value + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + value +} +pub fn artifact(path: &Path) -> Result { + let bytes = fs::read(path)?; + let fingerprint = bytes.iter().fold(0xcbf29ce484222325u64, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) + }); + Ok( + json!({"path":path,"bytes":bytes.len(),"fnv1a64":format!("{fingerprint:016x}"),"purpose":"non-security build identity"}), + ) +} +/// The compiler that `$CARGO` builds with: `$RUSTC` when set, else the +/// `rustc` beside `$CARGO`, else whichever `rustc` is on `PATH`. +fn rustc() -> PathBuf { + if let Some(rustc) = std::env::var_os("RUSTC") { + return rustc.into(); + } + std::env::var_os("CARGO") + .map(|cargo| { + Path::new(&cargo).with_file_name(format!("rustc{}", std::env::consts::EXE_SUFFIX)) + }) + .filter(|rustc| rustc.is_file()) + .unwrap_or_else(|| "rustc".into()) +} +fn run() -> Result { + let args = Args::parse()?; + ctrlc::set_handler(kill_live_groups_and_exit)?; + let path = args + .output + .clone() + .unwrap_or_else(|| root().join(".runs").join(token())); + if path.exists() { + require( + fs::read_dir(&path)?.next().is_none(), + "output directory must be empty; refusing to overwrite evidence", + )?; + } + fs::create_dir_all(&path)?; + let path = path.canonicalize()?; + fs::copy(root().join("Cargo.lock"), path.join("Cargo.lock"))?; + let mut records = Records { + path: path.join("events.jsonl"), + values: Vec::new(), + }; + records.push(json!({"event":"run","seed":args.seed,"suite":args.suite,"construction_rounds":args.construction_rounds,"max_requests":args.max_requests}))?; + records.push(json!({"event":"build_environment","rustc":String::from_utf8_lossy(&Command::new(rustc()).arg("--version").output()?.stdout).trim(),"host_os":std::env::consts::OS,"host_arch":std::env::consts::ARCH,"lockfile":artifact(&path.join("Cargo.lock"))?}))?; + let adapters = if args.adapter == "all" { + vec!["fastly", "cloudflare", "spin", "axum"] + } else { + vec![args.adapter.as_str()] + }; + for adapter in adapters { + let recorded = records.values.len(); + let result = if adapter == "fastly" { + runners::fastly(&args, &path, &mut records) + } else { + runners::provider(adapter, &args, &path, &mut records) + }; + if let Err(error) = result { + // Runners record their own contextual failure for this error; add one only + // when they did not (an earlier non-fatal fail record must not hide it). + let error = error.to_string(); + if !records.values[recorded..] + .iter() + .any(|r| r["status"] == "fail" && r["error"] == error.as_str()) + { + records.push(json!({"status":"fail","adapter":adapter,"error":error}))?; + } + } + } + let summary = evidence::summarize(&records.values); + if summary["build_identity"]["status"] == "invalid" { + let builds = records + .values + .iter() + .filter(|r| r["event"] == "build") + .map(|r| json!({"variant":r["variant"],"adapter":r["adapter"],"repetition":r["repetition"],"fnv1a64":r["artifact"]["fnv1a64"]})) + .collect::>(); + records + .push(json!({"status":"fail","reason":"mixed build fingerprints","builds":builds}))?; + } + let code = if records.values.iter().any(|r| r["status"] == "fail") { + 1 + } else if records + .values + .iter() + .any(|r| r["status"] == "unverified" || r["status"] == "unsupported") + { + 2 + } else { + 0 + }; + fs::write( + path.join("summary.json"), + serde_json::to_string_pretty(&summary)?, + )?; + for (key, value) in summary["variants"].as_object().unwrap() { + println!( + "{key}: {} samples, completion p50 {:.3} ms", + value["samples"], + value["completion_ns"]["p50"].as_f64().unwrap_or(0.) / 1_000_000. + ); + } + println!("{}", json!({"exit_code":code,"evidence":path})); + Ok(code) +} +fn main() { + match run() { + Ok(code) => std::process::exit(code), + Err(error) => { + eprintln!("fixture-harness: {error}"); + std::process::exit(1); + } + } +} diff --git a/tests/fixtures/reusable-app/crates/fixture-harness/src/net.rs b/tests/fixtures/reusable-app/crates/fixture-harness/src/net.rs new file mode 100644 index 00000000..d798810a --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-harness/src/net.rs @@ -0,0 +1,456 @@ +use crate::evidence::{Result, require}; +use serde_json::{Value, json}; +use std::{ + io::{BufRead, BufReader, Read, Write}, + net::{TcpListener, TcpStream}, + sync::{ + Arc, Condvar, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant}, +}; + +pub type Signal = Arc<(Mutex, Condvar)>; +static CLIENT_EVENTS: Mutex> = Mutex::new(Vec::new()); +pub fn take_client_events() -> Vec { + std::mem::take(&mut *CLIENT_EVENTS.lock().unwrap()) +} + +#[derive(Debug)] +pub struct InterruptedResponse { + pub status: u16, + pub body: Vec, + pub reason: String, +} +impl std::fmt::Display for InterruptedResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "response {} interrupted after {} bytes: {}", + self.status, + self.body.len(), + self.reason + ) + } +} +impl std::error::Error for InterruptedResponse {} + +#[derive(Debug)] +pub struct ResponseHeadAborted(std::io::ErrorKind); +impl std::fmt::Display for ResponseHeadAborted { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "response ended before its status: {:?}", self.0) + } +} +impl std::error::Error for ResponseHeadAborted {} + +fn read_status(reader: &mut impl BufRead) -> Result { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => { + return Err(Box::new(ResponseHeadAborted( + std::io::ErrorKind::UnexpectedEof, + ))); + } + Err(error) + if line.is_empty() + && matches!( + error.kind(), + std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::UnexpectedEof + ) => + { + return Err(Box::new(ResponseHeadAborted(error.kind()))); + } + Err(error) => return Err(error.into()), + Ok(_) => {} + } + Ok(line + .split_whitespace() + .nth(1) + .ok_or("missing HTTP response status")? + .parse()?) +} + +pub fn release(signal: &Signal) { + *signal.0.lock().unwrap() = true; + signal.1.notify_all(); +} +pub struct Backend { + pub port: u16, + pub receipts: Arc>>, + pub release: Signal, + stop: Arc, + worker: Option>, +} +impl Backend { + pub fn start() -> Result { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + listener.set_nonblocking(true)?; + let receipts = Arc::new(Mutex::new(Vec::new())); + let release = Arc::new((Mutex::new(false), Condvar::new())); + let barrier = Arc::new((Mutex::new(0usize), Condvar::new())); + let stop = Arc::new(AtomicBool::new(false)); + let (r, s, b, done) = ( + receipts.clone(), + release.clone(), + barrier.clone(), + stop.clone(), + ); + let worker = thread::spawn(move || { + while !done.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => { + let (r, s, b) = (r.clone(), s.clone(), b.clone()); + thread::spawn(move || { + let _ = serve(stream, r, s, b); + }); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)) + } + Err(_) => break, + } + } + }); + Ok(Self { + port, + receipts, + release, + stop, + worker: Some(worker), + }) + } + pub fn url(&self, path: &str) -> String { + format!("http://127.0.0.1:{}{path}", self.port) + } +} +impl Drop for Backend { + fn drop(&mut self) { + release(&self.release); + self.stop.store(true, Ordering::Relaxed); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} +fn serve( + mut stream: TcpStream, + receipts: Arc>>, + signal: Signal, + barrier: Arc<(Mutex, Condvar)>, +) -> Result<()> { + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(10)))?; + let mut reader = BufReader::new(stream.try_clone()?); + let mut line = String::new(); + reader.read_line(&mut line)?; + let path = line + .split_whitespace() + .nth(1) + .ok_or("missing request path")? + .to_owned(); + loop { + line.clear(); + reader.read_line(&mut line)?; + if line == "\r\n" || line.is_empty() { + break; + } + } + receipts.lock().unwrap().push(path.clone()); + if path.starts_with("/barrier") { + let mut count = barrier.0.lock().unwrap(); + *count += 1; + barrier.1.notify_all(); + let (count, _) = barrier + .1 + .wait_timeout_while(count, Duration::from_secs(8), |n| *n < 2) + .map_err(|_| "barrier poisoned")?; + if *count < 2 { + stream.write_all(b"HTTP/1.1 504 Gateway Timeout\r\nContent-Length: 0\r\n\r\n")?; + return Ok(()); + } + } + if path.starts_with("/chunks") { + stream.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n5\r\nfirst\r\n")?; + stream.flush()?; + let (ready, _) = signal + .1 + .wait_timeout_while(signal.0.lock().unwrap(), Duration::from_secs(8), |v| !*v) + .map_err(|_| "release poisoned")?; + if *ready { + if path.contains("large=1") { + let body = vec![b'z'; 256 * 1024]; + write!(stream, "{:x}\r\n", body.len())?; + stream.write_all(&body)?; + stream.write_all(b"\r\n0\r\n\r\n")?; + } else { + stream.write_all(b"6\r\nsecond\r\n0\r\n\r\n")?; + } + } + } else if path.starts_with("/abort") { + stream.write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\npartial", + )?; + if path.contains("gated=1") { + stream.flush()?; + let (_observed, _) = signal + .1 + .wait_timeout_while(signal.0.lock().unwrap(), Duration::from_secs(8), |v| !*v) + .map_err(|_| "release poisoned")?; + } + } else { + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")?; + } + Ok(()) +} +pub fn port() -> Result { + Ok(TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port()) +} + +pub fn malformed_request(port: u16, token: &str) -> Result { + let mut stream = TcpStream::connect(("127.0.0.1", port))?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + write!( + stream, + "POST /probe/malformed HTTP/1.1\r\nHost: localhost\r\nx-request-token: {token}\r\nContent-Length: invalid\r\nConnection: close\r\n\r\n" + )?; + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line)?; + Ok(line.trim().to_owned()) +} +pub fn request( + port: u16, + path: &str, + token: &str, + backend: Option<&str>, + signal: Option<&Signal>, +) -> Result { + let start = Instant::now(); + let mut stream = TcpStream::connect(("127.0.0.1", port))?; + stream.set_read_timeout(Some(Duration::from_secs(12)))?; + stream.set_write_timeout(Some(Duration::from_secs(12)))?; + let probe = path.starts_with("/probe/"); + let body = if probe { token } else { "" }; + write!( + stream, + "{} {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nx-request-token: {token}\r\nContent-Length: {}\r\nConnection: close\r\n", + if probe { "POST" } else { "GET" }, + body.len() + )?; + if let Some(url) = backend { + write!(stream, "x-fixture-backend: {url}\r\n")?; + } + write!(stream, "\r\n{body}")?; + stream.flush()?; + let mut reader = BufReader::new(stream); + let status = read_status(&mut reader)?; + let mut line = String::new(); + let mut headers = Vec::new(); + let mut length = None; + let mut chunked = false; + loop { + line.clear(); + require(reader.read_line(&mut line)? > 0, "truncated HTTP headers")?; + if line == "\r\n" { + break; + } + let (name, value) = line + .trim_end() + .split_once(':') + .ok_or("malformed HTTP header")?; + let value = value.trim(); + if name.eq_ignore_ascii_case("content-length") { + length = Some(value.parse::()?); + } + if name.eq_ignore_ascii_case("transfer-encoding") { + chunked = value.to_ascii_lowercase().contains("chunked"); + } + headers.push(json!([name, value])); + } + let header_ns = start.elapsed().as_nanos() as u64; + let mut first_byte_ns = None; + let mut bytes = Vec::new(); + let outcome = (|| -> Result<()> { + let mut read_part = |reader: &mut BufReader, count: usize| -> Result<()> { + if count == 0 { + return Ok(()); + } + let mut first = [0]; + reader.read_exact(&mut first)?; + bytes.push(first[0]); + if first_byte_ns.is_none() { + first_byte_ns = Some(start.elapsed().as_nanos() as u64); + if let Some(signal) = signal { + release(signal); + } + } + let mut remaining = count - 1; + let mut buffer = [0u8; 8192]; + while remaining > 0 { + let size = remaining.min(buffer.len()); + let read = reader.read(&mut buffer[..size])?; + require(read > 0, "truncated HTTP body")?; + bytes.extend_from_slice(&buffer[..read]); + remaining -= read; + } + Ok(()) + }; + if chunked { + loop { + line.clear(); + require(reader.read_line(&mut line)? > 0, "truncated chunk header")?; + let count = usize::from_str_radix(line.trim().split(';').next().unwrap_or(""), 16)?; + if count == 0 { + loop { + line.clear(); + require(reader.read_line(&mut line)? > 0, "truncated chunk trailer")?; + if line == "\r\n" { + break; + } + } + break; + } + read_part(&mut reader, count)?; + let mut crlf = [0; 2]; + reader.read_exact(&mut crlf)?; + require(crlf == *b"\r\n", "invalid chunk terminator")?; + } + } else if let Some(length) = length { + read_part(&mut reader, length)?; + } else { + let mut first = [0]; + if reader.read(&mut first)? != 0 { + bytes.push(first[0]); + first_byte_ns = Some(start.elapsed().as_nanos() as u64); + if let Some(signal) = signal { + release(signal); + } + reader.read_to_end(&mut bytes)?; + } + } + Ok(()) + })(); + if let Err(error) = outcome { + return Err(Box::new(InterruptedResponse { + status, + body: bytes, + reason: error.to_string(), + })); + } + let event = json!({"event":"client_completed","token":token,"status":status,"headers":headers,"body":String::from_utf8(bytes)?,"header_ns":header_ns,"first_byte_ns":first_byte_ns.unwrap_or(header_ns),"complete_ns":start.elapsed().as_nanos() as u64}); + CLIENT_EVENTS.lock().unwrap().push(event.clone()); + Ok(event) +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_response_head_classification_excludes_timeouts_and_malformed_bytes() { + struct FailedRead(std::io::ErrorKind); + impl Read for FailedRead { + fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result { + Err(std::io::Error::from(self.0)) + } + } + let eof = read_status(&mut std::io::Cursor::new(b"")).unwrap_err(); + assert!(eof.downcast_ref::().is_some()); + for kind in [ + std::io::ErrorKind::ConnectionReset, + std::io::ErrorKind::TimedOut, + ] { + let error = read_status(&mut BufReader::new(FailedRead(kind))).unwrap_err(); + assert_eq!( + error.downcast_ref::().is_some(), + kind == std::io::ErrorKind::ConnectionReset + ); + } + let invalid = read_status(&mut std::io::Cursor::new(b"invalid\r\n")).unwrap_err(); + assert!(invalid.downcast_ref::().is_none()); + assert_eq!( + read_status(&mut std::io::Cursor::new(b"HTTP/1.1 500 Error\r\n")).unwrap(), + 500 + ); + } + + #[test] + fn nonblocking_connection_waits_for_request_bytes() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let (server, _) = listener.accept().unwrap(); + server.set_nonblocking(true).unwrap(); + let worker = thread::spawn(move || { + serve( + server, + Arc::new(Mutex::new(Vec::new())), + Arc::new((Mutex::new(false), Condvar::new())), + Arc::new((Mutex::new(0), Condvar::new())), + ) + }); + thread::sleep(Duration::from_millis(100)); + client + .write_all(b"GET /ok HTTP/1.1\r\nHost: localhost\r\n\r\n") + .unwrap(); + let mut reply = String::new(); + client.read_to_string(&mut reply).unwrap(); + worker.join().unwrap().unwrap(); + assert!(reply.starts_with("HTTP/1.1 200")); + } + #[test] + fn progressive_body_and_abort() { + let backend = Backend::start().unwrap(); + let result = request(backend.port, "/chunks", "x", None, Some(&backend.release)).unwrap(); + assert_eq!(result["body"], "firstsecond"); + let failure = request(backend.port, "/abort", "x", None, None).unwrap_err(); + let partial = failure + .downcast_ref::() + .expect("response started"); + assert_eq!(partial.status, 200); + assert_eq!(partial.body, b"partial"); + } + + #[test] + fn gated_abort_waits_for_client_observation_before_closing() { + let backend = Backend::start().unwrap(); + let mut stream = TcpStream::connect(("127.0.0.1", backend.port)).unwrap(); + stream + .set_read_timeout(Some(Duration::from_millis(200))) + .unwrap(); + stream + .write_all(b"GET /abort?gated=1 HTTP/1.1\r\nHost: localhost\r\n\r\n") + .unwrap(); + let mut reader = BufReader::new(stream); + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" { + break; + } + } + let mut partial = [0; 7]; + reader.read_exact(&mut partial).unwrap(); + assert_eq!(&partial, b"partial"); + let before_release = reader.read(&mut [0]); + release(&backend.release); + assert!(matches!( + before_release, + Err(error) if matches!(error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) + )); + assert_eq!(reader.read(&mut [0]).unwrap(), 0); + } + + #[test] + fn connection_failure_is_not_an_interrupted_response() { + let unused = port().unwrap(); + let failure = request(unused, "/abort", "x", None, None).unwrap_err(); + assert!(failure.downcast_ref::().is_none()); + } +} diff --git a/tests/fixtures/reusable-app/crates/fixture-harness/src/runners.rs b/tests/fixtures/reusable-app/crates/fixture-harness/src/runners.rs new file mode 100644 index 00000000..28a99d0d --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-harness/src/runners.rs @@ -0,0 +1,1202 @@ +use crate::{evidence::*, net::Backend, *}; +use std::collections::HashMap; +fn cargo_build() -> Command { + let mut command = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())); + command + .current_dir(root()) + .env("CARGO_TARGET_DIR", root().join("target")); + command +} + +fn built_executable(output: &[u8], name: &str) -> Result { + String::from_utf8_lossy(output) + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find_map(|record| { + (record["reason"] == "compiler-artifact" && record["target"]["name"] == name) + .then(|| record["executable"].as_str().map(PathBuf::from)) + .flatten() + }) + .ok_or_else(|| format!("Cargo did not report an executable for {name}").into()) +} +/// Copies a built artifact (file or directory) into the run directory, so a +/// concurrent build into the shared target cannot swap it under a running +/// server; the copy is what gets served and fingerprinted. +fn stage(from: &Path, run: &Path) -> Result { + let to = run + .join("artifacts") + .join(from.file_name().ok_or("artifact has no file name")?); + copy_tree(from, &to)?; + Ok(to) +} +fn copy_tree(from: &Path, to: &Path) -> Result<()> { + if from.is_dir() { + fs::create_dir_all(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + copy_tree(&entry.path(), &to.join(entry.file_name()))?; + } + } else { + fs::create_dir_all(to.parent().ok_or("artifact has no parent")?)?; + fs::copy(from, to)?; + } + Ok(()) +} +/// Teardown shared by every runtime run. The runtime lives inside `outcome`'s +/// computation, so it has stopped by now: record client completions and guest +/// logs, then a failure carrying `fields` and `failure` context. +fn settle( + records: &mut Records, + log: &Path, + fields: Value, + failure: Value, + outcome: Result<()>, +) -> Result<()> { + records.collect_clients(fields.clone())?; + for event in logs(log) { + records.push(annotate(event, fields.clone()))?; + } + if let Err(error) = &outcome { + records.push(annotate( + annotate(json!({"status":"fail","error":error.to_string()}), fields), + failure, + ))?; + } + outcome +} +fn wrangler_runtime_config(package: &Path, main: &Path) -> Result { + let config = fs::read_to_string(package.join("wrangler.toml"))?; + let mut skip = false; + let mut lines = Vec::new(); + for line in config.lines() { + if line.trim().starts_with('[') { + skip = line.trim() == "[build]"; + } + if skip { + continue; + } + lines.push(if line.starts_with("main =") { + format!("main = {main:?}") + } else { + line.to_owned() + }); + } + Ok(lines.join("\n") + "\n") +} + +fn wasm(run: &Path, variant: &str) -> PathBuf { + run.join("artifacts").join(wasm_name(variant)) +} +fn wasm_name(variant: &str) -> String { + let name = match variant { + "a" => "single-request", + "b" => "rebuild-per-request", + "c" => "retained-app", + "custom-a" => "custom-single-request", + "custom-b" => "custom-rebuild-per-request", + "custom-c" => "custom-retained-app", + other => other, + }; + format!("fixture-fastly-{name}.wasm") +} +fn stage_fastly_artifacts(run: &Path) -> Result<()> { + let release = root().join("target/wasm32-wasip1/release"); + for variant in [ + "a", + "b", + "c", + "custom-a", + "custom-b", + "custom-c", + "logger-negative", + "limit-requests", + "limit-lifetime", + "limit-memory", + "faults", + ] { + stage(&release.join(wasm_name(variant)), run)?; + } + Ok(()) +} +fn fastly_command(exe: &Path, config: &Path, run: &Path, variant: &str, port: u16) -> Command { + let mut cmd = Command::new(exe); + cmd.args(["serve", "--addr", &format!("127.0.0.1:{port}"), "--config"]) + .arg(config) + .arg(wasm(run, variant)); + cmd +} + +fn custom_initialization_contract( + exe: &Path, + config: &Path, + run: &Path, + records: &mut Records, +) -> Result<()> { + let log = run.join("custom-initialization.log"); + let outcome = (|| -> Result<()> { + let port = net::port()?; + let _process = Process::start( + &mut fastly_command(exe, config, run, "custom-c", port), + &log, + port, + )?; + let mut instances = Vec::new(); + for (path, status, attempts, initialized) in [ + ("/health", 200, 0, false), + ("/initialization-error", 503, 1, false), + ("/health", 200, 1, false), + ("/probe/recovered", 200, 2, true), + ("/probe/retained", 200, 2, true), + ] { + let reply = req(port, path)?; + require( + reply["status"] == status, + "custom initialization status mismatch", + )?; + let value: Value = serde_json::from_str(reply["body"].as_str().ok_or("missing body")?)?; + instances.push(value["instance"].clone()); + let same_instance = instances.iter().all(|id| id == &instances[0]); + if same_instance { + require( + value["ordinal"] == instances.len(), + "recovery ordinal mismatch", + )?; + if initialized { + require(value["builds"] == 1, "recovered application was rebuilt")?; + } else { + require( + value["retained"] == false, + "health or failure retained an app", + )?; + } + require( + reply["headers"].as_array().unwrap().iter().any(|h| { + h[0].as_str() + .is_some_and(|name| name.eq_ignore_ascii_case("x-fixture-attempts")) + && h[1].as_str().and_then(|v| v.parse::().ok()) == Some(attempts) + }), + "lazy build attempt count mismatch", + )?; + } + records.push(annotate( + reply, + json!({"variant":"custom-initialization", "guest":value}), + ))?; + } + records.push(json!({"assertion":"custom_lazy_initialization_recovery", "status":if instances.iter().all(|id| id == &instances[0]) {"pass"} else {"unverified"}, "reason":"recovery requires all five callbacks in the same guest"})) + })(); + settle( + records, + &log, + json!({"variant":"custom-initialization","repetition":0}), + json!({"adapter":"fastly"}), + outcome, + ) +} + +pub fn fastly(args: &Args, run: &Path, records: &mut Records) -> Result<()> { + let Some(exe) = executable("VICEROY_BIN", "viceroy") else { + return records.push(json!({"status":"unsupported","reason":"Viceroy unavailable"})); + }; + let version = Command::new(&exe).arg("--version").output()?; + require(version.status.success(), "Viceroy version command failed")?; + let version = String::from_utf8(version.stdout)?.trim().to_owned(); + let pins = fs::read_to_string(root().join("../../../.tool-versions"))?; + let pin = pins + .lines() + .find_map(|line| line.strip_prefix("viceroy ")) + .map(str::trim) + .ok_or("missing Viceroy pin")?; + records.push(json!({"event":"runtime","adapter":"fastly","executable":exe,"version":version,"expected":pin}))?; + if version.split_whitespace().nth(1) != Some(pin) { + // Keep going: an unpinned Viceroy still gets signal, and a failure still exits 1. + records.push(json!({"status":"unverified","adapter":"fastly","reason":format!("Viceroy version {version} does not match pinned {pin}")}))?; + } + checked( + cargo_build() + .args([ + "build", + "--locked", + "--release", + "-p", + "fixture-fastly", + "--bins", + "--target", + "wasm32-wasip1", + ]) + .env("FIXTURE_BUILD_ROUNDS", args.construction_rounds.to_string()) + .env("FIXTURE_MAX_REQUESTS", args.max_requests.to_string()), + )?; + stage_fastly_artifacts(run)?; + let config_snapshot = run.join("fastly.toml"); + fs::copy( + root().join("crates/fixture-fastly/fastly.toml"), + &config_snapshot, + )?; + if args.suite == "smoke" { + custom_initialization_contract(&exe, &config_snapshot, run, records)?; + } + let mut variants = vec!["a", "b", "c", "custom-a", "custom-b", "custom-c"]; + if args.suite == "smoke" { + variants.extend(["limit-requests", "limit-lifetime", "limit-memory"]); + } + let mut rng = args.seed; + for repetition in 0..if args.suite == "benchmark" { + args.repetitions + } else { + 1 + } { + // A recorded deterministic Fisher-Yates order; independent of language RNG versions. + for i in (1..variants.len()).rev() { + rng = rng + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + variants.swap(i, (rng as usize) % (i + 1)); + } + records + .push(json!({"event":"variant_order","repetition":repetition,"variants":variants}))?; + for variant in &variants { + fastly_variant( + args, + &exe, + &config_snapshot, + run, + variant, + repetition, + records, + )?; + } + } + if args.suite == "smoke" { + logging(&exe, run, records)?; + faults(&exe, run, records)?; + } + if records + .values + .iter() + .any(|e| e["event"] == "heap_preflight" && e["heap_mib"].is_null()) + { + records.push(json!({"status":"unsupported","reason":"heap snapshot unavailable; memory limit unverified"}))?; + } + Ok(()) +} +fn fastly_variant( + args: &Args, + exe: &Path, + config: &Path, + run: &Path, + variant: &str, + repetition: usize, + records: &mut Records, +) -> Result<()> { + records.push(json!({"event":"build","variant":variant,"repetition":repetition,"target":"wasm32-wasip1","artifact":artifact(&wasm(run, variant))?,"config":artifact(config)?}))?; + let log = run.join(format!("{variant}-{repetition}.log")); + let outcome = (|| -> Result<()> { + let backend = Backend::start()?; + let port = net::port()?; + let _process = Process::start( + &mut fastly_command(exe, config, run, variant, port), + &log, + port, + )?; + let seen = fastly_probes(args, port, variant, repetition, records)?; + if ["c", "custom-c"].contains(&variant) { + thread::sleep(Duration::from_secs(2)); + let reply = req(port, "/probe/after-idle")?; + require( + !seen.contains_key( + guest(&reply)?["instance"] + .as_str() + .ok_or("missing instance")?, + ), + "idle guest was reused", + )?; + records.push(annotate( + reply, + json!({"variant":variant,"repetition":repetition,"assertion":"idle_fresh_initialization"}), + ))?; + } + let bindings = req(port, "/bindings")?; + require( + guest(&bindings)? == json!({"config":true,"kv":true,"secrets":true,"unknown":false}), + "binding mismatch", + )?; + records.push(annotate( + bindings, + json!({"variant":variant,"repetition":repetition,"assertion":"binding_reads"}), + ))?; + require( + cookie_values(&req(port, "/cookies")?["headers"]) + == vec!["first=1; Path=/", "second=2; Path=/"], + "duplicate cookies lost", + )?; + require( + req(port, "/rendered-error")?["status"] == 400, + "rendered error mismatch", + )?; + require( + req(port, "/probe/after")?["status"] == 200, + "request after error failed", + )?; + if variant.starts_with("custom-") { + custom_stream_checks(port, &backend, &log, variant, repetition, records)?; + } + if args.suite == "smoke" { + smoke_origin_checks(port, &backend, &log, variant, repetition, records)?; + } + if variant.starts_with("limit-") { + await_evidence(|| validate_limit_summaries(&logs(&log)))?; + } + records.push(json!({"status":"pass","variant":variant,"repetition":repetition,"assertions":["initialization","isolation","cookies","rendered_error"]})) + })(); + settle( + records, + &log, + json!({"variant":variant,"repetition":repetition}), + json!({"adapter":"fastly"}), + outcome, + ) +} +fn fastly_probes( + args: &Args, + port: u16, + variant: &str, + repetition: usize, + records: &mut Records, +) -> Result> { + let mut seen: HashMap = HashMap::new(); + let mut reused = false; + for _ in 0..if args.suite == "benchmark" { + args.requests + } else { + 5 + } { + let token = token(); + let reply = net::request(port, &format!("/probe/{token}"), &token, None, None)?; + let value = guest(&reply)?; + require( + value["token"] == token && value["path"] == token && value["body"] == token, + format!("request isolation: {value}"), + )?; + if let Some(previous) = seen.get(value["instance"].as_str().ok_or("missing instance")?) { + reused = true; + require( + value["ordinal"].as_u64() > previous["ordinal"].as_u64() + && value["correlation"] != previous["correlation"], + "reuse ordinal/correlation mismatch", + )?; + } + require( + value["mutated"] == variant.starts_with("custom-"), + "raw request mutation mismatch", + )?; + if variant.starts_with("custom-") { + require( + reply["headers"].as_array().unwrap().iter().any(|h| { + h[0].as_str() + .is_some_and(|name| name.eq_ignore_ascii_case("x-fixture-finalized")) + && h[1] == token + }), + "request-specific response extension was lost or leaked", + )?; + } + match variant { + "c" | "custom-c" => require( + value["ordinal"] + .as_u64() + .is_some_and(|n| n <= args.max_requests as u64) + && value["builds"] == 1, + "retained app initialization mismatch", + )?, + "b" | "custom-b" => require( + value["builds"] == value["ordinal"], + "per-request initialization mismatch", + )?, + _ => require( + value["ordinal"] == 1 && value["builds"] == 1, + "single-request initialization mismatch", + )?, + } + validate_shared_state(&value, ["c", "custom-c"].contains(&variant))?; + seen.insert(value["instance"].as_str().unwrap().into(), value.clone()); + records.push(annotate( + reply, + json!({"variant":variant,"repetition":repetition,"guest":value,"workload":"probe"}), + ))?; + } + if ["b", "c", "custom-b", "custom-c"].contains(&variant) && !reused { + records.push(json!({"status":"unverified","variant":variant,"repetition":repetition,"reason":"no actual guest reuse observed"}))?; + } + Ok(seen) +} +fn custom_stream_checks( + port: u16, + backend: &Backend, + log: &Path, + variant: &str, + repetition: usize, + records: &mut Records, +) -> Result<()> { + let token = token(); + let stream = net::request( + port, + "/stream", + &token, + Some(&backend.url(&format!("/chunks?token={token}"))), + Some(&backend.release), + )?; + require( + stream["body"] == "firstsecond", + "progressive stream mismatch", + )?; + require( + stream["headers"].as_array().unwrap().iter().any(|h| { + h[0].as_str() + .is_some_and(|s| s.eq_ignore_ascii_case("x-fixture-finalized")) + && h[1] == token + }), + "response not finalized", + )?; + await_evidence(|| validate_post_send(&token, &backend.receipts.lock().unwrap(), &logs(log)))?; + records.push(annotate( + stream, + json!({"variant":variant,"repetition":repetition,"assertion":"progressive_stream_and_post_send"}), + ))?; + *backend.release.0.lock().unwrap() = false; + let large_token = crate::token(); + let large = net::request( + port, + "/stream", + &large_token, + Some(&backend.url("/chunks?large=1")), + Some(&backend.release), + )?; + let large_body = large["body"].as_str().ok_or("missing large body")?; + require( + large_body.starts_with("first") && large_body.len() == 5 + 256 * 1024, + "large stream truncated", + )?; + records.push(annotate( + large, + json!({"variant":variant,"repetition":repetition,"assertion":"large_progressive_stream"}), + ))?; + // Trigger truncation only after the downstream client has observed the partial body. + *backend.release.0.lock().unwrap() = false; + let failure_token = crate::token(); + let failure = net::request( + port, + "/stream-error", + &failure_token, + Some(&backend.url(&format!("/abort?gated=1&token={failure_token}"))), + Some(&backend.release), + ); + let failure = match failure { + Ok(reply) => { + return Err(format!("interrupted stream unexpectedly completed: {reply}").into()); + } + Err(error) => error, + }; + let partial = failure + .downcast_ref::() + .ok_or("stream failure occurred before response body: not post-commit evidence")?; + require( + partial.status == 200 && partial.body == b"partial", + "missing partial committed response", + )?; + await_evidence(|| { + require( + logs(log) + .iter() + .any(|e| e["event"] == "post_commit_error" && e["token"] == failure_token), + "missing correlated post-commit error", + ) + })?; + records.push(json!({"event":"expected_stream_error","variant":variant,"repetition":repetition,"token":failure_token,"status_code":partial.status,"partial_body":String::from_utf8_lossy(&partial.body),"source":"controlled_backend_abort"})) +} +fn smoke_origin_checks( + port: u16, + backend: &Backend, + log: &Path, + variant: &str, + repetition: usize, + records: &mut Records, +) -> Result<()> { + for repeat in 0..3 { + let reply = net::request( + port, + "/origin/repeated", + &token(), + Some(&backend.url("/ok")), + None, + )?; + require( + reply["status"] == 200 && reply["body"] == "ok", + "repeated origin failed", + )?; + records.push(annotate(reply, json!({"variant":variant,"repetition":repetition,"origin_repeat":repeat,"assertion":"bounded_repeated_origin"})))?; + } + for origin in 0..3 { + let distinct = Backend::start()?; + let reply = net::request( + port, + &format!("/origin/{origin}"), + &token(), + Some(&distinct.url("/ok")), + None, + )?; + require( + reply["status"] == 200 && reply["body"] == "ok", + "distinct origin failed", + )?; + records.push(annotate( + reply, + json!({"variant":variant,"repetition":repetition,"assertion":"bounded_distinct_origin"}), + ))?; + } + if ["custom-b", "custom-c"].contains(&variant) { + let token = token(); + if let Ok(failed) = net::request(port, "/panic", &token, None, None) { + require( + failed["status"].as_u64().is_some_and(|s| s >= 500), + "panic did not fail", + )?; + } + let fresh = req(port, "/probe/fresh")?; + let value = guest(&fresh)?; + let mut failed_guest = Value::Null; + await_evidence(|| { + failed_guest = logs(log) + .iter() + .find(|e| e["event"] == "request_start" && e["token"] == token) + .map(|e| e["instance"].clone()) + .ok_or("missing panic attempt")?; + Ok(()) + })?; + require(value["instance"] != failed_guest, "terminated guest reused")?; + records.push(annotate(fresh,json!({"variant":variant,"repetition":repetition,"assertion":"terminated_guest_not_reused","failed_guest":failed_guest})))?; + } + Ok(()) +} +fn logging(exe: &Path, run: &Path, records: &mut Records) -> Result<()> { + let Some(service) = records + .values + .iter() + .find_map(|e| e["service_id"].as_str()) + .map(str::to_owned) + else { + return records.push(json!({"status":"unverified","reason":"service ID unavailable for logging configuration"})); + }; + let base = fs::read_to_string(root().join("crates/fixture-fastly/fastly.toml"))?; + let config = run.join("logging.toml"); + fs::write( + &config, + format!( + "{base}\n[local_server.config_stores.edgezero_runtime_env]\nformat = \"inline-toml\"\n[local_server.config_stores.edgezero_runtime_env.contents]\n\"EDGEZERO__SERVICES__{service}__LOGGING__ENDPOINT\" = \"fixture-logs\"\n" + ), + )?; + for variant in ["b", "c", "custom-c", "logger-negative"] { + let log = run.join(format!("logging-{variant}.log")); + let outcome = (|| -> Result<()> { + let port = net::port()?; + let process = Process::start( + &mut fastly_command(exe, &config, run, variant, port), + &log, + port, + )?; + let mut replies = Vec::new(); + for _ in 0..if variant == "logger-negative" { 2 } else { 3 } { + replies.push(req(port, "/probe/log")?); + } + drop(process); + records.push(json!({"event":"logging_check","variant":variant,"statuses":replies.iter().map(|r|r["status"].clone()).collect::>(),"log_file":log.file_name().map(|n| n.to_string_lossy())}))?; + if variant == "logger-negative" { + let starts = logs(&log) + .into_iter() + .filter(|e| e["event"] == "request_start") + .collect::>(); + let status = validate_negative_logging(&replies, &starts)?; + records.push(json!({"status":status,"assertion":"repeated_logger_installation","variant":variant}))?; + } else { + let guests = replies.iter().map(guest).collect::>>()?; + let text = fs::read_to_string(&log)?; + let delivered = text + .lines() + .filter(|l| l.starts_with("fixture-logs :: ")) + .filter_map(|l| { + l.rsplit_once("fixture request ") + .map(|(_, v)| v.trim().to_owned()) + }) + .collect::>(); + let status = validate_logging(&guests, &delivered)?; + records.push(json!({"status":status,"assertion":"named_endpoint_receipt","variant":variant,"received":delivered.len()}))?; + } + Ok(()) + })(); + settle( + records, + &log, + json!({"variant":format!("logging-{variant}"),"repetition":0}), + json!({"adapter":"fastly"}), + outcome, + )?; + } + Ok(()) +} +pub fn provider(name: &str, args: &Args, run: &Path, records: &mut Records) -> Result<()> { + let package = root().join("crates").join(format!("fixture-{name}")); + let Some((exe, staged)) = provider_build(name, &package, run, records)? else { + return Ok(()); + }; + let modes = if name == "axum" { + vec!["retained"] + } else { + vec!["per-request", "retained"] + }; + for mode in modes { + let port = net::port()?; + let mut command = provider_command(name, mode, &exe, &staged, &package, run, port)?; + let log = run.join(format!("{name}-{mode}.log")); + let mut failure = json!({"phase":"startup"}); + let outcome = Process::start(&mut command, &log, port) + .and_then(|_process| provider_checks(name, mode, port, args, records, &mut failure)); + settle( + records, + &log, + json!({"adapter":name,"mode":mode}), + failure, + outcome, + )?; + } + Ok(()) +} +/// Builds the provider fixture and stages its artifact in the run directory. +/// Returns the runtime executable and the staged artifact, or `None` when a +/// required tool is unavailable. +fn provider_build( + name: &str, + package: &Path, + run: &Path, + records: &mut Records, +) -> Result> { + if name == "axum" { + let output = cargo_build() + .args([ + "build", + "--locked", + "-p", + "fixture-axum", + "--message-format=json-render-diagnostics", + ]) + .stderr(Stdio::inherit()) + .output()?; + require(output.status.success(), "Axum fixture build failed")?; + let staged = stage(&built_executable(&output.stdout, "fixture-axum")?, run)?; + records.push(json!({"event":"build","adapter":name,"artifact":artifact(&staged)?}))?; + return Ok(Some((staged.clone(), staged))); + } + let (variable, tool) = if name == "cloudflare" { + ("WRANGLER_BIN", "wrangler") + } else { + ("SPIN_BIN", "spin") + }; + let Some(exe) = executable(variable, tool) else { + records.push( + json!({"status":"unsupported","adapter":name,"reason":format!("{tool} unavailable")}), + )?; + return Ok(None); + }; + records.push(json!({"event":"runtime","adapter":name,"executable":exe,"version":String::from_utf8_lossy(&Command::new(&exe).arg("--version").output()?.stdout).trim()}))?; + let (staged, wasm) = if name == "spin" { + let help = Command::new(&exe).args(["up", "--help"]).output()?; + fs::write(run.join("spin-up-help.txt"), &help.stdout)?; + records.push(json!({"event":"runtime_controls","adapter":"spin","selected":"host defaults","source":"spin-up-help.txt"}))?; + checked(cargo_build().args([ + "build", + "--locked", + "--release", + "-p", + "fixture-spin", + "--target", + "wasm32-wasip2", + ]))?; + let staged = stage( + &root().join("target/wasm32-wasip2/release/fixture_spin.wasm"), + run, + )?; + (staged.clone(), staged) + } else { + let Some(builder) = executable("WORKER_BUILD_BIN", "worker-build") else { + records.push(json!({"status":"unsupported","reason":"worker-build unavailable"}))?; + return Ok(None); + }; + checked( + Command::new(builder) + .args(["--release", ".", "--", "--locked"]) + .env("CARGO_TARGET_DIR", root().join("target")) + .current_dir(package), + )?; + let staged = stage(&package.join("build"), run)?; + // Only the explicit builder runs before artifact fingerprinting. + fs::write( + run.join("wrangler.toml"), + wrangler_runtime_config(package, &staged.join("worker/shim.mjs"))?, + )?; + let wasm = staged.join("index_bg.wasm"); + (staged, wasm) + }; + records.push(json!({"event":"build","adapter":name,"artifact":artifact(&wasm)?}))?; + Ok(Some((exe, staged))) +} +fn provider_command( + name: &str, + mode: &str, + exe: &Path, + staged: &Path, + package: &Path, + run: &Path, + port: u16, +) -> Result { + let mut command = Command::new(exe); + command + .env("CARGO_TARGET_DIR", root().join("target")) + .current_dir(package) + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", port.to_string()); + if name == "axum" { + let state = run.join("axum-state"); + fs::create_dir_all(state.join(".edgezero"))?; + fs::write( + state.join(".edgezero/local-config-fixture_config.json"), + "{\"marker\":\"local-fixture\"}", + )?; + command + .current_dir(state) + .env("fixture_marker", "fixture-only-value"); + } else if name == "cloudflare" { + let state = run.join(mode).join("worker-state"); + checked( + Command::new(exe) + .args([ + "kv", + "key", + "put", + "marker", + "local-fixture", + "--binding", + "fixture_config", + "--local", + "--persist-to", + ]) + .arg(&state) + .current_dir(package), + )?; + command + .arg("--config") + .arg(run.join("wrangler.toml")) + .args([ + "dev", + "--local", + "--ip", + "127.0.0.1", + "--port", + &port.to_string(), + "--var", + &format!("FIXTURE_MODE:{mode}"), + "--persist-to", + ]) + .arg(state); + } else { + let manifest = fs::read_to_string(package.join("spin.toml"))? + .replace( + "../../target/wasm32-wasip2/release/fixture_spin.wasm", + &staged.display().to_string(), + ) + .replace( + "FIXTURE_MODE = \"retained\"", + &format!("FIXTURE_MODE = \"{mode}\""), + ); + let config = run.join(format!("spin-{mode}.toml")); + fs::write(&config, manifest)?; + command + .args(["up", "--listen", &format!("127.0.0.1:{port}"), "--from"]) + .arg(config) + .arg("--runtime-config-file") + .arg(package.join("runtime-config.toml")); + } + Ok(command) +} +/// Runs the provider assertions against a listening runtime, keeping +/// `failure` pointed at the current phase so a failure record names its step. +fn provider_checks( + name: &str, + mode: &str, + port: u16, + args: &Args, + records: &mut Records, + failure: &mut Value, +) -> Result<()> { + let mut seen: HashMap = HashMap::new(); + let mut reused = false; + let repetitions = if args.suite == "benchmark" { + args.repetitions + } else { + 1 + }; + let requests = if args.suite == "benchmark" { + args.requests + } else { + 5 + }; + for repetition in 0..repetitions { + *failure = json!({"phase":"probe","repetition":repetition}); + for _ in 0..requests { + let token = token(); + let reply = net::request(port, &format!("/probe/{token}"), &token, None, None)?; + let value = guest(&reply)?; + require( + value["token"] == token && value["path"] == token && value["body"] == token, + "probe request isolation mismatch", + )?; + if mode == "retained" { + require(value["builds"] == 1, "retained initialization mismatch")?; + } + validate_shared_state(&value, mode == "retained")?; + let instance = value["instance"].as_str().ok_or("missing instance")?; + if let Some(previous) = seen.get(instance) { + reused = true; + require( + value["ordinal"].as_u64() > previous["ordinal"].as_u64(), + "ordinal did not increase", + )?; + if mode == "per-request" { + require( + value["builds"].as_u64() > previous["builds"].as_u64(), + "per-request app was not rebuilt", + )?; + } + } + seen.insert(instance.into(), value.clone()); + records.push(annotate( + reply, + json!({"guest":value,"adapter":name,"mode":mode,"repetition":repetition,"workload":"probe"}), + ))?; + } + } + if !reused { + records.push(json!({"status":"unverified","adapter":name,"mode":mode,"reason":"no same-guest sequential reuse"}))?; + } + if mode == "retained" && name != "axum" { + *failure = json!({"phase":"binding_failure"}); + let failed = req(port, "/binding-failure")?; + require( + failed["status"] == 503, + "injected required binding did not fail", + )?; + let failed_guest: Value = serde_json::from_str(failed["body"].as_str().unwrap())?; + require( + failed_guest["error"] + .as_str() + .is_some_and(|error| error.contains("missing_fixture_kv")), + "binding failure did not identify the injected missing KV binding", + )?; + let recovered = guest(&req(port, "/probe/after-binding-failure")?)?; + require( + recovered["builds"] == 1, + "app rebuilt after binding failure", + )?; + records.push(json!({"adapter":name,"mode":mode,"status":if failed_guest["instance"] == recovered["instance"] {"pass"} else {"unverified"},"assertion":"same_guest_binding_recovery","source":"injected_required_binding"}))?; + } + *failure = json!({"phase":"bindings"}); + let bindings = req(port, "/bindings")?; + require( + guest(&bindings)? == json!({"config":true,"kv":true,"secrets":true,"unknown":false}), + "binding mismatch", + )?; + records.push(annotate( + bindings, + json!({"adapter":name,"mode":mode,"assertion":"binding_reads"}), + ))?; + *failure = json!({"phase":"cookies"}); + let cookies = req(port, "/cookies")?; + if cookie_values(&cookies["headers"]) != vec!["first=1; Path=/", "second=2; Path=/"] { + records.push(annotate(cookies,json!({"status":"fail","adapter":name,"mode":mode,"phase":"cookies","reason":"duplicate Set-Cookie values were not preserved"})))?; + } + *failure = json!({"phase":"rendered_error"}); + require( + req(port, "/rendered-error")?["status"] == 400, + "rendered error mismatch", + )?; + if name != "axum" { + *failure = json!({"phase":"alternate_app"}); + require( + req(port, "/other")?["body"] == "other-app", + "alternate app mismatch", + )?; + } + *failure = json!({"phase":"overlap"}); + overlap_check(name, mode, port, records) +} +fn overlap_check(name: &str, mode: &str, port: u16, records: &mut Records) -> Result<()> { + let mut observed_overlap = false; + for attempt in 0..3 { + let backend = Backend::start()?; + let tokens = [token(), token()]; + let replies = thread::scope(|scope| { + let jobs = tokens + .iter() + .map(|token| { + let url = backend.url("/barrier"); + scope.spawn(move || { + net::request(port, &format!("/overlap/{token}"), token, Some(&url), None) + }) + }) + .collect::>(); + jobs.into_iter() + .map(|job| { + job.join() + .map_err(|_| "overlap request thread panicked".into()) + .and_then(|v| v) + }) + .collect::>>() + })?; + let values = replies.iter().map(guest).collect::>>()?; + for (value, token) in values.iter().zip(&tokens) { + require( + value["token"] == *token + && value["before"]["token"] == *token + && value["path"] == *token + && value["before"]["path"] == *token, + "overlap request-local values changed", + )?; + } + if let Err(error) = validate_overlap(&values[0], &values[1]) { + records.push(json!({"event":"overlap_observation","adapter":name,"mode":mode,"attempt":attempt,"reason":error.to_string(),"observations":values}))?; + continue; + } + observed_overlap = true; + break; + } + records.push(json!({"status":if observed_overlap {"pass"} else {"unverified"},"adapter":name,"mode":mode,"assertion":"same_guest_overlap","attempt_limit":3})) +} + +fn faults(exe: &Path, run: &Path, records: &mut Records) -> Result<()> { + let backend = Backend::start()?; + let config = run.join("faults.toml"); + fs::write( + &config, + format!( + "{}\n[local_server.backends.fault-origin]\nurl = {:?}\n", + fs::read_to_string(root().join("crates/fixture-fastly/fastly.toml"))?, + backend.url("/") + ), + )?; + for (index, path) in [ + "/constructor-panic", + "/collect-error", + "/inbound-error", + "/terminal-store-error", + "/recoverable-store-error", + ] + .iter() + .enumerate() + { + let log = run.join(format!("fault-{index}.log")); + let outcome = (|| -> Result<()> { + let port = net::port()?; + let _process = Process::start( + &mut fastly_command(exe, &config, run, "faults", port), + &log, + port, + )?; + let malformed_token = token(); + let rejected = net::malformed_request(port, &malformed_token)?; + require( + rejected.contains(" 400 "), + format!("malformed request was not rejected: {rejected}"), + )?; + require( + !logs(&log).iter().any(|e| e["token"] == malformed_token), + "malformed request reached callback unexpectedly", + )?; + records.push(json!({"status":"pass","assertion":"malformed_rejected_before_dispatch","token":malformed_token,"status_line":rejected}))?; + let failed_token = token(); + let reply = if *path == "/collect-error" { + let failure = net::request(port, path, &failed_token, None, None) + .err() + .ok_or("owned egress must abandon the failed response stream")?; + if let Some(partial) = failure.downcast_ref::() { + require( + partial.status == 200 && partial.body.is_empty(), + "egress source failure sent a replacement response", + )?; + json!({"status":partial.status,"body":"","interrupted":true}) + } else { + // An immediate source error can reset delivery before the head is observed. + require( + failure.downcast_ref::().is_some(), + format!("unexpected response-head failure: {failure}"), + )?; + json!({"no_response_head":true,"interrupted":true,"error":failure.to_string()}) + } + } else { + net::request(port, path, &failed_token, None, None)? + }; + let recoverable = *path == "/recoverable-store-error"; + let rendered_ingress = *path == "/inbound-error"; + require( + (*path == "/collect-error" && reply["no_response_head"] == true) + || reply["status"] + == if *path == "/collect-error" { + 200 + } else if recoverable { + 503 + } else { + 500 + }, + format!("fault not observed: {path}: {reply}"), + )?; + let following = req(port, "/probe/recovered")?; + let value = guest(&following)?; + let mut failed_guest = Value::Null; + await_evidence(|| { + let events = logs(&log); + failed_guest = events + .iter() + .find(|e| e["event"] == "request_start" && e["token"] == failed_token) + .map(|e| e["instance"].clone()) + .ok_or("missing fault attempt")?; + let event = if *path == "/constructor-panic" { + "constructor_panic" + } else if rendered_ingress { + "ingress_error_response" + } else { + "adapter_error" + }; + require( + events + .iter() + .any(|e| e["event"] == event && e["instance"] == failed_guest), + "missing actual failure boundary", + ) + })?; + if *path == "/collect-error" { + let terminal = logs(&log) + .into_iter() + .filter(|event| { + event["event"] == "egress_terminal" && event["token"] == failed_token + }) + .collect::>(); + require( + terminal.len() == 1 + && terminal[0]["outcome"] == "SourceError" + && terminal[0]["body_kind"] == "Application" + && terminal[0]["bytes_written"] == 0 + && terminal[0]["fallback"].is_null(), + "immediate source failure must terminate once without fallback delivery", + )?; + } + if recoverable || rendered_ingress { + let status = if value["instance"] == failed_guest { + "pass" + } else { + "unverified" + }; + require( + value["builds"] == 1, + "retained app rebuilt after handled failure", + )?; + require( + guest(&req(port, "/bindings")?)? + == json!({"config":true,"kv":true,"secrets":true,"unknown":false}), + "bindings failed to recover", + )?; + if rendered_ingress { + records.push(json!({"status":status,"assertion":"admitted_body_failure_rendered","instance":failed_guest,"source":"canonical_admitted_request_dispatch"}))?; + } else { + records.push(json!({"status":status,"assertion":"injected_selector_failure_recovery","instance":failed_guest,"source":"custom_callback_handles_real_registry_error"}))?; + } + } else { + require( + value["instance"] != failed_guest, + "terminal failure guest reused", + )?; + if *path != "/constructor-panic" { + await_evidence(|| { + require( + logs(&log).iter().any(|e| { + e["event"] == "sdk_summary" + && e["instance"] == failed_guest + && e["attempted"] == 1 + }), + "missing terminal attempt summary", + ) + })?; + } + records.push(json!({"status":"pass","assertion":"terminal_fault","path":path,"failed_guest":failed_guest,"source":"controlled_fixture_input"}))?; + } + records.push(annotate( + reply, + json!({"variant":"faults","repetition":index,"assertion":path}), + ))?; + Ok(()) + })(); + settle( + records, + &log, + json!({"variant":"faults","repetition":index}), + json!({"adapter":"fastly","path":path}), + outcome, + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn cargo_artifacts_select_the_fresh_executable_in_a_configured_target() { + let output = concat!( + "{\"reason\":\"compiler-artifact\",\"target\":{\"name\":\"dependency\"},\"executable\":null}\n", + "{\"reason\":\"compiler-artifact\",\"target\":{\"name\":\"fixture-axum\"},\"executable\":\"/tmp/target/host-triple/debug/fixture-axum\"}\n", + "{\"reason\":\"build-finished\",\"success\":true}\n", + ); + assert_eq!( + built_executable(output.as_bytes(), "fixture-axum").unwrap(), + PathBuf::from("/tmp/target/host-triple/debug/fixture-axum") + ); + assert!(built_executable(b"", "fixture-axum").is_err()); + } + #[test] + fn wrangler_runtime_configuration_does_not_rebuild_fingerprinted_artifacts() { + let package = root().join("crates/fixture-cloudflare"); + let staged = Path::new("/run/artifacts/build/worker/shim.mjs"); + let config = wrangler_runtime_config(&package, staged).unwrap(); + assert!(!config.contains("[build]") && !config.contains("worker-build")); + assert!(config.contains(&format!("main = {staged:?}"))); + assert!(config.contains("binding = \"fixture_kv\"")); + } + #[test] + fn builds_share_a_fixed_target_directory() { + let command = cargo_build(); + let target = root().join("target"); + assert!( + command + .get_envs() + .any(|(key, value)| key == "CARGO_TARGET_DIR" && value == Some(target.as_os_str())) + ); + assert_eq!(command.get_current_dir(), Some(root().as_path())); + } + #[test] + fn served_artifacts_are_copies_inside_the_run_directory() { + let run = std::env::temp_dir().join(format!("fixture-harness-stage-{}", token())); + let source = run.join("source/fixture-fastly-retained-app.wasm"); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + fs::write(&source, b"first").unwrap(); + let staged = stage(&source, &run).unwrap(); + assert_eq!(staged, wasm(&run, "c")); + fs::write(&source, b"second").unwrap(); + assert_eq!(fs::read(&staged).unwrap(), b"first"); + fs::remove_dir_all(&run).unwrap(); + } +} diff --git a/tests/fixtures/reusable-app/crates/fixture-spin/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-spin/Cargo.toml new file mode 100644 index 00000000..7d3dbc8c --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-spin/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "fixture-spin" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[features] +qualification = ["fixture-core/qualification"] + +[dependencies] +serde_json.workspace = true +fixture-core.workspace = true +edgezero-core.workspace = true +edgezero-adapter-spin.workspace = true +spin-sdk = { workspace = true, features = ["http", "key-value"] } +anyhow = "1" + +[lib] +crate-type = ["cdylib", "rlib"] + +[lints] +workspace = true diff --git a/tests/fixtures/reusable-app/crates/fixture-spin/runtime-config.toml b/tests/fixtures/reusable-app/crates/fixture-spin/runtime-config.toml new file mode 100644 index 00000000..d126eb41 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-spin/runtime-config.toml @@ -0,0 +1,4 @@ +[key_value_store.fixture_config] +type = "spin" +[key_value_store.fixture_kv] +type = "spin" diff --git a/tests/fixtures/reusable-app/crates/fixture-spin/spin.toml b/tests/fixtures/reusable-app/crates/fixture-spin/spin.toml new file mode 100644 index 00000000..5f7a2362 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-spin/spin.toml @@ -0,0 +1,17 @@ +spin_manifest_version = 2 +[application] +name = "reusable-app-fixture" +version = "0.1.0" +[variables] +fixture_marker = { default = "fixture-only-value", secret = true } +[[trigger.http]] +route = "/..." +component = "fixture" +[component.fixture] +source = "../../target/wasm32-wasip2/release/fixture_spin.wasm" +allowed_outbound_hosts = ["http://127.0.0.1:*", "http://localhost:*"] +key_value_stores = ["fixture_config", "fixture_kv"] +[component.fixture.variables] +fixture_marker = "{{ fixture_marker }}" +[component.fixture.environment] +FIXTURE_MODE = "retained" diff --git a/tests/fixtures/reusable-app/crates/fixture-spin/src/lib.rs b/tests/fixtures/reusable-app/crates/fixture-spin/src/lib.rs new file mode 100644 index 00000000..a5188305 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-spin/src/lib.rs @@ -0,0 +1,75 @@ +#![cfg(target_arch = "wasm32")] +#![allow( + unsafe_code, + reason = "spin's #[http_service] macro generates the unsafe wasm export" +)] + +use anyhow::Context as _; +use edgezero_core::app::{App, Hooks}; +#[cfg(not(feature = "qualification"))] +use fixture_core::FixtureApp; +#[cfg(feature = "qualification")] +use fixture_core::qualification::QualificationApp as FixtureApp; +use fixture_core::{MissingBindingApp, OtherApp, retained_app}; +use spin_sdk::{ + http::{IntoResponse, Request}, + http_service, +}; +use std::sync::OnceLock; +static APP: OnceLock = OnceLock::new(); +static OTHER_APP: OnceLock = OnceLock::new(); + +fn retained_app_for_dispatch(cell: &OnceLock) -> anyhow::Result<&App> { + retained_app::(cell, edgezero_adapter_spin::SPIN_PLATFORM) + .context("application configuration failed") +} + +#[http_service] +async fn handle(req: Request) -> anyhow::Result { + if !FixtureApp::owns_logging() { + drop(edgezero_adapter_spin::init_logger()); + } + let retained = std::env::var("FIXTURE_MODE").as_deref() == Ok("retained"); + if retained && req.uri().path() == "/binding-failure" { + let app = App::build::(edgezero_adapter_spin::SPIN_PLATFORM) + .context("application configuration failed")?; + let result = edgezero_adapter_spin::dispatch_app::(&app, req).await; + return match result { + Err(error) => { + let body = serde_json::json!({"instance":fixture_core::instance_id(),"source":"injected_required_binding","error":format!("{error:#}")}).to_string(); + edgezero_core::http::response_builder() + .status(503) + .header("content-type", "application/json") + .body(body)? + .into_response() + .map_err(|error| { + anyhow::anyhow!("fixture response conversion failed: {error:?}") + }) + } + Ok(response) => Ok(response), + }; + } + if req.uri().path() == "/bindings" { + let store = spin_sdk::key_value::Store::open("fixture_config").await?; + store.set("marker", b"local-fixture").await?; + } + if req.uri().path() == "/other" { + if !retained { + return edgezero_adapter_spin::run_app::(req).await; + } + return edgezero_adapter_spin::dispatch_app::( + retained_app_for_dispatch::(&OTHER_APP)?, + req, + ) + .await; + } + if retained { + edgezero_adapter_spin::dispatch_app::( + retained_app_for_dispatch::(&APP)?, + req, + ) + .await + } else { + edgezero_adapter_spin::run_app::(req).await + } +}