From a158c23f86c25d1e53285982b68f5b4a7209fac4 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 16 Jun 2026 20:03:35 -0500 Subject: [PATCH 1/2] feat: add EdgeZero-backed ts CLI --- .cargo/config.toml | 4 + .github/workflows/format.yml | 3 + .github/workflows/test.yml | 3 + .gitignore | 1 + CLAUDE.md | 21 +- Cargo.lock | 1544 ++++++++++++++++- Cargo.toml | 13 +- .../trusted-server-adapter-fastly/src/main.rs | 23 +- crates/trusted-server-cli/Cargo.toml | 31 + crates/trusted-server-cli/src/args.rs | 179 ++ .../trusted-server-cli/src/config_command.rs | 466 +++++ .../src/edgezero_delegate.rs | 436 +++++ crates/trusted-server-cli/src/error.rs | 25 + crates/trusted-server-cli/src/lib.rs | 24 + crates/trusted-server-cli/src/main.rs | 13 + crates/trusted-server-cli/src/run.rs | 203 +++ crates/trusted-server-core/Cargo.toml | 15 +- crates/trusted-server-core/build.rs | 63 +- .../src/auction/endpoints.rs | 51 +- .../src/auction/formats.rs | 2 +- .../src/auction_config_types.rs | 1 + crates/trusted-server-core/src/auth.rs | 4 +- .../trusted-server-core/src/config_payload.rs | 482 +++++ .../trusted-server-core/src/consent_config.rs | 5 + .../src/integrations/prebid.rs | 28 +- .../src/integrations/testlight.rs | 2 +- crates/trusted-server-core/src/lib.rs | 1 + crates/trusted-server-core/src/proxy.rs | 40 +- crates/trusted-server-core/src/publisher.rs | 32 +- .../src/request_signing/endpoints.rs | 27 +- crates/trusted-server-core/src/settings.rs | 149 +- .../trusted-server-core/src/settings_data.rs | 267 ++- ...gezero-based-ts-cli-implementation-plan.md | 292 ++++ ...06-16-trusted-server-cli-respec-context.md | 235 +++ ...26-06-16-edgezero-based-ts-audit-design.md | 1063 ++++++++++++ ...2026-06-16-edgezero-based-ts-cli-design.md | 671 +++++++ edgezero.toml | 25 + trusted-server.example.toml | 129 ++ trusted-server.toml | 336 ---- 39 files changed, 6207 insertions(+), 702 deletions(-) create mode 100644 crates/trusted-server-cli/Cargo.toml create mode 100644 crates/trusted-server-cli/src/args.rs create mode 100644 crates/trusted-server-cli/src/config_command.rs create mode 100644 crates/trusted-server-cli/src/edgezero_delegate.rs create mode 100644 crates/trusted-server-cli/src/error.rs create mode 100644 crates/trusted-server-cli/src/lib.rs create mode 100644 crates/trusted-server-cli/src/main.rs create mode 100644 crates/trusted-server-cli/src/run.rs create mode 100644 crates/trusted-server-core/src/config_payload.rs create mode 100644 docs/superpowers/plans/2026-06-16-edgezero-based-ts-cli-implementation-plan.md create mode 100644 docs/superpowers/plans/2026-06-16-trusted-server-cli-respec-context.md create mode 100644 docs/superpowers/specs/2026-06-16-edgezero-based-ts-audit-design.md create mode 100644 docs/superpowers/specs/2026-06-16-edgezero-based-ts-cli-design.md create mode 100644 edgezero.toml create mode 100644 trusted-server.example.toml delete mode 100644 trusted-server.toml diff --git a/.cargo/config.toml b/.cargo/config.toml index e0e3f5bef..271c0c25d 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,5 +1,9 @@ [alias] test_details = ["test", "--target", "aarch64-apple-darwin"] +test_cli_macos = ["test", "--package", "trusted-server-cli", "--target", "aarch64-apple-darwin"] +build_cli_macos = ["build", "--package", "trusted-server-cli", "--target", "aarch64-apple-darwin"] +test_cli_linux = ["test", "--package", "trusted-server-cli", "--target", "x86_64-unknown-linux-gnu"] +build_cli_linux = ["build", "--package", "trusted-server-cli", "--target", "x86_64-unknown-linux-gnu"] [build] target = "wasm32-wasip1" diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index b6aba137d..cdea62a74 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -35,6 +35,9 @@ jobs: - name: Run cargo clippy run: cargo clippy --workspace --all-targets --all-features -- -D warnings + - name: Run host-target CLI clippy + run: cargo clippy --package trusted-server-cli --target x86_64-unknown-linux-gnu --all-targets --all-features -- -D warnings + format-typescript: runs-on: ubuntu-latest defaults: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2da273aa0..ee0eec56e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,6 +51,9 @@ jobs: - name: Run tests run: cargo test --workspace + - name: Run host-target CLI tests + run: cargo test --package trusted-server-cli --target x86_64-unknown-linux-gnu + - name: Verify Fastly WASM release build env: TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:8080 diff --git a/.gitignore b/.gitignore index af70c452a..5f91df390 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # env .env* +trusted-server.toml # backup **/*.rs.bk diff --git a/CLAUDE.md b/CLAUDE.md index f37a1ac30..9c2d75492 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,12 +15,14 @@ real-time bidding integration, and publisher-side JavaScript injection. crates/ trusted-server-core/ # Core library — shared logic, integrations, HTML processing trusted-server-adapter-fastly/ # Fastly Compute entry point (wasm32-wasip1 binary) + trusted-server-cli/ # Host-target `ts` operator CLI js/ # TypeScript/JS build — per-integration IIFE bundles lib/ # TS source, Vitest tests, esbuild pipeline ``` -Supporting files: `fastly.toml`, `trusted-server.toml`, `.env.dev`, -`rust-toolchain.toml`, `CONTRIBUTING.md`. +Supporting files: `edgezero.toml`, `fastly.toml`, +`trusted-server.example.toml`, `.env.dev`, `rust-toolchain.toml`, +`CONTRIBUTING.md`. Operator-owned `trusted-server.toml` files are gitignored. ## Toolchain @@ -57,6 +59,11 @@ fastly compute publish # Run all Rust tests (uses viceroy) cargo test --workspace +# Run host-target CLI tests (workspace default target is wasm32-wasip1) +# Use your host triple, for example x86_64-unknown-linux-gnu on CI/Linux +# or aarch64-apple-darwin on Apple Silicon macOS. +cargo test --package trusted-server-cli --target + # Format cargo fmt --all -- --check @@ -264,10 +271,12 @@ IntegrationRegistration::builder(ID) | File | Purpose | | --------------------- | ---------------------------------------------------------- | -| `fastly.toml` | Fastly service configuration and build settings | -| `trusted-server.toml` | Application settings (ad servers, KV stores, ID templates) | -| `rust-toolchain.toml` | Pins Rust version to 1.91.1 | -| `.env.dev` | Local development environment variables | +| `edgezero.toml` | EdgeZero app/platform manifest and logical stores | +| `fastly.toml` | Fastly service configuration and build settings | +| `trusted-server.example.toml` | Source-controlled Trusted Server app-config template | +| `trusted-server.toml` | Operator-owned app config; gitignored and pushed with `ts` CLI | +| `rust-toolchain.toml` | Pins Rust version to 1.91.1 | +| `.env.dev` | Local development environment variables | --- diff --git a/Cargo.lock b/Cargo.lock index ed7b74869..83c1565d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -63,12 +75,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -126,12 +182,92 @@ dependencies = [ "syn 2.0.117", ] +[[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.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[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 = "base16ct" version = "0.2.0" @@ -244,6 +380,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -253,6 +391,12 @@ 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.9.1" @@ -335,6 +479,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -343,8 +488,22 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -353,6 +512,40 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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" @@ -387,7 +580,7 @@ dependencies = [ "serde_core", "serde_json", "toml", - "winnow", + "winnow 1.0.2", "yaml-rust2", ] @@ -445,6 +638,16 @@ dependencies = [ "version_check", ] +[[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" @@ -516,7 +719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -528,7 +731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -555,6 +758,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ctor" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -637,6 +850,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -722,6 +966,12 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ed25519" version = "2.2.3" @@ -740,17 +990,75 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2 0.10.9", "subtle", "zeroize", ] +[[package]] +name = "edgezero-adapter" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?rev=2eeccc9748daba92b9adf6afe4df105e79269ae9#2eeccc9748daba92b9adf6afe4df105e79269ae9" +dependencies = [ + "toml", +] + +[[package]] +name = "edgezero-adapter-axum" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?rev=2eeccc9748daba92b9adf6afe4df105e79269ae9#2eeccc9748daba92b9adf6afe4df105e79269ae9" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "bytes", + "ctor", + "edgezero-adapter", + "edgezero-core", + "futures", + "futures-util", + "http", + "log", + "redb", + "reqwest", + "serde_json", + "simple_logger", + "thiserror 2.0.18", + "tokio", + "toml", + "tower", + "tracing", + "walkdir", +] + +[[package]] +name = "edgezero-adapter-cloudflare" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?rev=2eeccc9748daba92b9adf6afe4df105e79269ae9#2eeccc9748daba92b9adf6afe4df105e79269ae9" +dependencies = [ + "anyhow", + "async-trait", + "brotli", + "bytes", + "ctor", + "edgezero-adapter", + "edgezero-core", + "flate2", + "futures", + "futures-util", + "log", + "serde_json", + "tempfile", + "toml_edit", + "walkdir", +] + [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?rev=2eeccc9748daba92b9adf6afe4df105e79269ae9#2eeccc9748daba92b9adf6afe4df105e79269ae9" dependencies = [ "anyhow", "async-stream", @@ -758,20 +1066,75 @@ dependencies = [ "brotli", "bytes", "chrono", + "ctor", + "edgezero-adapter", "edgezero-core", - "fastly", + "fastly 0.12.1", "fern", "flate2", "futures", "futures-util", "log", - "log-fastly", + "log-fastly 0.12.1", + "serde_json", + "thiserror 2.0.18", + "toml_edit", + "walkdir", +] + +[[package]] +name = "edgezero-adapter-spin" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?rev=2eeccc9748daba92b9adf6afe4df105e79269ae9#2eeccc9748daba92b9adf6afe4df105e79269ae9" +dependencies = [ + "anyhow", + "async-trait", + "brotli", + "bytes", + "ctor", + "edgezero-adapter", + "edgezero-core", + "flate2", + "futures", + "futures-util", + "log", + "rusqlite", + "serde", + "serde_json", + "subtle", + "thiserror 2.0.18", + "toml", + "toml_edit", + "walkdir", +] + +[[package]] +name = "edgezero-cli" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?rev=2eeccc9748daba92b9adf6afe4df105e79269ae9#2eeccc9748daba92b9adf6afe4df105e79269ae9" +dependencies = [ + "clap", + "edgezero-adapter", + "edgezero-adapter-axum", + "edgezero-adapter-cloudflare", + "edgezero-adapter-fastly", + "edgezero-adapter-spin", + "edgezero-core", + "futures", + "handlebars", + "log", + "serde", + "serde_json", + "simple_logger", + "thiserror 2.0.18", + "toml", + "validator", ] [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?rev=2eeccc9748daba92b9adf6afe4df105e79269ae9#2eeccc9748daba92b9adf6afe4df105e79269ae9" dependencies = [ "anyhow", "async-compression", @@ -784,7 +1147,7 @@ dependencies = [ "http", "http-body", "log", - "matchit", + "matchit 0.9.2", "serde", "serde_json", "serde_urlencoded", @@ -799,7 +1162,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?rev=2eeccc9748daba92b9adf6afe4df105e79269ae9#2eeccc9748daba92b9adf6afe4df105e79269ae9" dependencies = [ "log", "proc-macro2", @@ -828,7 +1191,7 @@ dependencies = [ "ff", "generic-array", "group", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -870,6 +1233,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "error-stack" version = "0.6.0" @@ -880,6 +1253,18 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastly" version = "0.11.13" @@ -890,9 +1275,9 @@ dependencies = [ "bytes", "downcast-rs", "elsa", - "fastly-macros", - "fastly-shared", - "fastly-sys", + "fastly-macros 0.11.13", + "fastly-shared 0.11.13", + "fastly-sys 0.11.13", "http", "itertools 0.13.0", "lazy_static", @@ -909,14 +1294,53 @@ dependencies = [ ] [[package]] -name = "fastly-macros" -version = "0.11.13" +name = "fastly" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51ae08eeeb5ed0c1a8b454fc89dca0e316e13b7889e81fc9a435503c1e84a2d7" +checksum = "531e4c3df48350d9f4fc95b4deaf87fd29820336b7926bb84bf460457c2a126b" dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", + "anyhow", + "bytes", + "downcast-rs", + "elsa", + "fastly-macros 0.12.1", + "fastly-shared 0.12.1", + "fastly-sys 0.12.1", + "http", + "itertools 0.13.0", + "lazy_static", + "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.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51ae08eeeb5ed0c1a8b454fc89dca0e316e13b7889e81fc9a435503c1e84a2d7" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "fastly-macros" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2aef5f9690b04c8890f9a54ddb591b12b9779ec25ee0e572d207106e52e3d8" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -929,6 +1353,16 @@ dependencies = [ "http", ] +[[package]] +name = "fastly-shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "080ad138403159fd366d3e0b14bb49cb0c01dc18c25095bbbd1c85e3338f5413" +dependencies = [ + "bitflags 1.3.2", + "http", +] + [[package]] name = "fastly-sys" version = "0.11.13" @@ -936,11 +1370,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1b82ebd99583740a074d8962ca75d7d17065b185a94e4919c3a3f2193268b6" dependencies = [ "bitflags 1.3.2", - "fastly-shared", + "fastly-shared 0.11.13", "wasip2", "wit-bindgen 0.46.0", ] +[[package]] +name = "fastly-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de75ef193f6c29c43d667458bede648970715aedd5db2d42c2eba3ffa3ad738b" +dependencies = [ + "bitflags 1.3.2", + "fastly-shared 0.12.1", + "http", + "wasip2", + "wit-bindgen 0.51.0", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -962,7 +1409,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1015,6 +1462,12 @@ 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" @@ -1121,8 +1574,24 @@ 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", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] @@ -1133,7 +1602,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -1145,7 +1614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1160,11 +1629,30 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "handlebars" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43ccdfe15a81ab0a8af639e90254227c9a46afd9c5f5b6ec7efaa345c4b0f00" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -1192,6 +1680,15 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "hashlink" version = "0.10.0" @@ -1248,6 +1745,90 @@ dependencies = [ "http", ] +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +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.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "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 = "iab_gpp" version = "0.1.2" @@ -1440,6 +2021,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is-terminal" version = "0.4.17" @@ -1448,9 +2035,15 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -1475,6 +2068,65 @@ 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.18", + "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.117", +] + +[[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.117", +] + +[[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 = "jose-b64" version = "0.1.2" @@ -1517,6 +2169,8 @@ version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1559,6 +2213,35 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "link-section" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2b1dd6fe32e55c0fc0ea9493aa57459ca3cf4ff3c857c7d0302290150da6e4f" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1586,7 +2269,18 @@ version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58a5d864949b863161476a8129ef0322c56c77bb15f98d88991002072f497b1e" dependencies = [ - "fastly", + "fastly 0.11.13", + "log", + "regex", +] + +[[package]] +name = "log-fastly" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51dae5def13a2d557fdb63862d642f8d4641ec3773c036bb14092697b6764013" +dependencies = [ + "fastly 0.12.1", "log", "regex", ] @@ -1610,6 +2304,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[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.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "matchit" version = "0.9.2" @@ -1638,6 +2344,17 @@ dependencies = [ "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 = "new_debug_unreachable" version = "1.0.6" @@ -1664,7 +2381,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -1706,6 +2423,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-modular" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc41a1374056e9672221567958a66c16be12d0e2c1b408761e14d901c237d5e0" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1716,12 +2448,27 @@ dependencies = [ "libm", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -1734,6 +2481,12 @@ 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 = "ordered-multimap" version = "0.7.3" @@ -1922,6 +2675,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "poly1305" version = "0.8.0" @@ -2013,6 +2772,62 @@ 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.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "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.45" @@ -2022,6 +2837,12 @@ 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" @@ -2035,8 +2856,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -2046,7 +2877,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -2058,6 +2899,24 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redb" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e925444704b5f17d32bf42f5b6e2df050bceebc3dcd6e71cc73dafe8092e839" +dependencies = [ + "libc", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2096,6 +2955,59 @@ 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 = "ron" version = "0.12.1" @@ -2123,13 +3035,27 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.9.1", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -2155,6 +3081,94 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[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.61.2", +] + +[[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" @@ -2176,6 +3190,15 @@ 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 = "scopeguard" version = "1.2.0" @@ -2195,6 +3218,29 @@ dependencies = [ "zeroize", ] +[[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 = "selectors" version = "0.33.0" @@ -2275,6 +3321,17 @@ dependencies = [ "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" @@ -2346,6 +3403,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[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 = "signature" version = "2.2.0" @@ -2353,7 +3420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest 0.10.7", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2362,6 +3429,34 @@ 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 = "siphasher" version = "1.0.2" @@ -2380,6 +3475,16 @@ 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.61.2", +] + [[package]] name = "spin" version = "0.9.8" @@ -2448,6 +3553,15 @@ dependencies = [ "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" @@ -2468,6 +3582,19 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2516,7 +3643,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -2568,6 +3697,21 @@ dependencies = [ "serde_json", ] +[[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.1" @@ -2575,8 +3719,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -2590,6 +3739,16 @@ dependencies = [ "syn 2.0.117", ] +[[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" @@ -2599,10 +3758,19 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.2", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", ] [[package]] @@ -2614,13 +3782,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[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", + "winnow 1.0.2", ] [[package]] @@ -2629,6 +3810,46 @@ 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" @@ -2641,6 +3862,7 @@ 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", @@ -2677,17 +3899,36 @@ dependencies = [ "edgezero-adapter-fastly", "edgezero-core", "error-stack", - "fastly", + "fastly 0.11.13", "fern", "futures", "log", - "log-fastly", + "log-fastly 0.11.13", "serde", "serde_json", "trusted-server-core", "urlencoding", ] +[[package]] +name = "trusted-server-cli" +version = "0.1.0" +dependencies = [ + "clap", + "derive_more", + "edgezero-adapter", + "edgezero-cli", + "edgezero-core", + "error-stack", + "log", + "serde", + "serde_json", + "tempfile", + "toml", + "trusted-server-core", + "validator", +] + [[package]] name = "trusted-server-core" version = "0.1.0" @@ -2705,7 +3946,7 @@ dependencies = [ "ed25519-dalek", "edgezero-core", "error-stack", - "fastly", + "fastly 0.11.13", "flate2", "futures", "hex", @@ -2715,9 +3956,9 @@ dependencies = [ "jose-jwk", "log", "lol_html", - "matchit", + "matchit 0.9.2", "mime", - "rand", + "rand 0.8.6", "regex", "serde", "serde_json", @@ -2753,6 +3994,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typeid" version = "1.0.3" @@ -2799,6 +4046,12 @@ dependencies = [ "subtle", ] +[[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" @@ -2823,6 +4076,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.1" @@ -2864,6 +4123,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2880,6 +4145,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2917,6 +4191,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.118" @@ -2983,6 +4267,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" @@ -2993,6 +4287,15 @@ dependencies = [ "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 = "which" version = "8.0.2" @@ -3008,7 +4311,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -3070,6 +4373,24 @@ 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" @@ -3079,6 +4400,144 @@ 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 = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.2" @@ -3103,6 +4562,7 @@ 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", ] @@ -3208,7 +4668,7 @@ checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" dependencies = [ "arraydeque", "encoding_rs", - "hashlink", + "hashlink 0.10.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9f2f4c673..0829fe97c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/trusted-server-core", "crates/trusted-server-adapter-fastly", + "crates/trusted-server-cli", "crates/js", "crates/openrtb", ] @@ -52,14 +53,17 @@ build-print = "1.0.1" bytes = "1.11" chacha20poly1305 = "0.10" chrono = "0.4.44" +clap = { version = "4", features = ["derive"] } config = "0.15.19" cookie = "0.18.1" derive_more = { version = "2.0", features = ["display", "error"] } ed25519-dalek = { version = "2.2", features = ["rand_core"] } -edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "170b74b", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "170b74b", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "170b74b", default-features = false } -edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "170b74b", default-features = false } +edgezero-adapter = { git = "https://github.com/stackpop/edgezero", rev = "2eeccc9748daba92b9adf6afe4df105e79269ae9", default-features = false } +edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "2eeccc9748daba92b9adf6afe4df105e79269ae9", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "2eeccc9748daba92b9adf6afe4df105e79269ae9", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "2eeccc9748daba92b9adf6afe4df105e79269ae9", default-features = false } +edgezero-cli = { git = "https://github.com/stackpop/edgezero", rev = "2eeccc9748daba92b9adf6afe4df105e79269ae9" } +edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "2eeccc9748daba92b9adf6afe4df105e79269ae9", default-features = false } error-stack = "0.6" fastly = "0.11.12" fern = "0.7.1" @@ -82,6 +86,7 @@ serde_json = "1.0.149" sha2 = "0.10.9" subtle = "2.6" temp-env = "0.3.6" +tempfile = "3.24" tokio = { version = "1.49", features = ["sync", "macros", "io-util", "rt", "time"] } toml = "1.0" trusted-server-core = { path = "crates/trusted-server-core" } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index b1e59e358..5b09180eb 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -45,7 +45,7 @@ use trusted_server_core::request_signing::{ handle_verify_signature, }; use trusted_server_core::settings::Settings; -use trusted_server_core::settings_data::get_settings; +use trusted_server_core::settings_data::get_settings_from_services; mod error; mod logging; @@ -122,7 +122,14 @@ fn main() { return; } - let settings = match get_settings() { + // Start with an unavailable primary KV slot. EC-backed routes lazily + // replace it with the configured EC identity store at dispatch time so + // unrelated routes stay available when EC KV is unavailable. + let kv_store = std::sync::Arc::new(UnavailableKvStore) + as std::sync::Arc; + let runtime_services = build_runtime_services(&req, kv_store); + + let settings = match get_settings_from_services(&runtime_services) { Ok(s) => s, Err(e) => { log::error!("Failed to load settings: {:?}", e); @@ -173,16 +180,10 @@ fn main() { } }; - // Start with an unavailable primary KV slot. EC-backed routes lazily - // replace it with the configured EC identity store at dispatch time so - // unrelated routes stay available when EC KV is unavailable. - let kv_store = std::sync::Arc::new(UnavailableKvStore) - as std::sync::Arc; - // Strip client-spoofable forwarded headers at the edge before building - // any request-derived context or converting to the core HTTP types. + // Strip client-spoofable forwarded headers at the edge before converting to + // the core HTTP types. compat::sanitize_fastly_forwarded_headers(&mut req); - let runtime_services = build_runtime_services(&req, kv_store); let http_req = compat::from_fastly_request(req); let route_result = futures::executor::block_on(route_request( @@ -478,7 +479,7 @@ async fn route_request( }; let kv_graph = if is_real_browser { kv_graph } else { None }; - // `get_settings()` should already have rejected invalid handler regexes. + // Settings loading should already have rejected invalid handler regexes. // Keep this fallback so manually-constructed or otherwise unprepared // settings still become an error response instead of panicking. match enforce_basic_auth(settings, &req) { diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml new file mode 100644 index 000000000..4509cf863 --- /dev/null +++ b/crates/trusted-server-cli/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "trusted-server-cli" +version = "0.1.0" +authors = [] +edition = "2021" +publish = false +license = "Apache-2.0" + +[[bin]] +name = "ts" +path = "src/main.rs" + +[lints] +workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +clap = { workspace = true } +derive_more = { workspace = true } +edgezero-adapter = { workspace = true, features = ["cli"] } +edgezero-cli = { workspace = true } +edgezero-core = { workspace = true } +error-stack = { workspace = true } +log = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +trusted-server-core = { workspace = true } +validator = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/trusted-server-cli/src/args.rs b/crates/trusted-server-cli/src/args.rs new file mode 100644 index 000000000..01f114466 --- /dev/null +++ b/crates/trusted-server-cli/src/args.rs @@ -0,0 +1,179 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +#[derive(Debug, Parser)] +#[command(name = "ts", about = "Trusted Server CLI")] +pub struct Args { + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Sign in / out / status against an `EdgeZero` adapter. + Auth(AuthArgs), + /// Build the project for a target adapter. + Build(DelegateArgs), + /// Trusted Server app-config commands. + #[command(subcommand)] + Config(ConfigCommand), + /// Deploy the project through a target adapter. + Deploy(DelegateArgs), + /// Provision platform resources through a target adapter. + Provision(DelegateArgs), + /// Serve the project locally through a target adapter. + Serve(DelegateArgs), +} + +#[derive(Debug, clap::Args)] +pub struct AuthArgs { + #[command(subcommand)] + pub command: AuthCommand, +} + +#[derive(Debug, Subcommand)] +pub enum AuthCommand { + /// Sign in through the adapter's native auth flow. + Login(AuthSubcommandArgs), + /// Sign out through the adapter's native auth flow. + Logout(AuthSubcommandArgs), + /// Show the current adapter auth status. + Status(AuthSubcommandArgs), +} + +#[derive(Debug, clap::Args)] +pub struct AuthSubcommandArgs { + /// Target adapter name. + #[arg(long, required = true)] + pub adapter: String, + /// Arguments passed through to `EdgeZero`. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub edgezero_args: Vec, +} + +#[derive(Debug, clap::Args)] +pub struct DelegateArgs { + /// Target adapter name. + #[arg(long, required = true)] + pub adapter: String, + /// Arguments passed through to `EdgeZero`. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub edgezero_args: Vec, +} + +#[derive(Debug, Subcommand)] +pub enum ConfigCommand { + /// Initialize a Trusted Server config file from the example template. + Init(ConfigInitArgs), + /// Validate and hash a local Trusted Server config file. + Validate(ConfigValidateArgs), + /// Push flattened Trusted Server config entries through `EdgeZero`. + Push(ConfigPushArgs), +} + +#[derive(Debug, clap::Args)] +pub struct ConfigInitArgs { + /// Target config path. + #[arg(long, default_value = "trusted-server.toml")] + pub config: PathBuf, + /// Overwrite an existing target file. + #[arg(long)] + pub force: bool, +} + +#[derive(Debug, clap::Args)] +pub struct ConfigValidateArgs { + /// Trusted Server config path. + #[arg(long, default_value = "trusted-server.toml")] + pub config: PathBuf, + /// Emit machine-readable JSON. + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, clap::Args)] +pub struct ConfigPushArgs { + /// Target adapter name. + #[arg(long, required = true)] + pub adapter: String, + /// Trusted Server config path. + #[arg(long, default_value = "trusted-server.toml")] + pub config: PathBuf, + /// `EdgeZero` manifest path. + #[arg(long, default_value = "edgezero.toml")] + pub manifest: PathBuf, + /// Logical config-store id. + #[arg(long, default_value = "app_config")] + pub store: String, + /// Push to local adapter state. + #[arg(long)] + pub local: bool, + /// Resolve and report without mutating platform or local state. + #[arg(long)] + pub dry_run: bool, + /// Adapter runtime config path. + #[arg(long)] + pub runtime_config: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_build_with_passthrough_args() { + let args = Args::try_parse_from([ + "ts", + "build", + "--adapter", + "fastly", + "--", + "--release", + "--flag=value", + ]) + .expect("should parse build command"); + let Command::Build(build) = args.command else { + panic!("expected build command"); + }; + assert_eq!(build.adapter, "fastly"); + assert_eq!(build.edgezero_args, ["--release", "--flag=value"]); + } + + #[test] + fn parses_auth_with_passthrough_args() { + let args = Args::try_parse_from([ + "ts", + "auth", + "login", + "--adapter", + "fastly", + "--", + "--profile", + "dev", + ]) + .expect("should parse auth command"); + let Command::Auth(auth) = args.command else { + panic!("expected auth command"); + }; + let AuthCommand::Login(login) = auth.command else { + panic!("expected login command"); + }; + assert_eq!(login.adapter, "fastly"); + assert_eq!(login.edgezero_args, ["--profile", "dev"]); + } + + #[test] + fn config_push_defaults_match_spec() { + let args = Args::try_parse_from(["ts", "config", "push", "--adapter", "fastly"]) + .expect("should parse config push"); + let Command::Config(ConfigCommand::Push(push)) = args.command else { + panic!("expected config push command"); + }; + assert_eq!(push.config, PathBuf::from("trusted-server.toml")); + assert_eq!(push.manifest, PathBuf::from("edgezero.toml")); + assert_eq!(push.store, "app_config"); + assert!(!push.local); + assert!(!push.dry_run); + } +} diff --git a/crates/trusted-server-cli/src/config_command.rs b/crates/trusted-server-cli/src/config_command.rs new file mode 100644 index 000000000..9b3811695 --- /dev/null +++ b/crates/trusted-server-cli/src/config_command.rs @@ -0,0 +1,466 @@ +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use trusted_server_core::config_payload::{ + build_config_payload, settings_from_config_entries, ConfigPayload, +}; +use trusted_server_core::ec::registry::PartnerRegistry; +use trusted_server_core::integrations::{ + adserver_mock::AdServerMockConfig, aps::ApsConfig, datadome::DataDomeConfig, + didomi::DidomiIntegrationConfig, google_tag_manager::GoogleTagManagerConfig, gpt::GptConfig, + lockr::LockrConfig, nextjs::NextJsIntegrationConfig, permutive::PermutiveConfig, prebid, + sourcepoint::SourcepointConfig, testlight::TestlightConfig, +}; +use trusted_server_core::settings::{IntegrationConfig, Settings}; +use validator::Validate as _; + +use crate::args::{ConfigInitArgs, ConfigValidateArgs}; +use crate::error::{cli_error, report_error, CliResult}; + +const EXAMPLE_CONFIG: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../trusted-server.example.toml" +)); + +#[derive(Debug)] +pub struct LoadedConfig { + pub path: PathBuf, + pub payload: ConfigPayload, +} + +#[derive(Serialize)] +struct ValidateJson<'a> { + valid: bool, + config_path: String, + entry_count: Option, + config_hash: Option<&'a str>, + errors: Vec, +} + +pub fn run_init(args: &ConfigInitArgs, out: &mut dyn Write) -> CliResult<()> { + if args.config.exists() && !args.force { + return cli_error(format!( + "{} already exists; pass --force to overwrite", + args.config.display() + )); + } + + if let Some(parent) = args + .config + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent).map_err(|error| { + report_error(format!( + "failed to create parent directory {}: {error}", + parent.display() + )) + })?; + } + + fs::write(&args.config, EXAMPLE_CONFIG).map_err(|error| { + report_error(format!( + "failed to write config {}: {error}", + args.config.display() + )) + })?; + writeln!(out, "Initialized config at {}", args.config.display()) + .map_err(|error| report_error(format!("failed to write command output: {error}")))?; + Ok(()) +} + +pub fn run_validate( + args: &ConfigValidateArgs, + out: &mut dyn Write, + err: &mut dyn Write, +) -> CliResult<()> { + match load_config(&args.config) { + Ok(loaded) => { + if args.json { + let response = ValidateJson { + valid: true, + config_path: absolute_display(&loaded.path), + entry_count: Some(loaded.payload.settings_entries.len()), + config_hash: Some(&loaded.payload.hash), + errors: Vec::new(), + }; + serde_json::to_writer(&mut *out, &response).map_err(|error| { + report_error(format!( + "failed to serialize validation JSON output: {error}" + )) + })?; + writeln!(out).map_err(|error| { + report_error(format!("failed to write command output: {error}")) + })?; + } else { + writeln!(out, "Config valid: {}", absolute_display(&loaded.path)).map_err( + |error| report_error(format!("failed to write command output: {error}")), + )?; + writeln!( + out, + "Config entries: {}", + loaded.payload.settings_entries.len() + ) + .map_err(|error| { + report_error(format!("failed to write command output: {error}")) + })?; + writeln!(out, "Config hash: {}", loaded.payload.hash).map_err(|error| { + report_error(format!("failed to write command output: {error}")) + })?; + } + Ok(()) + } + Err(error) => { + let message = format_config_error(&args.config, &error); + if args.json { + let response = ValidateJson { + valid: false, + config_path: absolute_display(&args.config), + entry_count: None, + config_hash: None, + errors: vec![message], + }; + serde_json::to_writer(&mut *out, &response).map_err(|error| { + report_error(format!( + "failed to serialize validation JSON output: {error}" + )) + })?; + writeln!(out).map_err(|error| { + report_error(format!("failed to write command output: {error}")) + })?; + } else { + writeln!(err, "{message}").map_err(|error| { + report_error(format!("failed to write error output: {error}")) + })?; + } + Err(error) + } + } +} + +pub fn load_config(path: &Path) -> CliResult { + let contents = fs::read_to_string(path).map_err(|error| { + report_error(format!( + "missing {}: run `ts config init` or pass --config : {error}", + path.display() + )) + })?; + let settings = Settings::from_toml(&contents) + .map_err(|error| report_error(format!("invalid app config: {error:?}")))?; + settings.validate().map_err(|error| { + report_error(format!( + "invalid app config: Configuration validation failed: {error}" + )) + })?; + settings + .reject_placeholder_secrets() + .map_err(|error| report_error(format!("invalid app config: {error:?}")))?; + let payload = build_config_payload(&settings) + .map_err(|error| report_error(format!("failed to build config payload: {error:?}")))?; + let runtime_settings = settings_from_config_entries(&payload.entries).map_err(|error| { + report_error(format!( + "invalid app config: flattened payload failed runtime reconstruction: {error:?}" + )) + })?; + validate_runtime_startup(&runtime_settings)?; + Ok(LoadedConfig { + path: path.to_path_buf(), + payload, + }) +} + +fn validate_runtime_startup(settings: &Settings) -> CliResult<()> { + let enabled_auction_providers = validate_enabled_integrations(settings)?; + validate_auction_provider_names(settings, &enabled_auction_providers)?; + PartnerRegistry::from_config(&settings.ec.partners) + .map(|_| ()) + .map_err(|error| { + report_error(format!( + "invalid app config: EC partner registry startup failed: {error:?}" + )) + })?; + Ok(()) +} + +fn validate_enabled_integrations( + settings: &Settings, +) -> CliResult> { + let mut enabled_auction_providers = std::collections::HashSet::new(); + + if validate_prebid(settings)? { + enabled_auction_providers.insert("prebid"); + } + if validate_integration::(settings, "aps")? { + enabled_auction_providers.insert("aps"); + } + if validate_integration::(settings, "adserver_mock")? { + enabled_auction_providers.insert("adserver_mock"); + } + validate_integration::(settings, "testlight")?; + validate_integration::(settings, "nextjs")?; + validate_integration::(settings, "permutive")?; + validate_integration::(settings, "lockr")?; + validate_integration::(settings, "didomi")?; + validate_integration::(settings, "sourcepoint")?; + validate_integration::(settings, "google_tag_manager")?; + validate_integration::(settings, "datadome")?; + validate_integration::(settings, "gpt")?; + + Ok(enabled_auction_providers) +} + +fn validate_prebid(settings: &Settings) -> CliResult { + prebid::validate_config_for_startup(settings) + .map(|config| config.is_some()) + .map_err(|error| { + report_error(format!( + "invalid app config: integration startup failed for `prebid`: {error:?}" + )) + }) +} + +fn validate_integration(settings: &Settings, integration_id: &str) -> CliResult +where + T: IntegrationConfig, +{ + settings + .integration_config::(integration_id) + .map(|config| config.is_some()) + .map_err(|error| { + report_error(format!( + "invalid app config: integration startup failed for `{integration_id}`: {error:?}" + )) + }) +} + +fn validate_auction_provider_names( + settings: &Settings, + enabled_auction_providers: &std::collections::HashSet<&'static str>, +) -> CliResult<()> { + if !settings.auction.enabled { + return Ok(()); + } + + for provider_name in settings + .auction + .providers + .iter() + .chain(settings.auction.mediator.iter()) + { + if !enabled_auction_providers.contains(provider_name.as_str()) { + return cli_error(format!( + "invalid app config: auction startup failed: provider `{provider_name}` is listed in [auction] but no enabled integration provides it" + )); + } + } + + Ok(()) +} + +fn absolute_display(path: &Path) -> String { + fs::canonicalize(path) + .unwrap_or_else(|_| path.to_path_buf()) + .display() + .to_string() +} + +fn format_config_error(path: &Path, error: &error_stack::Report) -> String { + let mut message = format!("Config invalid: {}: {error:?}", path.display()); + if !path.exists() { + message.push_str("\nHint: run `ts config init` or pass --config "); + } + message +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn valid_config() -> String { + r#" +[publisher] +domain = "example.com" +cookie_domain = ".example.com" +origin_url = "https://origin.example.com" +proxy_secret = "production-proxy-secret" + +[ec] +passphrase = "production-secret-key-32-bytes-min" + +[[handlers]] +path = "^/_ts/admin" +username = "admin" +password = "production-admin-password-32-bytes" +"# + .to_string() + } + + #[test] + fn init_writes_default_config_and_refuses_overwrite() { + let temp = TempDir::new().expect("should create temp dir"); + let path = temp.path().join("trusted-server.toml"); + let mut out = Vec::new(); + + run_init( + &ConfigInitArgs { + config: path.clone(), + force: false, + }, + &mut out, + ) + .expect("should initialize config"); + assert!(path.exists(), "should write config file"); + + let err = run_init( + &ConfigInitArgs { + config: path, + force: false, + }, + &mut Vec::new(), + ) + .expect_err("should refuse overwrite"); + assert!( + err.to_string().contains("already exists"), + "error should mention existing file" + ); + } + + #[test] + fn validate_json_success_reports_hash() { + let temp = TempDir::new().expect("should create temp dir"); + let path = temp.path().join("trusted-server.toml"); + fs::write(&path, valid_config()).expect("should write config"); + let mut out = Vec::new(); + + run_validate( + &ConfigValidateArgs { + config: path, + json: true, + }, + &mut out, + &mut Vec::new(), + ) + .expect("should validate config"); + + let value: serde_json::Value = serde_json::from_slice(&out).expect("should parse JSON"); + assert_eq!(value["valid"], true); + assert!( + value["entry_count"].as_u64().is_some(), + "entry count should be numeric" + ); + assert!( + value["config_hash"] + .as_str() + .expect("should have hash") + .starts_with("sha256:"), + "hash should use sha256 prefix" + ); + } + + #[test] + fn validate_rejects_unknown_fields() { + let temp = TempDir::new().expect("should create temp dir"); + let path = temp.path().join("trusted-server.toml"); + fs::write( + &path, + format!("{}\nunknown_top_level = true\n", valid_config()), + ) + .expect("should write config"); + + let err = load_config(&path).expect_err("should reject unknown field"); + assert!( + format!("{err:?}").contains("unknown_top_level"), + "error should mention unknown field" + ); + } + + #[test] + fn validate_rejects_enabled_integration_startup_errors() { + let temp = TempDir::new().expect("should create temp dir"); + let path = temp.path().join("trusted-server.toml"); + fs::write( + &path, + format!( + r#"{} + +[integrations.prebid] +enabled = true +server_url = "not-a-url" +"#, + valid_config() + ), + ) + .expect("should write config"); + + let err = load_config(&path).expect_err("should reject invalid enabled integration"); + let message = format!("{err:?}"); + assert!( + message.contains("integration startup failed") + || message.contains("auction startup failed"), + "error should mention runtime startup validation" + ); + assert!( + message.contains("server_url") || message.contains("url"), + "error should mention invalid URL" + ); + } + + #[test] + fn validate_rejects_prebid_startup_rule_errors() { + let temp = TempDir::new().expect("should create temp dir"); + let path = temp.path().join("trusted-server.toml"); + fs::write( + &path, + format!( + r#"{} + +[integrations.prebid] +enabled = true +server_url = "https://prebid.example.com/openrtb2/auction" + +[[integrations.prebid.bid_param_override_rules]] +when = {{ bidder = "kargo" }} +set = {{}} +"#, + valid_config() + ), + ) + .expect("should write config"); + + let err = load_config(&path).expect_err("should reject invalid Prebid runtime rule"); + let message = format!("{err:?}"); + assert!( + message.contains("prebid"), + "error should mention Prebid validation" + ); + assert!( + message.contains("set"), + "error should mention the invalid override set" + ); + } + + #[test] + fn validate_rejects_placeholders_from_init_template() { + let temp = TempDir::new().expect("should create temp dir"); + let path = temp.path().join("trusted-server.toml"); + let mut out = Vec::new(); + run_init( + &ConfigInitArgs { + config: path.clone(), + force: false, + }, + &mut out, + ) + .expect("should initialize config"); + + let err = load_config(&path).expect_err("template should require edits before validation"); + let error = format!("{err:?}"); + assert!( + error.contains("Insecure default") || error.contains("placeholder password"), + "error should mention an unreplaced placeholder secret" + ); + } +} diff --git a/crates/trusted-server-cli/src/edgezero_delegate.rs b/crates/trusted-server-cli/src/edgezero_delegate.rs new file mode 100644 index 000000000..fda67669b --- /dev/null +++ b/crates/trusted-server-cli/src/edgezero_delegate.rs @@ -0,0 +1,436 @@ +use std::env; +use std::io::{ErrorKind, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use clap::Parser as _; +use edgezero_adapter::registry::{ + self as adapter_registry, AdapterAction, AdapterPushContext, ResolvedStoreId, +}; +use edgezero_core::env_config::EnvConfig; +use edgezero_core::manifest::{Manifest, ManifestLoader, ResolvedEnvironment}; + +use crate::error::{cli_error, report_error, CliResult}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LifecycleCommand { + AuthLogin, + AuthLogout, + AuthStatus, + Build, + Deploy, + Provision, + Serve, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfigPushRequest { + pub adapter: String, + pub manifest: PathBuf, + pub store: String, + pub local: bool, + pub dry_run: bool, + pub runtime_config: Option, + pub entries: Vec<(String, String)>, + pub settings_entry_count: usize, + pub config_hash: String, +} + +pub trait EdgeZeroDelegate { + fn run_lifecycle( + &mut self, + command: LifecycleCommand, + adapter: &str, + passthrough: &[String], + ) -> CliResult<()>; + + fn push_config(&mut self, request: &ConfigPushRequest, out: &mut dyn Write) -> CliResult<()>; +} + +#[derive(Default)] +pub struct ProductionEdgeZeroDelegate; + +impl EdgeZeroDelegate for ProductionEdgeZeroDelegate { + fn run_lifecycle( + &mut self, + command: LifecycleCommand, + adapter: &str, + passthrough: &[String], + ) -> CliResult<()> { + match command { + LifecycleCommand::Provision => run_edgezero_provision(adapter, passthrough), + other => run_edgezero_lifecycle(other, adapter, passthrough), + } + } + + fn push_config(&mut self, request: &ConfigPushRequest, out: &mut dyn Write) -> CliResult<()> { + push_config_entries(request, out) + } +} + +fn run_edgezero_provision(adapter: &str, passthrough: &[String]) -> CliResult<()> { + let mut argv = vec![ + "edgezero".to_string(), + "provision".to_string(), + "--adapter".to_string(), + adapter.to_string(), + ]; + argv.extend(passthrough.iter().cloned()); + let parsed = edgezero_cli::args::Args::try_parse_from(argv).map_err(|error| { + report_error(format!( + "[edgezero] failed to parse provision args: {error}" + )) + })?; + let edgezero_cli::args::Command::Provision(args) = parsed.cmd else { + return cli_error("internal error: parsed EdgeZero command was not provision"); + }; + edgezero_cli::run_provision(&args).map_err(|error| report_error(format!("[edgezero] {error}"))) +} + +fn run_edgezero_lifecycle( + command: LifecycleCommand, + adapter_name: &str, + passthrough: &[String], +) -> CliResult<()> { + let manifest = load_manifest_optional()?; + ensure_adapter_defined(adapter_name, manifest.as_ref())?; + + if let Some(loader) = &manifest { + if let Some(command_text) = manifest_command(loader.manifest(), adapter_name, command) { + let manifest = loader.manifest(); + let root = manifest.root().unwrap_or_else(|| Path::new(".")); + let environment = manifest.environment_for(adapter_name); + let adapter_bind = adapter_bind_from_manifest(manifest, adapter_name); + return run_shell(command_text, root, &environment, adapter_bind, passthrough); + } + } + + let adapter = adapter_registry::get_adapter(adapter_name).ok_or_else(|| { + let available = adapter_registry::registered_adapters(); + report_error(if available.is_empty() { + format!("adapter `{adapter_name}` is not registered in this build") + } else { + format!( + "adapter `{}` is not registered (available: {})", + adapter_name, + available.join(", ") + ) + }) + })?; + + adapter + .execute(adapter_action(command), passthrough) + .map_err(|error| report_error(format!("[edgezero] {error}"))) +} + +fn adapter_action(command: LifecycleCommand) -> AdapterAction { + match command { + LifecycleCommand::AuthLogin => AdapterAction::AuthLogin, + LifecycleCommand::AuthLogout => AdapterAction::AuthLogout, + LifecycleCommand::AuthStatus => AdapterAction::AuthStatus, + LifecycleCommand::Build => AdapterAction::Build, + LifecycleCommand::Deploy => AdapterAction::Deploy, + LifecycleCommand::Serve => AdapterAction::Serve, + LifecycleCommand::Provision => AdapterAction::Build, + } +} + +fn manifest_command<'manifest>( + manifest: &'manifest Manifest, + adapter_name: &str, + command: LifecycleCommand, +) -> Option<&'manifest str> { + let (_canonical, cfg) = manifest.adapter_entry(adapter_name)?; + match command { + LifecycleCommand::AuthLogin => cfg.commands.auth_login.as_deref(), + LifecycleCommand::AuthLogout => cfg.commands.auth_logout.as_deref(), + LifecycleCommand::AuthStatus => cfg.commands.auth_status.as_deref(), + LifecycleCommand::Build => cfg.commands.build.as_deref(), + LifecycleCommand::Deploy => cfg.commands.deploy.as_deref(), + LifecycleCommand::Serve => cfg.commands.serve.as_deref(), + LifecycleCommand::Provision => None, + } +} + +fn load_manifest_optional() -> CliResult> { + let (path, explicit) = env::var("EDGEZERO_MANIFEST").map_or_else( + |_| (PathBuf::from("edgezero.toml"), false), + |raw| (PathBuf::from(raw), true), + ); + + match ManifestLoader::from_path(&path) { + Ok(loader) => Ok(Some(loader)), + Err(error) if error.kind() == ErrorKind::NotFound && !explicit => Ok(None), + Err(error) => cli_error(format!("failed to load {}: {error}", path.display())), + } +} + +fn ensure_adapter_defined( + adapter_name: &str, + manifest_loader: Option<&ManifestLoader>, +) -> CliResult<()> { + let Some(loader) = manifest_loader else { + return Ok(()); + }; + if loader.manifest().adapter_entry(adapter_name).is_some() { + return Ok(()); + } + let available: Vec = loader.manifest().adapters.keys().cloned().collect(); + if available.is_empty() { + cli_error(format!( + "adapter `{adapter_name}` is not configured in edgezero.toml (no adapters defined)" + )) + } else { + cli_error(format!( + "adapter `{}` is not configured in edgezero.toml (available: {})", + adapter_name, + available.join(", ") + )) + } +} + +fn run_shell( + command_text: &str, + cwd: &Path, + environment: &ResolvedEnvironment, + adapter_bind: (Option, Option), + passthrough: &[String], +) -> CliResult<()> { + let full_command = if passthrough.is_empty() { + command_text.to_string() + } else { + format!("{} {}", command_text, shell_join(passthrough)) + }; + let mut command = Command::new("sh"); + command.arg("-c").arg(&full_command).current_dir(cwd); + + apply_adapter_bind(adapter_bind, &mut command); + apply_environment(environment, &mut command)?; + + let status = command.status().map_err(|error| { + report_error(format!( + "failed to run EdgeZero command `{command_text}`: {error}" + )) + })?; + + if status.success() { + Ok(()) + } else { + cli_error(format!( + "EdgeZero command `{command_text}` exited with status {status}" + )) + } +} + +fn adapter_bind_from_manifest( + manifest: &Manifest, + adapter_name: &str, +) -> (Option, Option) { + let Some((_canonical, cfg)) = manifest.adapter_entry(adapter_name) else { + return (None, None); + }; + (cfg.adapter.host.clone(), cfg.adapter.port) +} + +fn apply_adapter_bind(adapter_bind: (Option, Option), command: &mut Command) { + let (host, port) = adapter_bind; + if let Some(host) = host { + if env::var_os("EDGEZERO__ADAPTER__HOST").is_none() { + command.env("EDGEZERO__ADAPTER__HOST", host); + } + } + if let Some(port) = port { + if env::var_os("EDGEZERO__ADAPTER__PORT").is_none() { + command.env("EDGEZERO__ADAPTER__PORT", port.to_string()); + } + } +} + +fn apply_environment(environment: &ResolvedEnvironment, command: &mut Command) -> CliResult<()> { + for binding in &environment.variables { + if let Some(value) = &binding.value { + if env::var_os(&binding.env).is_none() { + command.env(&binding.env, value); + } + } + } + + let missing: Vec = environment + .secrets + .iter() + .filter(|binding| env::var_os(&binding.env).is_none()) + .map(|binding| format!("{} (env `{}`)", binding.name, binding.env)) + .collect(); + if missing.is_empty() { + Ok(()) + } else { + cli_error(format!( + "EdgeZero command requires the following secrets to be set: {}", + missing.join(", ") + )) + } +} + +fn shell_escape(arg: &str) -> String { + if arg.is_empty() { + "''".to_string() + } else if arg + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || "._-/:=@".contains(ch)) + { + arg.to_string() + } else { + format!("'{}'", arg.replace('\'', "'\"'\"'")) + } +} + +fn shell_join(args: &[String]) -> String { + args.iter() + .map(|arg| shell_escape(arg.as_str())) + .collect::>() + .join(" ") +} + +fn push_config_entries(request: &ConfigPushRequest, out: &mut dyn Write) -> CliResult<()> { + let manifest_loader = ManifestLoader::from_path(&request.manifest).map_err(|error| { + report_error(format!( + "failed to load {}: {error}", + request.manifest.display() + )) + })?; + ensure_adapter_defined(&request.adapter, Some(&manifest_loader))?; + let manifest = manifest_loader.manifest(); + let (_canonical, adapter_cfg) = manifest.adapter_entry(&request.adapter).ok_or_else(|| { + report_error(format!( + "adapter `{}` is not declared in {}", + request.adapter, + request.manifest.display() + )) + })?; + + let adapter = adapter_registry::get_adapter(&request.adapter).ok_or_else(|| { + report_error(format!( + "adapter `{}` is declared in {} but not registered in this build", + request.adapter, + request.manifest.display() + )) + })?; + + let declaration = manifest.stores.config.as_ref().ok_or_else(|| { + report_error("manifest has no `[stores.config]` section; declare it before pushing config") + })?; + if !declaration.ids.iter().any(|id| id == &request.store) { + return cli_error(format!( + "--store={:?} is not in [stores.config].ids ({:?})", + request.store, declaration.ids + )); + } + + let env_config = EnvConfig::from_env(); + let store = ResolvedStoreId::new( + request.store.clone(), + env_config.store_name("config", &request.store), + ); + let manifest_root = request + .manifest + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let mut push_context = AdapterPushContext::new().with_local(request.local); + if let Some(path) = request.runtime_config.as_deref() { + push_context = push_context.with_runtime_config_path(path); + } + if let Some(deploy_cmd) = adapter_cfg.commands.deploy.as_deref() { + push_context = push_context.with_manifest_adapter_deploy_cmd(deploy_cmd); + } + + let lines = if request.local { + adapter.push_config_entries_local( + manifest_root, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.component.as_deref(), + &store, + &request.entries, + &push_context, + request.dry_run, + ) + } else { + adapter.push_config_entries( + manifest_root, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.component.as_deref(), + &store, + &request.entries, + &push_context, + request.dry_run, + ) + } + .map_err(|error| report_error(format!("[edgezero] {error}")))?; + + if request.dry_run { + writeln!( + out, + "Config push dry run: {} entries -> {} ({})", + request.settings_entry_count, request.store, request.config_hash + ) + .map_err(|error| report_error(format!("failed to write command output: {error}")))?; + } else { + writeln!( + out, + "Config pushed: {} entries -> {} ({})", + request.settings_entry_count, request.store, request.config_hash + ) + .map_err(|error| report_error(format!("failed to write command output: {error}")))?; + } + for key in request + .entries + .iter() + .map(|(key, _value)| key) + .filter(|key| key.as_str() != trusted_server_core::config_payload::CONFIG_KEYS_KEY) + .filter(|key| key.as_str() != trusted_server_core::config_payload::CONFIG_HASH_KEY) + { + writeln!(out, " {key}") + .map_err(|error| report_error(format!("failed to write command output: {error}")))?; + } + for line in lines { + writeln!(out, "{line}") + .map_err(|error| report_error(format!("failed to write command output: {error}")))?; + } + Ok(()) +} + +#[cfg(test)] +pub mod tests { + use super::*; + + #[derive(Default)] + pub struct FakeEdgeZeroDelegate { + pub lifecycle_calls: Vec<(LifecycleCommand, String, Vec)>, + pub push_calls: Vec, + } + + impl EdgeZeroDelegate for FakeEdgeZeroDelegate { + fn run_lifecycle( + &mut self, + command: LifecycleCommand, + adapter: &str, + passthrough: &[String], + ) -> CliResult<()> { + self.lifecycle_calls + .push((command, adapter.to_string(), passthrough.to_vec())); + Ok(()) + } + + fn push_config( + &mut self, + request: &ConfigPushRequest, + out: &mut dyn Write, + ) -> CliResult<()> { + self.push_calls.push(request.clone()); + writeln!(out, "fake push").map_err(|error| { + report_error(format!("failed to write fake push output: {error}")) + })?; + Ok(()) + } + } +} diff --git a/crates/trusted-server-cli/src/error.rs b/crates/trusted-server-cli/src/error.rs new file mode 100644 index 000000000..c13a9ebe2 --- /dev/null +++ b/crates/trusted-server-cli/src/error.rs @@ -0,0 +1,25 @@ +use core::error::Error; + +use error_stack::Report; + +#[derive(Debug, derive_more::Display)] +#[display("{message}")] +pub struct CliError { + message: String, +} + +impl Error for CliError {} + +pub type CliResult = Result>; + +pub fn cli_error(message: impl Into) -> CliResult { + Err(Report::new(CliError { + message: message.into(), + })) +} + +pub fn report_error(message: impl Into) -> Report { + Report::new(CliError { + message: message.into(), + }) +} diff --git a/crates/trusted-server-cli/src/lib.rs b/crates/trusted-server-cli/src/lib.rs new file mode 100644 index 000000000..67bc936b7 --- /dev/null +++ b/crates/trusted-server-cli/src/lib.rs @@ -0,0 +1,24 @@ +#![cfg_attr( + test, + allow( + clippy::print_stdout, + clippy::print_stderr, + clippy::panic, + clippy::dbg_macro, + clippy::unwrap_used, + ) +)] + +#[cfg(not(target_arch = "wasm32"))] +mod args; +#[cfg(not(target_arch = "wasm32"))] +mod config_command; +#[cfg(not(target_arch = "wasm32"))] +mod edgezero_delegate; +#[cfg(not(target_arch = "wasm32"))] +mod error; +#[cfg(not(target_arch = "wasm32"))] +mod run; + +#[cfg(not(target_arch = "wasm32"))] +pub use run::{run_from_env, run_with_io}; diff --git a/crates/trusted-server-cli/src/main.rs b/crates/trusted-server-cli/src/main.rs new file mode 100644 index 000000000..d9263de91 --- /dev/null +++ b/crates/trusted-server-cli/src/main.rs @@ -0,0 +1,13 @@ +#[cfg(not(target_arch = "wasm32"))] +fn main() { + use std::process; + + edgezero_cli::init_cli_logger(); + if let Err(err) = trusted_server_cli::run_from_env() { + log::error!("{err:?}"); + process::exit(1); + } +} + +#[cfg(target_arch = "wasm32")] +fn main() {} diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs new file mode 100644 index 000000000..fbe49f2ae --- /dev/null +++ b/crates/trusted-server-cli/src/run.rs @@ -0,0 +1,203 @@ +use std::io::Write; + +use clap::Parser as _; + +use crate::args::{Args, AuthCommand, Command, ConfigCommand}; +use crate::config_command::{load_config, run_init, run_validate}; +use crate::edgezero_delegate::{ + ConfigPushRequest, EdgeZeroDelegate, LifecycleCommand, ProductionEdgeZeroDelegate, +}; +use crate::error::CliResult; + +/// Run the CLI using process arguments and standard output streams. +/// +/// # Errors +/// +/// Returns an error when command parsing, config validation, `EdgeZero` +/// delegation, or output writing fails. +pub fn run_from_env() -> CliResult<()> { + let args = Args::parse(); + let mut stdout = std::io::stdout(); + let mut stderr = std::io::stderr(); + let mut delegate = ProductionEdgeZeroDelegate; + dispatch(args, &mut delegate, &mut stdout, &mut stderr) +} + +/// Run the CLI from explicit arguments and output streams. +/// +/// # Errors +/// +/// Returns an error when command parsing, config validation, `EdgeZero` +/// delegation, or output writing fails. +pub fn run_with_io(args: I, out: &mut dyn Write, err: &mut dyn Write) -> CliResult<()> +where + I: IntoIterator, + T: Into + Clone, +{ + let parsed = Args::try_parse_from(args).map_err(|error| { + crate::error::report_error(format!("failed to parse command arguments: {error}")) + })?; + let mut delegate = ProductionEdgeZeroDelegate; + dispatch(parsed, &mut delegate, out, err) +} + +fn dispatch( + args: Args, + delegate: &mut dyn EdgeZeroDelegate, + out: &mut dyn Write, + err: &mut dyn Write, +) -> CliResult<()> { + match args.command { + Command::Auth(auth) => match auth.command { + AuthCommand::Login(login) => delegate.run_lifecycle( + LifecycleCommand::AuthLogin, + &login.adapter, + &login.edgezero_args, + ), + AuthCommand::Logout(logout) => delegate.run_lifecycle( + LifecycleCommand::AuthLogout, + &logout.adapter, + &logout.edgezero_args, + ), + AuthCommand::Status(status) => delegate.run_lifecycle( + LifecycleCommand::AuthStatus, + &status.adapter, + &status.edgezero_args, + ), + }, + Command::Build(build) => delegate.run_lifecycle( + LifecycleCommand::Build, + &build.adapter, + &build.edgezero_args, + ), + Command::Config(ConfigCommand::Init(init)) => run_init(&init, out), + Command::Config(ConfigCommand::Validate(validate)) => run_validate(&validate, out, err), + Command::Config(ConfigCommand::Push(push)) => { + let loaded = load_config(&push.config)?; + let request = ConfigPushRequest { + adapter: push.adapter, + manifest: push.manifest, + store: push.store, + local: push.local, + dry_run: push.dry_run, + runtime_config: push.runtime_config, + entries: loaded.payload.entries.into_iter().collect(), + settings_entry_count: loaded.payload.settings_entries.len(), + config_hash: loaded.payload.hash, + }; + delegate.push_config(&request, out) + } + Command::Deploy(deploy) => delegate.run_lifecycle( + LifecycleCommand::Deploy, + &deploy.adapter, + &deploy.edgezero_args, + ), + Command::Provision(provision) => delegate.run_lifecycle( + LifecycleCommand::Provision, + &provision.adapter, + &provision.edgezero_args, + ), + Command::Serve(serve) => delegate.run_lifecycle( + LifecycleCommand::Serve, + &serve.adapter, + &serve.edgezero_args, + ), + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + use crate::edgezero_delegate::tests::FakeEdgeZeroDelegate; + + fn valid_config() -> String { + r#" +[publisher] +domain = "example.com" +cookie_domain = ".example.com" +origin_url = "https://origin.example.com" +proxy_secret = "production-proxy-secret" + +[ec] +passphrase = "production-secret-key-32-bytes-min" + +[[handlers]] +path = "^/_ts/admin" +username = "admin" +password = "production-admin-password-32-bytes" +"# + .to_string() + } + + fn parse(args: &[&str]) -> Args { + Args::try_parse_from(args).expect("should parse args") + } + + #[test] + fn build_delegates_to_edgezero_with_passthrough() { + let args = parse(&["ts", "build", "--adapter", "fastly", "--", "--release"]); + let mut delegate = FakeEdgeZeroDelegate::default(); + dispatch(args, &mut delegate, &mut Vec::new(), &mut Vec::new()) + .expect("should dispatch build"); + + assert_eq!(delegate.lifecycle_calls.len(), 1); + assert_eq!(delegate.lifecycle_calls[0].0, LifecycleCommand::Build); + assert_eq!(delegate.lifecycle_calls[0].1, "fastly"); + assert_eq!(delegate.lifecycle_calls[0].2, ["--release"]); + } + + #[test] + fn auth_status_delegates_to_edgezero() { + let args = parse(&["ts", "auth", "status", "--adapter", "fastly"]); + let mut delegate = FakeEdgeZeroDelegate::default(); + dispatch(args, &mut delegate, &mut Vec::new(), &mut Vec::new()) + .expect("should dispatch auth status"); + + assert_eq!(delegate.lifecycle_calls.len(), 1); + assert_eq!(delegate.lifecycle_calls[0].0, LifecycleCommand::AuthStatus); + assert_eq!(delegate.lifecycle_calls[0].1, "fastly"); + } + + #[test] + fn config_push_validates_and_forwards_entries() { + let temp = TempDir::new().expect("should create temp dir"); + let config_path = temp.path().join("trusted-server.toml"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&config_path, valid_config()).expect("should write config"); + fs::write(&manifest_path, "[app]\nname = \"trusted-server\"\n") + .expect("should write manifest placeholder"); + let args = Args::try_parse_from([ + "ts", + "config", + "push", + "--adapter", + "fastly", + "--config", + config_path.to_str().expect("path should be UTF-8"), + "--manifest", + manifest_path.to_str().expect("path should be UTF-8"), + "--dry-run", + ]) + .expect("should parse push args"); + let mut delegate = FakeEdgeZeroDelegate::default(); + let mut out = Vec::new(); + + dispatch(args, &mut delegate, &mut out, &mut Vec::new()).expect("should dispatch push"); + + assert_eq!(delegate.push_calls.len(), 1); + let call = &delegate.push_calls[0]; + assert_eq!(call.adapter, "fastly"); + assert!(call.dry_run, "should forward dry-run"); + assert_eq!(call.store, "app_config"); + assert!( + call.entries + .iter() + .any(|(key, _value)| key == trusted_server_core::config_payload::CONFIG_HASH_KEY), + "should include hash metadata" + ); + } +} diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index c80b0e6ae..d640f1d8d 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -18,7 +18,6 @@ bytes = { workspace = true } chacha20poly1305 = { workspace = true } chrono = { workspace = true } async-trait = { workspace = true } -config = { workspace = true } cookie = { workspace = true } derive_more = { workspace = true } error-stack = { workspace = true } @@ -50,23 +49,11 @@ validator = { workspace = true } ed25519-dalek = { workspace = true } edgezero-core = { workspace = true } -[build-dependencies] -config = { workspace = true } -derive_more = { workspace = true } -error-stack = { workspace = true } -http = { workspace = true } -log = { workspace = true } -regex = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -toml = { workspace = true } -url = { workspace = true } -validator = { workspace = true } - [features] default = [] [dev-dependencies] +config = { workspace = true } criterion = { workspace = true } edgezero-core = { workspace = true, features = ["test-utils"] } temp-env = { workspace = true } diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 8a26af409..c2bce4fe2 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -1,64 +1,3 @@ -// Build script includes source modules (`error`, `auction_config_types`, etc.) -// for compile-time config validation. Not all items from those modules are used -// in the build context, so `dead_code` is expected. -#![allow(clippy::unwrap_used, clippy::panic, dead_code)] - -#[path = "src/error.rs"] -mod error; - -#[path = "src/auction_config_types.rs"] -mod auction_config_types; - -#[path = "src/redacted.rs"] -mod redacted; - -#[path = "src/consent_config.rs"] -mod consent_config; - -#[path = "src/host_header.rs"] -mod host_header; - -#[path = "src/platform/image_optimizer.rs"] -mod platform_image_optimizer; - -mod platform { - pub use crate::platform_image_optimizer::PlatformImageOptimizerRegion; -} - -#[path = "src/settings.rs"] -mod settings; - -use std::fs; -use std::path::Path; - -const TRUSTED_SERVER_INIT_CONFIG_PATH: &str = "../../trusted-server.toml"; -const TRUSTED_SERVER_OUTPUT_CONFIG_PATH: &str = "../../target/trusted-server-out.toml"; - fn main() { - // Always rerun build.rs: integration settings are stored in a flat - // HashMap, so we cannot enumerate all possible env - // var keys ahead of time. Emitting rerun-if-changed for a nonexistent - // file forces cargo to always rerun the build script. - println!("cargo:rerun-if-changed=_always_rebuild_sentinel_"); - - // Read init config - let init_config_path = Path::new(TRUSTED_SERVER_INIT_CONFIG_PATH); - let toml_content = fs::read_to_string(init_config_path) - .unwrap_or_else(|_| panic!("Failed to read {init_config_path:?}")); - - // Merge base TOML with environment variable overrides and write output. - // Panics if admin endpoints are not covered by a handler. - let settings = settings::Settings::from_toml_and_env(&toml_content) - .expect("Failed to parse settings at build time"); - - let merged_toml = - toml::to_string_pretty(&settings).expect("Failed to serialize settings to TOML"); - - // Only write when content changes to avoid unnecessary recompilation. - let dest_path = Path::new(TRUSTED_SERVER_OUTPUT_CONFIG_PATH); - let current = fs::read_to_string(dest_path).unwrap_or_default(); - if current != merged_toml { - fs::write(dest_path, merged_toml) - .unwrap_or_else(|_| panic!("Failed to write {dest_path:?}")); - } + println!("cargo:rerun-if-changed=build.rs"); } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index f265eb1bf..1ba101c9c 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -78,7 +78,11 @@ pub async fn handle_auction( } let (parts, body) = req.into_parts(); - let body_bytes = body.into_bytes(); + let body_bytes = body.into_bytes().ok_or_else(|| { + Report::new(TrustedServerError::BadRequest { + message: "Streaming auction request bodies are not supported".to_string(), + }) + })?; if body_bytes.len() > MAX_AUCTION_BODY_SIZE { return Response::builder() .status(StatusCode::PAYLOAD_TOO_LARGE) @@ -801,4 +805,49 @@ mod tests { "should return 413 for auction body over limit" ); } + + #[tokio::test] + async fn auction_rejects_streaming_body_instead_of_treating_as_empty() { + use bytes::Bytes; + use edgezero_core::body::Body as EdgeBody; + use http::{Method, Request as HttpRequest}; + + use crate::auction::build_orchestrator; + use crate::consent::ConsentContext; + use crate::ec::EcContext; + use crate::error::TrustedServerError; + use crate::platform::test_support::noop_services; + use crate::test_support::tests::create_test_settings; + + let settings = create_test_settings(); + let orchestrator = build_orchestrator(&settings).expect("should build orchestrator"); + let services = noop_services(); + let ec_context = EcContext::new_for_test(None, ConsentContext::default()); + let stream = futures::stream::iter([Bytes::from_static(br#"{}"#)]); + let req = HttpRequest::builder() + .method(Method::POST) + .uri("https://test.com/auction") + .body(EdgeBody::stream(stream)) + .expect("should build request"); + + let result = handle_auction( + &settings, + &orchestrator, + None, + None, + &ec_context, + &services, + req, + ) + .await; + + let err = match result { + Ok(_) => panic!("streaming body should be rejected"), + Err(err) => err, + }; + assert!( + matches!(err.current_context(), TrustedServerError::BadRequest { .. }), + "streaming request body should fail as bad request" + ); + } } diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index a00af8d08..f6e6b43f4 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -441,7 +441,7 @@ mod tests { } fn response_json(response: Response) -> JsonValue { - serde_json::from_slice(&response.into_body().into_bytes()) + serde_json::from_slice(&response.into_body().into_bytes().unwrap_or_default()) .expect("should parse JSON response") } diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index bc486ded9..887513583 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -5,6 +5,7 @@ use std::collections::HashSet; /// Auction orchestration configuration. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct AuctionConfig { /// Enable the auction orchestrator #[serde(default)] diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs index 088d27e84..59661875c 100644 --- a/crates/trusted-server-core/src/auth.rs +++ b/crates/trusted-server-core/src/auth.rs @@ -17,8 +17,8 @@ const BASIC_AUTH_REALM: &str = r#"Basic realm="Trusted Server""#; /// when the supplied credentials are valid. Returns `Ok(Some(Response))` with /// the auth challenge when credentials are missing or invalid. /// -/// Admin endpoints are protected by requiring a handler at build time; see -/// [`Settings::from_toml_and_env`]. Credential checks use constant-time +/// Admin endpoints are protected by requiring a handler during settings +/// finalization; see [`Settings::from_toml`]. Credential checks use constant-time /// comparison for both username and password, and evaluate both regardless of /// individual match results to avoid timing oracles. /// diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs new file mode 100644 index 000000000..d799b4fcb --- /dev/null +++ b/crates/trusted-server-core/src/config_payload.rs @@ -0,0 +1,482 @@ +//! Deterministic config-store payloads for Trusted Server settings. +//! +//! The `ts` CLI uses this module to flatten validated [`Settings`] into +//! `EdgeZero` config-store entries. Runtime loading uses the same escaping, +//! hashing, and reconstruction rules so push-time and runtime semantics cannot +//! drift. + +use std::collections::BTreeMap; + +use error_stack::{Report, ResultExt}; +use serde_json::{Map as JsonMap, Value as JsonValue}; +use sha2::{Digest as _, Sha256}; + +use crate::error::TrustedServerError; +use crate::settings::Settings; + +/// Metadata key containing the SHA-256 hash of settings-only entries. +pub const CONFIG_HASH_KEY: &str = "ts-config-hash"; +/// Metadata key containing the sorted list of settings-only entry keys. +pub const CONFIG_KEYS_KEY: &str = "ts-config-keys"; +/// Prefix reserved for Trusted Server config metadata keys. +pub const CONFIG_METADATA_PREFIX: &str = "ts-config-"; + +/// Flattened Trusted Server config payload ready for config-store publication. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigPayload { + /// Flattened settings entries, excluding metadata entries. + pub settings_entries: BTreeMap, + /// Flattened settings entries plus Trusted Server metadata entries. + pub entries: BTreeMap, + /// Sorted flattened settings keys, excluding metadata entries. + pub keys: Vec, + /// `sha256:` over the canonical settings-only entry map. + pub hash: String, +} + +/// Escape one flattened-key path segment. +#[must_use] +pub fn escape_key_segment(segment: &str) -> String { + let mut escaped = String::with_capacity(segment.len()); + for ch in segment.chars() { + match ch { + '\\' => escaped.push_str("\\\\"), + '.' => escaped.push_str("\\."), + other => escaped.push(other), + } + } + escaped +} + +/// Split an escaped dotted key into unescaped path segments. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when the key has an empty +/// segment or ends with a dangling escape character. +pub fn split_escaped_key(key: &str) -> Result, Report> { + let mut segments = Vec::new(); + let mut current = String::new(); + let mut escaping = false; + + for ch in key.chars() { + if escaping { + current.push(ch); + escaping = false; + continue; + } + + match ch { + '\\' => escaping = true, + '.' => { + if current.is_empty() { + return configuration_error(format!( + "flattened config key `{key}` contains an empty path segment" + )); + } + segments.push(current); + current = String::new(); + } + other => current.push(other), + } + } + + if escaping { + return configuration_error(format!( + "flattened config key `{key}` ends with an incomplete escape" + )); + } + if current.is_empty() { + return configuration_error(format!( + "flattened config key `{key}` contains an empty path segment" + )); + } + + segments.push(current); + Ok(segments) +} + +/// Build a deterministic config-store payload from validated settings. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when settings cannot be +/// serialized, flattened, or hashed. +pub fn build_config_payload( + settings: &Settings, +) -> Result> { + let json = + serde_json::to_value(settings).change_context(TrustedServerError::Configuration { + message: "failed to serialize settings to JSON".to_string(), + })?; + + let mut settings_entries = BTreeMap::new(); + flatten_json_value(&json, &mut Vec::new(), &mut settings_entries)?; + + for key in settings_entries.keys() { + if key.starts_with(CONFIG_METADATA_PREFIX) { + return configuration_error(format!( + "settings key `{key}` uses reserved metadata prefix `{CONFIG_METADATA_PREFIX}`" + )); + } + } + + let keys: Vec = settings_entries.keys().cloned().collect(); + let hash = hash_settings_entries(&settings_entries)?; + let mut entries = settings_entries.clone(); + let keys_json = + serde_json::to_string(&keys).change_context(TrustedServerError::Configuration { + message: "failed to serialize config key metadata".to_string(), + })?; + entries.insert(CONFIG_KEYS_KEY.to_string(), keys_json); + entries.insert(CONFIG_HASH_KEY.to_string(), hash.clone()); + + Ok(ConfigPayload { + settings_entries, + entries, + keys, + hash, + }) +} + +/// Reconstruct validated [`Settings`] from flattened config-store entries. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when metadata is missing, the +/// hash does not match, flattened keys cannot be reconstructed, or the resulting +/// settings fail schema or semantic validation. +pub fn settings_from_config_entries( + entries: &BTreeMap, +) -> Result> { + let keys_value = entries.get(CONFIG_KEYS_KEY).ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: format!("missing `{CONFIG_KEYS_KEY}` metadata entry"), + }) + })?; + let keys: Vec = + serde_json::from_str(keys_value).change_context(TrustedServerError::Configuration { + message: format!("`{CONFIG_KEYS_KEY}` metadata is not a JSON string array"), + })?; + + let mut settings_entries = BTreeMap::new(); + for key in &keys { + if key.starts_with(CONFIG_METADATA_PREFIX) { + return configuration_error(format!( + "settings key `{key}` uses reserved metadata prefix `{CONFIG_METADATA_PREFIX}`" + )); + } + let value = entries.get(key).ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: format!("missing flattened config entry `{key}`"), + }) + })?; + settings_entries.insert(key.clone(), value.clone()); + } + + let expected_hash = hash_settings_entries(&settings_entries)?; + let actual_hash = entries.get(CONFIG_HASH_KEY).ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: format!("missing `{CONFIG_HASH_KEY}` metadata entry"), + }) + })?; + if actual_hash != &expected_hash { + return configuration_error(format!( + "config hash mismatch: expected `{expected_hash}`, got `{actual_hash}`" + )); + } + + let mut root = JsonMap::new(); + for (key, raw_value) in settings_entries { + let path = split_escaped_key(&key)?; + insert_flattened_value(&mut root, &path, parse_entry_value(&raw_value))?; + } + + let settings = Settings::from_json_value(JsonValue::Object(root))?; + settings.reject_placeholder_secrets()?; + Ok(settings) +} + +fn flatten_json_value( + value: &JsonValue, + path: &mut Vec, + out: &mut BTreeMap, +) -> Result<(), Report> { + match value { + JsonValue::Null => Ok(()), + JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => { + insert_leaf(path, value, out) + } + JsonValue::Array(_) => { + let canonical = canonical_json_value(value); + insert_leaf(path, &canonical, out) + } + JsonValue::Object(map) => { + let mut sorted = BTreeMap::new(); + for (key, child) in map { + sorted.insert(escape_key_segment(key), child); + } + for (escaped_key, child) in sorted { + path.push(escaped_key); + flatten_json_value(child, path, out)?; + path.pop(); + } + Ok(()) + } + } +} + +fn insert_leaf( + path: &[String], + value: &JsonValue, + out: &mut BTreeMap, +) -> Result<(), Report> { + if path.is_empty() { + return configuration_error( + "settings serialized to a scalar; expected a JSON object".to_string(), + ); + } + let encoded = + serde_json::to_string(value).change_context(TrustedServerError::Configuration { + message: "failed to serialize flattened config value".to_string(), + })?; + let key = path.join("."); + out.insert(key, encoded); + Ok(()) +} + +fn canonical_json_value(value: &JsonValue) -> JsonValue { + match value { + JsonValue::Array(items) => { + JsonValue::Array(items.iter().map(canonical_json_value).collect()) + } + JsonValue::Object(map) => { + let mut sorted = BTreeMap::new(); + for (key, value) in map { + sorted.insert(key.clone(), canonical_json_value(value)); + } + let mut canonical = JsonMap::new(); + for (key, value) in sorted { + canonical.insert(key, value); + } + JsonValue::Object(canonical) + } + other => other.clone(), + } +} + +fn hash_settings_entries( + entries: &BTreeMap, +) -> Result> { + let bytes = serde_json::to_vec(entries).change_context(TrustedServerError::Configuration { + message: "failed to serialize canonical settings entries".to_string(), + })?; + let digest = Sha256::digest(&bytes); + Ok(format!("sha256:{}", hex::encode(digest))) +} + +fn insert_flattened_value( + root: &mut JsonMap, + path: &[String], + value: JsonValue, +) -> Result<(), Report> { + if path.is_empty() { + return configuration_error("flattened config key path is empty".to_string()); + } + + let mut current = root; + for segment in &path[..path.len().saturating_sub(1)] { + let entry = current + .entry(segment.clone()) + .or_insert_with(|| JsonValue::Object(JsonMap::new())); + let JsonValue::Object(next) = entry else { + return configuration_error(format!( + "flattened config key collision at segment `{segment}`" + )); + }; + current = next; + } + + let leaf = path.last().expect("should have at least one segment"); + if current.insert(leaf.clone(), value).is_some() { + return configuration_error(format!( + "duplicate flattened config key `{}`", + path.join(".") + )); + } + Ok(()) +} + +fn parse_entry_value(raw: &str) -> JsonValue { + serde_json::from_str(raw).unwrap_or_else(|_| JsonValue::String(raw.to_string())) +} + +fn configuration_error(message: String) -> Result> { + Err(Report::new(TrustedServerError::Configuration { message })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::redacted::Redacted; + use crate::test_support::tests::crate_test_settings_str; + + fn test_settings() -> Settings { + Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings") + } + + #[test] + fn escapes_and_splits_key_segments() { + let escaped = escape_key_segment(r"a.b\c"); + assert_eq!(escaped, r"a\.b\\c"); + let parts = + split_escaped_key(&format!("root.{escaped}.leaf")).expect("should split escaped key"); + assert_eq!(parts, vec!["root", r"a.b\c", "leaf"]); + } + + #[test] + fn builds_payload_with_metadata_hash() { + let payload = build_config_payload(&test_settings()).expect("should build payload"); + assert!( + payload.entries.contains_key(CONFIG_KEYS_KEY), + "should include keys metadata" + ); + assert!( + payload.entries.contains_key(CONFIG_HASH_KEY), + "should include hash metadata" + ); + assert_eq!( + payload.entries.get(CONFIG_HASH_KEY), + Some(&payload.hash), + "metadata hash should match payload hash" + ); + assert!( + !payload.settings_entries.contains_key(CONFIG_HASH_KEY), + "settings-only map should exclude metadata" + ); + } + + #[test] + fn payload_round_trips_through_flattened_entries() { + let original = test_settings(); + let payload = build_config_payload(&original).expect("should build payload"); + let reconstructed = + settings_from_config_entries(&payload.entries).expect("should reconstruct settings"); + assert_eq!( + reconstructed.publisher.domain, original.publisher.domain, + "should preserve publisher domain" + ); + assert_eq!( + reconstructed.ec.pull_sync_concurrency, original.ec.pull_sync_concurrency, + "should preserve numeric fields" + ); + assert_eq!( + reconstructed.handlers.len(), + original.handlers.len(), + "should preserve arrays" + ); + } + + #[test] + fn strings_that_look_like_json_scalars_round_trip_as_strings() { + let mut original = test_settings(); + original.publisher.proxy_secret = Redacted::new("1234567890".to_string()); + original.ec.passphrase = Redacted::new("12345678901234567890123456789012".to_string()); + original.handlers[0].password = Redacted::new("true".to_string()); + + let payload = build_config_payload(&original).expect("should build payload"); + assert_eq!( + payload.settings_entries.get("publisher.proxy_secret"), + Some(&"\"1234567890\"".to_string()), + "string entries should be JSON encoded to preserve type" + ); + + let reconstructed = + settings_from_config_entries(&payload.entries).expect("should reconstruct settings"); + assert_eq!( + reconstructed.publisher.proxy_secret.expose(), + original.publisher.proxy_secret.expose(), + "numeric-looking proxy secret should remain a string" + ); + assert_eq!( + reconstructed.ec.passphrase.expose(), + original.ec.passphrase.expose(), + "numeric-looking passphrase should remain a string" + ); + assert_eq!( + reconstructed.handlers[0].password.expose(), + original.handlers[0].password.expose(), + "boolean-looking handler password should remain a string" + ); + } + + #[test] + fn arrays_use_canonical_object_key_order() { + let value = serde_json::json!({ + "items": [ + {"z": 1, "a": true}, + {"b": [{"d": 4, "c": 3}]} + ] + }); + let mut entries = BTreeMap::new(); + flatten_json_value(&value, &mut Vec::new(), &mut entries).expect("should flatten"); + assert_eq!( + entries.get("items"), + Some(&r#"[{"a":true,"z":1},{"b":[{"c":3,"d":4}]}]"#.to_string()), + "array object keys should be sorted" + ); + } + + #[test] + fn hash_is_stable_for_equivalent_toml_ordering() { + let first = r#" +[[handlers]] +path = "^/_ts/admin" +username = "admin" +password = "production-admin-password-32-bytes" + +[publisher] +domain = "example.com" +cookie_domain = ".example.com" +origin_url = "https://origin.example.com" +proxy_secret = "unit-test-proxy-secret" + +[ec] +passphrase = "test-secret-key-32-bytes-minimum" +pull_sync_concurrency = 5 +"#; + let second = r#" +[ec] +pull_sync_concurrency = 5 +passphrase = "test-secret-key-32-bytes-minimum" + +[publisher] +proxy_secret = "unit-test-proxy-secret" +origin_url = "https://origin.example.com" +cookie_domain = ".example.com" +domain = "example.com" + +[[handlers]] +password = "production-admin-password-32-bytes" +username = "admin" +path = "^/_ts/admin" +"#; + let first_settings = Settings::from_toml(first).expect("should parse first settings"); + let second_settings = Settings::from_toml(second).expect("should parse second settings"); + let first_payload = build_config_payload(&first_settings).expect("should build first"); + let second_payload = build_config_payload(&second_settings).expect("should build second"); + assert_eq!(first_payload.hash, second_payload.hash); + } + + #[test] + fn hash_mismatch_is_rejected() { + let payload = build_config_payload(&test_settings()).expect("should build payload"); + let mut entries = payload.entries; + entries.insert(CONFIG_HASH_KEY.to_string(), "sha256:bad".to_string()); + let err = settings_from_config_entries(&entries).expect_err("should reject hash mismatch"); + assert!( + err.to_string().contains("config hash mismatch"), + "error should mention hash mismatch" + ); + } +} diff --git a/crates/trusted-server-core/src/consent_config.rs b/crates/trusted-server-core/src/consent_config.rs index e5fed1a9c..d8027d7a5 100644 --- a/crates/trusted-server-core/src/consent_config.rs +++ b/crates/trusted-server-core/src/consent_config.rs @@ -35,6 +35,7 @@ fn str_vec(codes: &[&str]) -> Vec { /// Top-level consent configuration (`[consent]` in TOML). #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct ConsentConfig { /// Operating mode for consent handling. /// @@ -169,6 +170,7 @@ impl ConsentForwardingMode { /// this list, the system logs that GDPR applies, enabling publishers to /// monitor compliance coverage. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct GdprConfig { /// ISO 3166-1 alpha-2 country codes where GDPR applies. #[serde(default = "default_gdpr_countries")] @@ -191,6 +193,7 @@ impl Default for GdprConfig { /// /// Config-driven to avoid recompilation when new state laws take effect. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct UsStatesConfig { /// US state codes with active comprehensive privacy laws. #[serde(default = "default_us_privacy_states")] @@ -215,6 +218,7 @@ impl Default for UsStatesConfig { /// These reflect the publisher's actual compliance posture — they are /// **publisher policy**, not protocol requirements. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct UsPrivacyDefaultsConfig { /// Whether the publisher has actually shown a CCPA notice to the user. #[serde(default = "default_true")] @@ -248,6 +252,7 @@ impl Default for UsPrivacyDefaultsConfig { /// How to resolve disagreements between GPP and TC String when both are /// present (`[consent.conflict_resolution]`). #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct ConflictResolutionConfig { /// Resolution strategy. #[serde(default = "default_conflict_mode")] diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 8a2c61575..dd39ad899 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -192,6 +192,24 @@ impl IntegrationConfig for PrebidIntegrationConfig { } } +/// Validate enabled Prebid config using the same startup-only checks as runtime registration. +/// +/// # Errors +/// +/// Returns a configuration error if enabled Prebid settings fail typed parsing, +/// schema validation, or bidder-param override compilation. +pub fn validate_config_for_startup( + settings: &Settings, +) -> Result, Report> { + let Some(config) = + settings.integration_config::(PREBID_INTEGRATION_ID)? + else { + return Ok(None); + }; + BidParamOverrideEngine::try_from_config(&config)?; + Ok(Some(config)) +} + /// Canonical bidder-param override rule. /// /// A rule matches against the request-time facts in [`BidParamOverrideWhen`] @@ -2064,8 +2082,14 @@ server_url = "https://prebid.example" .expect("should have cache-control"); assert!(cache_control.contains("max-age=31536000")); - let body = String::from_utf8(response.into_body().into_bytes().to_vec()) - .expect("should parse script body as utf-8"); + let body = String::from_utf8( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .to_vec(), + ) + .expect("should parse script body as utf-8"); assert!(body.contains("// Script overridden by Trusted Server")); } diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index f4d1e3263..d6f5d30e4 100644 --- a/crates/trusted-server-core/src/integrations/testlight.rs +++ b/crates/trusted-server-core/src/integrations/testlight.rs @@ -458,7 +458,7 @@ mod tests { "should route outbound request through PlatformHttpClient" ); let response_json: serde_json::Value = - serde_json::from_slice(&response.into_body().into_bytes()) + serde_json::from_slice(&response.into_body().into_bytes().unwrap_or_default()) .expect("should parse JSON response"); assert_eq!( response_json["ok"], true, diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 2a5632e99..e30f925e3 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -36,6 +36,7 @@ pub mod auth; pub mod backend; #[doc(hidden)] pub mod compat; +pub mod config_payload; pub mod consent; pub mod consent_config; pub mod constants; diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 49d8ddd36..77a11c236 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -34,8 +34,24 @@ const STREAMING_CHUNK_SIZE: usize = 8192; const SIGN_MAX_BODY_BYTES: usize = 65536; const REBUILD_MAX_BODY_BYTES: usize = 65536; -fn body_as_reader(body: EdgeBody) -> Cursor { - Cursor::new(body.into_bytes()) +fn body_as_reader(body: EdgeBody) -> Result, Report> { + let bytes = body.into_bytes().ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "streaming response body reached buffered rewrite pipeline".to_string(), + }) + })?; + Ok(Cursor::new(bytes)) +} + +fn request_body_bytes( + body: EdgeBody, + endpoint: &str, +) -> Result> { + body.into_bytes().ok_or_else(|| { + Report::new(TrustedServerError::BadRequest { + message: format!("Streaming request bodies are not supported by {endpoint}"), + }) + }) } /// Headers copied from the original client request to the upstream proxy request @@ -398,7 +414,7 @@ fn process_response_with_pipeline( let mut output = Vec::new(); let mut pipeline = StreamingPipeline::new(config, processor); pipeline - .process(body_as_reader(body), &mut output) + .process(body_as_reader(body)?, &mut output) .change_context(TrustedServerError::Proxy { message: error_context.to_string(), })?; @@ -1515,7 +1531,7 @@ pub async fn handle_first_party_proxy_sign( let req_url = req.uri().to_string(); let payload = if method == Method::POST { - let body_bytes = req.into_body().into_bytes(); + let body_bytes = request_body_bytes(req.into_body(), "first-party sign")?; enforce_max_body_size(&body_bytes, SIGN_MAX_BODY_BYTES, "first-party sign")?; let body = std::str::from_utf8(&body_bytes).change_context(TrustedServerError::InvalidUtf8 { @@ -1630,7 +1646,7 @@ pub async fn handle_first_party_proxy_rebuild( let method = req.method().clone(); let req_url = req.uri().to_string(); let payload = if method == Method::POST { - let body_bytes = req.into_body().into_bytes(); + let body_bytes = request_body_bytes(req.into_body(), "first-party rebuild")?; enforce_max_body_size(&body_bytes, REBUILD_MAX_BODY_BYTES, "first-party rebuild")?; let body = std::str::from_utf8(&body_bytes).change_context(TrustedServerError::InvalidUtf8 { @@ -1999,8 +2015,14 @@ mod tests { } fn response_body_string(response: http::Response) -> String { - String::from_utf8(response.into_body().into_bytes().to_vec()) - .expect("response body should be valid UTF-8") + String::from_utf8( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .to_vec(), + ) + .expect("response body should be valid UTF-8") } fn build_http_response(status: StatusCode, body: EdgeBody) -> Response { @@ -2686,7 +2708,7 @@ mod tests { assert_eq!(ct, "text/html; charset=utf-8"); // Decompress output to verify content was rewritten - let compressed_output = out.into_body().into_bytes(); + let compressed_output = out.into_body().into_bytes().unwrap_or_default(); let mut decoder = GzDecoder::new(&compressed_output[..]); let mut decompressed = String::new(); decoder @@ -2742,7 +2764,7 @@ mod tests { assert_eq!(ct, "text/css; charset=utf-8"); // Decompress output to verify content was rewritten - let compressed_output = out.into_body().into_bytes(); + let compressed_output = out.into_body().into_bytes().unwrap_or_default(); let mut decoder = Decompressor::new(&compressed_output[..], 4096); let mut decompressed = String::new(); decoder diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index db8a17780..710eaa705 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -42,8 +42,16 @@ use crate::streaming_replacer::create_url_replacer; const SUPPORTED_ENCODING_VALUES: [&str; 3] = ["gzip", "deflate", "br"]; const DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); -fn body_as_reader(body: EdgeBody) -> std::io::Cursor { - std::io::Cursor::new(body.into_bytes()) +fn body_as_reader( + body: EdgeBody, +) -> Result, Report> { + let bytes = body.into_bytes().ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "streaming response body reached buffered publisher rewrite pipeline" + .to_string(), + }) + })?; + Ok(std::io::Cursor::new(bytes)) } fn not_found_response() -> Response { @@ -249,7 +257,7 @@ fn process_response_streaming( params.settings, params.integration_registry, )?; - StreamingPipeline::new(config, processor).process(body_as_reader(body), output)?; + StreamingPipeline::new(config, processor).process(body_as_reader(body)?, output)?; } else if is_rsc_flight { // RSC Flight responses are length-prefixed (T rows). A naive string replacement will // corrupt the stream by changing byte lengths without updating the prefixes. @@ -259,7 +267,7 @@ fn process_response_streaming( params.request_host, params.request_scheme, ); - StreamingPipeline::new(config, processor).process(body_as_reader(body), output)?; + StreamingPipeline::new(config, processor).process(body_as_reader(body)?, output)?; } else { let replacer = create_url_replacer( params.origin_host, @@ -267,7 +275,7 @@ fn process_response_streaming( params.request_host, params.request_scheme, ); - StreamingPipeline::new(config, replacer).process(body_as_reader(body), output)?; + StreamingPipeline::new(config, replacer).process(body_as_reader(body)?, output)?; } Ok(()) @@ -692,8 +700,14 @@ mod tests { } fn response_body_string(response: http::Response) -> String { - String::from_utf8(response.into_body().into_bytes().to_vec()) - .expect("response body should be valid UTF-8") + String::from_utf8( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .to_vec(), + ) + .expect("response body should be valid UTF-8") } #[test] @@ -1021,7 +1035,7 @@ mod tests { // Reattach and verify body content *response.body_mut() = body; let (_, final_body) = response.into_parts(); - let output = final_body.into_bytes(); + let output = final_body.into_bytes().unwrap_or_default(); assert_eq!( output, image_bytes, "pass-through should preserve body byte-for-byte" @@ -1467,7 +1481,7 @@ mod tests { "2048" ); let (_, final_body) = response.into_parts(); - let round_trip = final_body.into_bytes(); + let round_trip = final_body.into_bytes().unwrap_or_default(); assert_eq!( round_trip, image_bytes, "pass-through reattach must preserve bytes exactly" diff --git a/crates/trusted-server-core/src/request_signing/endpoints.rs b/crates/trusted-server-core/src/request_signing/endpoints.rs index b4750578f..26b2151b4 100644 --- a/crates/trusted-server-core/src/request_signing/endpoints.rs +++ b/crates/trusted-server-core/src/request_signing/endpoints.rs @@ -24,6 +24,17 @@ fn json_response(status: StatusCode, body: String) -> Response { .expect("should build json response") } +fn request_body_bytes( + body: EdgeBody, + endpoint: &str, +) -> Result> { + body.into_bytes().ok_or_else(|| { + Report::new(TrustedServerError::BadRequest { + message: format!("Streaming request bodies are not supported by {endpoint}"), + }) + }) +} + /// Retrieves and returns the trusted-server discovery document. /// /// This endpoint provides a standardized discovery mechanism following the IAB @@ -100,7 +111,7 @@ pub fn handle_verify_signature( services: &RuntimeServices, req: Request, ) -> Result, Report> { - let body = req.into_body().into_bytes(); + let body = request_body_bytes(req.into_body(), "verify-signature")?; enforce_max_body_size(&body, VERIFY_MAX_BODY_BYTES, "verify-signature")?; let verify_req: VerifySignatureRequest = serde_json::from_slice(&body).change_context(TrustedServerError::Configuration { @@ -243,7 +254,7 @@ pub fn handle_rotate_key( secret_store_id, } = signing_store_ids(settings)?; - let body = req.into_body().into_bytes(); + let body = request_body_bytes(req.into_body(), "rotate-key")?; enforce_max_body_size(&body, ADMIN_MAX_BODY_BYTES, "rotate-key")?; let rotate_req: RotateKeyRequest = if body.is_empty() { RotateKeyRequest { kid: None } @@ -362,7 +373,7 @@ pub fn handle_deactivate_key( secret_store_id, } = signing_store_ids(settings)?; - let body = req.into_body().into_bytes(); + let body = request_body_bytes(req.into_body(), "deactivate-key")?; enforce_max_body_size(&body, ADMIN_MAX_BODY_BYTES, "deactivate-key")?; let deactivate_req: DeactivateKeyRequest = serde_json::from_slice(&body).change_context(TrustedServerError::Configuration { @@ -461,8 +472,14 @@ mod tests { } fn response_body_string(response: http::Response) -> String { - String::from_utf8(response.into_body().into_bytes().to_vec()) - .expect("should decode response body") + String::from_utf8( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .to_vec(), + ) + .expect("should decode response body") } fn assert_json_content_type(response: &http::Response) { diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index c6984a342..f5630eb8b 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1,3 +1,4 @@ +#[cfg(test)] use config::{Config, Environment, File, FileFormat}; use error_stack::{Report, ResultExt}; use regex::Regex; @@ -17,7 +18,9 @@ use crate::host_header::validate_host_header_override_value; use crate::platform::PlatformImageOptimizerRegion; use crate::redacted::Redacted; +#[cfg(test)] pub const ENVIRONMENT_VARIABLE_PREFIX: &str = "TRUSTED_SERVER"; +#[cfg(test)] pub const ENVIRONMENT_VARIABLE_SEPARATOR: &str = "__"; #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] @@ -240,6 +243,7 @@ impl DerefMut for IntegrationSettings { /// registered via API. At startup, each partner's `api_token` is hashed /// (SHA-256) for O(1) auth lookups; the plaintext is never stored at runtime. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] pub struct EcPartner { /// Human-readable partner name. pub name: String, @@ -391,6 +395,7 @@ impl EcPartner { /// Mapped from the `[ec]` TOML section. Controls EC identity generation, /// KV store names, and partner registry. #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] pub struct Ec { /// Publisher passphrase used as HMAC key for EC generation. #[validate(custom(function = Ec::validate_passphrase))] @@ -485,6 +490,7 @@ impl Ec { } #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] pub struct Rewrite { /// List of domains to exclude from rewriting. Supports wildcards (e.g., "*.example.com"). /// URLs from these domains will not be proxied through first-party endpoints. @@ -521,6 +527,7 @@ impl Rewrite { } #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] pub struct Handler { #[validate(length(min = 1), custom(function = validate_path))] pub path: String, @@ -534,6 +541,23 @@ pub struct Handler { } impl Handler { + /// Known handler password placeholders that must not be used in deployments. + pub const PASSWORD_PLACEHOLDERS: &[&str] = &[ + "replace-with-admin-password-32-bytes", + "replace-with-admin-password", + "change-me-admin-password", + ]; + + /// Returns `true` if `password` matches a known placeholder value + /// (case-insensitive). + #[must_use] + pub fn is_placeholder_password(password: &str) -> bool { + let password = password.trim(); + Self::PASSWORD_PLACEHOLDERS + .iter() + .any(|placeholder| placeholder.eq_ignore_ascii_case(password)) + } + fn compiled_regex(&self) -> Result<&Regex, Report> { match self .regex @@ -569,6 +593,7 @@ impl Handler { } #[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct RequestSigning { #[serde(default = "default_request_signing_enabled")] pub enabled: bool, @@ -644,7 +669,7 @@ pub enum OriginQueryPolicy { /// Authentication configuration for an asset origin. #[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum AssetOriginAuth { /// Sign asset origin requests with AWS Signature Version 4 for `S3`. #[serde(rename = "s3_sigv4", alias = "s3_sig_v4")] @@ -755,6 +780,7 @@ impl S3SigV4AuthConfig { /// transformation table lives under top-level [`ImageOptimizerSettings`] so /// multiple routes can share one closed set of profiles. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct AssetImageOptimizerConfig { /// Enables Image Optimizer for this route when the table is present. #[serde( @@ -821,6 +847,7 @@ pub enum UnknownProfilePolicy { /// site-specific profile tables in private configuration overlays when those /// values should not be committed to the public repository. #[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct ImageOptimizerSettings { /// Named profile sets referenced by asset routes. #[serde(default)] @@ -855,6 +882,7 @@ impl ImageOptimizerSettings { /// supported subset: `quality`, `resize-filter`, `format`, `width`, `height`, /// and `crop`. Profile-specific parameters override [`Self::base_params`]. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct ImageOptimizerProfileSet { /// Params applied to every profile before profile-specific params. #[serde(default)] @@ -945,6 +973,7 @@ impl ImageOptimizerProfileSet { /// profile crop is replaced with an aspect-ratio crop derived from the request /// query value. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct ImageOptimizerAspectRatioConfig { /// Allowed aspect ratio query values such as `1-1` or `16-9`. #[serde(default, deserialize_with = "vec_from_seq_or_map")] @@ -1013,6 +1042,7 @@ pub enum MissingCropOffsetMode { /// Offset bucketing caps output variant cardinality. Request values outside /// `0..=100` or values that fail to parse fall back to [`Self::default`]. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct ImageOptimizerCropOffsetsConfig { /// Enable crop offset normalization. #[serde( @@ -1264,6 +1294,7 @@ fn validate_crop_param( /// A path-prefix asset route that proxies matched first-party requests to an alternate origin. #[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct ProxyAssetRoute { /// Path prefix matched against the incoming request path. Must start with `/`. /// @@ -1508,6 +1539,7 @@ impl ProxyAssetRoute { } #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct Proxy { /// Enable TLS certificate verification when proxying to HTTPS origins. /// Defaults to true for secure production use. @@ -1535,10 +1567,11 @@ fn default_certificate_check() -> bool { } fn is_admin_placeholder_password(password: &str) -> bool { - matches!( - password.trim().to_ascii_lowercase().as_str(), - "changeme" | "password" | "admin" - ) + Handler::is_placeholder_password(password) + || matches!( + password.trim().to_ascii_lowercase().as_str(), + "changeme" | "password" | "admin" + ) } impl Default for Proxy { @@ -1577,7 +1610,7 @@ impl Proxy { } if self.allowed_domains.is_empty() { - log::info!( + log::debug!( "proxy.allowed_domains is empty: all redirect destinations are permitted (open mode)" ); } @@ -1643,6 +1676,7 @@ impl Proxy { /// Debug-only features. All flags default to `false` (off in production). #[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct DebugConfig { /// Expose the JA4/TLS fingerprint debug endpoint at `GET /_ts/debug/ja4`. /// @@ -1654,6 +1688,7 @@ pub struct DebugConfig { } #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] pub struct Settings { #[validate(nested)] pub publisher: Publisher, @@ -1684,46 +1719,48 @@ pub struct Settings { } impl Settings { - /// Creates a new [`Settings`] instance from a pre-built TOML string. - /// - /// Use this for the runtime path where the TOML has already been - /// fully resolved (env vars baked in by build.rs). + /// Creates a new [`Settings`] instance from a TOML string. /// /// # Errors /// /// - [`TrustedServerError::Configuration`] if the TOML is invalid or missing required fields pub fn from_toml(toml_str: &str) -> Result> { - let mut settings: Self = + let settings: Self = toml::from_str(toml_str).change_context(TrustedServerError::Configuration { message: "Failed to deserialize TOML configuration".to_string(), })?; - settings.proxy.normalize(); - settings.image_optimizer.normalize(); - settings.consent.validate(); - settings.prepare_runtime()?; - - settings.validate().map_err(|err| { - Report::new(TrustedServerError::Configuration { - message: format!("Configuration validation failed: {err}"), - }) - })?; + Self::finalize_deserialized(settings, "Configuration") + } - settings.validate_admin_coverage()?; - settings.validate_admin_handler_passwords()?; + /// Creates a new [`Settings`] instance from a JSON value. + /// + /// Runtime config-store loading uses this after reconstructing the flattened + /// `app_config` entries into the same typed settings shape. + /// + /// # Errors + /// + /// - [`TrustedServerError::Configuration`] if the JSON value is invalid or missing required fields + pub fn from_json_value(value: JsonValue) -> Result> { + let settings: Self = + serde_json::from_value(value).change_context(TrustedServerError::Configuration { + message: "Failed to deserialize JSON configuration".to_string(), + })?; - Ok(settings) + Self::finalize_deserialized(settings, "Configuration") } - /// Creates a new [`Settings`] instance from a TOML string, applying - /// environment variable overrides using the `TRUSTED_SERVER__` prefix. + /// Creates a new [`Settings`] instance from a TOML string with legacy + /// test-only `TRUSTED_SERVER__` environment variable overrides. /// - /// Used by build.rs to merge the base config with env vars before - /// baking the result into the binary. + /// Production loading does not support app-config environment overlays; this + /// helper remains available to existing tests that exercise legacy parsing + /// behavior. /// /// # Errors /// /// - [`TrustedServerError::Configuration`] if the TOML is invalid or missing required fields + #[cfg(test)] pub fn from_toml_and_env(toml_str: &str) -> Result> { let environment = Environment::default() .prefix(ENVIRONMENT_VARIABLE_PREFIX) @@ -1737,25 +1774,33 @@ impl Settings { .change_context(TrustedServerError::Configuration { message: "Failed to build configuration".to_string(), })?; - let mut settings: Self = + let settings: Self = config .try_deserialize() .change_context(TrustedServerError::Configuration { message: "Failed to deserialize configuration".to_string(), })?; + Self::finalize_deserialized(settings, "Build-time configuration") + } + + fn finalize_deserialized( + mut settings: Self, + validation_label: &str, + ) -> Result> { settings.integrations.normalize(); settings.proxy.normalize(); settings.image_optimizer.normalize(); settings.consent.validate(); + settings.prepare_runtime()?; + settings.validate().map_err(|err| { Report::new(TrustedServerError::Configuration { - message: format!("Build-time configuration validation failed: {err}"), + message: format!("{validation_label} validation failed: {err}"), }) })?; - settings.prepare_runtime()?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; @@ -1812,6 +1857,11 @@ impl Settings { insecure_fields.push(format!("ec.partners[{}].api_token", partner.source_domain)); } } + for handler in &self.handlers { + if Handler::is_placeholder_password(handler.password.expose()) { + insecure_fields.push(format!("handlers[{}].password", handler.path)); + } + } if insecure_fields.is_empty() { return Ok(()); @@ -1874,7 +1924,7 @@ impl Settings { /// Known admin endpoint paths that must be covered by a handler. /// - /// [`from_toml_and_env`](Self::from_toml_and_env) rejects configurations + /// [`from_toml`](Self::from_toml) rejects configurations /// where any of these paths lack a matching handler, ensuring admin /// endpoints are always protected by authentication. /// Update [`ADMIN_ENDPOINTS`](Self::ADMIN_ENDPOINTS) when adding new @@ -1884,8 +1934,8 @@ impl Settings { /// Returns admin endpoint paths that no configured handler covers. /// - /// Called by [`from_toml_and_env`](Self::from_toml_and_env) at build time - /// to enforce that every admin endpoint has a handler. An empty return + /// Called during settings finalization to enforce that every admin endpoint + /// has a handler. An empty return /// value means all admin endpoints are properly covered. /// /// # Errors @@ -2597,6 +2647,32 @@ origin_host_header_overide = "www.example.com""#, ); } + #[test] + fn is_placeholder_handler_password_rejects_known_template_value() { + assert!( + Handler::is_placeholder_password("replace-with-admin-password-32-bytes"), + "init-template handler password should be rejected" + ); + } + + #[test] + fn reject_placeholder_secrets_includes_handler_passwords() { + let mut settings = + Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); + settings.publisher.proxy_secret = Redacted::new("unit-test-proxy-secret".to_owned()); + settings.ec.passphrase = Redacted::new("test-secret-key-32-bytes-minimum".to_owned()); + settings.handlers[0].password = + Redacted::new("replace-with-admin-password-32-bytes".to_owned()); + + let err = settings + .reject_placeholder_secrets() + .expect_err("should reject placeholder handler password"); + assert!( + format!("{err:?}").contains("handlers"), + "error should mention handler password field" + ); + } + #[test] fn test_settings_empty_toml() { let toml_str = ""; @@ -3096,7 +3172,10 @@ origin_host_header_overide = "www.example.com""#, let toml_str = crate_test_settings_str() + "\nhello = 1"; let settings = Settings::from_toml(&toml_str); - assert!(settings.is_ok(), "Extra fields should be ignored"); + assert!( + settings.is_err(), + "unknown top-level fields should be rejected" + ); } #[test] diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index 5207a7e66..efcfbcd45 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -1,202 +1,145 @@ -use core::str; +use std::collections::BTreeMap; + +use edgezero_core::env_config::EnvConfig; use error_stack::{Report, ResultExt}; -use validator::Validate; +use crate::config_payload::{settings_from_config_entries, CONFIG_HASH_KEY, CONFIG_KEYS_KEY}; use crate::error::TrustedServerError; +use crate::platform::{PlatformConfigStore, RuntimeServices, StoreName}; use crate::settings::Settings; -pub use crate::auction_config_types::AuctionConfig; - -const SETTINGS_DATA: &[u8] = include_bytes!("../../../target/trusted-server-out.toml"); +const DEFAULT_CONFIG_STORE_ID: &str = "app_config"; -/// Creates a new [`Settings`] instance from the embedded configuration file. +/// Loads [`Settings`] from the default `EdgeZero` `app_config` config store. /// -/// Loads the pre-built TOML that was generated by `build.rs` (base config -/// merged with any `TRUSTED_SERVER__` environment variable overrides at -/// build time). Environment variables are **not** read at runtime. +/// The store name is resolved from `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME` +/// and falls back to the logical id `app_config`. /// /// # Errors /// -/// - [`TrustedServerError::InvalidUtf8`] if the embedded TOML file contains invalid UTF-8 -/// - [`TrustedServerError::Configuration`] if the configuration is invalid or missing required fields -pub fn get_settings() -> Result> { - let toml_bytes = SETTINGS_DATA; - let toml_str = str::from_utf8(toml_bytes).change_context(TrustedServerError::InvalidUtf8 { - message: "embedded trusted-server.toml file".to_string(), - })?; - - let settings = Settings::from_toml(toml_str)?; - - // Validate the settings - settings - .validate() - .change_context(TrustedServerError::Configuration { - message: "Failed to validate configuration".to_string(), +/// Returns [`TrustedServerError::Configuration`] when metadata or any flattened +/// config entry is missing, cannot be read, fails hash verification, or fails +/// Trusted Server settings validation. +pub fn get_settings_from_services( + services: &RuntimeServices, +) -> Result> { + let env_config = EnvConfig::from_env(); + let store_name = StoreName::from(env_config.store_name("config", DEFAULT_CONFIG_STORE_ID)); + get_settings_from_config_store(services.config_store(), &store_name) +} + +/// Loads [`Settings`] from a platform config store. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when metadata or any flattened +/// config entry is missing, cannot be read, fails hash verification, or fails +/// Trusted Server settings validation. +pub fn get_settings_from_config_store( + config_store: &dyn PlatformConfigStore, + store_name: &StoreName, +) -> Result> { + let mut entries = BTreeMap::new(); + + let keys_raw = read_config_entry(config_store, store_name, CONFIG_KEYS_KEY)?; + let keys: Vec = + serde_json::from_str(&keys_raw).change_context(TrustedServerError::Configuration { + message: format!("`{CONFIG_KEYS_KEY}` metadata is not a JSON string array"), })?; + entries.insert(CONFIG_KEYS_KEY.to_string(), keys_raw); - if !settings.proxy.certificate_check { - log::warn!( - "INSECURE: proxy.certificate_check is disabled — TLS certificates will NOT be verified" - ); + let hash = read_config_entry(config_store, store_name, CONFIG_HASH_KEY)?; + entries.insert(CONFIG_HASH_KEY.to_string(), hash); + + for key in keys { + let value = read_config_entry(config_store, store_name, &key)?; + entries.insert(key, value); } - settings.reject_placeholder_secrets()?; + settings_from_config_entries(&entries) +} - Ok(settings) +fn read_config_entry( + config_store: &dyn PlatformConfigStore, + store_name: &StoreName, + key: &str, +) -> Result> { + config_store + .get(store_name, key) + .change_context(TrustedServerError::Configuration { + message: format!( + "failed to read Trusted Server app config key `{key}` from config store `{store_name}`" + ), + }) } #[cfg(test)] mod tests { - use crate::error::TrustedServerError; + use super::*; + use crate::config_payload::build_config_payload; + use crate::platform::PlatformError; use crate::settings::Settings; use crate::test_support::tests::crate_test_settings_str; - /// Builds a TOML string with the given secret values swapped in. - /// - /// # Panics - /// - /// Panics if the replacement patterns no longer match the test TOML, - /// which would cause the substitution to silently no-op. - fn toml_with_secrets(passphrase: &str, proxy_secret: &str) -> String { - let original = crate_test_settings_str(); - let after_passphrase = original.replace( - r#"passphrase = "test-secret-key-32-bytes-minimum""#, - &format!(r#"passphrase = "{passphrase}""#), - ); - assert_ne!( - after_passphrase, original, - "should have replaced passphrase value" - ); - let result = after_passphrase.replace( - r#"proxy_secret = "unit-test-proxy-secret""#, - &format!(r#"proxy_secret = "{proxy_secret}""#), - ); - assert_ne!( - result, after_passphrase, - "should have replaced proxy_secret value" - ); - result - } - - fn toml_with_partner_api_token(api_token: &str) -> String { - format!( - r#"{} - - [[ec.partners]] - name = "Unit Test Partner" - source_domain = "unit-test-partner.example.com" - api_token = "{}" - "#, - crate_test_settings_str(), - api_token - ) + struct MemoryConfigStore { + entries: BTreeMap, } - #[test] - fn rejects_placeholder_passphrase() { - let toml = toml_with_secrets("trusted-server-placeholder-secret", "real-proxy-secret"); - let settings = Settings::from_toml(&toml).expect("should parse TOML"); - let err = settings - .reject_placeholder_secrets() - .expect_err("should reject placeholder secret_key"); - let root = err.current_context(); - assert!( - matches!(root, TrustedServerError::InsecureDefault { field } if field.contains("ec.passphrase")), - "error should mention ec.passphrase, got: {root}" - ); - } + impl PlatformConfigStore for MemoryConfigStore { + fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + self.entries.get(key).cloned().ok_or_else(|| { + Report::new(PlatformError::ConfigStore).attach(format!("missing key `{key}`")) + }) + } - #[test] - fn rejects_placeholder_proxy_secret() { - let toml = toml_with_secrets( - "production-secret-key-32-bytes-min", - "change-me-proxy-secret", - ); - let settings = Settings::from_toml(&toml).expect("should parse TOML"); - let err = settings - .reject_placeholder_secrets() - .expect_err("should reject placeholder proxy_secret"); - let root = err.current_context(); - assert!( - matches!(root, TrustedServerError::InsecureDefault { field } if field.contains("publisher.proxy_secret")), - "error should mention publisher.proxy_secret, got: {root}" - ); - } + fn put( + &self, + _store_id: &crate::platform::StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Ok(()) + } - #[test] - fn rejects_both_placeholders_in_single_error() { - let toml = toml_with_secrets( - "trusted-server-placeholder-secret", - "change-me-proxy-secret", - ); - let settings = Settings::from_toml(&toml).expect("should parse TOML"); - let err = settings - .reject_placeholder_secrets() - .expect_err("should reject both placeholder secrets"); - let root = err.current_context(); - match root { - TrustedServerError::InsecureDefault { field } => { - assert!( - field.contains("ec.passphrase"), - "error should mention ec.passphrase, got: {field}" - ); - assert!( - field.contains("publisher.proxy_secret"), - "error should mention publisher.proxy_secret, got: {field}" - ); - } - other => panic!("expected InsecureDefault, got: {other}"), + fn delete( + &self, + _store_id: &crate::platform::StoreId, + _key: &str, + ) -> Result<(), Report> { + Ok(()) } } #[test] - fn accepts_non_placeholder_secrets() { - let toml = toml_with_secrets( - "production-secret-key-32-bytes-min", - "production-proxy-secret", + fn loads_settings_from_flattened_config_store_entries() { + let settings = + Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); + let payload = build_config_payload(&settings).expect("should build payload"); + let store = MemoryConfigStore { + entries: payload.entries, + }; + + let loaded = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect("should load settings"); + + assert_eq!( + loaded.publisher.domain, settings.publisher.domain, + "should load publisher domain" ); - let settings = Settings::from_toml(&toml).expect("should parse TOML"); - settings - .reject_placeholder_secrets() - .expect("non-placeholder secrets should pass validation"); } #[test] - fn rejects_placeholder_partner_api_token() { - let toml = toml_with_partner_api_token("sharedid-internal-token-32-bytes"); - let settings = Settings::from_toml(&toml).expect("should parse TOML"); - let err = settings - .reject_placeholder_secrets() - .expect_err("should reject placeholder partner api_token"); - let root = err.current_context(); - assert!( - matches!(root, TrustedServerError::InsecureDefault { field } if field.contains("ec.partners[unit-test-partner.example.com].api_token")), - "error should mention partner api_token, got: {root}" - ); - } + fn fails_when_metadata_is_missing() { + let store = MemoryConfigStore { + entries: BTreeMap::new(), + }; - #[test] - fn accepts_non_placeholder_partner_api_token() { - let toml = toml_with_partner_api_token("production-partner-token-32-bytes-min"); - let settings = Settings::from_toml(&toml).expect("should parse TOML"); - settings - .reject_placeholder_secrets() - .expect("non-placeholder partner api_token should pass validation"); - } + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("should fail when metadata is missing"); - /// Smoke-test the full `get_settings()` pipeline (embedded bytes → UTF-8 → - /// parse → validate → placeholder check). The build-time TOML ships with - /// placeholder secrets, so the expected outcome is an [`InsecureDefault`] - /// error — but reaching that error proves every earlier stage succeeded. - #[test] - fn get_settings_rejects_embedded_placeholder_secrets() { - let err = super::get_settings().expect_err("should reject embedded placeholder secrets"); assert!( - matches!( - err.current_context(), - TrustedServerError::InsecureDefault { .. } - ), - "should fail with InsecureDefault, got: {err}" + err.to_string().contains(CONFIG_KEYS_KEY), + "error should mention missing keys metadata" ); } } diff --git a/docs/superpowers/plans/2026-06-16-edgezero-based-ts-cli-implementation-plan.md b/docs/superpowers/plans/2026-06-16-edgezero-based-ts-cli-implementation-plan.md new file mode 100644 index 000000000..685afc825 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-edgezero-based-ts-cli-implementation-plan.md @@ -0,0 +1,292 @@ +# EdgeZero-Based Trusted Server CLI Implementation Plan + +**Date:** 2026-06-16 +**Status:** Draft implementation plan +**Spec:** `docs/superpowers/specs/2026-06-16-edgezero-based-ts-cli-design.md` + +## Decisions locked for this plan + +- Start by moving this repository to the target EdgeZero PR #269 branch/rev; do + not build the TS CLI against the older pinned EdgeZero rev. +- Keep platform lifecycle and platform writes inside EdgeZero. Trusted Server may + transform app config, but it must not implement Fastly/Wrangler/Spin writes. +- For v1, literal secrets that still live in `Settings` are allowed to be written + as flattened config-store entries. Secret-store write primitives are a future + EdgeZero coordination item. +- Flattened keys escape path segments before joining: `\` -> `\\`, `.` -> `\.`. +- CLI validation must reject unknown fields throughout the typed settings schema, + except for intentional dynamic map fields. +- Delegate commands support passthrough args after `--` and forward them + verbatim to EdgeZero. +- `ts config init` may create a placeholder-filled config; `ts config validate` + and `ts config push` must fail until required placeholders/secrets are + replaced. + +## Definition of done + +- `ts` binary exists and implements the spec command surface. +- `ts config init`, `validate`, and `push` behave exactly as specified. +- Lifecycle commands are thin EdgeZero delegates and are covered by fake-delegate + tests. +- Flatten/hash output is deterministic, escaped, and covered by known-vector + tests. +- `trusted-server.toml` is operator-owned, ignored, and no longer compiled into + runtime artifacts once the adjacent runtime-config-store migration lands. +- No Trusted Server code performs direct platform provisioning or config-store + writes. +- Repository docs and verification commands are updated. + +## Stage 0 — EdgeZero PR #269 baseline + +1. Update root `Cargo.toml` EdgeZero git dependencies from the current pinned rev + to the target PR #269 branch/rev. +2. Add any new EdgeZero crates needed by the CLI, likely including the library + crate that exposes CLI command handlers and config-push primitives. +3. Run `cargo update` for the EdgeZero crates and inspect the resulting + `Cargo.lock` diff. +4. Audit the target EdgeZero APIs for: + - auth login/status/logout delegation; + - provision delegation; + - serve/build/deploy delegation; + - manifest loading and adapter resolution; + - logical config-store resolution; + - caller-supplied flattened config-entry push; + - `--local`, `--dry-run`, and `--runtime-config` support; + - passthrough-arg support. +5. If a required EdgeZero API is missing, add it upstream on the EdgeZero branch + first or pause. Do not add TS-owned platform write logic as a workaround. +6. Run an initial compile check after the bump to surface dependency/API fallout. + +## Stage 1 — CLI crate and host-target test strategy + +1. Add `crates/trusted-server-cli` with binary name `ts`. +2. Keep the implementation internal/testable; do not commit to a public reusable + `trusted-server-cli` library API. +3. Decide and implement the workspace strategy before adding substantial code: + - preferred: keep the crate as a workspace member, but target-gate the real + CLI implementation to host targets and provide a tiny wasm-compatible stub + so existing `cargo test --workspace` wasm gates keep working; + - add explicit host commands for real CLI tests, for example + `cargo test --package trusted-server-cli --target `; + - document this in `CLAUDE.md` and/or `.cargo/config.toml` aliases. +4. Add dependencies only as needed: `clap`, `error-stack`, `derive_more`, + `serde`, `serde_json`, `sha2`, `hex`, `toml`, `trusted-server-core`, and the + EdgeZero CLI/delegate crate from Stage 0. Add `tempfile` as a justified + dev-dependency for filesystem command tests if needed. +5. Implement internal modules: + - `args` — clap command tree; + - `run` — testable command dispatcher with injectable stdout/stderr writers; + - `edgezero_delegate` — production EdgeZero wrapper plus fake test delegate; + - `config_command` — init/validate/push orchestration. +6. Avoid `println!`/`eprintln!`; write to injected `Write` handles so clippy's + print lints remain clean. +7. Add parser tests for every command shape, including passthrough args after + `--`. + +## Stage 2 — EdgeZero manifest and config template files + +1. Add `edgezero.toml` using the target EdgeZero PR #269 manifest schema: + - `[app] name = "trusted-server"`; + - config store logical ID `app_config`; + - secrets store logical ID `secrets`; + - adapter command metadata for the supported initial adapter(s). +2. Create `trusted-server.example.toml` from the current tracked config, keeping + only example/placeholder values and example domains. +3. Keep `trusted-server.example.toml` parseable as `Settings`, even though it is + expected to fail placeholder-secret validation until an operator edits it. +4. Do not remove tracked `trusted-server.toml` until Stage 8 removes build-time + embedding; otherwise current workspace builds will break. + +## Stage 3 — Strict `Settings` schema validation + +1. Audit every struct reachable from `Settings` in + `crates/trusted-server-core/src/settings.rs` and related config modules. +2. Add `#[serde(deny_unknown_fields)]` to concrete non-map config structs. +3. Do not add `deny_unknown_fields` to intentional dynamic map wrappers or + structs using `#[serde(flatten)]` as extension points. +4. Keep explicit dynamic maps for integrations, response headers, image profiles, + and similar keyed config. +5. Add tests for: + - unknown top-level fields; + - unknown nested fields; + - dynamic map keys still accepted; + - current example config still parses before placeholder rejection. +6. Verify both `Settings::from_toml` and any remaining build/runtime parsing path + still behave intentionally. + +## Stage 4 — Deterministic config payload module + +1. Put shared transformation logic in `trusted-server-core`, not only in the CLI, + so the future runtime-config-store loader can reuse the same escaping and hash + semantics. +2. Add a small public core module, for example `config_payload`, with documented + APIs such as: + - `escape_key_segment`; + - `split_escaped_key` / inverse unescape helper; + - `flatten_settings_value`; + - `build_config_payload(&Settings)`. +3. Load and validate config for CLI use with: + - UTF-8 file read; + - TOML parse; + - `Settings::from_toml` with no `TRUSTED_SERVER__` env overlay; + - `Settings::reject_placeholder_secrets`. +4. Convert validated settings to `serde_json::Value` and flatten into + `BTreeMap`. +5. Flattening rules: + - object keys are escaped path segments; + - object entries recurse; + - leaf values are stored as canonical JSON text so reconstruction is lossless; + - strings are JSON-quoted strings; + - booleans/numbers use JSON scalar text; + - arrays use canonical minified JSON with recursively sorted object keys; + - nulls are skipped; + - final settings keys beginning with `ts-config-` are rejected. +6. Compute metadata: + - `ts-config-keys` = minified sorted JSON array of settings-only keys; + - `ts-config-hash` = `sha256:` over the canonical settings-only entry + map JSON bytes; + - hash excludes metadata entries. +7. Add known-vector tests covering: + - nested flattening; + - `.` and `\` key escaping; + - arrays and canonical object ordering inside arrays; + - null skipping; + - lexicographic ordering by escaped key; + - metadata exclusion from hash; + - stable hash for reordered TOML input; + - dynamic map stability. + +## Stage 5 — `ts config init` and `ts config validate` + +1. Implement `ts config init [--config ] [--force]`: + - use the source-controlled example template as the copy source, embedded at + build time or otherwise available independent of an operator-owned config; + - create parent directories; + - refuse overwrite without `--force`; + - do not read `edgezero.toml`; + - do not contact EdgeZero/platforms; + - print only `Initialized config at ` on success. +2. Implement `ts config validate [--config ] [--json]`: + - run the Stage 4 loader/payload pipeline; + - produce human output on success; + - produce JSON success/failure shape exactly as specified; + - on `--json` failure, write JSON to stdout and exit non-zero; + - on human failure, write errors and hints to stderr; + - never print config values or secrets. +3. Add command tests for: + - default/custom config paths; + - missing file hint; + - malformed TOML; + - unknown fields; + - semantic validation errors; + - placeholder rejection; + - JSON success/failure validity; + - `config init` output failing validation until placeholders are replaced. + +## Stage 6 — EdgeZero lifecycle delegation + +1. Implement the production `EdgeZeroDelegate` wrapper around the Stage 0 + EdgeZero APIs. +2. Support: + - `ts auth login/status/logout --adapter [-- ...]`; + - `ts provision --adapter [-- ...]`; + - `ts serve --adapter [-- ...]`; + - `ts build --adapter [-- ...]`; + - `ts deploy --adapter [-- ...]`. +3. Forward adapter and passthrough args verbatim. +4. Do not read, validate, flatten, or push `trusted-server.toml` in these + lifecycle commands unless EdgeZero itself requires manifest context. +5. Surface EdgeZero adapter/manifest errors without converting them into + TS-owned platform logic. +6. Add fake-delegate tests proving each command calls the expected EdgeZero + method with the selected adapter and passthrough args. + +## Stage 7 — `ts config push` + +1. Implement `ts config push` after Stage 4 payload generation and Stage 6 + EdgeZero delegation are in place. +2. Parse: + - required `--adapter`; + - `--config`, default `trusted-server.toml`; + - `--manifest`, default `edgezero.toml`; + - `--store`, default `app_config`; + - `--local`; + - `--dry-run`; + - `--runtime-config`. +3. Run the exact same validation/flatten/hash path as `config validate`. +4. Build the push entry map with settings entries plus `ts-config-keys` and + `ts-config-hash`. +5. Call EdgeZero's caller-supplied-entry config push API with adapter, manifest, + logical store, local/dry-run/runtime-config options, and entries. +6. Ensure `--dry-run` does not mutate local or remote adapter state. TS output + should show key names, entry count, and hash, never full values. +7. Add fake-push tests for: + - validation happens before push; + - metadata entries are included; + - default store is `app_config`; + - all flags/options are forwarded; + - dry-run reaches the delegate as dry-run; + - secret-store writes are never requested; + - no full config values appear in output. + +## Stage 8 — Runtime/file-ownership alignment + +This spec does not define runtime loading details, but the repository is not +fully compliant with the file ownership model until build-time config embedding +is removed. + +1. Land or implement the runtime-config-store spec that reads flattened + `app_config` entries at runtime, uses the same escaping/hash helpers, and + fails closed when runtime config is invalid. +2. Remove the current build-time `trusted-server.toml` embedding path: + - stop `build.rs` from reading `../../trusted-server.toml`; + - remove or replace `settings_data.rs` embedded bytes usage; + - remove `TRUSTED_SERVER__` build-time app-settings env overlay. +3. Move the source-controlled app config to `trusted-server.example.toml` only. +4. Add `trusted-server.toml` to `.gitignore` and remove it from git tracking. +5. Keep local dev/test fixtures explicit so tests do not depend on an + operator-owned root `trusted-server.toml`. + +## Stage 9 — Documentation and verification + +1. Update operator docs with the minimal workflow: + + ```bash + ts config init + ts config validate + ts auth login --adapter fastly + ts provision --adapter fastly + ts config push --adapter fastly + ts serve --adapter fastly + ts deploy --adapter fastly + ``` + +2. Update `CLAUDE.md` for: + - the new CLI crate; + - host-target CLI test command; + - `edgezero.toml` and `trusted-server.example.toml` ownership; + - removal of `trusted-server.toml` as a tracked/build-time file. +3. Update `CONTRIBUTING.md` if developer workflow or verification commands + change. +4. Run verification: + - `cargo fmt --all -- --check`; + - `cargo clippy --workspace --all-targets --all-features -- -D warnings`; + - `cargo test --workspace`; + - host-target CLI tests, e.g. `cargo test --package trusted-server-cli --target `; + - `cargo build --package trusted-server-cli --target `; + - `cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1`; + - JS/docs checks only if those areas are touched. + +## Risks and watch points + +- The exact EdgeZero PR #269 API shape may differ from the spec assumptions. + Resolve that upstream before adding TS-owned workarounds. +- Host-only CLI testing must not break existing wasm-default workspace gates. +- `deny_unknown_fields` can uncover previously accepted config typos; update + tests and examples deliberately. +- Arrays stored as JSON values need canonical serialization to keep hashes + stable. +- Runtime reconstruction of flattened entries is owned by the runtime-config + spec; share escaping/hash helpers now to avoid divergent behavior later. +- Literal secrets in config-store entries are accepted for v1 but must never be + logged or printed. diff --git a/docs/superpowers/plans/2026-06-16-trusted-server-cli-respec-context.md b/docs/superpowers/plans/2026-06-16-trusted-server-cli-respec-context.md new file mode 100644 index 000000000..4c531df76 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-trusted-server-cli-respec-context.md @@ -0,0 +1,235 @@ +# Trusted Server CLI Respec Context + +**Date:** 2026-06-16 +**Status:** Research artifact, not a spec +**Purpose:** Capture context from the earlier Trusted Server CLI implementation, the existing Trusted Server CLI draft spec, and EdgeZero PR #269 so the new Trusted Server CLI specs can be cut cleanly. + +## Sources reviewed + +- Local branch `feature/ts-cli` + - `crates/trusted-server-cli/` + - `docs/guide/cli.md` + - `docs/guide/fastly-provisioning.md` + - `docs/superpowers/specs/2026-04-20-config-store-runtime-config-design.md` +- Local branch `spec/ts-cli` + - `docs/superpowers/specs/2026-04-23-trusted-server-cli-design.md` +- EdgeZero PR #269 at head `2eeccc9748daba92b9adf6afe4df105e79269ae9` + - PR summary and file list via GitHub API + - `docs/superpowers/specs/2026-05-19-cli-extensions-design.md` + - `docs/superpowers/specs/2026-06-01-spin-kv-config.md` + - representative implementation files under `crates/edgezero-cli/`, `crates/edgezero-adapter/`, and `crates/edgezero-core/` +- Current Trusted Server branch `feature/ts-cli-next` + - currently equal to `main`; no `trusted-server-cli` crate present + - still uses build-time embedded config via `settings_data.rs` / `build.rs` + - already has EdgeZero-derived core HTTP/body/platform abstractions and Fastly `PlatformConfigStore` / `PlatformSecretStore` / KV plumbing + +## What the old Trusted Server CLI actually implemented + +### Crate and binary + +- Added `crates/trusted-server-cli`. +- Binary name: `ts`. +- `main.rs` was a thin wrapper over `trusted_server_cli::run()`. +- Used `clap`, `error-stack`, `dialoguer`, `keyring`, `reqwest::blocking`, `chromiumoxide`, `scraper`, and `tokio` for host-only CLI behavior. +- Added host-target Cargo aliases because the workspace default target is `wasm32-wasip1`. + +### Command surface + +```text +ts config init +ts config validate [--json] +ts audit +ts dev [-a fastly] +ts auth fastly login|status|logout +ts provision fastly plan|apply +``` + +### Config model + +- `trusted-server.toml` remained the authoring file. +- `trusted-server.example.toml` became the tracked template; `trusted-server.toml` was gitignored. +- The CLI split `[providers]` out of the source TOML before canonicalizing runtime app config. +- Runtime app config was canonical TOML stored under fixed key `ts-config` in fixed runtime alias `ts_config_store`. +- Provider config did not affect the canonical config hash. + +### Runtime config-store change + +`feature/ts-cli` also implemented the runtime config architecture: + +- deleted `settings_data.rs` and made `build.rs` a no-op; +- added `trusted_server_core::runtime_config` for strict parse, validation, canonical TOML, and hash; +- changed Fastly startup to read `ts_config_store` / `ts-config` via `RuntimeServices.config_store()` before routing; +- made `/health` depend on successful runtime config loading. + +Current `feature/ts-cli-next` does **not** have this runtime config-store behavior yet; it still embeds config at build time. + +### Fastly provisioning model + +The old CLI did direct Fastly API orchestration, not native CLI delegation: + +- credential resolution: `FASTLY_API_KEY` first, then OS keyring via `ts auth fastly login`; +- `plan` inspected service versions, active/latest versions, stores, items, and resource links; +- `apply` created or reused stores, wrote config items/secrets, created or updated resource links, cloned locked service versions if needed, and activated when bindings changed; +- app config store was always managed; +- request signing resources were managed when enabled; +- consent KV store was managed when configured; +- apply was non-destructive, idempotent, and fail-fast; +- JSON output included completed actions and failed action on partial failure. + +### Audit model + +`ts audit` was Trusted-Server-specific and not covered by EdgeZero: + +- launched Chrome/Chromium via `chromiumoxide`; +- collected script tags and resource timing entries; +- detected integrations by URL/inline evidence; +- wrote `js-assets.toml` and a draft `trusted-server.toml`; +- refused overwrites unless `--force`. + +## Existing Trusted Server draft spec vs implementation + +`spec/ts-cli` contains `2026-04-23-trusted-server-cli-design.md`. It matches the old implementation at a high level, but the implementation moved beyond it in several ways: + +- Spec said `--service-id` was required for provisioning; implementation resolved service ID from CLI, `[providers.fastly].service_id`, then `fastly.toml`. +- Spec kept Fastly resource identity as an open question; implementation chose fixed runtime aliases plus configurable underlying resource names. +- Spec did not fully separate runtime config-store architecture into its own CLI-dependent implementation details; implementation did. +- Spec did not deeply specify request-signing bootstrap/runtime API token behavior; implementation did. +- Spec did not anticipate EdgeZero PR #269's manifest/app-config split or adapter registry design. + +## EdgeZero PR #269 patterns worth borrowing + +### CLI as a reusable library + +EdgeZero turned `edgezero-cli` into a library-first crate: + +- `pub mod args` exposes `*Args` structs; +- root-level `run_*` functions implement built-ins; +- default binary is a thin dispatcher; +- downstream app CLIs can reuse built-ins and wire typed config functions. + +Trusted Server can borrow this if we want publisher-specific or deployment-specific wrappers later. If not, we can still borrow the thin-main / testable-runner shape. + +### Adapter-owned dispatch + +EdgeZero centralizes adapter discovery in `edgezero-adapter::registry::Adapter`: + +- CLI dispatches `build`, `deploy`, `serve`, `auth`, `provision`, and `config push` to registered adapters; +- adapter crates own platform details; +- CLI avoids hard-coded adapter-specific branches where possible; +- adapter trait also owns validation hooks for platform-specific manifest/config constraints. + +Trusted Server currently has only Fastly in-tree, but the EdgeZero migration plan expects Axum/Cloudflare later. We should decide whether the new `ts` CLI starts with a small Trusted Server adapter trait now, or keeps Fastly-specific command trees and extracts a trait when the second adapter lands. + +### Manifest + typed app config split + +EdgeZero uses: + +- `edgezero.toml`: portable app/trigger/store/adapters manifest; +- `.toml`: typed per-service app config; +- `EDGEZERO__STORES______NAME`: runtime platform-name overlay. + +The earlier Trusted Server CLI used one `trusted-server.toml` containing app config plus `[providers]` deployment config, then stripped `[providers]` before canonicalization. + +This is the biggest respec decision: keep the single Trusted Server file for operator simplicity, or split runtime app config from provider/platform manifest like EdgeZero. + +### Store model + +EdgeZero moved to logical store IDs: + +```toml +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +``` + +Rules: + +- logical ids are portable; +- platform names resolve from env overlay, defaulting to the logical id; +- single-store adapters reject multiple ids for unsupported store kinds; +- legacy schema is a hard load error; +- store ids are validated for portability and env-var safety. + +Trusted Server's old implementation used fixed runtime aliases (`ts_config_store`, `jwks_store`, `signing_keys`, `api-keys`) and configurable Fastly underlying resource names under `[providers.fastly]`. A respec should either retain that TS-specific alias model or translate it into logical store declarations. + +### `config validate` and `config push` + +EdgeZero separates: + +- `config validate`: local app config + manifest validation; +- `provision`: create/bind platform resources; +- `config push`: push app config entries to config store. + +Old Trusted Server combined config upload into `provision apply`. Splitting `config push` out would align with EdgeZero and reduce provisioning blast radius, but may add one more operator command. + +### Spin KV follow-up + +The original EdgeZero CLI spec treated Spin config as flat variables. The later `2026-06-01-spin-kv-config` plan changes Spin config to KV-backed multi-store config with local/cloud push paths. For Trusted Server, this matters mainly as a warning: avoid baking in a config-store model that assumes all adapters look like Fastly Config Store. Future adapters may need backend-specific config push behavior. + +## Suggested new spec set + +Instead of one giant CLI spec, cut smaller specs with explicit dependencies: + +1. **Trusted Server CLI v1 substrate and UX** + - crate/binary, command tree, output, exit codes, host-target build, thin main/testable run functions; + - decide whether `ts` is library-extensible like EdgeZero. + +2. **Runtime application config store** + - remove build-time embed; + - canonical TOML + hash; + - production config store key/alias contract; + - local development projection; + - health/fail-closed behavior. + +3. **Trusted Server config and provider manifest model** + - decide monolithic `trusted-server.toml` + `[providers]` vs split app config + platform manifest; + - define store logical IDs, fixed aliases, provider resource names/IDs, and env overlays. + +4. **Fastly auth and provisioning** + - credential source policy; + - direct Fastly API vs native CLI delegation; + - plan/apply semantics; + - request-signing bootstrap; + - service-version cloning/activation; + - JSON schemas. + +5. **Config push / deploy config workflow** + - if split from provisioning: `ts config push --adapter fastly`; + - if not split: define why `provision apply` owns config upload; + - dry-run and idempotency behavior. + +6. **Local development / serve** + - `ts dev` vs `ts serve` naming; + - Fastly Viceroy local config-store projection; + - passthrough args and `--skip-build` behavior; + - future Axum adapter path. + +7. **Audit and config bootstrap** + - browser collector scope; + - integration detection; + - generated files; + - limits and future authenticated audit. + +## High-priority decisions before writing the new spec + +1. **File model:** keep one `trusted-server.toml` with `[providers]`, or move toward EdgeZero's manifest + app-config split? +2. **Store identity:** keep fixed runtime aliases plus provider resource names, or introduce logical store ids with platform-name env overlays? +3. **Provision vs push:** should config upload remain in `ts provision fastly apply`, or become `ts config push --adapter fastly`? +4. **Auth strategy:** keep OS keyring + direct Fastly API, or delegate to native Fastly CLI profiles like EdgeZero? +5. **Extensibility:** does `trusted-server-cli` need to be a reusable library for downstream/custom CLIs? +6. **Naming:** keep `ts dev`, rename to `ts serve`, or support both with one canonical name? +7. **Runtime health:** should `/health` require valid runtime config (old CLI branch) or stay config-independent (current branch)? +8. **Scope of v1:** runtime config-store migration and Fastly provisioning were coupled in `feature/ts-cli`; should they remain coupled or ship as separate specs/PRs? + +## Working recommendation + +For the next spec pass, start from Trusted Server's operator workflow, not EdgeZero's framework workflow: + +- keep `ts` as the product CLI; +- preserve `trusted-server.toml` as the operator-facing app config unless we deliberately choose a split; +- borrow EdgeZero's library-first runner shape and adapter-owned validation hooks; +- split `config push` from `provision apply` unless the team strongly prefers one-step Fastly provisioning; +- keep direct Fastly API provisioning because Trusted Server needs precise resource-link, config item, secret, and key-bootstrap behavior that EdgeZero intentionally avoided by delegating to native CLIs; +- write runtime config-store as its own prerequisite spec so the CLI can reference a stable config deployment contract. diff --git a/docs/superpowers/specs/2026-06-16-edgezero-based-ts-audit-design.md b/docs/superpowers/specs/2026-06-16-edgezero-based-ts-audit-design.md new file mode 100644 index 000000000..113bbc7ce --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-edgezero-based-ts-audit-design.md @@ -0,0 +1,1063 @@ +# Trusted Server CLI — Page Audit and Config Bootstrap + +**Date:** 2026-06-16 +**Status:** Draft design +**Scope:** `ts audit` in the EdgeZero-backed Trusted Server product CLI +**Related context:** + +- `docs/superpowers/specs/2026-06-16-edgezero-based-ts-cli-design.md` +- `docs/superpowers/plans/2026-06-16-trusted-server-cli-respec-context.md` +- Prior implementation on `feature/ts-cli`: + - `crates/trusted-server-cli/src/lib.rs` + - `crates/trusted-server-cli/src/audit.rs` + - `crates/trusted-server-cli/src/audit/analyzer.rs` + - `crates/trusted-server-cli/src/audit/browser_collector.rs` + - `crates/trusted-server-cli/src/audit/collector.rs` + - `docs/guide/cli.md` + +--- + +## 1. Goal + +Add `ts audit` back to the new EdgeZero-backed Trusted Server CLI as a +Trusted Server-specific browser audit and config-bootstrap command. + +`ts audit` loads a public publisher page in a real headless Chrome/Chromium +browser, collects rendered script evidence, classifies JavaScript assets, +detects known Trusted Server integrations, and writes local draft artifacts: + +```text +js-assets.toml # page/script audit artifact +trusted-server.toml # draft Trusted Server app config +``` + +The command is intentionally **not** an EdgeZero lifecycle command. It does not +provision, push config, deploy, build, serve, authenticate, or resolve platform +adapters. It runs inside the `ts` product CLI because its behavior is specific +to Trusted Server onboarding and integration discovery. + +The rebuilt command should preserve the old `feature/ts-cli` user-facing +behavior unless this spec explicitly tightens it: + +```bash +ts audit https://publisher.example + +ts audit https://publisher.example --no-config + +ts audit https://publisher.example \ + --js-assets audit/js-assets.toml \ + --config audit/trusted-server.toml +``` + +The audit output is a **starter draft**, not a production-ready deployment. The +operator must review the generated config, replace placeholders/secrets, validate +it with `ts config validate`, then push it through the separate EdgeZero-backed +config workflow. + +--- + +## 2. Non-goals + +The initial `ts audit` does **not** do any of the following: + +- delegate to EdgeZero adapters; +- require `--adapter`; +- read, validate, generate, or modify `edgezero.toml`; +- push config-store entries; +- write secret-store entries; +- provision platform resources; +- infer platform store names or runtime config-store settings; +- validate that the generated `trusted-server.toml` is production-ready; +- replace manual publisher configuration review; +- crawl more than the requested page; +- run Lighthouse or performance scoring; +- inspect non-script asset classes as first-class artifacts; +- capture request/response bodies, cookies, local storage, session storage, or + arbitrary page data; +- support authenticated pages, user profiles, or inherited browser cookies; +- provide a browser UI/headful mode; +- support remote browser execution; +- add a plugin system for third-party audit detectors; +- create tickets, docs pages, or reports beyond the local TOML artifacts; +- support JSON command output in v1. + +--- + +## 3. Relationship to the EdgeZero-backed CLI + +`ts audit` is a product-level onboarding command that lives beside the +EdgeZero-backed command surface defined in the base CLI spec: + +```text +ts config init +ts config validate +ts config push --adapter +ts auth login --adapter +ts provision --adapter +ts serve --adapter +ts build --adapter +ts deploy --adapter +ts audit +``` + +The boundary is: + +| Command family | Owner | Platform behavior | +| -------------------------------------- | ----------------------------------------- | ----------------- | +| `ts auth/provision/serve/build/deploy` | EdgeZero delegates | Yes | +| `ts config push` | Trusted Server transform + EdgeZero write | Yes | +| `ts audit` | Trusted Server CLI | No | + +`ts audit` may share generic CLI infrastructure with the base CLI crate: +argument parsing, path resolution, output helpers, error formatting, and +`trusted-server.example.toml` access. It must not share or introduce platform +adapter logic. + +Implementation rule: + +> Adding or changing `ts audit` must not require changes to EdgeZero adapter +> traits, EdgeZero platform manifests, or runtime platform stores. + +--- + +## 4. Command surface + +```bash +ts audit [options] +``` + +| Argument / option | Default | Description | +| -------------------- | --------------------- | ------------------------------------------------------- | +| `` | required | Public `http` or `https` page URL to audit. | +| `--js-assets ` | `js-assets.toml` | Write the JavaScript asset audit artifact to this path. | +| `--config ` | `trusted-server.toml` | Write the draft Trusted Server config to this path. | +| `--no-js-assets` | `false` | Do not write the JavaScript asset audit artifact. | +| `--no-config` | `false` | Do not write the draft Trusted Server config. | +| `--force` | `false` | Overwrite existing output files. | + +Rules: + +- `` must parse as a URL and must use the `http` or `https` scheme. +- Relative output paths resolve from the current working directory. +- Absolute output paths are used as-is. +- Parent directories are created for selected outputs. +- Existing output files are not overwritten unless `--force` is passed. +- `--no-js-assets` and `--no-config` may each be used alone. +- Passing both `--no-js-assets` and `--no-config` is an argument error because + the command would have no local output to write. +- `--force` applies to every selected output path. +- There is no `--adapter`, `--manifest`, `--store`, `--local`, `--dry-run`, or + `--json` option for audit in v1. + +Recommended fail-fast order: + +1. Parse arguments. +2. Validate ``. +3. Resolve the selected output paths. +4. Preflight all selected output paths for overwrite conflicts. +5. Run the browser audit. +6. Build all output content in memory. +7. Write selected output files. +8. Print the success summary. + +This improves the old implementation by avoiding a long browser run when output +paths are already known to be unwritable, and by avoiding partial writes when one +selected path conflicts. + +--- + +## 5. Output file model + +### 5.1 Generated files + +By default, `ts audit` writes: + +```text +js-assets.toml +trusted-server.toml +``` + +`js-assets.toml` is a local audit artifact. It contains URL inventory and +integration evidence for the audited page. + +`trusted-server.toml` is a draft app config generated from +`trusted-server.example.toml` and patched with values inferred from the final +audited page URL and detected integrations. + +### 5.2 Source control expectations + +The default output paths may contain publisher hostnames, vendor inventory, and +configuration placeholders. They are operator-owned artifacts, not generic sample +files. + +The repository should ignore the default operator-owned outputs: + +```text +trusted-server.toml +js-assets.toml +``` + +Custom paths under an `audit/` directory are allowed. Project documentation +should warn operators to review generated audit artifacts before committing them, +because they may describe a real publisher page. + +### 5.3 Draft config status + +The generated config is expected to be syntactically valid TOML, but it is not +required to pass production validation immediately. + +Reasons it may still fail `ts config validate`: + +- the starter template can include placeholder secrets; +- detected integrations may need publisher-specific IDs or endpoints; +- non-detected integrations may still need manual enablement or disablement; +- publisher-specific consent, auction, proxy, and request-signing fields cannot + be inferred reliably from one page load. + +The success summary and docs must call it a draft. + +--- + +## 6. Browser collection pipeline + +`ts audit` uses a real headless Chrome/Chromium browser because a static HTML +fetch misses scripts injected by tag managers, consent managers, ad stacks, and +other runtime code. + +The v1 collector preserves the old `feature/ts-cli` behavior: + +1. Locate a local Chrome/Chromium executable. +2. Launch a fresh headless browser session. +3. Open a new page at `about:blank`. +4. Navigate to the requested URL. +5. Wait for the main document navigation response. +6. Reject failed navigations and non-success HTTP statuses. +7. Wait for the page to settle. +8. Read the final page URL. +9. Read the page title. +10. Read the rendered HTML. +11. Read `document.scripts` from the rendered DOM. +12. Read browser resource timing entries. +13. Close the browser. +14. Analyze the collected page data. + +### 6.1 Browser executable resolution + +The collector checks common Chrome/Chromium executable names on `PATH`, then +standard local install locations for supported host operating systems. + +The old implementation checked PATH names equivalent to: + +```text +google-chrome +google-chrome-stable +chromium +chromium-browser +chrome +Google Chrome +Google Chrome for Testing +``` + +It also checked common macOS and Linux application paths. + +The rebuilt implementation should preserve that behavior. Windows-specific +fallback paths are not required for v1, but PATH discovery may work on Windows if +the chosen browser automation dependency supports the host. + +If no browser is found, fail with a clear hint: + +```text +Chrome/Chromium was not found on PATH or in the standard local install locations checked by `ts audit`. Install a local Chrome or Chromium binary before running `ts audit`. +``` + +### 6.2 Browser session isolation + +The browser session must be fresh and isolated: + +- do not use the user's normal browser profile; +- do not reuse persistent cookies or local storage; +- do not load extensions; +- do not require interactive login; +- do not persist browser state after the command exits. + +This keeps `ts audit` suitable for public-page onboarding and reduces the chance +of writing user-specific data into artifacts. + +### 6.3 Navigation validation + +The main document navigation is successful when the browser reports a status in +this range: + +```text +200 <= status < 400 +``` + +Redirects are allowed. The final URL after redirects is the canonical audited URL +for output artifacts and draft config generation. + +Failures are fatal when: + +- the browser does not report a main document response; +- the main request has browser failure text; +- the main response is missing; +- the main response status is outside `200..399`; +- browser launch, navigation, evaluation, or close fails. + +Subresource failures do not fail the command in v1 because the old collector only +read resource timing data and did not capture reliable per-resource status codes. + +### 6.4 Page settle heuristic + +After navigation, wait for the page to settle using the old constants: + +| Constant | Value | +| ------------------- | ------- | +| settle quiet period | `750ms` | +| poll interval | `250ms` | +| max wait | `6s` | + +At each poll: + +1. Read `document.readyState`. +2. Read `performance.getEntriesByType('resource').length`. +3. If `readyState == "complete"` and the resource count has remained stable for + the quiet period, the page is settled. + +If the page does not settle before the max wait, continue with a warning instead +of failing: + +```text +browser audit timed out while waiting for the page to settle; results may be partial +``` + +No CLI flags are defined for these timings in v1. + +### 6.5 Collected page data + +The collector produces this internal data model: + +```rust +struct CollectedPage { + requested_url: String, + final_url: String, + page_title: Option, + html: String, + script_tags: Vec, + network_requests: Vec, + warnings: Vec, +} + +struct CollectedScriptTag { + src: Option, + inline_text: Option, +} + +struct CollectedRequest { + url: String, + method: String, + resource_type: Option, + status: Option, +} +``` + +For browser resource timing entries: + +- `url` is the resource entry name; +- `method` is `GET` in v1; +- `resource_type` is the resource entry initiator type; +- `status` is `None` in v1. + +The public `js-assets.toml` artifact must not include raw rendered HTML or raw +inline script text. + +--- + +## 7. Analysis pipeline + +The analyzer converts `CollectedPage` into an `AuditArtifact`. + +Pipeline: + +1. Parse `requested_url` and `final_url` as URLs. +2. Parse rendered HTML as a document. +3. Derive a title from the HTML `` element. +4. Prefer the browser-reported title when it is non-empty; otherwise use the + derived HTML title. +5. Start with collector warnings. +6. If requested URL and final URL differ, append a redirect warning. +7. Inspect script elements from rendered HTML. +8. Inspect browser-collected `document.scripts` entries. +9. Inspect resource timing entries whose type is `script`, case-insensitive. +10. Resolve, classify, detect, and deduplicate script assets. +11. Sort assets and integrations deterministically. +12. Count total JavaScript assets and third-party assets. + +### 7.1 Script sources + +Use three evidence sources because each catches different browser behavior: + +| Source | Purpose | +| ---------------------------- | --------------------------------------------------------------------------- | +| Rendered HTML `<script src>` | Captures scripts present in final DOM markup. | +| Browser `document.scripts` | Captures normalized script URLs and inline scripts after runtime mutations. | +| Resource timing entries | Captures dynamically loaded script network resources. | + +For HTML `<script src>` values: + +- resolve relative URLs against the final page URL; +- if resolution fails, append a warning and continue. + +For browser `document.scripts` values: + +- `src` should normally already be absolute; +- parse absolute script URLs; +- ignore malformed entries rather than failing the whole audit. + +For resource timing entries: + +- only entries with resource type `script`, case-insensitive, are treated as JS + assets; +- parse absolute resource URLs; +- ignore malformed entries rather than failing the whole audit. + +### 7.2 Deduplication and ordering + +Assets are deduplicated by their absolute URL string after resolution/parsing. + +Output order must be deterministic: + +- assets sorted lexicographically by URL; +- detected integrations sorted lexicographically by integration ID; +- warnings preserved in append order. + +If the same asset URL is observed from multiple sources, write one asset row. If +any source identifies the asset's integration, the final row should contain that +integration ID. + +The last sentence is a slight tightening over the old implementation, which kept +the first inserted row unchanged. The user-visible intent is still the same: +produce one best-effort row per script URL. + +### 7.3 Party classification + +Each asset is classified as either: + +```text +first-party +third-party +``` + +Classification is based only on the final page host and asset host. + +An asset is first-party when any of these are true: + +- `asset_host == page_host`; +- `asset_host` is a dot-boundary subdomain of `page_host`; +- `page_host` is a dot-boundary subdomain of `asset_host`. + +Otherwise it is third-party. + +This intentionally preserves the old lightweight host relationship heuristic. It +does not use the Public Suffix List and can be imperfect for complex delegated +subdomain setups. Those cases should be corrected manually by the operator when +reviewing `js-assets.toml`. + +--- + +## 8. Integration detection + +`ts audit` detects integrations from two evidence types: + +1. script URL host/path patterns; +2. inline script text markers. + +The initial detector set matches the old implementation: + +| Integration ID | URL evidence | Inline evidence | Draft config action | +| -------------------- | ------------------------------------- | ----------------------------------- | --------------------------------------------- | +| `google_tag_manager` | Known tag-manager script URL patterns | `GTM-...` container ID pattern | Enable only when a container ID is extracted. | +| `gpt` | Known GPT script URL patterns | Case-insensitive `gpt` marker | Enable `[integrations.gpt]`. | +| `didomi` | Known Didomi script URL patterns | Case-insensitive `didomi` marker | Enable `[integrations.didomi]`. | +| `datadome` | Known DataDome script URL patterns | Case-insensitive `datadome` marker | Enable `[integrations.datadome]`. | +| `permutive` | Known Permutive script URL patterns | Case-insensitive `permutive` marker | Add manual-review comment. | +| `lockr` | Known Lockr script URL patterns | Case-insensitive `lockr` marker | Add manual-review comment. | +| `prebid` | Known Prebid script URL patterns | Case-insensitive `prebid` marker | Add manual-review comment. | + +The exact public vendor host/path substrings should be ported from the old +`feature/ts-cli` analyzer into runtime detector constants. Documentation and +examples should use `example` hostnames unless they are testing detector +constants directly. + +### 8.1 URL detection + +URL detection is best-effort and case-insensitive over the URL host and path. +Query strings can still be inspected separately for IDs such as a GTM container +ID. + +When URL detection finds an integration: + +- set `asset.integration` to that integration ID; +- add a `detected_integrations` entry if one does not already exist; +- use the script URL string as evidence. + +### 8.2 Inline detection + +Inline script text is inspected only for small integration markers. It is not +written to the audit artifact. + +GTM container IDs are detected with the old shape: + +```text +GTM-[A-Z0-9]+ +``` + +Other v1 inline detectors are case-insensitive substring checks for the +integration IDs listed above. + +When inline detection finds an integration: + +- add a `detected_integrations` entry if one does not already exist; +- use the container ID as GTM evidence when available; +- otherwise use a concise marker such as `inline script matched <integration>`. + +### 8.3 Evidence precedence + +For each integration ID, keep the first evidence string encountered in the +analysis pipeline. Because output is sorted by integration ID, the evidence order +should not affect TOML ordering, only the evidence value. + +### 8.4 Extensibility + +The detector implementation should be a small data-driven table or a set of +focused helper functions so future integrations can be added without changing the +collector. + +Do not add sourcepoint, APS, ad server, consent-string, ad slot, or bidder +configuration inference in v1 unless there is an explicit follow-up spec. The v1 +requirement is to preserve the old detector set. + +--- + +## 9. Audit artifact schema + +`js-assets.toml` is the pretty TOML serialization of this schema: + +```rust +struct AuditArtifact { + audited_url: String, + page_title: Option<String>, + js_asset_count: usize, + third_party_asset_count: usize, + detected_integrations: Vec<DetectedIntegration>, + assets: Vec<AuditedAsset>, + warnings: Vec<String>, +} + +struct DetectedIntegration { + id: String, + evidence: String, +} + +struct AuditedAsset { + kind: String, + url: String, + host: String, + party: AssetParty, + integration: Option<String>, +} + +enum AssetParty { + FirstParty, // serialized as "first-party" + ThirdParty, // serialized as "third-party" +} +``` + +Field rules: + +- `audited_url` is the final URL after redirects. +- `page_title` is omitted when unknown. +- `js_asset_count` is `assets.len()`. +- `third_party_asset_count` counts assets whose `party` is `third-party`. +- `detected_integrations` contains one row per detected integration ID. +- `assets` contains one row per deduplicated script URL. +- `kind` is `script` for every v1 asset row. +- `host` is the asset URL host, or an empty string when unavailable. +- `integration` is omitted when no integration is detected for the asset. +- `warnings` is an array of human-readable warning strings. + +No `schema_version` field is included in v1 so the artifact remains compatible +with the old `feature/ts-cli` shape. If a future schema version is added, it +should be additive and documented in a migration note. + +Example shape using non-real hosts: + +```toml +audited_url = "https://www.publisher.example/article" +page_title = "Example Publisher" +js_asset_count = 2 +third_party_asset_count = 1 +warnings = [] + +[[detected_integrations]] +id = "gpt" +evidence = "https://ads-vendor.example/tag/js/gpt.js" + +[[detected_integrations]] +id = "google_tag_manager" +evidence = "GTM-ABC123" + +[[assets]] +kind = "script" +url = "https://www.publisher.example/app.js" +host = "www.publisher.example" +party = "first-party" + +[[assets]] +kind = "script" +url = "https://ads-vendor.example/tag/js/gpt.js" +host = "ads-vendor.example" +party = "third-party" +integration = "gpt" +``` + +--- + +## 10. Draft config generation + +`trusted-server.toml` output is produced by taking the +`trusted-server.example.toml` starter template and applying audit-derived edits. + +The generated file should preserve the starter template's comments and ordering +as much as possible. Text replacement is acceptable if the template has stable +sentinel values. A parsed-TOML implementation is also acceptable if it preserves +all required fields and produces a readable draft. + +### 10.1 URL-derived fields + +Use the final audited URL, not the originally requested URL. + +From final URL: + +| Config field | Value | +| ------------------------- | -------------------------------------------- | +| `publisher.domain` | final URL host without port | +| `publisher.cookie_domain` | `.<host>` | +| `publisher.origin_url` | final URL origin, including non-default port | + +Examples: + +| Final URL | `publisher.domain` | `publisher.cookie_domain` | `publisher.origin_url` | +| ----------------------------------------- | ----------------------- | ------------------------- | ------------------------------------ | +| `https://publisher.example/page` | `publisher.example` | `.publisher.example` | `https://publisher.example` | +| `https://www.publisher.example:8443/path` | `www.publisher.example` | `.www.publisher.example` | `https://www.publisher.example:8443` | + +If the final URL is missing a host, fail the audit as an internal audit error. +This should not happen after `<url>` validation for normal `http`/`https` URLs. + +The command does not try to infer an apex cookie domain. Operators must review +and adjust `publisher.cookie_domain` for their domain policy. + +### 10.2 Integration-derived edits + +Detected integrations update only known starter-template sections. + +| Detection | Draft config edit | +| ----------------------------------------- | ----------------------------------------------------------------------------------- | +| `gpt` | Set `[integrations.gpt].enabled = true`. | +| `didomi` | Set `[integrations.didomi].enabled = true`. | +| `datadome` | Set `[integrations.datadome].enabled = true`. | +| `google_tag_manager` with container ID | Set `[integrations.google_tag_manager].enabled = true` and `container_id = "<id>"`. | +| `google_tag_manager` without container ID | Do not enable automatically; add manual-review comment. | +| `permutive` | Add manual-review comment. | +| `lockr` | Add manual-review comment. | +| `prebid` | Add manual-review comment. | + +Manual-review comments are appended near the end of the draft config: + +```toml +# Audit findings requiring manual review +# - Detected prebid; review the corresponding [integrations.prebid] section before enabling it. +# - Detected permutive; review the corresponding [integrations.permutive] section before enabling it. +``` + +This preserves the old behavior for Permutive, Lockr, and Prebid while tightening +GTM handling: GTM should only be enabled when a usable container ID was actually +extracted. + +### 10.3 What audit must not change + +`ts audit` must not: + +- remove starter-template sections; +- disable starter-template defaults; +- invent auction bidder settings; +- infer consent policy; +- infer request-signing settings; +- infer secret values; +- infer platform store names; +- add `[providers]` sections; +- write EdgeZero manifest fields; +- write environment overlays. + +The command may only patch the fields listed in this section and append +manual-review comments. + +--- + +## 11. Human output + +On success, print a concise summary to stdout: + +```text +Audited https://www.publisher.example/article +Title: Example Publisher +JS assets: 12 +Third-party assets: 8 +Detected integrations: google_tag_manager, gpt, prebid +Wrote: /path/to/js-assets.toml, /path/to/trusted-server.toml +``` + +Rules: + +- `Audited` uses the final URL after redirects. +- `Title` uses `<unknown>` when no page title is found. +- `JS assets` is the artifact `js_asset_count`. +- `Third-party assets` is the artifact `third_party_asset_count`. +- `Detected integrations` is `none` when empty, otherwise comma-separated IDs in + deterministic order. +- `Wrote` is `none` only if future command variants allow no files; in v1, both + `--no-js-assets` and `--no-config` are rejected, so success should write at + least one file. + +Warnings are written into `js-assets.toml`. The success summary does not need to +print them unless a future UX pass adds a warning count. + +--- + +## 12. Error behavior and exit codes + +`ts audit` follows the base CLI exit code policy: + +| Exit code | Meaning | +| --------- | ------------------------------------------------ | +| `0` | Audit completed and selected files were written. | +| `1` | Audit failed. | + +No cancellation exit code is needed because `ts audit` has no interactive prompt. + +### 12.1 Argument errors + +| Failure | Message guidance | +| ------------------------------------ | ----------------------------------------------- | +| invalid URL | include the invalid value and parser error | +| unsupported scheme | say `ts audit` only supports `http` and `https` | +| both outputs disabled | say there is nothing to do | +| output path exists without `--force` | say refusing to overwrite and suggest `--force` | + +### 12.2 Browser/audit errors + +| Failure | Message guidance | +| -------------------------- | ---------------------------------------------------------- | +| browser missing | install Chrome or Chromium locally | +| browser launch failed | include browser automation error context | +| navigation failed | include requested URL context | +| main response missing | say the browser did not capture the main document response | +| main status not `200..399` | include status, status text, and response URL | +| page evaluation failed | include which data collection step failed | +| browser close failed | report close failure; do not silently ignore it | + +### 12.3 Non-fatal warnings + +Warnings do not cause a non-zero exit: + +- page settle timeout; +- requested URL redirected to final URL; +- individual malformed script URLs in rendered HTML. + +Warnings are stored in the artifact and should be visible during review. + +--- + +## 13. Security and privacy notes + +`ts audit` intentionally loads a real public web page and allows that page's +scripts to execute in headless Chromium. Treat it as an operator-controlled +onboarding tool, not an unattended crawler. + +Required safeguards: + +- use an isolated temporary browser profile; +- do not use the operator's personal browser profile; +- do not persist cookies or storage; +- do not write raw inline script bodies; +- do not write rendered HTML; +- do not write request or response bodies; +- do not write browser cookies, local storage, session storage, or form values; +- do not print generated config values that may contain secrets; +- do not contact any platform APIs; +- do not upload artifacts anywhere. + +The artifact still contains URL inventory. Operators should treat it as +potentially sensitive publisher/vendor information. + +Docs, tests, and committed fixtures should use `example` domains and fictional +publisher data. Runtime detector constants may contain the public vendor +host/path patterns required for actual detection. + +--- + +## 14. Implementation architecture + +The base CLI spec owns the crate and binary. `ts audit` should be implemented as +an internal module of the host-target CLI crate, not as part of +`trusted-server-core` or any wasm-target adapter crate. + +Suggested module layout: + +```text +crates/trusted-server-cli/src/ + audit.rs # public command orchestration and output writing + audit/ + collector.rs # collected data structs and collector trait + browser_collector.rs# Chrome/Chromium implementation + analyzer.rs # artifact analysis and integration detection +``` + +### 14.1 Host-only dependencies + +Host-only browser dependencies are allowed in `trusted-server-cli` only. + +Do not add browser automation, Tokio runtime, `which`, or scraper dependencies to +runtime crates that build for `wasm32-wasip1`. + +The prior implementation used: + +- `chromiumoxide` for browser automation; +- `tokio` current-thread runtime inside the sync command handler; +- `scraper` for rendered HTML parsing; +- `regex` for GTM ID detection; +- `url` for URL parsing/resolution; +- `toml`/`serde` for artifact serialization. + +Reusing those dependencies is acceptable. Replacing `chromiumoxide` is also +acceptable if the command preserves the same collection behavior and tests can +run without a real browser. + +### 14.2 Testability boundary + +Use a collector abstraction so unit tests can feed synthetic `CollectedPage` +values directly into the analyzer and draft-config generator. + +Unit tests must not require a real browser. Browser smoke tests, if added, should +be ignored by default or feature-gated. + +Recommended orchestration shape: + +```rust +trait AuditCollector { + fn collect_page(&self, target_url: &Url) -> Result<CollectedPage, Report<CliError>>; +} + +fn perform_audit_with_collector( + collector: &dyn AuditCollector, + target_url: &Url, +) -> Result<AuditOutputs, Report<CliError>>; +``` + +The production command uses the browser collector. Tests use a fake collector. + +### 14.3 Output preflight and writes + +Build an `AuditOutputPlan` before launching the browser: + +```rust +struct AuditOutputPlan { + js_assets_path: Option<PathBuf>, + config_path: Option<PathBuf>, + force: bool, +} +``` + +The plan should: + +- reject both paths disabled; +- resolve defaults; +- check overwrite conflicts for all selected paths; +- avoid writing anything until all output content is ready. + +Atomic file replacement is not required in v1, but avoiding partial writes from +known path conflicts is required. + +--- + +## 15. Tests + +### 15.1 CLI arguments and path planning + +- `ts audit <http-url>` parses. +- `ts audit <https-url>` parses. +- non-HTTP schemes are rejected. +- malformed URLs are rejected. +- both `--no-js-assets` and `--no-config` are rejected. +- default paths resolve to `js-assets.toml` and `trusted-server.toml` under the + current working directory. +- custom `--js-assets` and `--config` paths are honored. +- parent directories are created for selected outputs. +- existing outputs are rejected without `--force`. +- existing outputs are overwritten with `--force`. +- if one selected output exists and another does not, no file is written before + the command reports the overwrite conflict. + +### 15.2 Browser collector units + +- browser executable discovery finds PATH candidates. +- browser executable discovery checks supported fallback paths. +- missing browser produces the install hint. +- navigation statuses `200`, `302`, and `399` are accepted. +- navigation statuses below `200` and at or above `400` are rejected. +- missing main document response is rejected. +- failed main document request is rejected. +- settle timeout returns a warning, not an error. + +Browser-launch integration tests should be opt-in and skipped by default. + +### 15.3 Analyzer + +- rendered HTML `<title>` is used when browser title is absent. +- browser title wins over rendered HTML title when present. +- requested-to-final URL redirect adds a warning. +- relative script URLs resolve against the final URL. +- malformed script URLs add warnings and do not fail the audit. +- HTML script tags, browser script tags, and script resource timing entries are + merged. +- duplicate script URLs produce one asset row. +- duplicate script URLs preserve detected integration when any source detects it. +- assets are sorted by URL. +- integrations are sorted by ID. +- first-party exact host match is classified as `first-party`. +- first-party subdomain relationship is classified as `first-party`. +- unrelated host is classified as `third-party`. +- non-script resource timing entries are ignored. + +### 15.4 Integration detection + +- GTM container IDs are extracted from inline script evidence. +- GTM container IDs are extracted from script URLs when present. +- GPT URL evidence detects `gpt`. +- Didomi URL evidence detects `didomi`. +- DataDome URL evidence detects `datadome`. +- Permutive URL evidence detects `permutive`. +- Lockr URL evidence detects `lockr`. +- Prebid URL evidence detects `prebid`. +- inline markers detect `gpt`, `didomi`, `datadome`, `permutive`, `lockr`, and + `prebid` case-insensitively. +- detector tests should avoid real publisher domains. + +### 15.5 Artifact serialization + +- `js-assets.toml` includes `audited_url`, counts, integrations, assets, and + warnings. +- `page_title` is omitted when unknown. +- `asset.integration` is omitted when unknown. +- `party` serializes as `first-party` or `third-party`. +- `js_asset_count` equals the number of asset rows. +- `third_party_asset_count` equals the number of third-party asset rows. +- output is deterministic for the same collected input. + +### 15.6 Draft config generation + +- final redirected URL is used for config fields. +- `publisher.domain` uses final host without port. +- `publisher.cookie_domain` uses `.<host>`. +- `publisher.origin_url` preserves non-default port. +- GPT detection enables `[integrations.gpt]`. +- Didomi detection enables `[integrations.didomi]`. +- DataDome detection enables `[integrations.datadome]`. +- GTM detection with container ID enables Google Tag Manager and sets + `container_id`. +- GTM detection without container ID does not enable Google Tag Manager and adds + a manual-review comment. +- Permutive, Lockr, and Prebid detections add manual-review comments. +- no platform/provider/EdgeZero sections are added. +- generated TOML parses successfully. + +### 15.7 Command orchestration + +Using a fake collector: + +- selected outputs are written on success. +- `--no-js-assets` writes only config. +- `--no-config` writes only assets. +- stdout summary includes final URL, title, counts, integrations, and paths. +- collector warnings are present in `js-assets.toml`. +- no EdgeZero delegate is invoked. +- no platform API is contacted. + +--- + +## 16. Implementation plan + +### Stage 1 — Wire command into the base CLI + +- Add `AuditArgs` to the new `ts` command tree. +- Add `Command::Audit(AuditArgs)` dispatch. +- Reuse base CLI output and error formatting helpers. +- Add URL parsing/validation. +- Add output-path planning and overwrite preflight. + +### Stage 2 — Port audit data model and analyzer + +- Add `CollectedPage`, `CollectedScriptTag`, and `CollectedRequest`. +- Add `AuditArtifact`, `AuditedAsset`, `DetectedIntegration`, and `AssetParty`. +- Port analyzer behavior from `feature/ts-cli`. +- Port integration detector behavior from `feature/ts-cli`. +- Add deterministic sorting/deduplication. +- Add analyzer and artifact serialization tests. + +### Stage 3 — Port draft config generation + +- Read from the new `trusted-server.example.toml` template. +- Patch URL-derived publisher fields from the final URL. +- Enable only the supported auto-enable integrations. +- Append manual-review comments for inferred-only integrations. +- Keep generated config as a draft. +- Add draft config tests. + +### Stage 4 — Port browser collector + +- Add host-only browser automation dependencies to `trusted-server-cli`. +- Implement browser discovery. +- Implement fresh headless browser launch. +- Implement navigation validation and settle wait. +- Collect final URL, title, rendered HTML, script tags, and resource timing + entries. +- Ensure browser close errors are surfaced. +- Add unit tests around browser-independent helper functions. + +### Stage 5 — Docs and verification + +- Document `ts audit` in the CLI guide. +- Document Chrome/Chromium requirement. +- Document generated files and draft-config caveat. +- Add default generated artifact path to `.gitignore` if not already ignored. +- Run host-target CLI tests. +- Run workspace formatting and linting required by the base CLI implementation + work. + +--- + +## 17. Open follow-ups outside this spec + +- Add `--browser <path>` or `TS_AUDIT_BROWSER` override for non-standard Chrome + installs. +- Add `--timeout` or named audit profiles for slow pages. +- Add `--json` machine-readable command summary. +- Add schema versioning for `js-assets.toml`. +- Add Sourcepoint, APS, ad server, or additional integration detectors. +- Add optional HAR export with explicit user opt-in. +- Add authenticated audit support using an explicitly provided isolated browser + profile. +- Add multi-page crawl support. +- Add richer config bootstrap for consent, auction, bidder, and proxy settings. +- Add a command to merge audit findings into an existing config instead of + writing a fresh draft. diff --git a/docs/superpowers/specs/2026-06-16-edgezero-based-ts-cli-design.md b/docs/superpowers/specs/2026-06-16-edgezero-based-ts-cli-design.md new file mode 100644 index 000000000..2a42de877 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-edgezero-based-ts-cli-design.md @@ -0,0 +1,671 @@ +# Trusted Server CLI — EdgeZero-Backed Product CLI + +**Date:** 2026-06-16 +**Status:** Draft design +**Scope:** Initial `ts` product CLI; audit is specified separately +**Related context:** + +- `docs/superpowers/plans/2026-06-16-trusted-server-cli-respec-context.md` +- `docs/superpowers/specs/2026-06-16-edgezero-based-ts-audit-design.md` +- EdgeZero PR #269 CLI/config/provision work — implementation temporarily targets this PR branch/rev before repinning to the merged EdgeZero revision +- Future runtime-config-store spec for loading flattened `app_config` entries + +--- + +## 1. Goal + +Add a Trusted Server product CLI binary, `ts`, as the normal operator +entrypoint for Trusted Server workflows. + +`ts` exposes Trusted Server-specific config commands and EdgeZero-backed +platform lifecycle commands through one binary. Trusted Server-specific commands +own Trusted Server behavior. Platform lifecycle commands are thin delegates to +EdgeZero and must not reimplement platform behavior. + +The initial command surface is: + +```text +ts config init +ts config validate +ts config push + +ts auth login --adapter <adapter> +ts auth status --adapter <adapter> +ts auth logout --adapter <adapter> + +ts provision --adapter <adapter> +ts serve --adapter <adapter> +ts build --adapter <adapter> +ts deploy --adapter <adapter> +``` + +`ts` is the user-facing binary. EdgeZero is the platform execution engine. + +`ts config push` owns the Trusted Server app-config transformation: + +```text +trusted-server.toml + -> parse and validate as Trusted Server Settings + -> serialize validated Settings to a JSON value + -> flatten to EdgeZero-style deterministic key/value entries + -> compute sha256 over the canonical entry map + -> push config-store entries through EdgeZero platform primitives +``` + +EdgeZero owns adapter resolution, logical-store to platform-store resolution, +local-vs-remote push behavior, dry-run behavior, auth, provisioning, serving, +building, deployment, and all platform-specific writes. + +--- + +## 2. Non-goals + +The initial `ts` CLI does **not** do any of the following: + +- reimplement EdgeZero auth/provision/serve/build/deploy logic in Trusted Server; +- construct Fastly/Wrangler/Spin commands directly in `ts`; +- define a Trusted Server-owned platform adapter registry; +- require operators to call `edgezero` for normal Trusted Server workflows; +- include `ts dev`; +- include `ts audit` — separate spec; +- perform custom Fastly API provisioning; +- add a Trusted Server platform adapter layer; +- support runtime plugin/subcommand discovery; +- expose a public reusable `trusted-server-cli` library API; +- support app-config environment overrides; +- write request-signing key/bootstrap secrets; +- write secret-store entries of any kind; +- generate config signing / DSSE artifacts; +- support config diff/pull/inspect commands. + +--- + +## 3. File ownership model + +### 3.1 Source-controlled files + +The repository tracks: + +```text +edgezero.toml +trusted-server.example.toml +``` + +`edgezero.toml` is the EdgeZero platform manifest. It declares the Trusted +Server app, stores, adapters, and platform command metadata. + +`trusted-server.example.toml` is the source-controlled app-config template. +It uses only example/placeholder values and is kept in sync with the Trusted +Server settings schema. + +### 3.2 Operator-owned files + +The repository ignores: + +```text +trusted-server.toml +``` + +`trusted-server.toml` is operator-authored app config. It is never compiled into +the binary and is never a source-controlled deployment artifact. + +### 3.3 App name + +The EdgeZero app name is fixed for this product: + +```toml +[app] +name = "trusted-server" +``` + +Because the app name is `trusted-server`, EdgeZero's app-config naming +convention and Trusted Server's historical config filename both resolve to: + +```text +trusted-server.toml +``` + +--- + +## 4. EdgeZero manifest requirements + +Trusted Server uses EdgeZero platform manifests and logical store IDs. + +Minimum initial manifest store declarations: + +```toml +[stores.config] +ids = ["app_config"] +default = "app_config" + +[stores.secrets] +ids = ["secrets"] +default = "secrets" +``` + +The initial `ts config push` only writes config-store entries. The `secrets` +store is declared for runtime/future use but is not written by this CLI spec. + +Platform store names are not stored in `trusted-server.toml`. They are resolved +by EdgeZero via its environment overlay, for example: + +```text +EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=publisher-a-ts-config +EDGEZERO__STORES__SECRETS__SECRETS__NAME=publisher-a-ts-secrets +``` + +--- + +## 5. Runtime payload contract + +The runtime-config-store spec owns runtime loading. This CLI spec only defines +what `ts config push` publishes. + +`ts config push` writes EdgeZero-style flattened config entries by default. It +does **not** store the whole Trusted Server config as one large JSON blob. + +| Key pattern | Value | +| ------------------------------- | --------------------------------------------------------------------------------------------- | +| `<escaped-dotted-settings-key>` | Canonical JSON text for one flattened Trusted Server setting leaf | +| `ts-config-hash` | `sha256:<hex>` over the canonical flattened settings entry map, excluding metadata entries | +| `ts-config-keys` | Minified JSON array of flattened settings keys in sorted order, excluding metadata entries | + +Flattening follows EdgeZero's config push model with Trusted Server key +escaping: + +- Each JSON object key is treated as one path segment. +- Before joining path segments, each segment is escaped deterministically: + - `\` becomes `\\` + - `.` becomes `\.` +- Flattened keys are escaped path segments joined by an unescaped `.`. +- The canonical map, `ts-config-keys`, hash input, and pushed entry keys all use + the escaped flattened keys. +- Runtime reconstruction must split only on unescaped `.` and then unescape in + reverse order. +- JSON objects flatten recursively. +- Leaf values are stored as canonical JSON text so runtime reconstruction is + lossless: + - strings are JSON-quoted strings; + - booleans and numbers use JSON scalar text; + - arrays are stored as canonical minified JSON arrays under the array field's + escaped dotted key. Any objects inside arrays must have recursively sorted + keys before serialization. +- Null values are skipped. +- Metadata keys beginning with `ts-config-` are reserved for Trusted Server and + must not be produced by app settings flattening. + +Reserved future keys, not written in this initial spec: + +| Key | Future purpose | +| --------------------- | -------------------------------------------------------------------------------- | +| `ts-config-signature` | Optional signature/DSSE envelope over the canonical flattened settings entry map | +| `ts-config-metadata` | Optional JSON metadata: version, published_at, valid_until, policy_id | + +The app config hash is computed only over flattened Trusted Server setting +entries, not over metadata entries and not over unrelated entries in the config +store. + +Request-signing public/private state is intentionally out of scope for this +initial CLI. It will be revisited after EdgeZero exposes suitable secret-store +write primitives. + +--- + +## 6. Flattened config entries + +`trusted-server.toml` remains the human-authored source format. The deployed +runtime payload is an EdgeZero-style deterministic key/value entry set. + +Flattening pipeline: + +1. Read `trusted-server.toml` as UTF-8. +2. Parse as TOML. +3. Deserialize into the Trusted Server `Settings` schema with strict unknown-field + rejection. +4. Run existing semantic validation. +5. Reject placeholder/default secrets using the same production safety rules as + runtime validation. +6. Convert the validated settings into a JSON value. +7. Flatten the JSON value using EdgeZero's config push rules and Trusted Server's + path-segment escaping rules. +8. Sort flattened entries lexicographically by escaped key. +9. Serialize the sorted settings-only entry map as minified JSON for hashing. +10. Compute SHA-256 over those exact UTF-8 bytes. + +The flattened entries and hash must be stable for semantically identical config. +Reordered TOML input and TOML formatting/comment changes must not change the +hash if the resulting `Settings` value is identical. + +If the settings schema contains maps or dynamic integration configuration, those +maps must be sorted during flattening by escaped key. Do not rely on parser +insertion order. + +Strict schema validation is part of this CLI contract. Every non-map settings +struct reachable from `Settings` must reject unknown fields. Explicit map fields +remain the supported extension points for dynamic integration, response-header, +profile, or similar keyed configuration. + +--- + +## 7. Command surface + +### 7.1 EdgeZero delegate commands + +```bash +ts auth login --adapter <adapter> [-- <edgezero-args>...] +ts auth status --adapter <adapter> [-- <edgezero-args>...] +ts auth logout --adapter <adapter> [-- <edgezero-args>...] + +ts provision --adapter <adapter> [-- <edgezero-args>...] +ts serve --adapter <adapter> [-- <edgezero-args>...] +ts build --adapter <adapter> [-- <edgezero-args>...] +ts deploy --adapter <adapter> [-- <edgezero-args>...] +``` + +These commands provide a Trusted Server product CLI wrapper around EdgeZero +platform lifecycle behavior. + +Behavior: + +- Delegate to EdgeZero command handlers for the selected adapter. +- Preserve EdgeZero adapter semantics, validation, local/remote behavior, and + platform-specific error handling. +- Forward supported command options and trailing passthrough args after `--` to + EdgeZero without translating them into Trusted Server-owned platform logic. +- Do not read, validate, flatten, or push `trusted-server.toml` unless a + delegated EdgeZero command explicitly requires app/manifest context. +- Do not construct Fastly, Wrangler, Spin, or other platform commands directly + in Trusted Server code. +- Do not implement platform-specific REST/API writes in Trusted Server code. + +Preferred implementation is to call EdgeZero Rust library APIs directly. Shelling +out to an `edgezero` binary is only acceptable as a temporary implementation +strategy if the required library API does not exist yet. + +The command shape intentionally mirrors EdgeZero so product documentation can map +`ts` commands to EdgeZero-backed behavior one-to-one. Passthrough args are +forwarded verbatim; Trusted Server only parses product-level options such as +`--adapter`. + +### 7.2 `ts config init` + +```bash +ts config init [--config <path>] [--force] +``` + +Defaults: + +| Option | Default | +| ---------- | --------------------- | +| `--config` | `trusted-server.toml` | + +Behavior: + +- Copies `trusted-server.example.toml` to the target config path. +- Creates parent directories when needed. +- Refuses to overwrite an existing file unless `--force` is passed. +- Does not read or validate `edgezero.toml`. +- Does not contact any platform. +- Does not run a wizard. +- May copy placeholder/example values. A successful init does not imply the + resulting file passes `ts config validate`; validation and push still reject + placeholder/default secrets until the operator replaces them. + +Success output is concise, for example: + +```text +Initialized config at trusted-server.toml +``` + +### 7.3 `ts config validate` + +```bash +ts config validate [--config <path>] [--json] +``` + +Defaults: + +| Option | Default | +| ---------- | --------------------- | +| `--config` | `trusted-server.toml` | + +Behavior: + +- Reads the local Trusted Server config file. +- Parses and validates it as Trusted Server app config. +- Builds flattened config entries. +- Computes the config hash over the canonical entry map. +- Does not read `edgezero.toml`. +- Does not contact any platform. +- Does not apply app-config environment overrides. + +Human success output (`Config entries` counts flattened settings entries only, +excluding metadata): + +```text +Config valid: /absolute/path/to/trusted-server.toml +Config entries: <count> +Config hash: sha256:<hex> +``` + +`--json` success output: + +```json +{ + "valid": true, + "config_path": "/absolute/path/to/trusted-server.toml", + "entry_count": 42, + "config_hash": "sha256:<hex>", + "errors": [] +} +``` + +On validation failure with `--json`, stdout still contains JSON and the process +exits non-zero: + +```json +{ + "valid": false, + "config_path": "/absolute/path/to/trusted-server.toml", + "entry_count": null, + "config_hash": null, + "errors": ["publisher.domain is required"] +} +``` + +Human failure output goes to stderr and exits non-zero. + +### 7.4 `ts config push` + +```bash +ts config push \ + --adapter <adapter> \ + [--config <path>] \ + [--manifest <path>] \ + [--store <logical-config-store-id>] \ + [--local] \ + [--dry-run] \ + [--runtime-config <path>] +``` + +Defaults: + +| Option | Default | +| ------------ | --------------------- | +| `--config` | `trusted-server.toml` | +| `--manifest` | `edgezero.toml` | +| `--store` | `app_config` | + +Behavior: + +1. Runs the same Trusted Server app-config validation and flattening as + `ts config validate`. +2. Produces config entries: + - one `<escaped-dotted-settings-key> = <canonical-json-value>` entry per flattened setting + - `ts-config-keys = <minified JSON array of settings keys>` + - `ts-config-hash = sha256:<hex>` +3. Delegates the entry write to EdgeZero's config-store push primitive using: + - adapter from `--adapter` + - manifest from `--manifest` + - logical config store from `--store` + - local mode from `--local` + - dry-run mode from `--dry-run` + - adapter runtime config from `--runtime-config`, when supplied + +`--store` selects the logical config store for **all** Trusted Server config +entries written by this command. + +`--dry-run` must not mutate platform or local adapter state. It should still +validate config, compute the hash, resolve the EdgeZero push target, and report +what would be written. Full values should not be printed by default; show key +names, entry count, and hash instead. + +No `--json` is defined for `ts config push` in this spec. Machine-readable push +output should be added to EdgeZero upstream and then exposed here consistently. + +--- + +## 8. EdgeZero integration boundary + +The Trusted Server CLI must not implement platform-specific lifecycle behavior or +platform-specific writes. + +Implementation starts by switching this repository's EdgeZero git dependencies +to the target PR #269 branch/rev that contains the needed CLI/config/provision +APIs. Before merging the Trusted Server work, repin to the merged EdgeZero +commit or release. Trusted Server must not add temporary platform-specific +writes while waiting for these EdgeZero APIs; missing APIs are upstream +prerequisites. + +There are two integration modes: + +1. Pure lifecycle delegation for `ts auth`, `ts provision`, `ts serve`, + `ts build`, and `ts deploy`. +2. Trusted Server transformation plus EdgeZero write delegation for + `ts config push`. + +Pure lifecycle delegate commands should call EdgeZero command/library APIs with +the parsed CLI arguments and selected adapter. They should not perform Trusted +Server config flattening, direct platform API calls, or adapter-specific command +construction. + +`ts config push` is intentionally different: it validates and transforms Trusted +Server app config first, then delegates flattened config-store entry writes to +EdgeZero. + +Allowed `ts config push` implementation approaches: + +1. Reuse EdgeZero's config push flattening and adapter push APIs directly, with + Trusted Server supplying the typed `Settings` value and reserved metadata + entries. +2. Call an EdgeZero Rust API that accepts already-flattened config entries and + executes the adapter push. +3. Shell out to `edgezero config push` only if EdgeZero supports the same typed + Trusted Server flattening path and metadata entries without introducing a + separate platform write path in `ts`. +4. Add the required public flatten/push API to EdgeZero first, then consume it + from `ts`. + +Not allowed: + +- direct Fastly REST API calls from `ts`; +- direct Wrangler/Fastly/Spin command construction in `ts`; +- TS-owned adapter registry for platform writes; +- duplicating EdgeZero store-name resolution logic beyond calling exposed + EdgeZero helpers. + +### 8.1 Required EdgeZero capability + +Trusted Server needs an EdgeZero config push path that can write flattened +entries in the same shape EdgeZero already uses for app config: + +```text +[ + ("publisher.domain", "example.com"), + ("ec.partners", "[...]"), + ("ts-config-keys", "[\"ec.partners\",\"publisher.domain\"]"), + ("ts-config-hash", "sha256:<hex>") +] +``` + +EdgeZero then resolves and writes those entries for the selected +adapter/logical store. + +If this public capability does not exist when implementation begins, it is an +upstream EdgeZero prerequisite, not a reason to implement platform-specific +writes in `ts`. + +--- + +## 9. App-config environment variables + +Trusted Server app config does not support environment overrides in this design. + +Removed / unsupported: + +```text +TRUSTED_SERVER__PUBLISHER__DOMAIN=... +TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=true +``` + +No build-time env merge, push-time env overlay, or runtime env overlay applies +to app settings. + +Environment variables remain valid for EdgeZero platform/runtime wiring only: + +```text +EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=... +EDGEZERO__ADAPTER__... +EDGEZERO__LOGGING__... +``` + +This keeps config hashes explainable: the hash is derived only from the local +config file's validated settings value. + +--- + +## 10. Error behavior and exit codes + +| Exit code | Meaning | +| --------- | ------------------------------ | +| `0` | Command completed successfully | +| `1` | Command failed | + +Initial `ts` commands do not need a special cancellation code because no command +is interactive. + +Failures with clear next steps should include hints: + +| Failure | Hint | +| ------------------------------------ | ---------------------------------------------------- | +| missing `trusted-server.toml` | run `ts config init` or pass `--config <path>` | +| invalid app config | fix reported field/schema errors | +| missing `edgezero.toml` during push | pass `--manifest <path>` or create EdgeZero manifest | +| EdgeZero push target missing | run `ts provision --adapter <adapter>` | +| adapter unsupported by EdgeZero push | use an adapter with config-store support | + +--- + +## 11. Security notes + +- `ts config push` does not write secret-store entries in this initial spec. +- Request-signing bootstrap is omitted until EdgeZero exposes secret-store write + primitives. +- Secret values must never be printed in logs, human output, dry-run output, or + future JSON output. +- If the active Trusted Server settings schema still contains literal secret + values in app config at implementation time, those values are written as + individual flattened config-store entries. This is accepted v1 behavior. + Secret-reference extraction/consolidation is a separate design track and + should be coordinated with EdgeZero secret-store write primitives before + production rollout where needed. +- Placeholder/default secrets must be rejected during validation/push using the + existing Trusted Server safety checks. + +--- + +## 12. Tests + +### 12.1 `config init` + +- writes `trusted-server.example.toml` contents to default path; +- writes custom `--config` path; +- creates parent directories; +- refuses overwrite without `--force`; +- overwrites with `--force`. + +### 12.2 `config validate` + +- accepts valid example config after replacing required placeholders as needed; +- rejects missing file with hint; +- rejects malformed TOML; +- rejects unknown fields; +- rejects semantic validation failures; +- rejects placeholder/default secrets; +- produces stable hash for reordered TOML input; +- `--json` success writes valid JSON and exits 0; +- `--json` failure writes valid JSON and exits non-zero. + +### 12.3 flattened config entries + +- nested objects flatten to escaped dotted keys; +- strings, booleans, numbers, arrays, and nulls follow EdgeZero flattening rules; +- arrays use canonical minified JSON with recursively sorted object keys; +- dynamic integration maps are stable; +- object/map keys containing `.` and `\` are escaped deterministically; +- escaped flattened keys can be split and unescaped without ambiguity; +- flattened entries are sorted before hashing; +- hash equals SHA-256 of the canonical settings-only entry map; +- metadata entries `ts-config-keys` and `ts-config-hash` are excluded from the + hash input. + +### 12.4 EdgeZero delegate commands + +Use a fake EdgeZero delegate implementation or test hook. Do not contact real +platforms in unit tests. + +- `ts auth login --adapter fastly` calls the EdgeZero auth login delegate with + the selected adapter; +- `ts auth status --adapter fastly` calls the EdgeZero auth status delegate; +- `ts auth logout --adapter fastly` calls the EdgeZero auth logout delegate; +- `ts provision --adapter fastly` calls the EdgeZero provision delegate; +- `ts serve --adapter fastly` calls the EdgeZero serve delegate; +- `ts build --adapter fastly` calls the EdgeZero build delegate; +- `ts deploy --adapter fastly` calls the EdgeZero deploy delegate; +- delegate commands forward supported args/options without Trusted + Server-specific platform translation; +- delegate commands surface missing/unsupported adapter errors from EdgeZero + clearly. + +### 12.5 `config push` + +Use a fake EdgeZero push implementation or test hook. Do not contact real +platforms in unit tests. + +- validates before pushing; +- passes flattened settings entries plus `ts-config-keys` and `ts-config-hash`; +- defaults `--store` to `app_config`; +- forwards `--adapter`, `--manifest`, `--store`, `--local`, `--dry-run`, and + `--runtime-config` to EdgeZero push layer; +- `--dry-run` performs no mutation; +- does not write secret-store entries; +- does not print full config values by default. + +--- + +## 13. Implementation sequencing + +The full implementation plan is maintained in: + +```text +docs/superpowers/plans/2026-06-16-edgezero-based-ts-cli-implementation-plan.md +``` + +Required sequencing: + +1. Start by switching this repository to the target EdgeZero PR #269 branch/rev + and verifying the required EdgeZero APIs. +2. Add the host-target `ts` CLI crate and testable runner/delegate boundaries. +3. Implement strict Trusted Server config parsing, deterministic escaping, + flattening, hashing, and local `config init|validate` behavior. +4. Implement EdgeZero lifecycle delegation and config push using EdgeZero APIs. +5. Align repository file ownership with this spec by removing build-time config + embedding, adding the EdgeZero manifest/template files, and ignoring + operator-owned `trusted-server.toml`. +6. Update docs and run the repository verification gates. + +--- + +## 14. Open follow-ups outside this spec + +- Runtime config-store spec: runtime reads flattened `app_config` entries, + reconstructs Trusted Server settings, computes/compares hash metadata, and + `/health` fails when config is invalid. +- EdgeZero wishlist: secret-store write primitive, public flatten/push entry API + if the current config push internals are not reusable, and JSON output for + push/provision. +- Request-signing bootstrap spec after EdgeZero secret writes exist. +- Trusted Server audit CLI implementation is specified separately in + `docs/superpowers/specs/2026-06-16-edgezero-based-ts-audit-design.md`. +- Secret-reference/config-secret consolidation spec if literal secrets should be + removed from flattened config-store entries before production rollout. diff --git a/edgezero.toml b/edgezero.toml new file mode 100644 index 000000000..d016ee67a --- /dev/null +++ b/edgezero.toml @@ -0,0 +1,25 @@ +[app] +name = "trusted-server" + +[adapters.fastly.adapter] +manifest = "fastly.toml" + +[adapters.fastly.commands] +auth-login = "fastly profile create" +auth-logout = "fastly profile delete" +auth-status = "fastly profile list" +build = "cargo build --bin trusted-server-adapter-fastly --release --target wasm32-wasip1 --color always" +serve = "fastly compute serve" +deploy = "fastly compute publish" + +[stores.config] +ids = ["app_config"] +default = "app_config" + +[stores.secrets] +ids = ["secrets"] +default = "secrets" + +[stores.kv] +ids = ["ec_identity_store"] +default = "ec_identity_store" diff --git a/trusted-server.example.toml b/trusted-server.example.toml new file mode 100644 index 000000000..0e8226efb --- /dev/null +++ b/trusted-server.example.toml @@ -0,0 +1,129 @@ +[[handlers]] +path = "^/_ts/admin" +username = "admin" +password = "replace-with-admin-password-32-bytes" + +[publisher] +domain = "example.com" +cookie_domain = ".example.com" +origin_url = "https://origin.example.com" +# Optional: override outbound Host header while connecting to origin_url. +# origin_host_header_override = "www.example.com" +proxy_secret = "change-me-proxy-secret" + +[ec] +passphrase = "trusted-server-placeholder-secret" +ec_store = "ec_identity_store" +pull_sync_concurrency = 3 +# cluster_trust_threshold = 10 +# cluster_recheck_secs = 3600 + +# Example partner configuration. Replace the token before validating/pushing. +# [[ec.partners]] +# name = "Example Partner" +# source_domain = "partner.example.com" +# openrtb_atype = 3 +# bidstream_enabled = true +# api_token = "replace-with-partner-api-token-32-bytes-minimum" +# batch_rate_limit = 60 +# pull_sync_enabled = false + +# Custom headers to include in every response. +# [response_headers] +# X-Robots-Tag = "noindex" + +[request_signing] +enabled = false +config_store_id = "app_config" +secret_store_id = "secrets" + +[integrations.prebid] +enabled = false +server_url = "https://prebid.example.com/openrtb2/auction" +timeout_ms = 1000 +bidders = [] +debug = false +client_side_bidders = [] + +[integrations.nextjs] +enabled = false +rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] +max_combined_payload_bytes = 10485760 + +[integrations.testlight] +enabled = false +endpoint = "https://testlight.example.com/openrtb2/auction" +timeout_ms = 1200 +rewrite_scripts = true + +[integrations.didomi] +enabled = false +sdk_origin = "https://sdk.example.com" +api_origin = "https://api.example.com" + +[integrations.sourcepoint] +enabled = false +rewrite_sdk = true +cdn_origin = "https://cdn.example.com" +cache_ttl_seconds = 3600 + +[integrations.permutive] +enabled = false +organization_id = "" +workspace_id = "" +project_id = "" +api_endpoint = "https://api.example.com" +secure_signals_endpoint = "https://secure-signals.example.com" + +[integrations.lockr] +enabled = false +app_id = "" +api_endpoint = "https://identity.example.com" +sdk_url = "https://identity.example.com/trusted-server.js" +cache_ttl_seconds = 3600 +rewrite_sdk = true + +[integrations.datadome] +enabled = false +sdk_origin = "https://sdk.example.com" +api_origin = "https://api.example.com" +cache_ttl_seconds = 3600 +rewrite_sdk = true + +[integrations.gpt] +enabled = false +script_url = "https://ads.example.com/gpt.js" +cache_ttl_seconds = 3600 +rewrite_script = true + +[proxy] +# certificate_check = true +# allowed_domains = ["ads.example.com", "*.cdn.example.com"] + +[auction] +enabled = false +providers = [] +timeout_ms = 2000 +allowed_context_keys = [] + +[integrations.aps] +enabled = false +pub_id = "your-aps-publisher-id" +endpoint = "https://aps.example.com/e/dtb/bid" +timeout_ms = 1000 + +[integrations.google_tag_manager] +enabled = false +container_id = "GTM-EXAMPLE" +upstream_url = "https://tags.example.com" + +[integrations.adserver_mock] +enabled = false +endpoint = "https://adserver.example.com/mediate" +timeout_ms = 1000 + +[integrations.adserver_mock.context_query_params] +example_segments = "segments" + +[debug] +ja4_endpoint_enabled = false diff --git a/trusted-server.toml b/trusted-server.toml deleted file mode 100644 index 8ff95361d..000000000 --- a/trusted-server.toml +++ /dev/null @@ -1,336 +0,0 @@ -[[handlers]] -path = "^/secure" -username = "user" -password = "pass" - -[[handlers]] -path = "^/_ts/admin" -username = "admin" -password = "replace-with-admin-password-32-bytes" - -[publisher] -domain = "test-publisher.com" -cookie_domain = ".test-publisher.com" -origin_url = "https://origin.test-publisher.com" -# Optional: override outbound Host header while connecting to origin_url. -# origin_host_header_override = "www.example.com" -proxy_secret = "change-me-proxy-secret" - -[ec] -passphrase = "local-dev-passphrase-32-bytes-min" -ec_store = "ec_identity_store" -pull_sync_concurrency = 3 -# cluster_trust_threshold = 10 # Entries with cluster_size <= this are individual users -# cluster_recheck_secs = 3600 # Re-evaluate cluster_size after this many seconds - -# [[ec.partners]] -# name = "LiveRamp" -# source_domain = "liveramp.com" -# openrtb_atype = 3 -# bidstream_enabled = true -# api_token = "partner-api-token-32-bytes-minimum" -# batch_rate_limit = 60 -# pull_sync_enabled = false - -# Configure real partners via private build-time config or environment -# overrides. Do not commit deployable partner API tokens in this placeholder -# config; the integration-test partners are injected by test scripts. -# -# [[ec.partners]] -# name = "Prebid SharedID" -# source_domain = "sharedid.org" -# openrtb_atype = 1 -# bidstream_enabled = true -# api_token = "replace-with-partner-api-token-32-bytes-minimum" - -# Custom headers to be included in every response -# Allows publishers to include tags such as X-Robots-Tag: noindex -# [response_headers] -# X-Custom-Header = "custom header value" -# -# Or via environment variable (JSON preserves header name casing and hyphens): -# TRUSTED_SERVER__RESPONSE_HEADERS='{"X-Robots-Tag": "noindex", "X-Custom-Header": "custom value"}' - -# Request Signing Configuration -# Enable signing of OpenRTB requests and other API calls -[request_signing] -enabled = false # Set to true to enable request signing -config_store_id = "<fastly-config-store-id>" # set config/secret store ids for key rotation -secret_store_id = "<fastly-secret-store-id>" - -[integrations.prebid] -enabled = true -server_url = "http://68.183.113.79:8000" -timeout_ms = 1000 -bidders = ["kargo", "appnexus", "openx"] -debug = false -# test_mode = false -# debug_query_params = "" -# script_patterns = ["/prebid.js"] - -# Bidders that run client-side via native Prebid.js adapters instead of -# being routed through the server-side auction. Their adapter modules must -# be statically imported in the JS bundle. -client_side_bidders = [] - -# Compatibility sugar for static per-bidder params merged into every outgoing -# PBS request. These normalize into bid_param_override_rules internally. -# Example: -# [integrations.prebid.bid_param_overrides.bidder-name] -# param1 = 12345 -# param2 = "value" - -# Compatibility sugar for zone-specific bid param overrides. -# The JS adapter reads the zone from mediaTypes.banner.name on each ad unit and -# includes it in the request. These normalize into bid_param_override_rules -# internally. -# [integrations.prebid.bid_param_zone_overrides.kargo] -# header = {placementId = "_abc"} - -# Preferred canonical override format for future rules. -# Rules run in order with exact-match conditions and shallow last-write-wins merge. -# [[integrations.prebid.bid_param_override_rules]] -# when.bidder = "kargo" -# when.zone = "header" -# set = { placementId = "_abc" } - -[integrations.nextjs] -enabled = false -rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] -# Maximum combined payload size for cross-script RSC processing (bytes). Default is 10 MB. -max_combined_payload_bytes = 10485760 - -[integrations.testlight] -endpoint = "https://testlight.example/openrtb2/auction" -timeout_ms = 1200 -rewrite_scripts = true - -[integrations.didomi] -enabled = false -sdk_origin = "https://sdk.privacy-center.org" -api_origin = "https://api.privacy-center.org" - -[integrations.sourcepoint] -enabled = false -rewrite_sdk = true -cdn_origin = "https://cdn.privacy-mgmt.com" -# Optional: forward a custom Sourcepoint authCookie name upstream. -# auth_cookie_name = "sp_auth" -cache_ttl_seconds = 3600 - -[integrations.permutive] -enabled = false -organization_id = "" -workspace_id = "" -project_id = "" -api_endpoint = "https://api.permutive.com" -secure_signals_endpoint = "https://secure-signals.permutive.app" - -[integrations.lockr] -enabled = false -app_id = "" -api_endpoint = "https://identity.loc.kr" -sdk_url = "https://aim.loc.kr/identity-lockr-trust-server.js" -cache_ttl_seconds = 3600 -rewrite_sdk = true - -# DataDome bot protection integration -# Proxies tags.js and signal collection API through first-party context -# Endpoints: -# GET /integrations/datadome/tags.js - Proxied SDK script -# ANY /integrations/datadome/js/* - Signal collection API -[integrations.datadome] -enabled = false -sdk_origin = "https://js.datadome.co" -api_origin = "https://api-js.datadome.co" -cache_ttl_seconds = 3600 -rewrite_sdk = true - -[integrations.gpt] -enabled = false -script_url = "https://securepubads.g.doubleclick.net/tag/js/gpt.js" -cache_ttl_seconds = 3600 -rewrite_script = true - -# Consent forwarding configuration -# Controls how Trusted Server interprets and forwards privacy consent signals. -# All values shown below are the defaults — uncomment to override. -# [consent] -# mode = "interpreter" # "interpreter" (decode + forward) or "proxy" (raw passthrough) -# check_expiration = true # Check TCF consent freshness -# max_consent_age_days = 395 # Max age before consent is treated as expired (~13 months) - -# [consent.gdpr] -# applies_in = ["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE","IS","LI","NO","GB"] - -# [consent.us_states] -# privacy_states = ["CA","VA","CO","CT","UT","MT","OR","TX","FL","DE","IA","NE","NH","NJ","TN","MN","MD","IN","KY","RI"] - -# [consent.us_privacy_defaults] -# notice_given = true # Has publisher actually shown CCPA notice? -# lspa_covered = false # Is publisher subject to LSPA? -# gpc_implies_optout = true # Should Sec-GPC: 1 trigger opt-out? - -# [consent.conflict_resolution] -# mode = "restrictive" # "restrictive" | "newest" | "permissive" -# freshness_threshold_days = 30 - -# Consent is interpreted from request cookies, headers, geolocation, and these -# policy settings. EC identity lifecycle state and withdrawal tombstones are -# stored in the KV store configured by [ec].ec_store. - -# Rewrite configuration for creative HTML/CSS processing -# [rewrite] -# Domains to exclude from first-party rewriting (supports wildcards like "*.example.com") -# URLs from these domains will be left as-is and not proxied -# exclude_domains = [ -# "*.edgecompute.app", -# ] - -# Reusable Fastly Image Optimizer profile sets for asset routes. -# Keep production/customer-specific profile names and tables in private deployment config. -# Profile values intentionally support a strict subset of IO params: quality, -# resize-filter, format, width, height, and crop. Client query parameters are -# mapped through this table instead of being passed through as arbitrary IO options. -# [image_optimizer.profile_sets.default_images] -# base_params = "quality=70&resize-filter=bicubic" -# default_profile = "default" -# unknown_profile = "use_default" # "use_default" or "reject" -# profile_param = "profile" -# aspect_ratio_param = "ar" -# debug_param = "_io_debug" # _io_debug=1 bypasses IO for one request -# -# [image_optimizer.profile_sets.default_images.profiles] -# default = "width=1920" -# thumbnail = "width=150&crop=1:1,smart" -# medium = "format=auto&width=828" -# large = "format=auto&width=1536" -# -# [image_optimizer.profile_sets.default_images.aspect_ratios] -# allowed = ["1-1", "16-9", "4-3"] -# profiles = ["medium", "large"] -# -# [image_optimizer.profile_sets.default_images.crop_offsets] -# enabled = true -# x_param = "x" -# y_param = "y" -# buckets = [10, 30, 50, 70, 90] -# default = 50 -# when_missing = "smart" - -# Proxy configuration -[proxy] -# Enable TLS certificate verification when proxying to HTTPS origins. -# Defaults to true. Set to false only for local development with self-signed certificates. -# certificate_check = true - -# Configure first-party asset paths that should proxy to a different backend origin. -# Matching is path-prefix-based and the longest matching prefix wins. -# Include a trailing / unless you intentionally want /static to also match paths such as /staticfile.js. -# Only GET/HEAD requests participate. Built-in and integration routes still take precedence. -# Trusted Server preserves the incoming query string. By default it also preserves -# the incoming path, but path_pattern/target_path can generically rewrite paths -# before sending them upstream. -# -# [[proxy.asset_routes]] -# prefix = "/.images/" -# origin_url = "https://some.fastly-service.example.com" -# -# Example: private S3 origin with Fastly IO profile-table conversion. -# [[proxy.asset_routes]] -# prefix = "/.image/" -# origin_url = "https://bucket.s3.us-east-1.amazonaws.com" -# -# [proxy.asset_routes.auth] -# type = "s3_sigv4" -# region = "us-east-1" -# origin_query = "strip" # Strip transform query params before S3 signing -# secret_store = "s3-auth" -# access_key_id = "access_key_id" -# secret_access_key = "secret_access_key" -# # session_token = "session_token" -# -# [proxy.asset_routes.image_optimizer] -# enabled = true -# region = "us_east" -# profile_set = "default_images" -# # Enabled IO routes strip origin queries by default. origin_query = "preserve" -# # is rejected while IO is enabled because Fastly can treat query params as transforms. -# -# Example: CDN-style first-party image path rewrite. -# [[proxy.asset_routes]] -# prefix = "/.image/" -# origin_url = "https://assets-cdn.example.com" -# path_pattern = "^/\\.image/(.*)/[^/]+\\.([^/.]+)$" -# target_path = "/image/upload/$1.$2" -# -# Example: shared static assets stored under an upstream /_network prefix. -# [[proxy.asset_routes]] -# prefix = "/_next/static/" -# origin_url = "https://static-assets.example.com" -# path_pattern = "^(.*)$" -# target_path = "/_network$1" -# -# Restrict redirect destinations for the first-party proxy to an explicit domain allowlist. -# Supports exact match ("example.com") and subdomain wildcard prefix ("*.example.com"). -# Wildcard prefix also matches the apex domain ("*.example.com" matches "example.com"). -# Matching is case-insensitive. A dot-boundary check prevents "*.example.com" from -# matching "evil-example.com". -# When omitted or empty, redirect destinations are unrestricted — configure this in -# production to prevent SSRF via signed URLs that redirect to internal services. -# Note: this list governs only the first-party proxy redirect chain, not integration -# endpoints defined under [integrations.*]. -# allowed_domains = [ - # "ad.example.com", - # "*.doubleclick.net", - # "*.googlesyndication.com", -# ] - -[auction] -enabled = true -providers = ["prebid"] -# mediator = "adserver_mock" # will use mediator when set -timeout_ms = 2000 -# Context keys the JS client is allowed to forward into auction requests. -# Keys not in this list are silently dropped. An empty list blocks all keys. -allowed_context_keys = ["permutive_segments"] - -[integrations.aps] -enabled = false -pub_id = "your-aps-publisher-id" -endpoint = "https://origin-mocktioneer.cdintel.com/e/dtb/bid" -timeout_ms = 1000 - -[integrations.google_tag_manager] -enabled = false -container_id = "GTM-XXXXXX" -# upstream_url = "https://www.googletagmanager.com" - -[integrations.adserver_mock] -enabled = false -endpoint = "https://origin-mocktioneer.cdintel.com/adserver/mediate" -timeout_ms = 1000 - -# Debug configuration (all flags default to false — do not enable in production) -# [debug] -# Enable the JA4/TLS fingerprint debug endpoint at GET /_ts/debug/ja4. -# Returns a plain-text response with the following fields (Fastly-observed values): -# ja4 — JA4 TLS client fingerprint -# h2_fp — HTTP/2 client fingerprint -# cipher — TLS cipher suite (OpenSSL name) -# tls_version — TLS protocol version -# user-agent — User-Agent request header -# ch-mobile — Sec-CH-UA-Mobile client hint -# ch-platform — Sec-CH-UA-Platform client hint -# Fastly TLS/fingerprint fields fall back to "unavailable"; client hints fall back -# to "not sent"; user-agent falls back to "none" when absent. -# Response always carries Cache-Control: no-store, private. -# IMPORTANT: This endpoint reflects TLS details that browser JS cannot normally read. -# Disable after investigation is complete. -# ja4_endpoint_enabled = false - -# Map auction-request context keys to mediation URL query parameters. -# Each key is a context key from the JS client; the value becomes the -# query parameter name. Arrays are joined with commas. -[integrations.adserver_mock.context_query_params] -permutive_segments = "permutive" From 14a91cc1224dfa3637cd8feaed2985824652cfad Mon Sep 17 00:00:00 2001 From: Christian <christianpavilonis@outlook.com> Date: Wed, 17 Jun 2026 12:40:59 -0500 Subject: [PATCH 2/2] Add Trusted Server audit command --- .gitignore | 1 + Cargo.lock | 407 ++++++++- Cargo.toml | 2 + README.md | 16 +- crates/trusted-server-cli/Cargo.toml | 8 + crates/trusted-server-cli/src/args.rs | 83 ++ crates/trusted-server-cli/src/audit.rs | 685 +++++++++++++++ .../trusted-server-cli/src/audit/analyzer.rs | 547 ++++++++++++ .../src/audit/browser_collector.rs | 353 ++++++++ .../trusted-server-cli/src/audit/collector.rs | 43 + .../trusted-server-cli/src/config_command.rs | 2 +- crates/trusted-server-cli/src/lib.rs | 2 + crates/trusted-server-cli/src/run.rs | 130 ++- docs/.vitepress/config.mts | 1 + docs/guide/cli.md | 77 ++ docs/guide/getting-started.md | 22 +- ...zero-based-ts-audit-implementation-plan.md | 820 ++++++++++++++++++ ...2026-06-16-edgezero-based-ts-cli-design.md | 10 +- 18 files changed, 3170 insertions(+), 39 deletions(-) create mode 100644 crates/trusted-server-cli/src/audit.rs create mode 100644 crates/trusted-server-cli/src/audit/analyzer.rs create mode 100644 crates/trusted-server-cli/src/audit/browser_collector.rs create mode 100644 crates/trusted-server-cli/src/audit/collector.rs create mode 100644 docs/guide/cli.md create mode 100644 docs/superpowers/plans/2026-06-16-edgezero-based-ts-audit-implementation-plan.md diff --git a/.gitignore b/.gitignore index 5f91df390..901c9ce15 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ # env .env* trusted-server.toml +js-assets.toml # backup **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock index 83c1565d2..e11f27bb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async-tungstenite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc405d38be14342132609f06f02acaf825ddccfe76c4824a69281e0458ebd4" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tokio", + "tungstenite", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -361,11 +378,20 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] [[package]] name = "cast" @@ -421,6 +447,71 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chromiumoxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ed067eb6c1f660bdb87c05efb964421d2ca262bae0296cdfe38cf0cd949a3e" +dependencies = [ + "async-tungstenite", + "base64", + "bytes", + "chromiumoxide_cdp", + "chromiumoxide_types", + "dunce", + "fnv", + "futures", + "futures-timer", + "pin-project-lite", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "which", + "windows-registry", +] + +[[package]] +name = "chromiumoxide_cdp" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a6a03a7ebac4ea85308f285d6959a3e6b2ce32a0c9465dc7a7b1db0144eec7" +dependencies = [ + "chromiumoxide_pdl", + "chromiumoxide_types", + "serde", + "serde_json", +] + +[[package]] +name = "chromiumoxide_pdl" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c602dea92337bc4d824668d78c5b79c3b4ddb29b40dd7218282bbe8fd3fc2091" +dependencies = [ + "chromiumoxide_types", + "either", + "heck", + "once_cell", + "proc-macro2", + "quote", + "regex", + "serde_json", +] + +[[package]] +name = "chromiumoxide_types" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678d5146e74f16fc4a41978b275af572cd913de1f10270d2b93b6c276bc57d80" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "chrono" version = "0.4.44" @@ -735,6 +826,19 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.11.3", + "smallvec", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -744,7 +848,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.13.1", "smallvec", ] @@ -830,6 +934,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "der" version = "0.7.10" @@ -1173,6 +1283,12 @@ dependencies = [ "validator", ] +[[package]] +name = "ego-tree" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8" + [[package]] name = "either" version = "1.15.0" @@ -1468,6 +1584,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.32" @@ -1539,6 +1665,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.32" @@ -1556,6 +1688,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1567,6 +1708,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1725,6 +1875,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + [[package]] name = "http" version = "1.4.0" @@ -2293,14 +2454,14 @@ checksum = "5ff94cb6aef6ee52afd2c69331e9109906d855e82bd241f3110dfdf6185899ab" dependencies = [ "bitflags 2.11.1", "cfg-if", - "cssparser", + "cssparser 0.36.0", "encoding_rs", "foldhash 0.2.0", "hashbrown 0.16.1", "memchr", "mime", "precomputed-hash", - "selectors", + "selectors 0.33.0", "thiserror 2.0.18", ] @@ -2310,6 +2471,34 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "matchit" version = "0.8.4" @@ -2595,25 +2784,55 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", ] [[package]] @@ -2623,7 +2842,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -2632,13 +2864,22 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -3205,6 +3446,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scraper" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5f3a24d916e78954af99281a455168d4a9515d65eca99a18da1b813689c4ad9" +dependencies = [ + "cssparser 0.35.0", + "ego-tree", + "getopts", + "html5ever", + "precomputed-hash", + "selectors 0.31.0", + "tendril", +] + [[package]] name = "sec1" version = "0.7.3" @@ -3241,6 +3497,25 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5685b6ae43bfcf7d2e7dfcfb5d8e8f61b46442c902531e41a32a9a8bf0ee0fb6" +dependencies = [ + "bitflags 2.11.1", + "cssparser 0.35.0", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf 0.11.3", + "phf_codegen 0.11.3", + "precomputed-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "selectors" version = "0.33.0" @@ -3248,12 +3523,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "feef350c36147532e1b79ea5c1f3791373e61cbd9a6a2615413b3807bb164fb7" dependencies = [ "bitflags 2.11.1", - "cssparser", + "cssparser 0.36.0", "derive_more", "log", "new_debug_unreachable", - "phf", - "phf_codegen", + "phf 0.13.1", + "phf_codegen 0.13.1", "precomputed-hash", "rustc-hash", "servo_arc", @@ -3373,6 +3648,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.9.9" @@ -3507,6 +3793,31 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3595,6 +3906,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3914,19 +4236,26 @@ dependencies = [ name = "trusted-server-cli" version = "0.1.0" dependencies = [ + "chromiumoxide", "clap", "derive_more", "edgezero-adapter", "edgezero-cli", "edgezero-core", "error-stack", + "futures", "log", + "regex", + "scraper", "serde", "serde_json", "tempfile", + "tokio", "toml", "trusted-server-core", + "url", "validator", + "which", ] [[package]] @@ -4000,6 +4329,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -4030,6 +4376,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -4070,6 +4422,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4287,6 +4645,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-root-certs" version = "1.0.7" @@ -4355,6 +4725,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 0829fe97c..1607ccb7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ build-print = "1.0.1" bytes = "1.11" chacha20poly1305 = "0.10" chrono = "0.4.44" +chromiumoxide = "0.9.1" clap = { version = "4", features = ["derive"] } config = "0.15.19" cookie = "0.18.1" @@ -81,6 +82,7 @@ matchit = "0.9" mime = "0.3" rand = "0.8" regex = "1.12.3" +scraper = "0.24.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.149" sha2 = "0.10.9" diff --git a/README.md b/README.md index 82dfe7b56..ab73a49c6 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,20 @@ The guide in `docs/guide/` (published at the link below) is the source of truth See the [Getting Started guide](https://iabtechlab.github.io/trusted-server/guide/getting-started) for installation and setup instructions. ```bash -# Build +# Build the runtime cargo build -# Run tests -cargo test +# Build the host-target CLI +HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" +cargo build --package trusted-server-cli --target "$HOST_TARGET" + +# Create local config, then edit placeholders before validation +ts config init +# Edit trusted-server.toml +ts config validate -# Start local server -fastly compute serve +# Audit a public page with Chrome/Chromium to bootstrap a draft config +ts audit https://publisher.example ``` ## Development diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml index 4509cf863..98c9a55e0 100644 --- a/crates/trusted-server-cli/Cargo.toml +++ b/crates/trusted-server-cli/Cargo.toml @@ -14,18 +14,26 @@ path = "src/main.rs" workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +chromiumoxide = { workspace = true } clap = { workspace = true } derive_more = { workspace = true } edgezero-adapter = { workspace = true, features = ["cli"] } edgezero-cli = { workspace = true } edgezero-core = { workspace = true } error-stack = { workspace = true } +futures = { workspace = true } log = { workspace = true } +regex = { workspace = true } +scraper = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true } toml = { workspace = true } trusted-server-core = { workspace = true } +url = { workspace = true } validator = { workspace = true } +which = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] tempfile = { workspace = true } diff --git a/crates/trusted-server-cli/src/args.rs b/crates/trusted-server-cli/src/args.rs index 01f114466..670fe8351 100644 --- a/crates/trusted-server-cli/src/args.rs +++ b/crates/trusted-server-cli/src/args.rs @@ -11,6 +11,8 @@ pub struct Args { #[derive(Debug, Subcommand)] pub enum Command { + /// Audit a public page and write draft Trusted Server artifacts. + Audit(AuditArgs), /// Sign in / out / status against an `EdgeZero` adapter. Auth(AuthArgs), /// Build the project for a target adapter. @@ -26,6 +28,27 @@ pub enum Command { Serve(DelegateArgs), } +#[derive(Debug, clap::Args)] +pub struct AuditArgs { + /// Public HTTP(S) URL to audit. + pub url: String, + /// JavaScript asset audit output path. + #[arg(long)] + pub js_assets: Option<PathBuf>, + /// Draft Trusted Server config output path. + #[arg(long)] + pub config: Option<PathBuf>, + /// Do not write the JavaScript asset audit file. + #[arg(long)] + pub no_js_assets: bool, + /// Do not write the draft Trusted Server config file. + #[arg(long)] + pub no_config: bool, + /// Overwrite existing output files. + #[arg(long)] + pub force: bool, +} + #[derive(Debug, clap::Args)] pub struct AuthArgs { #[command(subcommand)] @@ -121,6 +144,66 @@ pub struct ConfigPushArgs { mod tests { use super::*; + #[test] + fn parses_audit_with_default_outputs() { + let args = Args::try_parse_from(["ts", "audit", "https://publisher.example"]) + .expect("should parse audit command"); + let Command::Audit(audit) = args.command else { + panic!("expected audit command"); + }; + assert_eq!(audit.url, "https://publisher.example"); + assert_eq!(audit.js_assets, None); + assert_eq!(audit.config, None); + assert!(!audit.no_js_assets); + assert!(!audit.no_config); + assert!(!audit.force); + } + + #[test] + fn parses_audit_with_custom_outputs() { + let args = Args::try_parse_from([ + "ts", + "audit", + "https://publisher.example", + "--js-assets", + "audit/js-assets.toml", + "--config", + "audit/trusted-server.toml", + "--no-js-assets", + "--no-config", + "--force", + ]) + .expect("should parse audit command"); + let Command::Audit(audit) = args.command else { + panic!("expected audit command"); + }; + assert_eq!(audit.js_assets, Some(PathBuf::from("audit/js-assets.toml"))); + assert_eq!( + audit.config, + Some(PathBuf::from("audit/trusted-server.toml")) + ); + assert!(audit.no_js_assets); + assert!(audit.no_config); + assert!(audit.force); + } + + #[test] + fn audit_does_not_accept_adapter_option() { + let error = Args::try_parse_from([ + "ts", + "audit", + "https://publisher.example", + "--adapter", + "fastly", + ]) + .expect_err("should reject audit adapter option"); + assert!( + error.to_string().contains("unexpected argument") + || error.to_string().contains("Found argument"), + "error should explain unsupported option" + ); + } + #[test] fn parses_build_with_passthrough_args() { let args = Args::try_parse_from([ diff --git a/crates/trusted-server-cli/src/audit.rs b/crates/trusted-server-cli/src/audit.rs new file mode 100644 index 000000000..76ee62c99 --- /dev/null +++ b/crates/trusted-server-cli/src/audit.rs @@ -0,0 +1,685 @@ +mod analyzer; +pub(crate) mod browser_collector; +pub(crate) mod collector; + +use std::collections::BTreeSet; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use url::Url; + +use crate::args::AuditArgs; +use crate::audit::collector::AuditCollector; +use crate::config_command::EXAMPLE_CONFIG; +use crate::error::{cli_error, report_error, CliResult}; + +use analyzer::{analyze_collected_page, extract_gtm_container_id}; + +const DEFAULT_JS_ASSETS_PATH: &str = "js-assets.toml"; +const DEFAULT_CONFIG_PATH: &str = "trusted-server.toml"; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum AssetParty { + FirstParty, + ThirdParty, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct AuditedAsset { + pub(crate) kind: String, + pub(crate) url: String, + pub(crate) host: String, + pub(crate) party: AssetParty, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) integration: Option<String>, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct DetectedIntegration { + pub(crate) id: String, + pub(crate) evidence: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct AuditArtifact { + pub(crate) audited_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) page_title: Option<String>, + pub(crate) js_asset_count: usize, + pub(crate) third_party_asset_count: usize, + pub(crate) detected_integrations: Vec<DetectedIntegration>, + pub(crate) assets: Vec<AuditedAsset>, + pub(crate) warnings: Vec<String>, +} + +#[derive(Debug, Clone)] +pub(crate) struct AuditOutputs { + pub(crate) artifact: AuditArtifact, + pub(crate) js_assets_toml: String, + pub(crate) draft_config_toml: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AuditOutputPlan { + js_assets_path: Option<PathBuf>, + config_path: Option<PathBuf>, +} + +pub(crate) fn run_audit( + args: &AuditArgs, + collector: &dyn AuditCollector, + out: &mut dyn Write, +) -> CliResult<()> { + let target_url = parse_audit_url(&args.url)?; + let plan = resolve_output_plan(args)?; + let collected = collector.collect_page(&target_url)?; + let outputs = build_audit_outputs(&collected)?; + let wrote_config = plan.config_path.is_some(); + let written = write_audit_outputs(&outputs, &plan)?; + write_success_summary(&outputs, &written, wrote_config, out) +} + +fn parse_audit_url(value: &str) -> CliResult<Url> { + let url = Url::parse(value) + .map_err(|error| report_error(format!("invalid audit URL `{value}`: {error}")))?; + if !matches!(url.scheme(), "http" | "https") { + return cli_error(format!( + "`ts audit` only supports http/https URLs, got `{}`", + url.scheme() + )); + } + Ok(url) +} + +fn resolve_output_plan(args: &AuditArgs) -> CliResult<AuditOutputPlan> { + if args.no_js_assets && args.no_config { + return cli_error("nothing to do: both --no-js-assets and --no-config were set"); + } + + let js_assets_path = if args.no_js_assets { + None + } else { + Some(resolve_output_path( + args.js_assets.as_deref(), + DEFAULT_JS_ASSETS_PATH, + )?) + }; + let config_path = if args.no_config { + None + } else { + Some(resolve_output_path( + args.config.as_deref(), + DEFAULT_CONFIG_PATH, + )?) + }; + + if js_assets_path.is_some() && js_assets_path == config_path { + return cli_error("audit output paths must be distinct"); + } + + for path in [&js_assets_path, &config_path].into_iter().flatten() { + if path.exists() && !args.force { + return cli_error(format!( + "refusing to overwrite existing file `{}`; re-run with --force", + path.display() + )); + } + } + + Ok(AuditOutputPlan { + js_assets_path, + config_path, + }) +} + +fn resolve_output_path(path: Option<&Path>, default: &str) -> CliResult<PathBuf> { + let candidate = path.unwrap_or_else(|| Path::new(default)); + if candidate.is_absolute() { + Ok(candidate.to_path_buf()) + } else { + Ok(std::env::current_dir() + .map_err(|error| report_error(format!("failed to read current directory: {error}")))? + .join(candidate)) + } +} + +fn build_audit_outputs(collected: &collector::CollectedPage) -> CliResult<AuditOutputs> { + let artifact = analyze_collected_page(collected)?; + let final_url = collected + .final_url() + .map_err(|error| report_error(format!("invalid final URL: {error}")))?; + let js_assets_toml = toml::to_string_pretty(&artifact) + .map_err(|error| report_error(format!("failed to serialize audit artifact: {error}")))?; + let draft_config_toml = build_draft_config(&final_url, &artifact)?; + + Ok(AuditOutputs { + artifact, + js_assets_toml, + draft_config_toml, + }) +} + +fn write_audit_outputs(outputs: &AuditOutputs, plan: &AuditOutputPlan) -> CliResult<Vec<String>> { + let selected_paths = [&plan.js_assets_path, &plan.config_path] + .into_iter() + .flatten() + .collect::<Vec<_>>(); + for path in &selected_paths { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent).map_err(|error| { + report_error(format!( + "failed to create parent directory {}: {error}", + parent.display() + )) + })?; + } + } + + let mut written_paths = Vec::new(); + if let Some(path) = &plan.js_assets_path { + fs::write(path, &outputs.js_assets_toml).map_err(|error| { + report_error(format!( + "failed to write JS asset audit {}: {error}", + path.display() + )) + })?; + written_paths.push(path.display().to_string()); + } + if let Some(path) = &plan.config_path { + fs::write(path, &outputs.draft_config_toml).map_err(|error| { + report_error(format!( + "failed to write draft config {}: {error}", + path.display() + )) + })?; + written_paths.push(path.display().to_string()); + } + + Ok(written_paths) +} + +fn write_success_summary( + outputs: &AuditOutputs, + written: &[String], + wrote_config: bool, + out: &mut dyn Write, +) -> CliResult<()> { + let integrations = outputs + .artifact + .detected_integrations + .iter() + .map(|integration| integration.id.as_str()) + .collect::<Vec<_>>(); + let draft_note = if wrote_config { + "\nDraft config: review before validation and push" + } else { + "" + }; + writeln!( + out, + "Audited {}\nTitle: {}\nJS assets: {}\nThird-party assets: {}\nDetected integrations: {}\nWrote: {}{}", + outputs.artifact.audited_url, + outputs + .artifact + .page_title + .as_deref() + .unwrap_or("<unknown>"), + outputs.artifact.js_asset_count, + outputs.artifact.third_party_asset_count, + if integrations.is_empty() { + "none".to_string() + } else { + integrations.join(", ") + }, + if written.is_empty() { + "none".to_string() + } else { + written.join(", ") + }, + draft_note + ) + .map_err(|error| report_error(format!("failed to write command output: {error}"))) +} + +fn build_draft_config(target_url: &Url, artifact: &AuditArtifact) -> CliResult<String> { + let host = target_url + .host_str() + .ok_or_else(|| report_error("audited URL is missing a host"))?; + let origin = target_url.origin().ascii_serialization(); + let mut draft = EXAMPLE_CONFIG.to_string(); + + draft = replace_key_in_section( + &draft, + "publisher", + "domain", + &format!("domain = \"{host}\""), + )?; + draft = replace_key_in_section( + &draft, + "publisher", + "cookie_domain", + &format!("cookie_domain = \".{host}\""), + )?; + draft = replace_key_in_section( + &draft, + "publisher", + "origin_url", + &format!("origin_url = \"{origin}\""), + )?; + + let detected = artifact + .detected_integrations + .iter() + .map(|integration| integration.id.as_str()) + .collect::<BTreeSet<_>>(); + + if detected.contains("gpt") { + draft = replace_key_in_section(&draft, "integrations.gpt", "enabled", "enabled = true")?; + } + if detected.contains("didomi") { + draft = replace_key_in_section(&draft, "integrations.didomi", "enabled", "enabled = true")?; + } + if detected.contains("datadome") { + draft = + replace_key_in_section(&draft, "integrations.datadome", "enabled", "enabled = true")?; + } + + let mut manual_review = Vec::new(); + if detected.contains("google_tag_manager") { + if let Some(gtm_id) = extract_gtm_container_id(artifact) { + draft = replace_key_in_section( + &draft, + "integrations.google_tag_manager", + "enabled", + "enabled = true", + )?; + draft = replace_key_in_section( + &draft, + "integrations.google_tag_manager", + "container_id", + &format!("container_id = \"{gtm_id}\""), + )?; + } else { + manual_review.push("google_tag_manager"); + } + } + + for integration in detected { + if !matches!( + integration, + "gpt" | "didomi" | "datadome" | "google_tag_manager" + ) { + manual_review.push(integration); + } + } + + if !manual_review.is_empty() { + if !draft.ends_with('\n') { + draft.push('\n'); + } + draft.push_str("\n# Audit findings requiring manual review\n"); + for integration in manual_review { + draft.push_str(&format!( + "# - Detected {integration}; review the corresponding [integrations.{integration}] section before enabling it.\n" + )); + } + } + + Ok(draft) +} + +fn replace_key_in_section( + document: &str, + section: &str, + key: &str, + replacement_line: &str, +) -> CliResult<String> { + let section_header = format!("[{section}]"); + let mut in_section = false; + let mut replaced = false; + let mut saw_section = false; + let mut lines = Vec::new(); + + for line in document.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_section = trimmed == section_header; + saw_section |= in_section; + } + + if in_section && !replaced && is_key_line(trimmed, key) { + lines.push(replacement_line.to_string()); + replaced = true; + } else { + lines.push(line.to_string()); + } + } + + if !saw_section { + return cli_error(format!( + "failed to update starter config because section `{section_header}` was not found" + )); + } + if !replaced { + return cli_error(format!( + "failed to update starter config because key `{key}` was not found in `{section_header}`" + )); + } + + let mut output = lines.join("\n"); + if document.ends_with('\n') { + output.push('\n'); + } + Ok(output) +} + +fn is_key_line(trimmed_line: &str, key: &str) -> bool { + trimmed_line + .strip_prefix(key) + .and_then(|remaining| remaining.trim_start().strip_prefix('=')) + .is_some() +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use tempfile::TempDir; + + use super::*; + use crate::audit::collector::{CollectedPage, CollectedRequest, CollectedScriptTag}; + + struct FakeCollector { + collected: CollectedPage, + calls: Cell<usize>, + } + + impl FakeCollector { + fn new(collected: CollectedPage) -> Self { + Self { + collected, + calls: Cell::new(0), + } + } + } + + impl AuditCollector for FakeCollector { + fn collect_page(&self, _target_url: &Url) -> CliResult<CollectedPage> { + self.calls.set(self.calls.get() + 1); + Ok(self.collected.clone()) + } + } + + fn collected_page() -> CollectedPage { + CollectedPage { + requested_url: "https://publisher.example/page".to_string(), + final_url: "https://publisher.example/page".to_string(), + page_title: Some("Example Publisher".to_string()), + html: r#"<html><head><title>Example Publisher"#.to_string(), + script_tags: vec![CollectedScriptTag { + src: Some("https://www.googletagmanager.com/gtm.js?id=GTM-ABC123".to_string()), + inline_text: None, + }], + network_requests: vec![CollectedRequest { + url: "https://cdn.publisher.example/app.js".to_string(), + method: "GET".to_string(), + resource_type: Some("script".to_string()), + status: None, + }], + warnings: Vec::new(), + } + } + + fn audit_args(url: &str) -> AuditArgs { + AuditArgs { + url: url.to_string(), + js_assets: None, + config: None, + no_js_assets: false, + no_config: false, + force: false, + } + } + + #[test] + fn parse_audit_url_accepts_http_and_https() { + assert!(parse_audit_url("http://publisher.example").is_ok()); + assert!(parse_audit_url("https://publisher.example").is_ok()); + } + + #[test] + fn parse_audit_url_rejects_non_http_schemes() { + for url in [ + "file:///etc/passwd", + "data:text/html,hello", + "chrome://version", + ] { + let error = parse_audit_url(url).expect_err("should reject non-http URL"); + assert!( + format!("{error:?}").contains("only supports http/https"), + "should explain scheme restriction" + ); + } + } + + #[test] + fn resolve_output_plan_rejects_no_outputs() { + let mut args = audit_args("https://publisher.example"); + args.no_js_assets = true; + args.no_config = true; + + let error = resolve_output_plan(&args).expect_err("should reject empty output set"); + + assert!( + format!("{error:?}").contains("nothing to do"), + "should explain no-output error" + ); + } + + #[test] + fn resolve_output_plan_rejects_existing_files_without_force() { + let temp = TempDir::new().expect("should create temp dir"); + let path = temp.path().join("js-assets.toml"); + fs::write(&path, "existing").expect("should write existing file"); + let mut args = audit_args("https://publisher.example"); + args.js_assets = Some(path); + args.no_config = true; + + let error = resolve_output_plan(&args).expect_err("should reject overwrite"); + + assert!( + format!("{error:?}").contains("refusing to overwrite"), + "should explain overwrite refusal" + ); + } + + #[test] + fn resolve_output_plan_allows_existing_files_with_force() { + let temp = TempDir::new().expect("should create temp dir"); + let path = temp.path().join("js-assets.toml"); + fs::write(&path, "existing").expect("should write existing file"); + let mut args = audit_args("https://publisher.example"); + args.js_assets = Some(path.clone()); + args.no_config = true; + args.force = true; + + let plan = resolve_output_plan(&args).expect("should allow forced overwrite"); + + assert_eq!(plan.js_assets_path.as_deref(), Some(path.as_path())); + } + + #[test] + fn run_audit_writes_selected_outputs_and_summary() { + let temp = TempDir::new().expect("should create temp dir"); + let js_assets = temp.path().join("audit/js-assets.toml"); + let config = temp.path().join("audit/trusted-server.toml"); + let args = AuditArgs { + url: "https://publisher.example/page".to_string(), + js_assets: Some(js_assets.clone()), + config: Some(config.clone()), + no_js_assets: false, + no_config: false, + force: false, + }; + let collector = FakeCollector::new(collected_page()); + let mut out = Vec::new(); + + run_audit(&args, &collector, &mut out).expect("should run audit"); + + assert_eq!(collector.calls.get(), 1, "should collect page once"); + assert!(js_assets.exists(), "should write JS assets"); + assert!(config.exists(), "should write draft config"); + let summary = String::from_utf8(out).expect("summary should be UTF-8"); + assert!(summary.contains("Audited https://publisher.example/page")); + assert!(summary.contains("Detected integrations: google_tag_manager, gpt")); + assert!(summary.contains("Draft config: review before validation and push")); + } + + #[test] + fn run_audit_respects_no_config() { + let temp = TempDir::new().expect("should create temp dir"); + let js_assets = temp.path().join("js-assets.toml"); + let mut args = audit_args("https://publisher.example/page"); + args.js_assets = Some(js_assets.clone()); + args.no_config = true; + let collector = FakeCollector::new(collected_page()); + + run_audit(&args, &collector, &mut Vec::new()).expect("should run audit"); + + assert!(js_assets.exists(), "should write assets"); + assert!( + !temp.path().join("trusted-server.toml").exists(), + "should not write config" + ); + } + + #[test] + fn run_audit_respects_no_js_assets() { + let temp = TempDir::new().expect("should create temp dir"); + let config = temp.path().join("trusted-server.toml"); + let mut args = audit_args("https://publisher.example/page"); + args.config = Some(config.clone()); + args.no_js_assets = true; + let collector = FakeCollector::new(collected_page()); + let mut out = Vec::new(); + + run_audit(&args, &collector, &mut out).expect("should run audit"); + + assert!(config.exists(), "should write config"); + assert!( + !temp.path().join("js-assets.toml").exists(), + "should not write JS assets" + ); + let summary = String::from_utf8(out).expect("summary should be UTF-8"); + assert!(summary.contains("Draft config: review before validation and push")); + } + + #[test] + fn run_audit_writes_collector_warnings_to_asset_artifact() { + let temp = TempDir::new().expect("should create temp dir"); + let js_assets = temp.path().join("js-assets.toml"); + let mut args = audit_args("https://publisher.example/page"); + args.js_assets = Some(js_assets.clone()); + args.no_config = true; + let mut collected = collected_page(); + collected.warnings.push( + "browser audit timed out while waiting for the page to settle; results may be partial" + .to_string(), + ); + let collector = FakeCollector::new(collected); + + run_audit(&args, &collector, &mut Vec::new()).expect("should run audit"); + + let artifact = fs::read_to_string(js_assets).expect("should read artifact"); + assert!( + artifact.contains("results may be partial"), + "should persist collector warning" + ); + } + + #[test] + fn run_audit_conflict_prevents_collection() { + let temp = TempDir::new().expect("should create temp dir"); + let js_assets = temp.path().join("js-assets.toml"); + fs::write(&js_assets, "existing").expect("should write existing file"); + let mut args = audit_args("https://publisher.example/page"); + args.js_assets = Some(js_assets); + args.no_config = true; + let collector = FakeCollector::new(collected_page()); + + let error = run_audit(&args, &collector, &mut Vec::new()) + .expect_err("should reject existing output"); + + assert_eq!(collector.calls.get(), 0, "should not collect page"); + assert!( + format!("{error:?}").contains("refusing to overwrite"), + "should report overwrite conflict" + ); + } + + #[test] + fn build_draft_config_uses_final_url_and_detected_integrations() { + let url = Url::parse("https://www.publisher.example:8443/path").expect("should parse URL"); + let artifact = AuditArtifact { + audited_url: url.to_string(), + page_title: Some("Example".to_string()), + js_asset_count: 2, + third_party_asset_count: 2, + detected_integrations: vec![ + DetectedIntegration { + id: "google_tag_manager".to_string(), + evidence: "GTM-ABC123".to_string(), + }, + DetectedIntegration { + id: "gpt".to_string(), + evidence: "https://securepubads.g.doubleclick.net/tag/js/gpt.js".to_string(), + }, + DetectedIntegration { + id: "prebid".to_string(), + evidence: "inline script matched `prebid`".to_string(), + }, + ], + assets: Vec::new(), + warnings: Vec::new(), + }; + + let draft = build_draft_config(&url, &artifact).expect("should build draft config"); + + assert!(draft.contains("domain = \"www.publisher.example\"")); + assert!(draft.contains("cookie_domain = \".www.publisher.example\"")); + assert!(draft.contains("origin_url = \"https://www.publisher.example:8443\"")); + assert!(draft.contains("[integrations.gpt]\nenabled = true")); + assert!(draft.contains("[integrations.google_tag_manager]\nenabled = true")); + assert!(draft.contains("container_id = \"GTM-ABC123\"")); + assert!(draft.contains("Detected prebid")); + toml::from_str::(&draft).expect("draft should parse as TOML"); + } + + #[test] + fn build_draft_config_does_not_enable_gtm_without_container_id() { + let url = Url::parse("https://publisher.example/path").expect("should parse URL"); + let artifact = AuditArtifact { + audited_url: url.to_string(), + page_title: None, + js_asset_count: 1, + third_party_asset_count: 1, + detected_integrations: vec![DetectedIntegration { + id: "google_tag_manager".to_string(), + evidence: "https://www.googletagmanager.com/gtm.js".to_string(), + }], + assets: Vec::new(), + warnings: Vec::new(), + }; + + let draft = build_draft_config(&url, &artifact).expect("should build draft config"); + + assert!(draft.contains("[integrations.google_tag_manager]\nenabled = false")); + assert!(draft.contains("Detected google_tag_manager")); + } +} diff --git a/crates/trusted-server-cli/src/audit/analyzer.rs b/crates/trusted-server-cli/src/audit/analyzer.rs new file mode 100644 index 000000000..f25835b32 --- /dev/null +++ b/crates/trusted-server-cli/src/audit/analyzer.rs @@ -0,0 +1,547 @@ +use std::collections::BTreeMap; +use std::sync::LazyLock; + +use regex::Regex; +use scraper::{Html, Selector}; +use url::Url; + +use crate::audit::collector::CollectedPage; +use crate::audit::{AssetParty, AuditArtifact, AuditedAsset, DetectedIntegration}; +use crate::error::{report_error, CliResult}; + +static GTM_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"\bGTM-[A-Z0-9]+\b").expect("should compile GTM regex")); + +pub(crate) fn analyze_collected_page(collected: &CollectedPage) -> CliResult { + let final_url = collected + .final_url() + .map_err(|error| report_error(format!("invalid final URL: {error}")))?; + let requested_url = collected + .requested_url() + .map_err(|error| report_error(format!("invalid requested URL: {error}")))?; + + let document = Html::parse_document(&collected.html); + let title_selector = Selector::parse("title").expect("should parse title selector"); + let script_selector = Selector::parse("script").expect("should parse script selector"); + let derived_title = document + .select(&title_selector) + .next() + .map(|element| { + element + .text() + .collect::>() + .join(" ") + .trim() + .to_string() + }) + .filter(|title| !title.is_empty()); + + let mut assets_by_url = BTreeMap::::new(); + let mut integrations = BTreeMap::::new(); + let mut warnings = collected.warnings.clone(); + + if requested_url != final_url { + warnings.push(format!( + "page redirected from `{requested_url}` to `{final_url}`" + )); + } + + for element in document.select(&script_selector) { + if let Some(src) = element.value().attr("src") { + if let Ok(asset_url) = final_url.join(src) { + let integration = detect_integration_from_url(&asset_url); + record_integration(&mut integrations, &integration, asset_url.as_str()); + insert_asset(&mut assets_by_url, &final_url, &asset_url, integration); + } else { + warnings.push(format!("could not resolve script URL `{src}`")); + } + } else { + let inline_text = element.text().collect::>().join(" "); + for (integration_id, evidence) in detect_integrations_from_inline_script(&inline_text) { + integrations.entry(integration_id).or_insert(evidence); + } + } + } + + for tag in &collected.script_tags { + if let Some(src) = &tag.src { + if let Ok(asset_url) = Url::parse(src) { + let integration = detect_integration_from_url(&asset_url); + record_integration(&mut integrations, &integration, asset_url.as_str()); + insert_asset(&mut assets_by_url, &final_url, &asset_url, integration); + } + } + + if let Some(inline_text) = &tag.inline_text { + for (integration_id, evidence) in detect_integrations_from_inline_script(inline_text) { + integrations.entry(integration_id).or_insert(evidence); + } + } + } + + for request in &collected.network_requests { + let is_script = request + .resource_type + .as_deref() + .is_some_and(|resource_type| resource_type.eq_ignore_ascii_case("script")); + if !is_script { + continue; + } + if let Ok(asset_url) = Url::parse(&request.url) { + let integration = detect_integration_from_url(&asset_url); + record_integration(&mut integrations, &integration, asset_url.as_str()); + insert_asset(&mut assets_by_url, &final_url, &asset_url, integration); + } + } + + let assets = assets_by_url.into_values().collect::>(); + let third_party_asset_count = assets + .iter() + .filter(|asset| asset.party == AssetParty::ThirdParty) + .count(); + + let page_title = collected + .page_title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + .map(ToOwned::to_owned) + .or(derived_title); + + Ok(AuditArtifact { + audited_url: final_url.to_string(), + page_title, + js_asset_count: assets.len(), + third_party_asset_count, + detected_integrations: integrations + .into_iter() + .map(|(id, evidence)| DetectedIntegration { id, evidence }) + .collect(), + assets, + warnings, + }) +} + +fn insert_asset( + assets_by_url: &mut BTreeMap, + page_url: &Url, + asset_url: &Url, + integration: Option, +) { + let asset = assets_by_url + .entry(asset_url.to_string()) + .or_insert_with(|| AuditedAsset { + kind: "script".to_string(), + url: asset_url.to_string(), + host: asset_url.host_str().unwrap_or_default().to_string(), + party: classify_party(page_url, asset_url), + integration: None, + }); + + if asset.integration.is_none() { + asset.integration = integration; + } +} + +fn record_integration( + integrations: &mut BTreeMap, + integration: &Option, + evidence: &str, +) { + if let Some(integration_id) = integration { + integrations + .entry(integration_id.clone()) + .or_insert_with(|| evidence.to_string()); + } +} + +pub(crate) fn classify_party(page_url: &Url, asset_url: &Url) -> AssetParty { + let page_host = page_url.host_str().unwrap_or_default(); + let asset_host = asset_url.host_str().unwrap_or_default(); + + if host_matches(page_host, asset_host) { + AssetParty::FirstParty + } else { + AssetParty::ThirdParty + } +} + +fn host_matches(page_host: &str, asset_host: &str) -> bool { + asset_host == page_host + || asset_host + .strip_suffix(page_host) + .is_some_and(|prefix| prefix.ends_with('.')) + || page_host + .strip_suffix(asset_host) + .is_some_and(|prefix| prefix.ends_with('.')) +} + +pub(crate) fn detect_integration_from_url(url: &Url) -> Option { + let host = url.host_str().unwrap_or_default(); + let path = url.path(); + let value = format!("{host}{path}").to_ascii_lowercase(); + + if value.contains("googletagmanager.com") { + Some("google_tag_manager".to_string()) + } else if value.contains("securepubads.g.doubleclick.net") + || value.contains("googletagservices.com") + || value.contains("doubleclick.net/tag/js/gpt") + { + Some("gpt".to_string()) + } else if value.contains("privacy-center.org") { + Some("didomi".to_string()) + } else if value.contains("datadome.co") { + Some("datadome".to_string()) + } else if value.contains("permutive") { + Some("permutive".to_string()) + } else if value.contains("loc.kr") { + Some("lockr".to_string()) + } else if value.contains("prebid") { + Some("prebid".to_string()) + } else { + None + } +} + +pub(crate) fn detect_integrations_from_inline_script(script: &str) -> Vec<(String, String)> { + let mut matches = Vec::new(); + + if let Some(container_id) = GTM_REGEX.find(script) { + matches.push(( + "google_tag_manager".to_string(), + container_id.as_str().to_string(), + )); + } + + let lowered = script.to_ascii_lowercase(); + for integration in ["gpt", "didomi", "datadome", "permutive", "lockr", "prebid"] { + if lowered.contains(integration) { + matches.push(( + integration.to_string(), + format!("inline script matched `{integration}`"), + )); + } + } + + matches +} + +pub(crate) fn extract_gtm_container_id(artifact: &AuditArtifact) -> Option { + for integration in &artifact.detected_integrations { + if integration.id == "google_tag_manager" && GTM_REGEX.is_match(&integration.evidence) { + return Some(integration.evidence.clone()); + } + } + + for asset in &artifact.assets { + if asset.integration.as_deref() == Some("google_tag_manager") { + if let Some(matched) = GTM_REGEX.find(asset.url.as_str()) { + return Some(matched.as_str().to_string()); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audit::collector::{CollectedRequest, CollectedScriptTag}; + + fn page_url() -> Url { + Url::parse("https://publisher.example/page").expect("should parse URL") + } + + #[test] + fn analyze_collected_page_merges_dom_and_network_scripts() { + let collected = CollectedPage { + requested_url: "https://publisher.example/page".to_string(), + final_url: "https://publisher.example/page".to_string(), + page_title: Some("Browser Title".to_string()), + html: r#"HTML Title"#.to_string(), + script_tags: vec![CollectedScriptTag { + src: Some("https://securepubads.g.doubleclick.net/tag/js/gpt.js".to_string()), + inline_text: None, + }], + network_requests: vec![CollectedRequest { + url: "https://cdn.example.com/dynamic.js".to_string(), + method: "GET".to_string(), + resource_type: Some("Script".to_string()), + status: Some(200), + }], + warnings: vec!["partial settle".to_string()], + }; + + let artifact = analyze_collected_page(&collected).expect("should analyze collected page"); + + assert_eq!(artifact.page_title.as_deref(), Some("Browser Title")); + assert_eq!( + artifact.js_asset_count, 3, + "should merge all script evidence" + ); + assert_eq!(artifact.warnings, vec!["partial settle".to_string()]); + assert!( + artifact + .detected_integrations + .iter() + .any(|integration| integration.id == "google_tag_manager"), + "should preserve GTM detection" + ); + assert!( + artifact + .detected_integrations + .iter() + .any(|integration| integration.id == "gpt"), + "should detect GPT from browser collected scripts" + ); + } + + #[test] + fn analyze_collected_page_uses_html_title_when_browser_title_absent() { + let collected = CollectedPage { + requested_url: "https://publisher.example/page".to_string(), + final_url: "https://publisher.example/page".to_string(), + page_title: None, + html: "HTML Title".to_string(), + script_tags: Vec::new(), + network_requests: Vec::new(), + warnings: Vec::new(), + }; + + let artifact = analyze_collected_page(&collected).expect("should analyze collected page"); + + assert_eq!(artifact.page_title.as_deref(), Some("HTML Title")); + } + + #[test] + fn analyze_collected_page_uses_html_title_when_browser_title_is_empty() { + let collected = CollectedPage { + requested_url: "https://publisher.example/page".to_string(), + final_url: "https://publisher.example/page".to_string(), + page_title: Some(" ".to_string()), + html: "HTML Title".to_string(), + script_tags: Vec::new(), + network_requests: Vec::new(), + warnings: Vec::new(), + }; + + let artifact = analyze_collected_page(&collected).expect("should analyze collected page"); + + assert_eq!(artifact.page_title.as_deref(), Some("HTML Title")); + } + + #[test] + fn analyze_collected_page_deduplicates_scripts_and_updates_integration() { + let collected = CollectedPage { + requested_url: "https://publisher.example/page".to_string(), + final_url: "https://publisher.example/page".to_string(), + page_title: None, + html: r#""# + .to_string(), + script_tags: vec![CollectedScriptTag { + src: Some("https://cdn.example.com/prebid.js".to_string()), + inline_text: None, + }], + network_requests: vec![CollectedRequest { + url: "https://cdn.example.com/prebid.js".to_string(), + method: "GET".to_string(), + resource_type: Some("script".to_string()), + status: Some(200), + }], + warnings: Vec::new(), + }; + + let artifact = analyze_collected_page(&collected).expect("should analyze collected page"); + + assert_eq!( + artifact.js_asset_count, 1, + "should deduplicate identical script URLs" + ); + assert_eq!( + artifact.assets[0].integration.as_deref(), + Some("prebid"), + "should preserve detected integration on deduped asset" + ); + } + + #[test] + fn analyze_collected_page_resolves_relative_scripts_and_warns_on_invalid_src() { + let collected = CollectedPage { + requested_url: "https://publisher.example/page".to_string(), + final_url: "https://publisher.example/path/page".to_string(), + page_title: None, + html: r#""#.to_string(), + script_tags: Vec::new(), + network_requests: Vec::new(), + warnings: Vec::new(), + }; + + let artifact = analyze_collected_page(&collected).expect("should analyze collected page"); + + assert!( + artifact + .assets + .iter() + .any(|asset| asset.url == "https://publisher.example/static/app.js"), + "should resolve relative URL against final URL" + ); + assert!( + artifact + .warnings + .iter() + .any(|warning| warning.contains("could not resolve script URL")), + "should warn about malformed script URL" + ); + } + + #[test] + fn analyze_collected_page_uses_final_url_and_records_redirect_warning() { + let collected = CollectedPage { + requested_url: "http://publisher.example/page".to_string(), + final_url: "https://www.publisher.example/landing".to_string(), + page_title: Some("Example Publisher".to_string()), + html: "".to_string(), + script_tags: Vec::new(), + network_requests: Vec::new(), + warnings: Vec::new(), + }; + + let artifact = analyze_collected_page(&collected).expect("should analyze collected page"); + + assert_eq!( + artifact.audited_url, "https://www.publisher.example/landing", + "should report the final audited URL" + ); + assert!( + artifact.warnings.iter().any(|warning| warning.contains( + "page redirected from `http://publisher.example/page` to `https://www.publisher.example/landing`" + )), + "should preserve redirect context in warnings" + ); + } + + #[test] + fn classify_party_uses_host_relationship() { + let page = page_url(); + let exact = Url::parse("https://publisher.example/app.js").expect("should parse URL"); + let subdomain = + Url::parse("https://cdn.publisher.example/app.js").expect("should parse URL"); + let parent = Url::parse("https://example/app.js").expect("should parse URL"); + let unrelated = Url::parse("https://cdn.example.com/app.js").expect("should parse URL"); + + assert_eq!(classify_party(&page, &exact), AssetParty::FirstParty); + assert_eq!(classify_party(&page, &subdomain), AssetParty::FirstParty); + assert_eq!(classify_party(&page, &parent), AssetParty::FirstParty); + assert_eq!(classify_party(&page, &unrelated), AssetParty::ThirdParty); + } + + #[test] + fn detect_integrations_from_inline_script_reads_standard_gtm_snippet() { + let matches = detect_integrations_from_inline_script( + r#"(function(w,d,s,l,i){w[l]=w[l]||[];})(window,document,'script','dataLayer','GTM-ABC123');"#, + ); + + assert!( + matches.iter().any( + |(integration, evidence)| integration == "google_tag_manager" + && evidence == "GTM-ABC123" + ), + "should detect GTM IDs followed by snippet punctuation" + ); + } + + #[test] + fn detect_integrations_from_inline_script_reads_case_insensitive_markers() { + let matches = detect_integrations_from_inline_script("window.PREBID = window.Didomi;"); + + assert!(matches + .iter() + .any(|(integration, _)| integration == "prebid")); + assert!(matches + .iter() + .any(|(integration, _)| integration == "didomi")); + } + + #[test] + fn detect_integration_from_url_recognizes_known_patterns() { + let cases = [ + ( + "https://www.googletagmanager.com/gtm.js?id=GTM-ABC123", + "google_tag_manager", + ), + ( + "https://securepubads.g.doubleclick.net/tag/js/gpt.js", + "gpt", + ), + ("https://sdk.privacy-center.org/sdk.js", "didomi"), + ("https://js.datadome.co/tags.js", "datadome"), + ("https://cdn.permutive.com/sdk.js", "permutive"), + ("https://identity.loc.kr/sdk.js", "lockr"), + ("https://cdn.example.com/prebid.js", "prebid"), + ]; + + for (url, expected) in cases { + let parsed = Url::parse(url).expect("should parse URL"); + assert_eq!( + detect_integration_from_url(&parsed).as_deref(), + Some(expected), + "should detect {expected}" + ); + } + } + + #[test] + fn extract_gtm_container_id_reads_query_parameter_urls() { + let artifact = AuditArtifact { + audited_url: "https://publisher.example".to_string(), + page_title: None, + js_asset_count: 1, + third_party_asset_count: 1, + detected_integrations: Vec::new(), + assets: vec![AuditedAsset { + kind: "script".to_string(), + url: "https://www.googletagmanager.com/gtm.js?id=GTM-ABC123&l=dataLayer" + .to_string(), + host: "www.googletagmanager.com".to_string(), + party: AssetParty::ThirdParty, + integration: Some("google_tag_manager".to_string()), + }], + warnings: Vec::new(), + }; + + assert_eq!( + extract_gtm_container_id(&artifact).as_deref(), + Some("GTM-ABC123"), + "should extract GTM container IDs before query separators" + ); + } + + #[test] + fn artifact_serialization_uses_expected_shape() { + let artifact = AuditArtifact { + audited_url: "https://publisher.example".to_string(), + page_title: None, + js_asset_count: 1, + third_party_asset_count: 1, + detected_integrations: vec![DetectedIntegration { + id: "gpt".to_string(), + evidence: "https://securepubads.g.doubleclick.net/tag/js/gpt.js".to_string(), + }], + assets: vec![AuditedAsset { + kind: "script".to_string(), + url: "https://securepubads.g.doubleclick.net/tag/js/gpt.js".to_string(), + host: "securepubads.g.doubleclick.net".to_string(), + party: AssetParty::ThirdParty, + integration: Some("gpt".to_string()), + }], + warnings: Vec::new(), + }; + + let toml = toml::to_string_pretty(&artifact).expect("should serialize artifact"); + + assert!(toml.contains("audited_url = \"https://publisher.example\"")); + assert!(toml.contains("party = \"third-party\"")); + assert!(!toml.contains("page_title")); + } +} diff --git a/crates/trusted-server-cli/src/audit/browser_collector.rs b/crates/trusted-server-cli/src/audit/browser_collector.rs new file mode 100644 index 000000000..5317eea8f --- /dev/null +++ b/crates/trusted-server-cli/src/audit/browser_collector.rs @@ -0,0 +1,353 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use chromiumoxide::browser::{Browser, BrowserConfig}; +use chromiumoxide::ArcHttpRequest; +use futures::StreamExt as _; +use serde::Deserialize; +use tempfile::TempDir; +use tokio::runtime::Builder; +use tokio::time::sleep; +use url::Url; +use which::which; + +use crate::audit::collector::{ + AuditCollector, CollectedPage, CollectedRequest, CollectedScriptTag, +}; +use crate::error::{report_error, CliResult}; + +const SETTLE_QUIET_PERIOD: Duration = Duration::from_millis(750); +const SETTLE_POLL_INTERVAL: Duration = Duration::from_millis(250); +const SETTLE_MAX_WAIT: Duration = Duration::from_secs(6); + +#[derive(Default)] +pub(crate) struct BrowserAuditCollector; + +impl AuditCollector for BrowserAuditCollector { + fn collect_page(&self, target_url: &Url) -> CliResult { + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| { + report_error(format!( + "failed to build Tokio runtime for browser audit: {error}" + )) + })?; + + runtime.block_on(collect_page_via_browser_async(target_url)) + } +} + +async fn collect_page_via_browser_async(target_url: &Url) -> CliResult { + let chrome_executable = find_browser_executable()?; + let user_data_dir = TempDir::new().map_err(|error| { + report_error(format!( + "failed to create temporary browser profile for audit: {error}" + )) + })?; + let config = BrowserConfig::builder() + .chrome_executable(chrome_executable) + .user_data_dir(user_data_dir.path()) + .new_headless_mode() + .build() + .map_err(|error| { + report_error(format!( + "failed to build Chromium configuration for audit: {error}" + )) + })?; + + let (mut browser, mut handler) = Browser::launch(config).await.map_err(|error| { + report_error(format!( + "failed to launch Chrome/Chromium for audit: {error}" + )) + })?; + + let handler_task = tokio::spawn(async move { + while let Some(event) = handler.next().await { + if event.is_err() { + break; + } + } + }); + + let result = collect_page_from_browser(&mut browser, target_url).await; + + let close_result = browser + .close() + .await + .map_err(|error| report_error(format!("failed to close browser after audit: {error}"))); + if close_result.is_err() { + handler_task.abort(); + } + let _ = handler_task.await; + + match (result, close_result) { + (Ok(collected), Ok(_)) => Ok(collected), + (Ok(_), Err(error)) | (Err(error), _) => Err(error), + } +} + +async fn collect_page_from_browser( + browser: &mut Browser, + target_url: &Url, +) -> CliResult { + let page = browser.new_page("about:blank").await.map_err(|error| { + report_error(format!("failed to create browser page for audit: {error}")) + })?; + + page.goto(target_url.as_str()) + .await + .map_err(|error| report_error(format!("failed to navigate to `{target_url}`: {error}")))?; + + let navigation_response = page.wait_for_navigation_response().await.map_err(|error| { + report_error(format!( + "failed to read main document navigation response: {error}" + )) + })?; + validate_navigation_response(navigation_response)?; + + let mut warnings = Vec::new(); + if !wait_for_page_settle(&page).await? { + warnings.push( + "browser audit timed out while waiting for the page to settle; results may be partial" + .to_string(), + ); + } + + let final_url = page + .url() + .await + .map_err(|error| report_error(format!("failed to read final page URL: {error}")))? + .ok_or_else(|| report_error("browser page URL was empty after navigation"))?; + let page_title = page + .get_title() + .await + .map_err(|error| report_error(format!("failed to read page title: {error}")))?; + let html = page + .content() + .await + .map_err(|error| report_error(format!("failed to read rendered page HTML: {error}")))?; + + let script_tags: Vec = page + .evaluate( + r#"() => Array.from(document.scripts).map((script) => ({ + src: script.src || null, + inline_text: script.src ? null : (script.textContent || null), + }))"#, + ) + .await + .map_err(|error| report_error(format!("failed to read rendered script tags: {error}")))? + .into_value() + .map_err(|error| { + report_error(format!( + "failed to decode rendered script tag data: {error}" + )) + })?; + + let network_requests: Vec = page + .evaluate( + r#"() => performance.getEntriesByType('resource').map((entry) => ({ + url: entry.name, + initiator_type: entry.initiatorType || null, + }))"#, + ) + .await + .map_err(|error| { + report_error(format!( + "failed to read browser performance resource entries: {error}" + )) + })? + .into_value() + .map_err(|error| { + report_error(format!( + "failed to decode browser performance resource data: {error}" + )) + })?; + + Ok(CollectedPage { + requested_url: target_url.to_string(), + final_url, + page_title: page_title.filter(|title| !title.trim().is_empty()), + html, + script_tags: script_tags + .into_iter() + .map(|script| CollectedScriptTag { + src: script.src, + inline_text: script.inline_text.filter(|text| !text.trim().is_empty()), + }) + .collect(), + network_requests: network_requests + .into_iter() + .map(|entry| CollectedRequest { + url: entry.url, + method: "GET".to_string(), + resource_type: entry.initiator_type, + status: None, + }) + .collect(), + warnings, + }) +} + +async fn wait_for_page_settle(page: &chromiumoxide::Page) -> CliResult { + let mut elapsed = Duration::ZERO; + let mut previous_count = None; + let mut stable_for = Duration::ZERO; + + while elapsed < SETTLE_MAX_WAIT { + let ready_state: String = page + .evaluate("document.readyState") + .await + .map_err(|error| report_error(format!("failed to read document ready state: {error}")))? + .into_value() + .map_err(|error| { + report_error(format!("failed to decode document ready state: {error}")) + })?; + let resource_count: usize = page + .evaluate("performance.getEntriesByType('resource').length") + .await + .map_err(|error| report_error(format!("failed to read resource count: {error}")))? + .into_value() + .map_err(|error| report_error(format!("failed to decode resource count: {error}")))?; + + if ready_state == "complete" { + if previous_count == Some(resource_count) { + stable_for += SETTLE_POLL_INTERVAL; + } else { + stable_for = Duration::ZERO; + } + + if stable_for >= SETTLE_QUIET_PERIOD { + return Ok(true); + } + } + + previous_count = Some(resource_count); + sleep(SETTLE_POLL_INTERVAL).await; + elapsed += SETTLE_POLL_INTERVAL; + } + + Ok(false) +} + +fn validate_navigation_response(navigation_response: ArcHttpRequest) -> CliResult<()> { + let request = navigation_response + .ok_or_else(|| report_error("browser audit did not capture the main document response"))?; + + if let Some(failure_text) = &request.failure_text { + return Err(report_error(format!( + "main document request failed: {failure_text}" + ))); + } + + let response = request.response.as_ref().ok_or_else(|| { + report_error("browser audit did not capture the main document HTTP response") + })?; + + if is_successful_navigation_status(response.status) { + return Ok(()); + } + + Err(report_error(format!( + "audit request returned HTTP {} {} for `{}`", + response.status, response.status_text, response.url + ))) +} + +fn is_successful_navigation_status(status: i64) -> bool { + (200..400).contains(&status) +} + +fn find_browser_executable() -> CliResult { + for candidate in browser_executable_path_candidates() { + if let Ok(path) = which(candidate) { + return Ok(path); + } + } + + for candidate in browser_executable_fallbacks() { + let candidate_path = Path::new(candidate); + if candidate_path.is_file() { + return Ok(candidate_path.to_path_buf()); + } + } + + Err(report_error( + "Chrome/Chromium was not found on PATH or in the standard local install locations checked by `ts audit`. Install a local Chrome or Chromium binary before running `ts audit`.", + )) +} + +fn browser_executable_path_candidates() -> &'static [&'static str] { + &[ + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + "chrome", + "Google Chrome", + "Google Chrome for Testing", + ] +} + +fn browser_executable_fallbacks() -> &'static [&'static str] { + #[cfg(target_os = "macos")] + { + &[ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", + ] + } + + #[cfg(target_os = "linux")] + { + &[ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + ] + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + &[] + } +} + +#[derive(Debug, Deserialize)] +struct BrowserScriptTag { + src: Option, + inline_text: Option, +} + +#[derive(Debug, Deserialize)] +struct BrowserPerformanceEntry { + url: String, + initiator_type: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn successful_navigation_status_allows_redirects_but_rejects_errors() { + assert!(is_successful_navigation_status(200)); + assert!(is_successful_navigation_status(302)); + assert!(is_successful_navigation_status(399)); + assert!(!is_successful_navigation_status(199)); + assert!(!is_successful_navigation_status(400)); + assert!(!is_successful_navigation_status(500)); + } + + #[test] + fn browser_path_candidates_include_common_names() { + let candidates = browser_executable_path_candidates(); + + assert!(candidates.contains(&"google-chrome")); + assert!(candidates.contains(&"chromium")); + assert!(candidates.contains(&"Google Chrome for Testing")); + } +} diff --git a/crates/trusted-server-cli/src/audit/collector.rs b/crates/trusted-server-cli/src/audit/collector.rs new file mode 100644 index 000000000..184a8987a --- /dev/null +++ b/crates/trusted-server-cli/src/audit/collector.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::error::CliResult; + +pub(crate) trait AuditCollector { + fn collect_page(&self, target_url: &Url) -> CliResult; +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub(crate) struct CollectedPage { + pub(crate) requested_url: String, + pub(crate) final_url: String, + pub(crate) page_title: Option, + pub(crate) html: String, + pub(crate) script_tags: Vec, + pub(crate) network_requests: Vec, + pub(crate) warnings: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub(crate) struct CollectedScriptTag { + pub(crate) src: Option, + pub(crate) inline_text: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub(crate) struct CollectedRequest { + pub(crate) url: String, + pub(crate) method: String, + pub(crate) resource_type: Option, + pub(crate) status: Option, +} + +impl CollectedPage { + pub(crate) fn requested_url(&self) -> Result { + Url::parse(&self.requested_url) + } + + pub(crate) fn final_url(&self) -> Result { + Url::parse(&self.final_url) + } +} diff --git a/crates/trusted-server-cli/src/config_command.rs b/crates/trusted-server-cli/src/config_command.rs index 9b3811695..723a797d2 100644 --- a/crates/trusted-server-cli/src/config_command.rs +++ b/crates/trusted-server-cli/src/config_command.rs @@ -19,7 +19,7 @@ use validator::Validate as _; use crate::args::{ConfigInitArgs, ConfigValidateArgs}; use crate::error::{cli_error, report_error, CliResult}; -const EXAMPLE_CONFIG: &str = include_str!(concat!( +pub(crate) const EXAMPLE_CONFIG: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../trusted-server.example.toml" )); diff --git a/crates/trusted-server-cli/src/lib.rs b/crates/trusted-server-cli/src/lib.rs index 67bc936b7..26c1c37d2 100644 --- a/crates/trusted-server-cli/src/lib.rs +++ b/crates/trusted-server-cli/src/lib.rs @@ -12,6 +12,8 @@ #[cfg(not(target_arch = "wasm32"))] mod args; #[cfg(not(target_arch = "wasm32"))] +mod audit; +#[cfg(not(target_arch = "wasm32"))] mod config_command; #[cfg(not(target_arch = "wasm32"))] mod edgezero_delegate; diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index fbe49f2ae..8582b671c 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -3,6 +3,8 @@ use std::io::Write; use clap::Parser as _; use crate::args::{Args, AuthCommand, Command, ConfigCommand}; +use crate::audit::browser_collector::BrowserAuditCollector; +use crate::audit::collector::AuditCollector; use crate::config_command::{load_config, run_init, run_validate}; use crate::edgezero_delegate::{ ConfigPushRequest, EdgeZeroDelegate, LifecycleCommand, ProductionEdgeZeroDelegate, @@ -20,7 +22,12 @@ pub fn run_from_env() -> CliResult<()> { let mut stdout = std::io::stdout(); let mut stderr = std::io::stderr(); let mut delegate = ProductionEdgeZeroDelegate; - dispatch(args, &mut delegate, &mut stdout, &mut stderr) + let audit = BrowserAuditCollector; + let mut services = CliServices { + edgezero: &mut delegate, + audit: &audit, + }; + dispatch(args, &mut services, &mut stdout, &mut stderr) } /// Run the CLI from explicit arguments and output streams. @@ -38,34 +45,45 @@ where crate::error::report_error(format!("failed to parse command arguments: {error}")) })?; let mut delegate = ProductionEdgeZeroDelegate; - dispatch(parsed, &mut delegate, out, err) + let audit = BrowserAuditCollector; + let mut services = CliServices { + edgezero: &mut delegate, + audit: &audit, + }; + dispatch(parsed, &mut services, out, err) +} + +struct CliServices<'a> { + edgezero: &'a mut dyn EdgeZeroDelegate, + audit: &'a dyn AuditCollector, } fn dispatch( args: Args, - delegate: &mut dyn EdgeZeroDelegate, + services: &mut CliServices<'_>, out: &mut dyn Write, err: &mut dyn Write, ) -> CliResult<()> { match args.command { + Command::Audit(audit) => crate::audit::run_audit(&audit, services.audit, out), Command::Auth(auth) => match auth.command { - AuthCommand::Login(login) => delegate.run_lifecycle( + AuthCommand::Login(login) => services.edgezero.run_lifecycle( LifecycleCommand::AuthLogin, &login.adapter, &login.edgezero_args, ), - AuthCommand::Logout(logout) => delegate.run_lifecycle( + AuthCommand::Logout(logout) => services.edgezero.run_lifecycle( LifecycleCommand::AuthLogout, &logout.adapter, &logout.edgezero_args, ), - AuthCommand::Status(status) => delegate.run_lifecycle( + AuthCommand::Status(status) => services.edgezero.run_lifecycle( LifecycleCommand::AuthStatus, &status.adapter, &status.edgezero_args, ), }, - Command::Build(build) => delegate.run_lifecycle( + Command::Build(build) => services.edgezero.run_lifecycle( LifecycleCommand::Build, &build.adapter, &build.edgezero_args, @@ -85,19 +103,19 @@ fn dispatch( settings_entry_count: loaded.payload.settings_entries.len(), config_hash: loaded.payload.hash, }; - delegate.push_config(&request, out) + services.edgezero.push_config(&request, out) } - Command::Deploy(deploy) => delegate.run_lifecycle( + Command::Deploy(deploy) => services.edgezero.run_lifecycle( LifecycleCommand::Deploy, &deploy.adapter, &deploy.edgezero_args, ), - Command::Provision(provision) => delegate.run_lifecycle( + Command::Provision(provision) => services.edgezero.run_lifecycle( LifecycleCommand::Provision, &provision.adapter, &provision.edgezero_args, ), - Command::Serve(serve) => delegate.run_lifecycle( + Command::Serve(serve) => services.edgezero.run_lifecycle( LifecycleCommand::Serve, &serve.adapter, &serve.edgezero_args, @@ -107,11 +125,14 @@ fn dispatch( #[cfg(test)] mod tests { + use std::cell::Cell; use std::fs; use tempfile::TempDir; + use url::Url; use super::*; + use crate::audit::collector::{CollectedPage, CollectedRequest, CollectedScriptTag}; use crate::edgezero_delegate::tests::FakeEdgeZeroDelegate; fn valid_config() -> String { @@ -133,15 +154,58 @@ password = "production-admin-password-32-bytes" .to_string() } + struct FakeAuditCollector { + calls: Cell, + } + + impl AuditCollector for FakeAuditCollector { + fn collect_page(&self, _target_url: &Url) -> CliResult { + self.calls.set(self.calls.get() + 1); + Ok(CollectedPage { + requested_url: "https://publisher.example/page".to_string(), + final_url: "https://publisher.example/page".to_string(), + page_title: Some("Example Publisher".to_string()), + html: r#""#.to_string(), + script_tags: vec![CollectedScriptTag { + src: Some("https://www.googletagmanager.com/gtm.js?id=GTM-ABC123".to_string()), + inline_text: None, + }], + network_requests: vec![CollectedRequest { + url: "https://cdn.publisher.example/app.js".to_string(), + method: "GET".to_string(), + resource_type: Some("script".to_string()), + status: None, + }], + warnings: Vec::new(), + }) + } + } + fn parse(args: &[&str]) -> Args { Args::try_parse_from(args).expect("should parse args") } + fn dispatch_for_test( + args: Args, + delegate: &mut FakeEdgeZeroDelegate, + out: &mut dyn Write, + err: &mut dyn Write, + ) -> CliResult<()> { + let audit = FakeAuditCollector { + calls: Cell::new(0), + }; + let mut services = CliServices { + edgezero: delegate, + audit: &audit, + }; + dispatch(args, &mut services, out, err) + } + #[test] fn build_delegates_to_edgezero_with_passthrough() { let args = parse(&["ts", "build", "--adapter", "fastly", "--", "--release"]); let mut delegate = FakeEdgeZeroDelegate::default(); - dispatch(args, &mut delegate, &mut Vec::new(), &mut Vec::new()) + dispatch_for_test(args, &mut delegate, &mut Vec::new(), &mut Vec::new()) .expect("should dispatch build"); assert_eq!(delegate.lifecycle_calls.len(), 1); @@ -154,7 +218,7 @@ password = "production-admin-password-32-bytes" fn auth_status_delegates_to_edgezero() { let args = parse(&["ts", "auth", "status", "--adapter", "fastly"]); let mut delegate = FakeEdgeZeroDelegate::default(); - dispatch(args, &mut delegate, &mut Vec::new(), &mut Vec::new()) + dispatch_for_test(args, &mut delegate, &mut Vec::new(), &mut Vec::new()) .expect("should dispatch auth status"); assert_eq!(delegate.lifecycle_calls.len(), 1); @@ -162,6 +226,43 @@ password = "production-admin-password-32-bytes" assert_eq!(delegate.lifecycle_calls[0].1, "fastly"); } + #[test] + fn audit_uses_audit_collector_without_edgezero_delegate() { + let temp = TempDir::new().expect("should create temp dir"); + let assets_path = temp.path().join("js-assets.toml"); + let args = Args::try_parse_from([ + "ts", + "audit", + "https://publisher.example/page", + "--js-assets", + assets_path.to_str().expect("path should be UTF-8"), + "--no-config", + ]) + .expect("should parse audit args"); + let mut delegate = FakeEdgeZeroDelegate::default(); + let audit = FakeAuditCollector { + calls: Cell::new(0), + }; + let mut services = CliServices { + edgezero: &mut delegate, + audit: &audit, + }; + let mut out = Vec::new(); + + dispatch(args, &mut services, &mut out, &mut Vec::new()).expect("should dispatch audit"); + + assert_eq!(audit.calls.get(), 1, "should use audit collector"); + assert!( + delegate.lifecycle_calls.is_empty(), + "should not call EdgeZero lifecycle delegate" + ); + assert!( + delegate.push_calls.is_empty(), + "should not call EdgeZero config push delegate" + ); + assert!(assets_path.exists(), "should write audit artifact"); + } + #[test] fn config_push_validates_and_forwards_entries() { let temp = TempDir::new().expect("should create temp dir"); @@ -186,7 +287,8 @@ password = "production-admin-password-32-bytes" let mut delegate = FakeEdgeZeroDelegate::default(); let mut out = Vec::new(); - dispatch(args, &mut delegate, &mut out, &mut Vec::new()).expect("should dispatch push"); + dispatch_for_test(args, &mut delegate, &mut out, &mut Vec::new()) + .expect("should dispatch push"); assert_eq!(delegate.push_calls.len(), 1); let call = &delegate.push_calls[0]; diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index ec0830abf..8e66f3861 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -106,6 +106,7 @@ export default withMermaid( items: [ { text: 'Architecture', link: '/guide/architecture' }, { text: 'Configuration', link: '/guide/configuration' }, + { text: 'CLI', link: '/guide/cli' }, { text: 'Testing', link: '/guide/testing' }, { text: 'Integration Guide', link: '/guide/integration-guide' }, ], diff --git a/docs/guide/cli.md b/docs/guide/cli.md new file mode 100644 index 000000000..6bf0f681b --- /dev/null +++ b/docs/guide/cli.md @@ -0,0 +1,77 @@ +# Trusted Server CLI + +The Trusted Server CLI binary is `ts`. It is a host-target operator tool for +configuration, page audits, and EdgeZero-backed lifecycle commands. + +## Install from source + +The workspace default target is `wasm32-wasip1`, so build or test the CLI with +your host target: + +```bash +HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" +cargo build --package trusted-server-cli --target "$HOST_TARGET" +``` + +## Common workflow + +```bash +ts config init +# Edit trusted-server.toml +ts config validate +ts auth login --adapter fastly +ts provision --adapter fastly +ts config push --adapter fastly +ts serve --adapter fastly +``` + +## Audit a public page + +`ts audit` loads a public page in a fresh headless Chrome/Chromium session, +collects rendered JavaScript asset evidence, detects known Trusted Server +integrations, and writes local draft artifacts. + +Chrome or Chromium must be installed locally. The command checks common PATH +names and standard macOS/Linux install locations. + +```bash +ts audit https://publisher.example +``` + +By default, the command writes: + +| File | Purpose | +| --------------------- | ------------------------------------------------------------------------ | +| `js-assets.toml` | JavaScript asset inventory, detected integrations, counts, and warnings. | +| `trusted-server.toml` | Draft Trusted Server config based on the starter template and final URL. | + +The generated config is a draft. Review it, replace placeholders/secrets, adjust +publisher-specific settings, then run: + +```bash +ts config validate +``` + +If a config already exists, avoid overwriting it: + +```bash +ts audit https://publisher.example --no-config +``` + +Use custom output paths when reviewing artifacts first: + +```bash +ts audit https://publisher.example \ + --js-assets audit/js-assets.toml \ + --config audit/trusted-server.toml +``` + +Use `--force` only when replacing existing output files is intentional: + +```bash +ts audit https://publisher.example --force +``` + +`ts audit` is not an EdgeZero adapter command. It has no `--adapter` option and +it does not provision resources, push config, build, deploy, or contact platform +APIs. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index be43315f0..c85bc55a8 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -9,6 +9,7 @@ Before you begin, ensure you have the following installed (versions are pinned i - Rust {{RUST_VERSION}} - NodeJS {{NODEJS_VERSION}} - Fastly {{FASTLY_VERSION}} CLI installed +- Chrome or Chromium, required for `ts audit` - A Fastly account and API key - Basic familiarity with WebAssembly @@ -55,6 +56,25 @@ The server will be available at `http://localhost:7676`. ## Configuration +Create a starter Trusted Server config with the `ts` CLI: + +```bash +ts config init +``` + +To bootstrap from a public publisher page, run an audit first: + +```bash +ts audit https://publisher.example +``` + +The audit command writes `js-assets.toml` plus a draft `trusted-server.toml`. +Review the draft, replace placeholders/secrets, then validate it: + +```bash +ts config validate +``` + Edit `trusted-server.toml` to configure: - Ad server integrations @@ -62,7 +82,7 @@ Edit `trusted-server.toml` to configure: - EC configuration - GDPR settings -See [Configuration](/guide/configuration) for details. +See [Configuration](/guide/configuration) and [Trusted Server CLI](/guide/cli) for details. ## Deploy to Fastly diff --git a/docs/superpowers/plans/2026-06-16-edgezero-based-ts-audit-implementation-plan.md b/docs/superpowers/plans/2026-06-16-edgezero-based-ts-audit-implementation-plan.md new file mode 100644 index 000000000..a91fb1821 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-edgezero-based-ts-audit-implementation-plan.md @@ -0,0 +1,820 @@ +# EdgeZero-Based Trusted Server Audit CLI Implementation Plan + +**Date:** 2026-06-16 +**Status:** Approved implementation plan +**Spec:** `docs/superpowers/specs/2026-06-16-edgezero-based-ts-audit-design.md` +**Depends on:** base CLI pass from +`docs/superpowers/specs/2026-06-16-edgezero-based-ts-cli-design.md` + +## Current baseline + +The base CLI pass has added the host-target `trusted-server-cli` crate with: + +```text +crates/trusted-server-cli/ + Cargo.toml + src/args.rs + src/config_command.rs + src/edgezero_delegate.rs + src/error.rs + src/lib.rs + src/main.rs + src/run.rs +``` + +Important existing shapes to preserve: + +- The binary is `ts`. +- The implementation is gated to non-wasm targets in `lib.rs` and `main.rs`. +- `run_from_env()` parses process args and wires production services. +- `run_with_io()` supports testable invocation with injected writers. +- `run::dispatch()` currently injects an `EdgeZeroDelegate` for lifecycle/config + push tests. +- `config_command.rs` already embeds `trusted-server.example.toml` for + `config init`. +- `trusted-server.example.toml` now uses `example.com` sentinel values rather + than the old `test-publisher.com` values. +- `.gitignore` already ignores `trusted-server.toml`, but does not yet ignore + `js-assets.toml`. + +The old implementation to port from is on `feature/ts-cli`: + +```text +crates/trusted-server-cli/src/audit.rs +crates/trusted-server-cli/src/audit/analyzer.rs +crates/trusted-server-cli/src/audit/browser_collector.rs +crates/trusted-server-cli/src/audit/collector.rs +``` + +This plan recreates that behavior on top of the new base CLI structure, while +applying the spec's tightening around output preflight, deterministic merge +behavior, and EdgeZero separation. + +## Decisions locked for this plan + +- `ts audit` is Trusted Server-owned, not an EdgeZero delegate. +- No `--adapter`, `--manifest`, `--store`, `--local`, `--dry-run`, or `--json` + options are added to audit v1. +- The command writes local draft artifacts only; it never provisions, pushes, + deploys, or contacts platform APIs. +- Preserve the old command surface: + - `ts audit `; + - `--js-assets `; + - `--config `; + - `--no-js-assets`; + - `--no-config`; + - `--force`. +- Preserve the old artifact schema exactly enough that existing + `js-assets.toml` readers do not need a migration. +- Improve over the old implementation by preflighting selected output paths + before launching the browser and before writing any file. +- Use a fake collector in tests; unit tests must not require Chrome/Chromium. +- Browser smoke tests, if added, must be ignored by default or feature-gated. +- Generated `trusted-server.toml` is a draft. It may still fail production + validation until the operator replaces placeholders and reviews settings. +- Do not write rendered HTML, inline script bodies, cookies, storage, request + bodies, or response bodies to artifacts. +- Keep all browser automation dependencies host-only under + `trusted-server-cli`. +- Follow repository error/logging style: `error-stack::Report`, no `println!`, + output through injected `Write` handles in testable code. + +## Definition of done + +- `ts audit [options] ` appears in clap help and dispatches correctly. +- URL validation accepts only `http` and `https` URLs. +- Default outputs are `js-assets.toml` and `trusted-server.toml`. +- `--no-js-assets` and `--no-config` work individually. +- Passing both no-output flags fails before browser collection. +- Existing outputs are rejected without `--force` before browser collection. +- If any selected output path conflicts, no selected file is written. +- Browser collector launches an isolated headless Chrome/Chromium session. +- Browser collector captures final URL, title, rendered HTML, DOM scripts, and + script resource timing entries. +- Navigation failures and non-`200..399` main-document statuses fail clearly. +- Page settle timeout continues with a warning. +- Analyzer merges HTML, DOM, and resource-timing script evidence. +- Assets and detected integrations are deduplicated and sorted deterministically. +- First-party/third-party classification matches the spec's host relationship + heuristic. +- Integration detectors match the old v1 detector set. +- `js-assets.toml` serializes the specified schema. +- Draft config generation patches current `trusted-server.example.toml` + sentinels, uses the final redirected URL, and appends manual-review comments. +- `ts audit` does not invoke any `EdgeZeroDelegate` or platform API. +- `.gitignore` ignores the default `js-assets.toml` artifact. +- CLI guide / getting-started docs mention the audit command and Chrome + requirement. +- Focused unit tests pass. +- Host-target CLI tests pass. +- Formatting passes. + +## Proposed module layout + +Add audit as an internal host-only module under the existing CLI crate: + +```text +crates/trusted-server-cli/src/ + audit.rs + audit/ + analyzer.rs + browser_collector.rs + collector.rs +``` + +Responsibilities: + +| File | Responsibility | +| ---------------------------- | ----------------------------------------------------------------------------- | +| `args.rs` | Add `Command::Audit(AuditArgs)` and parse audit flags. | +| `run.rs` | Dispatch audit via an injectable collector and stdout writer. | +| `audit.rs` | Command orchestration, output planning, file writes, draft config generation. | +| `audit/collector.rs` | `CollectedPage` data structs and `AuditCollector` trait. | +| `audit/analyzer.rs` | Convert `CollectedPage` to `AuditArtifact`; detection/classification. | +| `audit/browser_collector.rs` | Production Chrome/Chromium collector. | +| `Cargo.toml` | Add host-only audit dependencies. | +| `.gitignore` | Ignore default `js-assets.toml`. | +| docs | Document command usage and draft status. | + +## Data model sketch + +Port these old public/internal shapes with doc comments as needed for clippy: + +```rust +pub struct AuditArgs { + pub url: String, + pub js_assets: Option, + pub config: Option, + pub no_js_assets: bool, + pub no_config: bool, + pub force: bool, +} + +pub trait AuditCollector { + fn collect_page(&self, target_url: &Url) -> CliResult; +} + +pub struct CollectedPage { + pub requested_url: String, + pub final_url: String, + pub page_title: Option, + pub html: String, + pub script_tags: Vec, + pub network_requests: Vec, + pub warnings: Vec, +} + +pub struct AuditArtifact { + pub audited_url: String, + pub page_title: Option, + pub js_asset_count: usize, + pub third_party_asset_count: usize, + pub detected_integrations: Vec, + pub assets: Vec, + pub warnings: Vec, +} +``` + +Keep serialization compatible with the old artifact: + +- `AssetParty` serializes with `#[serde(rename_all = "kebab-case")]`. +- `AuditedAsset.integration` remains `Option`. +- `page_title` remains `Option`. +- No `schema_version` in v1. + +## Service injection shape + +The current `dispatch()` injects only an `EdgeZeroDelegate`. To test audit +without launching Chrome, extend the dispatcher to inject both platform and audit +services. + +Preferred shape: + +```rust +struct CliServices<'a> { + edgezero: &'a mut dyn EdgeZeroDelegate, + audit: &'a dyn AuditCollector, +} + +fn dispatch( + args: Args, + services: &mut CliServices<'_>, + out: &mut dyn Write, + err: &mut dyn Write, +) -> CliResult<()>; +``` + +Production setup in `run_from_env()` and `run_with_io()`: + +```rust +let mut edgezero = ProductionEdgeZeroDelegate; +let audit = BrowserAuditCollector; +let mut services = CliServices { + edgezero: &mut edgezero, + audit: &audit, +}; +``` + +Tests can use: + +```rust +let mut edgezero = FakeEdgeZeroDelegate::default(); +let audit = FakeAuditCollector::new(collected_page); +let mut services = CliServices { + edgezero: &mut edgezero, + audit: &audit, +}; +``` + +This keeps the no-EdgeZero requirement testable: after dispatching `Command::Audit`, +assert fake EdgeZero lifecycle/push calls are empty. + +If introducing `CliServices` feels too large, an acceptable smaller alternative +is `dispatch_with_audit_collector(args, delegate, collector, out, err)` used by +production and tests. Avoid global mutable test hooks. + +## Dependencies + +Add only host-target CLI dependencies. + +Likely additions to root `[workspace.dependencies]`: + +```toml +chromiumoxide = "" +scraper = "0.21" # or current compatible version +``` + +Likely additions to `crates/trusted-server-cli/Cargo.toml` under +`target.'cfg(not(target_arch = "wasm32"))'.dependencies`: + +```toml +chromiumoxide = { workspace = true } +futures = { workspace = true } +regex = { workspace = true } +scraper = { workspace = true } +tokio = { workspace = true } +url = { workspace = true } +which = { workspace = true } +``` + +Existing workspace dependencies already include `futures`, `regex`, `tokio`, +`url`, and `which`. Confirm `tokio` features are sufficient for the browser +collector: + +- current workspace features include `rt`, `time`, `macros`, `io-util`, and + `sync`; +- browser collector needs current-thread runtime and timers; +- if `chromiumoxide` requires extra Tokio features, add only the minimum + host-safe features needed. + +Dependency constraints: + +- Do not add these dependencies to runtime crates. +- Do not make `trusted-server-core` depend on browser automation or HTML + scraping crates. +- Keep the CLI crate wasm stub compiling by leaving all real audit modules under + `#[cfg(not(target_arch = "wasm32"))]` via `lib.rs` module gating. + +## Stage 1 — Add CLI argument surface + +Files: + +- `crates/trusted-server-cli/src/args.rs` +- `crates/trusted-server-cli/src/run.rs` + +Steps: + +1. Add `Command::Audit(AuditArgs)` to `args.rs`. +2. Add `AuditArgs` with: + - positional `url: String`; + - `#[arg(long)] js_assets: Option`; + - `#[arg(long)] config: Option`; + - `#[arg(long)] no_js_assets: bool`; + - `#[arg(long)] no_config: bool`; + - `#[arg(long)] force: bool`. +3. Use clap's default kebab-case flag names, so the struct field `js_assets` + maps to `--js-assets`. +4. Add parser tests: + - parses default audit URL; + - parses all custom options; + - `--no-js-assets` and `--no-config` can each parse; + - audit does not accept `--adapter`. +5. Add a dispatch match arm that calls `audit::run_audit()` with the injected + collector. +6. Ensure existing delegate command parser tests remain unchanged. + +Do not implement browser collection in this stage. + +## Stage 2 — Add audit module scaffold and output planning + +Files: + +- `crates/trusted-server-cli/src/lib.rs` +- `crates/trusted-server-cli/src/audit.rs` +- `crates/trusted-server-cli/src/audit/collector.rs` + +Steps: + +1. Register `mod audit;` in `lib.rs` under the existing non-wasm module gate. +2. Add collector data structs and `AuditCollector` trait. +3. Add `AuditOutputPlan` in `audit.rs`: + + ```rust + struct AuditOutputPlan { + js_assets_path: Option, + config_path: Option, + } + ``` + +4. Add `parse_audit_url(value: &str) -> CliResult`. +5. Add `resolve_output_plan(args: &AuditArgs) -> CliResult`. +6. Rules for `resolve_output_plan()`: + - reject both `no_js_assets` and `no_config`; + - default JS asset path to `js-assets.toml` unless disabled; + - default config path to `trusted-server.toml` unless disabled; + - resolve relative paths against `std::env::current_dir()`; + - preserve absolute paths; + - reject existing selected paths unless `force`; + - create no directories yet, or create only after all selected paths pass the + conflict check. +7. Add `prepare_output_paths(plan)` or integrate directory creation after + successful preflight. +8. Tests: + - URL parsing accepts HTTP/HTTPS; + - URL parsing rejects `file:`, `data:`, `chrome:`; + - both no-output flags reject with a clear message; + - default and custom paths resolve as expected; + - existing file fails without `--force`; + - existing file passes with `--force`; + - one conflicting path prevents all writes. + +Implementation note: keep path planning separate from browser collection so a +fake collector can record whether it was called. Use that to prove conflicts +short-circuit before collection. + +## Stage 3 — Port analyzer and artifact schema + +Files: + +- `crates/trusted-server-cli/src/audit.rs` +- `crates/trusted-server-cli/src/audit/analyzer.rs` + +Steps: + +1. Add serializable artifact structs in `audit.rs`: + - `AssetParty`; + - `AuditedAsset`; + - `DetectedIntegration`; + - `AuditArtifact`; + - `AuditOutputs`. +2. Port `analyze_collected_page()` from the old branch. +3. Preserve these analysis inputs: + - rendered HTML `