diff --git a/.cargo/config.toml b/.cargo/config.toml index e0e3f5bef..2fce238e2 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 fbe958473..dfff83b53 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 b6aba137d..a9038c56d 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 5eea36a74..ebd625daf 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/CLAUDE.md b/CLAUDE.md index ec76ee46e..fa52b1b98 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 3688be307..52f77c27a 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 0b9f42309..d5fd029d1 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 82dfe7b56..125e8a629 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/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml new file mode 100644 index 000000000..25f77fe8b --- /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 000000000..e3db48ea9 --- /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 000000000..9e2c7c7a0 --- /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 000000000..cf55b9b1a --- /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 000000000..be9a53150 --- /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 000000000..4dcce8a67 --- /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 000000000..9846007d3 --- /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 000000000..77d97d4d1 --- /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 000000000..3168b9dcb --- /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 000000000..d7f1fa3d4 --- /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 000000000..8444a17ab --- /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 000000000..eea4d7b2b --- /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 000000000..b05636d88 --- /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 000000000..90b7d3e55 --- /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 000000000..c855cdcdd --- /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 000000000..0399a0f41 --- /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/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index d66a820c0..9051f8f5d 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 000000000..c3b7bca86 --- /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 dc7e46660..eba05f7a2 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -14,7 +14,13 @@ Trusted Server uses a runtime configuration system based on: ### 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] @@ -42,6 +48,12 @@ secret_key = "your-hmac-secret" openssl rand -base64 32 ``` +### Validate Configuration + +```bash +ts config validate +``` + ## Configuration Files | File | Purpose | @@ -1052,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/fastly-provisioning.md b/docs/guide/fastly-provisioning.md new file mode 100644 index 000000000..684da05cc --- /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 2a1edd79b..25c12f1b1 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 ce4b328d4..c277825b9 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)