From 74146e9d95371103f5e901b13fddb2aa4a8adab8 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 28 Apr 2026 16:02:30 -0500 Subject: [PATCH 1/2] Add trusted-server CLI provisioning workflow --- .github/pull_request_template.md | 8 +- .github/workflows/format.yml | 29 +- .github/workflows/test.yml | 28 +- CLAUDE.md | 28 +- Cargo.lock | 1175 ++++++++++++++++- Cargo.toml | 7 + README.md | 28 +- crates/trusted-server-cli/Cargo.toml | 33 + crates/trusted-server-cli/src/audit.rs | 448 +++++++ crates/trusted-server-cli/src/config.rs | 133 ++ crates/trusted-server-cli/src/dev.rs | 123 ++ crates/trusted-server-cli/src/error.rs | 27 + crates/trusted-server-cli/src/fastly/api.rs | 438 ++++++ crates/trusted-server-cli/src/fastly/auth.rs | 197 +++ crates/trusted-server-cli/src/fastly/mod.rs | 3 + .../src/fastly/provision.rs | 1139 ++++++++++++++++ crates/trusted-server-cli/src/lib.rs | 477 +++++++ crates/trusted-server-cli/src/main.rs | 3 + crates/trusted-server-cli/src/output.rs | 29 + docs/guide/configuration.md | 14 +- docs/guide/fastly.md | 31 +- docs/guide/getting-started.md | 14 +- 22 files changed, 4325 insertions(+), 87 deletions(-) create mode 100644 crates/trusted-server-cli/Cargo.toml create mode 100644 crates/trusted-server-cli/src/audit.rs create mode 100644 crates/trusted-server-cli/src/config.rs create mode 100644 crates/trusted-server-cli/src/dev.rs create mode 100644 crates/trusted-server-cli/src/error.rs create mode 100644 crates/trusted-server-cli/src/fastly/api.rs create mode 100644 crates/trusted-server-cli/src/fastly/auth.rs create mode 100644 crates/trusted-server-cli/src/fastly/mod.rs create mode 100644 crates/trusted-server-cli/src/fastly/provision.rs create mode 100644 crates/trusted-server-cli/src/lib.rs create mode 100644 crates/trusted-server-cli/src/main.rs create mode 100644 crates/trusted-server-cli/src/output.rs 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..a836ca0a8 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,12 @@ dependencies = [ "syn 2.0.111", ] +[[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,6 +275,12 @@ 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" @@ -253,6 +309,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -335,6 +397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -343,8 +406,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 +430,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 +474,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 +531,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 +631,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 +643,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 +669,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.13.1", "smallvec", ] @@ -617,6 +745,27 @@ dependencies = [ "syn 2.0.111", ] +[[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 +807,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" @@ -738,7 +899,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2 0.10.9", "subtle", @@ -808,6 +969,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 +993,7 @@ dependencies = [ "ff", "generic-array", "group", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -842,6 +1009,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 +1054,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 +1149,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 +1202,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" @@ -1117,6 +1300,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 +1320,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 +1336,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 +1349,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 +1363,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 +1440,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" @@ -1255,6 +1471,84 @@ dependencies = [ "http", ] +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "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" @@ -1438,6 +1732,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 +1756,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 +1846,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 +1876,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 +1893,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 +1937,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 +2010,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 +2038,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -1711,6 +2096,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,35 +2222,78 @@ 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.13.1" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", ] [[package]] -name = "phf_generator" +name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "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]] +name = "phf_generator" +version = "0.13.1" +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 +2302,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 +2354,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 +2451,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 +2528,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 +2549,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 +2571,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 +2618,60 @@ 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 = "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 +2699,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -2146,15 +2733,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 +2806,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 +2834,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 +2896,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", @@ -2342,6 +3034,12 @@ 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" @@ -2355,7 +3053,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]] @@ -2382,6 +3080,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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" version = "0.9.8" @@ -2404,6 +3112,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 +3183,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 +3212,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 +3336,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 +3358,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -2592,6 +3377,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 +3448,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 +3541,29 @@ dependencies = [ "trusted-server-core", ] +[[package]] +name = "trusted-server-cli" +version = "0.1.0" +dependencies = [ + "base64", + "clap", + "derive_more", + "dialoguer", + "error-stack", + "keyring", + "log", + "regex", + "reqwest", + "scraper", + "serde", + "serde_json", + "tempfile", + "toml 1.0.7+spec-1.1.0", + "trusted-server-core", + "url", + "uuid", +] + [[package]] name = "trusted-server-core" version = "0.1.0" @@ -2735,7 +3592,7 @@ dependencies = [ "log", "lol_html", "matchit", - "rand", + "rand 0.8.5", "regex", "serde", "serde_json", @@ -2771,6 +3628,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typeid" version = "1.0.3" @@ -2801,6 +3664,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 +3686,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 +3710,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 +3785,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 +3822,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 +3867,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 +3887,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 +3925,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]] @@ -3047,6 +3987,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 +4014,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 +4261,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..0c33742e2 100644 --- a/README.md +++ b/README.md @@ -22,27 +22,33 @@ 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 +# Create a starter config +cargo run --package trusted-server-cli --bin ts --target "$(rustc -vV | sed -n 's/^host: //p')" -- config init -# Run tests -cargo test +# Validate local config +cargo run --package trusted-server-cli --bin ts --target "$(rustc -vV | sed -n 's/^host: //p')" -- config validate -# Start local server -fastly compute serve +# Start local Fastly development +cargo run --package trusted-server-cli --bin ts --target "$(rustc -vV | sed -n 's/^host: //p')" -- dev -a fastly ``` ## Development ```bash # Format code -cargo fmt +cargo fmt --all -- --check -# Lint -cargo clippy --workspace --all-targets --all-features -- -D warnings +# Lint runtime crates (wasm target) +cargo clippy --workspace --exclude trusted-server-cli --all-targets --all-features -- -D warnings -# Run tests -cargo test +# Lint CLI crate (host target) +cargo clippy --package trusted-server-cli --target "$(rustc -vV | sed -n 's/^host: //p')" --all-targets -- -D warnings + +# Run runtime crate tests (wasm target) +cargo test --workspace --exclude trusted-server-cli + +# Run CLI tests (host target) +cargo test --package trusted-server-cli --target "$(rustc -vV | sed -n 's/^host: //p')" ``` See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml new file mode 100644 index 000000000..f6cdd1a38 --- /dev/null +++ b/crates/trusted-server-cli/Cargo.toml @@ -0,0 +1,33 @@ +[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 } +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..b85e5d446 --- /dev/null +++ b/crates/trusted-server-cli/src/audit.rs @@ -0,0 +1,448 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; + +use error_stack::{Report, ResultExt}; +use regex::Regex; +use reqwest::blocking::Client; +use scraper::{Html, Selector}; +use serde::Serialize; +use url::Url; + +use crate::config::{STARTER_CONFIG_TEMPLATE, ensure_writable_path}; +use crate::error::CliError; + +#[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, +} + +pub fn perform_audit(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))?; + + if !response.status().is_success() { + return Err(Report::new(CliError::Audit) + .attach(format!("audit request returned HTTP {}", response.status()))); + } + + let body = response.text().change_context(CliError::Audit)?; + let artifact = analyze_html(target_url, &body)?; + let js_assets_toml = toml::to_string_pretty(&artifact).change_context(CliError::Audit)?; + let draft_config_toml = build_draft_config(target_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) +} + +pub fn analyze_html(target_url: &Url, html: &str) -> Result> { + let document = Html::parse_document(html); + let title_selector = Selector::parse("title").expect("should parse title selector"); + let script_selector = Selector::parse("script").expect("should parse script selector"); + let title = document + .select(&title_selector) + .next() + .map(|element| { + element + .text() + .collect::>() + .join(" ") + .trim() + .to_string() + }) + .filter(|title| !title.is_empty()); + + let mut assets = Vec::new(); + let mut integrations = BTreeMap::::new(); + let mut warnings = Vec::new(); + + for element in document.select(&script_selector) { + if let Some(src) = element.value().attr("src") { + if let Ok(asset_url) = target_url.join(src) { + let host = asset_url.host_str().unwrap_or_default().to_string(); + let integration = detect_integration_from_url(&asset_url); + if let Some(integration_id) = &integration { + integrations + .entry(integration_id.clone()) + .or_insert_with(|| asset_url.as_str().to_string()); + } + assets.push(AuditedAsset { + kind: "script".to_string(), + url: asset_url.to_string(), + host: host.clone(), + party: classify_party(target_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); + } + } + } + + let detected_integrations = integrations + .into_iter() + .map(|(id, evidence)| DetectedIntegration { id, evidence }) + .collect::>(); + + let third_party_asset_count = assets + .iter() + .filter(|asset| asset.party == AssetParty::ThirdParty) + .count(); + + Ok(AuditArtifact { + audited_url: target_url.to_string(), + page_title: title, + js_asset_count: assets.len(), + third_party_asset_count, + detected_integrations, + assets, + warnings, + }) +} + +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 + } +} + +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 + } +} + +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 +} + +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 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 +} + +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" + ); + } +} 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/guide/configuration.md b/docs/guide/configuration.md index dc7e46660..036b7751c 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 | diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index 2a1edd79b..9b8c0c181 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. ## Domain Configuration @@ -72,27 +73,19 @@ 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): - -```bash -fastly secret-store create --name signing_keys -``` +`apply` automatically activates the Fastly service version after changing resource bindings. -Note the store IDs - you'll need them for your `trusted-server.toml` configuration. +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. ## Next Steps diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index ce4b328d4..6a27d7a9e 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -41,20 +41,28 @@ 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`. ## 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 From abd0c0dd1835a68e1d2dfdd4c0198738cdbfa49a Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 29 Apr 2026 13:19:19 -0500 Subject: [PATCH 2/2] Add Chromium-backed ts audit collection --- Cargo.lock | 188 +++++++++- README.md | 5 + crates/trusted-server-cli/Cargo.toml | 4 + crates/trusted-server-cli/src/audit.rs | 234 +++--------- .../trusted-server-cli/src/audit/analyzer.rs | 347 ++++++++++++++++++ .../src/audit/browser_collector.rs | 323 ++++++++++++++++ .../trusted-server-cli/src/audit/collector.rs | 37 ++ .../src/audit/http_collector.rs | 79 ++++ docs/guide/getting-started.md | 8 + 9 files changed, 1046 insertions(+), 179 deletions(-) create mode 100644 crates/trusted-server-cli/src/audit/analyzer.rs create mode 100644 crates/trusted-server-cli/src/audit/browser_collector.rs create mode 100644 crates/trusted-server-cli/src/audit/collector.rs create mode 100644 crates/trusted-server-cli/src/audit/http_collector.rs diff --git a/Cargo.lock b/Cargo.lock index a836ca0a8..52f77c27a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,6 +170,23 @@ 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" @@ -286,6 +303,9 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] [[package]] name = "cast" @@ -339,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" @@ -745,6 +830,12 @@ 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" @@ -881,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" @@ -1283,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" @@ -2658,6 +2761,35 @@ dependencies = [ "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" @@ -3010,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" @@ -3046,6 +3189,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -3361,6 +3514,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -3546,22 +3700,26 @@ name = "trusted-server-cli" version = "0.1.0" dependencies = [ "base64", + "chromiumoxide", "clap", "derive_more", "dialoguer", "error-stack", + "futures", "keyring", "log", "regex", - "reqwest", + "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]] @@ -3634,6 +3792,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.17", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -3969,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" diff --git a/README.md b/README.md index 0c33742e2..35ddf688b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ cargo run --package trusted-server-cli --bin ts --target "$(rustc -vV | sed -n ' # Start local Fastly development cargo run --package trusted-server-cli --bin ts --target "$(rustc -vV | sed -n 's/^host: //p')" -- dev -a fastly + +# Audit a public page with a real Chromium browser +cargo run --package trusted-server-cli --bin ts --target "$(rustc -vV | sed -n 's/^host: //p')" -- audit https://example.com ``` ## Development @@ -51,6 +54,8 @@ cargo test --workspace --exclude trusted-server-cli cargo test --package trusted-server-cli --target "$(rustc -vV | sed -n 's/^host: //p')" ``` +`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 index f6cdd1a38..25f77fe8b 100644 --- a/crates/trusted-server-cli/Cargo.toml +++ b/crates/trusted-server-cli/Cargo.toml @@ -21,6 +21,10 @@ 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 } diff --git a/crates/trusted-server-cli/src/audit.rs b/crates/trusted-server-cli/src/audit.rs index b85e5d446..e3db48ea9 100644 --- a/crates/trusted-server-cli/src/audit.rs +++ b/crates/trusted-server-cli/src/audit.rs @@ -1,17 +1,21 @@ -use std::collections::{BTreeMap, BTreeSet}; +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 regex::Regex; -use reqwest::blocking::Client; -use scraper::{Html, Selector}; 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 { @@ -52,28 +56,25 @@ pub struct AuditOutputs { 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 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))?; - - if !response.status().is_success() { - return Err(Report::new(CliError::Audit) - .attach(format!("audit request returned HTTP {}", response.status()))); - } + let collected = browser_collector::collect_page_via_browser(target_url)?; + build_audit_outputs(collected) +} - let body = response.text().change_context(CliError::Audit)?; - let artifact = analyze_html(target_url, &body)?; +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(target_url, &artifact)?; + let draft_config_toml = build_draft_config(&final_url, &artifact)?; Ok(AuditOutputs { artifact, @@ -105,141 +106,6 @@ pub fn write_audit_outputs( Ok(written_paths) } -pub fn analyze_html(target_url: &Url, html: &str) -> Result> { - let document = Html::parse_document(html); - let title_selector = Selector::parse("title").expect("should parse title selector"); - let script_selector = Selector::parse("script").expect("should parse script selector"); - let title = document - .select(&title_selector) - .next() - .map(|element| { - element - .text() - .collect::>() - .join(" ") - .trim() - .to_string() - }) - .filter(|title| !title.is_empty()); - - let mut assets = Vec::new(); - let mut integrations = BTreeMap::::new(); - let mut warnings = Vec::new(); - - for element in document.select(&script_selector) { - if let Some(src) = element.value().attr("src") { - if let Ok(asset_url) = target_url.join(src) { - let host = asset_url.host_str().unwrap_or_default().to_string(); - let integration = detect_integration_from_url(&asset_url); - if let Some(integration_id) = &integration { - integrations - .entry(integration_id.clone()) - .or_insert_with(|| asset_url.as_str().to_string()); - } - assets.push(AuditedAsset { - kind: "script".to_string(), - url: asset_url.to_string(), - host: host.clone(), - party: classify_party(target_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); - } - } - } - - let detected_integrations = integrations - .into_iter() - .map(|(id, evidence)| DetectedIntegration { id, evidence }) - .collect::>(); - - let third_party_asset_count = assets - .iter() - .filter(|asset| asset.party == AssetParty::ThirdParty) - .count(); - - Ok(AuditArtifact { - audited_url: target_url.to_string(), - page_title: title, - js_asset_count: assets.len(), - third_party_asset_count, - detected_integrations, - assets, - warnings, - }) -} - -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 - } -} - -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 - } -} - -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 -} - fn build_draft_config( target_url: &Url, artifact: &AuditArtifact, @@ -327,26 +193,6 @@ fn build_draft_config( Ok(draft) } -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 -} - fn replace_once( haystack: &str, needle: &str, @@ -445,4 +291,36 @@ mod tests { "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/docs/guide/getting-started.md b/docs/guide/getting-started.md index 6a27d7a9e..ab8027207 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -60,6 +60,14 @@ 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. + ## Configuration Use `ts config init` to generate `trusted-server.toml`, then edit it to configure: