diff --git a/.cargo/config.toml b/.cargo/config.toml index e0e3f5be..2fce238e 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,5 +1,9 @@ [alias] test_details = ["test", "--target", "aarch64-apple-darwin"] +build_cli = ["build", "--package", "trusted-server-cli", "--target", "aarch64-apple-darwin"] +check_cli = ["check", "--package", "trusted-server-cli", "--target", "aarch64-apple-darwin"] +test_cli = ["test", "--package", "trusted-server-cli", "--target", "aarch64-apple-darwin"] +install_cli = ["install", "--path", "crates/trusted-server-cli", "--target", "aarch64-apple-darwin"] [build] target = "wasm32-wasip1" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index fbe95847..dfff83b5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -23,8 +23,10 @@ Closes # -- [ ] `cargo test --workspace` -- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --exclude trusted-server-cli` +- [ ] `cargo test --package trusted-server-cli --target "$(rustc -vV | sed -n 's/^host: //p')"` +- [ ] `cargo clippy --workspace --exclude trusted-server-cli --all-targets --all-features -- -D warnings` +- [ ] `cargo clippy --package trusted-server-cli --target "$(rustc -vV | sed -n 's/^host: //p')" --all-targets -- -D warnings` - [ ] `cargo fmt --all -- --check` - [ ] JS tests: `cd crates/js/lib && npx vitest run` - [ ] JS format: `cd crates/js/lib && npm run format` @@ -37,6 +39,6 @@ Closes # - [ ] Changes follow [CLAUDE.md](/CLAUDE.md) conventions - [ ] No `unwrap()` in production code — use `expect("should ...")` -- [ ] Uses `tracing` macros (not `println!`) +- [ ] Uses `log` macros (not `println!`) - [ ] New code has tests - [ ] No secrets or credentials committed diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index b6aba137..a9038c56 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -33,7 +33,34 @@ jobs: uses: actions-rust-lang/rustfmt@v1 - name: Run cargo clippy - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + run: cargo clippy --workspace --exclude trusted-server-cli --all-targets --all-features -- -D warnings + + format-rust-cli-host: + name: cargo clippy (trusted-server-cli host) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Retrieve Rust version + id: rust-version-cli + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + shell: bash + + - name: Set up rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version-cli.outputs.rust-version }} + target: wasm32-wasip1 + components: "clippy, rustfmt" + cache-shared-key: cargo-${{ runner.os }} + + - name: Retrieve Rust host target + id: rust-host-target + run: echo "host-target=$(rustc -vV | sed -n 's/^host: //p')" >> $GITHUB_OUTPUT + shell: bash + + - name: Run trusted-server-cli clippy + run: cargo clippy --package trusted-server-cli --target "${{ steps.rust-host-target.outputs.host-target }}" --all-targets -- -D warnings format-typescript: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5eea36a7..ebd625da 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,7 +44,33 @@ jobs: run: cargo install --git https://github.com/fastly/Viceroy viceroy - name: Run tests - run: cargo test --workspace + run: cargo test --workspace --exclude trusted-server-cli + + test-cli-host: + name: cargo test (trusted-server-cli host) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Retrieve Rust version + id: rust-version-cli + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + shell: bash + + - name: Set up Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version-cli.outputs.rust-version }} + target: wasm32-wasip1 + cache-shared-key: cargo-${{ runner.os }} + + - name: Retrieve Rust host target + id: rust-host-target + run: echo "host-target=$(rustc -vV | sed -n 's/^host: //p')" >> $GITHUB_OUTPUT + shell: bash + + - name: Run trusted-server-cli tests + run: cargo test --package trusted-server-cli --target "${{ steps.rust-host-target.outputs.host-target }}" test-typescript: name: vitest diff --git a/.gitignore b/.gitignore index af70c452..1234c631 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ # env .env* +trusted-server.toml +fastly.local.toml # backup **/*.rs.bk diff --git a/CLAUDE.md b/CLAUDE.md index ec76ee46..fa52b1b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,14 +54,20 @@ fastly compute publish ### Testing & Quality ```bash -# Run all Rust tests (uses viceroy) -cargo test --workspace +# Run wasm-target Rust tests for the existing runtime crates (uses viceroy) +cargo test --workspace --exclude trusted-server-cli + +# Run host-target CLI tests +cargo test --package trusted-server-cli --target "$(rustc -vV | sed -n 's/^host: //p')" # Format cargo fmt --all -- --check -# Lint -cargo clippy --workspace --all-targets --all-features -- -D warnings +# Lint wasm-target runtime crates +cargo clippy --workspace --exclude trusted-server-cli --all-targets --all-features -- -D warnings + +# Lint host-target CLI crate +cargo clippy --package trusted-server-cli --target "$(rustc -vV | sed -n 's/^host: //p')" --all-targets -- -D warnings # Check compilation cargo check @@ -268,11 +274,13 @@ IntegrationRegistration::builder(ID) Every PR must pass: 1. `cargo fmt --all -- --check` -2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` -3. `cargo test --workspace` -4. JS build and test (`cd crates/js/lib && npx vitest run`) -5. JS format (`cd crates/js/lib && npm run format`) -6. Docs format (`cd docs && npm run format`) +2. `cargo clippy --workspace --exclude trusted-server-cli --all-targets --all-features -- -D warnings` +3. `cargo test --workspace --exclude trusted-server-cli` +4. `cargo clippy --package trusted-server-cli --target "$(rustc -vV | sed -n 's/^host: //p')" --all-targets -- -D warnings` +5. `cargo test --package trusted-server-cli --target "$(rustc -vV | sed -n 's/^host: //p')"` +6. JS build and test (`cd crates/js/lib && npx vitest run`) +7. JS format (`cd crates/js/lib && npm run format`) +8. Docs format (`cd docs && npm run format`) --- @@ -282,7 +290,7 @@ Every PR must pass: 2. **Get approval** — for non-trivial changes, present a plan first. 3. **Implement incrementally** — small, testable changes. Every change should impact as little code as possible. -4. **Test after every change** — `cargo test --workspace`. +4. **Test after every change** — run the relevant Rust lane(s): `cargo test --workspace --exclude trusted-server-cli` for the runtime crates and `cargo test --package trusted-server-cli --target "$(rustc -vV | sed -n 's/^host: //p')"` for the CLI. 5. **Explain as you go** — describe what you changed and why. 6. **If blocked** — explain what's blocking and why. diff --git a/Cargo.lock b/Cargo.lock index 3688be30..52f77c27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,12 +63,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +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.100" @@ -126,6 +170,29 @@ dependencies = [ "syn 2.0.111", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" @@ -225,11 +292,20 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[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" @@ -253,6 +329,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" @@ -277,6 +359,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 0.13.3", + "serde", + "serde_json", + "thiserror 2.0.17", + "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" @@ -335,6 +482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -343,8 +491,22 @@ version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.111", ] [[package]] @@ -353,6 +515,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "compression-codecs" version = "0.4.37" @@ -391,6 +559,18 @@ dependencies = [ "yaml-rust2", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -436,6 +616,26 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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 +716,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,10 +728,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "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" @@ -541,7 +754,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.13.1", "smallvec", ] @@ -617,6 +830,33 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + [[package]] name = "der" version = "0.7.10" @@ -658,6 +898,18 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + [[package]] name = "digest" version = "0.9.0" @@ -720,6 +972,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" @@ -738,7 +996,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2 0.10.9", "subtle", @@ -808,6 +1066,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" @@ -826,7 +1090,7 @@ dependencies = [ "ff", "generic-array", "group", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -842,6 +1106,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -881,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -976,7 +1246,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", ] @@ -1029,6 +1299,16 @@ dependencies = [ "percent-encoding", ] +[[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" @@ -1100,6 +1380,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.32" @@ -1117,6 +1403,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" @@ -1128,6 +1423,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.16" @@ -1135,8 +1439,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1146,9 +1452,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1158,7 +1466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1235,6 +1543,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" @@ -1256,57 +1575,135 @@ dependencies = [ ] [[package]] -name = "iab_gpp" -version = "0.1.2" +name = "http-body-util" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3be2d0191a3376e0176bb3df53b2754c644ead6edd50d9494ee8fa376a70e02" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ - "bitstream-io", - "fnv", - "iab_gpp_derive", - "num-derive", - "num-iter", - "num-traits", - "prettyplease", - "proc-macro2", - "quote", - "strum_macros", - "syn 2.0.111", - "thiserror 2.0.17", - "walkdir", + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", ] [[package]] -name = "iab_gpp_derive" -version = "0.1.0" +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5acda598b043c6386d20fffe86c600b63c7ca4980ee9a28f7e9aaa15d749747" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "iana-time-zone" -version = "0.1.64" +name = "hyper" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", ] [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "hyper-rustls" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3be2d0191a3376e0176bb3df53b2754c644ead6edd50d9494ee8fa376a70e02" +dependencies = [ + "bitstream-io", + "fnv", + "iab_gpp_derive", + "num-derive", + "num-iter", + "num-traits", + "prettyplease", + "proc-macro2", + "quote", + "strum_macros", + "syn 2.0.111", + "thiserror 2.0.17", + "walkdir", +] + +[[package]] +name = "iab_gpp_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5acda598b043c6386d20fffe86c600b63c7ca4980ee9a28f7e9aaa15d749747" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ "cc", ] @@ -1438,6 +1835,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -1446,9 +1859,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" @@ -1530,6 +1949,21 @@ dependencies = [ "serde", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "security-framework 2.11.1", + "security-framework 3.6.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1545,6 +1979,15 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libm" version = "0.2.15" @@ -1553,9 +1996,9 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -1597,17 +2040,51 @@ checksum = "5ff94cb6aef6ee52afd2c69331e9109906d855e82bd241f3110dfdf6185899ab" dependencies = [ "bitflags 2.10.0", "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.17", ] +[[package]] +name = "lru-slab" +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.111", +] + [[package]] name = "matchit" version = "0.9.1" @@ -1636,6 +2113,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -1653,7 +2141,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -1711,6 +2199,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[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" @@ -1831,25 +2325,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.5", ] [[package]] @@ -1859,7 +2383,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.111", ] [[package]] @@ -1868,13 +2405,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.111", ] +[[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" @@ -1911,6 +2457,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" @@ -2002,6 +2554,61 @@ 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.17", + "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 = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "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.42" @@ -2024,8 +2631,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 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]] @@ -2035,7 +2652,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]] @@ -2047,6 +2674,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[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 = "redox_syscall" version = "0.5.18" @@ -2085,6 +2721,89 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +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", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "sync_wrapper", + "tokio", + "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.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ron" version = "0.12.0" @@ -2112,7 +2831,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -2146,15 +2865,50 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", - "windows-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 = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[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-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -2184,6 +2938,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" @@ -2197,6 +2966,61 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "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.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5685b6ae43bfcf7d2e7dfcfb5d8e8f61b46442c902531e41a32a9a8bf0ee0fb6" +dependencies = [ + "bitflags 2.10.0", + "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" @@ -2204,12 +3028,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "feef350c36147532e1b79ea5c1f3791373e61cbd9a6a2615413b3807bb164fb7" dependencies = [ "bitflags 2.10.0", - "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", @@ -2318,6 +3142,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" @@ -2342,12 +3177,28 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" 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" @@ -2355,7 +3206,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]] @@ -2380,7 +3231,17 @@ checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] [[package]] name = "spin" @@ -2404,6 +3265,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" @@ -2450,6 +3336,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" @@ -2470,6 +3365,30 @@ 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.3.4", + "once_cell", + "rustix", + "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" @@ -2570,6 +3489,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.49.0" @@ -2577,8 +3511,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -2592,6 +3531,16 @@ dependencies = [ "syn 2.0.111", ] +[[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 = "0.9.8" @@ -2653,6 +3602,45 @@ version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" +[[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", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[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" @@ -2707,6 +3695,33 @@ dependencies = [ "trusted-server-core", ] +[[package]] +name = "trusted-server-cli" +version = "0.1.0" +dependencies = [ + "base64", + "chromiumoxide", + "clap", + "derive_more", + "dialoguer", + "error-stack", + "futures", + "keyring", + "log", + "regex", + "reqwest 0.12.28", + "scraper", + "serde", + "serde_json", + "tempfile", + "tokio", + "toml 1.0.7+spec-1.1.0", + "trusted-server-core", + "url", + "uuid", + "which", +] + [[package]] name = "trusted-server-core" version = "0.1.0" @@ -2735,7 +3750,7 @@ dependencies = [ "log", "lol_html", "matchit", - "rand", + "rand 0.8.5", "regex", "serde", "serde_json", @@ -2771,6 +3786,29 @@ 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 = "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.17", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -2801,6 +3839,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[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" @@ -2817,6 +3861,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" @@ -2835,12 +3885,24 @@ 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" 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.18.1" @@ -2898,6 +3960,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" @@ -2926,6 +3997,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.106" @@ -2958,6 +4042,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" @@ -2968,6 +4062,27 @@ 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-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "8.0.0" @@ -2985,7 +4100,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]] @@ -3029,6 +4144,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" @@ -3047,6 +4173,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" @@ -3056,6 +4200,135 @@ 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.14" @@ -3174,6 +4447,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "serde", + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0b9f4230..d5fd029d 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", ] @@ -85,6 +86,12 @@ tokio = { version = "1.49", features = ["sync", "macros", "io-util", "rt", "time toml = "1.0" trusted-server-core = { path = "crates/trusted-server-core" } url = "2.5.8" +clap = { version = "4.5.51", features = ["derive"] } +reqwest = { version = "0.12.24", default-features = false, features = ["blocking", "json", "rustls-tls"] } +dialoguer = "0.12.0" +keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "windows-native", "sync-secret-service"] } +scraper = "0.24.0" +tempfile = "3.23.0" urlencoding = "2.1" uuid = { version = "1.18", features = ["v4"] } validator = { version = "0.20", features = ["derive"] } diff --git a/README.md b/README.md index 82dfe7b5..125e8a62 100644 --- a/README.md +++ b/README.md @@ -22,29 +22,44 @@ 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 -cargo build +# Install the host-target CLI from this checkout +# The alias targets Apple Silicon macOS. See the CLI guide for other hosts. +cargo install_cli -# Run tests -cargo test +# Create a starter config +ts config init -# Start local server -fastly compute serve +# Validate local config +ts config validate + +# Start local Fastly development +ts dev -a fastly + +# Audit a public page with a real Chromium browser +ts audit https://example.com ``` ## Development ```bash # Format code -cargo fmt +cargo fmt --all -- --check + +# Lint runtime crates (wasm target) +cargo clippy --workspace --exclude trusted-server-cli --all-targets --all-features -- -D warnings -# Lint -cargo clippy --workspace --all-targets --all-features -- -D warnings +# Lint CLI crate (host target) +cargo clippy --package trusted-server-cli --target "$(rustc -vV | sed -n 's/^host: //p')" --all-targets -- -D warnings -# Run tests -cargo test +# Run runtime crate tests (wasm target) +cargo test --workspace --exclude trusted-server-cli + +# Run CLI tests (host target alias, Apple Silicon macOS) +cargo test_cli ``` +`ts audit` is host-only and currently expects a local Chrome/Chromium installation. It checks common PATH names and standard macOS app bundle locations. + See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. ## License diff --git a/crates/integration-tests/README.md b/crates/integration-tests/README.md index c9af0e78..b9d76016 100644 --- a/crates/integration-tests/README.md +++ b/crates/integration-tests/README.md @@ -168,8 +168,8 @@ fixtures/ 1. A Docker container starts for the frontend framework, mapped to a fixed origin port (default 8888) -2. The WASM binary is pre-built with `TRUSTED_SERVER__PUBLISHER__ORIGIN_URL` - pointing to `http://127.0.0.1:8888` so the proxy knows where to forward +2. A rendered Viceroy config projects the integration-test application config + into the local config store under the fixed `ts-config` key 3. Viceroy spawns with the WASM binary on a random port 4. **HTTP tests**: reqwest sends requests to Viceroy and asserts on responses 5. **Browser tests**: Playwright opens Chromium pointing at Viceroy and verifies @@ -177,8 +177,8 @@ fixtures/ ### Why `--test-threads=1` / `workers: 1` -All tests share the same fixed origin port (8888). The trusted server config is -baked into the WASM binary at compile time with this port, so only one Docker +All tests share the same fixed origin port (8888). The integration-test app +config fixture points Trusted Server at this port, so only one Docker container can be bound to it at a time. ## CI diff --git a/crates/integration-tests/browser/global-setup.ts b/crates/integration-tests/browser/global-setup.ts index 04a72929..be34c04e 100644 --- a/crates/integration-tests/browser/global-setup.ts +++ b/crates/integration-tests/browser/global-setup.ts @@ -1,5 +1,7 @@ import { writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { resolve } from "node:path"; +import { execFileSync } from "node:child_process"; import { startContainer, startViceroy, @@ -16,14 +18,24 @@ const WASM_PATH = "../../../target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm", ); -const VICEROY_CONFIG = - process.env.VICEROY_CONFIG_PATH || - resolve(__dirname, "../fixtures/configs/viceroy-template.toml"); +const VICEROY_TEMPLATE = resolve( + __dirname, + "../fixtures/configs/viceroy-template.toml", +); +const APP_CONFIG = resolve( + __dirname, + "../fixtures/configs/trusted-server.integration.toml", +); +const RENDER_SCRIPT = resolve( + __dirname, + "../../../scripts/render-fastly-local-config.py", +); /** Persist current state so global-teardown can always clean up. */ function writeState(state: { baseUrl?: string; containerId?: string; + renderedConfigPath?: string; viceroyPid?: number; framework: string; }): void { @@ -33,6 +45,7 @@ function writeState(state: { async function globalSetup(): Promise { const framework = process.env.TEST_FRAMEWORK || "nextjs"; let containerId: string | undefined; + let renderedConfig: string | undefined; let viceroyPid: number | undefined; try { @@ -43,8 +56,22 @@ async function globalSetup(): Promise { // even if Viceroy startup fails below. writeState({ containerId, framework }); + renderedConfig = resolve( + tmpdir(), + `trusted-server-browser-${Date.now()}.toml`, + ); + execFileSync("python3", [ + RENDER_SCRIPT, + "--app-config", + APP_CONFIG, + "--template", + VICEROY_TEMPLATE, + "--output", + renderedConfig, + ]); + console.log(`[global-setup] Starting Viceroy (WASM: ${WASM_PATH})...`); - const viceroy = await startViceroy(WASM_PATH, VICEROY_CONFIG); + const viceroy = await startViceroy(WASM_PATH, renderedConfig); viceroyPid = viceroy.process.pid; console.log(`[global-setup] Viceroy ready at ${viceroy.baseUrl}`); @@ -53,6 +80,7 @@ async function globalSetup(): Promise { writeState({ baseUrl: viceroy.baseUrl, containerId, + renderedConfigPath: renderedConfig, viceroyPid, framework, }); @@ -61,6 +89,12 @@ async function globalSetup(): Promise { console.error("[global-setup] Setup failed, cleaning up..."); if (viceroyPid) await stopViceroy(viceroyPid); if (containerId) stopContainer(containerId); + try { + const { unlinkSync } = await import("node:fs"); + if (renderedConfig) unlinkSync(renderedConfig); + } catch { + // Rendered config may not exist + } // Remove partial state file since we cleaned up manually try { diff --git a/crates/integration-tests/browser/global-teardown.ts b/crates/integration-tests/browser/global-teardown.ts index 97bdf282..1eb2b42f 100644 --- a/crates/integration-tests/browser/global-teardown.ts +++ b/crates/integration-tests/browser/global-teardown.ts @@ -5,7 +5,11 @@ import { stopContainer, stopViceroy } from "./helpers/infra.js"; const STATE_FILE = resolve(__dirname, ".browser-test-state.json"); async function globalTeardown(): Promise { - let state: { containerId?: string; viceroyPid?: number }; + let state: { + containerId?: string; + renderedConfigPath?: string; + viceroyPid?: number; + }; try { state = JSON.parse(readFileSync(STATE_FILE, "utf-8")); } catch { @@ -23,6 +27,14 @@ async function globalTeardown(): Promise { stopContainer(state.containerId); } + if (state.renderedConfigPath) { + try { + unlinkSync(state.renderedConfigPath); + } catch { + // Already removed + } + } + try { unlinkSync(STATE_FILE); } catch { diff --git a/crates/integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/integration-tests/fixtures/configs/trusted-server.integration.toml new file mode 100644 index 00000000..9bab25ea --- /dev/null +++ b/crates/integration-tests/fixtures/configs/trusted-server.integration.toml @@ -0,0 +1,101 @@ +[[handlers]] +path = "^/secure" +username = "user" +password = "pass" + +[[handlers]] +path = "^/admin" +username = "admin" +password = "changeme" + +[publisher] +domain = "test-publisher.com" +cookie_domain = ".test-publisher.com" +origin_url = "http://127.0.0.1:8888" +proxy_secret = "change-me-proxy-secret" + +[edge_cookie] +secret_key = "trusted-server" + +[request_signing] +enabled = false +config_store_id = "" +secret_store_id = "" + +[integrations.prebid] +enabled = true +server_url = "http://68.183.113.79:8000" +timeout_ms = 1000 +bidders = ["kargo", "appnexus", "openx"] +debug = false +client_side_bidders = ["rubicon"] + +[integrations.nextjs] +enabled = false +rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] +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.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 + +[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 + +[proxy] + +[auction] +enabled = true +providers = ["prebid"] +timeout_ms = 2000 +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" + +[integrations.adserver_mock] +enabled = false +endpoint = "https://origin-mocktioneer.cdintel.com/adserver/mediate" +timeout_ms = 1000 + +[integrations.adserver_mock.context_query_params] +permutive_segments = "permutive" diff --git a/crates/integration-tests/tests/common/runtime.rs b/crates/integration-tests/tests/common/runtime.rs index bf8b795a..0f4ba211 100644 --- a/crates/integration-tests/tests/common/runtime.rs +++ b/crates/integration-tests/tests/common/runtime.rs @@ -71,10 +71,10 @@ pub trait RuntimeProcessHandle: Send + Sync {} /// Trait defining how to run the trusted-server on different platforms. /// -/// The application configuration (origin URL, integrations, etc.) is baked -/// into the WASM binary at build time via `build.rs`. The runtime environment -/// only needs the WASM binary path and its own platform-specific config -/// (e.g. Viceroy's `fastly.toml` for KV stores and secret stores). +/// The application configuration is loaded at runtime from the platform config +/// store. Test environments render a local Viceroy/Fastly config that projects +/// a canonical TOML payload into the fixed `ts-config` key before spawning the +/// runtime. pub trait RuntimeEnvironment: Send + Sync { /// Platform identifier (e.g., "fastly", "cloudflare") fn id(&self) -> &'static str; @@ -112,8 +112,8 @@ pub fn wasm_binary_path() -> PathBuf { /// Get the fixed origin port used for Docker container port mapping. /// -/// This must match the port baked into the WASM binary via -/// `TRUSTED_SERVER__PUBLISHER__ORIGIN_URL` at build time. +/// This must match the origin URL stored in the integration-test application +/// config fixture that is projected into the local config store. pub fn origin_port() -> u16 { match std::env::var("INTEGRATION_ORIGIN_PORT") { Ok(value) => value diff --git a/crates/integration-tests/tests/environments/fastly.rs b/crates/integration-tests/tests/environments/fastly.rs index ec758432..e0a666f0 100644 --- a/crates/integration-tests/tests/environments/fastly.rs +++ b/crates/integration-tests/tests/environments/fastly.rs @@ -3,15 +3,15 @@ use crate::common::runtime::{ }; use error_stack::ResultExt as _; use std::io::{BufRead as _, BufReader}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; /// Fastly Compute runtime using Viceroy local simulator. /// -/// Spawns a `viceroy` child process with the WASM binary and the -/// Viceroy-specific `fastly.toml` config (KV stores, secrets). -/// The application config (origin URL, integrations) is baked into -/// the WASM binary at build time. +/// Spawns a `viceroy` child process with the WASM binary and a rendered +/// Viceroy-specific config (KV stores, secrets, and runtime app config store +/// contents). pub struct FastlyViceroy; impl RuntimeEnvironment for FastlyViceroy { @@ -22,7 +22,7 @@ impl RuntimeEnvironment for FastlyViceroy { fn spawn(&self, wasm_path: &Path) -> TestResult { let port = super::find_available_port()?; - let viceroy_config = self.viceroy_config_path(); + let viceroy_config = self.render_viceroy_config()?; let mut child = Command::new("viceroy") .arg(wasm_path) @@ -48,7 +48,10 @@ impl RuntimeEnvironment for FastlyViceroy { } // Wrap immediately so Drop::drop kills the process if readiness check fails - let handle = ViceroyHandle { child }; + let handle = ViceroyHandle { + child, + rendered_config_path: viceroy_config, + }; let base_url = format!("http://127.0.0.1:{port}"); // Fastly exposes a dedicated `/health` route, so root fallback only @@ -63,13 +66,45 @@ impl RuntimeEnvironment for FastlyViceroy { } impl FastlyViceroy { - /// Path to the Viceroy-specific `fastly.toml` template. - /// - /// This contains `[local_server]` configuration (backends, KV stores, - /// secret stores) that Viceroy needs, separate from the application config. - fn viceroy_config_path(&self) -> std::path::PathBuf { - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("fixtures/configs/viceroy-template.toml") + /// Render a Viceroy config with the application config projected into the + /// runtime config store. + fn render_viceroy_config(&self) -> TestResult { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let template = manifest_dir.join("fixtures/configs/viceroy-template.toml"); + let app_config = manifest_dir.join("fixtures/configs/trusted-server.integration.toml"); + let unique_suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("should compute monotonic temp suffix") + .as_nanos(); + let output = std::env::temp_dir().join(format!( + "trusted-server-viceroy-{unique_suffix}.toml" + )); + let render_script = manifest_dir + .parent() + .and_then(Path::parent) + .expect("should find repository root") + .join("scripts/render-fastly-local-config.py"); + + let status = Command::new("python3") + .arg(render_script) + .arg("--app-config") + .arg(app_config) + .arg("--template") + .arg(template) + .arg("--output") + .arg(&output) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .status() + .change_context(TestError::RuntimeSpawn) + .attach("Failed to render Viceroy config")?; + + if !status.success() { + return Err(error_stack::Report::new(TestError::RuntimeSpawn) + .attach("render-fastly-local-config.py exited unsuccessfully")); + } + + Ok(output) } } @@ -79,6 +114,7 @@ impl FastlyViceroy { /// preventing orphaned Viceroy processes. struct ViceroyHandle { child: Child, + rendered_config_path: PathBuf, } impl RuntimeProcessHandle for ViceroyHandle {} @@ -87,5 +123,6 @@ impl Drop for ViceroyHandle { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); + let _ = std::fs::remove_file(&self.rendered_config_path); } } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index a4566ad3..fdfd3c0c 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1,4 +1,4 @@ -use error_stack::Report; +use error_stack::{Report, ResultExt}; use fastly::http::Method; use fastly::{Error, Request, Response}; use log_fastly::Logger; @@ -14,7 +14,7 @@ use trusted_server_core::error::TrustedServerError; use trusted_server_core::geo::GeoInfo; use trusted_server_core::http_util::sanitize_forwarded_headers; use trusted_server_core::integrations::IntegrationRegistry; -use trusted_server_core::platform::RuntimeServices; +use trusted_server_core::platform::{RuntimeServices, StoreName}; use trusted_server_core::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -24,8 +24,10 @@ use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, handle_verify_signature, }; -use trusted_server_core::settings::Settings; -use trusted_server_core::settings_data::get_settings; +use trusted_server_core::runtime_config::{ + load_runtime_config, APPLICATION_CONFIG_KEY, APPLICATION_CONFIG_STORE_NAME, +}; +use trusted_server_core::settings::{EdgeCookie, Publisher, Settings}; mod error; mod platform; @@ -39,21 +41,31 @@ use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore} fn main(req: Request) -> Result { init_logger(); - // Keep the health probe independent from settings loading and routing so - // readiness checks still get a cheap liveness response during startup. - if req.get_method() == Method::GET && req.get_path() == "/health" { - return Ok(Response::from_status(200).with_body_text_plain("ok")); - } + // Start with an unavailable KV slot. Consent-dependent routes lazily + // replace it with the configured store at dispatch time so unrelated + // routes stay available when consent persistence is misconfigured. + 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() { - Ok(s) => s, + let loaded_config = match load_settings_from_config_store(&runtime_services) { + Ok(config) => config, Err(e) => { log::error!("Failed to load settings: {:?}", e); return Ok(to_error_response(&e)); } }; + let settings = loaded_config.settings; + log::debug!("Loaded runtime config hash={}", loaded_config.config_hash); log::debug!("Settings {settings:?}"); + // `/health` intentionally depends on successful runtime config loading. + // A missing or invalid `ts-config` payload means the service is not ready + // to serve application traffic, so health checks should fail as well. + if req.get_method() == Method::GET && req.get_path() == "/health" { + return Ok(Response::from_status(200).with_body_text_plain("ok")); + } + // Build the auction orchestrator once at startup let orchestrator = match build_orchestrator(&settings) { Ok(orchestrator) => orchestrator, @@ -71,13 +83,6 @@ fn main(req: Request) -> Result { } }; - // Start with an unavailable KV slot. Consent-dependent routes lazily - // replace it with the configured store at dispatch time so unrelated - // routes stay available when consent persistence is misconfigured. - let kv_store = std::sync::Arc::new(UnavailableKvStore) - as std::sync::Arc; - let runtime_services = build_runtime_services(&req, kv_store); - futures::executor::block_on(route_request( &settings, &orchestrator, @@ -87,6 +92,45 @@ fn main(req: Request) -> Result { )) } +fn load_settings_from_config_store( + runtime_services: &RuntimeServices, +) -> Result> { + let store_name = StoreName::from(APPLICATION_CONFIG_STORE_NAME); + let payload = runtime_services + .config_store() + .get(&store_name, APPLICATION_CONFIG_KEY) + .change_context(TrustedServerError::Configuration { + message: format!( + "Failed to read application config from store `{}` key `{}`", + APPLICATION_CONFIG_STORE_NAME, APPLICATION_CONFIG_KEY + ), + })?; + + let loaded = load_runtime_config(&payload)?; + + if !loaded.settings.proxy.certificate_check { + log::warn!( + "INSECURE: proxy.certificate_check is disabled — TLS certificates will NOT be verified" + ); + } + + if EdgeCookie::is_placeholder_secret_key(loaded.settings.edge_cookie.secret_key.expose()) { + log::warn!( + "INSECURE: edge_cookie.secret_key is set to a default placeholder — \ + HMAC-SHA256 signatures can be forged" + ); + } + + if Publisher::is_placeholder_proxy_secret(loaded.settings.publisher.proxy_secret.expose()) { + log::warn!( + "INSECURE: publisher.proxy_secret is set to a default placeholder — \ + XChaCha20-Poly1305 encrypted URLs can be decrypted by anyone" + ); + } + + Ok(loaded) +} + async fn route_request( settings: &Settings, orchestrator: &AuctionOrchestrator, @@ -109,7 +153,7 @@ async fn route_request( None }); - // `get_settings()` should already have rejected invalid handler regexes. + // Runtime config 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 00000000..25f77fe8 --- /dev/null +++ b/crates/trusted-server-cli/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "trusted-server-cli" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "ts" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +base64 = { workspace = true } +clap = { workspace = true } +dialoguer = { workspace = true } +derive_more = { workspace = true, features = ["display"] } +error-stack = { workspace = true } +log = { workspace = true } +regex = { workspace = true } +reqwest = { workspace = true } +scraper = { workspace = true } +serde = { workspace = true } +futures = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "time"] } +which = { workspace = true } +chromiumoxide = "0.9.1" +serde_json = { workspace = true } +tempfile = { workspace = true } +toml = { workspace = true } +trusted-server-core = { workspace = true } +url = { workspace = true } +keyring = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/trusted-server-cli/src/audit.rs b/crates/trusted-server-cli/src/audit.rs new file mode 100644 index 00000000..e3db48ea --- /dev/null +++ b/crates/trusted-server-cli/src/audit.rs @@ -0,0 +1,326 @@ +mod analyzer; +mod browser_collector; +mod collector; +mod http_collector; + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use error_stack::{Report, ResultExt}; +use serde::Serialize; +use url::Url; + +use crate::config::{STARTER_CONFIG_TEMPLATE, ensure_writable_path}; +use crate::error::CliError; + +use analyzer::{analyze_collected_page, extract_gtm_container_id}; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum AssetParty { + FirstParty, + ThirdParty, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct AuditedAsset { + pub kind: String, + pub url: String, + pub host: String, + pub party: AssetParty, + pub integration: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct DetectedIntegration { + pub id: String, + pub evidence: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +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, +} + +#[derive(Debug, Clone)] +pub struct AuditOutputs { + pub artifact: AuditArtifact, + pub js_assets_toml: String, + pub draft_config_toml: String, +} + +#[cfg_attr(not(test), allow(dead_code))] +pub fn analyze_html(target_url: &Url, html: &str) -> Result> { + analyzer::analyze_html(target_url, html) +} + +pub fn perform_audit(target_url: &Url) -> Result> { + let collected = browser_collector::collect_page_via_browser(target_url)?; + build_audit_outputs(collected) +} + +fn build_audit_outputs( + collected: collector::CollectedPage, +) -> Result> { + let artifact = analyze_collected_page(&collected)?; + let final_url = collected.final_url().map_err(|error| { + Report::new(CliError::Audit).attach(format!("invalid final URL: {error}")) + })?; + let js_assets_toml = toml::to_string_pretty(&artifact).change_context(CliError::Audit)?; + let draft_config_toml = build_draft_config(&final_url, &artifact)?; + + Ok(AuditOutputs { + artifact, + js_assets_toml, + draft_config_toml, + }) +} + +pub fn write_audit_outputs( + outputs: &AuditOutputs, + js_assets_path: Option<&Path>, + config_path: Option<&Path>, + force: bool, +) -> Result, Report> { + let mut written_paths = Vec::new(); + + if let Some(path) = js_assets_path { + ensure_writable_path(path, force)?; + fs::write(path, &outputs.js_assets_toml).change_context(CliError::Io)?; + written_paths.push(path.display().to_string()); + } + + if let Some(path) = config_path { + ensure_writable_path(path, force)?; + fs::write(path, &outputs.draft_config_toml).change_context(CliError::Io)?; + written_paths.push(path.display().to_string()); + } + + Ok(written_paths) +} + +fn build_draft_config( + target_url: &Url, + artifact: &AuditArtifact, +) -> Result> { + let mut draft = STARTER_CONFIG_TEMPLATE.to_string(); + let host = target_url + .host_str() + .ok_or_else(|| Report::new(CliError::Audit).attach("audited URL is missing a host"))?; + let origin = format!("{}://{}", target_url.scheme(), host); + + draft = replace_once( + &draft, + "domain = \"test-publisher.com\"", + &format!("domain = \"{host}\""), + )?; + draft = replace_once( + &draft, + "cookie_domain = \".test-publisher.com\"", + &format!("cookie_domain = \".{host}\""), + )?; + draft = replace_once( + &draft, + "origin_url = \"https://origin.test-publisher.com\"", + &format!("origin_url = \"{origin}\""), + )?; + + let detected = artifact + .detected_integrations + .iter() + .map(|integration| integration.id.as_str()) + .collect::>(); + + if detected.contains("gpt") { + draft = replace_once( + &draft, + "[integrations.gpt]\nenabled = false", + "[integrations.gpt]\nenabled = true", + )?; + } + if detected.contains("didomi") { + draft = replace_once( + &draft, + "[integrations.didomi]\nenabled = false", + "[integrations.didomi]\nenabled = true", + )?; + } + if detected.contains("datadome") { + draft = replace_once( + &draft, + "[integrations.datadome]\nenabled = false", + "[integrations.datadome]\nenabled = true", + )?; + } + + if let Some(gtm_id) = extract_gtm_container_id(artifact) { + draft = replace_once( + &draft, + "[integrations.google_tag_manager]\nenabled = false\ncontainer_id = \"GTM-XXXXXX\"", + &format!( + "[integrations.google_tag_manager]\nenabled = true\ncontainer_id = \"{gtm_id}\"" + ), + )?; + } + + let inferred_only = detected + .iter() + .filter(|integration| { + !matches!( + **integration, + "gpt" | "didomi" | "datadome" | "google_tag_manager" + ) + }) + .copied() + .collect::>(); + + if !inferred_only.is_empty() { + draft.push_str("\n# Audit findings requiring manual review\n"); + for integration in inferred_only { + draft.push_str(&format!( + "# - Detected {integration}; review the corresponding [integrations.{integration}] section before enabling it.\n" + )); + } + } + + Ok(draft) +} + +fn replace_once( + haystack: &str, + needle: &str, + replacement: &str, +) -> Result> { + let Some(index) = haystack.find(needle) else { + return Err(Report::new(CliError::Audit).attach(format!( + "failed to update starter config because `{needle}` was not found" + ))); + }; + + let mut output = String::with_capacity(haystack.len() - needle.len() + replacement.len()); + output.push_str(&haystack[..index]); + output.push_str(replacement); + output.push_str(&haystack[index + needle.len()..]); + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn analyzes_html_and_detects_integrations() { + let url = Url::parse("https://publisher.example/page").expect("should parse URL"); + let html = r#" + + + Example Publisher + + + + + "#; + + let artifact = analyze_html(&url, html).expect("should analyze HTML"); + + assert_eq!(artifact.page_title.as_deref(), Some("Example Publisher")); + assert_eq!(artifact.js_asset_count, 2, "should count script assets"); + assert!( + artifact + .detected_integrations + .iter() + .any(|integration| integration.id == "google_tag_manager"), + "should detect GTM" + ); + assert!( + artifact + .detected_integrations + .iter() + .any(|integration| integration.id == "gpt"), + "should detect GPT" + ); + } + + #[test] + fn builds_draft_config_with_detected_integrations() { + let url = Url::parse("https://publisher.example/page").expect("should parse URL"); + let artifact = AuditArtifact { + audited_url: url.to_string(), + page_title: Some("Example".to_string()), + js_asset_count: 1, + third_party_asset_count: 1, + detected_integrations: vec![ + DetectedIntegration { + id: "google_tag_manager".to_string(), + evidence: "GTM-ABCD123".to_string(), + }, + DetectedIntegration { + id: "gpt".to_string(), + evidence: "gpt".to_string(), + }, + ], + assets: vec![AuditedAsset { + kind: "script".to_string(), + url: "https://www.googletagmanager.com/gtm.js?id=GTM-ABCD123".to_string(), + host: "www.googletagmanager.com".to_string(), + party: AssetParty::ThirdParty, + integration: Some("google_tag_manager".to_string()), + }], + warnings: Vec::new(), + }; + + let draft = build_draft_config(&url, &artifact).expect("should build draft config"); + + assert!( + draft.contains("domain = \"publisher.example\""), + "should replace publisher domain" + ); + assert!( + draft.contains("enabled = true\ncontainer_id = \"GTM-ABCD123\""), + "should enable GTM with detected container ID" + ); + assert!( + draft.contains("[integrations.gpt]\nenabled = true"), + "should enable GPT" + ); + } + + #[test] + fn build_audit_outputs_uses_final_redirected_url_for_config() { + let collected = collector::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 outputs = build_audit_outputs(collected).expect("should build audit outputs"); + + assert_eq!( + outputs.artifact.audited_url, "https://www.publisher.example/landing", + "should report the final audited URL" + ); + assert!( + outputs + .draft_config_toml + .contains("domain = \"www.publisher.example\""), + "should derive the config domain from the final URL" + ); + assert!( + outputs + .draft_config_toml + .contains("origin_url = \"https://www.publisher.example\""), + "should derive the config origin from the final URL" + ); + } +} 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 00000000..9e2c7c7a --- /dev/null +++ b/crates/trusted-server-cli/src/audit/analyzer.rs @@ -0,0 +1,347 @@ +use std::collections::BTreeMap; + +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::CliError; +use error_stack::Report; + +pub fn analyze_collected_page( + collected: &CollectedPage, +) -> Result> { + let final_url = collected.final_url().map_err(|error| { + Report::new(CliError::Audit).attach(format!("invalid final URL: {error}")) + })?; + let requested_url = collected.requested_url().map_err(|error| { + Report::new(CliError::Audit).attach(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 + && 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(); + + Ok(AuditArtifact { + audited_url: final_url.to_string(), + page_title: collected.page_title.clone().or(derived_title), + js_asset_count: assets.len(), + third_party_asset_count, + detected_integrations: integrations + .into_iter() + .map(|(id, evidence)| DetectedIntegration { id, evidence }) + .collect(), + assets, + warnings, + }) +} + +#[cfg_attr(not(test), allow(dead_code))] +pub fn analyze_html(target_url: &Url, html: &str) -> Result> { + analyze_collected_page(&CollectedPage { + requested_url: target_url.to_string(), + final_url: target_url.to_string(), + page_title: None, + html: html.to_string(), + script_tags: Vec::new(), + network_requests: Vec::new(), + warnings: Vec::new(), + }) +} + +fn insert_asset( + assets_by_url: &mut BTreeMap, + page_url: &Url, + asset_url: &Url, + integration: Option, +) { + 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, + }); +} + +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 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 asset_host == page_host + || asset_host.ends_with(&format!(".{page_host}")) + || page_host.ends_with(&format!(".{asset_host}")) + { + AssetParty::FirstParty + } else { + AssetParty::ThirdParty + } +} + +pub 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 fn detect_integrations_from_inline_script(script: &str) -> Vec<(String, String)> { + let mut matches = Vec::new(); + let gtm_regex = Regex::new(r"GTM-[A-Z0-9]+$").expect("should compile GTM regex"); + + 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 fn extract_gtm_container_id(artifact: &AuditArtifact) -> Option { + let regex = Regex::new(r"GTM-[A-Z0-9]+$").expect("should compile GTM regex"); + + for integration in &artifact.detected_integrations { + if integration.id == "google_tag_manager" && regex.is_match(&integration.evidence) { + return Some(integration.evidence.clone()); + } + } + + for asset in &artifact.assets { + if asset.integration.as_deref() == Some("google_tag_manager") + && let Some(matched) = 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}; + + #[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("Example Publisher".to_string()), + html: r#""#.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.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_deduplicates_scripts() { + 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/a.js".to_string()), + inline_text: None, + }], + network_requests: vec![CollectedRequest { + url: "https://cdn.example.com/a.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" + ); + } + + #[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" + ); + } +} 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 00000000..cf55b9b1 --- /dev/null +++ b/crates/trusted-server-cli/src/audit/browser_collector.rs @@ -0,0 +1,323 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use chromiumoxide::ArcHttpRequest; +use chromiumoxide::browser::{Browser, BrowserConfig}; +use error_stack::{Report, ResultExt}; +use futures::StreamExt as _; +use serde::Deserialize; +use tokio::runtime::Builder; +use tokio::time::sleep; +use url::Url; +use which::which; + +use crate::audit::collector::{CollectedPage, CollectedRequest, CollectedScriptTag}; +use crate::error::CliError; + +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); + +pub fn collect_page_via_browser(target_url: &Url) -> Result> { + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .change_context(CliError::Audit) + .attach("failed to build Tokio runtime for browser audit")?; + + runtime.block_on(collect_page_via_browser_async(target_url)) +} + +async fn collect_page_via_browser_async( + target_url: &Url, +) -> Result> { + let chrome_executable = find_browser_executable()?; + let config = BrowserConfig::builder() + .chrome_executable(chrome_executable) + .new_headless_mode() + .build() + .map_err(|error| Report::new(CliError::Audit).attach(error)) + .attach("failed to build Chromium configuration for audit")?; + + let (mut browser, mut handler) = Browser::launch(config) + .await + .change_context(CliError::Audit) + .attach("failed to launch Chrome/Chromium for audit")?; + + let handler_task = tokio::spawn(async move { + while let Some(event) = handler.next().await { + if event.is_err() { + break; + } + } + }); + + let page = browser + .new_page("about:blank") + .await + .change_context(CliError::Audit) + .attach("failed to create browser page for audit")?; + + page.evaluate_on_new_document( + r#" + Object.defineProperty(Object.getPrototypeOf(navigator), 'webdriver', { + get: () => false, + }); + "#, + ) + .await + .change_context(CliError::Audit) + .attach("failed to inject browser audit init script")?; + + page.goto(target_url.as_str()) + .await + .change_context(CliError::Audit) + .attach(format!("failed to navigate to `{target_url}`"))?; + + let navigation_response = page + .wait_for_navigation_response() + .await + .change_context(CliError::Audit) + .attach("failed to read main document navigation response")?; + validate_navigation_response(target_url, 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 + .change_context(CliError::Audit) + .attach("failed to read final page URL")? + .ok_or_else(|| { + Report::new(CliError::Audit).attach("browser page URL was empty after navigation") + })?; + let page_title = page + .get_title() + .await + .change_context(CliError::Audit) + .attach("failed to read page title")?; + let html = page + .content() + .await + .change_context(CliError::Audit) + .attach("failed to read rendered page HTML")?; + + 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 + .change_context(CliError::Audit) + .attach("failed to read rendered script tags")? + .into_value() + .change_context(CliError::Audit) + .attach("failed to decode rendered script tag data")?; + + let network_requests: Vec = page + .evaluate( + r#"() => performance.getEntriesByType('resource').map((entry) => ({ + url: entry.name, + initiator_type: entry.initiatorType || null, + }))"#, + ) + .await + .change_context(CliError::Audit) + .attach("failed to read browser performance resource entries")? + .into_value() + .change_context(CliError::Audit) + .attach("failed to decode browser performance resource data")?; + + browser + .close() + .await + .change_context(CliError::Audit) + .attach("failed to close browser after audit")?; + let _ = handler_task.await; + + 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) -> Result> { + 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 + .change_context(CliError::Audit)? + .into_value() + .change_context(CliError::Audit)?; + let resource_count: usize = page + .evaluate("performance.getEntriesByType('resource').length") + .await + .change_context(CliError::Audit)? + .into_value() + .change_context(CliError::Audit)?; + + 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( + target_url: &Url, + navigation_response: ArcHttpRequest, +) -> Result<(), Report> { + if !matches!(target_url.scheme(), "http" | "https") { + return Ok(()); + } + + let request = navigation_response.ok_or_else(|| { + Report::new(CliError::Audit) + .attach("browser audit did not capture the main document response") + })?; + + if let Some(failure_text) = &request.failure_text { + return Err(Report::new(CliError::Audit) + .attach(format!("main document request failed: {failure_text}"))); + } + + let response = request.response.as_ref().ok_or_else(|| { + Report::new(CliError::Audit) + .attach("browser audit did not capture the main document HTTP response") + })?; + + if is_successful_navigation_status(response.status) { + return Ok(()); + } + + Err(Report::new(CliError::Audit).attach(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() -> Result> { + for candidate in [ + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + "chrome", + "Google Chrome", + "Google Chrome for Testing", + ] { + 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::new(CliError::Audit).attach( + "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_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(not(target_os = "macos"))] + { + &[] + } +} + +#[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(404)); + assert!(!is_successful_navigation_status(500)); + } + + #[cfg(target_os = "macos")] + #[test] + fn browser_fallbacks_include_standard_macos_google_chrome_path() { + assert!(browser_executable_fallbacks().iter().any(|candidate| { + *candidate == "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + })); + } +} 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 00000000..be9a5315 --- /dev/null +++ b/crates/trusted-server-cli/src/audit/collector.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; +use url::Url; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +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, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct CollectedScriptTag { + pub src: Option, + pub inline_text: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct CollectedRequest { + pub url: String, + pub method: String, + pub resource_type: Option, + pub status: Option, +} + +impl CollectedPage { + pub fn requested_url(&self) -> Result { + Url::parse(&self.requested_url) + } + + pub fn final_url(&self) -> Result { + Url::parse(&self.final_url) + } +} diff --git a/crates/trusted-server-cli/src/audit/http_collector.rs b/crates/trusted-server-cli/src/audit/http_collector.rs new file mode 100644 index 00000000..4dcce8a6 --- /dev/null +++ b/crates/trusted-server-cli/src/audit/http_collector.rs @@ -0,0 +1,79 @@ +use error_stack::{Report, ResultExt}; +use reqwest::blocking::Client; +use scraper::{Html, Selector}; +use url::Url; + +use crate::audit::collector::{CollectedPage, CollectedRequest, CollectedScriptTag}; +use crate::error::CliError; + +#[allow(dead_code)] +pub fn collect_page_via_http(target_url: &Url) -> Result> { + let client = Client::builder() + .user_agent("trusted-server-cli/0.1") + .redirect(reqwest::redirect::Policy::limited(10)) + .build() + .change_context(CliError::Audit)?; + + let response = client + .get(target_url.clone()) + .send() + .change_context(CliError::Audit) + .attach(format!("failed to load `{}`", target_url))?; + + let final_url = response.url().clone(); + let status = response.status(); + if !status.is_success() { + return Err( + Report::new(CliError::Audit).attach(format!("audit request returned HTTP {status}")) + ); + } + + let body = response.text().change_context(CliError::Audit)?; + let document = Html::parse_document(&body); + let title_selector = Selector::parse("title").expect("should parse title selector"); + let script_selector = Selector::parse("script").expect("should parse script selector"); + let page_title = document + .select(&title_selector) + .next() + .map(|element| { + element + .text() + .collect::>() + .join(" ") + .trim() + .to_string() + }) + .filter(|title| !title.is_empty()); + + let mut script_tags = Vec::new(); + for element in document.select(&script_selector) { + script_tags.push(CollectedScriptTag { + src: element + .value() + .attr("src") + .and_then(|src| final_url.join(src).ok()) + .map(|url| url.to_string()), + inline_text: element + .value() + .attr("src") + .is_none() + .then(|| element.text().collect::>().join(" ")) + .filter(|text| !text.trim().is_empty()), + }); + } + + Ok(CollectedPage { + requested_url: target_url.to_string(), + final_url: final_url.to_string(), + page_title, + html: body, + script_tags, + network_requests: vec![CollectedRequest { + url: final_url.to_string(), + method: "GET".to_string(), + resource_type: Some("document".to_string()), + status: Some(status.as_u16()), + }], + warnings: Vec::new(), + }) +} diff --git a/crates/trusted-server-cli/src/config.rs b/crates/trusted-server-cli/src/config.rs new file mode 100644 index 00000000..9846007d --- /dev/null +++ b/crates/trusted-server-cli/src/config.rs @@ -0,0 +1,133 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use error_stack::{Report, ResultExt}; +use serde::Serialize; +use trusted_server_core::runtime_config::LoadedRuntimeConfig; + +use crate::error::CliError; + +pub const DEFAULT_CONFIG_PATH: &str = "trusted-server.toml"; +pub const STARTER_CONFIG_TEMPLATE: &str = include_str!("../../../trusted-server.example.toml"); + +#[derive(Debug)] +pub struct ValidatedConfig { + pub path: PathBuf, + pub loaded: LoadedRuntimeConfig, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct ValidateConfigJson { + pub valid: bool, + pub path: String, + pub config_hash: Option, + pub errors: Vec, +} + +pub fn resolve_config_path(path: Option<&Path>) -> Result> { + let candidate = match path { + Some(path) if path.is_absolute() => path.to_path_buf(), + Some(path) => std::env::current_dir() + .change_context(CliError::Io)? + .join(path), + None => std::env::current_dir() + .change_context(CliError::Io)? + .join(DEFAULT_CONFIG_PATH), + }; + + Ok(candidate) +} + +pub fn ensure_writable_path(path: &Path, force: bool) -> Result<(), Report> { + if path.exists() && !force { + return Err(Report::new(CliError::Io).attach(format!( + "refusing to overwrite existing file `{}`; re-run with --force", + path.display() + ))); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).change_context(CliError::Io)?; + } + + Ok(()) +} + +pub fn write_starter_config(path: &Path, force: bool) -> Result<(), Report> { + ensure_writable_path(path, force)?; + fs::write(path, STARTER_CONFIG_TEMPLATE).change_context(CliError::Io) +} + +pub fn load_validated_config(path: Option<&Path>) -> Result> { + let resolved_path = resolve_config_path(path)?; + + let original_toml = fs::read_to_string(&resolved_path).map_err(|error| { + let hint = format!( + "failed to read config `{}`: {error}. Hint: run `ts config init` or pass `--config `.", + resolved_path.display() + ); + Report::new(CliError::Configuration).attach(hint) + })?; + + let loaded = trusted_server_core::runtime_config::load_runtime_config(&original_toml) + .change_context(CliError::Configuration) + .attach(format!("while validating `{}`", resolved_path.display()))?; + + Ok(ValidatedConfig { + path: resolved_path, + loaded, + }) +} + +pub fn validate_config_json(path: Option<&Path>) -> ValidateConfigJson { + match load_validated_config(path) { + Ok(validated) => ValidateConfigJson { + valid: true, + path: validated.path.display().to_string(), + config_hash: Some(validated.loaded.config_hash), + errors: Vec::new(), + }, + Err(error) => { + let resolved_path = resolve_config_path(path) + .map(|path| path.display().to_string()) + .unwrap_or_else(|_| DEFAULT_CONFIG_PATH.to_string()); + ValidateConfigJson { + valid: false, + path: resolved_path, + config_hash: None, + errors: vec![format!("{error:?}")], + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_config_json_reports_success_for_example_config() { + let tempdir = tempfile::tempdir().expect("should create tempdir"); + let path = tempdir.path().join(DEFAULT_CONFIG_PATH); + fs::write(&path, STARTER_CONFIG_TEMPLATE).expect("should write starter config"); + + let response = validate_config_json(Some(&path)); + + assert!(response.valid, "should report valid example config"); + assert!( + response.config_hash.is_some(), + "should include config hash for valid config" + ); + } + + #[test] + fn validate_config_json_reports_missing_file() { + let tempdir = tempfile::tempdir().expect("should create tempdir"); + let path = tempdir.path().join("missing.toml"); + + let response = validate_config_json(Some(&path)); + + assert!(!response.valid, "should report invalid for missing file"); + assert_eq!(response.config_hash, None, "should not have hash"); + } +} diff --git a/crates/trusted-server-cli/src/dev.rs b/crates/trusted-server-cli/src/dev.rs new file mode 100644 index 00000000..77d97d4d --- /dev/null +++ b/crates/trusted-server-cli/src/dev.rs @@ -0,0 +1,123 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus}; + +use error_stack::{Report, ResultExt}; + +use crate::config::ValidatedConfig; +use crate::error::CliError; + +pub const FASTLY_LOCAL_MANIFEST: &str = "fastly.local.toml"; +const EMBEDDED_FASTLY_TEMPLATE: &str = include_str!("../../../fastly.toml"); + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)] +pub enum Adapter { + #[default] + Fastly, +} + +pub fn render_local_fastly_manifest(template: &str, canonical_toml: &str) -> String { + let escaped = serde_json::to_string(canonical_toml).expect("should encode canonical TOML"); + let mut rendered = template.to_string(); + rendered.push('\n'); + rendered.push_str("[local_server.config_stores.ts_config_store]\n"); + rendered.push_str(" format = \"inline-toml\"\n"); + rendered.push_str("[local_server.config_stores.ts_config_store.contents]\n"); + rendered.push_str(&format!(" ts-config = {escaped}\n")); + rendered +} + +pub fn write_local_fastly_manifest( + project_dir: &Path, + canonical_toml: &str, +) -> Result> { + let output_path = project_dir.join(FASTLY_LOCAL_MANIFEST); + let template_path = project_dir.join("fastly.toml"); + let template = + fs::read_to_string(&template_path).unwrap_or_else(|_| EMBEDDED_FASTLY_TEMPLATE.to_string()); + fs::write( + &output_path, + render_local_fastly_manifest(&template, canonical_toml), + ) + .change_context(CliError::Development)?; + Ok(output_path) +} + +pub fn run_fastly_dev( + project_dir: &Path, + passthrough_args: &[String], +) -> Result> { + let mut args = vec![ + "compute".to_string(), + "serve".to_string(), + "--dir".to_string(), + project_dir.display().to_string(), + "--env=local".to_string(), + ]; + args.extend(passthrough_args.iter().cloned()); + + let has_skip_build = passthrough_args.iter().any(|arg| arg == "--skip-build"); + let has_file = passthrough_args + .iter() + .any(|arg| arg == "--file" || arg.strip_prefix("--file=").is_some()); + + if has_skip_build && !has_file { + let release_path = + project_dir.join("target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm"); + let debug_path = + project_dir.join("target/wasm32-wasip1/debug/trusted-server-adapter-fastly.wasm"); + let wasm_path = if release_path.exists() { + release_path + } else if debug_path.exists() { + debug_path + } else { + return Err(Report::new(CliError::Development).attach( + "--skip-build was passed but no built Wasm binary was found. Hint: run `cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1`.", + )); + }; + args.push("--file".to_string()); + args.push(wasm_path.display().to_string()); + } + + Command::new("fastly") + .args(&args) + .status() + .change_context(CliError::Development) + .attach("failed to launch `fastly compute serve`") +} + +pub fn run_dev_command( + adapter: Adapter, + validated: &ValidatedConfig, + passthrough_args: &[String], +) -> Result> { + match adapter { + Adapter::Fastly => { + let project_dir = std::env::current_dir().change_context(CliError::Io)?; + write_local_fastly_manifest(&project_dir, &validated.loaded.canonical_toml)?; + run_fastly_dev(&project_dir, passthrough_args) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rendered_manifest_embeds_runtime_config_store() { + let rendered = render_local_fastly_manifest( + EMBEDDED_FASTLY_TEMPLATE, + "[publisher]\ndomain = \"example.com\"\n", + ); + + assert!( + rendered.contains("[local_server.config_stores.ts_config_store]"), + "should add app config store section" + ); + assert!( + rendered.contains("ts-config = \"[publisher]\\ndomain = \\\"example.com\\\"\\n\""), + "should embed canonical TOML under ts-config" + ); + } +} diff --git a/crates/trusted-server-cli/src/error.rs b/crates/trusted-server-cli/src/error.rs new file mode 100644 index 00000000..3168b9dc --- /dev/null +++ b/crates/trusted-server-cli/src/error.rs @@ -0,0 +1,27 @@ +use core::error::Error; + +#[derive(Debug, derive_more::Display)] +pub enum CliError { + #[display("invalid arguments")] + Arguments, + #[display("I/O error")] + Io, + #[display("configuration error")] + Configuration, + #[display("authentication error")] + Authentication, + #[display("Fastly API error")] + FastlyApi, + #[display("provisioning error")] + Provisioning, + #[display("audit error")] + Audit, + #[display("development error")] + Development, + #[display("JSON serialization error")] + Json, + #[display("operation cancelled")] + Cancelled, +} + +impl Error for CliError {} diff --git a/crates/trusted-server-cli/src/fastly/api.rs b/crates/trusted-server-cli/src/fastly/api.rs new file mode 100644 index 00000000..d7f1fa3d --- /dev/null +++ b/crates/trusted-server-cli/src/fastly/api.rs @@ -0,0 +1,438 @@ +use std::collections::HashMap; + +use base64::{Engine as _, engine::general_purpose}; +use error_stack::{Report, ResultExt}; +use reqwest::blocking::{Client, Response}; +use serde::{Deserialize, Serialize}; + +use crate::error::CliError; + +const FASTLY_API_BASE: &str = "https://api.fastly.com"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NamedResource { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceVersion { + pub number: u32, + pub active: bool, + pub locked: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResourceLink { + pub id: String, + pub name: String, + pub resource_id: String, +} + +pub trait FastlyApi { + fn find_config_store_by_name( + &self, + name: &str, + ) -> Result, Report>; + fn create_config_store(&self, name: &str) -> Result>; + fn list_config_store_items( + &self, + store_id: &str, + ) -> Result, Report>; + fn upsert_config_item( + &self, + store_id: &str, + key: &str, + value: &str, + ) -> Result<(), Report>; + + fn find_secret_store_by_name( + &self, + name: &str, + ) -> Result, Report>; + fn create_secret_store(&self, name: &str) -> Result>; + fn list_secret_names(&self, store_id: &str) -> Result, Report>; + fn recreate_secret( + &self, + store_id: &str, + name: &str, + value: &str, + ) -> Result<(), Report>; + + fn find_kv_store_by_name(&self, name: &str) -> Result, Report>; + fn create_kv_store(&self, name: &str) -> Result>; + + fn list_service_versions( + &self, + service_id: &str, + ) -> Result, Report>; + fn clone_service_version( + &self, + service_id: &str, + version_number: u32, + ) -> Result>; + fn activate_service_version( + &self, + service_id: &str, + version_number: u32, + ) -> Result>; + fn list_resource_links( + &self, + service_id: &str, + version_number: u32, + ) -> Result, Report>; + fn create_resource_link( + &self, + service_id: &str, + version_number: u32, + resource_id: &str, + name: &str, + ) -> Result>; + fn update_resource_link( + &self, + service_id: &str, + version_number: u32, + link_id: &str, + resource_id: &str, + name: &str, + ) -> Result>; +} + +pub struct ReqwestFastlyApi { + client: Client, + api_key: String, +} + +impl ReqwestFastlyApi { + pub fn new(api_key: String) -> Result> { + let client = Client::builder() + .user_agent("trusted-server-cli/0.1") + .build() + .change_context(CliError::FastlyApi)?; + Ok(Self { client, api_key }) + } + + fn request(&self, method: reqwest::Method, path: &str) -> reqwest::blocking::RequestBuilder { + self.client + .request(method, format!("{FASTLY_API_BASE}{path}")) + .header("Fastly-Key", &self.api_key) + .header("Accept", "application/json") + } + + fn ensure_success( + &self, + response: Response, + context: &str, + ) -> Result> { + let status = response.status(); + if status.is_success() { + return Ok(response); + } + + let body = response + .text() + .unwrap_or_else(|_| "".to_string()); + Err(Report::new(CliError::FastlyApi) + .attach(format!("{context} failed with HTTP {status}: {body}"))) + } +} + +impl FastlyApi for ReqwestFastlyApi { + fn find_config_store_by_name( + &self, + name: &str, + ) -> Result, Report> { + let response = self + .request(reqwest::Method::GET, "/resources/stores/config") + .query(&[("name", name)]) + .send() + .change_context(CliError::FastlyApi)?; + let response = self.ensure_success(response, "listing config stores")?; + let stores: Vec = response.json().change_context(CliError::FastlyApi)?; + Ok(stores.into_iter().next()) + } + + fn create_config_store(&self, name: &str) -> Result> { + let response = self + .request(reqwest::Method::POST, "/resources/stores/config") + .form(&[("name", name)]) + .send() + .change_context(CliError::FastlyApi)?; + let response = self.ensure_success(response, "creating config store")?; + response.json().change_context(CliError::FastlyApi) + } + + fn list_config_store_items( + &self, + store_id: &str, + ) -> Result, Report> { + let response = self + .request( + reqwest::Method::GET, + &format!("/resources/stores/config/{store_id}/items"), + ) + .send() + .change_context(CliError::FastlyApi)?; + let response = self.ensure_success(response, "listing config store items")?; + let items: Vec = + response.json().change_context(CliError::FastlyApi)?; + Ok(items + .into_iter() + .map(|item| (item.item_key, item.item_value)) + .collect()) + } + + fn upsert_config_item( + &self, + store_id: &str, + key: &str, + value: &str, + ) -> Result<(), Report> { + let response = self + .request( + reqwest::Method::PUT, + &format!("/resources/stores/config/{store_id}/item/{key}"), + ) + .form(&[("item_key", key), ("item_value", value)]) + .send() + .change_context(CliError::FastlyApi)?; + self.ensure_success(response, "upserting config store item")?; + Ok(()) + } + + fn find_secret_store_by_name( + &self, + name: &str, + ) -> Result, Report> { + let response = self + .request(reqwest::Method::GET, "/resources/stores/secret") + .query(&[("name", name), ("limit", "200")]) + .send() + .change_context(CliError::FastlyApi)?; + let response = self.ensure_success(response, "listing secret stores")?; + let listing: SecretStoreListing = response.json().change_context(CliError::FastlyApi)?; + Ok(listing.data.into_iter().next().map(|store| NamedResource { + id: store.id, + name: store.name, + })) + } + + fn create_secret_store(&self, name: &str) -> Result> { + let response = self + .request(reqwest::Method::POST, "/resources/stores/secret") + .json(&serde_json::json!({ "name": name })) + .send() + .change_context(CliError::FastlyApi)?; + let response = self.ensure_success(response, "creating secret store")?; + let store: SecretStoreRecord = response.json().change_context(CliError::FastlyApi)?; + Ok(NamedResource { + id: store.id, + name: store.name, + }) + } + + fn list_secret_names(&self, store_id: &str) -> Result, Report> { + let response = self + .request( + reqwest::Method::GET, + &format!("/resources/stores/secret/{store_id}/secrets"), + ) + .query(&[("limit", "200")]) + .send() + .change_context(CliError::FastlyApi)?; + let response = self.ensure_success(response, "listing secret store secrets")?; + let listing: SecretItemListing = response.json().change_context(CliError::FastlyApi)?; + Ok(listing.data.into_iter().map(|secret| secret.name).collect()) + } + + fn recreate_secret( + &self, + store_id: &str, + name: &str, + value: &str, + ) -> Result<(), Report> { + let encoded = general_purpose::STANDARD.encode(value.as_bytes()); + let response = self + .request( + reqwest::Method::PUT, + &format!("/resources/stores/secret/{store_id}/secrets"), + ) + .json(&serde_json::json!({ "name": name, "secret": encoded })) + .send() + .change_context(CliError::FastlyApi)?; + self.ensure_success(response, "recreating secret")?; + Ok(()) + } + + fn find_kv_store_by_name(&self, name: &str) -> Result, Report> { + let response = self + .request(reqwest::Method::GET, "/resources/stores/kv") + .query(&[("name", name), ("limit", "1000")]) + .send() + .change_context(CliError::FastlyApi)?; + let response = self.ensure_success(response, "listing KV stores")?; + let listing: KvStoreListing = response.json().change_context(CliError::FastlyApi)?; + Ok(listing.data.into_iter().next().map(|store| NamedResource { + id: store.id, + name: store.name, + })) + } + + fn create_kv_store(&self, name: &str) -> Result> { + let response = self + .request(reqwest::Method::POST, "/resources/stores/kv") + .query(&[("location", "US")]) + .json(&serde_json::json!({ "name": name })) + .send() + .change_context(CliError::FastlyApi)?; + let response = self.ensure_success(response, "creating KV store")?; + let store: KvStoreRecord = response.json().change_context(CliError::FastlyApi)?; + Ok(NamedResource { + id: store.id, + name: store.name, + }) + } + + fn list_service_versions( + &self, + service_id: &str, + ) -> Result, Report> { + let response = self + .request( + reqwest::Method::GET, + &format!("/service/{service_id}/version"), + ) + .send() + .change_context(CliError::FastlyApi)?; + let response = self.ensure_success(response, "listing service versions")?; + response.json().change_context(CliError::FastlyApi) + } + + fn clone_service_version( + &self, + service_id: &str, + version_number: u32, + ) -> Result> { + let response = self + .request( + reqwest::Method::PUT, + &format!("/service/{service_id}/version/{version_number}/clone"), + ) + .send() + .change_context(CliError::FastlyApi)?; + let response = self.ensure_success(response, "cloning service version")?; + response.json().change_context(CliError::FastlyApi) + } + + fn activate_service_version( + &self, + service_id: &str, + version_number: u32, + ) -> Result> { + let response = self + .request( + reqwest::Method::PUT, + &format!("/service/{service_id}/version/{version_number}/activate"), + ) + .send() + .change_context(CliError::FastlyApi)?; + let response = self.ensure_success(response, "activating service version")?; + response.json().change_context(CliError::FastlyApi) + } + + fn list_resource_links( + &self, + service_id: &str, + version_number: u32, + ) -> Result, Report> { + let response = self + .request( + reqwest::Method::GET, + &format!("/service/{service_id}/version/{version_number}/resource"), + ) + .send() + .change_context(CliError::FastlyApi)?; + let response = self.ensure_success(response, "listing resource links")?; + response.json().change_context(CliError::FastlyApi) + } + + fn create_resource_link( + &self, + service_id: &str, + version_number: u32, + resource_id: &str, + name: &str, + ) -> Result> { + let response = self + .request( + reqwest::Method::POST, + &format!("/service/{service_id}/version/{version_number}/resource"), + ) + .form(&[("resource_id", resource_id), ("name", name)]) + .send() + .change_context(CliError::FastlyApi)?; + let response = self.ensure_success(response, "creating resource link")?; + response.json().change_context(CliError::FastlyApi) + } + + fn update_resource_link( + &self, + service_id: &str, + version_number: u32, + link_id: &str, + resource_id: &str, + name: &str, + ) -> Result> { + let response = self + .request( + reqwest::Method::PUT, + &format!("/service/{service_id}/version/{version_number}/resource/{link_id}"), + ) + .form(&[("resource_id", resource_id), ("name", name)]) + .send() + .change_context(CliError::FastlyApi)?; + let response = self.ensure_success(response, "updating resource link")?; + response.json().change_context(CliError::FastlyApi) + } +} + +#[derive(Debug, Deserialize)] +struct ConfigStoreItemResponse { + item_key: String, + item_value: String, +} + +#[derive(Debug, Deserialize)] +struct SecretStoreListing { + #[serde(default)] + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct SecretStoreRecord { + id: String, + name: String, +} + +#[derive(Debug, Deserialize)] +struct SecretItemListing { + #[serde(default)] + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct SecretItemRecord { + name: String, +} + +#[derive(Debug, Deserialize)] +struct KvStoreListing { + #[serde(default)] + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct KvStoreRecord { + id: String, + name: String, +} diff --git a/crates/trusted-server-cli/src/fastly/auth.rs b/crates/trusted-server-cli/src/fastly/auth.rs new file mode 100644 index 00000000..8444a17a --- /dev/null +++ b/crates/trusted-server-cli/src/fastly/auth.rs @@ -0,0 +1,197 @@ +use std::env; + +use dialoguer::Password; +use error_stack::{Report, ResultExt}; +use serde::Serialize; + +use crate::error::CliError; + +const KEYRING_SERVICE: &str = "trusted-server-cli.fastly"; +const KEYRING_USERNAME: &str = "api-key"; + +pub trait CredentialStore { + fn read(&self) -> Result, Report>; + fn write(&self, value: &str) -> Result<(), Report>; + fn delete(&self) -> Result<(), Report>; +} + +pub struct SystemCredentialStore; + +impl CredentialStore for SystemCredentialStore { + fn read(&self) -> Result, Report> { + let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USERNAME) + .change_context(CliError::Authentication)?; + match entry.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(error) => Err(Report::new(CliError::Authentication).attach(format!( + "failed to read Fastly credential from secure storage: {error}" + ))), + } + } + + fn write(&self, value: &str) -> Result<(), Report> { + let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USERNAME) + .change_context(CliError::Authentication)?; + entry + .set_password(value) + .map_err(|error| { + Report::new(CliError::Authentication).attach(format!( + "failed to store Fastly credential in secure storage: {error}. Hint: use FASTLY_API_KEY if secure storage is unavailable." + )) + }) + } + + fn delete(&self) -> Result<(), Report> { + let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USERNAME) + .change_context(CliError::Authentication)?; + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(error) => Err(Report::new(CliError::Authentication).attach(format!( + "failed to delete Fastly credential from secure storage: {error}" + ))), + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum CredentialSource { + Environment, + SecureStorage, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct AuthStatusJson { + pub has_env_credential: bool, + pub has_stored_credential: bool, + pub effective_source: Option, +} + +#[derive(Debug, Clone)] +pub struct ResolvedCredential { + pub value: String, + pub source: CredentialSource, +} + +pub fn resolve_fastly_api_key( + store: &dyn CredentialStore, +) -> Result> { + if let Ok(value) = env::var("FASTLY_API_KEY") + && !value.trim().is_empty() + { + return Ok(ResolvedCredential { + value, + source: CredentialSource::Environment, + }); + } + + if let Some(value) = store.read()? + && !value.trim().is_empty() + { + return Ok(ResolvedCredential { + value, + source: CredentialSource::SecureStorage, + }); + } + + Err(Report::new(CliError::Authentication) + .attach("missing Fastly credential. Run `ts auth fastly login` or set FASTLY_API_KEY.")) +} + +pub fn fastly_auth_status(store: &dyn CredentialStore) -> Result> { + let has_env_credential = env::var("FASTLY_API_KEY") + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + let has_stored_credential = store.read()?.is_some_and(|value| !value.trim().is_empty()); + let effective_source = if has_env_credential { + Some(CredentialSource::Environment) + } else if has_stored_credential { + Some(CredentialSource::SecureStorage) + } else { + None + }; + + Ok(AuthStatusJson { + has_env_credential, + has_stored_credential, + effective_source, + }) +} + +pub fn login_fastly(store: &dyn CredentialStore) -> Result<(), Report> { + let token = Password::new() + .with_prompt("Fastly API key") + .interact() + .change_context(CliError::Authentication)?; + + if token.trim().is_empty() { + return Err(Report::new(CliError::Authentication).attach("Fastly API key cannot be empty")); + } + + store.write(&token) +} + +pub fn logout_fastly(store: &dyn CredentialStore) -> Result<(), Report> { + store.delete() +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + + #[derive(Clone, Default)] + struct MemoryCredentialStore { + value: Arc>>, + } + + impl CredentialStore for MemoryCredentialStore { + fn read(&self) -> Result, Report> { + Ok(self.value.lock().expect("should lock store").clone()) + } + + fn write(&self, value: &str) -> Result<(), Report> { + *self.value.lock().expect("should lock store") = Some(value.to_string()); + Ok(()) + } + + fn delete(&self) -> Result<(), Report> { + *self.value.lock().expect("should lock store") = None; + Ok(()) + } + } + + #[test] + fn env_credential_wins_over_stored_credential() { + let store = MemoryCredentialStore::default(); + store.write("stored-token").expect("should store token"); + unsafe { + env::set_var("FASTLY_API_KEY", "env-token"); + } + + let resolved = resolve_fastly_api_key(&store).expect("should resolve token"); + + assert_eq!(resolved.value, "env-token"); + assert_eq!(resolved.source, CredentialSource::Environment); + + unsafe { + env::remove_var("FASTLY_API_KEY"); + } + } + + #[test] + fn stored_credential_is_used_when_env_is_missing() { + let store = MemoryCredentialStore::default(); + store.write("stored-token").expect("should store token"); + unsafe { + env::remove_var("FASTLY_API_KEY"); + } + + let resolved = resolve_fastly_api_key(&store).expect("should resolve stored token"); + + assert_eq!(resolved.value, "stored-token"); + assert_eq!(resolved.source, CredentialSource::SecureStorage); + } +} diff --git a/crates/trusted-server-cli/src/fastly/mod.rs b/crates/trusted-server-cli/src/fastly/mod.rs new file mode 100644 index 00000000..eea4d7b2 --- /dev/null +++ b/crates/trusted-server-cli/src/fastly/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod auth; +pub mod provision; diff --git a/crates/trusted-server-cli/src/fastly/provision.rs b/crates/trusted-server-cli/src/fastly/provision.rs new file mode 100644 index 00000000..b05636d8 --- /dev/null +++ b/crates/trusted-server-cli/src/fastly/provision.rs @@ -0,0 +1,1139 @@ +use std::collections::HashMap; + +use base64::{Engine as _, engine::general_purpose}; +use dialoguer::Confirm; +use error_stack::{Report, ResultExt}; +use serde::Serialize; +use trusted_server_core::request_signing::{ + JWKS_CONFIG_STORE_NAME, Keypair, SIGNING_SECRET_STORE_NAME, +}; +use trusted_server_core::runtime_config::{APPLICATION_CONFIG_KEY, APPLICATION_CONFIG_STORE_NAME}; +use uuid::Uuid; + +use crate::config::ValidatedConfig; +use crate::error::CliError; +use crate::fastly::api::{FastlyApi, NamedResource, ResourceLink}; + +const FASTLY_API_SECRET_STORE_NAME: &str = "api-keys"; +const FASTLY_API_SECRET_KEY: &str = "api_key"; + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ResourceKind { + Config, + Secret, + Kv, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ChangeKind { + Create, + Update, + Bind, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ProvisionActionJson { + pub action: ChangeKind, + pub resource_kind: ResourceKind, + pub name: String, + pub detail: String, + pub remote_id: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ServiceVersionPlanJson { + pub latest_version: u32, + pub target_version: u32, + pub clone_required: bool, + pub clone_source_version: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ProvisionPlanJson { + pub service_id: String, + pub config_path: String, + pub service_version: ServiceVersionPlanJson, + pub actions: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ProvisionApplyJson { + pub service_id: String, + pub config_path: String, + pub service_version: ServiceVersionPlanJson, + pub completed_actions: Vec, + pub warnings: Vec, + pub failed_action: Option, + pub activated_version: bool, +} + +#[derive(Debug, Clone)] +pub struct ProvisionPlan { + pub json: ProvisionPlanJson, + resources: Vec, +} + +#[derive(Debug, Clone)] +struct PlannedResource { + kind: ResourceKind, + name: String, + existing_id: Option, + create_store: bool, + config_items: Vec, + secrets: Vec, + link: Option, +} + +#[derive(Debug, Clone)] +struct ConfigItemPlan { + key: String, + value: String, + action: Option, +} + +#[derive(Debug, Clone)] +struct SecretPlan { + name: String, + value: SecretValuePlan, + action: Option, +} + +#[derive(Debug, Clone)] +enum SecretValuePlan { + Literal(String), + RuntimeApiKey, +} + +#[derive(Debug, Clone)] +struct LinkPlan { + existing_link_id: Option, + action: Option, +} + +#[derive(Debug, Clone)] +struct RequestSigningBootstrap { + kid: String, + jwk_json: String, + private_key_base64: String, +} + +#[derive(Debug, Clone)] +struct PlannedRequestSigningResources { + resources: Vec, + bootstrap_planned: bool, + runtime_api_key_required: bool, +} + +pub fn plan_fastly_provisioning( + api: &dyn FastlyApi, + validated: &ValidatedConfig, + service_id: &str, +) -> Result> { + let versions = api.list_service_versions(service_id)?; + let latest_version = versions + .iter() + .max_by_key(|version| version.number) + .cloned() + .ok_or_else(|| Report::new(CliError::Provisioning).attach("service has no versions"))?; + let existing_links = api.list_resource_links(service_id, latest_version.number)?; + + let mut resources = vec![plan_app_config_resource(api, validated, &existing_links)?]; + let mut warnings = Vec::new(); + + if let Some(request_signing) = validated.loaded.settings.request_signing.as_ref() + && request_signing.enabled + { + let request_signing_plan = plan_request_signing_resources(api, &existing_links)?; + if request_signing_plan.bootstrap_planned { + warnings.push( + "request signing stores are uninitialized; apply will generate and upload an initial Ed25519 signing keypair" + .to_string(), + ); + } + if request_signing_plan.runtime_api_key_required { + warnings.push( + "request signing requires a runtime Fastly API token for the `api-keys/api_key` secret; apply must be given `FASTLY_RUNTIME_API_KEY`, `--runtime-api-key`, or `--reuse-management-api-key`" + .to_string(), + ); + } + resources.extend(request_signing_plan.resources); + append_request_signing_warnings( + &mut warnings, + &resources, + &request_signing.config_store_id, + &request_signing.secret_store_id, + ); + } + + if let Some(consent_store) = validated.loaded.settings.consent.consent_store.as_deref() { + resources.push(plan_kv_resource(api, consent_store, &existing_links)?); + } + + let requires_binding_change = binding_changes_required(&resources); + let clone_required = requires_binding_change && latest_version.locked; + let actions = collect_actions(&resources); + + if clone_required { + warnings.push(format!( + "latest service version {} is locked; apply will clone it before creating or updating bindings", + latest_version.number + )); + } + if requires_binding_change { + warnings.push(format!( + "apply will activate service version {} after updating resource bindings", + latest_version.number + )); + } + + Ok(ProvisionPlan { + json: ProvisionPlanJson { + service_id: service_id.to_string(), + config_path: validated.path.display().to_string(), + service_version: ServiceVersionPlanJson { + latest_version: latest_version.number, + target_version: latest_version.number, + clone_required, + clone_source_version: clone_required.then_some(latest_version.number), + }, + actions, + warnings, + }, + resources, + }) +} + +pub fn apply_fastly_provisioning( + api: &dyn FastlyApi, + validated: &ValidatedConfig, + service_id: &str, + runtime_api_key: Option<&str>, + yes: bool, +) -> Result> { + let mut plan = plan_fastly_provisioning(api, validated, service_id)?; + + if requires_runtime_api_key(&plan.resources) && runtime_api_key.is_none() { + return Err(Report::new(CliError::Arguments).attach( + "request signing provisioning needs a runtime Fastly API token. Set FASTLY_RUNTIME_API_KEY, pass `--runtime-api-key`, or opt in to `--reuse-management-api-key`.", + )); + } + + if !yes && !plan.json.actions.is_empty() { + let confirmed = Confirm::new() + .with_prompt(format!( + "Apply {} Fastly provisioning change(s)?", + plan.json.actions.len() + )) + .default(false) + .interact() + .change_context(CliError::Cancelled)?; + if !confirmed { + return Err(Report::new(CliError::Cancelled).attach("user declined apply")); + } + } + + let mut target_version = plan.json.service_version.target_version; + if plan.json.service_version.clone_required { + let cloned = api.clone_service_version(service_id, target_version)?; + target_version = cloned.number; + plan.json.service_version.target_version = target_version; + } + + let mut resolved_ids = HashMap::::new(); + let mut completed_actions = Vec::new(); + let mut activated_version = false; + + for resource in &plan.resources { + let mut resource_id = match &resource.existing_id { + Some(id) => id.clone(), + None => String::new(), + }; + + if resource.create_store { + let created = create_store(api, resource)?; + resource_id = created.id.clone(); + resolved_ids.insert(resource.name.clone(), created.id.clone()); + completed_actions.push(ProvisionActionJson { + action: ChangeKind::Create, + resource_kind: resource.kind, + name: resource.name.clone(), + detail: format!( + "create {} `{}`", + resource_kind_label(resource.kind), + resource.name + ), + remote_id: Some(created.id), + }); + } else if let Some(existing_id) = &resource.existing_id { + resolved_ids.insert(resource.name.clone(), existing_id.clone()); + } + + if resource_id.is_empty() + && let Some(resolved) = resolved_ids.get(&resource.name) + { + resource_id = resolved.clone(); + } + + for item in &resource.config_items { + if let Some(action) = item.action { + api.upsert_config_item(&resource_id, &item.key, &item.value)?; + completed_actions.push(ProvisionActionJson { + action, + resource_kind: resource.kind, + name: resource.name.clone(), + detail: format!("set config item `{}` in `{}`", item.key, resource.name), + remote_id: Some(resource_id.clone()), + }); + } + } + + for secret in &resource.secrets { + if let Some(action) = secret.action { + let secret_value = match &secret.value { + SecretValuePlan::Literal(value) => value.clone(), + SecretValuePlan::RuntimeApiKey => runtime_api_key + .ok_or_else(|| { + Report::new(CliError::Arguments).attach( + "missing runtime Fastly API token for request signing provisioning", + ) + })? + .to_string(), + }; + api.recreate_secret(&resource_id, &secret.name, &secret_value)?; + completed_actions.push(ProvisionActionJson { + action, + resource_kind: resource.kind, + name: resource.name.clone(), + detail: format!( + "upload secret `{}` to `{}` (value redacted)", + secret.name, resource.name + ), + remote_id: Some(resource_id.clone()), + }); + } + } + + if let Some(link) = &resource.link { + match link.action { + Some(ChangeKind::Bind) => { + api.create_resource_link( + service_id, + target_version, + &resource_id, + &resource.name, + )?; + completed_actions.push(ProvisionActionJson { + action: ChangeKind::Bind, + resource_kind: resource.kind, + name: resource.name.clone(), + detail: format!( + "bind {} `{}` to service version {}", + resource_kind_label(resource.kind), + resource.name, + target_version + ), + remote_id: Some(resource_id.clone()), + }); + activated_version = true; + } + Some(ChangeKind::Update) => { + let link_id = link.existing_link_id.as_deref().ok_or_else(|| { + Report::new(CliError::Provisioning).attach("missing resource link ID") + })?; + api.update_resource_link( + service_id, + target_version, + link_id, + &resource_id, + &resource.name, + )?; + completed_actions.push(ProvisionActionJson { + action: ChangeKind::Update, + resource_kind: resource.kind, + name: resource.name.clone(), + detail: format!( + "update binding for {} `{}` on service version {}", + resource_kind_label(resource.kind), + resource.name, + target_version + ), + remote_id: Some(resource_id.clone()), + }); + activated_version = true; + } + _ => {} + } + } + } + + if activated_version { + api.activate_service_version(service_id, target_version)?; + } + + Ok(ProvisionApplyJson { + service_id: service_id.to_string(), + config_path: validated.path.display().to_string(), + service_version: plan.json.service_version, + completed_actions, + warnings: plan.json.warnings, + failed_action: None, + activated_version, + }) +} + +fn plan_app_config_resource( + api: &dyn FastlyApi, + validated: &ValidatedConfig, + existing_links: &[ResourceLink], +) -> Result> { + let store = api.find_config_store_by_name(APPLICATION_CONFIG_STORE_NAME)?; + let items = match &store { + Some(store) => api.list_config_store_items(&store.id)?, + None => HashMap::new(), + }; + + let action = match items.get(APPLICATION_CONFIG_KEY) { + Some(existing) if existing == &validated.loaded.canonical_toml => None, + Some(_) => Some(ChangeKind::Update), + None => Some(ChangeKind::Create), + }; + + Ok(PlannedResource { + kind: ResourceKind::Config, + name: APPLICATION_CONFIG_STORE_NAME.to_string(), + existing_id: store.as_ref().map(|store| store.id.clone()), + create_store: store.is_none(), + config_items: vec![ConfigItemPlan { + key: APPLICATION_CONFIG_KEY.to_string(), + value: validated.loaded.canonical_toml.clone(), + action, + }], + secrets: Vec::new(), + link: Some(plan_link( + existing_links, + &store, + APPLICATION_CONFIG_STORE_NAME, + )), + }) +} + +fn plan_request_signing_resources( + api: &dyn FastlyApi, + existing_links: &[ResourceLink], +) -> Result> { + let config_store = api.find_config_store_by_name(JWKS_CONFIG_STORE_NAME)?; + let config_items = match &config_store { + Some(store) => api.list_config_store_items(&store.id)?, + None => HashMap::new(), + }; + + let signing_secret_store = api.find_secret_store_by_name(SIGNING_SECRET_STORE_NAME)?; + let signing_secret_names = match &signing_secret_store { + Some(store) => api.list_secret_names(&store.id)?, + None => Vec::new(), + }; + + let bootstrap = determine_request_signing_bootstrap(&config_items, &signing_secret_names)?; + + let config_resource = PlannedResource { + kind: ResourceKind::Config, + name: JWKS_CONFIG_STORE_NAME.to_string(), + existing_id: config_store.as_ref().map(|store| store.id.clone()), + create_store: config_store.is_none(), + config_items: bootstrap + .as_ref() + .map(|bootstrap| { + vec![ + ConfigItemPlan { + key: "current-kid".to_string(), + value: bootstrap.kid.clone(), + action: Some(ChangeKind::Create), + }, + ConfigItemPlan { + key: "active-kids".to_string(), + value: bootstrap.kid.clone(), + action: Some(ChangeKind::Create), + }, + ConfigItemPlan { + key: bootstrap.kid.clone(), + value: bootstrap.jwk_json.clone(), + action: Some(ChangeKind::Create), + }, + ] + }) + .unwrap_or_default(), + secrets: Vec::new(), + link: Some(plan_link( + existing_links, + &config_store, + JWKS_CONFIG_STORE_NAME, + )), + }; + + let secret_resource = PlannedResource { + kind: ResourceKind::Secret, + name: SIGNING_SECRET_STORE_NAME.to_string(), + existing_id: signing_secret_store.as_ref().map(|store| store.id.clone()), + create_store: signing_secret_store.is_none(), + config_items: Vec::new(), + secrets: bootstrap + .as_ref() + .map(|bootstrap| { + vec![SecretPlan { + name: bootstrap.kid.clone(), + value: SecretValuePlan::Literal(bootstrap.private_key_base64.clone()), + action: Some(ChangeKind::Create), + }] + }) + .unwrap_or_default(), + link: Some(plan_link( + existing_links, + &signing_secret_store, + SIGNING_SECRET_STORE_NAME, + )), + }; + + let runtime_api_secret_resource = plan_runtime_api_secret_resource(api, existing_links)?; + let runtime_api_key_required = runtime_api_secret_resource + .secrets + .iter() + .any(|secret| secret.action.is_some()); + + Ok(PlannedRequestSigningResources { + resources: vec![ + config_resource, + secret_resource, + runtime_api_secret_resource, + ], + bootstrap_planned: bootstrap.is_some(), + runtime_api_key_required, + }) +} + +fn determine_request_signing_bootstrap( + config_items: &HashMap, + secret_names: &[String], +) -> Result, Report> { + let current_kid = config_items + .get("current-kid") + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let active_kids = config_items + .get("active-kids") + .map(|value| parse_active_kids(value)) + .unwrap_or_default(); + let has_jwk_entries = config_items + .keys() + .any(|key| key != "current-kid" && key != "active-kids"); + + if current_kid.is_none() + && active_kids.is_empty() + && !has_jwk_entries + && secret_names.is_empty() + { + return Ok(Some(generate_request_signing_bootstrap()?)); + } + + let Some(current_kid) = current_kid else { + return Err(Report::new(CliError::Provisioning).attach( + "request signing stores are partially initialized: missing `current-kid` in `jwks_store`", + )); + }; + + if !active_kids.iter().any(|kid| kid == ¤t_kid) { + return Err(Report::new(CliError::Provisioning).attach(format!( + "request signing stores are partially initialized: `active-kids` does not include `{current_kid}`" + ))); + } + + if !config_items.contains_key(¤t_kid) { + return Err(Report::new(CliError::Provisioning).attach(format!( + "request signing stores are partially initialized: config store is missing JWK entry `{current_kid}`" + ))); + } + + if !secret_names.iter().any(|name| name == ¤t_kid) { + return Err(Report::new(CliError::Provisioning).attach(format!( + "request signing stores are partially initialized: secret store is missing signing key `{current_kid}`" + ))); + } + + Ok(None) +} + +fn generate_request_signing_bootstrap() -> Result> { + let kid = format!("ts-{}", Uuid::new_v4().simple()); + let keypair = Keypair::generate(); + let jwk_json = serde_json::to_string(&keypair.get_jwk(kid.clone())) + .change_context(CliError::Provisioning)?; + let private_key_base64 = general_purpose::STANDARD.encode(keypair.signing_key.to_bytes()); + + Ok(RequestSigningBootstrap { + kid, + jwk_json, + private_key_base64, + }) +} + +fn plan_runtime_api_secret_resource( + api: &dyn FastlyApi, + existing_links: &[ResourceLink], +) -> Result> { + let store = api.find_secret_store_by_name(FASTLY_API_SECRET_STORE_NAME)?; + let secret_names = match &store { + Some(store) => api.list_secret_names(&store.id)?, + None => Vec::new(), + }; + let secret_exists = secret_names + .iter() + .any(|name| name == FASTLY_API_SECRET_KEY); + let secret_action = (!secret_exists).then_some(ChangeKind::Create); + + Ok(PlannedResource { + kind: ResourceKind::Secret, + name: FASTLY_API_SECRET_STORE_NAME.to_string(), + existing_id: store.as_ref().map(|store| store.id.clone()), + create_store: store.is_none(), + config_items: Vec::new(), + secrets: vec![SecretPlan { + name: FASTLY_API_SECRET_KEY.to_string(), + value: SecretValuePlan::RuntimeApiKey, + action: secret_action, + }], + link: Some(plan_link( + existing_links, + &store, + FASTLY_API_SECRET_STORE_NAME, + )), + }) +} + +fn plan_kv_resource( + api: &dyn FastlyApi, + name: &str, + existing_links: &[ResourceLink], +) -> Result> { + let store = api.find_kv_store_by_name(name)?; + + Ok(PlannedResource { + kind: ResourceKind::Kv, + name: name.to_string(), + existing_id: store.as_ref().map(|store| store.id.clone()), + create_store: store.is_none(), + config_items: Vec::new(), + secrets: Vec::new(), + link: Some(plan_link(existing_links, &store, name)), + }) +} + +fn parse_active_kids(active_kids: &str) -> Vec { + active_kids + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn plan_link( + existing_links: &[ResourceLink], + store: &Option, + alias: &str, +) -> LinkPlan { + let Some(store) = store else { + return LinkPlan { + existing_link_id: None, + action: Some(ChangeKind::Bind), + }; + }; + + match existing_links.iter().find(|link| link.name == alias) { + Some(link) if link.resource_id == store.id => LinkPlan { + existing_link_id: Some(link.id.clone()), + action: None, + }, + Some(link) => LinkPlan { + existing_link_id: Some(link.id.clone()), + action: Some(ChangeKind::Update), + }, + None => LinkPlan { + existing_link_id: None, + action: Some(ChangeKind::Bind), + }, + } +} + +fn binding_changes_required(resources: &[PlannedResource]) -> bool { + resources.iter().any(|resource| { + resource + .link + .as_ref() + .and_then(|link| link.action) + .is_some() + }) +} + +fn requires_runtime_api_key(resources: &[PlannedResource]) -> bool { + resources.iter().any(|resource| { + resource.secrets.iter().any(|secret| { + secret.action.is_some() && matches!(secret.value, SecretValuePlan::RuntimeApiKey) + }) + }) +} + +fn collect_actions(resources: &[PlannedResource]) -> Vec { + let mut actions = Vec::new(); + for resource in resources { + if resource.create_store { + actions.push(ProvisionActionJson { + action: ChangeKind::Create, + resource_kind: resource.kind, + name: resource.name.clone(), + detail: format!( + "create {} `{}`", + resource_kind_label(resource.kind), + resource.name + ), + remote_id: resource.existing_id.clone(), + }); + } + + for item in &resource.config_items { + if let Some(action) = item.action { + actions.push(ProvisionActionJson { + action, + resource_kind: resource.kind, + name: resource.name.clone(), + detail: format!("set config item `{}` in `{}`", item.key, resource.name), + remote_id: resource.existing_id.clone(), + }); + } + } + + for secret in &resource.secrets { + if let Some(action) = secret.action { + actions.push(ProvisionActionJson { + action, + resource_kind: resource.kind, + name: resource.name.clone(), + detail: format!( + "upload secret `{}` to `{}` (value redacted)", + secret.name, resource.name + ), + remote_id: resource.existing_id.clone(), + }); + } + } + + if let Some(link) = &resource.link + && let Some(action) = link.action + { + actions.push(ProvisionActionJson { + action, + resource_kind: resource.kind, + name: resource.name.clone(), + detail: format!( + "bind {} `{}` to the service", + resource_kind_label(resource.kind), + resource.name + ), + remote_id: resource.existing_id.clone(), + }); + } + } + + actions +} + +fn append_request_signing_warnings( + warnings: &mut Vec, + resources: &[PlannedResource], + configured_config_store_id: &str, + configured_secret_store_id: &str, +) { + for resource in resources { + if resource.name == JWKS_CONFIG_STORE_NAME + && let Some(actual_id) = resource.existing_id.as_deref() + && !configured_config_store_id.is_empty() + && configured_config_store_id != actual_id + { + warnings.push(format!( + "`request_signing.config_store_id` is `{configured_config_store_id}` but the Fastly `{}` store currently has ID `{actual_id}`; update trusted-server.toml after provisioning so runtime key rotation uses the correct ID", + JWKS_CONFIG_STORE_NAME + )); + } + if resource.name == SIGNING_SECRET_STORE_NAME + && let Some(actual_id) = resource.existing_id.as_deref() + && !configured_secret_store_id.is_empty() + && configured_secret_store_id != actual_id + { + warnings.push(format!( + "`request_signing.secret_store_id` is `{configured_secret_store_id}` but the Fastly `{}` store currently has ID `{actual_id}`; update trusted-server.toml after provisioning so runtime key rotation uses the correct ID", + SIGNING_SECRET_STORE_NAME + )); + } + } +} + +fn create_store( + api: &dyn FastlyApi, + resource: &PlannedResource, +) -> Result> { + match resource.kind { + ResourceKind::Config => api.create_config_store(&resource.name), + ResourceKind::Secret => api.create_secret_store(&resource.name), + ResourceKind::Kv => api.create_kv_store(&resource.name), + } +} + +fn resource_kind_label(kind: ResourceKind) -> &'static str { + match kind { + ResourceKind::Config => "config store", + ResourceKind::Secret => "secret store", + ResourceKind::Kv => "KV store", + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Mutex; + + use super::*; + use crate::fastly::api::{FastlyApi, ServiceVersion}; + + #[derive(Default)] + struct MockFastlyApi { + config_stores: HashMap, + config_items: HashMap>, + secret_stores: HashMap, + secret_names: HashMap>, + kv_stores: HashMap, + versions: Vec, + links: Vec, + clone_result: Option, + upserted_config_items: Mutex>, + recreated_secrets: Mutex>, + activated_versions: Mutex>, + } + + impl FastlyApi for MockFastlyApi { + fn find_config_store_by_name( + &self, + name: &str, + ) -> Result, Report> { + Ok(self.config_stores.get(name).cloned()) + } + + fn create_config_store(&self, name: &str) -> Result> { + Ok(NamedResource { + id: format!("created-{name}"), + name: name.to_string(), + }) + } + + fn list_config_store_items( + &self, + store_id: &str, + ) -> Result, Report> { + Ok(self.config_items.get(store_id).cloned().unwrap_or_default()) + } + + fn upsert_config_item( + &self, + store_id: &str, + key: &str, + value: &str, + ) -> Result<(), Report> { + self.upserted_config_items + .lock() + .expect("should lock upserted config items") + .push((store_id.to_string(), key.to_string(), value.to_string())); + Ok(()) + } + + fn find_secret_store_by_name( + &self, + name: &str, + ) -> Result, Report> { + Ok(self.secret_stores.get(name).cloned()) + } + + fn create_secret_store(&self, name: &str) -> Result> { + Ok(NamedResource { + id: format!("created-{name}"), + name: name.to_string(), + }) + } + + fn list_secret_names(&self, store_id: &str) -> Result, Report> { + Ok(self.secret_names.get(store_id).cloned().unwrap_or_default()) + } + + fn recreate_secret( + &self, + store_id: &str, + name: &str, + value: &str, + ) -> Result<(), Report> { + self.recreated_secrets + .lock() + .expect("should lock recreated secrets") + .push((store_id.to_string(), name.to_string(), value.to_string())); + Ok(()) + } + + fn find_kv_store_by_name( + &self, + name: &str, + ) -> Result, Report> { + Ok(self.kv_stores.get(name).cloned()) + } + + fn create_kv_store(&self, name: &str) -> Result> { + Ok(NamedResource { + id: format!("created-{name}"), + name: name.to_string(), + }) + } + + fn list_service_versions( + &self, + _service_id: &str, + ) -> Result, Report> { + Ok(self.versions.clone()) + } + + fn clone_service_version( + &self, + _service_id: &str, + version_number: u32, + ) -> Result> { + Ok(self.clone_result.clone().unwrap_or(ServiceVersion { + number: version_number + 1, + active: false, + locked: false, + })) + } + + fn activate_service_version( + &self, + service_id: &str, + version_number: u32, + ) -> Result> { + self.activated_versions + .lock() + .expect("should lock activated versions") + .push((service_id.to_string(), version_number)); + Ok(ServiceVersion { + number: version_number, + active: true, + locked: true, + }) + } + + fn list_resource_links( + &self, + _service_id: &str, + _version_number: u32, + ) -> Result, Report> { + Ok(self.links.clone()) + } + + fn create_resource_link( + &self, + _service_id: &str, + _version_number: u32, + resource_id: &str, + name: &str, + ) -> Result> { + Ok(ResourceLink { + id: format!("link-{name}"), + name: name.to_string(), + resource_id: resource_id.to_string(), + }) + } + + fn update_resource_link( + &self, + _service_id: &str, + _version_number: u32, + link_id: &str, + resource_id: &str, + name: &str, + ) -> Result> { + Ok(ResourceLink { + id: link_id.to_string(), + name: name.to_string(), + resource_id: resource_id.to_string(), + }) + } + } + + fn validated_config(enable_request_signing: bool) -> crate::config::ValidatedConfig { + let tempdir = tempfile::tempdir().expect("should create tempdir"); + let path = tempdir.path().join("trusted-server.toml"); + let mut config = crate::config::STARTER_CONFIG_TEMPLATE.to_string(); + if enable_request_signing { + config = config.replace( + "enabled = false # Set to true to enable request signing", + "enabled = true", + ); + } + std::fs::write(&path, config).expect("should write config"); + crate::config::load_validated_config(Some(&path)).expect("should validate config") + } + + #[test] + fn plan_reports_create_update_and_bind_actions() { + let config_store = NamedResource { + id: "cfg_123".to_string(), + name: APPLICATION_CONFIG_STORE_NAME.to_string(), + }; + let api = MockFastlyApi { + config_stores: HashMap::from([( + APPLICATION_CONFIG_STORE_NAME.to_string(), + config_store.clone(), + )]), + config_items: HashMap::from([( + config_store.id.clone(), + HashMap::from([(APPLICATION_CONFIG_KEY.to_string(), "old".to_string())]), + )]), + versions: vec![ServiceVersion { + number: 9, + active: true, + locked: true, + }], + ..Default::default() + }; + let validated = validated_config(false); + + let plan = plan_fastly_provisioning(&api, &validated, "svc_123") + .expect("should plan provisioning"); + + assert!( + plan.json + .actions + .iter() + .any(|action| action.action == ChangeKind::Update + && action.name == APPLICATION_CONFIG_STORE_NAME), + "should plan runtime config update" + ); + assert!( + plan.json.service_version.clone_required, + "should require a clone when bindings would be added on a locked version" + ); + } + + #[test] + fn plan_bootstraps_empty_request_signing_stores_and_warns_about_runtime_token() { + let api = MockFastlyApi { + versions: vec![ServiceVersion { + number: 9, + active: true, + locked: false, + }], + ..Default::default() + }; + let validated = validated_config(true); + + let plan = plan_fastly_provisioning(&api, &validated, "svc_123") + .expect("should plan provisioning"); + + assert!( + plan.json + .actions + .iter() + .any(|action| action.detail.contains("set config item `current-kid`")), + "should seed current-kid" + ); + assert!( + plan.json + .actions + .iter() + .any(|action| action.detail.contains("set config item `active-kids`")), + "should seed active-kids" + ); + assert!( + plan.json + .actions + .iter() + .any(|action| action.name == SIGNING_SECRET_STORE_NAME + && action.detail.contains("upload secret `ts-")), + "should upload an initial signing secret" + ); + assert!( + plan.json + .warnings + .iter() + .any(|warning| warning.contains("uninitialized")), + "should warn about signing key bootstrap" + ); + assert!( + plan.json + .warnings + .iter() + .any(|warning| warning.contains("FASTLY_RUNTIME_API_KEY")), + "should warn that apply needs an explicit runtime token" + ); + } + + #[test] + fn apply_requires_explicit_runtime_token_when_request_signing_needs_one() { + let api = MockFastlyApi { + versions: vec![ServiceVersion { + number: 9, + active: true, + locked: false, + }], + ..Default::default() + }; + let validated = validated_config(true); + + let error = apply_fastly_provisioning(&api, &validated, "svc_123", None, true) + .expect_err("should reject implicit reuse of the management token"); + + assert!( + format!("{error:?}").contains("FASTLY_RUNTIME_API_KEY"), + "should explain how to provide the runtime token" + ); + } + + #[test] + fn apply_activates_target_version_when_bindings_change() { + let api = MockFastlyApi { + versions: vec![ServiceVersion { + number: 9, + active: true, + locked: true, + }], + clone_result: Some(ServiceVersion { + number: 10, + active: false, + locked: false, + }), + ..Default::default() + }; + let validated = validated_config(false); + + let applied = apply_fastly_provisioning(&api, &validated, "svc_123", None, true) + .expect("should apply provisioning"); + + assert!( + applied.activated_version, + "should activate the modified version" + ); + assert_eq!( + api.activated_versions + .lock() + .expect("should lock activated versions") + .as_slice(), + &[("svc_123".to_string(), 10)], + "should activate the cloned target version" + ); + } +} diff --git a/crates/trusted-server-cli/src/lib.rs b/crates/trusted-server-cli/src/lib.rs new file mode 100644 index 00000000..90b7d3e5 --- /dev/null +++ b/crates/trusted-server-cli/src/lib.rs @@ -0,0 +1,477 @@ +mod audit; +mod config; +mod dev; +mod error; +mod fastly; +mod output; + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Args, Parser, Subcommand}; +use error_stack::Report; + +use crate::error::CliError; +use crate::fastly::api::ReqwestFastlyApi; +use crate::fastly::auth::{ + SystemCredentialStore, fastly_auth_status, login_fastly, logout_fastly, resolve_fastly_api_key, +}; +use crate::fastly::provision::{apply_fastly_provisioning, plan_fastly_provisioning}; +use crate::output::{format_report, write_json, write_stderr_line, write_stdout_line}; + +#[derive(Debug, Parser)] +#[command(name = "ts")] +#[command(about = "Trusted Server CLI")] +pub struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + Config { + #[command(subcommand)] + command: ConfigCommand, + }, + Audit(AuditArgs), + Dev(DevArgs), + Auth { + #[command(subcommand)] + command: AuthCommand, + }, + Provision { + #[command(subcommand)] + command: ProvisionCommand, + }, +} + +#[derive(Debug, Subcommand)] +enum ConfigCommand { + Init(ConfigInitArgs), + Validate(ConfigValidateArgs), +} + +#[derive(Debug, Args)] +struct ConfigInitArgs { + #[arg(long)] + config: Option, + #[arg(long)] + force: bool, +} + +#[derive(Debug, Args)] +struct ConfigValidateArgs { + #[arg(long)] + config: Option, + #[arg(long)] + json: bool, +} + +#[derive(Debug, Args)] +struct AuditArgs { + 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, +} + +#[derive(Debug, Args)] +struct DevArgs { + #[arg(long, short = 'a', default_value = "fastly")] + adapter: dev::Adapter, + #[arg(long)] + config: Option, + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + passthrough: Vec, +} + +#[derive(Debug, Subcommand)] +enum AuthCommand { + Fastly { + #[command(subcommand)] + command: FastlyAuthCommand, + }, +} + +#[derive(Debug, Subcommand)] +enum FastlyAuthCommand { + Login, + Status(FastlyAuthStatusArgs), + Logout, +} + +#[derive(Debug, Args)] +struct FastlyAuthStatusArgs { + #[arg(long)] + json: bool, +} + +#[derive(Debug, Subcommand)] +enum ProvisionCommand { + Fastly { + #[command(subcommand)] + command: FastlyProvisionCommand, + }, +} + +#[derive(Debug, Subcommand)] +enum FastlyProvisionCommand { + Plan(FastlyProvisionArgs), + Apply(FastlyProvisionApplyArgs), +} + +#[derive(Debug, Args)] +struct FastlyProvisionArgs { + #[arg(long)] + service_id: String, + #[arg(long)] + config: Option, + #[arg(long)] + json: bool, +} + +#[derive(Debug, Args)] +struct FastlyProvisionApplyArgs { + #[arg(long)] + service_id: String, + #[arg(long)] + config: Option, + #[arg(long)] + json: bool, + #[arg(long)] + yes: bool, + #[arg(long)] + runtime_api_key: Option, + #[arg(long)] + reuse_management_api_key: bool, +} + +#[must_use] +pub fn run() -> ExitCode { + match execute() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + let _ = write_stderr_line(format_report(&error)); + if matches!(error.current_context(), CliError::Cancelled) { + ExitCode::from(130) + } else { + ExitCode::from(1) + } + } + } +} + +fn execute() -> Result<(), Report> { + let cli = Cli::parse(); + match cli.command { + Command::Config { command } => run_config(command), + Command::Audit(args) => run_audit(&args), + Command::Dev(args) => run_dev(&args), + Command::Auth { command } => run_auth(command), + Command::Provision { command } => run_provision(command), + } +} + +fn run_config(command: ConfigCommand) -> Result<(), Report> { + match command { + ConfigCommand::Init(args) => { + let path = config::resolve_config_path(args.config.as_deref())?; + config::write_starter_config(&path, args.force)?; + write_stdout_line(format!("Initialized config at {}", path.display())) + } + ConfigCommand::Validate(args) => { + if args.json { + let response = config::validate_config_json(args.config.as_deref()); + let valid = response.valid; + write_json(&response)?; + if valid { + Ok(()) + } else { + Err(Report::new(CliError::Configuration) + .attach("configuration validation failed")) + } + } else { + let validated = config::load_validated_config(args.config.as_deref())?; + write_stdout_line(format!( + "Config valid: {}\nConfig hash: {}", + validated.path.display(), + validated.loaded.config_hash + )) + } + } + } +} + +fn run_audit(args: &AuditArgs) -> Result<(), Report> { + if args.no_js_assets && args.no_config { + return Err(Report::new(CliError::Arguments) + .attach("nothing to do: both --no-js-assets and --no-config were set")); + } + + let url = url::Url::parse(&args.url).map_err(|error| { + Report::new(CliError::Arguments) + .attach(format!("invalid audit URL `{}`: {error}", args.url)) + })?; + let outputs = audit::perform_audit(&url)?; + + let js_assets_path = if args.no_js_assets { + None + } else { + Some(config::resolve_config_path( + args.js_assets + .as_deref() + .or_else(|| Some(std::path::Path::new("js-assets.toml"))), + )?) + }; + let config_path = if args.no_config { + None + } else { + Some(config::resolve_config_path(args.config.as_deref())?) + }; + + let written = audit::write_audit_outputs( + &outputs, + js_assets_path.as_deref(), + config_path.as_deref(), + args.force, + )?; + + let integrations = outputs + .artifact + .detected_integrations + .iter() + .map(|integration| integration.id.clone()) + .collect::>(); + + write_stdout_line(format!( + "Audited {}\nTitle: {}\nJS assets: {}\nThird-party assets: {}\nDetected integrations: {}\nWrote: {}", + outputs.artifact.audited_url, + outputs + .artifact + .page_title + .clone() + .unwrap_or_else(|| "".to_string()), + 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(", ") + } + )) +} + +fn run_dev(args: &DevArgs) -> Result<(), Report> { + let validated = config::load_validated_config(args.config.as_deref())?; + let status = dev::run_dev_command(args.adapter, &validated, &args.passthrough)?; + if status.success() { + Ok(()) + } else { + Err(Report::new(CliError::Development).attach(format!( + "`fastly compute serve` exited with status {status}" + ))) + } +} + +fn run_auth(command: AuthCommand) -> Result<(), Report> { + let store = SystemCredentialStore; + match command { + AuthCommand::Fastly { + command: FastlyAuthCommand::Login, + } => { + login_fastly(&store)?; + write_stdout_line("Stored Fastly API key in secure storage") + } + AuthCommand::Fastly { + command: FastlyAuthCommand::Status(args), + } => { + let status = fastly_auth_status(&store)?; + if args.json { + write_json(&status) + } else { + write_stdout_line(format!( + "Environment credential: {}\nStored credential: {}\nEffective source: {}", + if status.has_env_credential { + "present" + } else { + "missing" + }, + if status.has_stored_credential { + "present" + } else { + "missing" + }, + match status.effective_source { + Some(crate::fastly::auth::CredentialSource::Environment) => "environment", + Some(crate::fastly::auth::CredentialSource::SecureStorage) => + "secure-storage", + None => "none", + } + )) + } + } + AuthCommand::Fastly { + command: FastlyAuthCommand::Logout, + } => { + logout_fastly(&store)?; + write_stdout_line("Removed stored Fastly credential") + } + } +} + +const FASTLY_RUNTIME_API_KEY_ENV: &str = "FASTLY_RUNTIME_API_KEY"; + +fn resolve_runtime_api_key_for_apply( + management_api_key: &str, + explicit_runtime_api_key: Option<&str>, + reuse_management_api_key: bool, + request_signing_enabled: bool, +) -> Result, Report> { + if !request_signing_enabled { + return Ok(None); + } + + let explicit_runtime_api_key = explicit_runtime_api_key + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let env_runtime_api_key = std::env::var(FASTLY_RUNTIME_API_KEY_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + let selected_sources = usize::from(explicit_runtime_api_key.is_some()) + + usize::from(env_runtime_api_key.is_some()) + + usize::from(reuse_management_api_key); + if selected_sources > 1 { + return Err(Report::new(CliError::Arguments).attach(format!( + "choose only one runtime Fastly API key source: `--runtime-api-key`, {FASTLY_RUNTIME_API_KEY_ENV}, or `--reuse-management-api-key`" + ))); + } + + if let Some(value) = explicit_runtime_api_key { + return Ok(Some(value)); + } + if let Some(value) = env_runtime_api_key { + return Ok(Some(value)); + } + if reuse_management_api_key { + return Ok(Some(management_api_key.to_string())); + } + + Ok(None) +} + +fn run_provision(command: ProvisionCommand) -> Result<(), Report> { + let store = SystemCredentialStore; + let resolved = resolve_fastly_api_key(&store)?; + write_stderr_line(format!( + "Using Fastly credential source: {}", + match resolved.source { + crate::fastly::auth::CredentialSource::Environment => "environment", + crate::fastly::auth::CredentialSource::SecureStorage => "secure-storage", + } + ))?; + let api = ReqwestFastlyApi::new(resolved.value.clone())?; + + match command { + ProvisionCommand::Fastly { + command: FastlyProvisionCommand::Plan(args), + } => { + let validated = config::load_validated_config(args.config.as_deref())?; + let plan = plan_fastly_provisioning(&api, &validated, &args.service_id)?; + if args.json { + write_json(&plan.json) + } else { + write_stdout_line(format!( + "Service: {}\nLatest version: {}\nTarget version: {}\nActions: {}\nWarnings: {}", + plan.json.service_id, + plan.json.service_version.latest_version, + plan.json.service_version.target_version, + if plan.json.actions.is_empty() { + "none".to_string() + } else { + plan.json + .actions + .iter() + .map(|action| { + format!( + "{} {}", + action.detail, + action.remote_id.as_deref().unwrap_or("") + ) + }) + .collect::>() + .join("; ") + }, + if plan.json.warnings.is_empty() { + "none".to_string() + } else { + plan.json.warnings.join("; ") + } + )) + } + } + ProvisionCommand::Fastly { + command: FastlyProvisionCommand::Apply(args), + } => { + let validated = config::load_validated_config(args.config.as_deref())?; + let runtime_api_key = resolve_runtime_api_key_for_apply( + &resolved.value, + args.runtime_api_key.as_deref(), + args.reuse_management_api_key, + validated + .loaded + .settings + .request_signing + .as_ref() + .is_some_and(|request_signing| request_signing.enabled), + )?; + let applied = apply_fastly_provisioning( + &api, + &validated, + &args.service_id, + runtime_api_key.as_deref(), + args.yes, + )?; + if args.json { + write_json(&applied) + } else { + write_stdout_line(format!( + "Applied {} change(s) to service {} using version {}\nActivated version: {}", + applied.completed_actions.len(), + applied.service_id, + applied.service_version.target_version, + if applied.activated_version { + "yes" + } else { + "no" + } + )) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn clap_command_debug_asserts() { + Cli::command().debug_assert(); + } +} diff --git a/crates/trusted-server-cli/src/main.rs b/crates/trusted-server-cli/src/main.rs new file mode 100644 index 00000000..c855cdcd --- /dev/null +++ b/crates/trusted-server-cli/src/main.rs @@ -0,0 +1,3 @@ +fn main() -> std::process::ExitCode { + trusted_server_cli::run() +} diff --git a/crates/trusted-server-cli/src/output.rs b/crates/trusted-server-cli/src/output.rs new file mode 100644 index 00000000..0399a0f4 --- /dev/null +++ b/crates/trusted-server-cli/src/output.rs @@ -0,0 +1,29 @@ +use std::io::{self, Write as _}; + +use error_stack::{Report, ResultExt}; +use serde::Serialize; + +use crate::error::CliError; + +pub fn write_stdout_line(line: impl AsRef) -> Result<(), Report> { + let mut stdout = io::stdout().lock(); + writeln!(stdout, "{}", line.as_ref()).change_context(CliError::Io) +} + +pub fn write_stderr_line(line: impl AsRef) -> Result<(), Report> { + let mut stderr = io::stderr().lock(); + writeln!(stderr, "{}", line.as_ref()).change_context(CliError::Io) +} + +pub fn write_json(value: &T) -> Result<(), Report> +where + T: Serialize, +{ + let mut stdout = io::stdout().lock(); + serde_json::to_writer_pretty(&mut stdout, value).change_context(CliError::Json)?; + writeln!(stdout).change_context(CliError::Io) +} + +pub fn format_report(error: &Report) -> String { + format!("{error:?}") +} diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 6f56e85a..f328e4d9 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -1,54 +1 @@ -// 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/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:?}")); - } -} +fn main() {} diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index bc486ded..88751358 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/bin/ts-config-canonicalize.rs b/crates/trusted-server-core/src/bin/ts-config-canonicalize.rs new file mode 100644 index 00000000..5f978f69 --- /dev/null +++ b/crates/trusted-server-core/src/bin/ts-config-canonicalize.rs @@ -0,0 +1,36 @@ +use std::env; +use std::fs; +use std::io::{self, Write as _}; +use std::process::ExitCode; + +use trusted_server_core::runtime_config::load_runtime_config; + +fn main() -> ExitCode { + let Some(path) = env::args().nth(1) else { + let _ = writeln!( + io::stderr(), + "usage: ts-config-canonicalize " + ); + return ExitCode::from(2); + }; + + let toml = match fs::read_to_string(&path) { + Ok(contents) => contents, + Err(error) => { + let _ = writeln!(io::stderr(), "failed to read {path}: {error}"); + return ExitCode::from(1); + } + }; + + let loaded = match load_runtime_config(&toml) { + Ok(config) => config, + Err(error) => { + let _ = writeln!(io::stderr(), "failed to canonicalize config: {error:?}"); + return ExitCode::from(1); + } + }; + + let _ = writeln!(io::stderr(), "config hash: {}", loaded.config_hash); + let _ = write!(io::stdout(), "{}", loaded.canonical_toml); + ExitCode::SUCCESS +} diff --git a/crates/trusted-server-core/src/consent_config.rs b/crates/trusted-server-core/src/consent_config.rs index e2074c48..7051f3ef 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. /// @@ -178,6 +179,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")] @@ -200,6 +202,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")] @@ -224,6 +227,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")] @@ -257,6 +261,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/lib.rs b/crates/trusted-server-core/src/lib.rs index 44fa108d..8ad58e1a 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -55,8 +55,8 @@ pub mod publisher; pub mod redacted; pub mod request_signing; pub mod rsc_flight; +pub mod runtime_config; pub mod settings; -pub mod settings_data; pub mod storage; pub mod streaming_processor; pub mod streaming_replacer; diff --git a/crates/trusted-server-core/src/runtime_config.rs b/crates/trusted-server-core/src/runtime_config.rs new file mode 100644 index 00000000..04e10a5d --- /dev/null +++ b/crates/trusted-server-core/src/runtime_config.rs @@ -0,0 +1,252 @@ +//! Runtime application configuration loading and canonicalization. +//! +//! This module defines the runtime configuration contract for Trusted Server: +//! application config is loaded as TOML, parsed strictly, validated +//! semantically, canonicalized deterministically, and hashed from canonical +//! bytes. + +use std::collections::BTreeSet; + +use error_stack::{Report, ResultExt}; +use sha2::{Digest as _, Sha256}; +use toml::Value as TomlValue; +use validator::Validate; + +use crate::error::TrustedServerError; +use crate::settings::{parse_toml_document, Settings, TOP_LEVEL_APPLICATION_CONFIG_KEYS}; + +/// Hardcoded runtime config store name. +pub const APPLICATION_CONFIG_STORE_NAME: &str = "ts_config_store"; + +/// Hardcoded runtime config payload key. +pub const APPLICATION_CONFIG_KEY: &str = "ts-config"; + +/// Fully processed runtime config. +#[derive(Debug, Clone)] +pub struct LoadedRuntimeConfig { + /// Validated immutable settings snapshot used for a single request. + pub settings: Settings, + /// Deterministic canonical TOML payload. + pub canonical_toml: String, + /// Lowercase hex SHA-256 of [`Self::canonical_toml`]. + pub config_hash: String, +} + +/// Parse, validate, canonicalize, and hash runtime configuration. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when the TOML is malformed, +/// contains unknown fields, fails semantic validation, or cannot be +/// canonicalized. +pub fn load_runtime_config( + toml_str: &str, +) -> Result> { + let parsed_document = parse_toml_document(toml_str)?; + let settings = Settings::from_toml(toml_str)?; + + settings + .validate() + .change_context(TrustedServerError::Configuration { + message: "Failed to validate configuration".to_string(), + })?; + + let normalized_value = + TomlValue::try_from(&settings).change_context(TrustedServerError::Configuration { + message: "Failed to serialize validated configuration".to_string(), + })?; + + let canonical_value = retain_declared_fields( + &TomlValue::Table(parsed_document), + &normalized_value, + "root", + )?; + + let canonical_toml = + toml::to_string(&canonical_value).change_context(TrustedServerError::Configuration { + message: "Failed to serialize canonical TOML".to_string(), + })?; + + let config_hash = hex::encode(Sha256::digest(canonical_toml.as_bytes())); + + Ok(LoadedRuntimeConfig { + settings, + canonical_toml, + config_hash, + }) +} + +/// Returns the known top-level application-config keys. +#[must_use] +pub fn known_top_level_keys() -> &'static [&'static str] { + TOP_LEVEL_APPLICATION_CONFIG_KEYS +} + +fn retain_declared_fields( + raw: &TomlValue, + normalized: &TomlValue, + path: &str, +) -> Result> { + match (raw, normalized) { + (TomlValue::Table(raw_table), TomlValue::Table(normalized_table)) => { + let mut output = toml::map::Map::new(); + let mut keys: BTreeSet<&String> = BTreeSet::new(); + for key in raw_table.keys() { + keys.insert(key); + } + + for key in keys { + let child_path = format!("{path}.{key}"); + let raw_value = raw_table.get(key).expect("should contain raw key"); + let normalized_value = normalized_table.get(key).ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: format!( + "Canonicalization failed because `{child_path}` was not preserved" + ), + }) + })?; + output.insert( + key.clone(), + retain_declared_fields(raw_value, normalized_value, &child_path)?, + ); + } + + Ok(TomlValue::Table(output)) + } + (TomlValue::Array(raw_items), TomlValue::Array(normalized_items)) => { + if raw_items.len() != normalized_items.len() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Canonicalization failed because array length changed at `{path}`" + ), + })); + } + + let mut items = Vec::with_capacity(raw_items.len()); + for (index, (raw_item, normalized_item)) in + raw_items.iter().zip(normalized_items).enumerate() + { + items.push(retain_declared_fields( + raw_item, + normalized_item, + &format!("{path}[{index}]"), + )?); + } + Ok(TomlValue::Array(items)) + } + (_, normalized_scalar) => Ok(normalized_scalar.clone()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_config() -> &'static str { + r#" +[[handlers]] +path = "^/admin" +username = "admin" +password = "secret" + +[publisher] +domain = "publisher.com" +cookie_domain = ".publisher.com" +origin_url = "https://origin.publisher.com" +proxy_secret = "proxy-secret" + +[edge_cookie] +secret_key = "secret-key" +"# + } + + #[test] + fn canonicalization_preserves_only_declared_fields() { + let loaded = load_runtime_config(base_config()).expect("should load valid config"); + assert!( + !loaded.canonical_toml.contains("[proxy]"), + "should not serialize omitted default sections" + ); + assert!( + !loaded.canonical_toml.contains("certificate_check"), + "should not serialize omitted default fields" + ); + } + + #[test] + fn canonicalization_normalizes_declared_values() { + let config = r#" +[[handlers]] +path = "^/admin" +username = "admin" +password = "secret" + +[publisher] +domain = "publisher.com" +cookie_domain = ".publisher.com" +origin_url = "https://origin.publisher.com" +proxy_secret = "proxy-secret" + +[edge_cookie] +secret_key = "secret-key" + +[proxy] +allowed_domains = [" Example.COM ", "*.DoubleClick.Net"] +"#; + + let loaded = load_runtime_config(config).expect("should load valid config"); + assert!( + loaded + .canonical_toml + .contains("allowed_domains = [\"example.com\", \"*.doubleclick.net\"]"), + "should canonicalize normalized proxy allowed_domains" + ); + } + + #[test] + fn config_hash_is_stable_for_reordered_input() { + let a = r#" +[[handlers]] +path = "^/admin" +username = "admin" +password = "secret" + +[publisher] +domain = "publisher.com" +cookie_domain = ".publisher.com" +origin_url = "https://origin.publisher.com" +proxy_secret = "proxy-secret" + +[edge_cookie] +secret_key = "secret-key" +"#; + + let b = r#" +[publisher] +origin_url = "https://origin.publisher.com" +proxy_secret = "proxy-secret" +cookie_domain = ".publisher.com" +domain = "publisher.com" + +[edge_cookie] +secret_key = "secret-key" + +[[handlers]] +username = "admin" +password = "secret" +path = "^/admin" +"#; + + let loaded_a = load_runtime_config(a).expect("should load first config"); + let loaded_b = load_runtime_config(b).expect("should load reordered config"); + + assert_eq!( + loaded_a.config_hash, loaded_b.config_hash, + "should produce identical hashes for semantically identical config" + ); + assert_eq!( + loaded_a.canonical_toml, loaded_b.canonical_toml, + "should canonicalize reordered input to the same bytes" + ); + } +} diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 78549262..a219991f 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; @@ -6,6 +7,7 @@ use serde_json::Value as JsonValue; use std::collections::HashMap; use std::ops::{Deref, DerefMut}; use std::sync::OnceLock; +use toml::Table as TomlTable; use url::Url; use validator::{Validate, ValidationError}; @@ -14,10 +16,27 @@ use crate::consent_config::ConsentConfig; use crate::error::TrustedServerError; use crate::redacted::Redacted; +/// Known top-level keys for the application configuration document. +pub const TOP_LEVEL_APPLICATION_CONFIG_KEYS: &[&str] = &[ + "auction", + "consent", + "edge_cookie", + "handlers", + "integrations", + "proxy", + "publisher", + "request_signing", + "response_headers", + "rewrite", +]; + +#[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)] +#[serde(deny_unknown_fields)] pub struct Publisher { pub domain: String, #[validate(custom(function = validate_cookie_domain))] @@ -83,6 +102,36 @@ pub trait IntegrationConfig: DeserializeOwned + Validate { fn is_enabled(&self) -> bool; } +/// Parse the root TOML document and reject unknown top-level keys. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when the payload is malformed +/// TOML or contains unsupported top-level fields. +pub(crate) fn parse_toml_document(toml_str: &str) -> Result> { + let document: TomlTable = + toml::from_str(toml_str).change_context(TrustedServerError::Configuration { + message: "Failed to deserialize TOML configuration".to_string(), + })?; + + let unknown_keys: Vec = document + .keys() + .filter(|key| !TOP_LEVEL_APPLICATION_CONFIG_KEYS.contains(&key.as_str())) + .cloned() + .collect(); + + if unknown_keys.is_empty() { + return Ok(document); + } + + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Unknown top-level configuration field(s): {}", + unknown_keys.join(", ") + ), + })) +} + impl IntegrationSettings { /// Inserts a configuration value for an integration. /// @@ -206,6 +255,7 @@ impl DerefMut for IntegrationSettings { /// Edge Cookie configuration. #[allow(unused)] #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] pub struct EdgeCookie { #[validate(custom(function = EdgeCookie::validate_secret_key))] pub secret_key: Redacted, @@ -238,6 +288,7 @@ impl EdgeCookie { } #[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. @@ -274,6 +325,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, @@ -322,6 +374,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, @@ -334,6 +387,7 @@ fn default_request_signing_enabled() -> bool { } #[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. @@ -427,15 +481,15 @@ pub struct Settings { #[allow(unused)] 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 runtime TOML string. /// /// # Errors /// - /// - [`TrustedServerError::Configuration`] if the TOML is invalid or missing required fields + /// - [`TrustedServerError::Configuration`] if the TOML is invalid, contains unknown fields, + /// or is missing required fields pub fn from_toml(toml_str: &str) -> Result> { + parse_toml_document(toml_str)?; + let mut settings: Self = toml::from_str(toml_str).change_context(TrustedServerError::Configuration { message: "Failed to deserialize TOML configuration".to_string(), @@ -452,13 +506,18 @@ impl Settings { /// Creates a new [`Settings`] instance from a TOML string, applying /// environment variable overrides using the `TRUSTED_SERVER__` prefix. /// - /// Used by build.rs to merge the base config with env vars before - /// baking the result into the binary. + /// Test-only compatibility helper retained for unit tests that still cover + /// legacy env-override parsing semantics. /// /// # Errors /// - /// - [`TrustedServerError::Configuration`] if the TOML is invalid or missing required fields + /// Returns [`TrustedServerError::Configuration`] if the TOML is invalid, + /// the merged configuration fails to deserialize, or required validation + /// checks fail. + #[cfg(test)] pub fn from_toml_and_env(toml_str: &str) -> Result> { + parse_toml_document(toml_str)?; + let environment = Environment::default() .prefix(ENVIRONMENT_VARIABLE_PREFIX) .separator(ENVIRONMENT_VARIABLE_SEPARATOR); @@ -481,13 +540,6 @@ impl Settings { settings.integrations.normalize(); settings.proxy.normalize(); settings.consent.validate(); - - settings.validate().map_err(|err| { - Report::new(TrustedServerError::Configuration { - message: format!("Build-time configuration validation failed: {err}"), - }) - })?; - settings.prepare_runtime()?; settings.validate_admin_coverage()?; @@ -1169,8 +1221,7 @@ mod tests { fn test_settings_extra_fields() { 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"); + let _error = Settings::from_toml(&toml_str).expect_err("should reject unknown field"); } #[test] diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs deleted file mode 100644 index f69fc7ba..00000000 --- a/crates/trusted-server-core/src/settings_data.rs +++ /dev/null @@ -1,76 +0,0 @@ -use core::str; -use error_stack::{Report, ResultExt}; -use validator::Validate; - -use crate::error::TrustedServerError; -use crate::settings::{EdgeCookie, Publisher, Settings}; - -pub use crate::auction_config_types::AuctionConfig; - -const SETTINGS_DATA: &[u8] = include_bytes!("../../../target/trusted-server-out.toml"); - -/// Creates a new [`Settings`] instance from the embedded configuration file. -/// -/// 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. -/// -/// # 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(), - })?; - - if !settings.proxy.certificate_check { - log::warn!( - "INSECURE: proxy.certificate_check is disabled — TLS certificates will NOT be verified" - ); - } - - if EdgeCookie::is_placeholder_secret_key(settings.edge_cookie.secret_key.expose()) { - log::warn!( - "INSECURE: edge_cookie.secret_key is set to a default placeholder — \ - HMAC-SHA256 signatures can be forged. \ - Override via TRUSTED_SERVER__EDGE_COOKIE__SECRET_KEY at build time" - ); - } - - if Publisher::is_placeholder_proxy_secret(settings.publisher.proxy_secret.expose()) { - log::warn!( - "INSECURE: publisher.proxy_secret is set to a default placeholder — \ - XChaCha20-Poly1305 encrypted URLs can be decrypted by anyone. \ - Override via TRUSTED_SERVER__PUBLISHER__PROXY_SECRET at build time" - ); - } - - Ok(settings) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn get_settings_loads_embedded_toml_successfully() { - // The embedded TOML contains placeholder secrets (e.g. "trusted-server", - // "change-me-proxy-secret"). This is expected — production builds override - // them via TRUSTED_SERVER__* env vars at build time. - let settings = get_settings().expect("should load settings from embedded TOML"); - assert!(!settings.publisher.domain.is_empty()); - assert!(!settings.publisher.cookie_domain.is_empty()); - assert!(!settings.publisher.origin_url.is_empty()); - } -} diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 8fdfaa85..b3a7c239 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -22,7 +22,6 @@ pub mod tests { [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" - origin_backend = "publisher_origin" origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index d66a820c..9051f8f5 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -104,6 +104,8 @@ export default withMermaid( items: [ { text: 'Architecture', link: '/guide/architecture' }, { text: 'Configuration', link: '/guide/configuration' }, + { text: 'CLI', link: '/guide/cli' }, + { text: 'Fastly Provisioning', link: '/guide/fastly-provisioning' }, { 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 00000000..c3b7bca8 --- /dev/null +++ b/docs/guide/cli.md @@ -0,0 +1,521 @@ +# Trusted Server CLI + +The Trusted Server CLI is the operator tool for local configuration, page audits, Fastly credentials, local development, and Fastly resource provisioning. + +The binary name is `ts`. Use it when you want to validate `trusted-server.toml`, start a local Fastly Compute server with runtime configuration, inspect a publisher page for integrations, or provision Fastly stores and bindings for an existing Compute service. + +## Requirements + +Install these tools before using the CLI: + +- Rust, pinned by this repository in `.tool-versions` and `rust-toolchain.toml` +- Fastly CLI, required by `ts dev` and Fastly deployments +- A Fastly API token, required by `ts provision fastly ...` +- Chrome or Chromium, required by `ts audit` + +The CLI is a host-target binary. Do not build or run it for `wasm32-wasip1`. + +## Run from source + +From the repository root, use the Cargo aliases in `.cargo/config.toml` when you need to build, check, test, or install the host-target CLI. These aliases avoid Cargo's default workspace target, which is `wasm32-wasip1` for the runtime crates. + +```bash +cargo build_cli +cargo check_cli +cargo test_cli +cargo install_cli +``` + +After installation, verify that the command is on your path: + +```bash +ts --help +``` + +If you do not want to install the binary, run it directly with an explicit host target: + +```bash +HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" +cargo run --package trusted-server-cli --bin ts --target "$HOST_TARGET" -- --help +``` + +## Common workflow + +A typical local workflow starts with a config file, validates it, and then starts Fastly Compute locally: + +```bash +ts config init +# Edit trusted-server.toml +ts config validate +ts dev -a fastly +``` + +To create a draft config from a live publisher page, audit the page before you write the final config: + +```bash +ts audit https://publisher.example +``` + +If you already have `trusted-server.toml`, avoid overwriting it during audit: + +```bash +ts audit https://publisher.example --no-config +``` + +To provision Fastly resources for an existing Compute service: + +```bash +ts auth fastly login +ts provision fastly plan --service-id svc_123 +FASTLY_RUNTIME_API_KEY=your-runtime-token \ + ts provision fastly apply --service-id svc_123 +``` + +## Paths and config files + +Most commands use `trusted-server.toml` in the current working directory by default. Pass `--config ` to use a different file: + +```bash +ts config validate --config config/publisher.toml +ts dev --config config/publisher.toml +ts provision fastly plan --service-id svc_123 --config config/publisher.toml +``` + +Relative paths resolve from the current working directory. Absolute paths are used as-is. + +## Cargo aliases + +This repository sets `wasm32-wasip1` as the default Cargo build target because the runtime deploys to Fastly Compute. The CLI is host-only, so CLI Cargo commands must override that default target. + +Use these aliases from the repository root: + +| Alias | Expands to | Purpose | +| -------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------- | +| `cargo build_cli` | `cargo build --package trusted-server-cli --target aarch64-apple-darwin` | Build the CLI for the configured host target. | +| `cargo check_cli` | `cargo check --package trusted-server-cli --target aarch64-apple-darwin` | Type-check the CLI for the configured host target. | +| `cargo test_cli` | `cargo test --package trusted-server-cli --target aarch64-apple-darwin` | Run CLI tests on the configured host target. | +| `cargo install_cli` | `cargo install --path crates/trusted-server-cli --target aarch64-apple-darwin` | Install `ts` from the local checkout. | +| `cargo test_details` | `cargo test --target aarch64-apple-darwin` | Run tests for the configured host target when you need host-target details. | + +The current aliases target `aarch64-apple-darwin`. If you are not on Apple Silicon macOS, use the explicit host-target form instead: + +```bash +HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" +cargo test --package trusted-server-cli --target "$HOST_TARGET" +``` + +## Command reference + +### `ts config init` + +Create a starter `trusted-server.toml` file from the repository example template. + +```bash +ts config init [--config ] [--force] +``` + +| Option | Description | +| ----------------- | ------------------------------------------------------------------------- | +| `--config ` | Write the starter config to this path. Defaults to `trusted-server.toml`. | +| `--force` | Overwrite the target file if it already exists. | + +By default, the command refuses to overwrite an existing file: + +```text +refusing to overwrite existing file `trusted-server.toml`; re-run with --force +``` + +### `ts config validate` + +Validate a Trusted Server config file and print the canonical config hash. + +```bash +ts config validate [--config ] [--json] +``` + +| Option | Description | +| ----------------- | ------------------------------------------------------------- | +| `--config ` | Validate this config file. Defaults to `trusted-server.toml`. | +| `--json` | Write a machine-readable validation result to stdout. | + +Human-readable output includes the resolved path and config hash: + +```text +Config valid: /path/to/trusted-server.toml +Config hash: 5f2c... +``` + +JSON output uses this shape: + +```json +{ + "valid": true, + "path": "/path/to/trusted-server.toml", + "config_hash": "5f2c...", + "errors": [] +} +``` + +When validation fails with `--json`, the command still writes JSON to stdout, sets `valid` to `false`, puts formatted errors in `errors`, and exits with a non-zero status. + +### `ts dev` + +Validate local config, write a Fastly local manifest, and run `fastly compute serve`. + +```bash +ts dev [--adapter fastly] [--config ] [passthrough args...] +``` + +| Option | Description | +| ---------------------- | ------------------------------------------------------------------------------------------- | +| `-a, --adapter fastly` | Select the runtime adapter. `fastly` is the only supported value. | +| `--config ` | Use this config file. Defaults to `trusted-server.toml`. | +| `passthrough args...` | Pass extra arguments to `fastly compute serve`. Use `--` before Fastly options for clarity. | + +The command writes `fastly.local.toml` in the current working directory. That file extends `fastly.toml` and embeds the canonical Trusted Server config in the local Fastly config store named `ts_config_store`, under item key `ts-config`. + +Then the CLI runs: + +```bash +fastly compute serve --dir --env=local +``` + +Pass Fastly CLI options after `--`: + +```bash +ts dev -- --skip-build +ts dev -- --watch +ts dev -- --addr 127.0.0.1:7676 +``` + +When `--skip-build` is passed without `--file`, the CLI looks for an existing Wasm binary at: + +1. `target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm` +2. `target/wasm32-wasip1/debug/trusted-server-adapter-fastly.wasm` + +If neither file exists, build the Fastly adapter first: + +```bash +cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 +``` + +### `ts audit` + +Audit a public URL with a real Chrome or Chromium browser session, then write draft assets and config files. + +```bash +ts audit [options] +``` + +| Option | Description | +| -------------------- | -------------------------------------------------------------------------------------- | +| `` | Public page URL to audit. | +| `--js-assets ` | Write the JS asset audit to this path. Defaults to `js-assets.toml`. | +| `--config ` | Write the draft Trusted Server config to this path. Defaults to `trusted-server.toml`. | +| `--no-js-assets` | Do not write the JS asset audit file. | +| `--no-config` | Do not write the draft Trusted Server config. | +| `--force` | Overwrite existing output files. | + +The audit collector loads the page in Chromium, reads script tags, records script network requests, and classifies assets as first-party or third-party by host relationship. It detects these integration IDs when there is matching URL or inline-script evidence: + +- `google_tag_manager` +- `gpt` +- `didomi` +- `datadome` +- `permutive` +- `lockr` +- `prebid` + +By default, `ts audit` writes two files: + +| File | Purpose | +| --------------------- | ------------------------------------------------------------------------ | +| `js-assets.toml` | Audit artifact with detected assets, integrations, counts, and warnings. | +| `trusted-server.toml` | Draft config based on the starter template and the audited page host. | + +The draft config updates publisher host fields from the audited URL. It can enable GPT, Didomi, DataDome, and Google Tag Manager when those integrations are detected. Other detected integrations are added as comments that require manual review. + +Use `--no-config` when you already have a config file: + +```bash +ts audit https://publisher.example --no-config +``` + +Use custom output paths when you want to inspect the generated files before moving them into place: + +```bash +ts audit https://publisher.example \ + --js-assets audit/js-assets.toml \ + --config audit/trusted-server.toml +``` + +Use `--force` only when you intend to replace existing output files: + +```bash +ts audit https://publisher.example --force +``` + +The command exits with an argument error if both `--no-js-assets` and `--no-config` are set, since there would be no output to write. + +### `ts auth fastly login` + +Prompt for a Fastly API token and store it in the host secure credential store. + +```bash +ts auth fastly login +``` + +Use this for local development. For CI and automation, set `FASTLY_API_KEY` instead of storing a credential on the machine. + +### `ts auth fastly status` + +Inspect Fastly credential availability. + +```bash +ts auth fastly status [--json] +``` + +Human-readable output reports whether each source is present and which source is active: + +```text +Environment credential: present +Stored credential: present +Effective source: environment +``` + +`FASTLY_API_KEY` takes precedence over secure storage. JSON output uses this shape: + +```json +{ + "has_env_credential": true, + "has_stored_credential": false, + "effective_source": "environment" +} +``` + +`effective_source` is `environment`, `secure-storage`, or `null`. + +### `ts auth fastly logout` + +Remove the stored Fastly credential from secure storage. + +```bash +ts auth fastly logout +``` + +This does not unset `FASTLY_API_KEY`. If the environment variable is set, it remains the effective credential source. + +### `ts provision fastly plan` + +Preview Fastly resources and bindings needed for the local config. + +```bash +ts provision fastly plan --service-id [--config ] [--json] +``` + +| Option | Description | +| --------------------------- | ------------------------------------------------------------ | +| `--service-id ` | Existing Fastly Compute service ID. Required. | +| `--config ` | Config file to provision. Defaults to `trusted-server.toml`. | +| `--json` | Write the plan as JSON. | + +The command uses `FASTLY_API_KEY` or the stored Fastly credential from `ts auth fastly login`. It does not modify the service. + +Plan output includes: + +- Service ID and config path +- Latest and target Fastly service version +- Whether cloning the service version is required +- Planned create, update, and bind actions +- Warnings, including request-signing bootstrap and locked service versions + +JSON output uses this high-level shape: + +```json +{ + "service_id": "svc_123", + "config_path": "/path/to/trusted-server.toml", + "service_version": { + "latest_version": 4, + "target_version": 4, + "clone_required": false, + "clone_source_version": null + }, + "actions": [ + { + "action": "create", + "resource_kind": "config", + "name": "ts_config_store", + "detail": "create config store `ts_config_store`", + "remote_id": null + } + ], + "warnings": [] +} +``` + +### `ts provision fastly apply` + +Apply the Fastly provisioning plan. + +```bash +ts provision fastly apply --service-id [options] +``` + +| Option | Description | +| ---------------------------- | ------------------------------------------------------------ | +| `--service-id ` | Existing Fastly Compute service ID. Required. | +| `--config ` | Config file to provision. Defaults to `trusted-server.toml`. | +| `--json` | Write apply results as JSON. | +| `--yes` | Skip the interactive confirmation prompt. | +| `--runtime-api-key ` | Runtime Fastly API token for request-signing provisioning. | +| `--reuse-management-api-key` | Use the management Fastly API token as the runtime token. | + +`apply` prompts before making changes unless `--yes` is passed. If binding changes are required and the latest Fastly service version is locked, the CLI clones it first. When bindings are created or updated, the CLI activates the target service version. + +`apply` provisions resources and bindings only. It does not deploy the Wasm package. Use `fastly compute publish` for deployment. + +JSON output uses this high-level shape: + +```json +{ + "service_id": "svc_123", + "config_path": "/path/to/trusted-server.toml", + "service_version": { + "latest_version": 4, + "target_version": 5, + "clone_required": true, + "clone_source_version": 4 + }, + "completed_actions": [], + "warnings": [], + "failed_action": null, + "activated_version": true +} +``` + +## Fastly provisioning resources + +Fastly provisioning is config-driven. The CLI reads the validated local config and plans the resources that runtime code expects. See [Fastly Provisioning Map](/guide/fastly-provisioning) for how config changes map to Fastly actions. + +| Resource | Type | When used | +| --------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- | +| `ts_config_store` | Config store | Always. Stores canonical app config under `ts-config`. | +| `jwks_store` | Config store | When `request_signing.enabled = true`. Stores `current-kid`, `active-kids`, and public JWK entries. | +| `signing_keys` | Secret store | When `request_signing.enabled = true`. Stores private signing keys by key ID. | +| `api-keys` | Secret store | When `request_signing.enabled = true`. Stores runtime Fastly API token under `api_key` when missing. | +| Configured consent KV store | KV store | When `[consent] consent_store = "..."` is set. | + +When request signing is enabled and the signing stores are empty, `plan` warns that `apply` will bootstrap an initial Ed25519 keypair. `apply` writes the public JWK data to `jwks_store` and the private signing key to `signing_keys`. + +Request signing also needs a runtime Fastly API token stored as `api-keys/api_key` so the running service can rotate keys. If that secret is missing, choose exactly one runtime token source: + +```bash +FASTLY_RUNTIME_API_KEY=runtime-token ts provision fastly apply --service-id svc_123 + +ts provision fastly apply --service-id svc_123 --runtime-api-key runtime-token + +ts provision fastly apply --service-id svc_123 --reuse-management-api-key +``` + +Prefer `FASTLY_RUNTIME_API_KEY` for local use and CI because it avoids putting the token in shell history. Use `--reuse-management-api-key` only when your management token is acceptable for runtime key rotation. + +After provisioning request signing resources, update these config fields if the plan or apply output warns that the configured IDs differ from Fastly: + +```toml +[request_signing] +config_store_id = "..." +secret_store_id = "..." +``` + +## Environment variables + +| Variable | Used by | Description | +| ------------------------ | -------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `FASTLY_API_KEY` | `ts auth fastly status`, `ts provision fastly ...` | Fastly management API token. Takes precedence over secure storage. | +| `FASTLY_RUNTIME_API_KEY` | `ts provision fastly apply` | Runtime Fastly API token used when request signing needs to create `api-keys/api_key`. | + +## Exit codes + +| Exit code | Meaning | +| --------- | -------------------------------------------- | +| `0` | Command completed successfully. | +| `1` | Command failed. Read stderr for the report. | +| `130` | Interactive apply was cancelled by the user. | + +## Troubleshooting + +### The CLI tries to build for Wasm + +Use the CLI Cargo aliases or pass the host target explicitly: + +```bash +cargo build_cli +cargo test_cli + +HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" +cargo run --package trusted-server-cli --bin ts --target "$HOST_TARGET" -- --help +``` + +### `trusted-server.toml` already exists + +`ts config init` and `ts audit` refuse to overwrite files by default. Use a custom output path, skip config output, or pass `--force` when replacement is intended: + +```bash +ts config init --config draft/trusted-server.toml +ts audit https://publisher.example --no-config +ts audit https://publisher.example --force +``` + +### `ts dev -- --skip-build` cannot find a Wasm file + +Build the Fastly adapter first: + +```bash +cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 +``` + +Or pass an explicit Wasm file to Fastly: + +```bash +ts dev -- --skip-build --file target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm +``` + +### Fastly provisioning cannot find credentials + +Set `FASTLY_API_KEY` or store a local credential: + +```bash +export FASTLY_API_KEY=your-token +# or +ts auth fastly login +``` + +Check which source is active: + +```bash +ts auth fastly status +``` + +### Request-signing provisioning asks for a runtime token + +Set exactly one runtime token source: + +```bash +FASTLY_RUNTIME_API_KEY=runtime-token ts provision fastly apply --service-id svc_123 +``` + +Do not combine `FASTLY_RUNTIME_API_KEY`, `--runtime-api-key`, and `--reuse-management-api-key` in the same command. + +### `ts audit` cannot launch a browser + +Install Chrome or Chromium on the host machine. The audit collector checks common PATH names and standard macOS app bundle locations. + +## Related docs + +- [Getting Started](/guide/getting-started) +- [Configuration](/guide/configuration) +- [Fastly Setup](/guide/fastly) +- [Fastly Provisioning Map](/guide/fastly-provisioning) +- [Request Signing](/guide/request-signing) +- [Testing](/guide/testing) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 029163bb..eba05f7a 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -4,17 +4,23 @@ Learn how to configure Trusted Server for your deployment. ## Overview -Trusted Server uses a flexible configuration system based on: +Trusted Server uses a runtime configuration system based on: -1. **TOML Files** - `trusted-server.toml` for base configuration -2. **Environment Variables** - Build-time overrides with `TRUSTED_SERVER__` prefix (baked into the binary by `build.rs`) -3. **Fastly Stores** - KV/Config/Secret stores for runtime data +1. **TOML authoring** - `trusted-server.toml` for local authoring in development +2. **Config store deployment** - canonical TOML stored at runtime under the fixed key `ts-config` +3. **Fastly stores** - KV/Config/Secret stores for runtime data ## Quick Start ### Minimal Configuration -Create `trusted-server.toml` in your project root: +Create `trusted-server.toml` in your project root with: + +```bash +ts config init +``` + +Then edit it to match your deployment: ```toml [publisher] @@ -27,18 +33,13 @@ proxy_secret = "your-secure-secret-here" secret_key = "your-hmac-secret" ``` -### Environment Variable Overrides +### Runtime loading model -Override any setting at build time. Environment variables are merged into the -config by `build.rs` and baked into the compiled binary — they are **not** read -at runtime. - -```bash -# Format: TRUSTED_SERVER__SECTION__FIELD -export TRUSTED_SERVER__PUBLISHER__DOMAIN=publisher.com -export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com -export TRUSTED_SERVER__EDGE_COOKIE__SECRET_KEY=your-secret -``` +- **Production** reads canonical TOML from the configured platform config store +- **Development** authors a local `trusted-server.toml`, then projects its + canonical form into the local simulated config store before startup +- Application settings are **not** merged from `TRUSTED_SERVER__*` environment + variables at build time anymore ### Generate Secure Secrets @@ -47,13 +48,20 @@ export TRUSTED_SERVER__EDGE_COOKIE__SECRET_KEY=your-secret openssl rand -base64 32 ``` +### Validate Configuration + +```bash +ts config validate +``` + ## Configuration Files -| File | Purpose | -| --------------------- | ------------------------------- | -| `trusted-server.toml` | Main application configuration | -| `fastly.toml` | Fastly Compute service settings | -| `.env.dev` | Local development overrides | +| File | Purpose | +| ----------------------------- | ----------------------------------------------------- | +| `trusted-server.toml` | Local development authoring file (untracked) | +| `trusted-server.example.toml` | Tracked template for authoring real config | +| `fastly.toml` | Fastly Compute service settings | +| `.env.dev` | Local development environment variables (non-app cfg) | ## Key Sections @@ -95,11 +103,15 @@ client_side_bidders = ["rubicon"] The sections below consolidate the full configuration reference on this page. -## Environment Variable Overrides (Build-Time) +## Runtime config store -Environment variables with the `TRUSTED_SERVER__` prefix are merged into the -base TOML configuration by `build.rs` at compile time. The resulting config is -embedded in the binary. Changing an environment variable requires a rebuild. +Application configuration is loaded at runtime from a config store entry whose +key is always `ts-config`. The payload stored under that key is canonical TOML. +Changing application settings no longer requires rebuilding the WASM binary. + +> **Note:** Older revisions of this guide referred to build-time +> `TRUSTED_SERVER__*` application-setting overrides. That mechanism has been +> removed for application configuration. ### Format @@ -1017,28 +1029,29 @@ trusted-server.dev.toml # Development overrides **Environment Variables Not Applied**: -- Env vars are applied at **build time** only — rebuild after changing them -- Verify prefix: `TRUSTED_SERVER__` -- Check separator: `__` (double underscore) -- Confirm variable is exported: `echo $VARIABLE_NAME` -- Try explicit string: `VARIABLE='value'` not `VARIABLE=value` +- Validate the authored TOML before projecting it into the config store +- Re-render the local Fastly/Viceroy config after changing `trusted-server.toml` +- Confirm the local rendered manifest contains `local_server.config_stores.ts_config_store` +- Confirm the `ts-config` key contains the expected canonical TOML ### Debug Configuration -**Print Loaded Config** (test only): - -```rust -use trusted_server_core::settings_data::get_settings; +**Canonicalize local config**: -let settings = get_settings()?; -println!("{:#?}", settings); +```bash +cargo run --target "$(rustc -vV | sed -n 's/^host: //p')" \ + -p trusted-server-core \ + --bin ts-config-canonicalize \ + -- trusted-server.toml ``` -**Check Environment**: +**Render local Fastly config**: ```bash -# List all TRUSTED_SERVER variables -env | grep TRUSTED_SERVER +python3 scripts/render-fastly-local-config.py \ + --app-config trusted-server.toml \ + --template fastly.toml \ + --output fastly.local.toml ``` **Validate TOML**: @@ -1051,6 +1064,7 @@ cat trusted-server.toml | npx toml-cli validate ## Next Steps - Set up [Request Signing](/guide/request-signing) for secure API calls +- Review [Fastly Provisioning Map](/guide/fastly-provisioning) for config changes that create Fastly resources - Configure [First-Party Proxy](/guide/first-party-proxy) for URL proxying - Learn about [Edge Cookies](/guide/edge-cookies) for privacy-preserving identification - Review [Integrations](/guide/integrations-overview) for partner support diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index 99f611aa..743f44e4 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -22,14 +22,16 @@ Common errors, their causes, and solutions when working with Trusted Server. Failed to load settings: ParseError ``` -**Cause:** Invalid TOML syntax in `trusted-server.toml` +**Cause:** Invalid application TOML in the runtime config store payload (`ts-config`), +or failure to read that payload from the configured config store. **Solution:** -1. Validate TOML syntax using an online validator -2. Check for missing quotes around strings -3. Ensure array syntax uses square brackets: `["item1", "item2"]` -4. Verify section headers use brackets: `[section]` +1. Validate the authored `trusted-server.toml` before deployment +2. Re-render local Fastly/Viceroy config after changing `trusted-server.toml` +3. Check for missing quotes around strings +4. Ensure array syntax uses square brackets: `["item1", "item2"]` +5. Verify section headers use brackets: `[section]` **Example Fix:** @@ -97,33 +99,36 @@ server_url = "https://prebid-server.example.com" --- -### Environment variable override failed +### Runtime config store payload failed **Error Message:** ``` -Failed to parse environment variable: TRUSTED_SERVER__PUBLISHER__DOMAIN +Failed to load settings ``` -**Cause:** Environment variable format doesn't match expected type +**Cause:** The runtime `ts-config` payload is missing, malformed, or does not +match the expected application-config schema. -**Solution:** Use correct format for the field type: +**Solution:** Validate and canonicalize the TOML before projecting it into the +config store: ```bash -# For strings -TRUSTED_SERVER__PUBLISHER__DOMAIN="example.com" - -# For numbers -TRUSTED_SERVER__INTEGRATIONS__PREBID__TIMEOUT_MS=1000 +cargo run --target "$(rustc -vV | sed -n 's/^host: //p')" \ + -p trusted-server-core \ + --bin ts-config-canonicalize \ + -- trusted-server.toml +``` -# For booleans -TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=true +For local Fastly/Viceroy development, regenerate the rendered manifest after +editing `trusted-server.toml`: -# For arrays (comma-separated) -TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS="appnexus,rubicon" +```bash +scripts/fastly-dev.sh --skip-build ``` -See [Configuration Reference](./configuration.md) for complete patterns. +Application settings are no longer loaded from `TRUSTED_SERVER__*` +configuration environment variables at build time. --- diff --git a/docs/guide/fastly-provisioning.md b/docs/guide/fastly-provisioning.md new file mode 100644 index 00000000..684da05c --- /dev/null +++ b/docs/guide/fastly-provisioning.md @@ -0,0 +1,251 @@ +# Fastly Provisioning Map + +Fastly provisioning is driven by `trusted-server.toml`. The CLI validates the config, compares the required Fastly resources against the existing service, and then plans create, update, and bind actions. + +Use this page when you want to understand which config changes produce Fastly infrastructure changes and which changes only update runtime configuration. + +## Summary + +Most Trusted Server config changes do not create new Fastly resources. They update the canonical app config stored in Fastly Config Store `ts_config_store`, item `ts-config`. + +Only these config surfaces can change the Fastly resource plan: + +| Config change | Fastly provisioning effect | +| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| Any valid `trusted-server.toml` content change | Updates `ts_config_store` item `ts-config`. Creates and binds `ts_config_store` if missing. | +| `[request_signing] enabled = true` | Creates and binds `jwks_store`, `signing_keys`, and `api-keys` if missing. May bootstrap signing material. | +| `[consent] consent_store = ""` | Creates and binds a Fastly KV store named `` if missing. | +| `request_signing.config_store_id` or `request_signing.secret_store_id` differs from Fastly | Adds warnings telling you to update IDs after provisioning. Does not choose resource names. | + +All other publisher, integration, handler, proxy, header, and auction settings are application config. They are deployed by updating `ts_config_store/ts-config`. + +## Provisioning commands + +Preview changes before applying them: + +```bash +ts provision fastly plan --service-id svc_123 +``` + +Apply the plan: + +```bash +ts provision fastly apply --service-id svc_123 +``` + +Use JSON output when you want to inspect exact actions in automation: + +```bash +ts provision fastly plan --service-id svc_123 --json +``` + +See [Trusted Server CLI](/guide/cli#ts-provision-fastly-plan) for the full command reference. + +## Application config store + +The app config store is always planned. + +| Fastly resource | Type | Name or key | Planned when | +| ----------------- | ------- | ----------------- | ------------------------------------------------------------------------- | +| Config Store | Store | `ts_config_store` | The store is missing. | +| Config Store item | Item | `ts-config` | The item is missing or its value differs from the canonical local config. | +| Resource link | Binding | `ts_config_store` | The current service version has no matching resource link. | + +The CLI stores the canonical TOML, not the raw file bytes. Reordering or formatting changes that canonicalize to the same config should not produce an app config item update. + +Config changes that map only to `ts_config_store/ts-config` include: + +| Config area | Example | +| ------------------------------- | ---------------------------------------------------------------------------------- | +| Publisher settings | `[publisher] domain`, `cookie_domain`, `origin_url` | +| Integrations | `[integrations.prebid]`, `[integrations.gpt]`, `[integrations.google_tag_manager]` | +| Proxy behavior | `[[first_party_proxy.origins]]`, proxy allowlists | +| Handlers | `[[handlers]]` | +| Response headers | `[response_headers]` | +| Auction and ad-serving settings | Prebid bidders, GAM, APS, creative settings | + +These changes affect runtime behavior after the new config item is written. They do not create separate Fastly backends, domains, dictionaries, or stores. + +## Request signing resources + +Request signing resources are planned only when request signing is enabled: + +```toml +[request_signing] +enabled = true +config_store_id = "..." +secret_store_id = "..." +``` + +When enabled, provisioning manages these resources: + +| Fastly resource | Type | Name or key | Purpose | +| ----------------- | -------- | ---------------------------------------- | -------------------------------------------------- | +| Config Store | Store | `jwks_store` | Stores public JWKS material and key state. | +| Config Store item | Item | `current-kid` | Current signing key ID. | +| Config Store item | Item | `active-kids` | Comma-separated active key IDs. | +| Config Store item | Item | `` | Public JWK JSON for a signing key. | +| Secret Store | Store | `signing_keys` | Stores private signing keys. | +| Secret Store item | Secret | `` | Private Ed25519 signing key bytes, base64 encoded. | +| Secret Store | Store | `api-keys` | Stores runtime API credentials. | +| Secret Store item | Secret | `api_key` | Runtime Fastly API token used for key rotation. | +| Resource links | Bindings | `jwks_store`, `signing_keys`, `api-keys` | Make the stores available to the service version. | + +If `jwks_store` and `signing_keys` are empty, `plan` warns that `apply` will bootstrap the first Ed25519 keypair. `apply` writes the public key material to `jwks_store` and the private signing key to `signing_keys`. + +If `api-keys/api_key` is missing, `apply` requires exactly one runtime API token source: + +```bash +FASTLY_RUNTIME_API_KEY=runtime-token ts provision fastly apply --service-id svc_123 + +ts provision fastly apply --service-id svc_123 --runtime-api-key runtime-token + +ts provision fastly apply --service-id svc_123 --reuse-management-api-key +``` + +Prefer `FASTLY_RUNTIME_API_KEY` because it avoids putting the token in shell history. + +### Request signing IDs + +The CLI uses fixed Fastly store names for request signing: + +- `jwks_store` +- `signing_keys` + +The config fields `request_signing.config_store_id` and `request_signing.secret_store_id` are runtime IDs used by key rotation code. They do not control which stores provisioning creates. + +After provisioning, update these fields if the plan or apply output warns that the configured IDs differ from Fastly: + +```toml +[request_signing] +config_store_id = "" +secret_store_id = "" +``` + +## Consent KV store + +Consent KV provisioning is controlled by the `[consent] consent_store` setting: + +```toml +[consent] +consent_store = "consent_store" +``` + +When `consent_store` is set, provisioning manages: + +| Fastly resource | Type | Name or key | Planned when | +| --------------- | ------- | ------------------------ | ---------------------------------------------------------- | +| KV Store | Store | Value of `consent_store` | The KV store is missing. | +| Resource link | Binding | Value of `consent_store` | The current service version has no matching resource link. | + +Changing the `consent_store` value changes the target KV store name. The CLI plans a new KV store and binding for the new name if it does not already exist. It does not delete the old KV store. + +Leaving `consent_store` unset means provisioning does not create or bind a consent KV store. + +## Service version changes + +Fastly resource bindings are attached to service versions. Provisioning may need to update the target service version when resource links change. + +| Situation | Plan or apply behavior | +| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| No binding changes are needed | The latest version remains the target version. No activation is needed. | +| Binding changes are needed and the latest version is unlocked | The latest version is updated and activated after binding changes. | +| Binding changes are needed and the latest version is locked | `apply` clones the latest version, applies binding changes to the clone, then activates the clone. | + +Updating a Config Store item or Secret Store item does not itself require cloning a service version. Creating or updating a resource link does. + +## Action types in JSON output + +JSON plan and apply output describes each change with an action and resource kind: + +| Action | Meaning | +| -------- | ------------------------------------------------------------------------ | +| `create` | Create a store or create an item in an existing store. | +| `update` | Update an existing config item or resource link. | +| `bind` | Create a resource link between the store and the Fastly service version. | + +Resource kinds are: + +| Resource kind | Fastly resource | +| ------------- | ----------------------------- | +| `config` | Fastly Config Store or item | +| `secret` | Fastly Secret Store or secret | +| `kv` | Fastly KV Store | + +## What provisioning does not do + +`ts provision fastly apply` does not: + +- Deploy the Wasm package. Use `fastly compute publish` for deployment. +- Create Fastly services or domains. +- Create Fastly backends for each integration setting. +- Delete stores or old resource bindings when config settings are removed or renamed. +- Rotate existing request-signing keys unless the stores are empty and bootstrap is required. +- Upload `trusted-server.toml` as raw text. The CLI writes canonical runtime config. + +## Examples + +### Change a Prebid server URL + +```toml +[integrations.prebid] +enabled = true +server_url = "https://prebid.example/openrtb2/auction" +``` + +Provisioning effect: + +- Update `ts_config_store/ts-config` if the canonical config changes. +- No new Fastly stores or bindings. + +### Enable request signing + +```toml +[request_signing] +enabled = true +config_store_id = "" +secret_store_id = "" +``` + +Provisioning effect: + +- Ensure `ts_config_store/ts-config` is current. +- Create or bind `jwks_store`. +- Create or bind `signing_keys`. +- Create or bind `api-keys`. +- Bootstrap key material if signing stores are empty. +- Require a runtime Fastly API token if `api-keys/api_key` is missing. +- Warn to update `config_store_id` and `secret_store_id` after store IDs are known. + +### Enable consent persistence + +```toml +[consent] +consent_store = "publisher_consent" +``` + +Provisioning effect: + +- Ensure `ts_config_store/ts-config` is current. +- Create or bind KV store `publisher_consent`. +- No request-signing stores unless request signing is also enabled. + +### Rename the consent KV store + +```toml +[consent] +consent_store = "publisher_consent_v2" +``` + +Provisioning effect: + +- Update `ts_config_store/ts-config`. +- Create or bind KV store `publisher_consent_v2`. +- Leave the old KV store in Fastly. Remove or migrate it manually if it is no longer needed. + +## Related docs + +- [Trusted Server CLI](/guide/cli) +- [Fastly Setup](/guide/fastly) +- [Configuration](/guide/configuration) +- [Request Signing](/guide/request-signing) diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index 2a1edd79..25c12f1b 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -43,15 +43,16 @@ Origins are the backend servers that Trusted Server will communicate with (ad se After saving origin information, you can select port numbers and toggle TLS on/off. ::: -## Configure Fastly CLI Profile +## Configure Trusted Server CLI Auth -After installing the Fastly CLI, create a profile with your API token: +The `ts` CLI manages Fastly credentials explicitly for provisioning: ```bash -fastly profile create +ts auth fastly login +ts auth fastly status ``` -Follow the interactive prompts to paste your API token. +For automation and CI, prefer setting `FASTLY_API_KEY` instead of storing a local credential. See [Trusted Server CLI](/guide/cli#ts-auth-fastly-login) for credential precedence and JSON status output. ## Domain Configuration @@ -72,30 +73,25 @@ When you're ready to use your own domain: - Fastly Compute **only accepts client traffic via TLS** (HTTPS) - Origins and backends can be non-TLS if needed -## Create Config and Secret Stores +## Provision Trusted Server Resources -For features like request signing, you'll need to create Fastly stores: - -### Config Store - -Used for storing public configuration (e.g., public keys, key metadata): +Provisioning is config-first. After authoring `trusted-server.toml`, use `ts` to preview and apply Fastly changes for an existing Compute service: ```bash -fastly config-store create --name jwks_store +ts provision fastly plan --service-id svc_123 +FASTLY_RUNTIME_API_KEY=your-runtime-token \ + ts provision fastly apply --service-id svc_123 ``` -### Secret Store - -Used for storing sensitive data (e.g., private signing keys): +`apply` automatically activates the Fastly service version after changing resource bindings. -```bash -fastly secret-store create --name signing_keys -``` +The CLI provisions the runtime config store, request-signing stores, and required bindings from local configuration. When request signing is enabled, `apply` will bootstrap the initial signing keypair if the signing stores are empty, and it requires an explicit runtime Fastly API token for the `api-keys/api_key` secret. Use `FASTLY_RUNTIME_API_KEY`, `--runtime-api-key`, or `--reuse-management-api-key` for that runtime credential. After provisioning, update `request_signing.config_store_id` and `request_signing.secret_store_id` in `trusted-server.toml` to match the store IDs reported by provisioning. -Note the store IDs - you'll need them for your `trusted-server.toml` configuration. +See [Trusted Server CLI](/guide/cli#ts-provision-fastly-plan) for the full provisioning command reference. See [Fastly Provisioning Map](/guide/fastly-provisioning) for how `trusted-server.toml` changes map to Fastly resources, items, and bindings. ## Next Steps - Return to [Getting Started](/guide/getting-started) to continue setup - See [Configuration](/guide/configuration) for detailed configuration options - See [Request Signing](/guide/request-signing) for setting up cryptographic signing +- See [Fastly Provisioning Map](/guide/fastly-provisioning) for config-to-resource behavior diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index ce4b328d..c277825b 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -30,6 +30,16 @@ Install and configure the Fastly CLI using the [Fastly setup guide](/guide/fastl cargo install viceroy ``` +### Install Trusted Server CLI + +From the repository root, install the host-target `ts` CLI with the Cargo alias: + +```bash +cargo install_cli +``` + +See [Trusted Server CLI](/guide/cli#cargo-aliases) if you need to run the CLI without installing it or if your host is not Apple Silicon macOS. + ## Local Development ### Build the Project @@ -41,20 +51,38 @@ cargo build ### Run Tests ```bash -cargo test +cargo test --workspace --exclude trusted-server-cli +cargo test --package trusted-server-cli --target "$(rustc -vV | sed -n 's/^host: //p')" +``` + +### Initialize and Validate Configuration + +```bash +ts config init +ts config validate ``` ### Start Local Server ```bash -fastly compute serve +ts dev -a fastly ``` The server will be available at `http://localhost:7676`. +### Audit a Public URL + +```bash +ts audit https://example.com +``` + +`ts audit` currently uses a real Chromium browser session and expects Chrome/Chromium to already be installed on the host machine. It checks common PATH names and standard macOS app bundle locations. + +See [Trusted Server CLI](/guide/cli) for the full command reference. + ## Configuration -Edit `trusted-server.toml` to configure: +Use `ts config init` to generate `trusted-server.toml`, then edit it to configure: - Ad server integrations - KV store mappings @@ -73,4 +101,5 @@ fastly compute publish - Learn about [Edge Cookies](/guide/edge-cookies) - Understand [GDPR Compliance](/guide/gdpr-compliance) +- Review the [Trusted Server CLI](/guide/cli) - Configure [Ad Serving](/guide/ad-serving) diff --git a/docs/guide/testing.md b/docs/guide/testing.md index cfa7ea51..b03dcf55 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -77,13 +77,18 @@ cargo test -- --test-threads=1 ```bash # Start local server -fastly compute serve +scripts/fastly-dev.sh # Test endpoints with curl curl http://localhost:7676/health curl http://localhost:7676/.well-known/trusted-server.json ``` +`GET /health` now depends on successful runtime config loading from the local +config store. If `trusted-server.toml` cannot be canonicalized and projected +into `ts-config`, the service is considered unhealthy and `/health` will return +an error instead of `200 ok`. + ## Real Test Examples ### EC ID Tests diff --git a/docs/superpowers/specs/2026-04-20-config-store-runtime-config-design.md b/docs/superpowers/specs/2026-04-20-config-store-runtime-config-design.md new file mode 100644 index 00000000..cd48b561 --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-config-store-runtime-config-design.md @@ -0,0 +1,515 @@ +# Runtime Config Store Architecture for `trusted-server.toml` + +> **Status:** Proposal +> **Scope:** Config store architecture only +> **Date:** April 2026 + +## Summary + +This document specifies the application configuration changes required to move +Trusted Server from **build-time embedded configuration** to **runtime-loaded +configuration**. + +Today, Trusted Server reads `trusted-server.toml` during the build, merges +`TRUSTED_SERVER__*` environment variable overrides, writes a generated TOML +file to `target/trusted-server-out.toml`, and embeds that generated file into +the WASM binary with `include_bytes!`. + +This proposal replaces that model with the following target architecture: + +- **Production** loads application config from a platform config store at runtime +- **Development** uses a local TOML-authored workflow rooted at the repo root by default +- The platform config store uses a **fixed key**: `ts-config` +- The stored payload is **canonical TOML** representing the application config +- The config schema remains substantially the same as the existing + `trusted-server.toml` schema +- Build-time embedding and build-time environment-variable merging for + application settings are removed + +The goal is to make `trusted-server.toml` remain the single application config +source of truth while changing **how it is deployed and loaded**. + +This document is intentionally limited to config-store behavior and does not +specify attestation signatures, discovery endpoints, or a full CLI design. + +## Scope + +### In scope + +- Production runtime loading of application config from a platform config store +- Development loading of application config from a local TOML file +- Bootstrap contract for locating the platform config store in production +- Deterministic validation and canonicalization of application config +- Config hashing based on canonical TOML bytes +- Repository file ownership changes related to `trusted-server.toml` +- Minimal tooling responsibilities required to deploy config to the store +- Explicit removal of the current build-time merge-and-embed model + +### Out of scope + +- Config signatures, DSSE, or signature verification +- Runtime attestation endpoints or statement formats +- Full CLI command design or operator UX +- Hot reload, file watching, or runtime config refresh +- Runtime mutation of application config +- Broad config schema redesign +- Migration rollout sequencing or temporary dual-source compatibility +- Platform-specific implementation details beyond the abstract store contract + +## Current state + +Trusted Server currently treats application config as a **build input**. + +The current flow is: + +1. Read root `trusted-server.toml` +2. Merge `TRUSTED_SERVER__*` environment variable overrides at build time +3. Serialize the merged config to `target/trusted-server-out.toml` +4. Embed that generated file into the WASM binary with `include_bytes!` +5. Parse the embedded TOML at runtime into `Settings` + +In the current codebase, this behavior is centered in: + +- `crates/trusted-server-core/build.rs` +- `crates/trusted-server-core/src/settings_data.rs` + +This means the deployed binary contains both: + +- application code +- publisher/operator configuration + +## Problems with the current state + +### Code and config are tightly coupled + +A config-only change produces a different binary artifact. That makes it harder +to reason about code provenance separately from config changes. + +### Config changes require a rebuild + +Operators cannot update configuration independently from the WASM build. +Changing runtime behavior requires regenerating and redeploying the binary. + +### Build-time environment merging weakens the source-of-truth model + +The current model combines a TOML file with build-time environment variables. +That makes the effective config less obvious and weakens the idea of a single, +authoritative application config document. + +### Repository ownership is wrong for operator config + +A tracked root `trusted-server.toml` suggests application config should live in +source control as a committed repository artifact. In practice, application +config is operator-owned and deployment-specific. + +### Runtime behavior depends on build tooling decisions + +Because config is preprocessed during the build, the runtime is not the +authoritative point where config loading and validity are determined. + +## Goals + +- Keep **one application config document**: `trusted-server.toml` +- Make **production** load application config from a platform config store +- Make **development** load application config from a local TOML file +- Remove build-time embedding of application settings into the WASM binary +- Remove build-time `TRUSTED_SERVER__*` application-setting merges +- Define a deterministic canonical TOML representation suitable for hashing +- Preserve the existing application config schema as much as possible +- Keep the architecture generic enough to work with a future cross-platform + config store abstraction + +## Non-goals + +- Designing config signing or signature verification +- Designing a runtime attestation document +- Supporting multiple application config sources simultaneously in production +- Supporting hidden fallback from one source to another after source selection +- Supporting runtime writeback of application config +- Preserving comments or operator formatting in stored canonical payloads +- Supporting unknown fields in application config +- Introducing broad schema cleanup unrelated to config-store loading + +## Target architecture + +### High-level model + +`trusted-server.toml` remains the single application configuration document. +What changes is how that document is sourced: + +- **Production:** platform config store +- **Development:** local file at repo root by default + +The authoritative production payload is stored in the platform config store under +key `ts-config`. + +The value stored under `ts-config` is canonical TOML representing the application +config document. + +### Production behavior + +In production, Trusted Server: + +1. Obtains a platform config store reference from deployment/bootstrap wiring +2. Reads the fixed key `ts-config` +3. Parses the TOML payload using the existing application config schema +4. Rejects unknown fields +5. Validates semantic config rules +6. Produces a valid immutable `Settings` snapshot for request handling +7. Derives canonical TOML bytes and a config hash from that valid config + +Production application behavior must treat the platform config store payload as +authoritative. + +### Development behavior + +In development, `trusted-server.toml` remains the default local authoring file +at the repository root. + +A flag may be used to choose a different TOML file path. + +The local TOML file is the development authoring source of truth. Runtime +consumption may happen either directly from that file or via a +platform-specific projection step, depending on platform constraints. + +The development pipeline is: + +1. Load `trusted-server.toml` from the repository root by default, or the + explicitly selected TOML file +2. Parse the TOML payload using the existing application config schema +3. Reject unknown fields according to the rules defined in this document +4. Validate semantic config rules +5. Derive canonical TOML bytes and a config hash from that valid config +6. Project the canonical TOML into the local development runtime in the + platform-appropriate way when needed +7. Produce a valid immutable `Settings` snapshot for request handling + +On platforms such as Fastly/Viceroy, the preferred local-development approach is +to populate the local simulated config store with the canonical payload under +`ts-config` before request handling, rather than relying on direct host-file +reads from within the WASM guest. + +When running in development, tooling or runtime logs should identify which local +TOML file path was loaded. + +Development loading does **not** automatically rewrite the source file into +canonical form. + +### Request-level semantics + +Each request must be handled against **one valid, internally consistent, +immutable `Settings` snapshot**. + +This document intentionally does **not** require a specific fetch or caching +strategy. An implementation may fetch fresh config for each request or reuse +previously loaded state, as long as: + +- each request sees one coherent snapshot +- invalid or partially loaded config is never used +- correctness does not depend on cross-request in-memory persistence + +Because platform lifecycle behavior varies, the architecture must not assume that +in-memory state survives across requests. + +## Bootstrap contract + +Production requires a minimal bootstrap mechanism to locate the platform config +store. That bootstrap mechanism is **not** part of the application config schema. + +Bootstrap is deployment plumbing only. + +### Bootstrap responsibilities + +Production bootstrap must provide: + +- the platform config store reference needed to open/read the store + +Production bootstrap must **not**: + +- inject application settings values +- override application settings values +- provide an alternate key name for the application config payload +- create a secondary source of truth for runtime application behavior + +### Fixed key name + +The application config payload key is fixed globally: + +- `ts-config` + +The runtime always reads `ts-config` once the production store reference has been +resolved. + +The key name is not configurable. + +## Config loading and canonicalization pipeline + +This section defines the semantic pipeline for both production and development. + +### Step 1: Select the source by environment + +- **Production** uses the platform config store +- **Development** uses a local TOML-authored workflow rooted at + `trusted-server.toml` in the repository root by default, with a flag to + choose a different file + +This document does not define a separate first-class runtime `config_source` +mode. It defines source behavior in terms of production vs. development. + +### Step 2: Load the payload + +The selected source must yield a UTF-8 TOML payload. + +- Production loads the payload from store key `ts-config` +- Development loads the payload from the selected local TOML file path and may + then project the canonicalized result into a local platform-simulated config + store before runtime consumption + +### Step 3: Parse strictly using the existing schema + +The payload is parsed as the existing application configuration schema used to +produce `Settings`. + +This spec is schema-preserving by default. It does not redesign the +`trusted-server.toml` structure beyond what is necessary to support the new +loading model. + +### Step 4: Reject unknown fields + +Unknown fields are rejected. + +The system must not silently preserve, ignore, or drop unsupported keys. + +This applies to strongly typed configuration sections in the application config +schema. The `integrations` section continues to follow the existing integration +configuration model, where integration IDs are discovered dynamically and each +integration's typed validation rules govern the contents of its own config. + +### Step 5: Validate semantic constraints + +After parsing, the config must satisfy existing semantic validation rules. + +Examples include: + +- required fields must be present +- invalid field combinations must fail +- invalid regexes, store identifiers, or route coverage rules must fail + +### Step 6: Define the canonical TOML representation + +Valid config has a deterministic canonical TOML representation. + +Canonicalization is part of the config pipeline semantics, not merely a tooling +implementation detail. + +Canonicalization is defined as a dedicated transformation over valid config. It +must not rely on whatever output happens to fall out of naive derived +serialization alone. Implementations may need explicit canonicalization logic to +ensure the required output properties. + +Canonicalization is defined as: + +- parse valid config through the typed config model +- serialize it in a deterministic TOML form +- include explicitly declared settings only +- do **not** expand the config into a full dump of all effective defaulted runtime values +- define stable ordering for map-like structures so identical semantic config + produces identical canonical bytes + +As a consequence: + +- comments are not preserved in canonical form +- original formatting is not preserved in canonical form +- canonical stored payloads are intended to be tight and deterministic +- additional implementation work may be required beyond current derived + `Serialize` behavior to satisfy these guarantees + +### Step 7: Compute the config hash + +The config hash is computed over the canonical TOML bytes. + +This provides a stable hashable representation of application config suitable +for observability and future attestation work. + +### Step 8: Produce the runtime snapshot + +The runtime uses the validated config to produce the `Settings` snapshot used by +request handling. + +Implementations may materialize canonical bytes eagerly or lazily, but the +canonical form is part of the defined semantics. + +## Failure behavior + +The selected config source must produce one valid `Settings` snapshot. + +If loading, parsing, or validation fails, the runtime must **fail closed**. + +That includes failures such as: + +- missing store/bootstrap reference in production +- inability to read the selected store or file +- missing `ts-config` key in production +- invalid UTF-8 payloads +- malformed TOML +- unknown fields +- missing required fields +- semantic validation failures + +### No fallback after source selection + +Once the source has been selected by environment, the runtime must not fall back +to another source. + +Examples of disallowed behavior: + +- production store mode falling back to a local file +- development file mode falling back to embedded config +- falling back to a previously cached last-known-good config +- loading partial config and continuing with defaults beyond normal schema behavior + +### Availability behavior + +A config failure means the service is not healthy for serving application +traffic. + +This spec does not define a special "healthy but unusable" mode for config +failure. + +## Repository and file ownership changes + +The repository layout should change to reflect the new ownership model. + +### Required changes + +- Remove tracked `trusted-server.toml` from source control +- Add `trusted-server.toml` to `.gitignore` +- Add or retain `trusted-server.example.toml` as a tracked template file + +### File roles + +#### `trusted-server.toml` + +- operator-owned local/deployment artifact +- default local authoring file for development +- not tracked in git + +#### `trusted-server.example.toml` + +- tracked template file +- kept in sync with currently supported configuration features +- intended to help operators create a real `trusted-server.toml` + +## Minimal tooling contract + +This document does not define a full CLI specification. + +It does define the minimum tooling responsibilities required by the target +architecture. + +Tooling responsible for publishing production config must be able to: + +1. Load a local TOML file +2. Parse it using the application config schema +3. Reject unknown fields +4. Validate semantic config rules +5. Produce canonical TOML +6. Compute a hash over canonical TOML bytes +7. Write the canonical TOML payload to the platform config store under `ts-config` + +For local platform simulators such as `fastly compute serve`, tooling may also +materialize that canonical payload into the simulator's local config-store input +before starting the runtime. + +Tooling may support additional commands later, such as: + +- pull +- diff +- inspect +- dry-run deployment + +Those capabilities are explicitly out of scope for this document. + +## Hashing + +Config hashing is part of this architecture because it depends on deterministic +canonicalization. + +### Hash source + +The config hash is computed over the canonical TOML bytes. + +Because the hash is derived from canonical bytes, canonicalization must produce +stable field and map ordering for semantically identical config. + +### Purpose + +The config hash exists to support: + +- observability +- config comparison +- deterministic deployment artifacts +- future attestation and provenance work + +### Out of scope + +This document does not define: + +- signature formats +- signed envelopes +- signature verification behavior +- runtime signature enforcement + +## Explicit removals from the current design + +This proposal explicitly removes the current application-config build pipeline. + +### Removed behaviors + +- build-time embedding of application config into the WASM binary +- build-time generation of `target/trusted-server-out.toml` as the runtime app-config source +- build-time merging of `TRUSTED_SERVER__*` application-setting overrides +- production dependence on a repository-tracked `trusted-server.toml` +- runtime mutation of application config + +### Resulting source-of-truth model + +After this change: + +- `trusted-server.toml` remains the canonical application config document +- in production, the authoritative deployed copy is the platform-store payload under `ts-config` +- in development, the authoritative copy is the selected local TOML file + +## Implementation notes + +These notes are informative, not additional scope. + +- The concrete platform config-store API may still be evolving while this is + implemented +- Existing generic config-store abstractions in the codebase may be reused as + they mature +- Runtime caching strategy is intentionally unspecified by this document +- The settings schema should remain substantially unchanged except where minor + adjustments are necessary to support strict parsing or canonicalization + +## Future work enabled by this design + +This design is intended to enable, but not itself specify: + +- config attestation based on canonical payload hashes +- runtime reporting of config hash metadata +- richer deployment tooling around validation, diffing, and inspection +- broader cross-platform config-store support behind a generic API + +## Recommended next step + +After agreeing on this architecture, a follow-up spec should define the concrete +operator tooling used to: + +- validate local config +- canonicalize it +- compute hashes +- publish canonical TOML to the platform config store +- support development ergonomics around local file selection +- project local authored config into platform-specific local runtime inputs when + direct runtime file access is not available diff --git a/scripts/fastly-dev.sh b/scripts/fastly-dev.sh new file mode 100755 index 00000000..2282d354 --- /dev/null +++ b/scripts/fastly-dev.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +config_path="${TRUSTED_SERVER_CONFIG_FILE:-$repo_root/trusted-server.toml}" +output_path="$repo_root/fastly.local.toml" +wasm_release_path="$repo_root/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm" +wasm_debug_path="$repo_root/target/wasm32-wasip1/debug/trusted-server-adapter-fastly.wasm" + +if [[ $# -gt 0 && "$1" != -* ]]; then + config_path="$1" + shift +fi + +if [[ ! -f "$config_path" ]]; then + echo "error: config file not found: $config_path" >&2 + echo "hint: cp trusted-server.example.toml trusted-server.toml" >&2 + exit 1 +fi + +config_path="$(cd "$(dirname "$config_path")" && pwd)/$(basename "$config_path")" + +python3 "$repo_root/scripts/render-fastly-local-config.py" \ + --app-config "$config_path" \ + --template "$repo_root/fastly.toml" \ + --output "$output_path" + +fastly_args=(compute serve --dir "$repo_root" --env=local) +fastly_args+=("$@") + +has_skip_build=false +has_file=false +for arg in "$@"; do + if [[ "$arg" == "--skip-build" ]]; then + has_skip_build=true + fi + if [[ "$arg" == --file=* || "$arg" == "--file" ]]; then + has_file=true + fi +done + +if [[ "$has_skip_build" == true && "$has_file" == false ]]; then + if [[ -f "$wasm_release_path" ]]; then + fastly_args+=(--file "$wasm_release_path") + elif [[ -f "$wasm_debug_path" ]]; then + fastly_args+=(--file "$wasm_debug_path") + else + echo "error: --skip-build was passed but no built Wasm binary was found" >&2 + echo "hint: run cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1" >&2 + exit 1 + fi +fi + +exec fastly "${fastly_args[@]}" diff --git a/scripts/render-fastly-local-config.py b/scripts/render-fastly-local-config.py new file mode 100755 index 00000000..92651ab5 --- /dev/null +++ b/scripts/render-fastly-local-config.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +import argparse +import json +import pathlib +import subprocess +import sys + +CONFIG_STORE_NAME = "ts_config_store" +CONFIG_KEY = "ts-config" + + +def host_target() -> str: + result = subprocess.run( + ["rustc", "-vV"], + check=True, + capture_output=True, + text=True, + ) + for line in result.stdout.splitlines(): + if line.startswith("host: "): + return line.removeprefix("host: ").strip() + raise RuntimeError("failed to determine rust host target") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Render a Fastly/Viceroy local config with runtime app config projected into a config store." + ) + parser.add_argument("--app-config", required=True, help="Path to trusted-server TOML") + parser.add_argument("--template", required=True, help="Path to fastly/viceroy template TOML") + parser.add_argument("--output", required=True, help="Path to write rendered TOML") + args = parser.parse_args() + + repo_root = pathlib.Path(__file__).resolve().parent.parent + app_config = pathlib.Path(args.app_config).resolve(strict=False) + template = pathlib.Path(args.template).resolve(strict=False) + output = pathlib.Path(args.output).resolve(strict=False) + + try: + result = subprocess.run( + [ + "cargo", + "run", + "--quiet", + "--target", + host_target(), + "--package", + "trusted-server-core", + "--bin", + "ts-config-canonicalize", + "--", + str(app_config), + ], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as error: + if error.stderr: + print(error.stderr.strip(), file=sys.stderr) + if error.stdout: + print(error.stdout.strip(), file=sys.stderr) + return error.returncode + + canonical_toml = result.stdout + if result.stderr: + print(result.stderr.strip(), file=sys.stderr) + + rendered = template.read_text(encoding="utf-8") + rendered += "\n" + rendered += f"[local_server.config_stores.{CONFIG_STORE_NAME}]\n" + rendered += ' format = "inline-toml"\n' + rendered += f"[local_server.config_stores.{CONFIG_STORE_NAME}.contents]\n" + rendered += f" {CONFIG_KEY} = {json.dumps(canonical_toml)}\n" + + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(rendered, encoding="utf-8") + print(output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/trusted-server.toml b/trusted-server.example.toml similarity index 97% rename from trusted-server.toml rename to trusted-server.example.toml index d9189aaa..d0338583 100644 --- a/trusted-server.toml +++ b/trusted-server.example.toml @@ -19,11 +19,9 @@ secret_key = "trusted-server" # Custom headers to be included in every response # Allows publishers to include tags such as X-Robots-Tag: noindex +# Configure these directly in the runtime TOML payload stored under `ts-config`. # [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