diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78ab368ed..efa31fe39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,17 +96,6 @@ jobs: - name: Docs run: cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api - # Each facade as consumers read it and as the docs site publishes it: the - # topic docs build alone, with default features. - - name: Facade docs - run: cargo doc -p runvil-engine --no-deps - - - name: Harness facade docs - run: cargo doc -p runvil --no-deps - - - name: Engine docs (private items) - run: cargo doc --locked --no-deps --all-features -p runvil-runtime --document-private-items - check-workshop: runs-on: windows-latest steps: @@ -278,46 +267,6 @@ jobs: exit 1 fi - # `cargo xtask api` reads rustdoc JSON, which only a nightly emits, so this - # job runs on the nightly pinned in crates/build-xtask/src/api/toolchain.rs. - # The date is read from that constant, never repeated here, so the two - # cannot drift; exporting it as RUSTUP_TOOLCHAIN puts every cargo below on it. - api-surface: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Read the pinned nightly - id: pin - shell: bash - run: | - set -euo pipefail - nightly="$(sed -n 's/^ *nightly: "\(nightly-[0-9-]*\)",.*$/\1/p' crates/build-xtask/src/api/toolchain.rs)" - case "$nightly" in - nightly-[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;; - *) - echo "expected one pinned nightly in crates/build-xtask/src/api/toolchain.rs, found: '$nightly'" - exit 1 - ;; - esac - echo "nightly=$nightly" >> "$GITHUB_OUTPUT" - echo "RUSTUP_TOOLCHAIN=$nightly" >> "$GITHUB_ENV" - - - name: Install the pinned nightly - run: rustup toolchain install "${{ steps.pin.outputs.nightly }}" --profile minimal - - - name: Install cargo-nextest - uses: taiki-e/install-action@4dc1969decfa71b34f25aa7f3dd4656654d9ad1e # nextest - - - name: Cache cargo - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - - - name: Surface closure and listing - run: cargo xtask api --check - - - name: Nightly-only fixtures - run: cargo nextest run --locked -p build-xtask --run-ignored only - # The custom lints, the dylint library in cppalliance/dylints at the # rev the root Cargo.toml's [workspace.metadata.dylint] pins. `cargo # dylint` fetches it and builds it on the nightly in its @@ -377,43 +326,13 @@ jobs: exit 1 fi - # The Engine crates inherit `rust-version` from the root Cargo.toml's - # [workspace.package]; this job builds them at that toolchain. The version - # is read from the manifest, never repeated here, so the two cannot drift. - msrv: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Read the declared floor - id: floor - shell: bash - run: | - set -euo pipefail - floor="$(sed -n 's/^rust-version = "\([0-9.]*\)"$/\1/p' Cargo.toml)" - if [[ ! "$floor" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then - echo "expected one rust-version in the root Cargo.toml, found: '$floor'" - exit 1 - fi - echo "floor=$floor" >> "$GITHUB_OUTPUT" - echo "RUSTUP_TOOLCHAIN=$floor" >> "$GITHUB_ENV" - - - name: Install the floor toolchain - run: rustup toolchain install "${{ steps.floor.outputs.floor }}" --profile minimal - - - name: Cache cargo - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - - - name: Build the Engine crates at the floor - run: cargo build --locked -p runvil-engine -p runvil-runtime -p runvil-lua -p runvil-prompt -p runvil-types -p runvil-vfs -p runvil-model-client - # Single required status check for branch protection: it fails when any # job fails or is cancelled, so a cancelled job cannot read as green. # needs: is a static list, so every newly added job must be added to it # or that job bypasses this gate. ci-green: runs-on: ubuntu-latest - needs: [fmt, clippy, test, docs, check-workshop, check-workshop-linux, ui, supply-chain, api-surface, dylint, msrv] + needs: [fmt, clippy, test, docs, check-workshop, check-workshop-linux, ui, supply-chain, dylint] if: always() steps: - name: Verify every job succeeded diff --git a/.gitignore b/.gitignore index a52481312..b5ffadc33 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ /.model-cache/ /target -/target-msrv/ /vibe-review.md /conformance-audit.md /local/ diff --git a/AGENTS.md b/AGENTS.md index 745f84688..c0c2ecc2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Runvil -Multi-crate Rust workspace for the Engine, the Harness that runs it, and Workshop, a Host. The Gateway is a separate product in its own repository, `cppalliance/runvil-gateway`. +Multi-crate Rust workspace for Workshop, a Host, and the tooling that builds its installers. The Engine and the Harness are in their own repository, `cppalliance/runvil-harness`, which the root `Cargo.toml` pins by commit. The Gateway is a separate product in its own repository, `cppalliance/runvil-gateway`. ## Definitions @@ -18,7 +18,6 @@ Seven terms have exactly one meaning each, everywhere in this repository: code c - "Host", in any capitalization, means only the Host. Other senses use another word: "machine", "real files", "Engine globals", "Engine call", the part's own name ("the desktop app"), or a verb such as run, serve, embed, or hold. - "Engine", "Harness", and "Plugin" mean only the defined terms. Anything else gets a qualified lowercase name, such as the Gateway's speech engine or a Tauri plugin. This repository's own checks are "structural checks", "test support", or "fixtures". -- Engine crates call the code that steps a run "the caller" and never mention the Host. They name the Harness only to say that a responsibility belongs to it, never how the Harness performs it. - Names defined elsewhere keep their own meaning (the HTTP `Host` header, Cargo's `harness = false`), crate names are written as they are, and quotations of people stay verbatim. - `crates/workshop/ui/test/docs-claims.mjs` enforces these rules in every `AGENTS.md`, every `## Invariants` crate doc, and every `.cursor/rules` file. @@ -36,7 +35,6 @@ Seven terms have exactly one meaning each, everywhere in this repository: code c - A Cargo feature gates a real constraint such as a toolchain requirement or heavy native build, not product shape. Feature-disabled builds must not leak optional types into core paths. - Runtime and serve paths never compile native dependencies or invoke build tools. - Unsafe code stays in its explicitly owned boundary. -- Engine and Harness crates report every failure as the universal `Error` in `runvil-types`, which `runvil-engine` and `runvil` re-export. A new failure is a new `ErrorCode` variant in its hundreds family with a never-used number, plus an `ErrorFact` variant only when no key names the value. No other error enum or struct, `thiserror` derive, or `impl std::error::Error`; a foreign cause joins through `Error::with_cause`. Allowed: `VfsError` in `runvil-vfs`, throwaway test error types, crate-private `Err` markers that the caller turns into `Error`, and pairs such as `InjectFailure`. - Comments explain a non-obvious constraint, ordering requirement, or workaround. A platform or external-bug workaround cites its upstream issue URL. - JSON that reaches a recorder or a replay comparison round-trips exactly: object keys sorted, numbers finite, parsed with `float_roundtrip`. A value derived and then logged is rounded to its meaningful precision at the source. @@ -45,8 +43,7 @@ Seven terms have exactly one meaning each, everywhere in this repository: code c - Tests: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`, and `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`. - Lint: `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features`, and `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets`, both with `CARGO_BUILD_WARNINGS=deny`. Clippy covers `cargo check`, so never run a standalone `cargo check --workspace`. - Format: `cargo fmt --all --check`. -- Docs: `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` and `cargo doc -p runvil-engine --no-deps` (default features), both with `RUSTDOCFLAGS="-D warnings"`. Clippy does not run rustdoc lints, so never skip these. -- Facade surface: `cargo + xtask api --check`, with the nightly named in `crates/build-xtask/src/api/toolchain.rs` and `CARGO_BUILD_WARNINGS` unset (`Remove-Item Env:CARGO_BUILD_WARNINGS` in PowerShell). It checks both facades, and fails when a surface names an item its facade does not re-export or differs from its listing, `crates/runvil-engine/public-api.txt` or `crates/runvil/public-api.txt`. +- Docs: `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`, with `RUSTDOCFLAGS="-D warnings"`. Clippy does not run rustdoc lints, so never skip this. - Custom lints: `cargo dylint --all -- --locked --workspace --all-targets --all-features`, with `cargo-dylint` and `dylint-link` installed. The lints and their UI tests live in `cppalliance/dylints`, at the `rev` the root `Cargo.toml`'s `[workspace.metadata.dylint]` pins; a new lint lands there first. - Structural checks: `cargo test -p build-xtask`. - Wording: `node --test crates/workshop/ui/test/docs-claims.mjs`, from any directory, no `npm ci` needed. @@ -57,8 +54,8 @@ Seven terms have exactly one meaning each, everywhere in this repository: code c - Pseudo-random test input comes only from a fixed seed that the failure prints. Temporary directory names come from the test's name, the process id, and a counter. - A file holds one concept and has no length limit. Split a file only when it holds a second unrelated concept. Size limits sit on functions and types, and clippy and `cargo test -p build-xtask` enforce them. Split an oversized type by responsibility; a new entry in the allowlist in `crates/build-xtask/src/type_size.rs` is a plan decision with a reason, never a way to make a change pass. - Production code uses the standard module layout: `foo.rs`, with `foo/bar.rs` for its children. No `#[path]`, and no inline `mod` blocks. A module never has exactly one child file (tests aside): merge it, or make it a peer module `foo_bar.rs` declared beside `foo.rs`. -- Test code goes at the bottom of the file under test in a `#[cfg(test)] mod tests { }` block when practical (if in doubt, beside), and otherwise in `foo-tests.rs` beside it, wired with `#[path = "foo-tests.rs"] mod tests;`. A big test file may split by topic into siblings named after it, `-.rs` (so `foo-tests-topic.rs`). These are the only files wired with `#[path]`, apart from the cfg-selected platform files in `crates/runvil-internal/vfs/src/real.rs`, so a hyphen in a file name means test code. Top-level `tests/` and `benches/` trees follow Cargo's conventions. -- Crates outside the Engine and Harness keep their older layout until their own pass. +- Test code goes at the bottom of the file under test in a `#[cfg(test)] mod tests { }` block when practical (if in doubt, beside), and otherwise in `foo-tests.rs` beside it, wired with `#[path = "foo-tests.rs"] mod tests;`. A big test file may split by topic into siblings named after it, `-.rs` (so `foo-tests-topic.rs`). These are the only files wired with `#[path]`, so a hyphen in a file name means test code. Top-level `tests/` and `benches/` trees follow Cargo's conventions. +- The crates in this repository keep their older layout until their own pass. ## SPA and CSS Rules diff --git a/Cargo.lock b/Cargo.lock index 0ed2f4432..7dddc970f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,12 +92,6 @@ dependencies = [ "libc", ] -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - [[package]] name = "anstyle" version = "1.0.14" @@ -706,12 +700,9 @@ dependencies = [ "anyhow", "proc-macro2", "pulldown-cmark", - "rustdoc-types", - "serde_json", "syn 3.0.3", "tempfile", "toml 1.1.4+spec-1.1.0", - "workshop-gateway-discovery", ] [[package]] @@ -831,12 +822,6 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - [[package]] name = "cc" version = "1.4.6" @@ -928,33 +913,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - [[package]] name = "cipher" version = "0.4.4" @@ -1183,42 +1141,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "criterion" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" -dependencies = [ - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "is-terminal", - "itertools 0.10.5", - "num-traits", - "once_cell", - "oorandom", - "plotters", - "rayon", - "regex", - "serde", - "serde_derive", - "serde_json", - "tinytemplate", - "walkdir", -] - -[[package]] -name = "criterion-plot" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" -dependencies = [ - "cast", - "itertools 0.10.5", -] - [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -1228,16 +1150,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - [[package]] name = "crossbeam-epoch" version = "0.9.20" @@ -1253,12 +1165,6 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-common" version = "0.1.7" @@ -2351,28 +2257,16 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - [[package]] name = "harness-gateway-client" version = "0.3.0" +source = "git+https://github.com/cppalliance/runvil-harness?rev=732429962c5873458213bb0072a0828a40ee4fb6#732429962c5873458213bb0072a0828a40ee4fb6" dependencies = [ "async-trait", - "axum", "bytes", "plugin-web", "reqwest", "runvil", - "runvil-engine", "serde", "serde_json", "tokio", @@ -2382,6 +2276,7 @@ dependencies = [ [[package]] name = "harness-plugin-loader" version = "0.3.0" +source = "git+https://github.com/cppalliance/runvil-harness?rev=732429962c5873458213bb0072a0828a40ee4fb6#732429962c5873458213bb0072a0828a40ee4fb6" dependencies = [ "libloading 0.8.9", "runvil-plugin", @@ -2392,29 +2287,16 @@ dependencies = [ [[package]] name = "harness-runner" version = "0.3.0" +source = "git+https://github.com/cppalliance/runvil-harness?rev=732429962c5873458213bb0072a0828a40ee4fb6#732429962c5873458213bb0072a0828a40ee4fb6" dependencies = [ "futures-util", "rand 0.9.5", "runvil-engine", "runvil-plugin", "serde_json", - "thiserror 2.0.19", - "tokio", "tracing", ] -[[package]] -name = "harness-test-plugin" -version = "0.3.0" -dependencies = [ - "harness-plugin-loader", - "libc", - "libloading 0.8.9", - "runvil", - "runvil-plugin", - "tokio", -] - [[package]] name = "hashbrown" version = "0.12.3" @@ -2928,17 +2810,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "is-wsl" version = "0.4.0" @@ -3976,12 +3847,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - [[package]] name = "opaque-debug" version = "0.3.1" @@ -4233,34 +4098,6 @@ dependencies = [ "time", ] -[[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" -dependencies = [ - "plotters-backend", -] - [[package]] name = "plugin-bashkit" version = "0.1.0" @@ -4276,8 +4113,8 @@ dependencies = [ [[package]] name = "plugin-mcp" version = "0.3.0" +source = "git+https://github.com/cppalliance/runvil-harness?rev=732429962c5873458213bb0072a0828a40ee4fb6#732429962c5873458213bb0072a0828a40ee4fb6" dependencies = [ - "axum", "reqwest", "rmcp", "runvil-plugin", @@ -4300,12 +4137,10 @@ dependencies = [ [[package]] name = "plugin-web" version = "0.3.0" +source = "git+https://github.com/cppalliance/runvil-harness?rev=732429962c5873458213bb0072a0828a40ee4fb6#732429962c5873458213bb0072a0828a40ee4fb6" dependencies = [ "async-trait", - "axum", "encoding_rs", - "flate2", - "futures-util", "htmd", "ipnet", "mime", @@ -4737,26 +4572,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "readabilityrs" version = "0.1.3" @@ -4989,18 +4804,18 @@ dependencies = [ [[package]] name = "runvil" version = "0.3.0" +source = "git+https://github.com/cppalliance/runvil-harness?rev=732429962c5873458213bb0072a0828a40ee4fb6#732429962c5873458213bb0072a0828a40ee4fb6" dependencies = [ "harness-plugin-loader", "harness-runner", "runvil-engine", "runvil-plugin", - "serde_json", - "tokio", ] [[package]] name = "runvil-engine" version = "0.3.0" +source = "git+https://github.com/cppalliance/runvil-harness?rev=732429962c5873458213bb0072a0828a40ee4fb6#732429962c5873458213bb0072a0828a40ee4fb6" dependencies = [ "runvil-lua", "runvil-model-client", @@ -5008,14 +4823,13 @@ dependencies = [ "runvil-runtime", "runvil-types", "runvil-vfs", - "serde_json", ] [[package]] name = "runvil-lua" version = "0.3.0" +source = "git+https://github.com/cppalliance/runvil-harness?rev=732429962c5873458213bb0072a0828a40ee4fb6#732429962c5873458213bb0072a0828a40ee4fb6" dependencies = [ - "criterion", "mlua", "runvil-markdown", "runvil-model-client", @@ -5028,6 +4842,7 @@ dependencies = [ [[package]] name = "runvil-markdown" version = "0.3.0" +source = "git+https://github.com/cppalliance/runvil-harness?rev=732429962c5873458213bb0072a0828a40ee4fb6#732429962c5873458213bb0072a0828a40ee4fb6" dependencies = [ "pulldown-cmark", "serde_json", @@ -5037,6 +4852,7 @@ dependencies = [ [[package]] name = "runvil-model-client" version = "0.3.0" +source = "git+https://github.com/cppalliance/runvil-harness?rev=732429962c5873458213bb0072a0828a40ee4fb6#732429962c5873458213bb0072a0828a40ee4fb6" dependencies = [ "runvil-types", "serde", @@ -5046,6 +4862,7 @@ dependencies = [ [[package]] name = "runvil-plugin" version = "0.3.0" +source = "git+https://github.com/cppalliance/runvil-harness?rev=732429962c5873458213bb0072a0828a40ee4fb6#732429962c5873458213bb0072a0828a40ee4fb6" dependencies = [ "runvil-types", "runvil-vfs", @@ -5068,8 +4885,8 @@ source = "git+https://github.com/cppalliance/runvil-plugin-api?rev=03a9fa37d2fe6 [[package]] name = "runvil-prompt" version = "0.3.0" +source = "git+https://github.com/cppalliance/runvil-harness?rev=732429962c5873458213bb0072a0828a40ee4fb6#732429962c5873458213bb0072a0828a40ee4fb6" dependencies = [ - "mlua", "pulldown-cmark", "runvil-lua", "runvil-types", @@ -5080,8 +4897,8 @@ dependencies = [ [[package]] name = "runvil-runtime" version = "0.3.0" +source = "git+https://github.com/cppalliance/runvil-harness?rev=732429962c5873458213bb0072a0828a40ee4fb6#732429962c5873458213bb0072a0828a40ee4fb6" dependencies = [ - "criterion", "mlua", "regex", "runvil-lua", @@ -5098,16 +4915,17 @@ dependencies = [ [[package]] name = "runvil-types" version = "0.3.0" +source = "git+https://github.com/cppalliance/runvil-harness?rev=732429962c5873458213bb0072a0828a40ee4fb6#732429962c5873458213bb0072a0828a40ee4fb6" dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "time", ] [[package]] name = "runvil-vfs" version = "0.3.0" +source = "git+https://github.com/cppalliance/runvil-harness?rev=732429962c5873458213bb0072a0828a40ee4fb6#732429962c5873458213bb0072a0828a40ee4fb6" [[package]] name = "rust-embed" @@ -5176,16 +4994,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustdoc-types" -version = "0.61.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa251f75d3b9d5d5d8317ce526dfa4a040c15938d82fd3a5443d103f30a66932" -dependencies = [ - "serde", - "serde_derive", -] - [[package]] name = "rustix" version = "0.38.44" @@ -6680,16 +6488,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "tinyvec" version = "1.12.0" @@ -6715,7 +6513,6 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index cb5e6d316..e627ca7bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,38 +1,40 @@ [workspace] resolver = "3" -members = ["crates/*", "crates/runvil-internal/types", "crates/runvil-internal/runtime", "crates/runvil-internal/lua", "crates/runvil-internal/markdown", "crates/runvil-internal/prompt", "crates/runvil-internal/vfs", "crates/runvil-internal/model-client", "crates/workshop/desktop", "crates/workshop/server", "crates/workshop/server-api", "crates/workshop/gateway", "crates/workshop/gateway-discovery", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/workshop/run-log", "crates/workshop/agents", "crates/workshop/plugin-std", "crates/workshop/plugin-bashkit", "crates/harness-internal/runner", "crates/harness-internal/plugin-loader", "crates/harness-internal/test-plugin"] -# crates/runvil-internal, crates/workshop, and crates/harness-internal -# are manifestless containers holding their families' private crates. +members = ["crates/*", "crates/workshop/desktop", "crates/workshop/server", "crates/workshop/server-api", "crates/workshop/gateway", "crates/workshop/gateway-discovery", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/workshop/run-log", "crates/workshop/agents", "crates/workshop/plugin-std", "crates/workshop/plugin-bashkit"] +# crates/workshop is a manifestless container holding Workshop's crates. # Cargo prunes excluded subtrees from member globs, so a "crates/workshop/*" # glob beside the exclude below would match nothing; the container crates # are enumerated explicitly instead. -exclude = ["crates/runvil-internal", "crates/workshop", "crates/harness-internal"] -# Plain `cargo build`/`cargo test` build the Engine and its facades: they -# compile on a fresh macOS or Linux clone with no native toolkit, no -# CUDA, and no Tauri system packages. The desktop app is an explicit -# choice: `cargo build -p workshop`. -default-members = ["crates/runvil", "crates/runvil-engine", "crates/runvil-plugin", "crates/harness-gateway-client"] +exclude = ["crates/workshop"] +# Plain `cargo build`/`cargo test` build every crate but the desktop app, +# the server it embeds, and the server's facade: the rest compile on a +# fresh macOS or Linux clone with no Node install and no Tauri system +# packages. The desktop app is an explicit choice: `cargo build -p workshop`. +default-members = ["crates/build-ui", "crates/build-user-guide", "crates/build-workshop", "crates/build-xtask", "crates/workshop/gateway", "crates/workshop/gateway-discovery", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/workshop/run-log", "crates/workshop/agents", "crates/workshop/plugin-std", "crates/workshop/plugin-bashkit"] [workspace.package] version = "0.3.0" edition = "2024" -# The Engine crates' floor, which CI's msrv job builds them at: -# `runvil-lua` calls the atomics' `try_update`, stable since 1.95. -rust-version = "1.95" license = "BSL-1.0" repository = "https://github.com/cppalliance/runvil" [workspace.dependencies] base64 = "0.22" -bytes = "1" -runvil-engine = { path = "crates/runvil-engine" } -runvil-runtime = { path = "crates/runvil-internal/runtime" } -runvil-plugin = { path = "crates/runvil-plugin" } -# The C Plugin contract, version 0.4, from its own repository, pinned by -# commit. `version` beside `git` keeps cargo-deny's wildcard check quiet. -runvil-plugin-sys = { version = "0.4.0", git = "https://github.com/cppalliance/runvil-plugin-api", rev = "03a9fa37d2fe690d42ee0aa5c7a8289173e8e0f2" } -# The safe layer over that contract, at the same commit. Only the library -# Plugin crates under `crates/workshop/` name it, for `export_plugin!`. +# The Engine and the Harness, from their own repository, pinned by commit: +# the `runvil` facade, which is the one crate a Host depends on, the +# standard Gateway client, and the two Rust Plugins that Workshop links +# in. All four name one commit, because two commits would be two copies +# of every type the crates share. `version` beside `git` keeps cargo-deny's +# wildcard check quiet. +runvil = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "732429962c5873458213bb0072a0828a40ee4fb6" } +harness-gateway-client = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "732429962c5873458213bb0072a0828a40ee4fb6" } +plugin-web = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "732429962c5873458213bb0072a0828a40ee4fb6" } +plugin-mcp = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "732429962c5873458213bb0072a0828a40ee4fb6" } +# The safe layer over the C Plugin contract, version 0.4, from its own +# repository, pinned by commit. Only the library Plugin crates under +# `crates/workshop/` name it, for `export_plugin!`. The commit is the one +# runvil-harness pins the contract's `runvil-plugin-sys` at: that crate +# sets `links`, so Cargo refuses two commits of the contract in one build. runvil-plugin-sdk = { version = "0.4.0", git = "https://github.com/cppalliance/runvil-plugin-api", rev = "03a9fa37d2fe690d42ee0aa5c7a8289173e8e0f2" } # The std Plugin, from its own repository, pinned by commit, which must be # built against the contract commit above. default-features off drops its @@ -47,19 +49,7 @@ std-plugin = { package = "plugin-std", version = "0.1.0", git = "https://github. # `std-plugin`: the crate's own library has the name of the file Workshop # ships, `plugin_bashkit`. bashkit-plugin = { package = "plugin-bashkit", version = "0.1.0", git = "https://github.com/cppalliance/runvil-plugin-bashkit", rev = "0864f1ff9cc1f4fad6c96f1e9f6d8a3ab85e9580", default-features = false } -runvil-types = { path = "crates/runvil-internal/types" } workshop-gateway-discovery = { path = "crates/workshop/gateway-discovery" } -runvil = { path = "crates/runvil" } -harness-gateway-client = { path = "crates/harness-gateway-client" } -harness-runner = { path = "crates/harness-internal/runner" } -harness-plugin-loader = { path = "crates/harness-internal/plugin-loader" } -plugin-web = { path = "crates/plugin-web" } -plugin-mcp = { path = "crates/plugin-mcp" } -runvil-lua = { path = "crates/runvil-internal/lua" } -runvil-markdown = { path = "crates/runvil-internal/markdown" } -runvil-model-client = { path = "crates/runvil-internal/model-client" } -runvil-prompt = { path = "crates/runvil-internal/prompt" } -runvil-vfs = { path = "crates/runvil-internal/vfs" } workshop-server = { path = "crates/workshop/server" } workshop-server-api = { path = "crates/workshop/server-api" } workshop-gateway = { path = "crates/workshop/gateway" } @@ -74,12 +64,10 @@ workshop-agents = { path = "crates/workshop/agents" } workshop-user-state = { path = "crates/workshop/user-state" } workshop-plugin-std = { path = "crates/workshop/plugin-std" } workshop-plugin-bashkit = { path = "crates/workshop/plugin-bashkit" } -# default-features off: both users (the parser and build-xtask's doctest -# scan) read only the core event stream, never the `html` renderer or the -# `getopts` binary. +# default-features off: build-xtask's doctest scan reads only the core +# event stream, never the `html` renderer or the `getopts` binary. pulldown-cmark = { version = "0.12", default-features = false } serde = { version = "1", features = ["derive"] } -serde_yaml_ng = "0.10" # `float_roundtrip` makes `from_str` reproduce the exact `f64` that # `to_string` printed, so a payload the run log stores reads back bit-for-bit. serde_json = { version = "1", features = ["float_roundtrip"] } @@ -92,48 +80,30 @@ serde_json = { version = "1", features = ["float_roundtrip"] } # `system-proxy` makes reqwest honor Windows registry and macOS proxies. reqwest = { version = "0.13", default-features = false, features = ["json", "query", "rustls", "stream", "system-proxy"] } rand = "0.9" -readabilityrs = "0.1" -htmd = "0.5" -mime = "0.3" -encoding_rs = "0.8" url = "2" # Percent-decoding for path parameters; already in the tree as url's own core. percent-encoding = "2" -ipnet = "2" dunce = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time", "sync"] } thiserror = "2" anyhow = "1" axum = { version = "0.8", features = ["multipart", "ws"] } -# Pinned to 3.9.0, which fixes RUSTSEC-2026-0457, -0461, and -0462. `plugin-mcp` -# was written against 3.5.1 (its `ClientConfig` handler, -# `send_cancellable_request`, and `RequestHandle` fields). default-features off -# drops `server` and `macros`; `base64` still arrives through `client-side-sse`, -# which `transport-streamable-http-client` turns on. Each crate that uses it -# names the features it needs. -rmcp = { version = "=3.9.0", default-features = false } # 0.29 rather than the newer 0.30 so the resolver unifies with the copy axum's # `ws` feature already pulls in, instead of compiling two. tokio-tungstenite = "0.29" async-trait = "0.1" -flate2 = "1" futures-util = "0.3" -mlua = { version = "0.12", features = ["lua55", "vendored", "serialize", "send"] } # 1.x rather than 0.8 so the resolver unifies with the copy tauri-utils # already pulls in, instead of compiling two toml lines (and the 0.8 line's # exclusive toml_edit 0.19 and winnow 0.5 tail). toml = "1" -# Parses the facades' source for build-xtask's shape check; -# `full` is the feature that parses items and `use` trees. 3 is the newest -# major the lockfile already carries. +# Parses each crate's sources for build-xtask's structural checks; `full` +# is the feature that parses items. 3 is the newest major the lockfile +# already carries. syn = { version = "3", features = ["full"] } # `span-locations` makes a span parsed outside a proc macro report its line, -# which the shape check prints with every violation. +# which the structural checks print with every violation. proc-macro2 = { version = "1", features = ["span-locations"] } -# The rustdoc JSON model `cargo xtask api` reads. Exact pin: each release -# matches one `format_version`, so it moves only together with the pinned -# nightly in build-xtask's `api/toolchain.rs`. -rustdoc-types = "=0.61.0" tracing = "0.1" tower = { version = "0.5", features = ["util"] } arc-swap = "1" @@ -150,7 +120,6 @@ tempfile = "3" # C build in a serve path), and `fts` is unused; the workspace schema is # three plain tables. turso = { version = "=0.7.2", default-features = false } -time = { version = "0.3", features = ["formatting"] } sha2 = "0.11" # `cargo workshop manifest` checks each updater signature as the Tauri # updater does; 0.2.5 is the copy tauri-plugin-updater already pulls in. @@ -158,8 +127,6 @@ minisign-verify = "0.2.5" # Signs the manifest tests' fixtures with a key generated per run. A # dev-dependency only: the release key never leaves CI. minisign = "0.9" -# Loads plugin dynamic libraries without linking them into Cargo builds. -libloading = "0.8" socket2 = { version = "0.6", features = ["all"] } # Cross-platform Ctrl-C handling for the Workshop build orchestrator. ctrlc = "3.5" @@ -167,11 +134,6 @@ ctrlc = "3.5" # detection; both are already in the tree transitively. libc = "0.2" windows-sys = { version = "0.61", default-features = false } -# Benchmark framework for the executor and Lua-boundary benches; dev-only. -criterion = "0.5" -# The Engine's GetDynamicTools pattern search; already in the tree through -# criterion, readabilityrs, and turso. -regex = "1" # Tauri v2 desktop framework for workshop. tauri is pinned to the # minor because `with_webview` exposes webview2-com/webkit2gtk types that may # shift in a Tauri minor release (the upstream guidance for `with_webview`). diff --git a/README.md b/README.md index 1131e466c..aac1d1d75 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Workshop connects to a Gateway that's already running or starts the installed on ## What a prompt looks like -This is [`prompts/greet.md`](prompts/greet.md), one of the sample prompts in this repository: +This is [`prompts/greet.md`](https://github.com/cppalliance/runvil-harness/blob/master/prompts/greet.md), one of the sample prompts in the Engine's repository: ````markdown --- @@ -66,7 +66,7 @@ The prompt declares no models, so `models.infer(prose)` runs on the Host's defau ## How it fits together -The Engine and the Harness are Rust libraries. The Engine parses a prompt and steps through it, and whenever the run needs a model reply, a Pack's render of the tools a section offers, a Pack tool's result, a timer, or a file outside a prelude's install, it asks for one and waits. The Harness answers each of those requests and records the run. A Host is any application that runs prompts through the Harness. Workshop is one, and your own program becomes one when it embeds the Harness. +The Engine and the Harness are Rust libraries. The Engine parses a prompt and steps through it, and whenever the run needs a model reply, a Pack's render of the tools a section offers, a Pack tool's result, a timer, or a file outside a prelude's install, it asks for one and waits. The Harness answers each of those requests and records the run. A Host is any application that runs prompts through the Harness. Workshop is one, and your own program becomes one when it embeds the Harness. The Engine and the Harness are developed in their own repository, [cppalliance/runvil-harness](https://github.com/cppalliance/runvil-harness), which this repository pins by commit. The Gateway is the one process that holds model credentials, so no other process ever sees a vendor key. It serves an OpenAI-compatible API and routes each request to a cloud provider or to a model running on your own machine. It also provides web search, speech-to-text, text-to-speech, and a settings page in your browser, and you can run it on its own, on a desktop or a server. It is developed and released separately, in [cppalliance/runvil-gateway](https://github.com/cppalliance/runvil-gateway). diff --git a/crates/README.md b/crates/README.md index 47c1379d6..a684a3247 100644 --- a/crates/README.md +++ b/crates/README.md @@ -1,3 +1,3 @@ # Crates -This directory holds the workspace's crates. The crates at this level are the public layer: the Engine's public API, the Harness and the pieces a Host plugs into it, the vocabulary and protocol code each product family publishes, and the build tooling. Each product family keeps its private crates in a container directory of its own, and code in one family reaches another family only through a crate at this level. +This directory holds the workspace's crates. The crates at this level are the build tooling. Workshop keeps its crates in a container directory of its own, `workshop/`, and only the build tooling may depend into it. diff --git a/crates/build-xtask/Cargo.toml b/crates/build-xtask/Cargo.toml index 7d0bd9b7e..3664f2a0d 100644 --- a/crates/build-xtask/Cargo.toml +++ b/crates/build-xtask/Cargo.toml @@ -10,14 +10,11 @@ repository.workspace = true anyhow.workspace = true proc-macro2.workspace = true pulldown-cmark.workspace = true -rustdoc-types.workspace = true -serde_json.workspace = true syn.workspace = true toml.workspace = true [dev-dependencies] tempfile.workspace = true -workshop-gateway-discovery = { workspace = true, features = ["test-fixtures"] } [lints] workspace = true diff --git a/crates/build-xtask/src/api.rs b/crates/build-xtask/src/api.rs deleted file mode 100644 index 84cc425f3..000000000 --- a/crates/build-xtask/src/api.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! `cargo xtask api`: proves each facade's surface closed and snapshots -//! it, from rustdoc JSON built on the pinned nightly. The facades are -//! `runvil-engine`, the Engine's, and `runvil`, the one a Host depends -//! on. -//! -//! A facade and every internal crate behind it are documented once, with -//! the facade's default features: each facade `use` must resolve to a -//! single item defined in an internal crate, every path a surface item's -//! signature, fields, bounds, impls, or doc links name must be a facade -//! re-export, std, core, alloc, or an allowlisted crate, and no surface -//! doc text may name an internal crate. The listing is compared with the -//! `public-api.txt` in the facade's crate directory. -//! -//! `runvil` passes on items of `runvil-engine` and of `runvil-plugin`. -//! Rustdoc names a re-exported item by the crate that defines it, so the -//! internal crates behind `runvil` are the Harness's and the Engine's, -//! and `runvil-plugin` is a public crate whose items `runvil` may -//! re-export. A surface item that names an Engine or Plugin contract -//! item `runvil` does not re-export is a violation, as one that names an -//! internal item is. -//! -//! A listing line is the item's facade path with its signature, in this -//! notation: -//! -//! - `#[non_exhaustive] ` prefixes a struct, union, enum, or variant line -//! when the item carries the attribute. -//! - A kind suffix follows any generics and where clause: a unit struct -//! ends in `;`, a unit variant has no suffix, a tuple kind ends in -//! `(..)`, and a braced kind ends in ` { .. }`. A variant's -//! ` = ` stays as it is. -//! - On a struct, union, or variant line, `..` means every field has its -//! own listed line, and `/* private fields */` replaces `..` when any -//! field is hidden. -//! - A provided trait method line ends in ` { .. }`, standing in for its -//! body. -//! -//! So `#[non_exhaustive] pub enum runvil_engine::vfs::VfsError`, -//! `pub struct runvil_engine::vfs::AllowAll;`, and -//! `pub struct runvil_engine::vfs::ExecId(/* private fields */)`. -//! -//! - `cargo + xtask api` prints every violation and the -//! listing's difference. -//! - `--check` fails on any violation or any difference. -//! - `--bless` rewrites the listing, and refuses while any violation -//! remains. - -mod closure; -mod doc_text; -mod items; -mod listing; -mod load; -mod render; -mod toolchain; -mod walk; - -use std::collections::BTreeSet; -use std::fmt; -use std::path::Path; -use std::process::ExitCode; - -const USAGE: &str = "usage: cargo xtask api [--check | --bless]"; - -/// One violation: the item, what it mentions, and what was required -/// versus found. -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -struct Finding { - /// The surface item, by its facade path or impl header. - item: String, - /// What it mentions, and where on the item. - mention: String, - required: String, - found: String, -} - -impl fmt::Display for Finding { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{}: mentions {}: required {}, found {}", - self.item, self.mention, self.required, self.found - ) - } -} - -/// What the command was asked to do. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Mode { - Report, - Check, - Bless, -} - -/// The lines to print, and whether the command fails. -#[derive(Debug)] -struct Outcome { - lines: Vec, - failed: bool, -} - -/// The findings every check reported, and the surface listing. -#[derive(Debug)] -struct Report { - findings: BTreeSet, - listing: Vec, -} - -/// Runs `cargo xtask api` with the arguments after `api`. -pub(crate) fn run(root: &Path, args: &[String]) -> ExitCode { - match outcome(root, args, toolchain::active().as_deref()) { - Ok(outcome) => { - for line in &outcome.lines { - println!("{line}"); - } - if outcome.failed { - ExitCode::FAILURE - } else { - ExitCode::SUCCESS - } - } - Err(message) => { - eprintln!("{message}"); - ExitCode::FAILURE - } - } -} - -/// Parses the mode, refuses any toolchain but the pinned nightly before -/// anything is built, and runs every facade in [`load::FACADES`] in order. -fn outcome(root: &Path, args: &[String], active: Option<&str>) -> Result { - let mode = match args { - [] => Mode::Report, - [flag] if flag == "--check" => Mode::Check, - [flag] if flag == "--bless" => Mode::Bless, - _ => return Err(USAGE.to_owned()), - }; - toolchain::require_pinned(active)?; - let mut all = Outcome { - lines: Vec::new(), - failed: false, - }; - for facade in &load::FACADES { - let outcome = execute(root, facade, mode)?; - all.lines.extend(outcome.lines); - all.failed |= outcome.failed; - } - Ok(all) -} - -/// Builds, checks, and compares `facade` in the workspace at `root` in -/// `mode`. The summary line names the facade's package. -fn execute(root: &Path, facade: &load::Facade, mode: Mode) -> Result { - let report = report(root, facade)?; - let path = listing::path(root, facade); - let package = facade.package; - let committed = match std::fs::read_to_string(&path) { - Ok(text) => Some(text), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - Err(error) => return Err(format!("{}: unreadable listing: {error}", path.display())), - }; - let difference = listing::difference(committed.as_deref(), &report.listing); - let violations = report.findings.len(); - let mut lines: Vec = report.findings.iter().map(ToString::to_string).collect(); - if mode == Mode::Bless { - if violations > 0 { - lines.push(format!( - "api: {package}: bless refused: {violations} violations remain" - )); - return Ok(Outcome { - lines, - failed: true, - }); - } - std::fs::write(&path, listing::text(&report.listing)) - .map_err(|error| format!("{}: cannot write the listing: {error}", path.display()))?; - lines.push(format!( - "api: blessed {} ({} lines)", - path.display(), - report.listing.len() - )); - return Ok(Outcome { - lines, - failed: false, - }); - } - let differs = !difference.is_empty(); - lines.extend(difference); - lines.push(format!( - "api: {package}: {violations} violations; the listing {} public-api.txt", - if differs { "differs from" } else { "matches" } - )); - let failed = mode == Mode::Check && (violations > 0 || differs); - Ok(Outcome { lines, failed }) -} - -/// Loads `facade`'s rustdoc JSON and runs every check over it. -fn report(root: &Path, facade: &load::Facade) -> Result { - let loaded = load::load(root, facade)?; - let (surface, mut findings) = items::Surface::resolve(&loaded); - let visits = walk::visits(&loaded, &surface); - findings.extend(closure::findings(&loaded, &surface, &visits)); - findings.extend(doc_text::findings(&loaded, &surface, &visits)); - Ok(Report { - findings: findings.into_iter().collect(), - listing: listing::lines(&surface, &visits), - }) -} - -#[cfg(test)] -#[path = "api/fixture-test-support.rs"] -mod fixture; diff --git a/crates/build-xtask/src/api/closure-tests.rs b/crates/build-xtask/src/api/closure-tests.rs deleted file mode 100644 index 57e9a7d75..000000000 --- a/crates/build-xtask/src/api/closure-tests.rs +++ /dev/null @@ -1,232 +0,0 @@ -//! Closure fixtures: each generated workspace exposes one kind of leak, or -//! one kind of allowed mention, through the facade over one internal -//! crate. Each asserts the exact findings, so an allowed mention that is -//! reported fails as surely as a leak that is missed. - -use std::collections::BTreeSet; - -use super::super::fixture::{ - HOST, findings, findings_of, leak, set, workspace, workspace_with_host, workspace_with_outer, -}; -use super::super::load::Facade; - -#[test] -#[ignore = "needs the pinned nightly"] -fn a_type_in_a_signature_that_is_not_re_exported_is_reported() { - let root = workspace( - "//! Inner.\n\n/// Kept internal.\npub struct Secret;\n\n\ - /// Leaks it.\npub fn make() -> Secret {\n Secret\n}\n", - "//! Facade.\npub use runvil_inner::make;\n", - ); - let leak = leak("runvil_inner"); - assert_eq!( - findings(root.path()), - set([format!( - "runvil::make: mentions `runvil_inner::Secret` in its signature: {leak}" - )]) - ); -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn an_internal_trait_in_a_bound_or_supertrait_is_reported() { - let root = workspace( - "//! Inner.\n\n/// Internal hook.\npub trait Hook {}\n\n\ - /// Bounded by it.\npub fn run(_hook: T) {}\n\n\ - /// Extends it.\npub trait Surface: Hook {}\n", - "//! Facade.\npub use runvil_inner::Surface;\npub use runvil_inner::run;\n", - ); - let leak = leak("runvil_inner"); - assert_eq!( - findings(root.path()), - set([ - format!("runvil::Surface: mentions `runvil_inner::Hook` in its supertrait: {leak}"), - format!("runvil::run: mentions `runvil_inner::Hook` in its bound: {leak}"), - ]) - ); -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn a_trait_impl_that_mentions_an_internal_type_is_reported() { - let root = workspace( - "//! Inner.\n\n/// Kept internal.\npub struct Secret;\n\n\ - /// Exposed.\npub struct Visible;\n\n\ - impl From for Visible {\n fn from(_: Secret) -> Self {\n Visible\n }\n}\n", - "//! Facade.\npub use runvil_inner::Visible;\n", - ); - let header = "impl core::convert::From for runvil::Visible"; - let leak = leak("runvil_inner"); - assert_eq!( - findings(root.path()), - set([ - format!("{header}: mentions `runvil_inner::Secret` in its implemented trait: {leak}"), - format!( - "{header} {{ fn from }}: mentions `runvil_inner::Secret` in its signature: {leak}" - ), - ]) - ); -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn an_impl_of_an_internal_trait_on_a_surface_type_is_reported() { - let root = workspace( - "//! Inner.\n\n/// Internal hook.\npub trait Hook {}\n\n\ - /// Exposed.\npub struct Visible;\n\nimpl Hook for Visible {}\n", - "//! Facade.\npub use runvil_inner::Visible;\n", - ); - let leak = leak("runvil_inner"); - assert_eq!( - findings(root.path()), - set([format!( - "impl runvil_inner::Hook for runvil::Visible: mentions \ - `runvil_inner::Hook` in its implemented trait: {leak}" - )]) - ); -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn an_impl_written_in_a_downstream_internal_crate_is_reported() { - let root = workspace_with_outer( - "//! Inner.\n\n/// Exposed.\npub struct Visible;\n", - "//! Outer.\n\n/// Internal hook.\npub trait Hook {}\n\n\ - impl Hook for runvil_inner::Visible {}\n", - "//! Facade.\npub use runvil_inner::Visible;\n", - ); - let leak = leak("runvil_outer"); - assert_eq!( - findings(root.path()), - set([format!( - "impl runvil_outer::Hook for runvil::Visible: mentions \ - `runvil_outer::Hook` in its implemented trait: {leak}" - )]) - ); -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn a_type_from_an_allowlisted_crate_passes() { - let root = workspace( - "//! Inner.\n\n/// Exposed.\npub struct Visible;\n\nimpl serde::Serialize for Visible {}\n\n\ - /// Takes any serializable value.\npub fn encode(_value: &dyn serde::Serialize) {}\n", - "//! Facade.\npub use runvil_inner::Visible;\npub use runvil_inner::encode;\n", - ); - assert_eq!(findings(root.path()), BTreeSet::new()); -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn a_link_to_an_internal_item_written_through_crate_or_super_is_reported() { - let root = workspace( - "//! Inner.\n\n/// Kept internal.\npub struct Secret;\n\n\ - /// Exposed; see [crate::Secret].\npub struct Visible;\n\n\ - /// A module.\npub mod nested {\n \ - /// Also exposed; see [super::Secret] and [super::Visible].\n pub struct Other;\n}\n", - "//! Facade.\npub use runvil_inner::Visible;\npub use runvil_inner::nested::Other;\n", - ); - let leak = leak("runvil_inner"); - assert_eq!( - findings(root.path()), - set([ - format!( - "runvil::Other: mentions `runvil_inner::Secret` through its doc link \ - `[super::Secret]`: {leak}" - ), - format!( - "runvil::Visible: mentions `runvil_inner::Secret` through its doc link \ - `[crate::Secret]`: {leak}" - ), - ]) - ); -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn a_link_whose_target_the_facade_re_exports_passes() { - let root = workspace( - "//! Inner.\n\n/// Exposed; see [crate::Other], [Other::run], [Kind::A], and [String].\n\ - pub struct Visible;\n\n/// Also exposed.\npub struct Other;\n\n\ - impl Other {\n /// Runs.\n pub fn run(&self) {}\n}\n\n\ - /// Kinds.\npub enum Kind {\n /// The first.\n A,\n}\n", - "//! Facade; see [Visible].\npub use runvil_inner::Kind;\n\ - pub use runvil_inner::Other;\npub use runvil_inner::Visible;\n", - ); - assert_eq!(findings(root.path()), BTreeSet::new()); -} - -const VISIBLE: &str = "//! Inner.\n\n/// Exposed.\npub struct Visible;\n"; - -const DEAL: &str = "//! Contract.\n\n/// A deal.\npub struct Deal;\n"; - -#[test] -#[ignore = "needs the pinned nightly"] -fn a_second_facade_re_exports_the_first_facade_s_re_export_and_a_public_crate_s_item() { - let root = workspace_with_host( - VISIBLE, - "//! Facade.\npub use runvil_inner::Visible;\n", - DEAL, - "//! Host.\npub use contract::Deal;\npub use runvil::Visible;\n", - ); - assert_eq!(findings_of(root.path(), &HOST), BTreeSet::new()); - let listing = super::super::report(root.path(), &HOST) - .expect("the fixture documents") - .listing; - for line in ["pub struct host::Deal;", "pub struct host::Visible;"] { - assert!( - listing.iter().any(|listed| listed == line), - "{line} is not in {listing:?}" - ); - } -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn a_second_facade_must_re_export_what_its_own_surface_names() { - let root = workspace_with_host( - "//! Inner.\n\n/// Exposed by the first facade.\npub struct Secret;\n\n\ - /// Returns it.\npub fn make() -> Secret {\n Secret\n}\n", - "//! Facade.\npub use runvil_inner::Secret;\npub use runvil_inner::make;\n", - "//! Contract.\n\n/// Passed on by no facade.\npub struct Terms;\n\n\ - /// Returns them.\npub fn sign() -> Terms {\n Terms\n}\n", - "//! Host.\npub use contract::sign;\npub use runvil::make;\n", - ); - assert_eq!( - findings(root.path()), - BTreeSet::new(), - "the first facade re-exports what its surface names" - ); - let internal = leak("runvil_inner"); - let public = internal.replace("internal crate `runvil_inner`", "public crate `contract`"); - assert_eq!( - findings_of(root.path(), &HOST), - set([ - format!("host::make: mentions `runvil_inner::Secret` in its signature: {internal}"), - format!("host::sign: mentions `contract::Terms` in its signature: {public}"), - ]) - ); -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn a_public_crate_s_item_is_a_re_export_target_only_for_a_facade_that_names_the_crate() { - let root = workspace_with_host( - VISIBLE, - "//! Facade.\npub use runvil_inner::Visible;\n", - DEAL, - "//! Host.\npub use contract::Deal;\n", - ); - let closed = Facade { - public: &[], - ..HOST - }; - assert_eq!( - findings_of(root.path(), &closed), - set([ - "host::Deal: mentions `contract::Deal` as its re-export target: required an item \ - defined in an internal crate, found an item of crate `contract`" - .to_owned() - ]) - ); -} diff --git a/crates/build-xtask/src/api/closure.rs b/crates/build-xtask/src/api/closure.rs deleted file mode 100644 index eaae15373..000000000 --- a/crates/build-xtask/src/api/closure.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! The closure check: every path a surface item's rendering names, and -//! every target of its resolved intra-doc links, must be a facade -//! re-export, std, core, alloc, or an allowlisted crate. -//! -//! A link may also target a member of a surface item (a method, field, -//! variant, or associated item), which rustdoc resolves to the member's -//! own path or, when the member has none in the path table, to an id the -//! walk visited. - -use std::collections::HashSet; - -use rustdoc_types::{Crate, Id, Item, ItemKind}; - -use super::Finding; -use super::items::{Surface, target}; -use super::load::Loaded; -use super::render::Renderer; -use super::walk::Visit; - -/// The closure findings for one build's visits and the facade's modules. -pub(super) fn findings(loaded: &Loaded, surface: &Surface, visits: &[Visit<'_>]) -> Vec { - let visited: HashSet<(&str, Id)> = visits - .iter() - .map(|visit| (visit.crate_name, visit.item.id)) - .collect(); - let mut findings = Vec::new(); - for visit in visits { - let mut renderer = Renderer::new(visit.krate, surface); - renderer.line(visit); - for mention in renderer.mentions { - let refusal = match &mention.target { - Some((krate, path)) => surface - .refusal(krate, path) - .map(|found| (path.join("::"), found)), - None => Some(( - mention.written.clone(), - "a path rustdoc could not resolve".to_owned(), - )), - }; - if let Some((shown, found)) = refusal { - findings.push(Finding { - item: visit.label.clone(), - mention: format!("`{shown}` in its {}", mention.context), - required: surface.required(), - found, - }); - } - } - let links = Links { - surface, - krate: visit.krate, - crate_name: visit.crate_name, - visited: &visited, - }; - findings.extend(links.findings(&visit.label, visit.item)); - } - for (label, id) in &surface.modules { - if let Some(item) = loaded.facade.index.get(id) { - let links = Links { - surface, - krate: &loaded.facade, - crate_name: &loaded.name, - visited: &visited, - }; - findings.extend(links.findings(label, item)); - } - } - findings -} - -/// Checks the resolved intra-doc links of items from one crate's JSON. -struct Links<'a> { - surface: &'a Surface, - krate: &'a Crate, - crate_name: &'a str, - visited: &'a HashSet<(&'a str, Id)>, -} - -impl Links<'_> { - fn findings(&self, label: &str, item: &Item) -> Vec { - let mut links: Vec<(&String, &Id)> = item.links.iter().collect(); - links.sort(); - let mut findings = Vec::new(); - for (text, id) in links { - let refusal = match target(self.krate, *id) { - Some(found) => self - .refusal(found.krate, found.path, found.kind) - .map(|why| (found.path.join("::"), why)), - None if self.visited.contains(&(self.crate_name, *id)) => None, - None => Some((text.clone(), "a target rustdoc gives no path".to_owned())), - }; - if let Some((shown, found)) = refusal { - findings.push(Finding { - item: label.to_owned(), - mention: format!("`{shown}` through its doc link `[{text}]`"), - required: self.surface.required(), - found, - }); - } - } - findings - } - - /// A member's link passes when its owner is on the surface, since - /// rustdoc names members by their owner's path plus their own name. - fn refusal(&self, krate: &str, path: &[String], kind: ItemKind) -> Option { - let refusal = self.surface.refusal(krate, path)?; - let member = matches!( - kind, - ItemKind::Function - | ItemKind::StructField - | ItemKind::Variant - | ItemKind::AssocConst - | ItemKind::AssocType - ); - let owner = path.split_last().map(|(_, owner)| owner); - match owner { - Some(owner) if member && self.surface.facade_path(krate, owner).is_some() => None, - _ => Some(refusal), - } - } -} - -#[cfg(test)] -#[path = "closure-tests.rs"] -mod tests; diff --git a/crates/build-xtask/src/api/doc_text-tests.rs b/crates/build-xtask/src/api/doc_text-tests.rs deleted file mode 100644 index 6282ae6fa..000000000 --- a/crates/build-xtask/src/api/doc_text-tests.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Doc-text fixtures: internal crate names in either spelling are found -//! in facade module docs and surface item docs, and only as whole names. - -use super::super::fixture::{findings, set, workspace}; -use super::*; - -#[test] -fn a_crate_name_matches_only_as_a_whole_name() { - for text in [ - "built on runvil-inner.", - "`runvil-inner`", - "runvil_inner::Visible", - "(runvil-inner)", - "runvil-inner", - ] { - let name = if text.contains('_') { - "runvil_inner" - } else { - "runvil-inner" - }; - assert!(contains_name(text, name), "{text}"); - } - for text in [ - "runvil-innerly", - "my-runvil-inner", - "runvil-inner-two", - "xrunvil_inner", - "runvil_inner2", - ] { - let name = if text.contains('_') { - "runvil_inner" - } else { - "runvil-inner" - }; - assert!(!contains_name(text, name), "{text}"); - } -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn an_internal_crate_name_in_doc_text_is_reported() { - let root = workspace( - "//! Inner.\n\n/// Exposed.\n/// Built in runvil-inner.\npub struct Visible {\n \ - /// Named `runvil_inner::Visible` inside.\n pub field: u8,\n}\n\n\ - /// Not a name: runvil-innerly.\npub struct Clean;\n", - "//! Facade over runvil-inner.\npub use runvil_inner::Clean;\n\ - pub use runvil_inner::Visible;\n", - ); - let required = "required surface doc text that names no internal crate"; - assert_eq!( - findings(root.path()), - set([ - format!( - "runvil: mentions internal crate name `runvil-inner` in its doc text: \ - {required}, found `runvil-inner` on doc line 1" - ), - format!( - "runvil::Visible: mentions internal crate name `runvil-inner` in its \ - doc text: {required}, found `runvil-inner` on doc line 2" - ), - format!( - "runvil::Visible::field: mentions internal crate name `runvil_inner` \ - in its doc text: {required}, found `runvil_inner` on doc line 1" - ), - ]) - ); -} diff --git a/crates/build-xtask/src/api/doc_text.rs b/crates/build-xtask/src/api/doc_text.rs deleted file mode 100644 index 5ad582cb2..000000000 --- a/crates/build-xtask/src/api/doc_text.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Reports any internal crate name, in kebab or snake spelling, found in -//! the doc text of a surface item or a facade module. Rustdoc inlines a -//! re-exported item's docs into the facade, so a name in them is a name -//! dependents read and cannot use. - -use super::Finding; -use super::items::Surface; -use super::load::Loaded; -use super::walk::Visit; - -const REQUIRED: &str = "surface doc text that names no internal crate"; - -/// The doc-text findings for one build's visits and the facade's modules. -pub(super) fn findings(loaded: &Loaded, surface: &Surface, visits: &[Visit<'_>]) -> Vec { - let names = spellings(surface); - let mut findings = Vec::new(); - for (label, id) in &surface.modules { - if let Some(docs) = loaded - .facade - .index - .get(id) - .and_then(|item| item.docs.as_deref()) - { - findings.extend(scan(label, docs, &names)); - } - } - for visit in visits { - if let Some(docs) = visit.item.docs.as_deref() { - findings.extend(scan(&visit.label, docs, &names)); - } - } - findings -} - -/// Both spellings of every container crate's name, sorted. A public crate -/// the facade passes on is one dependents can use, so docs may name it. -fn spellings(surface: &Surface) -> Vec { - let mut names: Vec = surface - .private - .iter() - .flat_map(|name| [name.clone(), name.replace('_', "-")]) - .collect(); - names.sort(); - names.dedup(); - names -} - -/// One finding per internal crate name in `docs`, at its first line. -fn scan(label: &str, docs: &str, names: &[String]) -> Vec { - names - .iter() - .filter_map(|name| { - let line = docs.lines().position(|line| contains_name(line, name))?; - Some(Finding { - item: label.to_owned(), - mention: format!("internal crate name `{name}` in its doc text"), - required: REQUIRED.to_owned(), - found: format!("`{name}` on doc line {}", line + 1), - }) - }) - .collect() -} - -/// Whether `text` holds `name` as a whole crate name: not inside a longer -/// identifier or hyphenated name (`runvil-lua` is not in -/// `runvil-luau`). -fn contains_name(text: &str, name: &str) -> bool { - let part_of_name = |c: char| c.is_ascii_alphanumeric() || c == '_' || c == '-'; - text.match_indices(name).any(|(start, _)| { - let before = text[..start].chars().next_back(); - let after = text[start + name.len()..].chars().next(); - !before.is_some_and(part_of_name) && !after.is_some_and(part_of_name) - }) -} - -#[cfg(test)] -#[path = "doc_text-tests.rs"] -mod tests; diff --git a/crates/build-xtask/src/api/fixture-test-support.rs b/crates/build-xtask/src/api/fixture-test-support.rs deleted file mode 100644 index 318a690eb..000000000 --- a/crates/build-xtask/src/api/fixture-test-support.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! Generated workspaces for the `cargo xtask api` fixtures: a facade -//! crate over one internal crate, `runvil-inner`, plus a stand-in -//! `serde` so the allowlist is exercised without a registry; one fixture -//! adds a second internal crate, `runvil-outer`, downstream of the -//! first, and one adds a second facade, `host`, over the first and over -//! a public crate, `contract`. Building their rustdoc JSON needs the pinned nightly, so every -//! test that calls [`findings`] or `execute` carries `#[ignore = "needs -//! the pinned nightly"]`. - -use std::collections::BTreeSet; -use std::path::Path; - -use super::load::Facade; -use super::{Mode, Outcome, Report}; - -/// The second facade of [`workspace_with_host`]: `crates/host` over the -/// same container as the first, passing on the public crate `contract`. -pub(super) const HOST: Facade = Facade { - package: "host", - dir: ["crates", "host"], - containers: &["runvil-internal"], - public: &["contract"], - allowed: &[], -}; - -/// The fixture workspaces' facade: `crates/runvil` over -/// `crates/runvil-internal/`. -pub(super) const FIXTURE: Facade = Facade { - package: "runvil", - dir: ["crates", "runvil"], - containers: &["runvil-internal"], - public: &[], - allowed: &[], -}; - -const WORKSPACE: &str = "[workspace]\nresolver = \"3\"\nmembers = [\ - \"crates/runvil\", \"crates/runvil-internal/*\", \"crates/serde\"]\n"; - -const FACADE_MANIFEST: &str = "[package]\nname = \"runvil\"\nversion = \"0.0.0\"\n\ - edition = \"2024\"\npublish = false\n\n\ - [features]\ntest-support = [\"runvil-inner/test-support\"]\n\n\ - [dependencies]\nrunvil-inner = { path = \"../runvil-internal/inner\" }\n"; - -const OUTER_DEPENDENCY: &str = "runvil-outer = { path = \"../runvil-internal/outer\" }\n"; - -const INNER_MANIFEST: &str = "[package]\nname = \"runvil-inner\"\nversion = \"0.0.0\"\n\ - edition = \"2024\"\npublish = false\n\n\ - [dependencies]\nserde = { path = \"../../serde\" }\n\n\ - [features]\ntest-support = []\n"; - -const OUTER_MANIFEST: &str = "[package]\nname = \"runvil-outer\"\nversion = \"0.0.0\"\n\ - edition = \"2024\"\npublish = false\n\n\ - [dependencies]\nrunvil-inner = { path = \"../inner\" }\n"; - -const HOST_WORKSPACE: &str = "[workspace]\nresolver = \"3\"\nmembers = [\ - \"crates/runvil\", \"crates/runvil-internal/*\", \"crates/serde\", \ - \"crates/host\", \"crates/contract\"]\n"; - -const HOST_MANIFEST: &str = "[package]\nname = \"host\"\nversion = \"0.0.0\"\n\ - edition = \"2024\"\npublish = false\n\n\ - [dependencies]\nrunvil = { path = \"../runvil\" }\n\ - contract = { path = \"../contract\" }\n"; - -const CONTRACT_MANIFEST: &str = "[package]\nname = \"contract\"\nversion = \"0.0.0\"\n\ - edition = \"2024\"\npublish = false\n"; - -const SERDE_MANIFEST: &str = - "[package]\nname = \"serde\"\nversion = \"1.0.0\"\nedition = \"2024\"\npublish = false\n"; - -const SERDE_LIB: &str = "//! Stand-in for the allowlisted `serde`.\n\n\ - /// A stand-in serialization trait.\npub trait Serialize {}\n"; - -/// A workspace whose internal crate's `lib.rs` is `inner` and whose -/// facade's is `facade`, with its lockfile written so `--locked` holds. -pub(super) fn workspace(inner: &str, facade: &str) -> tempfile::TempDir { - generate(inner, None, facade) -} - -/// [`workspace`] plus `runvil-outer`, whose `lib.rs` is `outer`, -/// depending on the inner crate; the facade depends on both. -pub(super) fn workspace_with_outer(inner: &str, outer: &str, facade: &str) -> tempfile::TempDir { - generate(inner, Some(outer), facade) -} - -/// [`workspace`] plus a second facade, `host`, whose `lib.rs` is `host`. -/// It depends on the first facade and on the public crate `contract`, -/// whose `lib.rs` is `contract`. -pub(super) fn workspace_with_host( - inner: &str, - facade: &str, - contract: &str, - host: &str, -) -> tempfile::TempDir { - let root = generate(inner, None, facade); - for (path, text) in [ - ("Cargo.toml", HOST_WORKSPACE), - ("crates/host/Cargo.toml", HOST_MANIFEST), - ("crates/host/src/lib.rs", host), - ("crates/contract/Cargo.toml", CONTRACT_MANIFEST), - ("crates/contract/src/lib.rs", contract), - ] { - write(root.path(), path, text); - } - lock(root.path()); - root -} - -fn generate(inner: &str, outer: Option<&str>, facade: &str) -> tempfile::TempDir { - let root = tempfile::TempDir::new().expect("tempdir"); - let facade_manifest = match outer { - Some(_) => format!("{FACADE_MANIFEST}{OUTER_DEPENDENCY}"), - None => FACADE_MANIFEST.to_owned(), - }; - for (path, text) in [ - ("Cargo.toml", WORKSPACE), - ("crates/runvil/Cargo.toml", &facade_manifest), - ("crates/runvil/src/lib.rs", facade), - ("crates/runvil-internal/inner/Cargo.toml", INNER_MANIFEST), - ("crates/runvil-internal/inner/src/lib.rs", inner), - ("crates/serde/Cargo.toml", SERDE_MANIFEST), - ("crates/serde/src/lib.rs", SERDE_LIB), - ] { - write(root.path(), path, text); - } - if let Some(outer) = outer { - write( - root.path(), - "crates/runvil-internal/outer/Cargo.toml", - OUTER_MANIFEST, - ); - write( - root.path(), - "crates/runvil-internal/outer/src/lib.rs", - outer, - ); - } - lock(root.path()); - root -} - -/// Writes the lockfile of the workspace at `root`, so `--locked` holds. -fn lock(root: &Path) { - let output = super::load::cargo(root) - .args(["generate-lockfile", "--offline"]) - .output() - .expect("cargo runs"); - assert!( - output.status.success(), - "the fixture lockfile generates:\n{}", - String::from_utf8_lossy(&output.stderr) - ); -} - -/// Writes `text` at `path` under `root`, creating directories. -pub(super) fn write(root: &Path, path: &str, text: &str) { - let file = root.join(path); - std::fs::create_dir_all(file.parent().expect("a fixture file has a directory")) - .expect("the fixture directory creates"); - std::fs::write(file, text).expect("the fixture file writes"); -} - -/// The report for the fixture facade of the workspace at `root`. -pub(super) fn report(root: &Path) -> Result { - super::report(root, &FIXTURE) -} - -/// Runs the fixture facade of the workspace at `root` in `mode`. -pub(super) fn execute(root: &Path, mode: Mode) -> Result { - super::execute(root, &FIXTURE, mode) -} - -/// The findings for `facade` in the workspace at `root`, as printed. -pub(super) fn findings_of(root: &Path, facade: &Facade) -> BTreeSet { - super::report(root, facade) - .expect("the fixture documents") - .findings - .iter() - .map(ToString::to_string) - .collect() -} - -/// The findings for the workspace at `root`, as printed. -pub(super) fn findings(root: &Path) -> BTreeSet { - report(root) - .expect("the fixture documents") - .findings - .iter() - .map(ToString::to_string) - .collect() -} - -/// The findings `expected` names, as a set to compare with [`findings`]. -pub(super) fn set(expected: [String; N]) -> BTreeSet { - expected.into_iter().collect() -} - -/// A closure finding's text after the mention, for an item of internal -/// crate `krate` the fixture facade does not re-export. -pub(super) fn leak(krate: &str) -> String { - format!( - "required {}, found an item of internal crate `{krate}` the facade does not re-export", - super::items::required_with(&[]) - ) -} diff --git a/crates/build-xtask/src/api/items-tests.rs b/crates/build-xtask/src/api/items-tests.rs deleted file mode 100644 index 53fad03b8..000000000 --- a/crates/build-xtask/src/api/items-tests.rs +++ /dev/null @@ -1,130 +0,0 @@ -//! Re-export resolution fixtures: a module re-export and a second facade -//! path for one item are reported; the rustdoc JSON supplies the item -//! kinds syntax cannot. The allowlist's approved crates pass closure, and -//! the closure requirement names each one. - -use super::super::fixture::{findings, set, workspace}; -use super::*; - -#[test] -fn every_approved_crate_passes_closure_and_any_other_is_refused() { - let surface = Surface::default(); - for krate in ["serde", "serde_core", "serde_json", "serde_yaml_ng"] { - let path = [krate.to_owned(), "Value".to_owned()]; - assert_eq!(surface.refusal(krate, &path), None, "{krate}"); - } - let path = ["toml".to_owned(), "Value".to_owned()]; - assert_eq!( - surface.refusal("toml", &path).as_deref(), - Some("an item of crate `toml`, which is not on the allowlist") - ); -} - -#[test] -fn a_facade_s_own_allowed_crate_passes_closure_and_its_requirement_names_the_crate() { - let surface = Surface { - allowed: &["extra_sys"], - ..Surface::default() - }; - let path = ["extra_sys".to_owned(), "Entry".to_owned()]; - assert_eq!(surface.refusal("extra_sys", &path), None); - assert!( - surface.required().ends_with("serde_yaml_ng, extra_sys)"), - "{}", - surface.required() - ); - assert_eq!( - Surface::default().refusal("extra_sys", &path).as_deref(), - Some("an item of crate `extra_sys`, which is not on the allowlist"), - "another facade does not get the crate" - ); -} - -#[test] -fn a_mention_of_an_item_the_facade_lacks_says_whether_its_crate_is_internal_or_public() { - let surface = Surface { - internal: ["contract".to_owned(), "runvil_inner".to_owned()].into(), - private: ["runvil_inner".to_owned()].into(), - ..Surface::default() - }; - let secret = ["runvil_inner".to_owned(), "Secret".to_owned()]; - assert_eq!( - surface.refusal("runvil_inner", &secret).as_deref(), - Some("an item of internal crate `runvil_inner` the facade does not re-export") - ); - let terms = ["contract".to_owned(), "Terms".to_owned()]; - assert_eq!( - surface.refusal("contract", &terms).as_deref(), - Some("an item of public crate `contract` the facade does not re-export") - ); -} - -#[test] -fn the_closure_requirement_names_every_crate_it_permits() { - let required = required_with(&[]); - let words: Vec<&str> = required - .split(|c: char| !(c.is_alphanumeric() || c == '_')) - .collect(); - for krate in STD_CRATES.iter().chain(&ALLOWLIST) { - assert!(words.contains(krate), "{krate} in {required}"); - } -} - -#[test] -fn a_path_table_entry_the_index_lacks_is_no_re_export_target() { - let summary = rustdoc_types::ItemSummary { - crate_id: 0, - path: vec!["runvil_inner".to_owned(), "Gone".to_owned()], - kind: ItemKind::Struct, - }; - let krate = Crate { - root: Id(0), - crate_version: None, - includes_private: false, - index: HashMap::new(), - paths: HashMap::from([(Id(1), summary)]), - external_crates: HashMap::new(), - target: rustdoc_types::Target { - triple: String::new(), - target_features: Vec::new(), - }, - format_version: rustdoc_types::FORMAT_VERSION, - }; - assert!(local_paths(&krate).is_empty()); -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn a_module_re_export_is_reported() { - let root = workspace( - "//! Inner.\n\n/// A module.\npub mod nested {\n /// Inside.\n pub struct Deep;\n}\n", - "//! Facade.\npub use runvil_inner::nested;\n", - ); - assert_eq!( - findings(root.path()), - set([ - "runvil::nested: mentions `runvil_inner::nested` as its re-export \ - target: required a single-item re-export, found a module" - .to_owned() - ]) - ); -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn a_second_facade_path_for_one_item_is_reported() { - let root = workspace( - "//! Inner.\n\n/// Exposed.\npub struct Visible;\n", - "//! Facade.\npub use runvil_inner::Visible;\n\n\ - /// Again.\npub mod again {\n pub use runvil_inner::Visible;\n}\n", - ); - assert_eq!( - findings(root.path()), - set([ - "runvil::again::Visible: mentions `runvil_inner::Visible` as its \ - re-export target: required exactly one facade path per item, found a second \ - facade path beside `runvil::Visible`" - .to_owned() - ]) - ); -} diff --git a/crates/build-xtask/src/api/items.rs b/crates/build-xtask/src/api/items.rs deleted file mode 100644 index 83b45304b..000000000 --- a/crates/build-xtask/src/api/items.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! Matches items across crates. Rustdoc ids are only meaningful inside the -//! JSON that assigned them, and the facade's JSON names a re-exported item -//! by its defining crate and its definition path (`runvil_runtime:: -//! execute::run::Run`, private modules included), so every cross-crate -//! match here goes by crate name and path, never by id. - -use std::collections::{BTreeSet, HashMap}; - -use rustdoc_types::{Crate, Id, ItemEnum, ItemKind, Use}; - -use super::Finding; -use super::load::Loaded; - -/// Crates any surface item may mention. -const STD_CRATES: [&str; 3] = ["std", "core", "alloc"]; - -/// Third-party crates the surface may mention: serde's traits are how -/// dependents serialize the surface's types, and `serde_json` and -/// `serde_yaml_ng` supply the values some of those types carry. `serde` -/// defines its traits in `serde_core` and re-exports them, and rustdoc -/// names the defining crate. -const ALLOWLIST: [&str; 4] = ["serde", "serde_core", "serde_json", "serde_yaml_ng"]; - -/// What a closure mention must be, naming every crate it may come from: -/// the shared ones, then the crates in `allowed`, which one facade may -/// also mention. -pub(super) fn required_with(allowed: &[&str]) -> String { - let mut crates = ALLOWLIST.to_vec(); - crates.extend(allowed); - format!( - "a facade re-export, {}, or an allowlisted crate ({})", - STD_CRATES.join(", "), - crates.join(", ") - ) -} - -/// Where an id points, as the JSON that holds the id resolves it. -#[derive(Debug)] -pub(super) struct Target<'a> { - /// The defining crate's name. - pub(super) krate: &'a str, - /// The definition path, starting with the crate name. - pub(super) path: &'a [String], - pub(super) kind: ItemKind, -} - -/// The name of crate `crate_id` as `krate` numbers crates; 0 is `krate`. -fn crate_name(krate: &Crate, crate_id: u32) -> Option<&str> { - if crate_id == 0 { - krate.index.get(&krate.root)?.name.as_deref() - } else { - Some(krate.external_crates.get(&crate_id)?.name.as_str()) - } -} - -/// Resolves `id` through `krate`'s path table. -pub(super) fn target(krate: &Crate, id: Id) -> Option> { - let summary = krate.paths.get(&id)?; - Some(Target { - krate: crate_name(krate, summary.crate_id)?, - path: &summary.path, - kind: summary.kind, - }) -} - -/// One facade re-export, resolved to its defining item. -#[derive(Debug)] -pub(super) struct Entry { - /// The facade path dependents name it by (`runvil::model::Message`). - pub(super) label: String, - /// The defining internal crate's name. - pub(super) krate: String, - /// The item's id in the defining crate's JSON, present in its index. - pub(super) id: Id, -} - -/// The facade's surface: its re-exports and its own modules. -#[derive(Debug, Default)] -pub(super) struct Surface { - pub(super) entries: Vec, - /// Each facade module (the crate root first) and its id in the - /// facade's JSON. - pub(super) modules: Vec<(String, Id)>, - /// The facade's crate name, which roots every facade path. - pub(super) root: String, - /// The names of the crates that may define a re-exported item: the - /// container crates and the public crates the facade passes on. - pub(super) internal: BTreeSet, - /// The container crates' names, which surface docs may not hold. - pub(super) private: BTreeSet, - /// The crates the surface may mention beside the shared allowlist. - allowed: &'static [&'static str], - /// Defining crate, then definition path, to facade path. - facade_paths: HashMap, String>>, -} - -impl Surface { - /// Walks the facade's modules from the crate root and resolves every - /// re-export, returning a finding for each one that is not a single - /// item defined in an internal crate. - pub(super) fn resolve(loaded: &Loaded) -> (Surface, Vec) { - let mut resolver = Resolver { - loaded, - local: loaded - .internal - .iter() - .map(|(name, krate)| (name.as_str(), local_paths(krate))) - .collect(), - surface: Surface { - root: loaded.name.clone(), - internal: loaded.internal.keys().cloned().collect(), - private: loaded.private.clone(), - allowed: loaded.allowed, - ..Surface::default() - }, - uses: Vec::new(), - findings: Vec::new(), - }; - resolver.module(loaded.facade.root, &loaded.name); - let mut uses = std::mem::take(&mut resolver.uses); - uses.sort_by(|a, b| a.0.cmp(&b.0)); - for (label, reexport) in uses { - resolver.reexport(label, reexport); - } - (resolver.surface, resolver.findings) - } - - /// The facade path of the item defined at `path` in crate `krate`. - pub(super) fn facade_path(&self, krate: &str, path: &[String]) -> Option<&str> { - self.facade_paths.get(krate)?.get(path).map(String::as_str) - } - - /// What a closure mention must be, naming every crate it may come - /// from. - pub(super) fn required(&self) -> String { - required_with(self.allowed) - } - - /// Why mentioning the item at `path` in `krate` breaks closure, or - /// `None` when the facade re-exports it or its crate is allowed. - pub(super) fn refusal(&self, krate: &str, path: &[String]) -> Option { - if krate == self.root - || STD_CRATES.contains(&krate) - || ALLOWLIST.contains(&krate) - || self.allowed.contains(&krate) - || self.facade_path(krate, path).is_some() - { - None - } else if self.private.contains(krate) { - Some(format!( - "an item of internal crate `{krate}` the facade does not re-export" - )) - } else if self.internal.contains(krate) { - Some(format!( - "an item of public crate `{krate}` the facade does not re-export" - )) - } else { - Some(format!( - "an item of crate `{krate}`, which is not on the allowlist" - )) - } - } -} - -/// Every item `krate` defines and documents, by definition path. The path -/// table and the index are separate tables, so an id the index lacks is -/// left out: a re-export of it is refused rather than walked as nothing. -fn local_paths(krate: &Crate) -> HashMap<&[String], Id> { - krate - .paths - .iter() - .filter(|(id, summary)| summary.crate_id == 0 && krate.index.contains_key(id)) - .map(|(id, summary)| (summary.path.as_slice(), *id)) - .collect() -} - -struct Resolver<'a> { - loaded: &'a Loaded, - local: HashMap<&'a str, HashMap<&'a [String], Id>>, - surface: Surface, - /// Every facade `use` and its facade path, resolved in path order - /// once the walk is done: rustdoc lists a module's items in no fixed - /// order, and the first path an item is re-exported at is the one a - /// second path is reported against. - uses: Vec<(String, &'a Use)>, - findings: Vec, -} - -impl<'a> Resolver<'a> { - fn module(&mut self, id: Id, label: &str) { - let loaded = self.loaded; - let facade = &loaded.facade; - let Some(ItemEnum::Module(module)) = facade.index.get(&id).map(|item| &item.inner) else { - return; - }; - self.surface.modules.push((label.to_owned(), id)); - for child in &module.items { - let Some(item) = facade.index.get(child) else { - continue; - }; - let name = item.name.as_deref().unwrap_or("_"); - match &item.inner { - ItemEnum::Module(_) => self.module(*child, &format!("{label}::{name}")), - ItemEnum::Use(reexport) => self - .uses - .push((format!("{label}::{}", reexport.name), reexport)), - _ => {} - } - } - } - - fn reexport(&mut self, label: String, reexport: &'a Use) { - let loaded = self.loaded; - let Some(target) = reexport.id.and_then(|id| target(&loaded.facade, id)) else { - return self.refuse( - label, - &reexport.source, - "a re-export rustdoc resolves", - "none", - ); - }; - let path = target.path.join("::"); - if reexport.is_glob { - return self.refuse(label, &path, "a single-item re-export", "a glob"); - } - if target.kind == ItemKind::Module { - return self.refuse(label, &path, "a single-item re-export", "a module"); - } - let Some(id) = self - .local - .get(target.krate) - .and_then(|paths| paths.get(target.path)) - .copied() - else { - let found = if loaded.internal.contains_key(target.krate) { - "no item at that path in its crate's rustdoc JSON".to_owned() - } else { - format!("an item of crate `{}`", target.krate) - }; - // A facade that passes on public crates may re-export their - // items too. - let required = if loaded.internal.len() > loaded.private.len() { - "an item defined in an internal or public crate" - } else { - "an item defined in an internal crate" - }; - return self.refuse(label, &path, required, &found); - }; - let paths = self - .surface - .facade_paths - .entry(target.krate.to_owned()) - .or_default(); - if let Some(first) = paths.get(target.path).cloned() { - let found = format!("a second facade path beside `{first}`"); - return self.refuse(label, &path, "exactly one facade path per item", &found); - } - paths.insert(target.path.to_vec(), label.clone()); - self.surface.entries.push(Entry { - label, - krate: target.krate.to_owned(), - id, - }); - } - - fn refuse(&mut self, label: String, path: &str, required: &str, found: &str) { - self.findings.push(Finding { - item: label, - mention: format!("`{path}` as its re-export target"), - required: required.to_owned(), - found: found.to_owned(), - }); - } -} - -#[cfg(test)] -#[path = "items-tests.rs"] -mod tests; diff --git a/crates/build-xtask/src/api/listing.rs b/crates/build-xtask/src/api/listing.rs deleted file mode 100644 index df6e3b219..000000000 --- a/crates/build-xtask/src/api/listing.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! The surface listing: one sorted line per surface item, including the -//! methods, fields, variants, and trait impls of every re-exported type, -//! built from the default build only because that is the surface dependents -//! get. It is committed as `public-api.txt` in the facade's crate -//! directory, so every change to the surface shows up in review as a -//! change to that file. -//! -//! Trait impls are listed compactly. Impls of `StructuralPartialEq`, -//! `TrivialClone`, and `UnsafeUnpin`, markers no consumer can depend on, are -//! left out. A type's auto traits share one line, `auto : `, -//! with `!` before each one the type lacks, and its derive-style impls -//! share another, `derives : `, each list in alphabetical -//! order. An impl whose header bounds a type parameter keeps its own line, -//! as does every other impl, so a type losing `Send` or gaining -//! `Serialize` still changes a line. - -use std::collections::BTreeSet; -use std::path::{Path, PathBuf}; - -use rustdoc_types::ItemEnum; - -use super::items::Surface; -use super::load::Facade; -use super::render::Renderer; -use super::walk::{Role, Visit}; -use compact::{Placement, Shared, placement, trait_name}; - -/// The path of `facade`'s committed listing under the workspace root. -pub(super) fn path(root: &Path, facade: &Facade) -> PathBuf { - facade - .dir - .iter() - .fold(root.to_path_buf(), |dir, part| dir.join(part)) - .join("public-api.txt") -} - -/// The listing lines for one build, sorted and deduplicated. -pub(super) fn lines(surface: &Surface, visits: &[Visit<'_>]) -> Vec { - let mut lines: BTreeSet = surface - .modules - .iter() - .filter(|(label, _)| *label != surface.root) - .map(|(label, _)| format!("pub mod {label}")) - .collect(); - let mut shared = Shared::default(); - for visit in visits { - let placed = match (visit.role, &visit.item.inner) { - (Role::Impl, ItemEnum::Impl(block)) => placement(trait_name(visit.krate, block), block), - _ => Placement::Own, - }; - let owner = visit.parent.as_str(); - match placed { - Placement::Omitted => {} - Placement::Auto { name, lacks } => { - shared.auto.entry(owner).or_default().insert(name, lacks); - } - Placement::Derives(name) => { - shared.derives.entry(owner).or_default().insert(name); - } - Placement::Own => { - if let Some(line) = Renderer::new(visit.krate, surface).line(visit) { - lines.insert(line); - } - } - } - } - lines.extend(shared.lines()); - lines.into_iter().collect() -} - -/// The listing file's text: each line newline-terminated. -pub(super) fn text(lines: &[String]) -> String { - let mut text = String::new(); - for line in lines { - text.push_str(line); - text.push('\n'); - } - text -} - -/// The report lines for how `committed` (the file's text, or `None` when -/// it is missing) differs from `lines`; empty when they match exactly. -/// Line endings are compared as `\n`, whatever the checkout wrote. -pub(super) fn difference(committed: Option<&str>, lines: &[String]) -> Vec { - let committed_text = committed.map(|text| text.replace("\r\n", "\n")); - if committed_text.as_deref() == Some(text(lines).as_str()) { - return Vec::new(); - } - let old: BTreeSet<&str> = committed_text.as_deref().unwrap_or("").lines().collect(); - let new: BTreeSet<&str> = lines.iter().map(String::as_str).collect(); - let mut report = vec![match committed { - Some(_) => "public-api.txt differs from the surface listing:".to_owned(), - None => "public-api.txt is missing; the surface listing is:".to_owned(), - }]; - report.extend(old.difference(&new).map(|line| format!("- {line}"))); - report.extend(new.difference(&old).map(|line| format!("+ {line}"))); - if report.len() == 1 { - report.push(" the same lines, not sorted or not newline-terminated".to_owned()); - } - report -} - -mod compact; - -#[cfg(test)] -mod tests; diff --git a/crates/build-xtask/src/api/listing/compact-tests.rs b/crates/build-xtask/src/api/listing/compact-tests.rs deleted file mode 100644 index 9b8d3fb1e..000000000 --- a/crates/build-xtask/src/api/listing/compact-tests.rs +++ /dev/null @@ -1,356 +0,0 @@ -//! The listing's compact impl lines, rule by rule: markers are left out, -//! auto traits and derive-style impls each share one line per type, and -//! an impl bounding a type parameter, like every other impl, keeps its -//! own line. The fixture checks all four against real rustdoc JSON. - -use rustdoc_types::{GenericBound, GenericParamDef, Id, Path, TraitBoundModifier, Type}; - -use super::super::super::fixture::{report, workspace}; -use super::*; - -fn trait_path(args: Option) -> Path { - Path { - path: String::new(), - id: Id(0), - args: args.map(Box::new), - } -} - -fn generics(params: Vec, where_predicates: Vec) -> Generics { - Generics { - params, - where_predicates, - } -} - -/// A trait impl passing the trait `args`, over `generics`. -fn impl_of(args: Option, generics: Generics, negative: bool) -> Impl { - Impl { - is_unsafe: false, - generics, - provided_trait_methods: Vec::new(), - trait_: Some(trait_path(args)), - for_: Type::Infer, - items: Vec::new(), - is_negative: negative, - is_synthetic: false, - blanket_impl: None, - } -} - -fn plain() -> Impl { - impl_of(None, generics(Vec::new(), Vec::new()), false) -} - -fn negative() -> Impl { - impl_of(None, generics(Vec::new(), Vec::new()), true) -} - -fn trait_bound() -> GenericBound { - GenericBound::TraitBound { - trait_: trait_path(None), - generic_params: Vec::new(), - modifier: TraitBoundModifier::None, - } -} - -fn type_param(bounds: Vec) -> GenericParamDef { - GenericParamDef { - name: "T".to_owned(), - kind: GenericParamDefKind::Type { - bounds, - default: None, - is_synthetic: false, - }, - } -} - -fn lifetime_param(name: &str) -> GenericParamDef { - GenericParamDef { - name: name.to_owned(), - kind: GenericParamDefKind::Lifetime { - outlives: Vec::new(), - }, - } -} - -/// `impl Trait for Type`. -fn bounded_inline() -> Impl { - impl_of( - None, - generics(vec![type_param(vec![trait_bound()])], Vec::new()), - false, - ) -} - -/// `impl Trait for Type where T: Bound`. -fn bounded_in_where() -> Impl { - let predicate = WherePredicate::BoundPredicate { - type_: Type::Generic("T".to_owned()), - bounds: vec![trait_bound()], - generic_params: Vec::new(), - }; - impl_of( - None, - generics(vec![type_param(Vec::new())], vec![predicate]), - false, - ) -} - -#[test] -fn marker_impls_are_left_out_even_when_bounded() { - for name in ["StructuralPartialEq", "TrivialClone", "UnsafeUnpin"] { - for block in [plain(), bounded_inline(), bounded_in_where()] { - assert_eq!( - placement(Some(("core", name)), &block), - Placement::Omitted, - "{name}" - ); - } - } -} - -#[test] -fn an_auto_trait_impl_joins_the_auto_line_marked_when_negative() { - for name in [ - "Freeze", - "RefUnwindSafe", - "Send", - "Sync", - "Unpin", - "UnwindSafe", - ] { - assert_eq!( - placement(Some(("core", name)), &plain()), - Placement::Auto { name, lacks: false } - ); - assert_eq!( - placement(Some(("core", name)), &negative()), - Placement::Auto { name, lacks: true } - ); - } -} - -#[test] -fn the_auto_line_names_its_traits_alphabetically_with_a_bang_on_each_one_lacking() { - let mut shared = Shared::default(); - for (name, lacks) in [ - ("UnwindSafe", false), - ("Sync", true), - ("Freeze", false), - ("Send", true), - ("Unpin", false), - ("RefUnwindSafe", false), - ] { - shared - .auto - .entry("runvil::Local") - .or_default() - .insert(name, lacks); - } - assert_eq!( - shared.lines(), - ["auto runvil::Local: Freeze, RefUnwindSafe, !Send, !Sync, Unpin, UnwindSafe"] - ); -} - -#[test] -fn a_derive_style_impl_joins_the_derives_line() { - for name in [ - "Clone", - "Copy", - "Debug", - "Default", - "Eq", - "Hash", - "Ord", - "PartialEq", - "PartialOrd", - ] { - assert_eq!( - placement(Some(("core", name)), &plain()), - Placement::Derives(name) - ); - } - for krate in ["serde", "serde_core"] { - for name in ["Deserialize", "Serialize"] { - assert_eq!( - placement(Some((krate, name)), &plain()), - Placement::Derives(name), - "{krate}" - ); - } - } - let deserialize = impl_of( - Some(GenericArgs::AngleBracketed { - args: vec![GenericArg::Lifetime("'de".to_owned())], - constraints: Vec::new(), - }), - generics(vec![lifetime_param("'de")], Vec::new()), - false, - ); - assert_eq!( - placement(Some(("serde_core", "Deserialize")), &deserialize), - Placement::Derives("Deserialize"), - "a lifetime is no type parameter" - ); -} - -#[test] -fn the_derives_line_names_its_traits_alphabetically() { - let mut shared = Shared::default(); - for name in [ - "Serialize", - "PartialOrd", - "Clone", - "Deserialize", - "PartialEq", - "Default", - "Copy", - "Ord", - "Debug", - "Hash", - "Eq", - ] { - shared - .derives - .entry("runvil::Plain") - .or_default() - .insert(name); - } - assert_eq!( - shared.lines(), - [ - "derives runvil::Plain: Clone, Copy, Debug, Default, Deserialize, Eq, Hash, \ - Ord, PartialEq, PartialOrd, Serialize" - ] - ); -} - -#[test] -fn an_impl_bounding_a_type_parameter_keeps_its_own_line() { - for block in [bounded_inline(), bounded_in_where()] { - for name in ["Clone", "Send"] { - assert_eq!( - placement(Some(("core", name)), &block), - Placement::Own, - "{name}" - ); - } - } - let unbounded = impl_of( - None, - generics( - vec![type_param(Vec::new()), lifetime_param("'a")], - vec![WherePredicate::LifetimePredicate { - lifetime: "'a".to_owned(), - outlives: vec!["'static".to_owned()], - }], - ), - false, - ); - assert_eq!( - placement(Some(("core", "Send")), &unbounded), - Placement::Auto { - name: "Send", - lacks: false - }, - "an unbounded parameter and a lifetime bound bound no type parameter" - ); -} - -#[test] -fn every_other_impl_keeps_its_own_line() { - for name in ["Display", "Error", "From"] { - assert_eq!( - placement(Some(("core", name)), &plain()), - Placement::Own, - "{name}" - ); - } - let partial_eq_str = impl_of( - Some(GenericArgs::AngleBracketed { - args: vec![GenericArg::Type(Type::Primitive("str".to_owned()))], - constraints: Vec::new(), - }), - generics(Vec::new(), Vec::new()), - false, - ); - assert_eq!( - placement(Some(("core", "PartialEq")), &partial_eq_str), - Placement::Own, - "an impl relating the type to another is no derive" - ); - assert_eq!( - placement(Some(("core", "Clone")), &negative()), - Placement::Own - ); - for name in ["Clone", "Send", "TrivialClone", "UnsafeUnpin"] { - assert_eq!( - placement(Some(("runvil_inner", name)), &plain()), - Placement::Own, - "a same-named trait outside core: {name}" - ); - } - assert_eq!(placement(None, &plain()), Placement::Own); -} - -const INNER: &str = "//! Inner.\n\n\ - use std::fmt;\n\n\ - /// Plain data.\n\ - #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\n\ - pub struct Plain;\n\n\ - impl fmt::Display for Plain {\n \ - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n \ - f.write_str(\"plain\")\n \ - }\n\ - }\n\n\ - impl PartialEq for Plain {\n \ - fn eq(&self, _other: &str) -> bool {\n \ - false\n \ - }\n\ - }\n\n\ - impl serde::Serialize for Plain {}\n\n\ - /// Bound to one thread.\n\ - pub struct Local(*const ());\n\n\ - /// Holds anything.\n\ - #[derive(Clone)]\n\ - pub struct Wrapper(pub T);\n"; - -const FACADE: &str = "//! Facade.\npub use runvil_inner::Local;\n\ - pub use runvil_inner::Plain;\npub use runvil_inner::Wrapper;\n"; - -#[test] -#[ignore = "needs the pinned nightly"] -fn the_listing_drops_markers_and_collapses_auto_and_derive_style_impls() { - let root = workspace(INNER, FACADE); - let listing = report(root.path()).expect("the fixture documents").listing; - let wrapper_auto = ["Freeze", "Send", "Sync", "Unpin"].map(|name| { - format!("impl core::marker::{name} for runvil::Wrapper where T: core::marker::{name}") - }); - let wrapper_unwind = ["RefUnwindSafe", "UnwindSafe"].map(|name| { - format!( - "impl core::panic::unwind_safe::{name} for runvil::Wrapper where T: \ - core::panic::unwind_safe::{name}" - ) - }); - let mut expected: Vec = [ - "auto runvil::Local: Freeze, RefUnwindSafe, !Send, !Sync, Unpin, UnwindSafe", - "auto runvil::Plain: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe", - "derives runvil::Plain: Clone, Copy, Debug, Default, Eq, PartialEq, Serialize", - "impl core::cmp::PartialEq for runvil::Plain", - "impl core::fmt::Display for runvil::Plain", - "impl core::clone::Clone for runvil::Wrapper", - "pub runvil::Wrapper::0: T", - "pub struct runvil::Local(/* private fields */)", - "pub struct runvil::Plain;", - "pub struct runvil::Wrapper(..)", - ] - .map(str::to_owned) - .into_iter() - .chain(wrapper_auto) - .chain(wrapper_unwind) - .collect(); - expected.sort(); - assert_eq!(listing, expected); -} diff --git a/crates/build-xtask/src/api/listing/compact.rs b/crates/build-xtask/src/api/listing/compact.rs deleted file mode 100644 index bb559c830..000000000 --- a/crates/build-xtask/src/api/listing/compact.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Where the listing places each trait impl: left out, on its type's -//! shared `auto` or `derives` line, or on a line of its own. - -use std::collections::{BTreeMap, BTreeSet}; - -use rustdoc_types::{ - Crate, GenericArg, GenericArgs, GenericParamDefKind, Generics, Impl, WherePredicate, -}; - -use super::super::items::target; - -/// The `core` markers whose impls the listing leaves out: no consumer can -/// depend on them, and the nightly-only `TrivialClone` and `UnsafeUnpin` -/// would churn whenever the pinned nightly moves. -const OMITTED: [&str; 3] = ["StructuralPartialEq", "TrivialClone", "UnsafeUnpin"]; - -/// The `core` auto traits a type's `auto` line names. -const AUTO: [&str; 6] = [ - "Freeze", - "RefUnwindSafe", - "Send", - "Sync", - "Unpin", - "UnwindSafe", -]; - -/// The `core` traits a type's `derives` line names. -const CORE_DERIVES: [&str; 9] = [ - "Clone", - "Copy", - "Debug", - "Default", - "Eq", - "Hash", - "Ord", - "PartialEq", - "PartialOrd", -]; - -/// The serde traits a type's `derives` line names. `serde` defines them in -/// `serde_core` and re-exports them, and rustdoc names the defining crate. -const SERDE_DERIVES: [&str; 2] = ["Deserialize", "Serialize"]; - -/// Where the listing shows one visited item. -#[derive(Debug, PartialEq, Eq)] -pub(super) enum Placement<'a> { - /// Nowhere. - Omitted, - /// On its type's `auto` line, with `!` when the type `lacks` it. - Auto { name: &'a str, lacks: bool }, - /// On its type's `derives` line. - Derives(&'a str), - /// On a line of its own. - Own, -} - -/// The defining crate and name of the trait `block` implements, or `None` -/// for an inherent impl or a trait rustdoc could not resolve. -pub(super) fn trait_name<'a>(krate: &'a Crate, block: &Impl) -> Option<(&'a str, &'a str)> { - let found = target(krate, block.trait_.as_ref()?.id)?; - Some((found.krate, found.path.last()?.as_str())) -} - -/// Where the listing shows `block`, an impl of the trait `trait_` names -/// by its defining crate and name. -pub(super) fn placement<'a>(trait_: Option<(&str, &'a str)>, block: &Impl) -> Placement<'a> { - let Some((krate, name)) = trait_ else { - return Placement::Own; - }; - let core = krate == "core"; - if core && OMITTED.contains(&name) { - return Placement::Omitted; - } - let trait_args = block.trait_.as_ref().and_then(|path| path.args.as_deref()); - if bounds_a_type_parameter(&block.generics) || passes_a_type(trait_args) { - return Placement::Own; - } - if core && AUTO.contains(&name) { - return Placement::Auto { - name, - lacks: block.is_negative, - }; - } - let derive = (core && CORE_DERIVES.contains(&name)) - || (matches!(krate, "serde" | "serde_core") && SERDE_DERIVES.contains(&name)); - if derive && !block.is_negative { - Placement::Derives(name) - } else { - Placement::Own - } -} - -/// Whether `generics` bound a type parameter, inline or in a `where` -/// clause, so the impl holds only for some of the type's arguments. -fn bounds_a_type_parameter(generics: &Generics) -> bool { - let inline = generics.params.iter().any(|param| { - matches!(¶m.kind, GenericParamDefKind::Type { bounds, .. } if !bounds.is_empty()) - }); - inline - || generics - .where_predicates - .iter() - .any(|predicate| !matches!(predicate, WherePredicate::LifetimePredicate { .. })) -} - -/// Whether a trait's `args` pass it anything but lifetimes, as -/// `PartialEq` does and `Deserialize<'de>` does not: such an impl -/// relates the type to another, which a derive never does. -fn passes_a_type(args: Option<&GenericArgs>) -> bool { - match args { - None => false, - Some(GenericArgs::AngleBracketed { args, constraints }) => { - !constraints.is_empty() - || args - .iter() - .any(|arg| !matches!(arg, GenericArg::Lifetime(_))) - } - Some(_) => true, - } -} - -/// The impls that share their type's `auto` and `derives` lines, keyed by -/// the type's facade path. -#[derive(Debug, Default)] -pub(super) struct Shared<'a> { - /// Each type's auto traits, each with whether the type lacks it. - pub(super) auto: BTreeMap<&'a str, BTreeMap<&'a str, bool>>, - pub(super) derives: BTreeMap<&'a str, BTreeSet<&'a str>>, -} - -impl Shared<'_> { - /// One `auto` line and one `derives` line per type that has any, each - /// naming its traits in alphabetical order. - pub(super) fn lines(&self) -> Vec { - let auto = self.auto.iter().map(|(owner, traits)| { - let traits: Vec = traits - .iter() - .map(|(name, lacks)| format!("{}{name}", if *lacks { "!" } else { "" })) - .collect(); - format!("auto {owner}: {}", traits.join(", ")) - }); - let derives = self.derives.iter().map(|(owner, traits)| { - let traits: Vec<&str> = traits.iter().copied().collect(); - format!("derives {owner}: {}", traits.join(", ")) - }); - auto.chain(derives).collect() - } -} - -#[cfg(test)] -#[path = "compact-tests.rs"] -mod tests; diff --git a/crates/build-xtask/src/api/listing/tests.rs b/crates/build-xtask/src/api/listing/tests.rs deleted file mode 100644 index c6993f89b..000000000 --- a/crates/build-xtask/src/api/listing/tests.rs +++ /dev/null @@ -1,284 +0,0 @@ -//! The listing's comparison with `public-api.txt`, and the snapshot -//! workflow end to end: a difference fails `--check`, `--bless` writes -//! the listing and refuses while a violation remains, and a violation -//! alone fails `--check` while a plain run only prints it. -//! -//! The notation fixtures pin what a data line says beyond its path: the -//! `#[non_exhaustive]` prefix, the kind suffix, and the private-fields -//! marker, each read from the rustdoc JSON the pinned nightly writes. - -use super::super::Mode; -use super::super::fixture::{FIXTURE, execute, leak, report, workspace, write}; -use super::*; - -fn owned(lines: &[&str]) -> Vec { - lines.iter().map(|line| (*line).to_owned()).collect() -} - -#[test] -fn a_matching_listing_has_no_difference_whatever_its_line_endings() { - let lines = owned(&["pub fn runvil::a()", "pub struct runvil::B"]); - assert!(difference(Some(&text(&lines)), &lines).is_empty()); - assert!( - difference( - Some("pub fn runvil::a()\r\npub struct runvil::B\r\n"), - &lines - ) - .is_empty() - ); -} - -#[test] -fn a_changed_listing_reports_removed_then_added_lines() { - let lines = owned(&["pub fn runvil::a()", "pub struct runvil::C"]); - assert_eq!( - difference(Some("pub fn runvil::a()\npub struct runvil::B\n"), &lines), - owned(&[ - "public-api.txt differs from the surface listing:", - "- pub struct runvil::B", - "+ pub struct runvil::C", - ]) - ); -} - -#[test] -fn a_missing_listing_reports_every_line_as_added() { - let lines = owned(&["pub struct runvil::B"]); - assert_eq!( - difference(None, &lines), - owned(&[ - "public-api.txt is missing; the surface listing is:", - "+ pub struct runvil::B" - ]) - ); -} - -#[test] -fn an_unsorted_listing_with_the_same_lines_still_differs() { - let lines = owned(&["pub fn runvil::a()", "pub struct runvil::B"]); - let report = difference(Some("pub struct runvil::B\npub fn runvil::a()\n"), &lines); - assert_eq!(report.len(), 2, "{report:?}"); - assert!(report[1].contains("not sorted"), "{report:?}"); -} - -const INNER: &str = - "//! Inner.\n\n/// Exposed.\npub struct Visible {\n /// A count.\n pub count: u8,\n}\n"; - -const LEAKING_INNER: &str = "//! Inner.\n\n/// Kept internal.\npub struct Secret;\n\n\ - /// Leaks it.\npub fn make() -> Secret {\n Secret\n}\n"; - -const LEAKING_FACADE: &str = "//! Facade.\npub use runvil_inner::make;\n"; - -#[test] -#[ignore = "needs the pinned nightly"] -fn a_snapshot_difference_fails_check_until_blessed() { - let root = workspace(INNER, "//! Facade.\npub use runvil_inner::Visible;\n"); - let first = execute(root.path(), Mode::Check).expect("the fixture documents"); - assert!(first.failed, "{:?}", first.lines); - assert!( - first - .lines - .contains(&"public-api.txt is missing; the surface listing is:".to_owned()) - ); - assert!( - first - .lines - .contains(&"+ pub struct runvil::Visible { .. }".to_owned()) - ); - assert!( - first - .lines - .contains(&"+ pub runvil::Visible::count: u8".to_owned()) - ); - let blessed = execute(root.path(), Mode::Bless).expect("the fixture documents"); - assert!(!blessed.failed, "{:?}", blessed.lines); - let clean = execute(root.path(), Mode::Check).expect("the fixture documents"); - assert!(!clean.failed, "{:?}", clean.lines); - assert_eq!( - clean.lines, - ["api: runvil: 0 violations; the listing matches public-api.txt"] - ); - write( - root.path(), - "crates/runvil-internal/inner/src/lib.rs", - &format!( - "{INNER}\nimpl Visible {{\n /// Doubles it.\n pub fn double(&self) -> u16 {{\n u16::from(self.count) * 2\n }}\n}}\n" - ), - ); - let changed = execute(root.path(), Mode::Check).expect("the fixture documents"); - assert!(changed.failed, "{:?}", changed.lines); - assert!( - changed - .lines - .contains(&"+ pub fn runvil::Visible::double(&self) -> u16".to_owned()), - "{:?}", - changed.lines - ); -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn bless_refuses_while_a_violation_remains() { - let root = workspace(LEAKING_INNER, LEAKING_FACADE); - let outcome = execute(root.path(), Mode::Bless).expect("the fixture documents"); - assert!(outcome.failed, "{:?}", outcome.lines); - assert_eq!( - outcome.lines.last().map(String::as_str), - Some("api: runvil: bless refused: 1 violations remain") - ); - assert!(!path(root.path(), &FIXTURE).exists()); -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn a_violation_alone_fails_check_and_a_plain_run_only_prints_it() { - let root = workspace(LEAKING_INNER, LEAKING_FACADE); - let listing = report(root.path()).expect("the fixture documents").listing; - std::fs::write(path(root.path(), &FIXTURE), text(&listing)).expect("the listing writes"); - let leak = leak("runvil_inner"); - let expected = [ - format!("runvil::make: mentions `runvil_inner::Secret` in its signature: {leak}"), - "api: runvil: 1 violations; the listing matches public-api.txt".to_owned(), - ]; - let check = execute(root.path(), Mode::Check).expect("the fixture documents"); - assert!(check.failed, "{:?}", check.lines); - assert_eq!(check.lines, expected); - let plain = execute(root.path(), Mode::Report).expect("the fixture documents"); - assert!(!plain.failed, "{:?}", plain.lines); - assert_eq!(plain.lines, expected); -} - -const SHAPES: &str = "//! Inner.\n\n\ - /// A unit struct.\npub struct AllowAll;\n\n\ - /// A tuple struct whose field is private.\npub struct ExecId(u64);\n\n\ - /// A braced struct with every field public.\n\ - pub struct Open {\n /// A count.\n pub count: u8,\n}\n\n\ - /// A braced struct with a private field.\n\ - pub struct Guarded {\n /// A count.\n pub count: u8,\n seed: u64,\n}\n\n\ - /// A union with a private field.\n\ - pub union Word {\n /// The bits.\n pub bits: u32,\n seed: f32,\n}\n"; - -const SHAPES_FACADE: &str = "//! Facade.\n\ - pub use runvil_inner::AllowAll;\npub use runvil_inner::ExecId;\n\ - pub use runvil_inner::Guarded;\npub use runvil_inner::Open;\n\ - pub use runvil_inner::Word;\n"; - -const VARIANTS: &str = "//! Inner.\n\n\ - /// An exhaustive enum.\n\ - pub enum Verdict {\n /// A unit variant.\n Allow,\n /// A tuple variant.\n Deny(u8),\n /// A struct variant.\n Ask {\n /// A reason.\n reason: u8,\n },\n}\n\n\ - /// A non-exhaustive enum.\n#[non_exhaustive]\n\ - pub enum VfsError {\n /// A non-exhaustive struct variant.\n #[non_exhaustive]\n Missing {\n /// A code.\n code: u8,\n },\n /// A unit variant.\n Denied,\n}\n\n\ - /// An enum whose variant has a discriminant.\n\ - pub enum Code {\n /// The first.\n First = 1,\n}\n"; - -const VARIANTS_FACADE: &str = "//! Facade.\n\ - pub use runvil_inner::Code;\npub use runvil_inner::Verdict;\n\ - pub use runvil_inner::VfsError;\n"; - -const UNIT: &str = "//! Inner.\n\n/// A unit struct.\npub struct AllowAll;\n"; - -const UNIT_FACADE: &str = "//! Facade.\npub use runvil_inner::AllowAll;\n"; - -/// The surface listing of the fixture workspace built from `inner` and -/// `facade`, as a set to test membership against. -fn listing(inner: &str, facade: &str) -> BTreeSet { - let root = workspace(inner, facade); - report(root.path()) - .expect("the fixture documents") - .listing - .into_iter() - .collect() -} - -fn assert_lines(listing: &BTreeSet, shown: &[&str], absent: &[&str]) { - for line in shown { - assert!( - listing.contains(*line), - "{line} is missing from {listing:?}" - ); - } - for line in absent { - assert!(!listing.contains(*line), "{line} is still in {listing:?}"); - } -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn a_struct_or_union_line_ends_with_its_kind_and_names_hidden_fields() { - let listing = listing(SHAPES, SHAPES_FACADE); - assert_lines( - &listing, - &[ - "pub struct runvil::AllowAll;", - "pub struct runvil::ExecId(/* private fields */)", - "pub struct runvil::Open { .. }", - "pub struct runvil::Guarded { /* private fields */ }", - "pub union runvil::Word { /* private fields */ }", - "pub runvil::Guarded::count: u8", - ], - &[ - "pub struct runvil::AllowAll", - "pub struct runvil::ExecId", - "pub struct runvil::Open", - "pub struct runvil::Guarded", - "pub union runvil::Word", - ], - ); -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn an_enum_or_variant_line_carries_non_exhaustive_and_its_variant_kind() { - let listing = listing(VARIANTS, VARIANTS_FACADE); - assert_lines( - &listing, - &[ - "pub enum runvil::Verdict", - "pub runvil::Verdict::Allow", - "pub runvil::Verdict::Deny(..)", - "pub runvil::Verdict::Ask { .. }", - "#[non_exhaustive] pub enum runvil::VfsError", - "#[non_exhaustive] pub runvil::VfsError::Missing { .. }", - "pub runvil::VfsError::Denied", - "pub runvil::Code::First = 1", - ], - &[ - "pub enum runvil::VfsError", - "pub runvil::VfsError::Missing { .. }", - "#[non_exhaustive] pub enum runvil::Verdict", - "#[non_exhaustive] pub runvil::VfsError::Denied", - ], - ); -} - -#[test] -#[ignore = "needs the pinned nightly"] -fn marking_a_blessed_unit_struct_non_exhaustive_fails_check() { - let root = workspace(UNIT, UNIT_FACADE); - let blessed = execute(root.path(), Mode::Bless).expect("the fixture documents"); - assert!(!blessed.failed, "{:?}", blessed.lines); - write( - root.path(), - "crates/runvil-internal/inner/src/lib.rs", - &UNIT.replace( - "pub struct AllowAll;", - "#[non_exhaustive]\npub struct AllowAll;", - ), - ); - let changed = execute(root.path(), Mode::Check).expect("the fixture documents"); - assert!(changed.failed, "{:?}", changed.lines); - assert!( - changed - .lines - .contains(&"- pub struct runvil::AllowAll;".to_owned()), - "{:?}", - changed.lines - ); - assert!( - changed - .lines - .contains(&"+ #[non_exhaustive] pub struct runvil::AllowAll;".to_owned()), - "{:?}", - changed.lines - ); -} diff --git a/crates/build-xtask/src/api/load-tests.rs b/crates/build-xtask/src/api/load-tests.rs deleted file mode 100644 index 4cc416260..000000000 --- a/crates/build-xtask/src/api/load-tests.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! Internal crate discovery and rustdoc JSON from another nightly. - -use super::*; - -#[test] -fn internal_packages_are_every_container_crate_by_package_name_sorted() { - let root = tempfile::TempDir::new().expect("tempdir"); - crate::product::test_support::write_crate( - root.path(), - "runvil-internal/zeta", - "runvil-zeta", - "", - ); - crate::product::test_support::write_crate( - root.path(), - "runvil-internal/alpha", - "runvil-alpha", - "", - ); - assert_eq!( - internal_packages(root.path(), "runvil-internal"), - Ok(vec!["runvil-alpha".to_owned(), "runvil-zeta".to_owned()]) - ); -} - -#[test] -fn an_internal_manifest_without_a_package_name_is_an_error() { - let root = tempfile::TempDir::new().expect("tempdir"); - let dir = root - .path() - .join("crates") - .join("runvil-internal") - .join("broken"); - std::fs::create_dir_all(&dir).expect("the crate directory creates"); - std::fs::write(dir.join("Cargo.toml"), "[workspace]\n").expect("the manifest writes"); - let error = - internal_packages(root.path(), "runvil-internal").expect_err("an unnamed crate is refused"); - assert!(error.ends_with("no readable package name"), "{error}"); -} - -#[test] -fn json_in_another_format_version_names_the_pinned_nightly() { - let doc = tempfile::TempDir::new().expect("tempdir"); - std::fs::write(doc.path().join("stale.json"), "{\"format_version\": 1}") - .expect("the JSON writes"); - let error = read_crate(doc.path(), "stale").expect_err("another format is refused"); - assert!( - error.contains(&format!( - "required rustdoc JSON format_version {FORMAT_VERSION} (from `{}`, read by \ - rustdoc-types {}), found 1", - PINNED.nightly, PINNED.rustdoc_types - )), - "{error}" - ); -} diff --git a/crates/build-xtask/src/api/load.rs b/crates/build-xtask/src/api/load.rs deleted file mode 100644 index a94e31414..000000000 --- a/crates/build-xtask/src/api/load.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! Builds rustdoc JSON for a facade, every crate under its containers, -//! and the public crates it passes on, with default features, and parses -//! it. -//! -//! One `cargo doc` invocation selects the facade and every internal -//! crate, so each internal crate is documented with the features the -//! facade activates. Selecting an internal crate as a root also turns on -//! its `default` feature, which the facade's own graph turns on too, -//! since no internal crate declares one. The JSON lands under -//! `target/xtask-api/`. - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use rustdoc_types::{Crate, FORMAT_VERSION}; - -use super::toolchain::PINNED; - -/// One facade the command checks. -#[derive(Debug)] -pub(super) struct Facade { - /// The package `cargo doc -p` takes. Its crate name swaps `-` for `_`. - pub(super) package: &'static str, - /// The crate directory, relative to the workspace root, which holds - /// `public-api.txt`. - pub(super) dir: [&'static str; 2], - /// The containers under `crates/` whose crates may define a - /// re-exported item. Surface docs may name none of those crates. - pub(super) containers: &'static [&'static str], - /// The packages outside every container that may define a - /// re-exported item too: the public crates the facade passes on. - pub(super) public: &'static [&'static str], - /// The crates the surface may mention beside the shared allowlist, by - /// crate name. - pub(super) allowed: &'static [&'static str], -} - -impl Facade { - /// The crate name, which roots every facade path. - pub(super) fn crate_name(&self) -> String { - self.package.replace('-', "_") - } -} - -/// Every facade the command checks, in the order it reports them: the -/// Engine's, then the one a Host depends on. The second passes on the -/// Engine's items, which rustdoc names by the crate that defines them, so -/// its containers are the Harness's and the Engine's, and it passes on -/// the Plugin contract's items too. `insert_plugin` takes the C contract's -/// entry point, a type of a crate in another repository. -pub(super) const FACADES: [Facade; 2] = [ - Facade { - package: "runvil-engine", - dir: crate::facade_shape::FACADE_DIR, - containers: &[crate::engine_guards::ENGINE_CONTAINER], - public: &[], - allowed: &[], - }, - Facade { - package: "runvil", - dir: crate::facade_shape::FACADE_DIRS[1], - containers: &[ - crate::doc_hidden::HARNESS_CONTAINER, - crate::engine_guards::ENGINE_CONTAINER, - ], - public: &["runvil-plugin"], - allowed: &["runvil_plugin_sys"], - }, -]; - -/// The rustdoc flags every build passes. Hidden items are documented -/// because dependents can still name them, and the transport codec is -/// hidden in its defining crate. Lints are capped at warn: documenting -/// hidden items lints docs the workspace docs gate never renders, and -/// link integrity is that gate's to deny - an unresolved link never -/// reaches the JSON `links` table this command reads. -const RUSTDOC_FLAGS: [&str; 6] = [ - "-Zunstable-options", - "--output-format", - "json", - "--document-hidden-items", - "--cap-lints", - "warn", -]; - -/// The parsed rustdoc JSON of a facade and its internal crates. -#[derive(Debug)] -pub(super) struct Loaded { - /// The facade's crate name. - pub(super) name: String, - /// The facade crate. - pub(super) facade: Crate, - /// Every crate that may define a re-exported item, keyed by its crate - /// name (`runvil_runtime`): the container crates and the public - /// crates. - pub(super) internal: BTreeMap, - /// The container crates' names, which surface docs may not hold. - pub(super) private: BTreeSet, - /// The crates the surface may mention beside the shared allowlist. - pub(super) allowed: &'static [&'static str], -} - -/// The package names of every crate under `container`, sorted. -/// A manifest that cannot be read or names no package is an error: a -/// crate the check cannot name cannot be documented or matched. -fn internal_packages(root: &Path, container: &str) -> Result, String> { - let container = root.join("crates").join(container); - let mut dirs = Vec::new(); - crate::engine_guards::collect_crates(&container, &mut dirs); - let mut packages = Vec::new(); - for dir in dirs { - let manifest = dir.join("Cargo.toml"); - let name = std::fs::read_to_string(&manifest) - .ok() - .and_then(|text| toml::from_str::(&text).ok()) - .and_then(|value| { - value - .get("package") - .and_then(|package| package.get("name")) - .and_then(toml::Value::as_str) - .map(str::to_owned) - }) - .ok_or_else(|| format!("{}: no readable package name", manifest.display()))?; - packages.push(name); - } - if packages.is_empty() { - return Err(format!("{}: no internal crates found", container.display())); - } - packages.sort(); - Ok(packages) -} - -/// Builds and parses the rustdoc JSON of `facade` and its internal crates -/// in the workspace at `root`. -pub(super) fn load(root: &Path, facade: &Facade) -> Result { - let mut private = Vec::new(); - for container in facade.containers { - private.extend(internal_packages(root, container)?); - } - let mut packages = private.clone(); - packages.extend(facade.public.iter().map(|package| (*package).to_owned())); - packages.sort(); - packages.dedup(); - let target = root.join("target").join("xtask-api"); - run_cargo_doc(root, &target, facade.package, &packages)?; - let doc = target.join("doc"); - let name = facade.crate_name(); - let krate = read_crate(&doc, &name)?; - let mut internal = BTreeMap::new(); - for package in &packages { - let name = package.replace('-', "_"); - let krate = read_crate(&doc, &name)?; - internal.insert(name, krate); - } - Ok(Loaded { - name, - facade: krate, - internal, - private: private - .iter() - .map(|package| package.replace('-', "_")) - .collect(), - allowed: facade.allowed, - }) -} - -/// A cargo command in `root` on the pinned nightly: the cargo that runs -/// this process when there is one, with rustup's proxies told the pinned -/// toolchain so the rustc and rustdoc it spawns match. -pub(super) fn cargo(root: &Path) -> Command { - let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); - let mut command = Command::new(cargo); - command - .current_dir(root) - .env("RUSTUP_TOOLCHAIN", PINNED.nightly); - command -} - -fn run_cargo_doc( - root: &Path, - target: &Path, - facade: &str, - packages: &[String], -) -> Result<(), String> { - let mut command = cargo(root); - command - .env("CARGO_ENCODED_RUSTDOCFLAGS", RUSTDOC_FLAGS.join("\u{1f}")) - .env_remove("RUSTDOCFLAGS") - .args(["doc", "--locked", "--no-deps", "--lib", "--target-dir"]) - .arg(target) - .args(["-p", facade]); - for package in packages { - command.args(["-p", package]); - } - let output = command - .output() - .map_err(|error| format!("cannot run cargo: {error}"))?; - if output.status.success() { - return Ok(()); - } - let stderr = String::from_utf8_lossy(&output.stderr); - let tail: Vec<&str> = stderr.lines().rev().take(40).collect(); - Err(format!( - "`cargo doc` for rustdoc JSON failed ({}):\n{}", - output.status, - tail.into_iter().rev().collect::>().join("\n") - )) -} - -/// Parses `/.json`. A file in another `format_version` is -/// reported as that, naming the pinned nightly, rather than as whatever -/// field the parse tripped on first. -fn read_crate(doc: &Path, name: &str) -> Result { - let path: PathBuf = doc.join(format!("{name}.json")); - let text = std::fs::read_to_string(&path) - .map_err(|error| format!("{}: unreadable rustdoc JSON: {error}", path.display()))?; - let wrong_version = |found: u64| { - format!( - "{}: required rustdoc JSON format_version {FORMAT_VERSION} (from `{}`, read by \ - rustdoc-types {}), found {found}", - path.display(), - PINNED.nightly, - PINNED.rustdoc_types - ) - }; - let krate = serde_json::from_str::(&text).map_err(|error| { - let version = serde_json::from_str::(&text) - .ok() - .and_then(|value| { - value - .get("format_version") - .and_then(serde_json::Value::as_u64) - }); - match version { - Some(found) if found != u64::from(FORMAT_VERSION) => wrong_version(found), - _ => format!("{}: unparseable rustdoc JSON: {error}", path.display()), - } - })?; - if krate.format_version != FORMAT_VERSION { - return Err(wrong_version(krate.format_version.into())); - } - Ok(krate) -} - -#[cfg(test)] -#[path = "load-tests.rs"] -mod tests; diff --git a/crates/build-xtask/src/api/render-types.rs b/crates/build-xtask/src/api/render-types.rs deleted file mode 100644 index 54cce1b4c..000000000 --- a/crates/build-xtask/src/api/render-types.rs +++ /dev/null @@ -1,356 +0,0 @@ -//! Type-level rendering for [`Renderer`]: types, paths and their generic -//! arguments, bounds, generic parameters, `where` clauses, and function -//! signatures. Every resolved path is recorded as a mention on the way. - -use rustdoc_types::{ - Abi, DynTrait, FunctionHeader, FunctionPointer, FunctionSignature, GenericArg, GenericArgs, - GenericBound, GenericParamDef, GenericParamDefKind, Generics, Path, PreciseCapturingArg, Term, - TraitBoundModifier, Type, WherePredicate, -}; - -use super::super::items::target; -use super::{Mention, Renderer}; - -impl Renderer<'_> { - /// `: A + B`, or nothing when there are no bounds. - pub(super) fn colon_bounds(&mut self, bounds: &[GenericBound]) -> String { - let bounds = self.bounds(bounds); - if bounds.is_empty() { - bounds - } else { - format!(": {bounds}") - } - } - - /// ` = T`, or nothing when there is no type. - pub(super) fn value(&mut self, ty: Option<&Type>) -> String { - ty.map_or_else(String::new, |ty| format!(" = {}", self.ty(ty))) - } - - pub(super) fn signature(&mut self, signature: &FunctionSignature, named: bool) -> String { - let mut inputs = Vec::new(); - for (name, ty) in &signature.inputs { - inputs.push(match (name.as_str(), ty) { - ("self", Type::Generic(generic)) if generic == "Self" => "self".to_owned(), - ( - "self", - Type::BorrowedRef { - lifetime, - is_mutable, - type_, - }, - ) if matches!(type_.as_ref(), Type::Generic(generic) if generic == "Self") => { - format!( - "&{}{}self", - lifetime_prefix(lifetime.as_ref()), - mutable(*is_mutable) - ) - } - (name, ty) if named => format!("{name}: {}", self.ty(ty)), - (_, ty) => self.ty(ty), - }); - } - if signature.is_c_variadic { - inputs.push("...".to_owned()); - } - let output = match &signature.output { - Some(ty) => format!(" -> {}", self.ty(ty)), - None => String::new(), - }; - format!("({}){output}", inputs.join(", ")) - } - - pub(super) fn ty(&mut self, ty: &Type) -> String { - match ty { - Type::ResolvedPath(path) => self.path(path), - Type::DynTrait(dyn_trait) => self.dyn_trait(dyn_trait), - Type::Generic(name) | Type::Primitive(name) => name.clone(), - Type::FunctionPointer(pointer) => self.fn_pointer(pointer), - Type::Tuple(types) if types.len() == 1 => format!("({},)", self.list(types)), - Type::Tuple(types) => format!("({})", self.list(types)), - Type::Slice(inner) => format!("[{}]", self.ty(inner)), - Type::Array { type_, len } => format!("[{}; {len}]", self.ty(type_)), - Type::Pat { type_, .. } => format!("{} is ..", self.ty(type_)), - Type::ImplTrait(bounds) => format!("impl {}", self.bounds(bounds)), - Type::Infer => "_".to_owned(), - Type::RawPointer { is_mutable, type_ } => { - let kind = if *is_mutable { "mut" } else { "const" }; - format!("*{kind} {}", self.ty(type_)) - } - Type::BorrowedRef { - lifetime, - is_mutable, - type_, - } => format!( - "&{}{}{}", - lifetime_prefix(lifetime.as_ref()), - mutable(*is_mutable), - self.ty(type_) - ), - Type::QualifiedPath { - name, - args, - self_type, - trait_, - } => { - let self_type = self.ty(self_type); - let args = args - .as_ref() - .map_or_else(String::new, |args| self.args(args)); - match trait_ { - Some(path) => format!("<{self_type} as {}>::{name}{args}", self.path(path)), - None => format!("{self_type}::{name}{args}"), - } - } - } - } - - fn list(&mut self, types: &[Type]) -> String { - types - .iter() - .map(|ty| self.ty(ty)) - .collect::>() - .join(", ") - } - - pub(super) fn path(&mut self, path: &Path) -> String { - let resolved = target(self.krate, path.id); - let shown = match &resolved { - Some(found) => match self.surface.facade_path(found.krate, found.path) { - Some(facade) => facade.to_owned(), - None => found.path.join("::"), - }, - None => path.path.clone(), - }; - self.mentions.push(Mention { - context: self.context, - target: resolved.map(|found| (found.krate.to_owned(), found.path.to_vec())), - written: path.path.clone(), - }); - match &path.args { - Some(args) => format!("{shown}{}", self.args(args)), - None => shown, - } - } - - fn args(&mut self, args: &GenericArgs) -> String { - match args { - GenericArgs::AngleBracketed { args, constraints } => { - let mut parts: Vec = args.iter().map(|arg| self.arg(arg)).collect(); - for constraint in constraints { - let args = constraint - .args - .as_ref() - .map_or_else(String::new, |args| self.args(args)); - let binding = match &constraint.binding { - rustdoc_types::AssocItemConstraintKind::Equality(term) => { - format!(" = {}", self.term(term)) - } - rustdoc_types::AssocItemConstraintKind::Constraint(bounds) => { - format!(": {}", self.bounds(bounds)) - } - }; - parts.push(format!("{}{args}{binding}", constraint.name)); - } - if parts.is_empty() { - String::new() - } else { - format!("<{}>", parts.join(", ")) - } - } - GenericArgs::Parenthesized { inputs, output } => { - let inputs = self.list(inputs); - let output = output - .as_ref() - .map_or_else(String::new, |ty| format!(" -> {}", self.ty(ty))); - format!("({inputs}){output}") - } - GenericArgs::ReturnTypeNotation => "(..)".to_owned(), - } - } - - fn arg(&mut self, arg: &GenericArg) -> String { - match arg { - GenericArg::Lifetime(lifetime) => lifetime.clone(), - GenericArg::Type(ty) => self.ty(ty), - GenericArg::Const(constant) => constant.expr.clone(), - GenericArg::Infer => "_".to_owned(), - } - } - - fn term(&mut self, term: &Term) -> String { - match term { - Term::Type(ty) => self.ty(ty), - Term::Constant(constant) => constant.expr.clone(), - } - } - - fn bounds(&mut self, bounds: &[GenericBound]) -> String { - let mut parts = Vec::new(); - for bound in bounds { - parts.push(match bound { - GenericBound::TraitBound { - trait_, - generic_params, - modifier, - } => { - let binder = self.binder(generic_params); - let modifier = match modifier { - TraitBoundModifier::None => "", - TraitBoundModifier::Maybe => "?", - TraitBoundModifier::MaybeConst => "[const] ", - }; - format!("{binder}{modifier}{}", self.path(trait_)) - } - GenericBound::Outlives(lifetime) => lifetime.clone(), - GenericBound::Use(args) => { - let args: Vec<&str> = args - .iter() - .map(|arg| match arg { - PreciseCapturingArg::Lifetime(name) - | PreciseCapturingArg::Param(name) => name.as_str(), - }) - .collect(); - format!("use<{}>", args.join(", ")) - } - }); - } - parts.join(" + ") - } - - fn binder(&mut self, params: &[GenericParamDef]) -> String { - if params.is_empty() { - return String::new(); - } - let params: Vec = params.iter().map(|param| self.param(param)).collect(); - format!("for<{}> ", params.join(", ")) - } - - fn param(&mut self, param: &GenericParamDef) -> String { - let name = ¶m.name; - match ¶m.kind { - GenericParamDefKind::Lifetime { outlives } if outlives.is_empty() => name.clone(), - GenericParamDefKind::Lifetime { outlives } => { - format!("{name}: {}", outlives.join(" + ")) - } - GenericParamDefKind::Type { - bounds, default, .. - } => { - let bounds = self.colon_bounds(bounds); - let default = self.value(default.as_ref()); - format!("{name}{bounds}{default}") - } - GenericParamDefKind::Const { type_, default } => { - let default = default - .as_ref() - .map_or_else(String::new, |value| format!(" = {value}")); - format!("const {name}: {}{default}", self.ty(type_)) - } - } - } - - /// `<..>` over the declared parameters; the synthetic ones an - /// argument-position `impl Trait` introduces render in the argument. - pub(super) fn generics(&mut self, generics: &Generics) -> String { - self.context = "bound"; - let params: Vec = generics - .params - .iter() - .filter(|param| { - !matches!( - param.kind, - GenericParamDefKind::Type { - is_synthetic: true, - .. - } - ) - }) - .map(|param| self.param(param)) - .collect(); - if params.is_empty() { - String::new() - } else { - format!("<{}>", params.join(", ")) - } - } - - pub(super) fn where_clause(&mut self, generics: &Generics) -> String { - self.context = "bound"; - let mut predicates = Vec::new(); - for predicate in &generics.where_predicates { - predicates.push(match predicate { - WherePredicate::BoundPredicate { - type_, - bounds, - generic_params, - } => { - let binder = self.binder(generic_params); - format!("{binder}{}: {}", self.ty(type_), self.bounds(bounds)) - } - WherePredicate::LifetimePredicate { lifetime, outlives } => { - format!("{lifetime}: {}", outlives.join(" + ")) - } - WherePredicate::EqPredicate { lhs, rhs } => { - format!("{} == {}", self.ty(lhs), self.term(rhs)) - } - }); - } - if predicates.is_empty() { - String::new() - } else { - format!(" where {}", predicates.join(", ")) - } - } - - fn dyn_trait(&mut self, dyn_trait: &DynTrait) -> String { - let mut parts = Vec::new(); - for poly in &dyn_trait.traits { - let binder = self.binder(&poly.generic_params); - parts.push(format!("{binder}{}", self.path(&poly.trait_))); - } - parts.extend(dyn_trait.lifetime.clone()); - format!("dyn {}", parts.join(" + ")) - } - - fn fn_pointer(&mut self, pointer: &FunctionPointer) -> String { - let binder = self.binder(&pointer.generic_params); - let signature = self.signature(&pointer.sig, false); - format!("{binder}{}fn{signature}", header(&pointer.header)) - } -} - -/// `const async unsafe extern "abi" `, each part only when it applies. -pub(super) fn header(header: &FunctionHeader) -> String { - let mut text = String::new(); - for (on, word) in [ - (header.is_const, "const "), - (header.is_async, "async "), - (header.is_unsafe, "unsafe "), - ] { - if on { - text.push_str(word); - } - } - let (abi, unwind) = match &header.abi { - Abi::Rust => return text, - Abi::C { unwind } => ("C", *unwind), - Abi::Cdecl { unwind } => ("cdecl", *unwind), - Abi::Stdcall { unwind } => ("stdcall", *unwind), - Abi::Fastcall { unwind } => ("fastcall", *unwind), - Abi::Aapcs { unwind } => ("aapcs", *unwind), - Abi::Win64 { unwind } => ("win64", *unwind), - Abi::SysV64 { unwind } => ("sysv64", *unwind), - Abi::System { unwind } => ("system", *unwind), - Abi::Other(name) => (name.as_str(), false), - }; - let unwind = if unwind { "-unwind" } else { "" }; - format!("{text}extern \"{abi}{unwind}\" ") -} - -fn lifetime_prefix(lifetime: Option<&String>) -> String { - lifetime.map_or_else(String::new, |lifetime| format!("{lifetime} ")) -} - -fn mutable(is_mutable: bool) -> &'static str { - if is_mutable { "mut " } else { "" } -} diff --git a/crates/build-xtask/src/api/render.rs b/crates/build-xtask/src/api/render.rs deleted file mode 100644 index 050e08303..000000000 --- a/crates/build-xtask/src/api/render.rs +++ /dev/null @@ -1,279 +0,0 @@ -//! Renders visited items as one listing line each, and records every path -//! the rendering names as a mention, so the closure check sees exactly -//! what the listing shows: signatures, fields, generic bounds, -//! supertraits, both sides of every trait impl, and associated types. -//! -//! A path the facade re-exports renders as its facade path; any other -//! renders as its definition path (`alloc::string::String`), which keeps -//! the listing independent of how the source happened to spell it. - -use rustdoc_types::{ - Attribute, Crate, Function, Generics, Impl, Item, ItemEnum, StructKind, VariantKind, -}; - -use super::items::Surface; -use super::walk::{Role, Visit}; -use types::header; - -/// One path a rendering named. -#[derive(Debug)] -pub(super) struct Mention { - /// Where on the item it appeared (`signature`, `bound`, ...). - pub(super) context: &'static str, - /// The defining crate and definition path, when rustdoc resolved it. - pub(super) target: Option<(String, Vec)>, - /// The path as the source wrote it. - pub(super) written: String, -} - -/// Renders items whose ids resolve in one crate's JSON. -#[derive(Debug)] -pub(super) struct Renderer<'a> { - krate: &'a Crate, - surface: &'a Surface, - context: &'static str, - pub(super) mentions: Vec, -} - -impl<'a> Renderer<'a> { - pub(super) fn new(krate: &'a Crate, surface: &'a Surface) -> Self { - Renderer { - krate, - surface, - context: "signature", - mentions: Vec::new(), - } - } - - /// The listing line for `visit`, or `None` for an item the listing - /// leaves to its impl line (trait impl methods, and inherent impl - /// headers without generics). Mentions are recorded either way. - pub(super) fn line(&mut self, visit: &Visit<'_>) -> Option { - let label = visit.label.as_str(); - match (visit.role, &visit.item.inner) { - (Role::Impl | Role::ImplItem, _) => self.impl_line(visit), - (role, ItemEnum::Function(function)) => { - let line = self.function(label, function); - let provided = role == Role::TraitItem && function.has_body; - Some(if provided { - format!("{line} {{ .. }}") - } else { - line - }) - } - (_, ItemEnum::Struct(structure)) => Some(self.data( - "struct", - visit.item, - label, - &structure.generics, - struct_shape(&structure.kind), - )), - (_, ItemEnum::Union(union)) => Some(self.data( - "union", - visit.item, - label, - &union.generics, - braced(union.has_stripped_fields), - )), - (_, ItemEnum::Enum(enumeration)) => { - Some(self.data("enum", visit.item, label, &enumeration.generics, "")) - } - (_, ItemEnum::StructField(ty)) => { - self.context = "field"; - Some(format!("pub {label}: {}", self.ty(ty))) - } - (_, ItemEnum::Variant(variant)) => { - let exhaustiveness = non_exhaustive(visit.item); - let shape = variant_shape(&variant.kind); - Some(match &variant.discriminant { - Some(discriminant) => { - format!("{exhaustiveness}pub {label} = {}{shape}", discriminant.expr) - } - None => format!("{exhaustiveness}pub {label}{shape}"), - }) - } - (_, ItemEnum::Trait(definition)) => { - let generics = self.generics(&definition.generics); - self.context = "supertrait"; - let supertraits = self.colon_bounds(&definition.bounds); - let where_clause = self.where_clause(&definition.generics); - let unsafety = if definition.is_unsafe { "unsafe " } else { "" }; - let auto = if definition.is_auto { "auto " } else { "" }; - Some(format!( - "pub {unsafety}{auto}trait {label}{generics}{supertraits}{where_clause}" - )) - } - ( - _, - ItemEnum::AssocType { - generics, - bounds, - type_, - .. - }, - ) => { - let params = self.generics(generics); - self.context = "associated type"; - let bounds = self.colon_bounds(bounds); - let value = self.value(type_.as_ref()); - let where_clause = self.where_clause(generics); - Some(format!( - "pub type {label}{params}{bounds}{value}{where_clause}" - )) - } - (_, ItemEnum::AssocConst { type_, .. } | ItemEnum::Constant { type_, .. }) => { - self.context = "type"; - Some(format!("pub const {label}: {}", self.ty(type_))) - } - (_, ItemEnum::Static(item)) => { - self.context = "type"; - let mutability = if item.is_mutable { "mut " } else { "" }; - Some(format!( - "pub static {mutability}{label}: {}", - self.ty(&item.type_) - )) - } - (_, ItemEnum::TypeAlias(alias)) => { - let generics = self.generics(&alias.generics); - self.context = "aliased type"; - let ty = self.ty(&alias.type_); - let where_clause = self.where_clause(&alias.generics); - Some(format!("pub type {label}{generics} = {ty}{where_clause}")) - } - (_, ItemEnum::Macro(_) | ItemEnum::ProcMacro(_)) => Some(format!("pub macro {label}!")), - (_, other) => Some(format!("pub {:?} {label}", other.item_kind())), - } - } - - /// The line for an impl block or a trait impl's associated item. - fn impl_line(&mut self, visit: &Visit<'_>) -> Option { - let name = visit.item.name.as_deref().unwrap_or("_"); - match &visit.item.inner { - ItemEnum::Impl(block) => { - let header = self.impl_header(block); - let generic = !block.generics.params.is_empty() - || !block.generics.where_predicates.is_empty(); - (block.trait_.is_some() || generic).then_some(header) - } - ItemEnum::Function(function) => { - self.function(&visit.label, function); - None - } - ItemEnum::AssocType { type_, .. } => { - self.context = "associated type"; - let value = self.value(type_.as_ref()); - Some(format!("{} {{ type {name}{value} }}", visit.parent)) - } - ItemEnum::AssocConst { type_, .. } => { - self.context = "associated const"; - let ty = self.ty(type_); - Some(format!("{} {{ const {name}: {ty} }}", visit.parent)) - } - _ => None, - } - } - - /// `impl<..> Trait<..> for Type where ..`, or `impl<..> Type` for an - /// inherent impl. - pub(crate) fn impl_header(&mut self, block: &Impl) -> String { - let generics = self.generics(&block.generics); - let where_clause = self.where_clause(&block.generics); - let unsafety = if block.is_unsafe { "unsafe " } else { "" }; - let trait_part = match &block.trait_ { - Some(path) => { - self.context = "implemented trait"; - let negative = if block.is_negative { "!" } else { "" }; - format!("{negative}{} for ", self.path(path)) - } - None => String::new(), - }; - self.context = "impl self type"; - let self_type = self.ty(&block.for_); - format!("{unsafety}impl{generics} {trait_part}{self_type}{where_clause}") - } - - fn data( - &mut self, - keyword: &str, - item: &Item, - label: &str, - generics: &Generics, - shape: &str, - ) -> String { - let params = self.generics(generics); - let where_clause = self.where_clause(generics); - let exhaustiveness = non_exhaustive(item); - format!("{exhaustiveness}pub {keyword} {label}{params}{where_clause}{shape}") - } - - fn function(&mut self, label: &str, function: &Function) -> String { - let generics = self.generics(&function.generics); - let where_clause = self.where_clause(&function.generics); - self.context = "signature"; - let signature = self.signature(&function.sig, true); - let header = header(&function.header); - format!("pub {header}fn {label}{generics}{signature}{where_clause}") - } -} - -/// The `#[non_exhaustive] ` prefix, when the item carries the attribute. -/// A variant's own attributes carry it; an enum's say nothing about its -/// variants. -fn non_exhaustive(item: &Item) -> &'static str { - if item - .attrs - .iter() - .any(|attr| matches!(attr, Attribute::NonExhaustive)) - { - "#[non_exhaustive] " - } else { - "" - } -} - -/// What a struct line ends with: nothing but `;` for a unit struct, the -/// tuple or braced form otherwise. -fn struct_shape(kind: &StructKind) -> &'static str { - match kind { - StructKind::Unit => ";", - StructKind::Tuple(fields) => tuple(fields.iter().any(Option::is_none)), - StructKind::Plain { - has_stripped_fields, - .. - } => braced(*has_stripped_fields), - } -} - -/// What a variant line ends with; a unit variant ends with nothing, so -/// its line is the path alone. -fn variant_shape(kind: &VariantKind) -> &'static str { - match kind { - VariantKind::Plain => "", - VariantKind::Tuple(fields) => tuple(fields.iter().any(Option::is_none)), - VariantKind::Struct { - has_stripped_fields, - .. - } => braced(*has_stripped_fields), - } -} - -/// A tuple kind's shape, naming hidden fields in place of `..`. -fn tuple(hidden: bool) -> &'static str { - if hidden { - "(/* private fields */)" - } else { - "(..)" - } -} - -/// A braced kind's shape, naming hidden fields in place of `..`. -fn braced(hidden: bool) -> &'static str { - if hidden { - " { /* private fields */ }" - } else { - " { .. }" - } -} - -#[path = "render-types.rs"] -mod types; diff --git a/crates/build-xtask/src/api/toolchain-tests.rs b/crates/build-xtask/src/api/toolchain-tests.rs deleted file mode 100644 index 952844dbe..000000000 --- a/crates/build-xtask/src/api/toolchain-tests.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! The pinned toolchain: refusal on any other toolchain, and agreement -//! between the pin, the workspace manifest, and `rustdoc-types` itself. - -use super::*; - -#[test] -fn a_toolchain_other_than_the_pinned_nightly_fails_naming_it() { - for active in [ - "stable-x86_64-pc-windows-msvc", - "nightly", - "nightly-2026-09-06", - ] { - let message = require_pinned(Some(active)).expect_err(active); - assert!( - message.contains(&format!("`{}`", PINNED.nightly)), - "{message}" - ); - assert!(message.contains(&format!("`{active}`")), "{message}"); - assert!( - message.contains(&format!("cargo +{} xtask api", PINNED.nightly)), - "{message}" - ); - } -} - -#[test] -fn an_unset_toolchain_fails_naming_the_pinned_nightly() { - let message = require_pinned(None).expect_err("no toolchain is refused"); - assert!(message.contains(PINNED.nightly), "{message}"); - assert!(message.contains("RUSTUP_TOOLCHAIN is unset"), "{message}"); -} - -#[test] -fn the_pinned_nightly_passes_with_or_without_a_host_triple() { - let with_triple = format!("{}-x86_64-pc-windows-msvc", PINNED.nightly); - assert_eq!(require_pinned(Some(PINNED.nightly)), Ok(())); - assert_eq!(require_pinned(Some(&with_triple)), Ok(())); - let bare_dash = format!("{}-", PINNED.nightly); - assert!(require_pinned(Some(&bare_dash)).is_err()); -} - -#[test] -fn the_command_refuses_another_toolchain_before_building_anything() { - let empty = tempfile::TempDir::new().expect("tempdir"); - let args = ["--check".to_owned()]; - let message = super::super::outcome(empty.path(), &args, Some("stable-x86_64-pc-windows-msvc")) - .expect_err("stable is refused"); - assert!( - message.starts_with("cargo xtask api: required the pinned nightly"), - "{message}" - ); - assert!(message.contains(PINNED.nightly), "{message}"); -} - -#[test] -fn an_unknown_argument_is_refused_with_the_usage() { - let empty = tempfile::TempDir::new().expect("tempdir"); - let message = super::super::outcome(empty.path(), &["--fix".to_owned()], Some(PINNED.nightly)) - .expect_err("an unknown flag is refused"); - assert_eq!(message, super::super::USAGE); -} - -#[test] -fn the_pinned_rustdoc_types_release_reads_the_pinned_format() { - let minor = PINNED - .rustdoc_types - .split('.') - .nth(1) - .and_then(|minor| minor.parse::().ok()); - assert_eq!(minor, Some(rustdoc_types::FORMAT_VERSION)); -} - -#[test] -fn the_workspace_manifest_pins_the_same_rustdoc_types_release() { - let root = crate::product::test_support::workspace_root(); - let text = std::fs::read_to_string(root.join("Cargo.toml")).expect("the root manifest reads"); - let manifest: toml::Value = toml::from_str(&text).expect("the root manifest parses"); - let pin = manifest - .get("workspace") - .and_then(|workspace| workspace.get("dependencies")) - .and_then(|dependencies| dependencies.get("rustdoc-types")) - .and_then(toml::Value::as_str); - assert_eq!(pin, Some(format!("={}", PINNED.rustdoc_types).as_str())); -} diff --git a/crates/build-xtask/src/api/toolchain.rs b/crates/build-xtask/src/api/toolchain.rs deleted file mode 100644 index 9ceca4af9..000000000 --- a/crates/build-xtask/src/api/toolchain.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! The one toolchain `cargo xtask api` runs on. Rustdoc JSON is nightly -//! only and changes format roughly every release, so the nightly and the -//! `rustdoc-types` release that reads its `format_version` move together, -//! in [`PINNED`], and nowhere else. CI reads the nightly from here. - -/// A nightly toolchain paired with the `rustdoc-types` release matching -/// its rustdoc JSON `format_version`. -#[derive(Debug)] -pub(super) struct Pinned { - /// The rustup toolchain name, as in `cargo + xtask api`. - pub(super) nightly: &'static str, - /// The exact `rustdoc-types` version the workspace manifest pins. - pub(super) rustdoc_types: &'static str, -} - -/// The pinned pair. `nightly-2026-09-05` emits `format_version` 61, which -/// `rustdoc-types` 0.61.0 reads. -pub(super) const PINNED: Pinned = Pinned { - nightly: "nightly-2026-09-05", - rustdoc_types: "0.61.0", -}; - -/// The toolchain rustup resolved for this process. Rustup sets -/// `RUSTUP_TOOLCHAIN` for every tool it proxies, to the full name with the -/// host triple (`nightly-2026-09-05-x86_64-pc-windows-msvc`). -pub(super) fn active() -> Option { - std::env::var("RUSTUP_TOOLCHAIN").ok() -} - -/// Accepts `active` only when it names the pinned nightly, with or without -/// a host triple; anything else is refused before any build starts, with -/// the command that runs on the right toolchain. -pub(super) fn require_pinned(active: Option<&str>) -> Result<(), String> { - let nightly = PINNED.nightly; - let found = match active { - Some(name) if name == nightly => return Ok(()), - Some(name) => { - let triple = name - .strip_prefix(nightly) - .and_then(|rest| rest.strip_prefix('-')); - if triple.is_some_and(|triple| !triple.is_empty()) { - return Ok(()); - } - format!("toolchain `{name}`") - } - None => "no rustup toolchain (RUSTUP_TOOLCHAIN is unset)".to_owned(), - }; - Err(format!( - "cargo xtask api: required the pinned nightly `{nightly}`, found {found}; \ - run `cargo +{nightly} xtask api`" - )) -} - -#[cfg(test)] -#[path = "toolchain-tests.rs"] -mod tests; diff --git a/crates/build-xtask/src/api/walk.rs b/crates/build-xtask/src/api/walk.rs deleted file mode 100644 index e0a853567..000000000 --- a/crates/build-xtask/src/api/walk.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! Enumerates every item the surface exposes: each re-exported item, its -//! fields, variants, inherent associated items, trait items, and the trait -//! impls rustdoc shows on its page, with their associated items. -//! -//! A type's impls come from its own `impls` list (which carries the auto -//! trait and blanket impls rustdoc synthesizes) and from every internal -//! crate's impl blocks whose self type is that item, because an impl in a -//! downstream internal crate shows on the facade's page too; a trait's -//! come from its `implementations` and every impl of it the same way. -//! Blanket impls of traits outside the internal crates (`From for T`, -//! `Borrow`, and every dependency's) say nothing about the facade and -//! are skipped. - -use std::collections::{HashMap, HashSet}; - -use rustdoc_types::{Crate, Id, Item, ItemEnum, StructKind, Type, VariantKind}; - -use super::items::{Surface, target}; -use super::load::Loaded; -use super::render::Renderer; - -/// Where a visited item sits on the surface. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum Role { - /// A re-exported item. - Item, - /// A struct, union, or variant field. - Field, - /// An enum variant. - Variant, - /// An associated item of an inherent impl. - Member, - /// An associated item of a surface trait. - TraitItem, - /// An impl block, inherent or trait. - Impl, - /// An associated item of a trait impl. - ImplItem, -} - -/// One visited item. -#[derive(Debug)] -pub(super) struct Visit<'a> { - /// The label of what holds it: the owning type's facade path, or for - /// a trait impl's associated item, the impl's header. - pub(super) parent: String, - /// How findings and the listing name it. - pub(super) label: String, - pub(super) role: Role, - pub(super) item: &'a Item, - /// The JSON the item's ids resolve in, and that crate's name. - pub(super) krate: &'a Crate, - pub(super) crate_name: &'a str, -} - -type Key = (String, Vec); -type Located<'a> = (&'a str, &'a Crate, Id); - -/// Every item the surface exposes, each impl once. -pub(super) fn visits<'a>(loaded: &'a Loaded, surface: &'a Surface) -> Vec> { - let mut walker = Walker { - loaded, - surface, - by_type: HashMap::new(), - by_trait: HashMap::new(), - seen: HashSet::new(), - visits: Vec::new(), - }; - walker.index_impls(); - for entry in &surface.entries { - // `Surface::resolve` records an entry only for a crate in - // `loaded.internal` and an id in that crate's index, refusing any - // other re-export, so neither arm below skips an entry. - let Some((crate_name, krate)) = loaded.internal.get_key_value(&entry.krate) else { - continue; - }; - let Some(item) = krate.index.get(&entry.id) else { - continue; - }; - walker.entry(crate_name, krate, item, &entry.label); - } - walker.visits -} - -struct Walker<'a> { - loaded: &'a Loaded, - surface: &'a Surface, - by_type: HashMap>>, - by_trait: HashMap>>, - seen: HashSet<(&'a str, Id)>, - visits: Vec>, -} - -impl<'a> Walker<'a> { - /// Indexes every written impl block in every internal crate by the - /// item its self type names and by its trait. - fn index_impls(&mut self) { - for (name, krate) in &self.loaded.internal { - for (id, item) in &krate.index { - let ItemEnum::Impl(block) = &item.inner else { - continue; - }; - if block.is_synthetic || block.blanket_impl.is_some() { - continue; - } - let located = (name.as_str(), krate, *id); - if let Type::ResolvedPath(path) = &block.for_ - && let Some(key) = key(krate, path.id) - { - self.by_type.entry(key).or_default().push(located); - } - if let Some(key) = block.trait_.as_ref().and_then(|path| key(krate, path.id)) { - self.by_trait.entry(key).or_default().push(located); - } - } - } - } - - fn push( - &mut self, - parent: &str, - label: String, - role: Role, - item: &'a Item, - at: (&'a str, &'a Crate), - ) { - self.visits.push(Visit { - parent: parent.to_owned(), - label, - role, - item, - krate: at.1, - crate_name: at.0, - }); - } - - fn entry(&mut self, crate_name: &'a str, krate: &'a Crate, item: &'a Item, label: &str) { - let at = (crate_name, krate); - self.push(label, label.to_owned(), Role::Item, item, at); - let own = key(krate, item.id); - match &item.inner { - ItemEnum::Struct(structure) => { - match &structure.kind { - StructKind::Plain { fields, .. } => { - self.fields(fields.iter().copied(), label, at); - } - StructKind::Tuple(fields) => { - self.fields(fields.iter().flatten().copied(), label, at); - } - StructKind::Unit => {} - } - self.impls(&structure.impls, own.as_ref(), label, at); - } - ItemEnum::Union(union) => { - self.fields(union.fields.iter().copied(), label, at); - self.impls(&union.impls, own.as_ref(), label, at); - } - ItemEnum::Enum(enumeration) => { - for id in &enumeration.variants { - self.variant(*id, label, at); - } - self.impls(&enumeration.impls, own.as_ref(), label, at); - } - ItemEnum::Trait(definition) => { - for id in &definition.items { - if let Some(member) = krate.index.get(id) { - let name = member.name.as_deref().unwrap_or("_"); - self.push( - label, - format!("{label}::{name}"), - Role::TraitItem, - member, - at, - ); - } - } - let written = own.and_then(|own| self.by_trait.get(&own).cloned()); - for &id in &definition.implementations { - self.impl_block((crate_name, krate, id), label); - } - for located in written.into_iter().flatten() { - self.impl_block(located, label); - } - } - _ => {} - } - } - - fn fields(&mut self, ids: impl Iterator, owner: &str, at: (&'a str, &'a Crate)) { - for id in ids { - if let Some(field) = at.1.index.get(&id) { - let name = field.name.as_deref().unwrap_or("_"); - self.push(owner, format!("{owner}::{name}"), Role::Field, field, at); - } - } - } - - fn variant(&mut self, id: Id, owner: &str, at: (&'a str, &'a Crate)) { - let Some(variant) = at.1.index.get(&id) else { - return; - }; - let label = format!("{owner}::{}", variant.name.as_deref().unwrap_or("_")); - self.push(owner, label.clone(), Role::Variant, variant, at); - if let ItemEnum::Variant(inner) = &variant.inner { - match &inner.kind { - VariantKind::Plain => {} - VariantKind::Tuple(fields) => { - self.fields(fields.iter().flatten().copied(), &label, at); - } - VariantKind::Struct { fields, .. } => { - self.fields(fields.iter().copied(), &label, at); - } - } - } - } - - fn impls(&mut self, own: &[Id], key: Option<&Key>, owner: &str, at: (&'a str, &'a Crate)) { - let written = key.and_then(|key| self.by_type.get(key).cloned()); - for &id in own { - self.impl_block((at.0, at.1, id), owner); - } - for located in written.into_iter().flatten() { - self.impl_block(located, owner); - } - } - - fn impl_block(&mut self, (crate_name, krate, id): Located<'a>, owner: &str) { - let Some(item) = krate.index.get(&id) else { - return; - }; - let ItemEnum::Impl(block) = &item.inner else { - return; - }; - if !self.seen.insert((crate_name, id)) { - return; - } - if block.blanket_impl.is_some() { - let internal = block - .trait_ - .as_ref() - .and_then(|path| target(krate, path.id)) - .is_some_and(|trait_| self.surface.internal.contains(trait_.krate)); - if !internal { - return; - } - } - let at = (crate_name, krate); - let header = Renderer::new(krate, self.surface).impl_header(block); - self.push(owner, header.clone(), Role::Impl, item, at); - for child in &block.items { - let Some(member) = krate.index.get(child) else { - continue; - }; - let name = member.name.as_deref().unwrap_or("_"); - if block.trait_.is_none() { - self.push(owner, format!("{owner}::{name}"), Role::Member, member, at); - } else { - let label = format!("{header} {{ {} {name} }}", keyword(&member.inner)); - self.push(&header, label, Role::ImplItem, member, at); - } - } - } -} - -/// The keyword an associated item is declared with. -fn keyword(inner: &ItemEnum) -> &'static str { - match inner { - ItemEnum::Function(_) => "fn", - ItemEnum::AssocConst { .. } => "const", - _ => "type", - } -} - -/// The cross-crate key of the item `id` names in `krate`'s JSON. -fn key(krate: &Crate, id: Id) -> Option { - let target = target(krate, id)?; - Some((target.krate.to_owned(), target.path.to_vec())) -} diff --git a/crates/build-xtask/src/debug_spelling-tests.rs b/crates/build-xtask/src/debug_spelling-tests.rs index b25bb2a9d..9ca742df6 100644 --- a/crates/build-xtask/src/debug_spelling-tests.rs +++ b/crates/build-xtask/src/debug_spelling-tests.rs @@ -136,16 +136,12 @@ use std::fmt::{{self, {BARE}List, Write}}; fn a_crate_whose_manifest_does_not_parse_is_still_scanned() { let root = tempfile::TempDir::new().expect("tempdir"); let lib = format!("impl {DEBUG_PATH} for Seam {{}}\n"); - write_crate( - root.path(), - "runvil-internal/vfs", - &[("lib.rs", lib.as_str())], - ); + write_crate(root.path(), "workshop/status", &[("lib.rs", lib.as_str())]); std::fs::write( root.path() .join("crates") - .join("runvil-internal") - .join("vfs") + .join("workshop") + .join("status") .join("Cargo.toml"), "not [valid toml", ) @@ -153,9 +149,7 @@ fn a_crate_whose_manifest_does_not_parse_is_still_scanned() { let violations = debug_spelling_violations(root.path()); assert_eq!(violations.len(), 1, "{violations:?}"); assert!( - violations[0].starts_with(&format!( - "crates/runvil-internal/vfs/src/lib.rs:1: {RULE}: " - )), + violations[0].starts_with(&format!("crates/workshop/status/src/lib.rs:1: {RULE}: ")), "a crate whose manifest was never read cannot be shown clean: {violations:?}" ); } diff --git a/crates/build-xtask/src/doc_hidden-tests.rs b/crates/build-xtask/src/doc_hidden-tests.rs deleted file mode 100644 index c7f655162..000000000 --- a/crates/build-xtask/src/doc_hidden-tests.rs +++ /dev/null @@ -1,352 +0,0 @@ -//! The `doc(hidden)` ban over this workspace's facades and their -//! containers, plus fixtures that each hiding form fails at its line and -//! each doc form that hides nothing passes. - -use std::path::{Path, PathBuf}; - -use super::*; - -fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("build-xtask lives at /crates/build-xtask") - .to_path_buf() -} - -fn check(text: &str) -> Vec { - source_violations(Path::new("lib.rs"), text) -} - -/// Asserts `text` has exactly one violation, a hidden attribute at `line`. -fn assert_hidden_at(text: &str, line: usize) { - let violations = check(text); - assert_eq!(violations.len(), 1, "{text}\n{violations:?}"); - assert!( - violations[0].starts_with(&format!("lib.rs:{line}: {RULE}: required ")), - "{text}\n{violations:?}" - ); -} - -/// `path`, written with `/`, under `root` with the platform's separators, -/// so it compares equal to a path the scan built. -fn at(root: &Path, path: &str) -> PathBuf { - path.split('/') - .fold(root.to_path_buf(), |dir, part| dir.join(part)) -} - -/// A fake workspace root holding each `(path, text)`. -fn tree(files: &[(&str, &[u8])]) -> tempfile::TempDir { - let root = tempfile::TempDir::new().expect("tempdir"); - for (path, text) in files { - let file = at(root.path(), path); - let dir = file.parent().expect("a fixture file has a directory"); - std::fs::create_dir_all(dir).expect("the fixture directory creates"); - std::fs::write(&file, text).expect("the fixture file writes"); - } - root -} - -const HIDDEN: &[u8] = b"#[doc(hidden)]\npub struct Seam;\n"; -/// One clean source under each directory the ban covers, so none is -/// reported missing. -const BANNED_DIR_LIBS: [(&str, &[u8]); 4] = [ - ("crates/runvil-engine/src/lib.rs", b"pub use x::Y;\n"), - ("crates/runvil/src/lib.rs", b"pub use x::Y;\n"), - ( - "crates/runvil-internal/lua/src/lib.rs", - b"pub struct Plain;\n", - ), - ( - "crates/harness-internal/runner/src/lib.rs", - b"pub struct Plain;\n", - ), -]; - -/// Asserts a hidden item in `path`, beside a clean source under every -/// banned directory, is the scan's one violation. -fn assert_scanned(path: &str) { - let mut files = BANNED_DIR_LIBS.to_vec(); - files.push((path, HIDDEN)); - let root = tree(&files); - let violations = doc_hidden_violations(root.path()); - let file = at(root.path(), path); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with(&format!("{}:1: {RULE}: ", file.display())), - "{path} was not scanned: {violations:?}" - ); -} - -#[test] -fn the_real_facades_and_containers_carry_no_doc_hidden() { - let violations = doc_hidden_violations(&workspace_root()); - assert!( - violations.is_empty(), - "doc(hidden) violations:\n{}", - violations.join("\n") - ); -} - -#[test] -fn a_hidden_item_of_every_kind_is_rejected_at_its_line() { - for text in [ - "#[doc(hidden)]\npub struct Seam;", - "#[doc(hidden)]\npub enum Seam {}", - "#[doc(hidden)]\npub union Seam { a: u32 }", - "#[doc(hidden)]\npub fn seam() {}", - "#[doc(hidden)]\npub trait Seam {}", - "#[doc(hidden)]\npub mod seam {}", - "#[doc(hidden)]\n#[path = \"seam.rs\"]\npub mod seam;", - "#[doc(hidden)]\npub const SEAM: u32 = 1;", - "#[doc(hidden)]\npub static SEAM: u32 = 1;", - "#[doc(hidden)]\npub type Seam = u32;", - "#[doc(hidden)]\nimpl Seam {}", - "#[doc(hidden)]\n#[macro_export]\nmacro_rules! seam { () => {} }", - ] { - assert_hidden_at(text, 1); - } -} - -#[test] -fn a_hidden_field_variant_or_method_is_rejected_at_its_line() { - for (text, line) in [ - ( - "pub struct Wire {\n #[doc(hidden)]\n pub name: String,\n}", - 2, - ), - ("pub struct Wire(\n #[doc(hidden)] pub String,\n);", 2), - ( - "pub enum Kind {\n Plain,\n #[doc(hidden)]\n Seam,\n}", - 3, - ), - ( - "pub enum Kind {\n Plain {\n #[doc(hidden)]\n raw: u8,\n },\n}", - 3, - ), - ( - "impl Wire {\n #[doc(hidden)]\n pub fn for_test() {}\n}", - 2, - ), - ( - "impl Wire {\n #[doc(hidden)]\n pub const SEAM: u8 = 0;\n}", - 2, - ), - ( - "pub trait Host {\n #[doc(hidden)]\n fn seam(&self);\n}", - 2, - ), - ("pub trait Host {\n #[doc(hidden)]\n type Seam;\n}", 2), - ( - "unsafe extern \"C\" {\n #[doc(hidden)]\n pub fn seam();\n}", - 2, - ), - ] { - assert_hidden_at(text, line); - } -} - -#[test] -fn a_hidden_re_export_is_rejected_at_its_line() { - for (text, line) in [ - ("#[doc(hidden)]\npub use crate::error::Error;", 1), - ( - "#[doc(hidden)]\npub use read::{ChunkSource, read_body_capped};", - 1, - ), - ( - "/// Seams.\npub mod model {\n #[doc(hidden)]\n pub use engine::Seam;\n}", - 3, - ), - ("#[doc(hidden)]\npub extern crate alloc;", 1), - ] { - assert_hidden_at(text, line); - } -} - -#[test] -fn an_inner_doc_hidden_is_rejected_at_its_line() { - for (text, line) in [ - ("#![doc(hidden)]\n\npub struct Seam;", 1), - ("pub mod seam {\n #![doc(hidden)]\n}", 2), - ("pub fn seam() {\n #![doc(hidden)]\n}", 2), - ] { - assert_hidden_at(text, line); - } -} - -#[test] -fn hidden_in_a_doc_list_under_cfg_attr_or_spaced_out_is_rejected() { - for text in [ - "#[doc(hidden, alias = \"seam\")]\npub struct Seam;", - "#[doc(alias = \"seam\", hidden)]\npub struct Seam;", - "#[cfg_attr(test, doc(hidden))]\npub struct Seam;", - "#[cfg_attr(feature = \"test-support\", allow(dead_code), doc(hidden))]\npub struct Seam;", - "#[cfg_attr(test, cfg_attr(unix, doc(hidden)))]\npub struct Seam;", - "# [ doc ( hidden ) ]\npub struct Seam;", - ] { - assert_hidden_at(text, 1); - } -} - -#[test] -fn a_doc_hidden_in_a_macro_body_or_macro_input_is_rejected_at_its_line() { - for (text, line) in [ - ( - "macro_rules! seams {\n () => {\n #[doc(hidden)]\n pub struct Seam;\n };\n}", - 3, - ), - ( - "events! {\n pub enum Event {\n #[doc(hidden)]\n Seam {},\n }\n}", - 3, - ), - ] { - assert_hidden_at(text, line); - } -} - -#[test] -fn doc_forms_that_hide_nothing_pass() { - let text = r##"//! Crate docs that mention `#[doc(hidden)]` in prose. -#![doc = include_str!("lib.md")] - -/// Docs that say `#[doc(hidden)]` is gone. -#[doc(inline)] -pub use engine::Run; - -#[doc = "hidden"] -#[doc(alias = "hidden")] -#[cfg_attr(test, doc(inline))] -#[cfg_attr(hidden, allow(dead_code))] -#[allow(hidden)] -pub struct Visible { - /// A field. - pub field: &'static str, -} - -pub const TEXT: &str = "#[doc(hidden)]"; -pub const RAW: &str = r#"#[doc(hidden)]"#; - -macro_rules! forward { - ($(#[$meta:meta])* $name:ident) => { - $(#[$meta])* - pub struct $name; - }; -} -"##; - let violations = check(text); - assert!(violations.is_empty(), "{violations:?}"); -} - -#[test] -fn a_violation_names_the_file_line_rule_required_and_found() { - let violations = check("/// A seam.\n#[doc(hidden)]\npub struct Seam;\n"); - assert_eq!( - violations, - [format!( - "lib.rs:2: {RULE}: required {REQUIRED}, found `#[doc(hidden)]`" - )] - ); -} - -#[test] -fn every_rs_file_under_the_facade_and_the_container_is_scanned() { - let hidden = [ - "crates/runvil-engine/tests/suite/main.rs", - "crates/runvil-engine/benches/surface.rs", - "crates/runvil-internal/lua/src/nested/deep.rs", - "crates/runvil-internal/lua/build.rs", - "crates/runvil-internal/model-client/src/client/wire.rs", - "crates/runvil-internal/stray.rs", - ]; - let mut files = BANNED_DIR_LIBS.to_vec(); - files.extend(hidden.iter().map(|path| (*path, HIDDEN))); - let root = tree(&files); - let violations = doc_hidden_violations(root.path()); - assert_eq!(violations.len(), hidden.len(), "{violations:?}"); - for path in hidden { - let file = at(root.path(), path); - assert!( - violations - .iter() - .any(|violation| violation.starts_with(&format!("{}:1: ", file.display()))), - "{path} was not scanned: {violations:?}" - ); - } -} - -#[test] -fn a_doc_hidden_under_the_harness_facade_is_rejected() { - assert_scanned("crates/runvil/src/log.rs"); -} - -#[test] -fn a_doc_hidden_under_a_harness_internal_crate_is_rejected() { - assert_scanned("crates/harness-internal/sessions/src/environment.rs"); -} - -#[test] -fn files_outside_the_facades_and_containers_are_not_scanned() { - let mut files = BANNED_DIR_LIBS.to_vec(); - files.extend([ - ("crates/workshop/server/src/lib.rs", HIDDEN), - ("crates/shared-crypto/src/lib.rs", HIDDEN), - ("crates/runvil-internal-old/src/lib.rs", HIDDEN), - ("crates/harness-internal-old/src/lib.rs", HIDDEN), - ("crates/runvil-internal/target/debug/out.rs", HIDDEN), - ("crates/runvil-engine/src/notes.md", HIDDEN), - ]); - let root = tree(&files); - let violations = doc_hidden_violations(root.path()); - assert!(violations.is_empty(), "{violations:?}"); -} - -#[test] -fn a_missing_facade_or_container_directory_is_reported_not_skipped() { - for missing in [ - "crates/runvil-engine", - "crates/runvil", - "crates/runvil-internal", - "crates/harness-internal", - ] { - let present: Vec<_> = BANNED_DIR_LIBS - .into_iter() - .filter(|(path, _)| !path.starts_with(&format!("{missing}/"))) - .collect(); - assert_eq!(present.len(), BANNED_DIR_LIBS.len() - 1, "{missing}"); - let root = tree(&present); - let violations = doc_hidden_violations(root.path()); - let dir = at(root.path(), missing); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with(&format!("{}: ", dir.display())), - "{violations:?}" - ); - } -} - -#[test] -fn an_unreadable_or_unparseable_source_is_reported_not_skipped() { - let unreadable: &[u8] = &[0xff, 0xfe, 0xfd]; - for (text, problem) in [ - (unreadable, ": unreadable source: "), - ( - b"pub struct Seam;\npub use = Seam;\n".as_slice(), - ":2: unparseable source: ", - ), - (b"pub struct Seam {\n".as_slice(), ": unparseable source: "), - ] { - let path = "crates/runvil-internal/lua/src/broken.rs"; - let mut files = BANNED_DIR_LIBS.to_vec(); - files.push((path, text)); - let root = tree(&files); - let file = at(root.path(), path); - let violations = doc_hidden_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with(&file.display().to_string()) - && violations[0].contains(problem), - "{violations:?}" - ); - } -} diff --git a/crates/build-xtask/src/doc_hidden.rs b/crates/build-xtask/src/doc_hidden.rs deleted file mode 100644 index a2784dd24..000000000 --- a/crates/build-xtask/src/doc_hidden.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! The `doc(hidden)` ban, live: no `.rs` file under a facade in -//! `facade_shape::FACADE_DIRS` (`runvil-engine`, `runvil`) or under their -//! containers (`crates/runvil-internal/`, `crates/harness-internal/`) -//! carries a `doc(hidden)` attribute. An item is either on a facade's -//! surface and documented, or off it: private, or a free function in a -//! `detail` module the facade never re-exports. Runs as part of -//! `cargo test -p build-xtask` and `cargo xtask tidy`. -//! -//! Each file must parse with `syn`; then every attribute in its token -//! tree is checked, outer or inner, at any depth. That covers items, -//! fields, variants, methods, and re-exports, and also the tokens inside -//! a macro, where `syn` sees no items but a `macro_rules!` body or a -//! declaring macro's input still hides what it expands to. `doc(hidden)`, -//! a `doc` list naming `hidden` among other settings, and a `cfg_attr` -//! that applies either are all rejected. - -use std::fs; -use std::path::{Path, PathBuf}; - -use proc_macro2::{Delimiter, TokenStream, TokenTree}; - -const RULE: &str = "doc(hidden) attribute"; -const REQUIRED: &str = "no hidden item in a facade or its container: document it, or make it \ - private or a `detail` function"; - -/// The Harness facade's container, relative to `crates/`. -pub(crate) const HARNESS_CONTAINER: &str = "harness-internal"; - -/// The directories the ban covers: every facade crate, then the Engine and -/// Harness containers, whether or not they exist. -fn banned_dirs(root: &Path) -> Vec { - let facades = crate::facade_shape::FACADE_DIRS.iter().map(|parts| { - parts - .iter() - .fold(root.to_path_buf(), |dir, part| dir.join(part)) - }); - let containers = [crate::engine_guards::ENGINE_CONTAINER, HARNESS_CONTAINER] - .map(|container| root.join("crates").join(container)); - facades.chain(containers).collect() -} - -/// Runs the ban over every `.rs` file under the facades and their -/// containers. A missing directory, an unreadable file, or an unparseable -/// file is reported, not skipped: a file that was never scanned cannot be -/// shown clean. -#[must_use] -pub(crate) fn doc_hidden_violations(root: &Path) -> Vec { - let mut violations = Vec::new(); - for dir in banned_dirs(root) { - if !dir.is_dir() { - violations.push(format!( - "{}: the facade or container directory is missing, so its sources cannot be \ - shown free of doc(hidden)", - dir.display() - )); - continue; - } - let mut files = crate::tidy::rust_files(&dir); - files.sort(); - for file in &files { - match fs::read_to_string(file) { - Ok(text) => violations.extend(source_violations(file, &text)), - Err(error) => { - violations.push(format!("{}: unreadable source: {error}", file.display())); - } - } - } - } - violations -} - -/// The hidden attributes in one source file, labeled with `file`. -fn source_violations(file: &Path, text: &str) -> Vec { - let unparseable = |line: usize, error: &dyn std::fmt::Display| { - vec![format!( - "{}:{line}: unparseable source: required a file that parses as Rust, found {error}", - file.display() - )] - }; - let tokens: TokenStream = match text.parse() { - Ok(tokens) => tokens, - Err(error) => return unparseable(error.span().start().line, &error), - }; - if let Err(error) = syn::parse2::(tokens.clone()) { - return unparseable(error.span().start().line, &error); - } - let lines: Vec<&str> = text.lines().collect(); - let mut hidden = Vec::new(); - hidden_attribute_lines(tokens, &mut hidden); - hidden - .into_iter() - .map(|line| { - let found = line - .checked_sub(1) - .and_then(|index| lines.get(index)) - .map_or("", |text| text.trim()); - format!( - "{}:{line}: {RULE}: required {REQUIRED}, found `{found}`", - file.display() - ) - }) - .collect() -} - -/// Pushes the line of every attribute in `tokens` that hides what it sits -/// on, outer (`#[...]`) or inner (`#![...]`), at any depth. -fn hidden_attribute_lines(tokens: TokenStream, lines: &mut Vec) { - let mut tokens = tokens.into_iter().peekable(); - while let Some(token) = tokens.next() { - match token { - TokenTree::Group(group) => hidden_attribute_lines(group.stream(), lines), - TokenTree::Punct(pound) if pound.as_char() == '#' => { - if matches!(tokens.peek(), Some(TokenTree::Punct(bang)) if bang.as_char() == '!') { - tokens.next(); - } - // The bracket group itself is left for the next turn of the - // loop, which descends into it like any other group. - if let Some(TokenTree::Group(content)) = tokens.peek() - && content.delimiter() == Delimiter::Bracket - && hides(content.stream()) - { - lines.push(pound.span().start().line); - } - } - _ => {} - } - } -} - -/// Whether an attribute's content, the tokens inside its brackets, hides -/// the item: `doc(...)` naming `hidden` among its settings, or -/// `cfg_attr(predicate, ...)` applying an attribute that does. The -/// predicate is skipped, so `cfg_attr(hidden, ...)` names a cfg, not a -/// doc setting. -fn hides(content: TokenStream) -> bool { - let mut tokens = content.into_iter(); - let (Some(TokenTree::Ident(name)), Some(TokenTree::Group(args)), None) = - (tokens.next(), tokens.next(), tokens.next()) - else { - return false; - }; - if args.delimiter() != Delimiter::Parenthesis { - return false; - } - let settings = comma_separated(args.stream()); - if name == "doc" { - settings.iter().any( - |setting| matches!(setting.first(), Some(TokenTree::Ident(word)) if word == "hidden"), - ) - } else if name == "cfg_attr" { - settings - .into_iter() - .skip(1) - .any(|attribute| hides(attribute.into_iter().collect())) - } else { - false - } -} - -/// Splits `tokens` at their top-level commas. -fn comma_separated(tokens: TokenStream) -> Vec> { - let mut parts = vec![Vec::new()]; - for token in tokens { - match &token { - TokenTree::Punct(comma) if comma.as_char() == ',' => parts.push(Vec::new()), - _ => { - if let Some(part) = parts.last_mut() { - part.push(token); - } - } - } - } - parts -} - -#[cfg(test)] -#[path = "doc_hidden-tests.rs"] -mod tests; diff --git a/crates/build-xtask/src/engine_clippy-tests.rs b/crates/build-xtask/src/engine_clippy-tests.rs deleted file mode 100644 index f905cf291..000000000 --- a/crates/build-xtask/src/engine_clippy-tests.rs +++ /dev/null @@ -1,314 +0,0 @@ -//! The Engine clippy guard over this workspace, plus fixtures that each -//! failure case is reported against a fake workspace that otherwise passes. - -use std::path::Path; - -use super::*; -use crate::product::test_support::{self, workspace_root}; - -/// Writes `text` at `path`, written with `/`, under `root`. -fn write(root: &Path, path: &str, text: &str) { - let file = at(root, path); - let dir = file.parent().expect("a fixture file has a directory"); - std::fs::create_dir_all(dir).expect("the fixture directory creates"); - std::fs::write(&file, text).expect("the fixture file writes"); -} - -/// A fake workspace that passes the guard: the root `clippy.toml`, the -/// three Engine files, both root Engine crates, and one container crate. -fn clean_root() -> tempfile::TempDir { - let root = tempfile::TempDir::new().expect("tempdir"); - test_support::write_engine_clippy_tomls(root.path()); - for (dir, package) in [ - ("runvil-engine", "runvil-engine"), - ("runvil-plugin", "runvil-plugin"), - ("runvil-internal/runtime", "runvil-runtime"), - ] { - test_support::write_crate(root.path(), dir, package, ""); - write( - root.path(), - &format!("crates/{dir}/src/lib.rs"), - "pub struct Live;\n", - ); - } - root -} - -/// Asserts the findings, in order: each starts with its file's path, and -/// any `::` written after it, and holds each of its fragments. -fn assert_findings(root: &Path, found: &[String], expected: &[(&str, &[&str])]) { - assert_eq!(found.len(), expected.len(), "{found:#?}"); - for (finding, (path, fragments)) in found.iter().zip(expected) { - let (file, line) = path.split_at(path.find(':').unwrap_or(path.len())); - let path = format!("{}{line}", at(root, file).display()); - assert!( - finding.starts_with(&path), - "expected a finding for {path}: {finding}" - ); - for fragment in *fragments { - assert!( - finding.contains(fragment), - "expected `{fragment}` in: {finding}" - ); - } - } -} - -#[test] -fn an_engine_crate_resolving_to_another_clippy_toml_is_reported() { - let root = clean_root(); - test_support::write_crate(root.path(), "runvil-internal/lua", "runvil-lua", ""); - write( - root.path(), - "crates/runvil-internal/lua/clippy.toml", - &test_support::engine_clippy_toml(None), - ); - // Clippy reads `.clippy.toml` before `clippy.toml` in one directory. - test_support::write_crate(root.path(), "runvil-internal/vfs", "runvil-vfs", ""); - write( - root.path(), - "crates/runvil-internal/vfs/.clippy.toml", - &test_support::engine_clippy_toml(None), - ); - write( - root.path(), - "crates/runvil-internal/vfs/clippy.toml", - &test_support::engine_clippy_toml(None), - ); - let found = engine_clippy_violations(root.path()); - let lua_toml = at(root.path(), "crates/runvil-internal/lua/clippy.toml"); - let vfs_toml = at(root.path(), "crates/runvil-internal/vfs/.clippy.toml"); - assert_findings( - root.path(), - &found, - &[ - ( - "crates/runvil-internal/lua/Cargo.toml", - &[ - "required: the nearest clippy.toml is one of", - "crates/runvil-internal/clippy.toml", - &format!("found: {}", lua_toml.display()), - ], - ), - ( - "crates/runvil-internal/vfs/Cargo.toml", - &[&format!("found: {}", vfs_toml.display())], - ), - ], - ); - - let bare = clean_root(); - std::fs::remove_file(at(bare.path(), "clippy.toml")).expect("the root clippy.toml removes"); - std::fs::remove_file(at(bare.path(), "crates/runvil-plugin/clippy.toml")) - .expect("the plugin clippy.toml removes"); - let found = engine_clippy_violations(bare.path()); - assert!( - found.iter().any(|f| f.starts_with( - &at(bare.path(), "crates/runvil-plugin/Cargo.toml") - .display() - .to_string() - ) && f.contains("found: no clippy.toml")), - "a crate with no clippy.toml at or below the workspace root is reported: {found:#?}" - ); -} - -#[test] -fn a_clippy_toml_missing_a_root_entry_is_reported() { - let root = clean_root(); - write( - root.path(), - "crates/runvil-internal/clippy.toml", - &test_support::engine_clippy_toml(Some("std::panic::set_hook")), - ); - write( - root.path(), - "crates/runvil-plugin/clippy.toml", - &test_support::engine_clippy_toml(Some("allow-unwrap-in-tests")), - ); - write( - root.path(), - "crates/runvil-engine/clippy.toml", - &test_support::engine_clippy_toml(None) - .replace( - "allow-unwrap-in-tests = true", - "allow-unwrap-in-tests = false", - ) - .replace("use a returned error instead", "return an error"), - ); - let found = engine_clippy_violations(root.path()); - assert_findings( - root.path(), - &found, - &[ - ( - "crates/runvil-internal/clippy.toml", - &[ - "required: the root clippy.toml's disallowed-methods entry", - "std::panic::set_hook", - "found: no entry", - ], - ), - ( - "crates/runvil-engine/clippy.toml", - &[ - "required: the root clippy.toml's setting `allow-unwrap-in-tests = true`", - "found: `allow-unwrap-in-tests = false`", - ], - ), - ( - "crates/runvil-engine/clippy.toml", - &["std::panic::set_hook", "found:", "return an error"], - ), - ( - "crates/runvil-plugin/clippy.toml", - &[ - "required: the root clippy.toml's setting `allow-unwrap-in-tests = true`", - "found: no `allow-unwrap-in-tests`", - ], - ), - ], - ); - - let rootless = clean_root(); - std::fs::write(at(rootless.path(), "clippy.toml"), "not [valid toml") - .expect("the root clippy.toml writes"); - let found = engine_clippy_violations(rootless.path()); - assert_findings( - rootless.path(), - &found, - &[("clippy.toml", &["unparseable", "required:"])], - ); -} - -#[test] -fn a_clippy_toml_missing_a_determinism_ban_is_reported() { - let root = clean_root(); - write( - root.path(), - "crates/runvil-engine/clippy.toml", - &test_support::engine_clippy_toml(Some("\"std::thread::park_timeout\"")), - ); - write( - root.path(), - "crates/runvil-plugin/clippy.toml", - &test_support::engine_clippy_toml(None).replace( - "{ path = \"std::thread::sleep\", reason = \"use the deterministic form instead\" }", - "\"std::thread::sleep\"", - ), - ); - let found = engine_clippy_violations(root.path()); - assert_findings( - root.path(), - &found, - &[ - ( - "crates/runvil-engine/clippy.toml", - &[ - "required: the determinism ban `std::thread::park_timeout` with a reason", - "found: no entry", - ], - ), - ( - "crates/runvil-plugin/clippy.toml", - &["`std::thread::sleep`", "found: an entry with no reason"], - ), - ], - ); - - let missing = clean_root(); - std::fs::remove_file(at(missing.path(), "crates/runvil-internal/clippy.toml")) - .expect("the internal clippy.toml removes"); - let found = engine_clippy_violations(missing.path()); - let internal = at(missing.path(), "crates/runvil-internal/clippy.toml"); - assert!( - found - .iter() - .any(|f| f.starts_with(&internal.display().to_string()) && f.contains("unreadable")), - "a missing Engine clippy.toml holds no ban, so it is reported: {found:#?}" - ); -} - -#[test] -fn an_engine_source_suppressing_disallowed_methods_is_reported() { - let root = clean_root(); - let engine = "crates/runvil-internal/runtime"; - write( - root.path(), - &format!("{engine}/src/lib.rs"), - "//! Mentions clippy::disallowed_methods in prose.\n\ - #![expect(clippy::disallowed_methods, reason = \"crate-wide\")]\n\ - /// Never `#[allow(clippy::disallowed_methods)]`.\n\ - #[expect(clippy::unwrap_used, reason = \"another lint\")]\n\ - #[warn(clippy::disallowed_methods)]\n\ - pub struct Live;\n", - ); - write( - root.path(), - &format!("{engine}/tests/it/main.rs"), - "mod a {\n fn b() {}\n\n #[expect(\n clippy::disallowed_methods,\n \ - reason = \"multi-line\"\n )]\n fn c() {}\n}\n", - ); - write( - root.path(), - &format!("{engine}/benches/bench.rs"), - "#[cfg_attr(test, allow(clippy::disallowed_methods))]\nfn main() {}\n\ - #[allow(clippy::all)]\nfn helper() {}\n", - ); - write( - root.path(), - &format!("{engine}/examples/example.rs"), - "macro_rules! m {\n () => {\n #[expect(clippy::style, reason = \"its group\")]\n \ - fn g() {}\n };\n}\nfn main() {}\n", - ); - // A crate outside the Engine may suppress the lint, as a binary entry - // point installing process-global state does. - test_support::write_crate(root.path(), "runvil", "runvil", ""); - write( - root.path(), - "crates/runvil/src/main.rs", - "#[expect(clippy::disallowed_methods, reason = \"entry point\")]\nfn main() {}\n", - ); - let found = engine_clippy_violations(root.path()); - let required = "required: no allow or expect of clippy::disallowed_methods"; - assert_findings( - root.path(), - &found, - &[ - ( - &format!("{engine}/benches/bench.rs:1:"), - &[ - required, - "found: `#[cfg_attr(test, allow(clippy::disallowed_methods))]`", - ], - ), - ( - &format!("{engine}/benches/bench.rs:3:"), - &[required, "found: `#[allow(clippy::all)]`"], - ), - ( - &format!("{engine}/examples/example.rs:3:"), - &[required, "clippy::style"], - ), - ( - &format!("{engine}/src/lib.rs:2:"), - &[required, "#![expect(clippy::disallowed_methods"], - ), - ( - &format!("{engine}/tests/it/main.rs:4:"), - &[required, "found: `#[expect(`"], - ), - ], - ); -} - -#[test] -fn the_engine_clippy_tomls_pass() { - let found = engine_clippy_violations(&workspace_root()); - assert!(found.is_empty(), "{}", found.join("\n")); - let clean = clean_root(); - let found = engine_clippy_violations(clean.path()); - assert!( - found.is_empty(), - "the fixture every failure case starts from passes: {found:#?}" - ); -} diff --git a/crates/build-xtask/src/engine_clippy.rs b/crates/build-xtask/src/engine_clippy.rs deleted file mode 100644 index 19ba7ee53..000000000 --- a/crates/build-xtask/src/engine_clippy.rs +++ /dev/null @@ -1,293 +0,0 @@ -//! Engine clippy guard: every Engine crate lints under one of the three -//! Engine `clippy.toml` files, each of them holds every root entry and -//! every determinism ban, and no Engine source suppresses the bans. -//! -//! Clippy reads the nearest `.clippy.toml` or `clippy.toml` walking up from -//! a crate's manifest directory and never merges it with the root file. So -//! `crates/runvil-internal/clippy.toml` binds the seven internal crates, -//! `crates/runvil-engine/clippy.toml` and -//! `crates/runvil-plugin/clippy.toml` bind the two root crates, and each -//! repeats the root file's settings and -//! `disallowed-methods` entries before banning the std calls that sleep, -//! read the clock, wait with a timeout, or seed a hasher from the OS. The -//! gate's clippy runs `--all-targets`, so the bans bind tests, benches, and -//! examples too. -//! -//! The guard fails in three cases: -//! -//! - An Engine crate resolves, from its manifest directory up to the -//! workspace root, to another file or to none. -//! - One of the three files is unreadable, or lacks a root setting with -//! its value, a root `disallowed-methods` entry with its reason, or a -//! determinism ban with a reason. -//! - A `.rs` file under an Engine crate's `src`, `tests`, `benches`, or -//! `examples` allows or expects `clippy::disallowed_methods`, or a group -//! that holds it, at any depth, through `cfg_attr` included. Comments and -//! doc text are not attributes, so prose naming the lint passes. - -use std::fs; -use std::path::{Path, PathBuf}; - -use proc_macro2::TokenStream; - -/// The Engine `clippy.toml` files, relative to the workspace root. -const ENGINE_CLIPPY_TOMLS: [&str; 3] = [ - "crates/runvil-internal/clippy.toml", - "crates/runvil-engine/clippy.toml", - "crates/runvil-plugin/clippy.toml", -]; - -/// The file names clippy looks for in each directory, in its order. -const CONFIG_NAMES: [&str; 2] = [".clippy.toml", "clippy.toml"]; - -/// The key whose entries are compared one by one rather than whole. -const METHODS_KEY: &str = "disallowed-methods"; - -/// The determinism bans every Engine `clippy.toml` holds. -pub(crate) const DETERMINISM_BANS: [&str; 8] = [ - "std::thread::sleep", - "std::time::SystemTime::now", - "std::time::Instant::now", - "std::collections::hash_map::RandomState::new", - "std::sync::Condvar::wait_timeout", - "std::sync::Condvar::wait_timeout_while", - "std::sync::mpsc::Receiver::recv_timeout", - "std::thread::park_timeout", -]; - -/// `clippy::disallowed_methods` and the clippy groups that hold it, any of -/// which an `allow` or `expect` would use to reopen the bans. -const SUPPRESSING_LINTS: [&str; 3] = ["disallowed_methods", "style", "all"]; - -/// The Engine crate directories whose `.rs` files the suppression scan -/// reads. -const SOURCE_DIRS: [&str; 4] = ["src", "tests", "benches", "examples"]; - -/// Runs the guard over every Engine crate: where each crate's clippy -/// config resolves, what the three Engine files hold, and every source -/// under the scanned directories. -#[must_use] -pub(crate) fn engine_clippy_violations(root: &Path) -> Vec { - let crates = crate::engine_guards::engine_crates(root); - let mut violations = Vec::new(); - let root_file = root.join("clippy.toml"); - let root_config = match read_config(&root_file) { - Ok(config) => Some(config), - Err(error) => { - violations.push(format!( - "{}: required: a root clippy.toml whose entries the Engine files repeat; found: {error}", - root_file.display() - )); - None - } - }; - let required: Vec = ENGINE_CLIPPY_TOMLS - .iter() - .map(|path| at(root, path)) - .collect(); - for dir in &crates { - violations.extend(resolution_violation(root, dir, &required)); - } - for file in &required { - match read_config(file) { - Ok(config) => { - if let Some(root_config) = &root_config { - violations.extend(root_entry_violations(file, root_config, &config)); - } - violations.extend(ban_violations(file, &config)); - } - Err(error) => violations.push(format!( - "{}: required: an Engine clippy.toml holding every root entry and determinism ban; found: {error}", - file.display() - )), - } - } - let mut files: Vec = crates - .iter() - .flat_map(|dir| SOURCE_DIRS.iter().map(move |sub| dir.join(sub))) - .flat_map(|dir| crate::tidy::rust_files(&dir)) - .collect(); - files.sort(); - for file in &files { - match fs::read_to_string(file) { - Ok(text) => violations.extend(suppression_violations(file, &text)), - Err(error) => violations.push(format!( - "{}: required: a source the suppression scan can read; found: {error}", - file.display() - )), - } - } - violations -} - -/// The finding for an Engine crate whose nearest clippy config is not one -/// of `required`. A directory without a manifest is left to the dependency -/// guard, which reports it. -fn resolution_violation(root: &Path, dir: &Path, required: &[PathBuf]) -> Option { - let manifest = dir.join("Cargo.toml"); - if !manifest.is_file() { - return None; - } - let resolved = dir - .ancestors() - .take_while(|ancestor| ancestor.starts_with(root)) - .flat_map(|ancestor| CONFIG_NAMES.iter().map(move |name| ancestor.join(name))) - .find(|candidate| candidate.is_file()); - if resolved - .as_ref() - .is_some_and(|file| required.contains(file)) - { - return None; - } - let found = resolved.map_or_else( - || "no clippy.toml at or below the workspace root".to_owned(), - |file| file.display().to_string(), - ); - Some(format!( - "{}: clippy reads only the nearest clippy.toml above a crate; required: the nearest clippy.toml is one of {}; found: {found}", - manifest.display(), - ENGINE_CLIPPY_TOMLS.join(", ") - )) -} - -/// The root settings and `disallowed-methods` entries `config` lacks or -/// holds with another value, because clippy does not merge it with the -/// root file. -fn root_entry_violations(file: &Path, root: &toml::Table, config: &toml::Table) -> Vec { - let mut violations = Vec::new(); - for (key, value) in root.iter().filter(|(key, _)| key.as_str() != METHODS_KEY) { - let found = match config.get(key) { - Some(held) if held == value => continue, - Some(held) => format!("`{key} = {held}`"), - None => format!("no `{key}`"), - }; - violations.push(format!( - "{}: required: the root clippy.toml's setting `{key} = {value}`; found: {found}", - file.display() - )); - } - let held = methods(config); - for entry in methods(root) { - if held.contains(&entry) { - continue; - } - let path = entry_path(entry).unwrap_or_default(); - let found = held - .iter() - .find(|held| entry_path(held) == Some(path)) - .map_or_else( - || format!("no entry for `{path}`"), - |held| format!("`{held}`"), - ); - violations.push(format!( - "{}: required: the root clippy.toml's {METHODS_KEY} entry `{entry}`; found: {found}", - file.display() - )); - } - violations -} - -/// The determinism bans `config` lacks, or holds without a reason. -fn ban_violations(file: &Path, config: &toml::Table) -> Vec { - let held = methods(config); - DETERMINISM_BANS - .iter() - .filter_map(|ban| { - let entry = held.iter().find(|entry| entry_path(entry) == Some(*ban)); - let found = match entry { - Some(entry) if has_reason(entry) => return None, - Some(_) => "an entry with no reason", - None => "no entry", - }; - Some(format!( - "{}: required: the determinism ban `{ban}` with a reason that names the replacement; found: {found}", - file.display() - )) - }) - .collect() -} - -/// The `disallowed-methods` entries in `config`, in order. -fn methods(config: &toml::Table) -> Vec<&toml::Value> { - config - .get(METHODS_KEY) - .and_then(toml::Value::as_array) - .map(|entries| entries.iter().collect()) - .unwrap_or_default() -} - -/// The path a `disallowed-methods` entry bans: the entry itself when it is -/// a string, else its `path`. -fn entry_path(entry: &toml::Value) -> Option<&str> { - entry - .as_str() - .or_else(|| entry.get("path").and_then(toml::Value::as_str)) -} - -/// Whether a `disallowed-methods` entry gives a non-blank reason. -fn has_reason(entry: &toml::Value) -> bool { - entry - .get("reason") - .and_then(toml::Value::as_str) - .is_some_and(|reason| !reason.trim().is_empty()) -} - -/// Reads and parses one clippy config, or says why it could not. -fn read_config(file: &Path) -> Result { - let text = - fs::read_to_string(file).map_err(|error| format!("unreadable clippy.toml: {error}"))?; - toml::from_str(&text).map_err(|error| format!("unparseable clippy.toml: {error}")) -} - -/// `path`, written with `/`, under `root` with the platform's separators. -fn at(root: &Path, path: &str) -> PathBuf { - path.split('/') - .fold(root.to_path_buf(), |dir, part| dir.join(part)) -} - -/// The attributes in one source file that suppress the bans, labeled with -/// `file` and the line each opens on. -fn suppression_violations(file: &Path, text: &str) -> Vec { - let tokens: TokenStream = match text.parse() { - Ok(tokens) => tokens, - Err(error) => { - return vec![format!( - "{}:{}: required: a source that lexes as Rust, so the suppression scan can read it; found: {error}", - file.display(), - error.span().start().line - )]; - } - }; - let mut lines = Vec::new(); - crate::unsafe_allowlist::collect_relaxations(tokens, &suppressing_lint, &mut lines); - let source: Vec<&str> = text.lines().collect(); - lines - .into_iter() - .map(|line| { - let found = line - .checked_sub(1) - .and_then(|index| source.get(index)) - .map_or("", |text| text.trim()); - format!( - "{}:{line}: required: no allow or expect of clippy::disallowed_methods, or of a group that holds it, in an Engine crate, because it reopens the determinism bans; found: `{found}`", - file.display() - ) - }) - .collect() -} - -/// Whether a lint path is `clippy::` followed by one of -/// [`SUPPRESSING_LINTS`]. -fn suppressing_lint(path: &syn::Path) -> bool { - let segments: Vec = path - .segments - .iter() - .map(|segment| segment.ident.to_string()) - .collect(); - matches!(segments.as_slice(), [tool, lint] - if tool == "clippy" && SUPPRESSING_LINTS.contains(&lint.as_str())) -} - -#[cfg(test)] -#[path = "engine_clippy-tests.rs"] -mod tests; diff --git a/crates/build-xtask/src/engine_deps-tests.rs b/crates/build-xtask/src/engine_deps-tests.rs deleted file mode 100644 index 9d319a22e..000000000 --- a/crates/build-xtask/src/engine_deps-tests.rs +++ /dev/null @@ -1,408 +0,0 @@ -//! Fixture tests for the Engine dependency guard: manifest cases write one -//! manifest into a temporary directory and scan it in isolation; graph -//! cases write a small workspace of local path crates and resolve it. - -use std::path::{Path, PathBuf}; - -use super::*; - -/// Writes one manifest into a fresh temporary directory and returns its path -/// beside the directory guard that keeps it alive. -fn manifest(text: &str) -> (tempfile::TempDir, PathBuf) { - let dir = tempfile::TempDir::new().expect("tempdir"); - let path = dir.path().join("Cargo.toml"); - std::fs::write(&path, format!("[package]\nname = \"fixture\"\n{text}")) - .expect("the manifest writes"); - (dir, path) -} - -#[test] -fn a_clean_engine_manifest_has_no_violations() { - let (_dir, path) = manifest( - "[dependencies]\nserde = \"1\"\nmlua = { version = \"0.10\", features = [\"lua54\"] }\n\ - [build-dependencies]\ncc = \"1\"\n", - ); - let violations = forbidden_engine_dependencies(&path); - assert!(violations.is_empty(), "{violations:?}"); -} - -#[test] -fn a_forbidden_crate_in_dependencies_is_reported() { - let (_dir, path) = - manifest("[dependencies]\ntokio = { version = \"1\", features = [\"rt\"] }\n"); - let violations = forbidden_engine_dependencies(&path); - assert_eq!(violations.len(), 1, "{violations:?}"); - let rendered = violations[0].to_string(); - assert!( - rendered.contains("[dependencies]") && rendered.contains("tokio"), - "the violation names the table and the crate: {rendered}" - ); -} - -#[test] -fn a_forbidden_crate_in_dev_dependencies_is_reported() { - let (_dir, path) = - manifest("[dev-dependencies]\ntokio = { version = \"1\", features = [\"test-util\"] }\n"); - let violations = forbidden_engine_dependencies(&path); - assert_eq!(violations.len(), 1, "{violations:?}"); - let rendered = violations[0].to_string(); - assert!( - rendered.contains("[dev-dependencies]") && rendered.contains("tokio"), - "tests are Engine code too, so dev-dependencies are scanned: {rendered}" - ); -} - -#[test] -fn tokio_in_a_target_specific_dev_table_is_reported() { - let (_dir, path) = manifest("[target.'cfg(windows)'.dev-dependencies]\ntokio = \"1\"\n"); - let violations = forbidden_engine_dependencies(&path); - assert_eq!(violations.len(), 1, "{violations:?}"); - let rendered = violations[0].to_string(); - assert!( - rendered.contains("[target.'cfg(windows)'.dev-dependencies]") && rendered.contains("tokio"), - "the violation names the target-specific dev table: {rendered}" - ); -} - -#[test] -fn an_optional_entry_enabled_only_by_test_support_is_reported() { - let (_dir, path) = manifest( - "[dependencies]\ntokio = { version = \"1\", optional = true }\n\ - [features]\ntest-support = [\"dep:tokio\"]\n", - ); - let violations = forbidden_engine_dependencies(&path); - assert_eq!( - violations.len(), - 1, - "no feature exempts a forbidden entry: {violations:?}" - ); -} - -#[test] -fn an_optional_entry_enabled_by_another_feature_is_reported() { - let (_dir, path) = manifest( - "[dependencies]\nreqwest = { version = \"0.12\", optional = true }\n\ - [features]\ntest-support = [\"dep:reqwest\"]\nnet = [\"dep:reqwest\"]\n", - ); - let violations = forbidden_engine_dependencies(&path); - assert_eq!( - violations.len(), - 1, - "an optional entry is reported once, however many features enable it: {violations:?}" - ); -} - -#[test] -fn a_dep_gated_entry_also_enabled_by_a_strong_feature_path_is_reported() { - let (_dir, path) = manifest( - "[dependencies]\ntokio = { version = \"1\", optional = true }\n\ - [features]\ntest-support = [\"dep:tokio\"]\nrt = [\"tokio/rt\"]\n", - ); - let violations = forbidden_engine_dependencies(&path); - assert_eq!(violations.len(), 1, "{violations:?}"); -} - -#[test] -fn a_dep_gated_entry_with_only_a_weak_feature_path_is_reported() { - let (_dir, path) = manifest( - "[dependencies]\ntokio = { version = \"1\", optional = true }\n\ - [features]\ntest-support = [\"dep:tokio\"]\nrt = [\"tokio?/rt\"]\n", - ); - let violations = forbidden_engine_dependencies(&path); - assert_eq!(violations.len(), 1, "{violations:?}"); -} - -#[test] -fn an_entry_whose_test_support_gate_is_enabled_by_default_is_reported() { - let (_dir, path) = manifest( - "[dependencies]\ntokio = { version = \"1\", optional = true }\n\ - [features]\ndefault = [\"test-support\"]\ntest-support = [\"dep:tokio\"]\n", - ); - let violations = forbidden_engine_dependencies(&path); - assert_eq!(violations.len(), 1, "{violations:?}"); -} - -#[test] -fn an_optional_entry_with_an_implicit_feature_is_reported() { - let (_dir, path) = manifest( - "[dependencies]\ntokio = { version = \"1\", optional = true }\n\ - [features]\ntest-support = [\"tokio\"]\n", - ); - let violations = forbidden_engine_dependencies(&path); - assert_eq!(violations.len(), 1, "{violations:?}"); -} - -#[test] -fn build_and_target_specific_tables_are_scanned_and_renames_resolved() { - let (_dir, path) = manifest( - "[build-dependencies]\nasync-trait = \"0.1\"\n\ - [target.'cfg(windows)'.dependencies]\ntu = { package = \"tokio-util\", version = \"0.7\" }\n\ - [target.'cfg(unix)'.dev-dependencies]\ntokio = \"1\"\n", - ); - let violations = forbidden_engine_dependencies(&path); - assert_eq!(violations.len(), 3, "{violations:?}"); - let rendered: Vec = violations.iter().map(ToString::to_string).collect(); - assert!( - rendered - .iter() - .any(|v| v.contains("[build-dependencies]") && v.contains("async-trait")), - "the build-dependencies entry is reported: {rendered:?}" - ); - assert!( - rendered - .iter() - .any(|v| v.contains("cfg(windows)") && v.contains("tokio-util")), - "the renamed target-specific entry is reported by package name: {rendered:?}" - ); - assert!( - rendered - .iter() - .any(|v| v.contains("cfg(unix)'.dev-dependencies") && v.contains("tokio")), - "the target-specific dev entry is reported: {rendered:?}" - ); -} - -#[test] -fn an_unreadable_or_unparseable_manifest_is_reported() { - let (dir, path) = manifest("not [valid toml"); - let violations = forbidden_engine_dependencies(&path); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].to_string().contains("unparseable manifest"), - "{violations:?}" - ); - let missing = forbidden_engine_dependencies(&dir.path().join("absent").join("Cargo.toml")); - assert_eq!(missing.len(), 1, "{missing:?}"); - assert!( - missing[0].to_string().contains("unreadable manifest"), - "{missing:?}" - ); - for crate_dir in [dir.path().to_path_buf(), dir.path().join("absent")] { - let graph = forbidden_engine_graph(dir.path(), &crate_dir); - assert!( - graph.is_empty(), - "the manifest scan alone reports an unreadable manifest: {graph:?}" - ); - } - let unnamed = dir.path().join("unnamed"); - std::fs::create_dir_all(&unnamed).expect("the crate directory creates"); - std::fs::write( - unnamed.join("Cargo.toml"), - "[dependencies]\nserde = \"1\"\n", - ) - .expect("the manifest writes"); - let declared = forbidden_engine_dependencies(&unnamed.join("Cargo.toml")); - assert!(declared.is_empty(), "{declared:?}"); - let unchecked = forbidden_engine_graph(dir.path(), &unnamed); - assert_eq!(unchecked.len(), 1, "{unchecked:?}"); - assert!( - unchecked[0].to_string().contains("names no package"), - "a manifest that names no package leaves its graph unchecked, so it is reported: {unchecked:?}" - ); -} - -/// One local crate in a graph fixture: its directory under the fixture -/// root, its package name, and its manifest body after `[package]`. -type LocalCrate<'a> = (&'a str, &'a str, &'a str); - -/// The local stand-ins for forbidden crates every graph fixture holds, so -/// `cargo tree` resolves offline. The `tempfile` stand-in depends on the -/// `getrandom` and `fastrand` ones, as the real crate does. -const STAND_INS: [LocalCrate<'static>; 4] = [ - ("vendor/tokio", "tokio", ""), - ("vendor/getrandom", "getrandom", ""), - ("vendor/fastrand", "fastrand", ""), - ( - "vendor/tempfile", - "tempfile", - "[dependencies]\ngetrandom = { path = \"../getrandom\" }\n\ - fastrand = { path = \"../fastrand\" }\n", - ), -]; - -/// Writes a fixture workspace whose members are the stand-ins and `crates`. -fn graph_workspace(crates: &[LocalCrate<'_>]) -> tempfile::TempDir { - let root = tempfile::TempDir::new().expect("tempdir"); - let mut members = Vec::new(); - for (dir, name, body) in STAND_INS.iter().chain(crates) { - let crate_dir = root.path().join(dir); - std::fs::create_dir_all(crate_dir.join("src")).expect("the crate directory creates"); - std::fs::write( - crate_dir.join("Cargo.toml"), - format!( - "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n{body}" - ), - ) - .expect("the manifest writes"); - std::fs::write(crate_dir.join("src").join("lib.rs"), "").expect("lib.rs writes"); - members.push(format!("\"{dir}\"")); - } - std::fs::write( - root.path().join("Cargo.toml"), - format!( - "[workspace]\nresolver = \"3\"\nmembers = [{}]\n", - members.join(", ") - ), - ) - .expect("the workspace manifest writes"); - root -} - -/// The rendered graph findings for the fixture crate at `dir`. -fn graph_findings(root: &Path, dir: &str) -> Vec { - forbidden_engine_graph(root, &root.join(dir)) - .iter() - .map(ToString::to_string) - .collect() -} - -#[test] -fn a_forbidden_crate_reached_through_a_dev_dependency_is_reported() { - let engine = "crates/runvil-internal/runtime"; - let root = graph_workspace(&[ - ( - "vendor/driver", - "driver", - "[dependencies]\ntokio = { path = \"../tokio\" }\n", - ), - ( - engine, - "runvil-runtime", - "[dev-dependencies]\ndriver = { path = \"../../../vendor/driver\" }\n", - ), - ]); - let declared = forbidden_engine_dependencies(&root.path().join(engine).join("Cargo.toml")); - assert!( - declared.is_empty(), - "the manifest names no forbidden crate, so only the graph can catch it: {declared:?}" - ); - let found = graph_findings(root.path(), engine); - assert_eq!(found.len(), 1, "{found:?}"); - assert!( - found[0].contains("runvil-runtime") - && found[0].contains("resolved graph") - && found[0].contains("tokio"), - "the finding names the Engine crate, the graph, and the package: {found:?}" - ); -} - -#[test] -fn a_forbidden_crate_reached_only_on_another_platform_is_reported() { - let engine = "crates/runvil-internal/runtime"; - let root = graph_workspace(&[ - ( - "vendor/driver", - "driver", - "[target.wasm32-unknown-unknown.dependencies]\ntokio = { path = \"../tokio\" }\n", - ), - ( - engine, - "runvil-runtime", - "[dependencies]\ndriver = { path = \"../../../vendor/driver\" }\n", - ), - ]); - let declared = forbidden_engine_dependencies(&root.path().join(engine).join("Cargo.toml")); - assert!( - declared.is_empty(), - "the manifest names no forbidden crate, so only the graph can catch it: {declared:?}" - ); - let found = graph_findings(root.path(), engine); - assert_eq!(found.len(), 1, "{found:?}"); - assert!( - found[0].contains("runvil-runtime") - && found[0].contains("resolved graph") - && found[0].contains("tokio"), - "a forbidden crate behind a non-host target edge is reported: {found:?}" - ); -} - -#[test] -fn getrandom_reached_through_any_edge_is_reported() { - let root = graph_workspace(&[ - ( - "vendor/seeder", - "seeder", - "[dependencies]\ngetrandom = { path = \"../getrandom\" }\n", - ), - ( - "crates/runvil-internal/normal", - "runvil-normal", - "[dependencies]\nseeder = { path = \"../../../vendor/seeder\" }\n", - ), - ( - "crates/runvil-internal/build", - "runvil-build", - "[build-dependencies]\nseeder = { path = \"../../../vendor/seeder\" }\n", - ), - ( - "crates/runvil-internal/dev", - "runvil-dev", - "[dev-dependencies]\nseeder = { path = \"../../../vendor/seeder\" }\n", - ), - // Only a graph resolved with all features reaches this optional entry. - ( - "crates/runvil-internal/optional", - "runvil-optional", - "[dependencies]\nseeder = { path = \"../../../vendor/seeder\", optional = true }\n\ - [features]\nseed = [\"dep:seeder\"]\n", - ), - ]); - for edge in ["normal", "build", "dev", "optional"] { - let found = graph_findings(root.path(), &format!("crates/runvil-internal/{edge}")); - assert_eq!(found.len(), 1, "{edge}: {found:?}"); - assert!( - found[0].contains(&format!("runvil-{edge}")) && found[0].contains("getrandom"), - "getrandom reached through the {edge} seeder entry is reported: {found:?}" - ); - } -} - -#[test] -fn a_tempfile_dev_dependency_is_reported_for_getrandom_and_fastrand() { - let vfs = "crates/runvil-internal/vfs"; - let root = graph_workspace(&[( - vfs, - "runvil-vfs", - "[dev-dependencies]\ntempfile = { path = \"../../../vendor/tempfile\" }\n", - )]); - let found = graph_findings(root.path(), vfs); - assert_eq!(found.len(), 2, "{found:?}"); - assert!( - found[0].contains("fastrand") && found[1].contains("getrandom"), - "each entropy crate is reported once, in name order: {found:?}" - ); - assert!( - found.iter().all(|f| !f.contains("reaches tempfile")), - "tempfile itself is not forbidden: {found:?}" - ); -} - -#[test] -fn a_clean_engine_crate_graph_has_no_violations() { - // `driver` turns on `clean`'s optional tokio. Resolving the workspace as - // a whole would unify that feature into the Engine crate's graph; - // resolving the Engine crate alone does not. - let engine = "crates/runvil-internal/runtime"; - let root = graph_workspace(&[ - ( - "vendor/clean", - "clean", - "[dependencies]\ntokio = { path = \"../tokio\", optional = true }\n\ - [features]\nrt = [\"dep:tokio\"]\n", - ), - ( - "vendor/driver", - "driver", - "[dependencies]\nclean = { path = \"../clean\", features = [\"rt\"] }\n", - ), - ( - engine, - "runvil-runtime", - "[dependencies]\nclean = { path = \"../../../vendor/clean\" }\n\ - [build-dependencies]\nclean = { path = \"../../../vendor/clean\" }\n\ - [dev-dependencies]\nclean = { path = \"../../../vendor/clean\" }\n", - ), - ]); - let found = graph_findings(root.path(), engine); - assert!(found.is_empty(), "{found:?}"); -} diff --git a/crates/build-xtask/src/engine_deps.rs b/crates/build-xtask/src/engine_deps.rs deleted file mode 100644 index faf14a196..000000000 --- a/crates/build-xtask/src/engine_deps.rs +++ /dev/null @@ -1,243 +0,0 @@ -//! Engine dependency guard: no Engine crate depends on an async runtime, -//! an HTTP client, or an OS entropy source, directly or transitively. -//! -//! The Engine (the `runvil-*` crates) is a -//! deterministic state machine: every wait becomes an effect the Harness -//! performs, and every pseudo-random value comes from a seed. Its crates, -//! tests included, therefore never depend on `tokio`, `tokio-util`, -//! `async-trait`, `reqwest`, `getrandom` (the OS entropy source), or -//! `fastrand` (whose global generator seeds itself). There is no -//! exemption. -//! -//! Two checks enforce it: -//! -//! - The manifest scan reads every dependency table an Engine manifest -//! declares, `[dependencies]`, `[build-dependencies]`, and -//! `[dev-dependencies]`, directly and under `[target.]`, and -//! resolves `package = ...` renames. -//! - The resolved-graph check runs `cargo tree` for each Engine crate with -//! all features and all targets over its normal, build, and dev edges, so -//! a forbidden crate is reported when it arrives through another crate, -//! behind an optional dependency, or only on another platform. Each crate -//! is resolved on its own, because `cargo metadata` resolves features for -//! the whole workspace at once, and a feature another workspace crate -//! turns on must not flag an Engine crate. -//! -//! The calls that would break determinism without a dependency, a sleep, a -//! clock read, a timed wait, or an OS-seeded hasher, are clippy bans that -//! `engine_clippy` keeps in force. - -use std::collections::BTreeSet; -use std::ffi::OsString; -use std::fmt; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -/// The crates no Engine crate may depend on, directly or transitively. -const FORBIDDEN: [&str; 6] = [ - "tokio", - "tokio-util", - "async-trait", - "reqwest", - "getrandom", - "fastrand", -]; - -/// The dependency tables the manifest scan reads, directly and under -/// `[target]`. -const CHECKED_KINDS: [&str; 3] = ["dependencies", "build-dependencies", "dev-dependencies"]; - -/// The `cargo tree` edge kinds the resolved-graph check follows. -const GRAPH_EDGES: &str = "normal,build,dev"; - -/// One finding from checking an Engine crate. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum Violation { - /// A forbidden crate declared in an Engine manifest. - Forbidden { - /// The manifest scanned. - manifest: PathBuf, - /// The table the entry sits in, as it would head the section in the - /// manifest (`dependencies`, `target.'cfg(windows)'.build-dependencies`). - table: String, - /// The forbidden package name, after resolving `package = ...` renames. - package: String, - }, - /// A forbidden crate in an Engine crate's resolved graph. - Reached { - /// The Engine crate's package name. - engine_crate: String, - /// The forbidden package reached. - package: String, - }, - /// The manifest could not be read or parsed. - Unreadable { - /// The manifest scanned. - manifest: PathBuf, - /// What went wrong. - error: String, - }, - /// `cargo tree` could not resolve an Engine crate's graph, or the - /// crate's manifest names no package to resolve. - Unresolved { - /// The Engine crate's package name, or its manifest path when the - /// manifest names none. - engine_crate: String, - /// What went wrong. - error: String, - }, -} - -impl fmt::Display for Violation { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let rule = format!( - "Engine crates never depend on {}, directly or transitively", - FORBIDDEN.join(", ") - ); - match self { - Self::Forbidden { - manifest, - table, - package, - } => write!( - f, - "{}: [{table}] declares {package}; {rule}", - manifest.display() - ), - Self::Reached { - engine_crate, - package, - } => write!( - f, - "{engine_crate}: the resolved graph (all features; all targets; {GRAPH_EDGES} edges) reaches {package}; {rule}" - ), - Self::Unreadable { manifest, error } => { - write!(f, "{}: {error}", manifest.display()) - } - Self::Unresolved { - engine_crate, - error, - } => write!( - f, - "{engine_crate}: the resolved graph is unchecked; required: a graph that resolves; actual: {error}" - ), - } - } -} - -/// Scans one Engine manifest for forbidden dependencies. A manifest that -/// cannot be read or parsed yields one [`Violation::Unreadable`]. -#[must_use] -pub(crate) fn forbidden_engine_dependencies(manifest: &Path) -> Vec { - let value = match read_manifest(manifest) { - Ok(value) => value, - Err(error) => { - return vec![Violation::Unreadable { - manifest: manifest.to_path_buf(), - error, - }]; - } - }; - let mut violations = Vec::new(); - for (table, entries) in crate::manifest::dependency_tables(&value, &CHECKED_KINDS) { - for (key, entry) in entries { - let package = entry - .get("package") - .and_then(toml::Value::as_str) - .unwrap_or(key); - if FORBIDDEN.contains(&package) { - violations.push(Violation::Forbidden { - manifest: manifest.to_path_buf(), - table: table.clone(), - package: package.to_owned(), - }); - } - } - } - violations -} - -/// Resolves the graph of the Engine crate in `crate_dir` for all targets with -/// `cargo tree`, run in the workspace at `root` by the cargo running this -/// process, and reports each forbidden package it reaches once. A root without a -/// `Cargo.toml` has no workspace to resolve, a crate whose manifest cannot -/// be read or parsed is left to the manifest scan, which reports it, and a -/// manifest that names no package yields one [`Violation::Unresolved`]. -#[must_use] -pub(crate) fn forbidden_engine_graph(root: &Path, crate_dir: &Path) -> Vec { - let workspace = root.join("Cargo.toml"); - if !workspace.is_file() { - return Vec::new(); - } - let manifest = crate_dir.join("Cargo.toml"); - let Ok(value) = read_manifest(&manifest) else { - return Vec::new(); - }; - let Some(engine_crate) = package_name(&value) else { - return vec![Violation::Unresolved { - engine_crate: manifest.display().to_string(), - error: "the manifest names no package, so cargo tree has none to resolve".to_owned(), - }]; - }; - let cargo = std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")); - let output = Command::new(cargo) - .current_dir(root) - .args(["tree", "--quiet", "--manifest-path"]) - .arg(&workspace) - .args(["--package", &engine_crate, "--all-features"]) - .args(["--target", "all", "--edges", GRAPH_EDGES]) - .args(["--prefix", "none"]) - .output(); - let stdout = match output { - Ok(output) if output.status.success() => output.stdout, - Ok(output) => { - return vec![Violation::Unresolved { - engine_crate, - error: format!( - "cargo tree failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ), - }]; - } - Err(error) => { - return vec![Violation::Unresolved { - engine_crate, - error: format!("cargo did not start: {error}"), - }]; - } - }; - let stdout = String::from_utf8_lossy(&stdout); - let reached: BTreeSet<&str> = stdout - .lines() - .filter_map(|line| line.split_whitespace().next()) - .filter(|package| FORBIDDEN.contains(package)) - .collect(); - reached - .into_iter() - .map(|package| Violation::Reached { - engine_crate: engine_crate.clone(), - package: package.to_owned(), - }) - .collect() -} - -/// The package name a parsed manifest declares, if it names one. -fn package_name(manifest: &toml::Value) -> Option { - manifest - .get("package")? - .get("name")? - .as_str() - .map(str::to_owned) -} - -/// Reads and parses one manifest, or says why it could not. -fn read_manifest(manifest: &Path) -> Result { - let text = - fs::read_to_string(manifest).map_err(|error| format!("unreadable manifest: {error}"))?; - toml::from_str(&text).map_err(|error| format!("unparseable manifest: {error}")) -} - -#[cfg(test)] -#[path = "engine_deps-tests.rs"] -mod tests; diff --git a/crates/build-xtask/src/engine_guards-tests.rs b/crates/build-xtask/src/engine_guards-tests.rs deleted file mode 100644 index 5e4bd6400..000000000 --- a/crates/build-xtask/src/engine_guards-tests.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! The live Engine guards over this workspace, plus fixture tests that a -//! forbidden dependency in an Engine crate fails the guard. - -use std::path::{Path, PathBuf}; - -use super::*; -use crate::product::test_support; - -fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("build-xtask lives at /crates/build-xtask") - .to_path_buf() -} - -/// Writes one crate under `/crates//` with the given manifest -/// body (after `[package]`) and `src/lib.rs` text. -fn write_crate(root: &Path, dir: &str, manifest: &str, lib: &str) { - let crate_dir = root.join("crates").join(dir); - let src = crate_dir.join("src"); - std::fs::create_dir_all(&src).expect("the crate source directory creates"); - std::fs::write( - crate_dir.join("Cargo.toml"), - format!("[package]\nname = \"fixture\"\n{manifest}"), - ) - .expect("the manifest writes"); - std::fs::write(src.join("lib.rs"), lib).expect("lib.rs writes"); -} - -/// A fake workspace whose root Engine crates and clippy files are clean, so -/// a fixture can add one container crate and see only that crate's -/// findings. -fn clean_engine_root() -> tempfile::TempDir { - let root = tempfile::TempDir::new().expect("tempdir"); - test_support::write_engine_clippy_tomls(root.path()); - for name in ENGINE_ROOT_CRATES { - write_crate( - root.path(), - name, - "[dependencies]\nserde = \"1\"\n", - "pub struct Live;\n", - ); - } - root -} - -#[test] -fn engine_crates_declare_no_forbidden_dependencies() { - let violations = engine_dependency_violations(&workspace_root()); - assert!( - violations.is_empty(), - "engine manifest violations:\n{}", - violations.join("\n") - ); -} - -#[test] -fn the_engine_crate_set_is_the_root_crates_plus_every_container_member() { - let root = clean_engine_root(); - write_crate(root.path(), "runvil-internal/lua", "", "pub struct Vm;\n"); - write_crate( - root.path(), - "runvil-internal/store", - "", - "pub struct Store;\n", - ); - write_crate(root.path(), "harness/runner", "", "pub struct Runner;\n"); - write_crate(root.path(), "shared-crypto", "", "pub struct Digest;\n"); - let mut names: Vec = engine_crates(root.path()) - .iter() - .map(|dir| { - dir.strip_prefix(root.path().join("crates")) - .expect("under crates/") - .to_string_lossy() - .replace('\\', "/") - }) - .collect(); - names.sort(); - assert_eq!( - names, - [ - "runvil-engine", - "runvil-internal/lua", - "runvil-internal/store", - "runvil-plugin", - ], - "harness and shared crates are outside the engine" - ); -} - -#[test] -fn a_forbidden_dependency_in_the_plugin_contract_fails_the_guard() { - let root = clean_engine_root(); - write_crate( - root.path(), - "runvil-plugin", - "[dependencies]\nasync-trait = \"0.1\"\n", - "pub trait Plugin {}\n", - ); - let violations = engine_guard_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("async-trait") && violations[0].contains("runvil-plugin"), - "the Plugin contract is an engine root crate, so the manifest guard binds it: {violations:?}" - ); -} - -#[test] -fn the_facade_forwarding_the_engine_test_support_feature_passes_every_engine_guard() { - let root = tempfile::TempDir::new().expect("tempdir"); - test_support::write_engine_clippy_tomls(root.path()); - test_support::write_crate( - root.path(), - "runvil-internal/runtime", - "runvil-runtime", - "[features]\ntest-support = []\n", - ); - test_support::write_crate(root.path(), "runvil-internal/types", "runvil-types", ""); - test_support::write_crate( - root.path(), - "runvil-engine", - "runvil-engine", - "[dependencies]\nrunvil-runtime = { path = \"../runvil-internal/runtime\" }\n\ - [features]\ntest-support = [\"runvil-runtime/test-support\"]\n", - ); - test_support::write_crate(root.path(), "runvil-plugin", "runvil-plugin", ""); - let violations = engine_guard_violations(root.path()); - assert!( - violations.is_empty(), - "the facade is an engine crate, so forwarding its own test-support is gated: {violations:?}" - ); -} - -#[test] -fn a_forbidden_dependency_in_a_container_crate_fails_the_guard() { - let root = clean_engine_root(); - write_crate( - root.path(), - "runvil-internal/store", - "[dependencies]\ntokio = { version = \"1\", features = [\"rt\"] }\n", - "pub struct Store;\n", - ); - let violations = engine_guard_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("tokio") && violations[0].contains("store"), - "the forbidden dependency is reported against its crate: {violations:?}" - ); -} - -#[test] -fn a_missing_root_engine_crate_is_reported_not_skipped() { - let root = tempfile::TempDir::new().expect("tempdir"); - test_support::write_engine_clippy_tomls(root.path()); - write_crate( - root.path(), - "runvil-internal/runtime", - "[dependencies]\nserde = \"1\"\n", - "pub struct Live;\n", - ); - write_crate( - root.path(), - "runvil-plugin", - "[dependencies]\nserde = \"1\"\n", - "pub struct Live;\n", - ); - let facade_manifest = root - .path() - .join("crates") - .join("runvil-engine") - .join("Cargo.toml"); - let violations = engine_guard_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with(&facade_manifest.display().to_string()) - && violations[0].contains("unreadable manifest"), - "{violations:?}" - ); -} diff --git a/crates/build-xtask/src/engine_guards.rs b/crates/build-xtask/src/engine_guards.rs deleted file mode 100644 index 358b6d92e..000000000 --- a/crates/build-xtask/src/engine_guards.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Engine guards, live: the dependency guard's manifest scan and -//! resolved-graph check and the clippy guard run over the Engine crates, -//! and the `test-support` leak guard runs over the whole workspace, as -//! part of `cargo test -p build-xtask` and `cargo xtask tidy`. -//! -//! The Engine is the `runvil-engine` facade, the `runvil-plugin` -//! contract, and every crate under the `crates/runvil-internal/` -//! container. The root crates are named, so a missing manifest is -//! reported rather than skipped; the container's members are discovered, -//! so a new Engine crate is covered the moment it lands. - -use std::fs; -use std::path::{Path, PathBuf}; - -/// The Engine crates that live directly under `crates/`. -const ENGINE_ROOT_CRATES: [&str; 2] = ["runvil-engine", "runvil-plugin"]; - -/// The private container whose every member is an Engine crate. -pub(crate) const ENGINE_CONTAINER: &str = "runvil-internal"; - -/// Every Engine crate directory: the named root crates first, whether or -/// not they exist, then every crate under the container in path order. -#[must_use] -pub(crate) fn engine_crates(root: &Path) -> Vec { - let crates_dir = root.join("crates"); - let mut crates: Vec = ENGINE_ROOT_CRATES - .iter() - .map(|name| crates_dir.join(name)) - .collect(); - collect_crates(&crates_dir.join(ENGINE_CONTAINER), &mut crates); - crates -} - -/// Every crate directory under `dir`, in path order: a directory holding a -/// `Cargo.toml` is a crate and is not descended into; any other directory -/// is a container and the walk descends. The order is sorted because -/// `read_dir` order differs between filesystems, and findings must not. -pub(crate) fn collect_crates(dir: &Path, crates: &mut Vec) { - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - let mut subs: Vec = entries.flatten().map(|entry| entry.path()).collect(); - subs.sort(); - for sub in subs { - if !sub.is_dir() { - continue; - } - if sub.join("Cargo.toml").exists() { - crates.push(sub); - } else { - collect_crates(&sub, crates); - } - } -} - -/// Runs the dependency guard over every Engine crate: its manifest scan, -/// then its resolved-graph check. -#[must_use] -fn engine_dependency_violations(root: &Path) -> Vec { - engine_crates(root) - .iter() - .flat_map(|dir| { - let mut violations = - crate::engine_deps::forbidden_engine_dependencies(&dir.join("Cargo.toml")); - violations.extend(crate::engine_deps::forbidden_engine_graph(root, dir)); - violations - }) - .map(|violation| violation.to_string()) - .collect() -} - -/// Every Engine guard, in one list: the dependency guard, the clippy -/// guard, and the workspace-wide `test-support` leak guard. -#[must_use] -pub(crate) fn engine_guard_violations(root: &Path) -> Vec { - let mut violations = engine_dependency_violations(root); - violations.extend(crate::engine_clippy::engine_clippy_violations(root)); - violations.extend(crate::test_support_leak::test_support_leak_violations(root)); - violations -} - -#[cfg(test)] -#[path = "engine_guards-tests.rs"] -mod tests; diff --git a/crates/build-xtask/src/facade_shape-tests.rs b/crates/build-xtask/src/facade_shape-tests.rs deleted file mode 100644 index 8bec77011..000000000 --- a/crates/build-xtask/src/facade_shape-tests.rs +++ /dev/null @@ -1,375 +0,0 @@ -//! The facade shape check over this workspace's facades, plus fixtures that -//! each allowed form passes and each forbidden form fails at its line. - -use std::path::{Path, PathBuf}; - -use super::*; - -fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("build-xtask lives at /crates/build-xtask") - .to_path_buf() -} - -/// The dependency crates the source fixtures may re-export from. -const FIXTURE_CRATES: [&str; 3] = ["runvil_runtime", "runvil_model_client", "runvil_types"]; - -/// A facade manifest declaring exactly [`FIXTURE_CRATES`]. -const FIXTURE_MANIFEST: &str = "[package]\nname = \"runvil-engine\"\n\n[dependencies]\n\ - runvil-runtime.workspace = true\n\ - runvil-model-client.workspace = true\n\ - runvil-types.workspace = true\n"; - -fn check(text: &str) -> Vec { - let crates = FIXTURE_CRATES - .iter() - .map(|name| (*name).to_owned()) - .collect(); - source_violations(Path::new("lib.rs"), text, &crates) -} - -/// Asserts `text` has exactly one violation, reported at `line` for `rule`. -fn assert_one(text: &str, line: usize, rule: &str) { - let violations = check(text); - assert_eq!(violations.len(), 1, "{text}\n{violations:?}"); - assert!( - violations[0].starts_with(&format!("lib.rs:{line}: {rule}: required ")), - "{text}\n{violations:?}" - ); -} - -fn facade_dir(root: &Path) -> PathBuf { - root.join("crates").join("runvil-engine") -} - -/// Writes `manifest`, when given, as `dir/Cargo.toml`, and each -/// `(path, text)` under `dir/src/`. -fn write_facade(dir: &Path, manifest: Option<&str>, files: &[(&str, &str)]) { - std::fs::create_dir_all(dir).expect("the facade directory creates"); - if let Some(manifest) = manifest { - std::fs::write(dir.join("Cargo.toml"), manifest).expect("the manifest writes"); - } - let src = dir.join("src"); - for (path, text) in files { - let file = src.join(path); - let dir = file.parent().expect("a source file has a directory"); - std::fs::create_dir_all(dir).expect("the source directory creates"); - std::fs::write(&file, text).expect("the source file writes"); - } -} - -/// A fake workspace whose `crates/runvil-engine/` facade holds `manifest`, -/// when given, and each `(path, text)` under its `src/`. -fn facade_root(manifest: Option<&str>, files: &[(&str, &str)]) -> tempfile::TempDir { - let root = tempfile::TempDir::new().expect("tempdir"); - write_facade(&facade_dir(root.path()), manifest, files); - root -} - -#[test] -fn the_real_facades_pass_the_shape_check() { - let violations = facade_shape_violations(&workspace_root()); - assert!( - violations.is_empty(), - "facade shape violations:\n{}", - violations.join("\n") - ); -} - -#[test] -fn the_harness_facade_is_held_to_the_same_shape() { - let root = facade_root( - Some(FIXTURE_MANIFEST), - &[("lib.rs", "pub use runvil_runtime::Run;\n")], - ); - let harness = root.path().join("crates").join("runvil"); - write_facade( - &harness, - Some( - "[package]\nname = \"runvil\"\n\n[dependencies]\n\ - harness-runner.workspace = true\n\ - harness-sessions.workspace = true\n", - ), - &[( - "lib.rs", - "pub use harness_sessions::runtime::Harness;\n\ - pub use harness_runner::cancel::{CancelHandle, scope};\n", - )], - ); - let violations = facade_shape_violations(root.path()); - let lib = harness.join("src").join("lib.rs"); - assert_eq!( - violations, - [format!( - "{}:2: grouped use list: required {REEXPORT}, found `pub use harness_runner::cancel::{{CancelHandle, scope}};`", - lib.display() - )] - ); -} - -#[test] -fn every_allowed_form_passes() { - let text = r#"#![doc = include_str!("lib.md")] - -pub use runvil_runtime::Run; -pub use runvil_model_client::model::CompletionOptions as ModelOptions; -pub use ::runvil_runtime::Step; - -/// The effects a run issues. -pub mod effect { - //! More on effects. - - /// A re-export may carry its own docs. - #[doc(inline)] - pub use runvil_runtime::Effect; - - /// A grouping module may nest. - pub mod record { - pub use runvil_runtime::EffectRecord; - } -} - -/// A grouping module may live in its own file. -pub mod event; -"#; - let violations = check(text); - assert!(violations.is_empty(), "{violations:?}"); -} - -#[test] -fn a_violation_names_the_file_line_rule_required_and_found() { - let violations = check("/// Effects.\npub mod effect {\n pub use runvil_runtime::*;\n}\n"); - assert_eq!( - violations, - [format!( - "lib.rs:3: glob re-export: required {REEXPORT}, found `pub use runvil_runtime::*;`" - )] - ); -} - -#[test] -fn glob_grouped_crate_and_facade_path_re_exports_are_rejected() { - for (text, rule) in [ - ("pub use runvil_runtime::*;", "glob re-export"), - ("pub use runvil_runtime::input::*;", "glob re-export"), - ("pub use runvil_runtime::{Run, Step};", "grouped use list"), - ("pub use runvil_runtime::{Run};", "grouped use list"), - ("pub use runvil_runtime;", "crate re-export"), - ("pub use runvil_runtime as engine;", "crate re-export"), - ("pub use ::runvil_runtime;", "crate re-export"), - ( - "pub use crate::effect::Effect;", - "re-export of a facade path", - ), - ( - "pub use self::effect::Effect;", - "re-export of a facade path", - ), - ("pub use super::Run;", "re-export of a facade path"), - ("use runvil_runtime::Run;", "non-public use"), - ("pub(crate) use runvil_runtime::Run;", "non-public use"), - ] { - assert_one(text, 1, rule); - } -} - -#[test] -fn a_re_export_rooted_outside_the_facade_dependencies_is_rejected() { - const RULE: &str = "re-export not rooted at a facade dependency"; - for (text, line) in [ - ("pub use effect::Effect;", 1), - ( - "/// Effects.\npub mod effect {\n pub use record::EffectRecord;\n}\n", - 3, - ), - ("pub use std::sync::Arc;", 1), - ("pub use ::serde::Serialize;", 1), - ("pub use runvil_lua::VfsOp;", 1), - ] { - assert_one(text, line, RULE); - } -} - -#[test] -fn the_dependency_crates_are_the_manifest_dependency_keys() { - let manifest = "[package]\nname = \"runvil-engine\"\n\n\ - [dependencies]\n\ - runvil-runtime.workspace = true\n\ - types = { package = \"runvil-types\", workspace = true }\n\n\ - [target.'cfg(windows)'.dependencies]\n\ - runvil-vfs.workspace = true\n\n\ - [dev-dependencies]\n\ - serde_json.workspace = true\n"; - let lib = "pub use runvil_runtime::Run;\n\ - pub use types::event::Event;\n\ - pub use runvil_vfs::Vfs;\n\ - pub use serde_json::Value;\n\ - pub use runvil_types::ids::RunId;\n"; - let root = facade_root(Some(manifest), &[("lib.rs", lib)]); - let violations = facade_violations(&facade_dir(root.path())); - assert_eq!(violations.len(), 2, "{violations:?}"); - for (violation, line) in violations.iter().zip([4, 5]) { - assert!( - violation.contains(&format!( - "lib.rs:{line}: re-export not rooted at a facade dependency: " - )), - "{violations:?}" - ); - } -} - -#[test] -fn every_item_definition_is_rejected_with_its_kind() { - for (text, kind) in [ - ("pub fn run() {}", "fn"), - ("pub struct Run;", "struct"), - ("pub enum Step {}", "enum"), - ("pub trait Host {}", "trait"), - ("impl Clone for Run {}", "impl"), - ("pub const LIMIT: u32 = 1;", "const"), - ("pub static NAME: &str = \"\";", "static"), - ("pub type Result = ();", "type alias"), - ("macro_rules! forward { () => {} }", "macro_rules!"), - ("pub union Bits { a: u32 }", "union"), - ("extern crate runvil_runtime;", "extern crate"), - ("extern \"C\" {}", "extern block"), - ("include!(\"items.rs\");", "macro invocation"), - ] { - assert_one(text, 1, &format!("item definition ({kind})")); - } -} - -#[test] -fn an_item_definition_inside_a_grouping_module_is_rejected() { - assert_one( - "/// Effects.\npub mod effect {\n /// Hidden.\n pub struct Hidden;\n}\n", - 4, - "item definition (struct)", - ); -} - -#[test] -fn attributes_other_than_doc_are_rejected() { - for (text, line) in [ - ("#![forbid(unsafe_code)]\n", 1), - ("#[allow(unused_imports)]\npub use runvil_runtime::Run;", 1), - ("#[cfg(test)]\npub use runvil_runtime::Run;", 1), - ( - "#[cfg(feature = \"other\")]\npub use runvil_runtime::Run;", - 1, - ), - ( - "#[cfg(feature = \"test-support\")]\npub use runvil_runtime::Run;", - 1, - ), - ( - "#[cfg(not(feature = \"test-support\"))]\npub use runvil_runtime::Run;", - 1, - ), - ("/// Effects.\n#[path = \"effects.rs\"]\npub mod effect;", 2), - ] { - assert_one(text, line, "disallowed attribute"); - } -} - -#[test] -fn a_module_must_be_public_and_carry_a_doc_comment() { - for (text, line, rule) in [ - ("/// Effects.\nmod effect {}", 2, "non-public module"), - ( - "/// Effects.\npub(crate) mod effect {}", - 2, - "non-public module", - ), - ("pub mod effect {}", 1, "undocumented module"), - ("pub mod effect;", 1, "undocumented module"), - ( - "#[doc(hidden)]\npub mod effect {}", - 2, - "undocumented module", - ), - ] { - assert_one(text, line, rule); - } -} - -#[test] -fn unparseable_source_is_reported_at_its_line() { - assert_one( - "pub use runvil_runtime::Run;\npub use = Step;\n", - 2, - "unparseable source", - ); -} - -#[test] -fn every_source_file_under_the_facade_src_is_checked() { - let root = facade_root( - Some(FIXTURE_MANIFEST), - &[ - ("lib.rs", "/// Events.\npub mod event;\n"), - ("event.rs", "pub use runvil_types::event::Event;\n"), - ("nested/stray.rs", "pub struct Stray;\n"), - ], - ); - let violations = facade_violations(&facade_dir(root.path())); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("stray.rs:1: item definition (struct)"), - "{violations:?}" - ); -} - -#[test] -fn a_missing_facade_crate_root_is_reported_not_skipped() { - let root = tempfile::TempDir::new().expect("tempdir"); - let violations = facade_shape_violations(root.path()); - assert_eq!(violations.len(), 2, "{violations:?}"); - for (violation, facade) in violations.iter().zip(["runvil-engine", "runvil"]) { - let lib = root - .path() - .join("crates") - .join(facade) - .join("src") - .join("lib.rs"); - assert!( - violation.starts_with(&lib.display().to_string()), - "{violations:?}" - ); - } -} - -#[test] -fn an_unreadable_facade_source_is_reported_not_skipped() { - let root = facade_root( - Some(FIXTURE_MANIFEST), - &[("lib.rs", "/// Events.\npub mod event;\n")], - ); - let event = facade_dir(root.path()).join("src").join("event.rs"); - std::fs::write(&event, [0xff, 0xfe, 0xfd]).expect("the non-UTF-8 source writes"); - let violations = facade_violations(&facade_dir(root.path())); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with(&format!("{}: unreadable facade source: ", event.display())), - "{violations:?}" - ); -} - -#[test] -fn a_missing_or_unparseable_facade_manifest_is_reported_not_skipped() { - for (manifest, problem) in [ - (None, "unreadable facade manifest"), - (Some("[dependencies\n"), "unparseable facade manifest"), - ] { - let root = facade_root(manifest, &[("lib.rs", "pub struct Stray;\n")]); - let path = facade_dir(root.path()).join("Cargo.toml"); - let violations = facade_violations(&facade_dir(root.path())); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with(&format!("{}: {problem}: ", path.display())), - "{violations:?}" - ); - } -} diff --git a/crates/build-xtask/src/facade_shape.rs b/crates/build-xtask/src/facade_shape.rs deleted file mode 100644 index 2b339eeb0..000000000 --- a/crates/build-xtask/src/facade_shape.rs +++ /dev/null @@ -1,264 +0,0 @@ -//! The facade shape check, live: every `.rs` file under the -//! `runvil-engine` and `runvil` facades' `src/` holds only grouping -//! `pub mod` blocks with doc comments, single-item -//! `pub use internal_crate::path::Item;` re-exports, and doc attributes, -//! so every surface item is defined in an internal crate. Runs as part of -//! `cargo test -p build-xtask` and `cargo xtask tidy`. -//! -//! A re-export's path must start at a crate the facade manifest declares -//! under `[dependencies]`. Uniform paths resolve any other head, such as a -//! facade module (`pub use effect::Effect;`), inside the facade, which -//! would give the item a second facade path. -//! -//! Syntax cannot tell a module from a function, since both are snake_case, -//! so a `pub use` whose target is a module passes here; `cargo xtask api` -//! rejects it from the item kinds in rustdoc JSON. - -use std::collections::BTreeSet; -use std::fs; -use std::path::Path; - -use proc_macro2::Span; -use syn::spanned::Spanned; -use syn::{Attribute, Item, ItemMod, ItemUse, Meta}; -use syn::{UseTree, Visibility}; - -/// The `runvil-engine` facade's crate directory, relative to the -/// workspace root. -pub(crate) const FACADE_DIR: [&str; 2] = ["crates", "runvil-engine"]; - -/// Every facade the shape check covers, relative to the workspace root. -pub(crate) const FACADE_DIRS: [[&str; 2]; 2] = [FACADE_DIR, ["crates", "runvil"]]; - -const REEXPORT: &str = "a single-item re-export `pub use internal_crate::path::Item;`"; -const FACADE_ITEMS: &str = "only grouping `pub mod` blocks and single-item `pub use` re-exports"; -const FACADE_ATTRIBUTES: &str = "only doc attributes"; - -/// Runs the shape check over every facade in [`FACADE_DIRS`], in order. -#[must_use] -pub(crate) fn facade_shape_violations(root: &Path) -> Vec { - FACADE_DIRS - .iter() - .flat_map(|parts| { - facade_violations( - &parts - .iter() - .fold(root.to_path_buf(), |dir, part| dir.join(part)), - ) - }) - .collect() -} - -/// Runs the shape check over every `.rs` file under the facade crate -/// `dir`'s `src/`. A missing crate root, an unreadable manifest, or an -/// unreadable file is reported, not skipped: a file that was never parsed -/// cannot be shown clean. -fn facade_violations(dir: &Path) -> Vec { - let src = dir.join("src"); - let lib = src.join("lib.rs"); - if !lib.is_file() { - return vec![format!( - "{}: the facade has no crate root to check", - lib.display() - )]; - } - let crates = match dependency_crates(&dir.join("Cargo.toml")) { - Ok(crates) => crates, - Err(violation) => return vec![violation], - }; - let mut files = crate::tidy::rust_files(&src); - files.sort(); - let mut violations = Vec::new(); - for file in &files { - match fs::read_to_string(file) { - Ok(text) => violations.extend(source_violations(file, &text, &crates)), - Err(error) => violations.push(format!( - "{}: unreadable facade source: {error}", - file.display() - )), - } - } - violations -} - -/// The crate names a facade re-export path may start at: the keys of the -/// manifest's `[dependencies]` tables, plain and target-specific, with `-` -/// spelled `_` as in code. A key is the crate's name in code even when a -/// `package` rename points it at another package. -fn dependency_crates(manifest: &Path) -> Result, String> { - let text = fs::read_to_string(manifest).map_err(|error| { - format!( - "{}: unreadable facade manifest: {error}", - manifest.display() - ) - })?; - let value: toml::Value = toml::from_str(&text).map_err(|error| { - format!( - "{}: unparseable facade manifest: {error}", - manifest.display() - ) - })?; - Ok( - crate::manifest::dependency_tables(&value, &["dependencies"]) - .into_iter() - .flat_map(|(_, table)| table.keys()) - .map(|key| key.replace('-', "_")) - .collect(), - ) -} - -/// The shape violations in one facade source file, labeled with `file`; -/// `crates` are the names a re-export path may start at. -fn source_violations(file: &Path, text: &str, crates: &BTreeSet) -> Vec { - let mut checker = Checker { - file, - crates, - lines: text.lines().collect(), - violations: Vec::new(), - }; - match syn::parse_file(text) { - Ok(parsed) => { - checker.attributes(&parsed.attrs); - checker.items(&parsed.items); - } - Err(error) => checker.violations.push(format!( - "{}:{}: unparseable source: required a file that parses as Rust, found {error}", - file.display(), - error.span().start().line - )), - } - checker.violations -} - -struct Checker<'a> { - file: &'a Path, - crates: &'a BTreeSet, - lines: Vec<&'a str>, - violations: Vec, -} - -impl Checker<'_> { - /// Records a violation at `span`'s line, quoting that source line as - /// what was found. - fn report(&mut self, span: Span, rule: &str, required: &str) { - let line = span.start().line; - let found = line - .checked_sub(1) - .and_then(|index| self.lines.get(index)) - .map_or("", |text| text.trim()); - self.violations.push(format!( - "{}:{line}: {rule}: required {required}, found `{found}`", - self.file.display() - )); - } - - fn items(&mut self, items: &[Item]) { - for item in items { - match item { - Item::Use(item) => self.reexport(item), - Item::Mod(item) => self.module(item), - other => { - let (kind, span) = definition(other); - self.report(span, &format!("item definition ({kind})"), FACADE_ITEMS); - } - } - } - } - - fn reexport(&mut self, item: &ItemUse) { - self.attributes(&item.attrs); - let rule = if matches!(item.vis, Visibility::Public(_)) { - reexport_rule(&item.tree, self.crates) - } else { - Some("non-public use") - }; - if let Some(rule) = rule { - self.report(item.use_token.span, rule, REEXPORT); - } - } - - fn module(&mut self, item: &ItemMod) { - self.attributes(&item.attrs); - if !matches!(item.vis, Visibility::Public(_)) { - self.report( - item.mod_token.span, - "non-public module", - "a grouping `pub mod`", - ); - } else if !item.attrs.iter().any(is_doc_comment) { - self.report( - item.mod_token.span, - "undocumented module", - "a doc comment on every grouping `pub mod`", - ); - } - if let Some((_, items)) = &item.content { - self.items(items); - } - } - - fn attributes(&mut self, attrs: &[Attribute]) { - for attr in attrs { - if !attr.path().is_ident("doc") { - self.report(attr.span(), "disallowed attribute", FACADE_ATTRIBUTES); - } - } - } -} - -/// The rule a public `use` tree breaks, or `None` for a single item named -/// through a path that starts at one of `crates`. -fn reexport_rule(tree: &UseTree, crates: &BTreeSet) -> Option<&'static str> { - let mut head = None; - let mut tail = tree; - while let UseTree::Path(path) = tail { - head.get_or_insert(&path.ident); - tail = &path.tree; - } - match (tail, head) { - (UseTree::Glob(_), _) => Some("glob re-export"), - (UseTree::Group(_), _) => Some("grouped use list"), - (_, None) => Some("crate re-export"), - (_, Some(head)) if *head == "crate" || *head == "self" || *head == "super" => { - Some("re-export of a facade path") - } - (_, Some(head)) if !crates.iter().any(|name| head == name) => { - Some("re-export not rooted at a facade dependency") - } - _ => None, - } -} - -/// The kind of an item the facade may not define, and the span of the -/// token that names it (past any attributes, so the line is the item's own). -fn definition(item: &Item) -> (&'static str, Span) { - match item { - Item::Const(item) => ("const", item.ident.span()), - Item::Enum(item) => ("enum", item.ident.span()), - Item::ExternCrate(item) => ("extern crate", item.ident.span()), - Item::Fn(item) => ("fn", item.sig.ident.span()), - Item::ForeignMod(item) => ("extern block", item.abi.extern_token.span), - Item::Impl(item) => ("impl", item.impl_token.span), - Item::Macro(item) => match &item.ident { - Some(ident) => ("macro_rules!", ident.span()), - None => ("macro invocation", item.mac.path.span()), - }, - Item::Static(item) => ("static", item.ident.span()), - Item::Struct(item) => ("struct", item.ident.span()), - Item::Trait(item) => ("trait", item.ident.span()), - Item::TraitAlias(item) => ("trait alias", item.ident.span()), - Item::Type(item) => ("type alias", item.ident.span()), - Item::Union(item) => ("union", item.ident.span()), - other => ("item syn does not model", other.span()), - } -} - -/// Whether an attribute is a doc comment (`///`, `//!`, or `#[doc = ...]`), -/// as opposed to a doc setting such as `#[doc(hidden)]`. -fn is_doc_comment(attr: &Attribute) -> bool { - attr.path().is_ident("doc") && matches!(attr.meta, Meta::NameValue(_)) -} - -#[cfg(test)] -#[path = "facade_shape-tests.rs"] -mod tests; diff --git a/crates/build-xtask/src/harness_bans-tests.rs b/crates/build-xtask/src/harness_bans-tests.rs deleted file mode 100644 index 317532285..000000000 --- a/crates/build-xtask/src/harness_bans-tests.rs +++ /dev/null @@ -1,185 +0,0 @@ -//! Fixture tests for the Harness tokio ban, plus the live check over this -//! workspace's `crates/harness-internal/` container and its -//! `crates/runvil/` facade. - -use std::path::Path; - -use super::*; - -/// A fake workspace root; `container` and `facade` name its Harness -/// directories whether or not they exist yet. -fn fake_root() -> tempfile::TempDir { - tempfile::TempDir::new().expect("tempdir") -} - -fn container(root: &Path) -> std::path::PathBuf { - root.join("crates").join("harness-internal") -} - -fn facade(root: &Path) -> std::path::PathBuf { - root.join("crates").join("runvil") -} - -/// Writes a crate directory whose manifest is a fixture package followed by -/// `tables`. -fn write_crate(dir: &Path, tables: &str) { - std::fs::create_dir_all(dir).expect("the crate directory creates"); - std::fs::write( - dir.join("Cargo.toml"), - format!("[package]\nname = \"fixture\"\n{tables}"), - ) - .expect("the manifest writes"); -} - -fn bans(root: &Path) -> Vec { - harness_tokio_bans(&container(root), &facade(root)) -} - -#[test] -fn an_absent_container_is_vacuously_clean() { - let root = fake_root(); - let violations = bans(root.path()); - assert!(violations.is_empty(), "{violations:?}"); -} - -#[test] -fn an_empty_container_is_vacuously_clean() { - let root = fake_root(); - std::fs::create_dir_all(container(root.path())).expect("the container creates"); - let violations = bans(root.path()); - assert!(violations.is_empty(), "{violations:?}"); -} - -#[test] -fn tokio_as_a_normal_dependency_of_an_internal_crate_is_reported() { - let root = fake_root(); - write_crate( - &container(root.path()).join("runner"), - "[dependencies]\ntokio = { workspace = true, features = [\"rt\"] }\n", - ); - let violations = bans(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("runner") - && violations[0].contains("[dependencies]") - && violations[0].contains("tokio"), - "the violation names the crate, the table, and the package: {violations:?}" - ); -} - -#[test] -fn tokio_as_a_dev_dependency_passes() { - let root = fake_root(); - write_crate( - &container(root.path()).join("runner"), - "[dependencies]\nfutures-util.workspace = true\n\ - [dev-dependencies]\ntokio = { workspace = true, features = [\"macros\", \"rt\"] }\n\ - tokio-util.workspace = true\n", - ); - let violations = bans(root.path()); - assert!( - violations.is_empty(), - "the suites drive runs on tokio, so dev-dependencies are outside the ban: {violations:?}" - ); -} - -#[test] -fn a_renamed_tokio_util_in_a_target_table_is_reported() { - let root = fake_root(); - write_crate( - &container(root.path()).join("plugins"), - "[target.'cfg(windows)'.dependencies]\ntu = { package = \"tokio-util\", version = \"0.7\" }\n", - ); - let violations = bans(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("cfg(windows)") && violations[0].contains("tokio-util"), - "the target table and the package behind the rename are named: {violations:?}" - ); -} - -#[test] -fn the_facade_is_held_to_the_ban() { - let root = fake_root(); - write_crate(&container(root.path()).join("runner"), ""); - write_crate(&facade(root.path()), "[dependencies]\ntokio = \"1\"\n"); - let violations = bans(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains(&facade(root.path()).display().to_string()), - "the facade's manifest is named: {violations:?}" - ); -} - -#[test] -fn a_crate_nested_under_a_manifestless_subdirectory_is_checked() { - let root = fake_root(); - write_crate( - &container(root.path()).join("stt").join("engine"), - "[dependencies]\ntokio = \"1\"\n", - ); - let violations = bans(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!(violations[0].contains("engine"), "{violations:?}"); -} - -#[test] -fn an_unparseable_manifest_is_reported() { - let root = fake_root(); - write_crate(&container(root.path()).join("runner"), "[dependencies\n"); - let violations = bans(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!(violations[0].contains("unparseable"), "{violations:?}"); -} - -#[test] -fn a_facade_directory_without_a_manifest_is_reported() { - let root = fake_root(); - std::fs::create_dir_all(facade(root.path())).expect("the facade directory creates"); - let violations = bans(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("unreadable manifest"), - "{violations:?}" - ); -} - -#[test] -fn the_harness_crates_are_the_three_container_crates_and_the_facade() { - let root = crate::product::test_support::workspace_root(); - let internal = root.join("crates").join("harness-internal"); - let covered = harness_crates(&internal, &root.join("crates").join("runvil")); - let expected = [ - internal.join("plugin-loader"), - internal.join("runner"), - internal.join("test-plugin"), - root.join("crates").join("runvil"), - ]; - assert_eq!( - covered.len(), - expected.len(), - "the harness container holds three crates beside the facade; covered: {covered:?}" - ); - for dir in &expected { - assert!( - covered.contains(dir), - "{} is a harness crate; covered: {covered:?}", - dir.display() - ); - } -} - -#[test] -fn harness_crates_declare_no_tokio_outside_dev_dependencies() { - let root = crate::product::test_support::workspace_root(); - let crates_dir = root.join("crates"); - let violations = harness_tokio_bans( - &crates_dir.join("harness-internal"), - &crates_dir.join("runvil"), - ); - assert!( - violations.is_empty(), - "harness tokio-ban violations:\n{}", - violations.join("\n") - ); -} diff --git a/crates/build-xtask/src/harness_bans.rs b/crates/build-xtask/src/harness_bans.rs deleted file mode 100644 index 84638a5df..000000000 --- a/crates/build-xtask/src/harness_bans.rs +++ /dev/null @@ -1,108 +0,0 @@ -//! Harness tokio ban: no Harness crate declares `tokio` or `tokio-util` as -//! a normal dependency. -//! -//! The Harness polls every effect inside the run's own future and starts -//! no task, so any executor can drive a run. The `crates/runvil/` facade -//! and every crate under `crates/harness-internal/` therefore may not name -//! `tokio` or `tokio-util` in `[dependencies]` or its target-specific -//! forms. `[dev-dependencies]` are outside the ban: the suites and doc -//! examples drive runs on tokio, as a real Host does. -//! -//! The check reads declared dependencies, not the resolved graph. It binds -//! the facade and every crate in the container, the set its workspace test -//! pins. - -use std::fs; -use std::path::{Path, PathBuf}; - -/// The crates a Harness manifest may not declare outside -/// `[dev-dependencies]`. -const BANNED: [&str; 2] = ["tokio", "tokio-util"]; - -/// The dependency tables the ban scans, directly and under `[target]`. -const CHECKED_KINDS: [&str; 1] = ["dependencies"]; - -/// Checks every Harness crate's manifest for a banned normal dependency. -#[must_use] -pub(crate) fn harness_tokio_bans(container: &Path, public_crate: &Path) -> Vec { - harness_crates(container, public_crate) - .iter() - .flat_map(|dir| check_manifest(&dir.join("Cargo.toml"))) - .collect() -} - -/// The Harness family's crate directories: every crate under `container` -/// plus `public_crate` when its directory exists. -#[must_use] -pub(crate) fn harness_crates(container: &Path, public_crate: &Path) -> Vec { - let mut crates = Vec::new(); - collect_crates(container, &mut crates); - if public_crate.is_dir() { - crates.push(public_crate.to_path_buf()); - } - crates -} - -/// Every crate directory under `dir`: a directory holding a `Cargo.toml` -/// is a crate and is not descended into; any other directory is a -/// container and the walk descends. -fn collect_crates(dir: &Path, crates: &mut Vec) { - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let sub = entry.path(); - if !sub.is_dir() { - continue; - } - if sub.join("Cargo.toml").exists() { - crates.push(sub); - } else { - collect_crates(&sub, crates); - } - } -} - -/// The violations in one manifest: one per banned entry, or one when the -/// manifest cannot be read or parsed. -fn check_manifest(manifest: &Path) -> Vec { - let text = match fs::read_to_string(manifest) { - Ok(text) => text, - Err(error) => { - return vec![format!( - "{}: unreadable manifest: {error}", - manifest.display() - )]; - } - }; - let value: toml::Value = match toml::from_str(&text) { - Ok(value) => value, - Err(error) => { - return vec![format!( - "{}: unparseable manifest: {error}", - manifest.display() - )]; - } - }; - let mut violations = Vec::new(); - for (table, entries) in crate::manifest::dependency_tables(&value, &CHECKED_KINDS) { - for (key, entry) in entries { - let package = entry - .get("package") - .and_then(toml::Value::as_str) - .unwrap_or(key); - if BANNED.contains(&package) { - violations.push(format!( - "{}: [{table}] declares {package}; harness crates declare {} only under [dev-dependencies]", - manifest.display(), - BANNED.join(" and ") - )); - } - } - } - violations -} - -#[cfg(test)] -#[path = "harness_bans-tests.rs"] -mod tests; diff --git a/crates/build-xtask/src/harness_pin-tests.rs b/crates/build-xtask/src/harness_pin-tests.rs new file mode 100644 index 000000000..9360d17f9 --- /dev/null +++ b/crates/build-xtask/src/harness_pin-tests.rs @@ -0,0 +1,238 @@ +//! The runvil-harness pin over this workspace, plus fixtures that each +//! rule fails for the entry that breaks it, every spelling of the +//! repository's URL matches, and a git dependency on another repository +//! is left alone. + +use super::*; +use crate::product::test_support::{workspace_root, write_crate}; + +/// The `git` field of a dependency on the repository, spelled out here so +/// the fixtures do not follow the constant they test. +const GIT: &str = "git = \"https://github.com/cppalliance/runvil-harness\""; + +/// The commit the fixtures pin. +const REV: &str = "732429962c5873458213bb0072a0828a40ee4fb6"; + +/// A second commit, which sorts before [`REV`]. +const OTHER_REV: &str = "03a9fa37d2fe690d42ee0aa5c7a8289173e8e0f2"; + +/// A fake workspace root whose `Cargo.toml` is `manifest`, with an empty +/// `crates/` directory. +fn workspace(manifest: &str) -> tempfile::TempDir { + let root = tempfile::TempDir::new().expect("tempdir"); + std::fs::write(root.path().join("Cargo.toml"), manifest).expect("the root manifest writes"); + std::fs::create_dir_all(root.path().join("crates")).expect("the crates directory creates"); + root +} + +#[test] +fn the_workspace_takes_only_the_public_crates_from_runvil_harness() { + let violations = harness_pin_violations(&workspace_root()); + assert!( + violations.is_empty(), + "runvil-harness pin violations:\n{}", + violations.join("\n") + ); +} + +#[test] +fn the_four_public_crates_at_one_rev_pass() { + let root = workspace(&format!( + "[workspace.dependencies]\n\ + base64 = \"0.22\"\n\ + workshop-protocol = {{ path = \"crates/workshop/protocol\" }}\n\ + runvil = {{ version = \"0.3.0\", {GIT}, rev = \"{REV}\" }}\n\ + harness-gateway-client = {{ {GIT}, rev = \"{REV}\" }}\n\ + plugin-web = {{ {GIT}, rev = \"{REV}\" }}\n\ + plugin-mcp = {{ {GIT}, rev = \"{REV}\" }}\n" + )); + write_crate( + root.path(), + "workshop/server", + "workshop-server", + "[dependencies]\nrunvil.workspace = true\nplugin-web = { workspace = true }\n\ + [dev-dependencies]\nworkshop-protocol.workspace = true\n", + ); + let violations = harness_pin_violations(root.path()); + assert!(violations.is_empty(), "{violations:?}"); +} + +#[test] +fn a_fifth_crate_from_the_repository_is_reported() { + let root = workspace(&format!( + "[workspace.dependencies]\n\ + runvil = {{ {GIT}, rev = \"{REV}\" }}\n\ + harness-runner = {{ {GIT}, rev = \"{REV}\" }}\n" + )); + let violations = harness_pin_violations(root.path()); + assert_eq!( + violations, + [ + "[workspace.dependencies] harness-runner: harness-runner comes from runvil-harness; \ + crates here may take only runvil, harness-gateway-client, plugin-web, and \ + plugin-mcp from it" + ] + ); +} + +#[test] +fn a_renamed_entry_is_reported_by_its_package_name() { + let root = workspace(&format!( + "[workspace.dependencies]\n\ + engine = {{ package = \"runvil-engine\", {GIT}, rev = \"{REV}\" }}\n\ + facade = {{ package = \"runvil\", {GIT}, rev = \"{REV}\" }}\n" + )); + let violations = harness_pin_violations(root.path()); + assert_eq!(violations.len(), 1, "{violations:?}"); + assert!( + violations[0].starts_with( + "[workspace.dependencies] engine: runvil-engine comes from runvil-harness;" + ), + "the key locates the entry and the package names the crate: {violations:?}" + ); +} + +#[test] +fn an_entry_with_a_branch_and_no_rev_is_reported() { + for pin in ["branch = \"main\"", "tag = \"v0.3.0\""] { + let root = workspace(&format!( + "[workspace.dependencies]\n\ + runvil = {{ {GIT}, {pin} }}\n\ + plugin-web = {{ {GIT}, rev = \"{REV}\" }}\n" + )); + let violations = harness_pin_violations(root.path()); + assert_eq!( + violations, + [ + "[workspace.dependencies] runvil: comes from runvil-harness with no rev; \ + required one pinned commit" + ], + "{pin}" + ); + } +} + +#[test] +fn two_different_revs_are_reported_once() { + let root = workspace(&format!( + "[workspace.dependencies]\n\ + runvil = {{ {GIT}, rev = \"{REV}\" }}\n\ + harness-gateway-client = {{ {GIT}, rev = \"{REV}\" }}\n\ + plugin-web = {{ {GIT}, rev = \"{OTHER_REV}\" }}\n\ + plugin-mcp = {{ {GIT}, rev = \"{OTHER_REV}\" }}\n" + )); + let violations = harness_pin_violations(root.path()); + assert_eq!( + violations, + [format!( + "[workspace.dependencies]: the entries from runvil-harness name 2 commits \ + ({OTHER_REV}, {REV}); required one, because two commits are two copies of every \ + type the crates share" + )] + ); +} + +#[test] +fn a_member_naming_the_repository_directly_is_reported() { + let root = workspace(&format!( + "[workspace.dependencies]\nrunvil = {{ {GIT}, rev = \"{REV}\" }}\n" + )); + write_crate( + root.path(), + "workshop/server", + "workshop-server", + &format!( + "[dependencies]\nrunvil = {{ {GIT}, rev = \"{REV}\" }}\n\ + [dev-dependencies]\nplugin-web.workspace = true\n" + ), + ); + write_crate( + root.path(), + "workshop/desktop", + "workshop", + &format!( + "[target.'cfg(windows)'.dependencies]\nplugin-mcp = {{ {GIT}, rev = \"{REV}\" }}\n" + ), + ); + let mut violations = harness_pin_violations(root.path()); + violations.sort(); + assert_eq!( + violations, + [ + "workshop-server: runvil names runvil-harness directly; required \ + runvil.workspace = true", + "workshop: plugin-mcp names runvil-harness directly; required \ + plugin-mcp.workspace = true", + ] + ); +} + +#[test] +fn the_git_suffix_and_a_trailing_slash_still_match() { + for url in [ + "https://github.com/cppalliance/runvil-harness.git", + "https://github.com/cppalliance/runvil-harness/", + "https://github.com/cppalliance/runvil-harness.git/", + "https://GitHub.com/CppAlliance/Runvil-Harness", + ] { + let root = workspace(&format!( + "[workspace.dependencies]\n\ + harness-runner = {{ git = \"{url}\", rev = \"{REV}\" }}\n" + )); + write_crate( + root.path(), + "workshop/server", + "workshop-server", + &format!("[dependencies]\nrunvil = {{ git = \"{url}\", rev = \"{REV}\" }}\n"), + ); + let violations = harness_pin_violations(root.path()); + assert_eq!(violations.len(), 2, "{url}: {violations:?}"); + assert!( + violations[0].starts_with("[workspace.dependencies] harness-runner: harness-runner") + && violations[1].starts_with("workshop-server: runvil names runvil-harness"), + "{url} names the repository in the root manifest and in a member: {violations:?}" + ); + } +} + +#[test] +fn a_git_dependency_on_another_repository_is_ignored() { + let other = "git = \"https://github.com/cppalliance/runvil-plugin-api\""; + let near = "git = \"https://github.com/cppalliance/runvil-harness-tools\""; + let root = workspace(&format!( + "[workspace.dependencies]\n\ + runvil = {{ {GIT}, rev = \"{REV}\" }}\n\ + runvil-plugin-sdk = {{ {other}, branch = \"main\" }}\n\ + harness-runner = {{ {near}, rev = \"{OTHER_REV}\" }}\n" + )); + write_crate( + root.path(), + "workshop/plugin-std", + "workshop-plugin-std", + &format!("[dependencies]\nrunvil-plugin-sdk = {{ {other}, rev = \"{OTHER_REV}\" }}\n"), + ); + let violations = harness_pin_violations(root.path()); + assert!( + violations.is_empty(), + "another repository's crates, pins, and commits are not this check's: {violations:?}" + ); +} + +#[test] +fn an_unreadable_or_unparseable_root_manifest_is_reported() { + let missing = tempfile::TempDir::new().expect("tempdir"); + let broken = workspace("not [valid toml"); + for (root, problem) in [ + (&missing, ": unreadable manifest: "), + (&broken, ": unparseable manifest: "), + ] { + let manifest = root.path().join("Cargo.toml"); + let violations = harness_pin_violations(root.path()); + assert_eq!(violations.len(), 1, "{violations:?}"); + assert!( + violations[0].starts_with(&manifest.display().to_string()) + && violations[0].contains(problem), + "a table that was never read cannot be shown pinned: {violations:?}" + ); + } +} diff --git a/crates/build-xtask/src/harness_pin.rs b/crates/build-xtask/src/harness_pin.rs new file mode 100644 index 000000000..2af243bbf --- /dev/null +++ b/crates/build-xtask/src/harness_pin.rs @@ -0,0 +1,160 @@ +//! The pin on the crates this workspace takes from runvil-harness, the +//! repository that holds the Engine and the Harness. Those crates arrive +//! as git dependencies, which the product matrix cannot see, so this check +//! reads the manifests instead: crates here reach the Engine and the +//! Harness only through the public crates in [`HARNESS_PUBLIC`], all at +//! one commit. Runs as part of `cargo test -p build-xtask` and +//! `cargo xtask tidy`. +//! +//! The rules: +//! +//! 1. An entry of the root `[workspace.dependencies]` that comes from the +//! repository names one of the four public crates, by its `package` +//! field when it has one and by its key otherwise. +//! 2. Each such entry pins a `rev`; a `branch` or a `tag` does not count. +//! 3. The entries name one `rev` between them, because two commits are two +//! copies of every type the crates share. +//! 4. No workspace member names the repository in its own manifest, in any +//! dependency table, the target-specific ones included: a member takes +//! these crates with `.workspace = true`. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +/// The repository that holds the Engine and the Harness. +const HARNESS_REPOSITORY: &str = "https://github.com/cppalliance/runvil-harness"; + +/// The four crates of that repository a crate here may depend on: the +/// Harness facade, which is the one crate a Host depends on, the standard +/// Gateway client, and the two Rust Plugins Workshop links in. +const HARNESS_PUBLIC: [&str; 4] = [ + "runvil", + "harness-gateway-client", + "plugin-web", + "plugin-mcp", +]; + +/// Runs the four rules over the root manifest's `[workspace.dependencies]` +/// and every workspace member's manifest. A root manifest that cannot be +/// read or parsed is reported, not skipped: a table that was never read +/// cannot be shown pinned. +#[must_use] +pub(crate) fn harness_pin_violations(root: &Path) -> Vec { + let mut violations = workspace_violations(&root.join("Cargo.toml")); + violations.extend(member_violations(root)); + violations +} + +/// The breaches of rules 1 to 3 in the `[workspace.dependencies]` of the +/// root manifest at `path`, or the one reason the manifest could not be +/// read. +fn workspace_violations(path: &Path) -> Vec { + let text = match fs::read_to_string(path) { + Ok(text) => text, + Err(error) => { + return vec![format!("{}: unreadable manifest: {error}", path.display())]; + } + }; + let manifest: toml::Value = match toml::from_str(&text) { + Ok(manifest) => manifest, + Err(error) => { + return vec![format!("{}: unparseable manifest: {error}", path.display())]; + } + }; + let Some(entries) = manifest + .get("workspace") + .and_then(|workspace| workspace.get("dependencies")) + .and_then(toml::Value::as_table) + else { + return Vec::new(); + }; + let mut violations = Vec::new(); + let mut revs = BTreeSet::new(); + for (key, entry) in entries { + if !from_harness(entry) { + continue; + } + let package = entry + .get("package") + .and_then(toml::Value::as_str) + .unwrap_or(key); + if !HARNESS_PUBLIC.contains(&package) { + violations.push(format!( + "[workspace.dependencies] {key}: {package} comes from runvil-harness; crates \ + here may take only {} from it", + public_crates() + )); + } + match entry.get("rev").and_then(toml::Value::as_str) { + Some(rev) => { + revs.insert(rev); + } + None => violations.push(format!( + "[workspace.dependencies] {key}: comes from runvil-harness with no rev; \ + required one pinned commit" + )), + } + } + if revs.len() > 1 { + violations.push(format!( + "[workspace.dependencies]: the entries from runvil-harness name {} commits ({}); \ + required one, because two commits are two copies of every type the crates share", + revs.len(), + revs.into_iter().collect::>().join(", ") + )); + } + violations +} + +/// The breaches of rule 4: each dependency, in any dependency table of any +/// workspace member's manifest, that names the repository itself. +/// +/// The walk's read failures belong to `tidy::marker_violations`, the one +/// owner, as they do for the product-boundary check. +fn member_violations(root: &Path) -> Vec { + let walk = crate::product::workspace_crates(root); + let mut violations = Vec::new(); + for krate in &walk.crates { + let tables = + crate::manifest::dependency_tables(&krate.manifest, &crate::product::DEP_KINDS); + for (_, table) in tables { + for (dep, entry) in table { + if from_harness(entry) { + violations.push(format!( + "{}: {dep} names runvil-harness directly; required \ + {dep}.workspace = true", + krate.package + )); + } + } + } + } + violations +} + +/// Whether a dependency entry's `git` names [`HARNESS_REPOSITORY`]. The +/// URLs are compared without a trailing `/` or `.git` and without regard +/// to ASCII case, since each of those spellings reaches the same +/// repository. +fn from_harness(entry: &toml::Value) -> bool { + entry + .get("git") + .and_then(toml::Value::as_str) + .is_some_and(|git| { + let url = git.to_ascii_lowercase(); + let url = url.strip_suffix('/').unwrap_or(&url); + let url = url.strip_suffix(".git").unwrap_or(url); + url.eq_ignore_ascii_case(HARNESS_REPOSITORY) + }) +} + +/// [`HARNESS_PUBLIC`] as a violation lists it: `a, b, c, and d`. +fn public_crates() -> String { + let [rest @ .., last] = HARNESS_PUBLIC; + format!("{}, and {last}", rest.join(", ")) +} + +#[cfg(test)] +#[path = "harness_pin-tests.rs"] +mod tests; diff --git a/crates/build-xtask/src/main.rs b/crates/build-xtask/src/main.rs index 6493312e3..4d57fabc5 100644 --- a/crates/build-xtask/src/main.rs +++ b/crates/build-xtask/src/main.rs @@ -5,24 +5,14 @@ //! - Tier: tooling. The tidy-style //! architecture checks run as tests (`cargo test -p build-xtask`); //! `cargo xtask tidy` prints the same report on demand. -//! - `cargo xtask api` reads rustdoc JSON, so it runs only on the nightly -//! pinned in `api/toolchain.rs`; its fixtures that build rustdoc JSON are -//! `#[ignore]`d on stable. -mod api; mod debug_spelling; -mod doc_hidden; -mod engine_clippy; -mod engine_deps; -mod engine_guards; -mod facade_shape; -mod harness_bans; +mod harness_pin; mod manifest; mod new_crate; mod no_doctests; mod product; mod site; -mod test_support_leak; mod tidy; mod type_size; mod unsafe_allowlist; @@ -50,7 +40,6 @@ fn main() -> ExitCode { }, None => usage(), }, - Some("api") => api::run(root, &args[2..]), Some("site") => site::run( root, std::env::var_os(site::DOCS_VAR).as_deref(), @@ -75,7 +64,6 @@ fn main() -> ExitCode { fn usage() -> ExitCode { eprintln!( "usage: cargo xtask new-crate | cargo xtask tidy | \ - cargo + xtask api [--check | --bless] | \ cargo xtask site [--books-only]" ); ExitCode::from(2) diff --git a/crates/build-xtask/src/manifest.rs b/crates/build-xtask/src/manifest.rs index 82592528a..18d6a7531 100644 --- a/crates/build-xtask/src/manifest.rs +++ b/crates/build-xtask/src/manifest.rs @@ -2,8 +2,8 @@ //! requested kinds, both at the top level and under `[target.]`. //! //! Every structural check that reads declared dependencies (the product -//! matrix, the workshop tiers, the Engine manifest guard) goes through -//! this one walk and differs only in the kinds it asks for. +//! matrix, the workshop tiers, the runvil-harness pin) goes through this +//! one walk and differs only in the kinds it asks for. /// Every dependency table of the given kinds, labeled the way its section /// header reads: the plain `` tables and their `[target..]` diff --git a/crates/build-xtask/src/no_doctests-tests.rs b/crates/build-xtask/src/no_doctests-tests.rs index 84eccd3fb..fb20f91f7 100644 --- a/crates/build-xtask/src/no_doctests-tests.rs +++ b/crates/build-xtask/src/no_doctests-tests.rs @@ -233,11 +233,11 @@ fn every_rs_file_in_every_workspace_crate_is_scanned() { "crates/tool/tests/it/main.rs", "crates/tool/benches/bench.rs", "crates/tool/examples/demo.rs", - "crates/plugin-mcp/src/lib.rs", + "crates/build-ui/src/lib.rs", ]; let mut files = vec![ ("crates/tool/Cargo.toml", MANIFEST), - ("crates/plugin-mcp/Cargo.toml", MANIFEST), + ("crates/build-ui/Cargo.toml", MANIFEST), ]; files.extend(doctests.iter().map(|path| (*path, DOCTEST))); let root = tree(&files); diff --git a/crates/build-xtask/src/product.rs b/crates/build-xtask/src/product.rs index b2e328c5b..b1782d2f7 100644 --- a/crates/build-xtask/src/product.rs +++ b/crates/build-xtask/src/product.rs @@ -1,57 +1,35 @@ -//! Product-boundary check: the dependency matrix between product -//! families, their public crates, and their private containers. +//! Product-boundary check: the dependency matrix between the crates this +//! workspace holds. //! -//! Every workspace crate is classified by package name into a product -//! family, and its dependencies of every kind (normal, dev, build, and -//! target-specific) are checked against the matrix: +//! Every workspace crate is classified by package name into a family: +//! Workshop crates (`workshop` and `workshop-*`), build crates +//! (`build-*`), and unaffiliated crates, which are all the rest. Its +//! dependencies of every kind (normal, dev, build, and target-specific) +//! are checked against the matrix: //! -//! - Engine crates (`runvil-*`) must not depend on workshop or Harness -//! crates. -//! - `workshop`/`workshop-*` crates must not depend on Engine crates, -//! which they reach through `runvil`, or on Harness crates, except -//! through the Harness family's two public crates. -//! - Harness crates (`runvil` and `harness-*`) must not depend on -//! workshop crates; outside their own family they may name only -//! `runvil-engine` and `runvil-plugin`, which rules out `build-*` -//! and other unaffiliated crates. -//! - `plugin-*` crates may depend only on `runvil-plugin` and outside -//! libraries. Engine and Harness crates must not depend on `plugin-*` -//! crates, except `harness-gateway-client`, which implements web's -//! `SearchProvider`. -//! - Public API: a crate outside the Engine family may depend on -//! the family only through its two public crates, `runvil-engine` and -//! `runvil-plugin`, and a crate outside the Harness family on that -//! family only through its two public crates, `runvil` and -//! `harness-gateway-client`. The `build-*` crates are bound too. -//! - Container privacy: the manifestless `crates/runvil-internal/`, -//! `crates/workshop/`, and `crates/harness-internal/` directories are -//! private to their families; only the crates inside a container and the -//! container's named outside exceptions (`runvil-engine` and -//! `runvil-plugin` for `crates/runvil-internal/`, `runvil` for -//! `crates/harness-internal/`; the workshop container names none) may -//! depend on the crates it holds. //! - Desktop-app boundary: the `workshop` desktop app may depend on no //! workshop crate except `workshop-server-api`, and `workshop-server-api` //! on none except `workshop-server`. A crate's dependency on itself is //! not an edge. +//! - Container privacy: a manifestless directory under `crates/`, as +//! `crates/workshop/` is, is private to its family; only the crates +//! inside a container and the `build-*` crates, which are meta tooling, +//! may depend on the crates it holds. Containers may nest. //! -//! The Gateway is a separate product in its own repository, so no crate -//! here is classified into a gateway family: a dependency on a crate this -//! workspace does not hold is outside the matrix's reach. +//! Crates from other repositories are outside the matrix: it classifies +//! and binds only workspace members. The crates that come from +//! runvil-harness are checked by `harness_pin` instead. use std::fs; use std::path::{Path, PathBuf}; /// The dependency tables cargo recognizes, directly and under `[target]`. -const DEP_KINDS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"]; +pub(crate) const DEP_KINDS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"]; /// The product family a package name belongs to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Family { - Engine, Workshop, - Harness, - Plugin, Build, /// Named after no product family; has no matrix rules of its own. Unaffiliated, @@ -59,14 +37,8 @@ enum Family { /// Classifies a package name into its product family. fn family(package: &str) -> Family { - if package.starts_with("runvil-") { - Family::Engine - } else if package == "workshop" || package.starts_with("workshop-") { + if package == "workshop" || package.starts_with("workshop-") { Family::Workshop - } else if package == "runvil" || package.starts_with("harness-") { - Family::Harness - } else if package.starts_with("plugin-") { - Family::Plugin } else if package.starts_with("build-") { Family::Build } else { @@ -75,10 +47,12 @@ fn family(package: &str) -> Family { } /// A workspace crate: its package name, its manifest directory relative to -/// the workspace root, and its dependency package names. +/// the workspace root, its parsed manifest, and its dependency package +/// names. pub(crate) struct CrateInfo { pub(crate) package: String, pub(crate) dir: PathBuf, + pub(crate) manifest: toml::Value, deps: Vec, } @@ -130,20 +104,6 @@ const DESKTOP: &str = "workshop"; const SERVER_API: &str = "workshop-server-api"; /// The server crate: the one workshop crate the facade may name. const SERVER: &str = "workshop-server"; -/// The Engine-family crates outside crates may depend on directly: -/// the Engine facade and the Plugin contract. -const PUBLIC_ENGINE: [&str; 2] = ["runvil-engine", "runvil-plugin"]; -/// The Harness family's facade: the one outside crate permitted into -/// `crates/harness-internal/`. -const HARNESS_FACADE: &str = "runvil"; -/// The Harness gateway client, which implements web's `SearchProvider`: -/// the one Harness crate that may name a Plugin crate. -const GATEWAY_CLIENT: &str = "harness-gateway-client"; -/// The Harness family's two public root crates, the facade and the gateway -/// client: the only Harness crates outside crates may name. -const PUBLIC_HARNESS: [&str; 2] = [HARNESS_FACADE, GATEWAY_CLIENT]; -/// The Plugin contract: the one Engine crate Plugin crates may name. -const PLUGIN_CONTRACT: &str = "runvil-plugin"; /// The reason a dependency from `package` to `dep` breaches the matrix, /// or `None` when the edge is legal. @@ -166,84 +126,16 @@ fn boundary_breach(package: &CrateInfo, dep: &CrateInfo) -> Option { let inside = package .dir .starts_with(Path::new("crates").join(&container)); - let named = container_named_exception(&container).contains(&package.package.as_str()); // The build-* crates are meta tooling, not a product family: they // may depend into any container. let meta = family(&package.package) == Family::Build; - if !inside && !named && !meta { - return Some(match container_face(&container) { - Some(face) => format!( - "crates/{container} is private to its family; only {face} may depend into it" - ), - None => format!( - "crates/{container} is private to its family; no outside crate may depend into it" - ), - }); - } - } - if let Some(reason) = plugin_breach(package, dep) { - return Some(reason.to_owned()); - } - let (from, to) = (family(&package.package), family(&dep.package)); - let family_rule = match (from, to) { - (Family::Engine, Family::Workshop | Family::Harness) => { - Some("Engine crates must not depend on workshop or harness crates") - } - (Family::Workshop, Family::Engine) => Some( - "workshop crates must not depend on Engine crates; they reach the Engine through runvil", - ), - (Family::Harness, Family::Workshop) => { - Some("harness crates must not depend on workshop crates") - } - (Family::Harness, Family::Build) => Some( - "harness crates must not depend on build-* crates; outside their family they may name only runvil-engine and runvil-plugin", - ), - (Family::Harness, Family::Unaffiliated) => Some( - "harness crates must not depend on unaffiliated crates; outside their family they may name only runvil-engine and runvil-plugin", - ), - _ => None, - }; - family_rule.map(str::to_owned).or_else(|| { - if from != Family::Engine - && to == Family::Engine - && !PUBLIC_ENGINE.contains(&dep.package.as_str()) - { - Some( - "outside crates may depend on the Engine family only through runvil-engine or runvil-plugin" - .to_owned(), - ) - } else if from != Family::Harness - && to == Family::Harness - && !PUBLIC_HARNESS.contains(&dep.package.as_str()) - { - Some( - "outside crates may depend on the harness family only through runvil or harness-gateway-client" - .to_owned(), - ) - } else { - None - } - }) -} - -/// The reason an edge from `package` to `dep` breaches the Plugin family's -/// rules, or `None` when they allow it: a Plugin crate names only its -/// allow-list, and no Engine or Harness crate but the gateway client names -/// a Plugin crate. -fn plugin_breach(package: &CrateInfo, dep: &CrateInfo) -> Option<&'static str> { - let (from, to) = (family(&package.package), family(&dep.package)); - let allowed = [PLUGIN_CONTRACT, package.package.as_str()].contains(&dep.package.as_str()); - match (from, to) { - (Family::Plugin, _) if !allowed => { - Some("plugin crates may depend only on runvil-plugin and outside libraries") - } - (Family::Engine | Family::Harness, Family::Plugin) if package.package != GATEWAY_CLIENT => { - Some( - "Engine and harness crates must not depend on plugin crates; only harness-gateway-client may", - ) + if !inside && !meta { + return Some(format!( + "crates/{container} is private to its family; no outside crate may depend into it" + )); } - _ => None, } + None } /// The one workshop crate a crate on the desktop-app boundary may name, or @@ -258,7 +150,7 @@ fn desktop_boundary_face(package: &str) -> Option<&'static str> { /// The root-relative path of the deepest manifestless `crates//` /// directory holding the crate whose root-relative manifest directory is -/// `dir` (`crates/runvil-internal/runtime` is in `runvil-internal`), or +/// `dir` (`crates/workshop/server` is in `workshop`), or /// `None` for a crate directly under `crates/`. fn container_of(dir: &Path) -> Option { let mut components = dir.components(); @@ -274,31 +166,12 @@ fn container_of(dir: &Path) -> Option { } } -/// The crates outside a container permitted to depend into it; empty when -/// the container names none. -fn container_named_exception(container: &str) -> &'static [&'static str] { - match container { - "runvil-internal" => &["runvil-engine", "runvil-plugin"], - "harness-internal" => &[HARNESS_FACADE], - _ => &[], - } -} - -/// The crates a container-privacy violation names as the legal way in: the -/// named outside exceptions, or none when the container names none, which -/// makes the message say no outside crate may depend into it. -fn container_face(container: &str) -> Option { - match container_named_exception(container) { - [] => None, - named => Some(named.join(" or ")), - } -} - -/// Every workspace crate's package name, manifest directory, and dependency -/// package names, plus the directories and violations for manifests that -/// could not be read or parsed. A directory under `crates/` containing a -/// `Cargo.toml` is a crate and is not descended into; any other directory -/// is a container and the walk descends, so containers may nest. +/// Every workspace crate's package name, manifest directory, parsed +/// manifest, and dependency package names, plus the directories and +/// violations for manifests that could not be read or parsed. A directory +/// under `crates/` containing a `Cargo.toml` is a crate and is not +/// descended into; any other directory is a container and the walk +/// descends, so containers may nest. pub(crate) fn workspace_crates(root: &Path) -> CrateWalk { let mut walk = CrateWalk { crates: Vec::new(), @@ -385,10 +258,12 @@ fn read_crate(root: &Path, dir: &Path, walk: &mut CrateWalk) { )); return; }; + let (package, deps) = (package.to_owned(), manifest_dependencies(&manifest)); walk.crates.push(CrateInfo { - package: package.to_owned(), + package, dir: relative, - deps: manifest_dependencies(&manifest), + manifest, + deps, }); } @@ -417,10 +292,6 @@ fn collect_deps(table: &toml::map::Map, names: &mut Vec/` -//! containers are private to their families, with the named exceptions. +//! containers are private to their families; from outside, only the +//! `build-*` crates may depend into one. +use super::product_boundary_violations; use super::test_support::write_crate; -use super::{container_named_exception, product_boundary_violations}; - -#[test] -fn an_outside_crate_depending_into_the_private_container_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "workshop-sessions", - "workshop-sessions", - "[dependencies]\nrunvil-lua = { path = \"../runvil-internal/lua\" }\n", - ); - write_crate(root.path(), "runvil-internal/lua", "runvil-lua", ""); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("workshop-sessions depends on runvil-lua") - && violations[0].contains("crates/runvil-internal is private to its family") - && violations[0].contains("only runvil-engine or runvil-plugin may depend into it"), - "the violation includes the container privacy message: {violations:?}" - ); -} - -#[test] -fn a_container_crate_depending_on_a_container_sibling_passes() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "runvil-internal/prompt", - "runvil-prompt", - "[dependencies]\nrunvil-lua = { path = \"../lua\" }\n", - ); - write_crate(root.path(), "runvil-internal/lua", "runvil-lua", ""); - let violations = product_boundary_violations(root.path()); - assert!( - violations.is_empty(), - "container siblings may depend on each other: {violations:?}" - ); -} - -#[test] -fn the_facade_depending_into_the_engine_container_passes() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "runvil-engine", - "runvil-engine", - "[dependencies]\nrunvil-lua = { path = \"../runvil-internal/lua\" }\n", - ); - write_crate(root.path(), "runvil-internal/lua", "runvil-lua", ""); - let violations = product_boundary_violations(root.path()); - assert!( - violations.is_empty(), - "the facade is one of the container's named crates: {violations:?}" - ); -} - -#[test] -fn the_plugin_contract_depending_into_the_engine_container_passes() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "runvil-plugin", - "runvil-plugin", - "[dependencies]\nrunvil-types = { path = \"../runvil-internal/types\" }\n\ - runvil-vfs = { path = \"../runvil-internal/vfs\" }\n", - ); - write_crate(root.path(), "runvil-internal/types", "runvil-types", ""); - write_crate(root.path(), "runvil-internal/vfs", "runvil-vfs", ""); - let violations = product_boundary_violations(root.path()); - assert!( - violations.is_empty(), - "the Plugin contract is the container's second named crate: {violations:?}" - ); -} - -#[test] -fn a_root_engine_crate_other_than_the_named_ones_depending_into_the_engine_container_is_reported() { - assert_eq!( - container_named_exception("runvil-internal"), - ["runvil-engine", "runvil-plugin"], - "the facade and the Plugin contract are the container's named crates" - ); - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "runvil-extra", - "runvil-extra", - "[dependencies]\nrunvil-lua = { path = \"../runvil-internal/lua\" }\n", - ); - write_crate(root.path(), "runvil-internal/lua", "runvil-lua", ""); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with("runvil-extra depends on runvil-lua:") - && violations[0].contains("only runvil-engine or runvil-plugin may depend into it"), - "no crate outside the container but the named ones enters it: {violations:?}" - ); -} #[test] fn an_outside_crate_depending_into_the_workshop_container_is_reported() { @@ -156,92 +60,25 @@ fn a_build_crate_depending_into_a_container_passes() { } #[test] -fn a_crate_outside_the_harness_container_cannot_reach_a_nested_one() { +fn a_crate_outside_the_workshop_container_cannot_reach_a_nested_one() { let root = tempfile::TempDir::new().expect("tempdir"); write_crate( root.path(), - "workshop-server", - "workshop-server", - "[dependencies]\nharness-runner = { path = \"../harness-internal/nested/runner\" }\n", + "outside-tool", + "outside-tool", + "[dependencies]\nworkshop-protocol = { path = \"../workshop/nested/protocol\" }\n", ); write_crate( root.path(), - "harness-internal/nested/runner", - "harness-runner", + "workshop/nested/protocol", + "workshop-protocol", "", ); let violations = product_boundary_violations(root.path()); assert_eq!(violations.len(), 1, "{violations:?}"); assert!( - violations[0].contains("crates/harness-internal/nested is private to its family") + violations[0].contains("crates/workshop/nested is private to its family") && violations[0].contains("no outside crate"), "the violation names the nested container and refuses every outside crate: {violations:?}" ); } - -#[test] -fn an_outside_crate_depending_into_the_harness_container_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "workshop/server", - "workshop-server", - "[dependencies]\nharness-runner = { path = \"../../harness-internal/runner\" }\n", - ); - write_crate(root.path(), "harness-internal/runner", "harness-runner", ""); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("workshop-server depends on harness-runner") - && violations[0].contains( - "crates/harness-internal is private to its family; only runvil may depend into it" - ), - "the violation includes the harness container privacy message: {violations:?}" - ); -} - -#[test] -fn the_harness_facade_depending_into_the_harness_container_passes() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "runvil", - "runvil", - "[dependencies]\nharness-runner = { path = \"../harness-internal/runner\" }\n\ - harness-sessions = { path = \"../harness-internal/sessions\" }\n", - ); - write_crate(root.path(), "harness-internal/runner", "harness-runner", ""); - write_crate( - root.path(), - "harness-internal/sessions", - "harness-sessions", - "", - ); - let violations = product_boundary_violations(root.path()); - assert!( - violations.is_empty(), - "runvil is the one outside crate permitted into crates/harness-internal: {violations:?}" - ); -} - -#[test] -fn harness_container_siblings_may_depend_on_each_other() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "harness-internal/sessions", - "harness-sessions", - "[dependencies]\nharness-plugins = { path = \"../plugins\" }\n", - ); - write_crate( - root.path(), - "harness-internal/plugins", - "harness-plugins", - "", - ); - let violations = product_boundary_violations(root.path()); - assert!( - violations.is_empty(), - "harness container siblings may depend on each other: {violations:?}" - ); -} diff --git a/crates/build-xtask/src/product/harness_tests.rs b/crates/build-xtask/src/product/harness_tests.rs deleted file mode 100644 index 3dbe86947..000000000 --- a/crates/build-xtask/src/product/harness_tests.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! Harness-family fixtures: outside their family, Harness crates depend on -//! `runvil-engine` and `runvil-plugin` only, and outside crates reach -//! the family only through `runvil` and `harness-gateway-client`. - -use super::product_boundary_violations; -use super::test_support::write_crate; - -#[test] -fn a_harness_crate_depending_on_a_gateway_crate_is_reported() { - // The Gateway is its own repository now, so a package named like one of - // its crates is an unaffiliated crate, and those are closed to the - // Harness family as well. - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "harness-internal/sessions", - "harness-sessions", - "[dependencies]\ngateway-api-types = { path = \"../../gateway-api-types\" }\n", - ); - write_crate(root.path(), "gateway-api-types", "gateway-api-types", ""); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with("harness-sessions depends on gateway-api-types:") - && violations[0].contains("harness crates must not depend on unaffiliated crates"), - "every gateway crate is closed to harness crates: {violations:?}" - ); -} - -#[test] -fn a_harness_crate_depending_on_the_plugin_contract_passes() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "harness-internal/plugins", - "harness-plugins", - "[dependencies]\nrunvil-plugin = { path = \"../../runvil-plugin\" }\n", - ); - write_crate(root.path(), "runvil-plugin", "runvil-plugin", ""); - let violations = product_boundary_violations(root.path()); - assert!( - violations.is_empty(), - "runvil-plugin is the second public Engine crate harness crates may name: {violations:?}" - ); -} - -#[test] -fn a_harness_crate_build_depending_on_a_build_crate_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "harness-internal/runner", - "harness-runner", - "[build-dependencies]\nbuild-ui = { path = \"../../build-ui\" }\n", - ); - write_crate(root.path(), "build-ui", "build-ui", ""); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with("harness-runner depends on build-ui:") - && violations[0].contains( - "harness crates must not depend on build-* crates; outside their family they may name only runvil-engine and runvil-plugin" - ), - "build-* crates are closed to harness crates in every dependency table: {violations:?}" - ); -} - -#[test] -fn a_harness_crate_depending_on_an_unaffiliated_crate_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "harness-internal/plugins", - "harness-plugins", - "[dependencies]\nsome-tool = { path = \"../../some-tool\" }\n", - ); - write_crate(root.path(), "some-tool", "some-tool", ""); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with("harness-plugins depends on some-tool:") - && violations[0].contains( - "harness crates must not depend on unaffiliated crates; outside their family they may name only runvil-engine and runvil-plugin" - ), - "unaffiliated crates are closed to harness crates: {violations:?}" - ); -} - -#[test] -fn a_non_workshop_outside_crate_depending_past_the_harness_facade_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "outside-tool", - "outside-tool", - "[dependencies]\nharness-runner = { path = \"../harness-runner\" }\n", - ); - write_crate(root.path(), "harness-runner", "harness-runner", ""); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with("outside-tool depends on harness-runner:") - && violations[0].ends_with( - "outside crates may depend on the harness family only through runvil or harness-gateway-client" - ), - "the harness facade rule binds every outside crate: {violations:?}" - ); -} - -#[test] -fn a_build_crate_depending_past_the_harness_facade_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "build-xtask", - "build-xtask", - "[dependencies]\nharness-runner = { path = \"../harness-internal/runner\" }\n", - ); - write_crate(root.path(), "harness-internal/runner", "harness-runner", ""); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with("build-xtask depends on harness-runner:") - && violations[0].ends_with( - "outside crates may depend on the harness family only through runvil or harness-gateway-client" - ), - "container privacy exempts build-* crates, but the harness facade rule does not: {violations:?}" - ); -} - -#[test] -fn a_workshop_crate_depending_on_the_harness_gateway_client_passes() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "workshop/server", - "workshop-server", - "[dependencies]\nharness-gateway-client = { path = \"../../harness-gateway-client\" }\n", - ); - write_crate( - root.path(), - "harness-gateway-client", - "harness-gateway-client", - "", - ); - let violations = product_boundary_violations(root.path()); - assert!( - violations.is_empty(), - "harness-gateway-client is the second harness public crate: {violations:?}" - ); -} - -#[test] -fn the_harness_gateway_client_depending_into_the_harness_container_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "harness-gateway-client", - "harness-gateway-client", - "[dependencies]\nharness-runner = { path = \"../harness-internal/runner\" }\n", - ); - write_crate(root.path(), "harness-internal/runner", "harness-runner", ""); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with("harness-gateway-client depends on harness-runner:") - && violations[0].ends_with( - "crates/harness-internal is private to its family; only runvil may depend into it" - ), - "being public does not admit the gateway client into the harness container: {violations:?}" - ); -} diff --git a/crates/build-xtask/src/product/plugin_tests.rs b/crates/build-xtask/src/product/plugin_tests.rs deleted file mode 100644 index 21d48e7a8..000000000 --- a/crates/build-xtask/src/product/plugin_tests.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! Plugin-family fixtures: a `plugin-*` crate depends only on -//! `runvil-plugin` and outside libraries; Engine, Gateway, and Harness -//! crates may not depend on a Plugin crate, except -//! `harness-gateway-client`. - -use super::product_boundary_violations; -use super::test_support::write_crate; - -/// The violations of a workspace holding `plugin-web` with `deps`, beside -/// a crate for each workspace dependency it may name. -fn plugin_web_naming(deps: &str) -> Vec { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate(root.path(), "plugin-web", "plugin-web", deps); - for (dir, name) in [ - ("runvil-plugin", "runvil-plugin"), - ("runvil-engine", "runvil-engine"), - ("runvil", "runvil"), - ("plugin-user-input", "plugin-user-input"), - ("build-ui", "build-ui"), - ] { - write_crate(root.path(), dir, name, ""); - } - product_boundary_violations(root.path()) -} - -const PLUGIN_RULE: &str = "plugin crates may depend only on runvil-plugin and outside libraries"; - -#[test] -fn a_plugin_crate_naming_the_contract_passes() { - let violations = plugin_web_naming( - "[dependencies]\nrunvil-plugin = { path = \"../runvil-plugin\" }\n\ - [dev-dependencies]\nrunvil-plugin = { path = \"../runvil-plugin\", features = [\"test-support\"] }\n", - ); - assert!(violations.is_empty(), "{violations:?}"); -} - -#[test] -fn a_plugin_crate_naming_any_other_workspace_crate_is_reported_in_every_table() { - for (table, dep) in [ - ("dependencies", "runvil-engine"), - ("dependencies", "runvil"), - ("dev-dependencies", "plugin-user-input"), - ("build-dependencies", "build-ui"), - ] { - let violations = - plugin_web_naming(&format!("[{table}]\n{dep} = {{ path = \"../{dep}\" }}\n")); - assert_eq!(violations.len(), 1, "{table} {dep}: {violations:?}"); - assert!( - violations[0].starts_with(&format!("plugin-web depends on {dep}:")) - && violations[0].contains(PLUGIN_RULE), - "{table} {dep}: {violations:?}" - ); - } -} - -/// The violations of `package` in `dir` naming `plugin-web` under `table`. -fn naming_plugin_web(dir: &str, package: &str, table: &str) -> Vec { - let root = tempfile::TempDir::new().expect("tempdir"); - let depth = "../".repeat(dir.matches('/').count() + 1); - write_crate( - root.path(), - dir, - package, - &format!("[{table}]\nplugin-web = {{ path = \"{depth}plugin-web\" }}\n"), - ); - write_crate(root.path(), "plugin-web", "plugin-web", ""); - product_boundary_violations(root.path()) -} - -#[test] -fn engine_and_harness_crates_naming_a_plugin_crate_are_reported() { - for (dir, package, table) in [ - ("runvil-engine", "runvil-engine", "dependencies"), - ("runvil", "runvil", "dev-dependencies"), - ("harness-internal/runner", "harness-runner", "dependencies"), - ] { - let violations = naming_plugin_web(dir, package, table); - assert_eq!(violations.len(), 1, "{package}: {violations:?}"); - assert!( - violations[0].starts_with(&format!("{package} depends on plugin-web:")) - && violations[0] - .contains("Engine and harness crates must not depend on plugin crates"), - "{package}: {violations:?}" - ); - } -} - -#[test] -fn the_harness_gateway_client_and_workshop_crates_may_name_a_plugin_crate() { - for (dir, package) in [ - ("harness-gateway-client", "harness-gateway-client"), - ("workshop/server", "workshop-server"), - ] { - let violations = naming_plugin_web(dir, package, "dependencies"); - assert!(violations.is_empty(), "{package}: {violations:?}"); - } -} diff --git a/crates/build-xtask/src/product/test_support.rs b/crates/build-xtask/src/product/test_support.rs index 6844e49c1..6007d6bc2 100644 --- a/crates/build-xtask/src/product/test_support.rs +++ b/crates/build-xtask/src/product/test_support.rs @@ -1,5 +1,5 @@ -//! Shared fixtures for the product-boundary test siblings and the Engine -//! guards. +//! Shared fixtures for the product-boundary test siblings and the other +//! checks' tests. use std::path::{Path, PathBuf}; @@ -13,7 +13,7 @@ pub(crate) fn workspace_root() -> PathBuf { /// Writes a minimal crate manifest into a fake workspace; `dir_name` may /// contain a slash to nest the crate under a container -/// (`runvil-internal/lua`). +/// (`workshop/protocol`). pub(crate) fn write_crate(root: &Path, dir_name: &str, package: &str, deps: &str) { let dir = root.join("crates").join(dir_name); std::fs::create_dir_all(&dir).expect("the crate directory creates"); @@ -23,41 +23,3 @@ pub(crate) fn write_crate(root: &Path, dir_name: &str, package: &str, deps: &str ) .expect("the manifest writes"); } - -/// A fixture root `clippy.toml`: one setting and one `disallowed-methods` -/// entry for the Engine files to repeat. -pub(crate) const ROOT_CLIPPY_TOML: &str = "allow-unwrap-in-tests = true\n\ - disallowed-methods = [\n \ - { path = \"std::panic::set_hook\", reason = \"use a returned error instead\" },\n\ - ]\n"; - -/// An Engine `clippy.toml` that repeats [`ROOT_CLIPPY_TOML`] and holds -/// every determinism ban, one entry per line, without the lines that -/// contain `omit`. -pub(crate) fn engine_clippy_toml(omit: Option<&str>) -> String { - let mut lines = vec![ - "allow-unwrap-in-tests = true".to_owned(), - "disallowed-methods = [".to_owned(), - " { path = \"std::panic::set_hook\", reason = \"use a returned error instead\" }," - .to_owned(), - ]; - lines.extend(crate::engine_clippy::DETERMINISM_BANS.iter().map(|path| { - format!(" {{ path = \"{path}\", reason = \"use the deterministic form instead\" }},") - })); - lines.push("]".to_owned()); - lines.retain(|line| omit.is_none_or(|omit| !line.contains(omit))); - lines.join("\n") + "\n" -} - -/// Writes [`ROOT_CLIPPY_TOML`] at `root` and a passing Engine -/// `clippy.toml` at each Engine location the guard requires. -pub(crate) fn write_engine_clippy_tomls(root: &Path) { - std::fs::write(root.join("clippy.toml"), ROOT_CLIPPY_TOML) - .expect("the root clippy.toml writes"); - for dir in ["runvil-internal", "runvil-engine", "runvil-plugin"] { - let dir = root.join("crates").join(dir); - std::fs::create_dir_all(&dir).expect("the Engine clippy.toml directory creates"); - std::fs::write(dir.join("clippy.toml"), engine_clippy_toml(None)) - .expect("the Engine clippy.toml writes"); - } -} diff --git a/crates/build-xtask/src/product/tests.rs b/crates/build-xtask/src/product/tests.rs index 68b99745a..f074a2710 100644 --- a/crates/build-xtask/src/product/tests.rs +++ b/crates/build-xtask/src/product/tests.rs @@ -1,6 +1,6 @@ -//! Family-matrix fixtures: the dependency rules between product families, -//! the desktop-app boundary, and the classification itself. Container-privacy -//! fixtures sit in `container_tests.rs`. +//! Product-boundary fixtures: the desktop-app boundary, the dependency +//! tables and renames the check reads, and the classification itself. +//! Container-privacy fixtures sit in `container_tests.rs`. use super::test_support::{workspace_root, write_crate}; use super::*; @@ -125,83 +125,26 @@ fn a_facade_dependency_on_a_workshop_crate_other_than_the_server_is_reported() { ); } -#[test] -fn an_outside_crate_reaching_past_the_one_public_crate_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "outside-tool", - "outside-tool", - "[dependencies]\nrunvil-lua = { path = \"../runvil-lua\" }\nrunvil-engine = { path = \"../runvil-engine\" }\n", - ); - write_crate(root.path(), "runvil-lua", "runvil-lua", ""); - write_crate(root.path(), "runvil-engine", "runvil-engine", ""); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("outside-tool") && violations[0].contains("runvil-lua"), - "the violation names the crate and the forbidden dep: {violations:?}" - ); -} - -#[test] -fn an_outside_crate_depending_on_a_root_engine_crate_other_than_the_facade_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "outside-tool", - "outside-tool", - "[dependencies]\nrunvil-extra = { path = \"../runvil-extra\" }\n\ - runvil-spare = { path = \"../runvil-spare\" }\n", - ); - for name in ["runvil-extra", "runvil-spare"] { - write_crate(root.path(), name, name, ""); - } - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 2, "{violations:?}"); - assert!( - violations - .iter() - .all(|v| v.starts_with("outside-tool depends on runvil-") - && v.ends_with("only through runvil-engine or runvil-plugin")), - "runvil-engine and runvil-plugin are the public crates, wherever the others sit: {violations:?}" - ); -} - -#[test] -fn an_outside_crate_depending_on_the_plugin_contract_passes() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "outside-tool", - "outside-tool", - "[dependencies]\nrunvil-plugin = { path = \"../runvil-plugin\" }\n", - ); - write_crate(root.path(), "runvil-plugin", "runvil-plugin", ""); - let violations = product_boundary_violations(root.path()); - assert!(violations.is_empty(), "{violations:?}"); -} - #[test] fn a_crate_named_like_the_former_gateway_family_is_an_outside_crate() { // The Gateway moved to its own repository, so no crate here is // classified into a gateway family. A package that would have been one - // is an ordinary outside crate and reaches the Engine only through its - // facade. + // is an ordinary outside crate and is kept out of the workshop + // container like any other. let root = tempfile::TempDir::new().expect("tempdir"); write_crate( root.path(), "gateway-routing", "gateway-routing", - "[dependencies]\nrunvil-lua = { path = \"../runvil-lua\" }\n", + "[dependencies]\nworkshop-protocol = { path = \"../workshop/protocol\" }\n", ); - write_crate(root.path(), "runvil-lua", "runvil-lua", ""); + write_crate(root.path(), "workshop/protocol", "workshop-protocol", ""); let violations = product_boundary_violations(root.path()); assert_eq!(violations.len(), 1, "{violations:?}"); assert!( - violations[0].starts_with("gateway-routing depends on runvil-lua:") - && violations[0].contains("only through runvil-engine or runvil-plugin"), - "the outside-crate rule binds it as it binds any other: {violations:?}" + violations[0].starts_with("gateway-routing depends on workshop-protocol:") + && violations[0].contains("no outside crate may depend into it"), + "container privacy binds it as it binds any other outside crate: {violations:?}" ); } @@ -210,13 +153,13 @@ fn dev_build_and_target_dependencies_are_checked() { let root = tempfile::TempDir::new().expect("tempdir"); write_crate( root.path(), - "workshop-server", - "workshop-server", - "[dev-dependencies]\nrunvil-prompt = { path = \"../runvil-prompt\" }\n\ - [target.'cfg(windows)'.dependencies]\nharness-runner = { path = \"../harness-runner\" }\n", + "outside-tool", + "outside-tool", + "[dev-dependencies]\nworkshop-protocol = { path = \"../workshop/protocol\" }\n\ + [target.'cfg(windows)'.dependencies]\nworkshop-status = { path = \"../workshop/status\" }\n", ); - write_crate(root.path(), "runvil-prompt", "runvil-prompt", ""); - write_crate(root.path(), "harness-runner", "harness-runner", ""); + write_crate(root.path(), "workshop/protocol", "workshop-protocol", ""); + write_crate(root.path(), "workshop/status", "workshop-status", ""); let violations = product_boundary_violations(root.path()); assert_eq!( violations.len(), @@ -230,71 +173,16 @@ fn package_renames_are_resolved_before_classification() { let root = tempfile::TempDir::new().expect("tempdir"); write_crate( root.path(), - "workshop-sessions", - "workshop-sessions", - "[dependencies]\npf = { package = \"runvil-store\", path = \"../runvil-store\" }\n", + "outside-tool", + "outside-tool", + "[dependencies]\nwire = { package = \"workshop-protocol\", path = \"../workshop/protocol\" }\n", ); - write_crate(root.path(), "runvil-store", "runvil-store", ""); + write_crate(root.path(), "workshop/protocol", "workshop-protocol", ""); let violations = product_boundary_violations(root.path()); assert_eq!( violations.len(), 1, - "the renamed dependency on runvil-store is reported: {violations:?}" - ); -} - -#[test] -fn an_engine_crate_depending_on_a_workshop_crate_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "runvil-internal/runtime", - "runvil-runtime", - "[dependencies]\nworkshop-protocol = { path = \"../../workshop-protocol\" }\n", - ); - write_crate(root.path(), "workshop-protocol", "workshop-protocol", ""); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations.iter().all(|v| v.contains("runvil-runtime")) - && violations[0] - .contains("Engine crates must not depend on workshop or harness crates"), - "the violation names the Engine crate and the workshop ban: {violations:?}" - ); -} - -#[test] -fn an_engine_crate_depending_on_the_harness_facade_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "runvil-internal/runtime", - "runvil-runtime", - "[dependencies]\nrunvil = { path = \"../../runvil\" }\n", - ); - write_crate(root.path(), "runvil", "runvil", ""); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("Engine crates must not depend on workshop or harness crates"), - "the violation names the harness ban: {violations:?}" - ); -} - -#[test] -fn an_outside_crate_depending_on_the_facade_passes() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "outside-tool", - "outside-tool", - "[dependencies]\nrunvil-engine = { path = \"../runvil-engine\" }\n", - ); - write_crate(root.path(), "runvil-engine", "runvil-engine", ""); - let violations = product_boundary_violations(root.path()); - assert!( - violations.is_empty(), - "the Engine facade is a legal dependency for outside crates: {violations:?}" + "the renamed dependency on workshop-protocol is reported: {violations:?}" ); } @@ -316,179 +204,12 @@ fn an_unparseable_manifest_is_reported() { ); } -#[test] -fn a_harness_crate_depending_on_the_engine_facade_passes() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "harness-internal/runner", - "harness-runner", - "[dependencies]\nrunvil-engine = { path = \"../../runvil-engine\" }\n", - ); - write_crate(root.path(), "runvil-engine", "runvil-engine", ""); - let violations = product_boundary_violations(root.path()); - assert!( - violations.is_empty(), - "the Engine facade is legal for harness crates: {violations:?}" - ); -} - -#[test] -fn a_harness_crate_depending_on_a_workshop_crate_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "harness-internal/sessions", - "harness-sessions", - "[dependencies]\nworkshop-registry = { path = \"../../workshop-registry\" }\n", - ); - write_crate(root.path(), "workshop-registry", "workshop-registry", ""); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with("harness-sessions depends on workshop-registry:") - && violations[0].contains("harness crates must not depend on workshop crates"), - "the violation names the harness crate and the workshop dep: {violations:?}" - ); -} - -#[test] -fn a_harness_crate_depending_on_a_private_product_crate_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "harness-internal/plugins", - "harness-plugins", - "[dependencies]\nrunvil-runtime = { path = \"../../runvil-runtime\" }\n", - ); - write_crate(root.path(), "runvil-runtime", "runvil-runtime", ""); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with("harness-plugins depends on runvil-runtime:") - && violations[0].contains( - "outside crates may depend on the Engine family only through runvil-engine or runvil-plugin" - ), - "the violation names the harness crate and the facade rule: {violations:?}" - ); -} - -#[test] -fn a_harness_crate_reaching_past_the_engine_facade_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "harness-internal/runner", - "harness-runner", - "[dependencies]\nrunvil-lua = { path = \"../../runvil-lua\" }\n", - ); - write_crate(root.path(), "runvil-lua", "runvil-lua", ""); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with("harness-runner depends on runvil-lua:"), - "the single-public-crate rule binds harness crates: {violations:?}" - ); -} - -#[test] -fn a_workshop_crate_depending_on_an_engine_crate_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "workshop-sessions", - "workshop-sessions", - "[dependencies]\nrunvil-engine = { path = \"../runvil-engine\" }\n\ - runvil-plugin = { path = \"../runvil-plugin\" }\n\ - runvil = { path = \"../runvil\" }\n", - ); - for name in ["runvil-engine", "runvil-plugin", "runvil"] { - write_crate(root.path(), name, name, ""); - } - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 2, "{violations:?}"); - assert!( - violations - .iter() - .all(|v| v.starts_with("workshop-sessions depends on runvil-") - && v.ends_with("they reach the Engine through runvil")), - "a workshop crate reaches the Engine through runvil alone: {violations:?}" - ); -} - -#[test] -fn a_workshop_crate_depending_on_the_harness_facade_passes() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "workshop/server", - "workshop-server", - "[dependencies]\nrunvil = { path = \"../../runvil\" }\n", - ); - write_crate(root.path(), "runvil", "runvil", ""); - let violations = product_boundary_violations(root.path()); - assert!( - violations.is_empty(), - "runvil is the harness public crate for workshop crates: {violations:?}" - ); -} - -#[test] -fn a_workshop_crate_depending_on_a_harness_crate_other_than_the_facade_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate( - root.path(), - "workshop/server", - "workshop-server", - "[dependencies]\nharness-runner = { path = \"../../harness-runner\" }\n", - ); - write_crate(root.path(), "harness-runner", "harness-runner", ""); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with("workshop-server depends on harness-runner:") - && violations[0].ends_with("only through runvil or harness-gateway-client"), - "the violation names the workshop crate and the harness facade: {violations:?}" - ); -} - -#[test] -fn the_engine_facade_depending_on_the_harness_facade_is_reported() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate(root.path(), "runvil", "runvil", ""); - write_crate( - root.path(), - "runvil-engine", - "runvil-engine", - "[dependencies]\nrunvil = { path = \"../runvil\" }\n", - ); - let violations = product_boundary_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].starts_with("runvil-engine depends on runvil:"), - "the Engine facade depending on the harness facade is reported: {violations:?}" - ); -} - #[test] fn family_classification_follows_the_naming_rules() { - assert_eq!(family("runvil-runtime"), Family::Engine); assert_eq!(family("workshop"), Family::Workshop); assert_eq!(family("workshop-server"), Family::Workshop); assert_eq!(family("build-xtask"), Family::Build); assert_eq!(family("serde"), Family::Unaffiliated); -} - -#[test] -fn the_runvil_package_belongs_to_the_harness_family() { - assert_eq!(family("runvil"), Family::Harness); - assert_eq!(family("harness-runner"), Family::Harness); - assert_eq!(family("harness"), Family::Unaffiliated); - assert_eq!(family("harnesses"), Family::Unaffiliated); -} - -#[test] -fn only_a_prefixed_runvil_package_belongs_to_the_engine_family() { - assert_eq!(family("runvil-engine"), Family::Engine); - assert_eq!(family("runvilr"), Family::Unaffiliated); + assert_eq!(family("runvil"), Family::Unaffiliated); + assert_eq!(family("harness-runner"), Family::Unaffiliated); } diff --git a/crates/build-xtask/src/site.rs b/crates/build-xtask/src/site.rs index 8a347fd51..76b0b2211 100644 --- a/crates/build-xtask/src/site.rs +++ b/crates/build-xtask/src/site.rs @@ -48,7 +48,9 @@ pub(crate) const DOCS_VAR: &str = "RUNVIL_DOCS"; /// The rustdoc sites: the folder under `target/site/`, the crate it /// documents, with default features, the facade as dependents read it, and -/// the few broad words its landing row shows under Covers. +/// the few broad words its landing row shows under Covers. The crates come +/// from runvil-harness, so each site documents the commit the root +/// `Cargo.toml` pins. const RUSTDOC_SITES: [(&str, &str, &str); 5] = [ ( "runvil-engine", diff --git a/crates/build-xtask/src/test_support_leak-tests.rs b/crates/build-xtask/src/test_support_leak-tests.rs deleted file mode 100644 index deb41bf8a..000000000 --- a/crates/build-xtask/src/test_support_leak-tests.rs +++ /dev/null @@ -1,359 +0,0 @@ -//! Fixture tests for the `test-support` leak guard, plus the live check -//! over this workspace: no non-dev dependency table anywhere enables a -//! Engine or Harness crate's `test-support` feature. - -use std::path::{Path, PathBuf}; - -use super::*; - -fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("build-xtask lives at /crates/build-xtask") - .to_path_buf() -} - -/// Writes one crate under `/crates//` named `name`, with the -/// given manifest body after `[package]`. -fn write_crate(root: &Path, dir: &str, name: &str, manifest: &str) { - let crate_dir = root.join("crates").join(dir); - std::fs::create_dir_all(crate_dir.join("src")).expect("the crate directory creates"); - std::fs::write( - crate_dir.join("Cargo.toml"), - format!("[package]\nname = \"{name}\"\n{manifest}"), - ) - .expect("the manifest writes"); - std::fs::write(crate_dir.join("src").join("lib.rs"), "pub struct Live;\n") - .expect("lib.rs writes"); -} - -/// A fake workspace holding three container Engine crates, each exposing -/// a `test-support` feature, so a fixture can add one consumer and see -/// only that consumer's findings. -fn engine_root() -> tempfile::TempDir { - let root = tempfile::TempDir::new().expect("tempdir"); - let features = "[features]\ntest-support = []\n"; - write_crate( - root.path(), - "runvil-internal/runtime", - "runvil-runtime", - features, - ); - write_crate( - root.path(), - "runvil-internal/types", - "runvil-types", - features, - ); - write_crate(root.path(), "runvil-internal/lua", "runvil-lua", features); - root -} - -/// `engine_root` plus one container Harness crate, `harness-runner`, -/// exposing a `test-support` feature. -fn guarded_root() -> tempfile::TempDir { - let root = engine_root(); - write_crate( - root.path(), - "harness-internal/runner", - "harness-runner", - "[features]\ntest-support = []\n", - ); - root -} - -#[test] -fn no_non_dev_table_in_the_workspace_enables_an_engine_test_support_feature() { - let violations = test_support_leak_violations(&workspace_root()); - assert!( - violations.is_empty(), - "test-support leaks:\n{}", - violations.join("\n") - ); -} - -#[test] -fn a_dependencies_table_enabling_an_engine_test_support_feature_is_reported() { - let root = engine_root(); - write_crate( - root.path(), - "harness-internal/plugins", - "harness-plugins", - "[dependencies]\n\ - runvil-runtime = { workspace = true, features = [\"test-support\"] }\n", - ); - let violations = test_support_leak_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("[dependencies]") - && violations[0].contains("runvil-runtime/test-support") - && violations[0].contains("plugins"), - "the leak names the table, the feature, and the consuming crate: {violations:?}" - ); -} - -#[test] -fn a_dev_dependencies_table_enabling_an_engine_test_support_feature_passes() { - let root = engine_root(); - write_crate( - root.path(), - "harness-internal/plugins", - "harness-plugins", - "[dependencies]\nrunvil-runtime = { workspace = true }\n\ - [dev-dependencies]\n\ - runvil-runtime = { workspace = true, features = [\"test-support\"] }\n\ - [target.'cfg(unix)'.dev-dependencies]\n\ - runvil-lua = { workspace = true, features = [\"test-support\"] }\n", - ); - let violations = test_support_leak_violations(root.path()); - assert!( - violations.is_empty(), - "dev-dependencies may enable test-support: {violations:?}" - ); -} - -#[test] -fn build_and_target_tables_are_scanned_renames_resolved_and_non_engine_features_ignored() { - let root = engine_root(); - write_crate( - root.path(), - "harness-internal/runner", - "harness-runner", - "[build-dependencies]\n\ - rt = { package = \"runvil-runtime\", features = [\"test-support\"] }\n\ - [target.'cfg(windows)'.dependencies]\n\ - runvil-lua = { workspace = true, features = [\"serialize\", \"test-support\"] }\n\ - [dependencies]\n\ - runvil-types = { workspace = true, features = [\"serde\"] }\n\ - harness-plugins = { workspace = true, features = [\"test-support\"] }\n", - ); - let violations = test_support_leak_violations(root.path()); - assert_eq!(violations.len(), 2, "{violations:?}"); - assert!( - violations.iter().any( - |v| v.contains("[build-dependencies]") && v.contains("runvil-runtime/test-support") - ), - "the renamed build-dependency is reported by package name: {violations:?}" - ); - assert!( - violations - .iter() - .any(|v| v.contains("cfg(windows)") && v.contains("runvil-lua/test-support")), - "the target-specific entry is reported: {violations:?}" - ); -} - -#[test] -fn a_workspace_dependencies_entry_enabling_an_engine_test_support_feature_is_reported() { - let root = engine_root(); - std::fs::write( - root.path().join("Cargo.toml"), - "[workspace]\nmembers = []\n[workspace.dependencies]\n\ - runvil-runtime = { path = \"crates/runvil-internal/runtime\", features = [\"test-support\"] }\n\ - runvil-lua = { path = \"crates/runvil-internal/lua\" }\n", - ) - .expect("the root manifest writes"); - let violations = test_support_leak_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("[workspace.dependencies]") - && violations[0].contains("runvil-runtime/test-support"), - "the inherited entry is reported against the root manifest: {violations:?}" - ); -} - -#[test] -fn a_features_value_enabling_an_engine_test_support_feature_is_reported() { - let root = engine_root(); - write_crate( - root.path(), - "workshop/server", - "workshop-server", - "[dependencies]\n\ - runvil-runtime = { workspace = true }\n\ - rt-types = { package = \"runvil-types\", workspace = true, optional = true }\n\ - runvil-lua = { workspace = true, optional = true }\n\ - harness-plugins = { workspace = true }\n\ - [features]\n\ - default = [\"runvil-runtime/test-support\"]\n\ - types = [\"rt-types?/test-support\"]\n\ - fixtures = [\"dep:runvil-lua\", \"harness-plugins/test-support\", \"runvil-lua/serialize\"]\n", - ); - let violations = test_support_leak_violations(root.path()); - assert_eq!(violations.len(), 2, "{violations:?}"); - assert!( - violations.iter().any(|v| v.contains("[features] default") - && v.contains("runvil-runtime/test-support") - && v.contains("server")), - "the plain dependency-feature reference names the feature, the engine crate, and the consuming crate: {violations:?}" - ); - assert!( - violations - .iter() - .any(|v| v.contains("[features] types") && v.contains("runvil-types/test-support")), - "the weak `?/` reference resolves its `package` rename: {violations:?}" - ); -} - -#[test] -fn an_engine_crate_forwarding_its_own_test_support_feature_passes() { - let root = engine_root(); - write_crate( - root.path(), - "runvil-internal/runtime", - "runvil-runtime", - "[dependencies]\nrunvil-lua = { workspace = true }\n\ - [features]\ntest-support = [\"runvil-lua/test-support\"]\n\ - other = [\"runvil-lua/serialize\"]\n", - ); - let violations = test_support_leak_violations(root.path()); - assert!( - violations.is_empty(), - "an engine crate's own test-support forwarding is gated by the guarded feature: {violations:?}" - ); -} - -#[test] -fn the_facade_forwarding_the_engine_test_support_feature_passes() { - let root = engine_root(); - write_crate( - root.path(), - "runvil-engine", - "runvil-engine", - "[dependencies]\nrunvil-runtime = { workspace = true }\n\ - [features]\ntest-support = [\"runvil-runtime/test-support\"]\n", - ); - let violations = test_support_leak_violations(root.path()); - assert!( - violations.is_empty(), - "the facade is an engine crate, so forwarding its own test-support is gated: {violations:?}" - ); -} - -#[test] -fn an_engine_crate_enabling_a_sibling_test_support_feature_outside_dev_is_reported() { - let root = engine_root(); - write_crate( - root.path(), - "runvil-internal/runtime", - "runvil-runtime", - "[dependencies]\nrunvil-lua = { workspace = true, features = [\"test-support\"] }\n\ - [features]\ntest-support = []\n", - ); - let violations = test_support_leak_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("runvil-lua/test-support"), - "{violations:?}" - ); -} - -#[test] -fn a_dependencies_table_enabling_a_harness_test_support_feature_is_reported() { - let root = guarded_root(); - write_crate( - root.path(), - "harness-internal/plugins", - "harness-plugins", - "[dependencies]\n\ - harness-runner = { workspace = true, features = [\"test-support\"] }\n", - ); - let violations = test_support_leak_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("[dependencies]") - && violations[0].contains("harness-runner/test-support") - && violations[0].contains("plugins"), - "the leak names the table, the feature, and the consuming crate: {violations:?}" - ); -} - -#[test] -fn a_dev_dependencies_table_enabling_a_harness_test_support_feature_passes() { - let root = guarded_root(); - write_crate( - root.path(), - "harness-internal/plugins", - "harness-plugins", - "[dependencies]\nharness-runner = { workspace = true }\n\ - [dev-dependencies]\n\ - harness-runner = { workspace = true, features = [\"test-support\"] }\n", - ); - let violations = test_support_leak_violations(root.path()); - assert!( - violations.is_empty(), - "dev-dependencies may enable a harness crate's test-support: {violations:?}" - ); -} - -#[test] -fn a_harness_crate_forwarding_its_own_test_support_feature_passes() { - let root = guarded_root(); - write_crate( - root.path(), - "harness-internal/sessions", - "harness-sessions", - "[dependencies]\nharness-runner = { workspace = true }\n\ - [features]\ntest-support = [\"harness-runner/test-support\"]\n", - ); - let violations = test_support_leak_violations(root.path()); - assert!( - violations.is_empty(), - "a harness crate's own test-support forwarding a harness sibling's is gated: {violations:?}" - ); -} - -#[test] -fn a_harness_crate_default_feature_enabling_a_sibling_test_support_is_reported() { - let root = guarded_root(); - write_crate( - root.path(), - "harness-internal/plugins", - "harness-plugins", - "[dependencies]\nharness-runner = { workspace = true }\n\ - [features]\ndefault = [\"harness-runner/test-support\"]\n\ - test-support = [\"harness-runner/test-support\"]\n", - ); - let violations = test_support_leak_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("[features] default") - && violations[0].contains("harness-runner/test-support") - && violations[0].contains("plugins"), - "only a crate's own test-support feature is exempt when forwarding a sibling's: {violations:?}" - ); -} - -#[test] -fn a_test_support_feature_forwarding_into_the_other_family_is_reported() { - let root = guarded_root(); - write_crate( - root.path(), - "harness-internal/plugins", - "harness-plugins", - "[dependencies]\nrunvil-lua = { workspace = true }\n\ - [features]\ntest-support = [\"runvil-lua/test-support\"]\n", - ); - write_crate( - root.path(), - "runvil-internal/runtime", - "runvil-runtime", - "[dependencies]\nharness-runner = { workspace = true }\n\ - [features]\ntest-support = [\"harness-runner/test-support\"]\n", - ); - let violations = test_support_leak_violations(root.path()); - assert_eq!(violations.len(), 2, "{violations:?}"); - assert!( - violations - .iter() - .any(|v| v.contains("plugins") && v.contains("runvil-lua/test-support")), - "a harness crate forwarding an Engine crate's test-support is reported: {violations:?}" - ); - assert!( - violations - .iter() - .any(|v| v.contains("runtime") && v.contains("harness-runner/test-support")), - "an Engine crate forwarding a harness crate's test-support is reported: {violations:?}" - ); -} diff --git a/crates/build-xtask/src/test_support_leak.rs b/crates/build-xtask/src/test_support_leak.rs deleted file mode 100644 index 2763aa92b..000000000 --- a/crates/build-xtask/src/test_support_leak.rs +++ /dev/null @@ -1,240 +0,0 @@ -//! `test-support` leak guard: no non-dev dependency table anywhere in the -//! workspace enables an Engine or Harness crate's `test-support` -//! feature. The Engine family is its two root crates plus every -//! `crates/runvil-internal/` member; the Harness family is the -//! `crates/runvil` facade plus every `crates/harness-internal/` member. -//! -//! An Engine crate may keep test drivers behind its `test-support` feature -//! (`runvil-runtime`'s serial driver and scripted model, for its -//! bench), and a Harness crate may keep test fixtures behind its own. Both -//! are safe only while `test-support` is enabled from `[dev-dependencies]` -//! alone: a production `[dependencies]` entry such as -//! `runvil-runtime = { workspace = true, features = ["test-support"] }` -//! would pull the test drivers into a shipping binary. This guard closes -//! that path. It scans every crate under `crates/` (containers included), -//! the `[dependencies]` and `[build-dependencies]` tables and their -//! `[target.]` forms, and the root manifest's -//! `[workspace.dependencies]` table, which every `workspace = true` entry -//! inherits. `[dev-dependencies]` tables are outside the guard. -//! -//! It also scans each crate's `[features]` table: a value of the form -//! `/test-support` or `?/test-support` enables the feature on -//! a non-dev dependency (cargo forbids `[features]` from naming -//! dev-dependencies), so `default = ["runvil-runtime/test-support"]` -//! is the same leak spelled through a feature. `` is resolved through -//! the crate's own dependency entries and their `package` renames. One -//! shape is exempt, within each family: a crate's own `test-support` -//! feature forwarding to another `test-support` in its family (one -//! container crate forwarding a sibling's; the guard counts both facades -//! as members of their families), because that forwarding -//! is gated by a feature this guard already confines to dev tables. -//! Forwarding into the other family is reported. -//! -//! The check reads declared dependencies, not the resolved graph. -//! Manifests that cannot be read or parsed are skipped here; the -//! product-boundary check and the Engine dependency guard already report -//! them. - -use std::fs; -use std::path::{Path, PathBuf}; - -/// The dependency tables the guard scans, directly and under `[target]`. -const CHECKED_KINDS: [&str; 2] = ["dependencies", "build-dependencies"]; - -/// The feature that gates test drivers and fixtures, which no non-dev -/// table may enable on a guarded crate. -const EXEMPTING_FEATURE: &str = "test-support"; - -/// Scans the workspace for non-dev dependency tables, and `[features]` -/// values, that enable an Engine or Harness crate's `test-support` -/// feature. -#[must_use] -pub(crate) fn test_support_leak_violations(root: &Path) -> Vec { - let families = guarded_families(root); - if families.iter().all(Vec::is_empty) { - return Vec::new(); - } - let mut violations = Vec::new(); - let root_manifest = root.join("Cargo.toml"); - if let Some(manifest) = parse_manifest(&root_manifest) - && let Some(table) = manifest - .get("workspace") - .and_then(|w| w.get("dependencies")) - .and_then(toml::Value::as_table) - { - scan_table( - &root_manifest, - "workspace.dependencies", - table, - &families, - &mut violations, - ); - } - let mut crates = Vec::new(); - crate::engine_guards::collect_crates(&root.join("crates"), &mut crates); - for dir in crates { - let manifest_path = dir.join("Cargo.toml"); - let Some(manifest) = parse_manifest(&manifest_path) else { - continue; - }; - let tables = crate::manifest::dependency_tables(&manifest, &CHECKED_KINDS); - for (table, entries) in &tables { - scan_table(&manifest_path, table, entries, &families, &mut violations); - } - scan_features( - &manifest_path, - &manifest, - &tables, - &families, - &mut violations, - ); - } - violations -} - -/// Reports every `[features]` value that enables the guarded feature on a -/// guarded crate through a dependency-feature reference. -fn scan_features( - manifest_path: &Path, - manifest: &toml::Value, - tables: &[(String, &toml::map::Map)], - families: &[Vec], - violations: &mut Vec, -) { - let Some(features) = manifest.get("features").and_then(toml::Value::as_table) else { - return; - }; - let self_family = manifest - .get("package") - .and_then(|p| p.get("name")) - .and_then(toml::Value::as_str) - .and_then(|name| family_of(name, families)); - for (feature, values) in features { - let Some(values) = values.as_array() else { - continue; - }; - for dep in values - .iter() - .filter_map(toml::Value::as_str) - .filter_map(guarded_dependency_reference) - { - let package = resolve_package(dep, tables); - let Some(family) = family_of(package, families) else { - continue; - }; - // A crate's own `test-support` forwarding to a family sibling's - // is gated by a feature the dependency-table scan already - // confines to dev tables, so any other feature leaks. - // Cross-family forwarding is reported because the exemption - // applies within each family only. - if feature == EXEMPTING_FEATURE && self_family == Some(family) { - continue; - } - violations.push(format!( - "{}: [features] {feature} enables {package}/{EXEMPTING_FEATURE}; only [dev-dependencies] may enable an Engine or harness crate's {EXEMPTING_FEATURE} feature", - manifest_path.display() - )); - } - } -} - -/// The dependency key a `[features]` value names when it enables the -/// guarded feature: `/test-support` or `?/test-support`. -fn guarded_dependency_reference(value: &str) -> Option<&str> { - let (dep, feature) = value.split_once('/')?; - (feature == EXEMPTING_FEATURE).then(|| dep.strip_suffix('?').unwrap_or(dep)) -} - -/// The package a dependency key names: its `package` rename when the -/// key appears in one of the crate's dependency tables, else the key. -fn resolve_package<'a>( - key: &'a str, - tables: &[(String, &'a toml::map::Map)], -) -> &'a str { - tables - .iter() - .find_map(|(_, entries)| entries.get(key)) - .and_then(|entry| entry.get("package").and_then(toml::Value::as_str)) - .unwrap_or(key) -} - -/// Reports every entry in `table` that names a guarded crate and lists the -/// guarded feature. -fn scan_table( - manifest: &Path, - table: &str, - entries: &toml::map::Map, - families: &[Vec], - violations: &mut Vec, -) { - for (key, entry) in entries { - let package = entry - .get("package") - .and_then(toml::Value::as_str) - .unwrap_or(key); - if family_of(package, families).is_none() { - continue; - } - let enables = entry - .get("features") - .and_then(toml::Value::as_array) - .is_some_and(|features| { - features - .iter() - .filter_map(toml::Value::as_str) - .any(|feature| feature == EXEMPTING_FEATURE) - }); - if enables { - violations.push(format!( - "{}: [{table}] enables {package}/{EXEMPTING_FEATURE}; only [dev-dependencies] may enable an Engine or harness crate's {EXEMPTING_FEATURE} feature", - manifest.display() - )); - } - } -} - -/// The index of the family in `families` that names `package`, or `None` -/// when no guarded family does. -fn family_of(package: &str, families: &[Vec]) -> Option { - families - .iter() - .position(|names| names.iter().any(|name| name == package)) -} - -/// The package names of each guarded family's crates whose manifests -/// parse: the Engine crates, then the Harness crates. -fn guarded_families(root: &Path) -> [Vec; 2] { - let crates_dir = root.join("crates"); - [ - package_names(&crate::engine_guards::engine_crates(root)), - package_names(&crate::harness_bans::harness_crates( - &crates_dir.join("harness-internal"), - &crates_dir.join("runvil"), - )), - ] -} - -/// The package names of every crate in `dirs` whose manifest parses. -fn package_names(dirs: &[PathBuf]) -> Vec { - dirs.iter() - .filter_map(|dir| parse_manifest(&dir.join("Cargo.toml"))) - .filter_map(|manifest| { - manifest - .get("package") - .and_then(|p| p.get("name")) - .and_then(toml::Value::as_str) - .map(str::to_owned) - }) - .collect() -} - -/// Reads and parses one manifest, or `None` when it cannot be read or parsed. -fn parse_manifest(path: &Path) -> Option { - fs::read_to_string(path) - .ok() - .and_then(|text| toml::from_str(&text).ok()) -} - -#[cfg(test)] -#[path = "test_support_leak-tests.rs"] -mod tests; diff --git a/crates/build-xtask/src/tidy-tests.rs b/crates/build-xtask/src/tidy-tests.rs index 3d675243e..2105fd947 100644 --- a/crates/build-xtask/src/tidy-tests.rs +++ b/crates/build-xtask/src/tidy-tests.rs @@ -70,48 +70,36 @@ fn write_crate(root: &Path, dir: &str, name: &str, lib_docs: &str) { std::fs::write(src.join("lib.rs"), lib_docs).expect("lib.rs writes"); } -const MARKED: &str = "//! Effect loop.\n//!\n//! ## Invariants\n//!\n//! - none\n"; -const UNMARKED: &str = "//! Effect loop, with no invariants block.\n"; +const MARKED: &str = "//! Status bar.\n//!\n//! ## Invariants\n//!\n//! - none\n"; +const UNMARKED: &str = "//! Status bar, with no invariants block.\n"; #[test] -fn a_harness_crate_without_the_marker_is_a_violation() { +fn a_workshop_crate_without_the_marker_is_a_violation() { let root = tempfile::TempDir::new().expect("tempdir"); - write_crate(root.path(), "harness/runner", "harness-runner", UNMARKED); + write_crate(root.path(), "workshop/status", "workshop-status", UNMARKED); let violations = marker_violations(root.path()); assert_eq!(violations.len(), 1, "{violations:?}"); assert!( - violations[0].contains("harness-runner") && violations[0].contains(INVARIANT_MARKER), + violations[0].contains("workshop-status") && violations[0].contains(INVARIANT_MARKER), "the crate and the missing marker are named: {violations:?}" ); } #[test] -fn a_plugin_crate_without_the_marker_is_a_violation() { +fn a_workshop_crate_whose_manifest_has_no_package_name_is_reported_not_skipped() { let root = tempfile::TempDir::new().expect("tempdir"); - write_crate(root.path(), "plugin-web", "plugin-web", UNMARKED); - let violations = marker_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("plugin-web") && violations[0].contains(INVARIANT_MARKER), - "the crate and the missing marker are named: {violations:?}" - ); -} - -#[test] -fn a_harness_crate_whose_manifest_has_no_package_name_is_reported_not_skipped() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_crate(root.path(), "harness/runner", "harness-runner", UNMARKED); + write_crate(root.path(), "workshop/status", "workshop-status", UNMARKED); let manifest = root .path() .join("crates") - .join("harness") - .join("runner") + .join("workshop") + .join("status") .join("Cargo.toml"); std::fs::write(&manifest, "[lints]\nworkspace = true\n").expect("the manifest rewrites"); let violations = marker_violations(root.path()); assert_eq!(violations.len(), 1, "{violations:?}"); assert!( - violations[0].contains("runner") && violations[0].contains("no package name"), + violations[0].contains("status") && violations[0].contains("no package name"), "the directory and the failure mode are named: {violations:?}" ); } @@ -145,9 +133,9 @@ fn the_workshop_desktop_app_without_the_marker_passes() { } #[test] -fn an_unmarked_crate_outside_the_families_is_left_alone() { +fn an_unmarked_crate_outside_the_workshop_family_is_left_alone() { let root = tempfile::TempDir::new().expect("tempdir"); - write_crate(root.path(), "runvil-internal/vfs", "runvil-vfs", UNMARKED); + write_crate(root.path(), "build-ui", "build-ui", UNMARKED); assert!(marker_violations(root.path()).is_empty()); } @@ -165,8 +153,8 @@ fn write_root_manifest(root: &Path) { fn crates_that_inherit_the_workspace_lints_pass_with_or_without_the_marker() { let root = tempfile::TempDir::new().expect("tempdir"); write_root_manifest(root.path()); - write_crate(root.path(), "runvil-internal/vfs", "runvil-vfs", UNMARKED); - write_crate(root.path(), "harness/runner", "harness-runner", MARKED); + write_crate(root.path(), "build-ui", "build-ui", UNMARKED); + write_crate(root.path(), "workshop/status", "workshop-status", MARKED); let violations = lint_inheritance_violations(root.path()); assert!(violations.is_empty(), "{violations:?}"); } @@ -175,20 +163,19 @@ fn crates_that_inherit_the_workspace_lints_pass_with_or_without_the_marker() { fn an_unmarked_crate_with_its_own_lint_table_is_reported() { let root = tempfile::TempDir::new().expect("tempdir"); write_root_manifest(root.path()); - write_crate(root.path(), "runvil-internal/vfs", "runvil-vfs", UNMARKED); + write_crate(root.path(), "build-ui", "build-ui", UNMARKED); std::fs::write( root.path() .join("crates") - .join("runvil-internal") - .join("vfs") + .join("build-ui") .join("Cargo.toml"), - "[package]\nname = \"runvil-vfs\"\n[lints.clippy]\npedantic = \"warn\"\n", + "[package]\nname = \"build-ui\"\n[lints.clippy]\npedantic = \"warn\"\n", ) .expect("the manifest rewrites"); let violations = lint_inheritance_violations(root.path()); assert_eq!(violations.len(), 1, "{violations:?}"); assert!( - violations[0].contains("vfs") && violations[0].contains("[lints] workspace = true"), + violations[0].contains("build-ui") && violations[0].contains("[lints] workspace = true"), "every crate, not only a marked one, must inherit: {violations:?}" ); } @@ -210,7 +197,7 @@ fn a_lint_inheritance_scan_that_finds_no_crate_fails() { fn a_crate_whose_manifest_does_not_parse_is_held_to_lint_inheritance_not_skipped() { let root = tempfile::TempDir::new().expect("tempdir"); write_root_manifest(root.path()); - write_crate(root.path(), "runvil-internal/vfs", "runvil-vfs", UNMARKED); + write_crate(root.path(), "build-ui", "build-ui", UNMARKED); let dir = root.path().join("crates").join("broken"); std::fs::create_dir_all(&dir).expect("the crate directory creates"); std::fs::write(dir.join("Cargo.toml"), "not [valid toml").expect("the manifest writes"); diff --git a/crates/build-xtask/src/tidy.rs b/crates/build-xtask/src/tidy.rs index eb77afc64..4dad73643 100644 --- a/crates/build-xtask/src/tidy.rs +++ b/crates/build-xtask/src/tidy.rs @@ -1,10 +1,7 @@ //! Tidy-style architecture checks for the workshop server decomposition, -//! the Harness family, the sans-I/O Engine (dependency guard, clippy -//! guard, and `test-support` leak guard, run from `engine_guards`), the -//! `runvil-engine` and `runvil` facades' source shape (run from -//! `facade_shape`), the `doc(hidden)` ban over both facades and their -//! containers (run from `doc_hidden`), the doctest ban over every -//! workspace crate (run from `no_doctests`), the `Debug` trait's one +//! the product boundary (run from `product`), the pin on the crates that +//! come from runvil-harness (run from `harness_pin`), the doctest ban over +//! every workspace crate (run from `no_doctests`), the `Debug` trait's one //! spelling over every workspace crate (run from `debug_spelling`), the //! unsafe allowlist (run from `unsafe_allowlist`), and the type-size //! limits over every workspace crate (run from `type_size`). @@ -55,9 +52,8 @@ const FEATURES: &[Tiered] = &[ const SERVER: &[Tiered] = &[("workshop-server", "server")]; /// Marker in a crate's `lib.rs` (or `main.rs`) crate docs. Mandatory for -/// every `workshop-*`, `harness-*`, and `plugin-*` crate (see -/// [`family_requires_marker`]); -/// optional on any other crate. The `new-crate` scaffolder emits it. +/// every `workshop-*` crate (see [`family_requires_marker`]); optional on +/// any other crate. The `new-crate` scaffolder emits it. const INVARIANT_MARKER: &str = "//! ## Invariants"; /// Runs every check and returns all violations. @@ -68,13 +64,7 @@ pub(crate) fn all_violations(root: &Path) -> Vec { violations.extend(lint_inheritance_violations(root)); violations.extend(crate::unsafe_allowlist::unsafe_allowlist_violations(root)); violations.extend(crate::product::product_boundary_violations(root)); - violations.extend(crate::harness_bans::harness_tokio_bans( - &root.join("crates").join("harness-internal"), - &root.join("crates").join("runvil"), - )); - violations.extend(crate::engine_guards::engine_guard_violations(root)); - violations.extend(crate::facade_shape::facade_shape_violations(root)); - violations.extend(crate::doc_hidden::doc_hidden_violations(root)); + violations.extend(crate::harness_pin::harness_pin_violations(root)); violations.extend(crate::no_doctests::no_doctests_violations(root)); violations.extend(crate::debug_spelling::debug_spelling_violations(root)); violations.extend(crate::type_size::type_size_violations(root)); @@ -244,7 +234,7 @@ fn lint_inheritance_violations(root: &Path) -> Vec { violations } -/// Check that every crate the families bind by name has the marker. +/// Check that every `workshop-*` crate has the marker. /// /// A manifest the walk could not read, parse, or find a package name in is /// reported as itself, not skipped: a crate with no readable name cannot be @@ -259,7 +249,7 @@ fn marker_violations(root: &Path) -> Vec { if family_requires_marker(&krate.package) && !has_marker(&root.join(&krate.dir)) { violations.push(format!( "{}: src/lib.rs lacks the `{INVARIANT_MARKER}` marker required of every \ - workshop-*, harness-*, and plugin-* crate", + workshop-* crate", krate.package )); } @@ -267,15 +257,11 @@ fn marker_violations(root: &Path) -> Vec { violations } -/// Whether a package name places the crate in a family that must have the -/// marker: `workshop-*`, `harness-*`, and `plugin-*`. The Tauri desktop app -/// (the `workshop` package) and the `runvil` facade fall outside every -/// prefix, so they are exempt. +/// Whether a package name places the crate in the family that must have +/// the marker: `workshop-*`. The Tauri desktop app (the `workshop` +/// package) falls outside the prefix, so it is exempt. fn family_requires_marker(name: &str) -> bool { - name != "workshop" - && ["workshop-", "harness-", "plugin-"] - .iter() - .any(|prefix| name.starts_with(prefix)) + name.starts_with("workshop-") } /// Whether a crate's `lib.rs` or `main.rs` crate docs have the marker. diff --git a/crates/build-xtask/src/type_size.rs b/crates/build-xtask/src/type_size.rs index 3e97b4ead..6b2c2322b 100644 --- a/crates/build-xtask/src/type_size.rs +++ b/crates/build-xtask/src/type_size.rs @@ -33,87 +33,6 @@ const MAX_FIELDS: usize = 12; /// The types over a limit today. A new entry is a plan decision with a /// reason, never a way to make a change pass. const ALLOWED: &[Allowed] = &[ - Allowed { - krate: "crates/runvil-internal/runtime", - name: "ChainOps", - limit: Limit::Methods, - recorded: 34, - reason: "The one-chain request view: by design, every request that touches only its \ - own chain runs here, in an `impl` block beside the arm it serves.", - }, - Allowed { - krate: "crates/runvil-internal/runtime", - name: "Scheduler", - limit: Limit::Methods, - recorded: 82, - reason: "The run's protocol orchestrator: the 2026-10-26 split moved its bookkeeping \ - into four state owners, the inboxes added a fifth, and each remaining method \ - runs the run's lifecycle or coordinates two or more of them, as the inbox \ - arms, the reply arm, and the delivery rules coordinate the inboxes with the \ - chains, the tasks, and the effect table, and the idle cap's close ends a task \ - the task arena closed (12 are test hooks).", - }, - Allowed { - krate: "crates/runvil-internal/runtime", - name: "Chain", - limit: Limit::Fields, - recorded: 16, - reason: "One chain's whole state, its identity, entered section, walk, `var`, and \ - per-chain handles, which the chain arena owns as one value.", - }, - Allowed { - krate: "crates/runvil-internal/runtime", - name: "Scenario", - limit: Limit::Fields, - recorded: 14, - reason: "A test-only run setup as data; each field is one setting a suite chooses \ - before it runs or steps the scenario.", - }, - Allowed { - krate: "crates/runvil-internal/runtime", - name: "SectionVmSetup", - limit: Limit::Fields, - recorded: 14, - reason: "A parameter object: the borrowed inputs every section VM setup shares, \ - bundled so each frame constructor passes one set of borrows instead of a \ - dozen parameters.", - }, - Allowed { - krate: "crates/runvil-internal/runtime", - name: "SharedState", - limit: Limit::Fields, - recorded: 14, - reason: "The run-wide state every chain of one run shares through one `Arc`, built \ - once with the root chain and never forked; every field is the same for every \ - chain.", - }, - Allowed { - krate: "crates/runvil-internal/lua", - name: "SectionVm", - limit: Limit::Methods, - recorded: 46, - reason: "One hardened Lua VM per section, whose methods follow its typestate \ - lifecycle: construction, hardening, injection, the Engine globals' \ - installers, and the block runs.", - }, - Allowed { - krate: "crates/runvil-internal/lua", - name: "SectionVm", - limit: Limit::Fields, - recorded: 17, - reason: "One hardened Lua VM per section; each field is a per-section handle the \ - Engine globals and the executor share, such as the tool set, the offers, and \ - the jump and loop flags.", - }, - Allowed { - krate: "crates/runvil-internal/vfs", - name: "Access", - limit: Limit::Methods, - recorded: 29, - reason: "The filesystem capability one identity holds: each method is one operation \ - through the VFS with its path, policy, and claims checks, so the count \ - follows the operation set.", - }, Allowed { krate: "crates/workshop/gateway-discovery", name: "ProcBsdInfo", diff --git a/crates/build-xtask/src/unsafe_allowlist-tests.rs b/crates/build-xtask/src/unsafe_allowlist-tests.rs index 47af78835..d91c959a2 100644 --- a/crates/build-xtask/src/unsafe_allowlist-tests.rs +++ b/crates/build-xtask/src/unsafe_allowlist-tests.rs @@ -41,12 +41,7 @@ fn the_workspace_relaxes_unsafe_code_only_in_its_owned_crates() { #[test] fn the_owned_crates_may_relax_unsafe_code_and_text_that_spells_it_passes() { let root = tempfile::TempDir::new().expect("tempdir"); - for dir in [ - "workshop/desktop", - "workshop/gateway-discovery", - "harness-internal/plugin-loader", - "harness-internal/test-plugin", - ] { + for dir in ["workshop/desktop", "workshop/gateway-discovery"] { write_crate(root.path(), dir, &[("lib.rs", RELAXES)]); } write_crate( @@ -71,7 +66,7 @@ fn each_way_of_relaxing_unsafe_code_outside_the_owned_crates_is_reported_at_its_ let root = tempfile::TempDir::new().expect("tempdir"); write_crate( root.path(), - "runvil-internal/lua", + "workshop/status", &[( "lib.rs", "//! Local inference.\n\ @@ -100,7 +95,7 @@ fn a_source_file_that_does_not_parse_is_reported() { let root = tempfile::TempDir::new().expect("tempdir"); write_crate( root.path(), - "runvil-internal/lua", + "workshop/status", &[("lib.rs", "fn broken( {\n")], ); let violations = unsafe_allowlist_violations(root.path()); @@ -116,14 +111,14 @@ fn a_crate_whose_manifest_does_not_parse_is_still_scanned() { let root = tempfile::TempDir::new().expect("tempdir"); write_crate( root.path(), - "runvil-internal/lua", + "workshop/status", &[("lib.rs", "#[allow(unsafe_code)]\nmod sys {}\n")], ); std::fs::write( root.path() .join("crates") - .join("runvil-internal") - .join("lua") + .join("workshop") + .join("status") .join("Cargo.toml"), "not [valid toml", ) diff --git a/crates/build-xtask/src/unsafe_allowlist.rs b/crates/build-xtask/src/unsafe_allowlist.rs index d368e58ab..196cca1f6 100644 --- a/crates/build-xtask/src/unsafe_allowlist.rs +++ b/crates/build-xtask/src/unsafe_allowlist.rs @@ -18,11 +18,9 @@ use syn::{Meta, Token}; /// The crate directories, relative to the workspace root, that own an /// unsafe boundary and may relax `unsafe_code` in the module that holds it. -const OWNED: [&str; 4] = [ +const OWNED: [&str; 2] = [ "crates/workshop/desktop", "crates/workshop/gateway-discovery", - "crates/harness-internal/plugin-loader", - "crates/harness-internal/test-plugin", ]; /// Runs the allowlist over every `.rs` file in every workspace crate @@ -105,7 +103,7 @@ fn source_violations(file: &Path, text: &str) -> Vec { /// Walks `tokens` at every depth, macro bodies included, and records the /// line of each `#[...]` or `#![...]` attribute that expects or allows a /// lint `is_lint` accepts. -pub(crate) fn collect_relaxations( +fn collect_relaxations( tokens: TokenStream, is_lint: &dyn Fn(&syn::Path) -> bool, lines: &mut Vec, diff --git a/crates/harness-gateway-client/AGENTS.md b/crates/harness-gateway-client/AGENTS.md deleted file mode 100644 index 4cb47c5a4..000000000 --- a/crates/harness-gateway-client/AGENTS.md +++ /dev/null @@ -1,3 +0,0 @@ -# harness-gateway-client - -- `build_request_body` is the only place the chat-completions body is shaped, so every transport sends the same JSON for one `Chat` effect. Never build or patch a body elsewhere. diff --git a/crates/harness-gateway-client/Cargo.toml b/crates/harness-gateway-client/Cargo.toml deleted file mode 100644 index 3faaac097..000000000 --- a/crates/harness-gateway-client/Cargo.toml +++ /dev/null @@ -1,48 +0,0 @@ -[package] -name = "harness-gateway-client" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -# The one crate a Host depends on: the `InferenceBroker` trait the -# Gateway broker implements, with its boxed future, the chat vocabulary -# (Message, ToolSchema, Completion) and the universal error the wire -# code reads and builds through its public constructors, and the -# canonical metrics and model catalog vocabulary. -runvil.workspace = true -# The web Plugin crate's `SearchProvider` trait and its query, -# result, and error vocabulary, which `GatewaySearch` implements. -plugin-web.workspace = true -# `SearchProvider` is an `async_trait` trait. -async-trait.workspace = true -# Names `bytes::Bytes`, the buffer `reqwest` yields per body chunk, as the -# reassembly's chunk type so no chunk is copied on its way in. -bytes.workspace = true -reqwest.workspace = true -# Derives the private wire shapes of the `usage` and llama.cpp `timings` -# metadata sections, of the `GET /v1/models` list, and of the web search -# request and reply. -serde.workspace = true -# The request body, every streamed payload, and the reassembled response -# are JSON. -serde_json.workspace = true -# `time` for the per-receive timeout. Non-test code spawns no task. -tokio = { workspace = true, features = ["time"] } -url.workspace = true - -[dev-dependencies] -# The mock gateways the client tests send real rounds to. -axum.workspace = true -# The Engine's stepping items: the request suite steps a real run to get -# a tool schema and an assistant tool-call message, which have no public -# constructor. -runvil-engine.workspace = true -# The raw-socket mocks read the request and write paced or truncated -# responses by hand. -tokio = { workspace = true, features = ["io-util"] } - -[lints] -workspace = true diff --git a/crates/harness-gateway-client/README.md b/crates/harness-gateway-client/README.md deleted file mode 100644 index ffda4a4bc..000000000 --- a/crates/harness-gateway-client/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Harness gateway client - -This crate is the standard way a Host connects the Harness to the Runvil gateway. It supplies the inference broker that runs each model round through the gateway, which can stream a reply to the Host as it forms, and the search provider that runs web searches through the gateway. Its wire code opens no connections and reads no clocks, so another broker can reuse it, and credentials never appear in logs or error text. diff --git a/crates/harness-gateway-client/src/broker-tests.rs b/crates/harness-gateway-client/src/broker-tests.rs deleted file mode 100644 index 0d1ca5336..000000000 --- a/crates/harness-gateway-client/src/broker-tests.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! The Gateway broker against the transport suite's mock gateways: -//! `chat_streaming` hands each piece to its callback in wire order, the -//! `InferenceBroker` round answers with the whole reply, every round is -//! labeled with the model its request named, and `models` lists the -//! catalog the gateway serves under the broker's key. Its `Debug` never -//! prints the bearer key. - -use std::num::NonZeroU32; -use std::sync::{Arc, Mutex}; - -use runvil::effect::Round; -use runvil::event::ReplyOrigin; -use runvil::harness::InferenceBroker; -use runvil::ids::RoundId; -use runvil::model::{ - CompletionOptions, CompletionResult, Message, ModelBinding, ModelId, ModelInvocation, - ThinkingMode, -}; -use serde_json::Value; - -use super::GatewayBroker; -use crate::StreamDelta; -use crate::config::{GatewayEndpoint, SecretString}; -use crate::transport::tests::{content_chunk, serve, sse_app, sse_body}; - -/// The bearer key every mock gateway here is keyed with. -const KEY: &str = "tok"; - -/// Serves `app` on a loopback port and returns a broker keyed for its -/// `/v1` root. -async fn broker_for(app: axum::Router) -> GatewayBroker { - GatewayBroker::new( - GatewayEndpoint::new(&serve(app).await).expect("valid test endpoint"), - SecretString::new(KEY).expect("non-empty test key"), - ) -} - -/// A binding for the round; the broker runs the round under the effect's -/// frozen options, so the binding's own fields are inert here. -fn binding() -> ModelBinding { - ModelBinding::new( - "writer", - "the round's model", - ModelId::gateway("m").expect("a literal model name is valid"), - ModelInvocation::new(), - NonZeroU32::new(4096).expect("4096 is non-zero"), - ) -} - -/// A reply that reasons once and then answers in three fragments, closed -/// by a stop finish. -fn three_fragments() -> String { - sse_body(&[ - serde_json::json!({ - "choices": [{ "index": 0, "delta": { "reasoning_content": "hmm" }, "finish_reason": null }] - }), - content_chunk("one "), - content_chunk("two "), - content_chunk("three"), - serde_json::json!({ - "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] - }), - ]) -} - -/// The callback `chat_streaming` hands each piece to. -type OnPiece = Arc; - -/// A piece callback that keeps every piece it is handed, beside what it -/// kept. -fn recording() -> (OnPiece, Arc>>) { - let kept: Arc>> = Arc::default(); - let sink = Arc::clone(&kept); - ( - Arc::new(move |piece| sink.lock().unwrap().push(piece)), - kept, - ) -} - -/// The run's first round, dispatched from `origin`. -fn round(origin: ReplyOrigin) -> Round { - Round::new(RoundId::new(0), origin) -} - -fn reply_of(result: &CompletionResult) -> &str { - match result { - CompletionResult::Text(text) => text, - other => panic!("the round replies with text: {other:?}"), - } -} - -#[tokio::test] -async fn chat_streaming_hands_each_piece_to_its_callback_in_wire_order() { - let broker = broker_for(sse_app(three_fragments())).await; - let (on_piece, kept) = recording(); - - let completion = broker - .chat_streaming( - binding(), - vec![Message::user("hi")], - Vec::new(), - CompletionOptions::new("m"), - on_piece, - ) - .await - .expect("the mock round completes"); - assert_eq!(reply_of(completion.result()), "one two three"); - assert_eq!( - *kept.lock().unwrap(), - vec![ - StreamDelta::Reasoning("hmm".to_owned()), - StreamDelta::Text("one ".to_owned()), - StreamDelta::Text("two ".to_owned()), - StreamDelta::Text("three".to_owned()), - ], - "each piece, reasoning and text alike, reaches the callback as it arrives, in the stream's order" - ); -} - -#[tokio::test] -async fn the_inference_broker_round_answers_with_the_whole_reply() { - let broker = broker_for(sse_app(three_fragments())).await; - - let completion = broker - .chat( - binding(), - vec![Message::user("hi")], - Vec::new(), - CompletionOptions::new("m"), - round(ReplyOrigin::Chat), - ) - .await - .expect("the mock round completes"); - assert_eq!( - reply_of(completion.result()), - "one two three", - "a headless Host's round streams nowhere, and the completed reply travels in the answer" - ); -} - -/// A one-chunk reply whose chunks name `model`, or no model at all. -fn reply_naming(model: Option<&str>) -> String { - let mut chunks = vec![ - serde_json::json!({ - "choices": [{ "index": 0, "delta": { "content": "ok" }, "finish_reason": null }] - }), - serde_json::json!({ - "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] - }), - ]; - if let Some(model) = model { - for chunk in &mut chunks { - chunk["model"] = Value::from(model); - } - } - sse_body(&chunks) -} - -#[tokio::test] -async fn every_round_whole_or_streamed_is_labeled_with_the_model_its_request_named() { - for body_model in [Some("served-elsewhere"), None] { - let broker = broker_for(sse_app(reply_naming(body_model))).await; - let whole = broker - .chat( - binding(), - vec![Message::user("hi")], - Vec::new(), - CompletionOptions::new("routed-to"), - round(ReplyOrigin::Chat), - ) - .await - .expect("the mock round completes"); - let (on_piece, _kept) = recording(); - let streamed = broker - .chat_streaming( - binding(), - vec![Message::user("hi")], - Vec::new(), - CompletionOptions::new("routed-to"), - on_piece, - ) - .await - .expect("the mock round completes"); - for completion in [&whole, &streamed] { - assert_eq!( - completion.model(), - "routed-to", - "the label is the requested model, whatever the body named ({body_model:?})" - ); - } - if body_model.is_none() { - assert_eq!( - streamed.metadata_diagnostics(), - ["completion response named no string `model`; labeled with the requested model"], - "the diagnostic says where the label came from" - ); - } - } -} - -#[test] -fn debug_never_leaks_the_bearer_key() { - let api_root = "http://127.0.0.1:8081/v1"; - let broker = GatewayBroker::new( - GatewayEndpoint::new(api_root).expect("valid test endpoint"), - SecretString::new("super-secret-token").expect("non-empty test key"), - ); - let rendered = format!("{broker:?}"); - assert!( - !rendered.contains("super-secret-token"), - "the bearer key must never appear in Debug output, got: {rendered}" - ); - assert!( - rendered.contains(""), - "the key field must be redacted, got: {rendered}" - ); - assert!( - rendered.contains(api_root), - "the API root is not a secret and should still appear, got: {rendered}" - ); -} - -#[tokio::test] -async fn models_lists_the_catalog_the_gateway_serves_under_the_brokers_key() { - use axum::Router; - use axum::http::header::AUTHORIZATION; - use axum::http::{HeaderMap, StatusCode}; - use axum::routing::get; - - async fn models(headers: HeaderMap) -> Result, StatusCode> { - let bearer = headers - .get(AUTHORIZATION) - .and_then(|value| value.to_str().ok()); - if bearer != Some("Bearer tok") { - return Err(StatusCode::UNAUTHORIZED); - } - Ok(axum::Json(serde_json::json!({ - "data": [{ - "id": "mock-model", - "description": "the mock model", - "context": 131_072, - "thinking": "switchable", - }] - }))) - } - let broker = broker_for(Router::new().route("/v1/models", get(models))).await; - - let catalog = broker - .models() - .await - .expect("the keyed mock serves its catalog"); - assert_eq!(catalog.models().len(), 1, "the one served model"); - let info = catalog - .get(&ModelId::gateway("mock-model").expect("a literal model name is valid")) - .expect("the served model is listed under its gateway id"); - assert_eq!(info.description(), "the mock model"); - assert_eq!(info.context().get(), 131_072); - assert_eq!(info.thinking(), ThinkingMode::Switchable); -} diff --git a/crates/harness-gateway-client/src/broker.rs b/crates/harness-gateway-client/src/broker.rs deleted file mode 100644 index 2df08de80..000000000 --- a/crates/harness-gateway-client/src/broker.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! The Gateway's inference broker: one model round per `Chat` effect on -//! the Gateway client, and the Gateway's model list. -//! -//! The Harness takes only a round's finished reply, so the broker's -//! `InferenceBroker` round returns it whole. A Host that shows the reply -//! as it forms runs the round through [`GatewayBroker::chat_streaming`] -//! instead, which hands each live piece to the Host's callback as it -//! arrives, so no fragment ever reaches the Host's recorder. Either way -//! the completion names the model the round's options sent it to, so a -//! Host that routes a round to another model sees that model on its reply. - -use std::fmt; -use std::sync::Arc; - -use runvil::effect::Round; -use runvil::harness::InferenceBroker; -use runvil::model::{ - Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, -}; -use runvil::{BoxFuture, Error}; - -use crate::catalog::fetch_model_catalog; -use crate::config::{GatewayEndpoint, SecretString}; -use crate::transport::GatewayChat; -use crate::wire::delta::StreamDelta; - -/// A model broker that sends the Harness's model rounds to the Runvil -/// Gateway and lists the Gateway's models. -/// -/// A Host passes this broker to the Harness as its [`InferenceBroker`]. -/// The broker performs each `Chat` effect from the Engine as one model -/// round on a [`GatewayChat`]. It lists the Gateway's models through -/// [`fetch_model_catalog`]. Both kinds of request go to the same API root -/// and authenticate with the same bearer key. -/// -/// Every round uses the client's own request limits: a 120 second wait for -/// each receive and a 16 MiB cap on each reply. -/// -/// The broker's futures, [`models`](InferenceBroker::models) included, need -/// a tokio runtime with its reactor and timer. A Host that uses this broker -/// must therefore await `Harness::run` inside such a runtime. The Harness's -/// own futures run on any executor. -#[derive(Clone)] -pub struct GatewayBroker { - client: GatewayChat, - endpoint: GatewayEndpoint, - key: SecretString, -} - -impl fmt::Debug for GatewayBroker { - /// The bearer key is never written to logs or `Debug` output. - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("GatewayBroker") - .field("client", &self.client) - .field("api_root", &self.endpoint.url()) - .field("key", &"") - .finish() - } -} - -impl GatewayBroker { - /// Creates a broker that reaches the Gateway at `endpoint` with the - /// bearer key `key`. - /// - /// `endpoint` is the Gateway's OpenAI-compatible API root, the `/v1` - /// path. Model rounds and model-list requests both go to it. - #[must_use] - pub fn new(endpoint: GatewayEndpoint, key: SecretString) -> GatewayBroker { - let client = GatewayChat::new(endpoint.clone(), key.clone()); - GatewayBroker { - client, - endpoint, - key, - } - } - - /// Runs one model round and passes each piece of the reply to - /// `on_piece` as it arrives. - /// - /// The round is the same one `InferenceBroker::chat` runs. `on_piece` - /// receives the pieces in the order the stream delivers them. The - /// returned completion holds the whole reply. - /// - /// `on_piece` is called inline as each piece is read, so it must not - /// block. - pub fn chat_streaming( - &self, - _binding: ModelBinding, - messages: Vec, - tools: Vec, - options: CompletionOptions, - on_piece: Arc, - ) -> BoxFuture<'static, Result, Error>> { - self.round(messages, tools, options, move |piece| on_piece(piece)) - } - - /// One round on the client, handing each live piece to `on_piece`. - fn round( - &self, - messages: Vec, - tools: Vec, - options: CompletionOptions, - on_piece: impl Fn(StreamDelta) + Send + Sync + 'static, - ) -> BoxFuture<'static, Result, Error>> { - let client = self.client.clone(); - Box::pin(async move { - // An empty advertisement sends no `tools` field at all, the - // plain chat-completions shape. - let tools = (!tools.is_empty()).then_some(tools.as_slice()); - client - .complete(&messages, tools, &options, on_piece) - .await - .map(Box::new) - }) - } -} - -impl InferenceBroker for GatewayBroker { - fn models(&self) -> BoxFuture<'static, Result> { - let api_root = self.endpoint.url().to_owned(); - let key = self.key.clone(); - Box::pin(async move { fetch_model_catalog(&api_root, key.expose()).await }) - } - - fn chat( - &self, - _binding: ModelBinding, - messages: Vec, - tools: Vec, - options: CompletionOptions, - _round: Round, - ) -> BoxFuture<'static, Result, Error>> { - self.round(messages, tools, options, |_| {}) - } -} - -#[cfg(test)] -#[path = "broker-tests.rs"] -mod tests; diff --git a/crates/harness-gateway-client/src/catalog-tests.rs b/crates/harness-gateway-client/src/catalog-tests.rs deleted file mode 100644 index faa3d7d62..000000000 --- a/crates/harness-gateway-client/src/catalog-tests.rs +++ /dev/null @@ -1,329 +0,0 @@ -//! Tests for the model catalog fetch against an axum mock gateway: the body -//! bounds, the failure causes kept in the message, the entry filter, and -//! failure prose that carries no Gateway-supplied text. - -use runvil::ErrorCode; - -use super::*; - -/// The text the fetch appended to `err`'s message after its prose: the -/// provider detail or the cause, which the prose never holds. -fn appended(err: &Error) -> &str { - err.message - .strip_prefix(err.prose.as_str()) - .and_then(|rest| rest.strip_prefix(": ")) - .unwrap_or_else(|| panic!("the message extends the prose: {err:?}")) -} - -async fn spawn_models(app: axum::Router) -> std::net::SocketAddr { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - addr -} - -#[tokio::test] -async fn fetch_model_catalog_bounds_and_reports_non_success_body() { - use axum::Router; - use axum::routing::get; - - async fn models() -> (axum::http::StatusCode, String) { - ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "e".repeat(MAX_CATALOG_ERROR_BODY * 4), - ) - } - let app = Router::new().route("/models", get(models)); - let addr = spawn_models(app).await; - - let err = fetch_model_catalog(&format!("http://{addr}"), "tok") - .await - .expect_err("a 500 response must surface as an error"); - assert_eq!(err.code, ErrorCode::ServerError); - assert_eq!( - err.prose, - "the model backend reported a fault of its own (status 500)" - ); - let detail = appended(&err); - assert!( - detail.len() < MAX_CATALOG_ERROR_BODY + 128, - "the error-path body must be bounded, got {} bytes", - detail.len() - ); -} - -#[tokio::test] -async fn fetch_model_catalog_bounds_an_oversized_success_body() { - use axum::Router; - use axum::routing::get; - - // A 200 response whose body exceeds the success cap must be refused - // BEFORE decoding, not buffered unbounded. The body is deliberately not - // valid JSON: the bound must trip first, regardless of contents. - async fn models() -> (axum::http::StatusCode, String) { - let oversized = usize::try_from(MAX_CATALOG_BODY).unwrap() + 1; - (axum::http::StatusCode::OK, "e".repeat(oversized)) - } - let app = Router::new().route("/models", get(models)); - let addr = spawn_models(app).await; - - let err = fetch_model_catalog(&format!("http://{addr}"), "tok") - .await - .expect_err("an oversized success body must be refused"); - assert_eq!(err.code, ErrorCode::MalformedResponse); - assert!( - err.to_string().contains("exceeds"), - "the bound must report the size limit, got {err:?}" - ); - assert_eq!( - err.message, err.prose, - "our own wording is not provider text" - ); -} - -#[tokio::test] -async fn fetch_model_catalog_keeps_the_json_decode_error_in_the_message() { - use axum::Router; - use axum::routing::get; - - // MODEL-009: a 200 body that is not a valid model list is classified as - // MalformedResponse, and the underlying `serde_json::Error` is appended - // to the message and kept out of the prose. - async fn models() -> (axum::http::StatusCode, String) { - (axum::http::StatusCode::OK, "{ this is not json".to_owned()) - } - let app = Router::new().route("/models", get(models)); - let addr = spawn_models(app).await; - - let err = fetch_model_catalog(&format!("http://{addr}"), "tok") - .await - .expect_err("an undecodable body must surface as an error"); - assert_eq!(err.code, ErrorCode::MalformedResponse); - let decode = serde_json::from_slice::(b"{ this is not json") - .expect_err("the body is not JSON") - .to_string(); - assert_eq!(appended(&err), decode); - assert!(!err.prose.contains(&decode), "got {err:?}"); -} - -#[tokio::test] -async fn fetch_model_catalog_skips_entries_without_a_context_window() { - use axum::Router; - use axum::routing::get; - - // A gateway with speech-to-text lists its transcription models beside - // the inference models, and those entries omit `context` and - // `thinking` (they answer no completion request). The fetch must keep - // the inference models' `ModelInfo` values instead of rejecting the whole - // list, otherwise the Harness binds each run on such a gateway to a - // fallback model info and the context precheck refuses real conversations. - async fn models() -> axum::Json { - axum::Json(serde_json::json!({ - "object": "list", - "data": [ - { "id": "chat-model", "object": "model", "kind": "chat", - "description": "a chat model", "context": 1_000_000, "thinking": "never" }, - { "id": "whisper-base-en", "object": "model", "kind": "transcription" }, - { "id": "whisper-small-en", "object": "model", "kind": "transcription" } - ] - })) - } - let app = Router::new().route("/models", get(models)); - let addr = spawn_models(app).await; - - let catalog = fetch_model_catalog(&format!("http://{addr}"), "tok") - .await - .expect("transcription entries must not fail the inference catalog"); - assert_eq!( - catalog.models().len(), - 1, - "only the inference model gets a model info" - ); - let chat = catalog - .get(&ModelId::gateway("chat-model").expect("valid id")) - .expect("the inference model survives the filter"); - assert_eq!( - chat.context(), - NonZeroU32::new(1_000_000).expect("non-zero") - ); - assert_eq!(chat.thinking(), ThinkingMode::Never); -} - -#[tokio::test] -async fn fetch_model_catalog_reads_a_provider_only_when_the_entry_names_one() { - use axum::Router; - use axum::routing::get; - - async fn models() -> axum::Json { - axum::Json(serde_json::json!({ - "object": "list", - "data": [ - { "id": "grok", "object": "model", "kind": "chat", "description": "d", - "context": 4096, "thinking": "never", "provider": "xai" }, - { "id": "local", "object": "model", "kind": "chat", "description": "d", - "context": 4096, "thinking": "never" } - ] - })) - } - let app = Router::new().route("/models", get(models)); - let addr = spawn_models(app).await; - - let catalog = fetch_model_catalog(&format!("http://{addr}"), "tok") - .await - .expect("an optional provider decodes"); - let provider = |name: &str| { - catalog - .get(&ModelId::gateway(name).expect("valid id")) - .expect("the model is in the catalog") - .provider() - .map(str::to_owned) - }; - assert_eq!(provider("grok").as_deref(), Some("xai")); - assert_eq!(provider("local"), None); -} - -#[tokio::test] -async fn fetch_model_catalog_still_rejects_a_zero_context_window() { - use axum::Router; - use axum::routing::get; - - // Skipping applies only to entries with no context field at all; an - // inference entry that declares a zero window is still malformed. - async fn models() -> axum::Json { - axum::Json(serde_json::json!({ - "object": "list", - "data": [ - { "id": "broken", "object": "model", "kind": "chat", - "description": "d", "context": 0, "thinking": "never" } - ] - })) - } - let app = Router::new().route("/models", get(models)); - let addr = spawn_models(app).await; - - let err = fetch_model_catalog(&format!("http://{addr}"), "tok") - .await - .expect_err("a zero context window is malformed"); - assert_eq!(err.code, ErrorCode::MalformedResponse); - assert!(err.prose.contains("zero-token"), "got {err:?}"); - assert!(!err.prose.contains("broken"), "got {err:?}"); - assert!( - appended(&err).contains("broken"), - "the model name belongs in the message only, got {err:?}" - ); -} - -/// Serves `data` as the gateway model list and returns the fetch failure. -async fn fetch_rejection(data: serde_json::Value) -> Error { - let list = serde_json::json!({ "object": "list", "data": data }); - let app = axum::Router::new().route( - "/models", - axum::routing::get(move || async move { axum::Json(list) }), - ); - let addr = spawn_models(app).await; - fetch_model_catalog(&format!("http://{addr}"), "tok") - .await - .expect_err("the model list is malformed") -} - -#[tokio::test] -async fn fetch_model_catalog_keeps_the_name_of_a_model_without_a_thinking_mode_out_of_the_prose() { - let err = fetch_rejection(serde_json::json!([ - { "id": "thoughtless", "object": "model", "kind": "chat", - "description": "d", "context": 4096 } - ])) - .await; - assert_eq!(err.code, ErrorCode::MalformedResponse); - assert!(!err.prose.contains("thoughtless"), "got {err:?}"); - assert!( - appended(&err).contains("thoughtless"), - "the model name belongs in the message only, got {err:?}" - ); -} - -#[tokio::test] -async fn fetch_model_catalog_keeps_a_duplicate_id_out_of_the_prose() { - let entry = serde_json::json!( - { "id": "twin", "object": "model", "kind": "chat", - "description": "d", "context": 4096, "thinking": "never" } - ); - let err = fetch_rejection(serde_json::json!([entry.clone(), entry])).await; - assert_eq!(err.code, ErrorCode::MalformedResponse); - assert!(!err.prose.contains("twin"), "got {err:?}"); - assert!( - appended(&err).contains("gateway/twin"), - "the duplicate identity belongs in the message only, got {err:?}" - ); -} - -#[tokio::test] -async fn fetch_model_catalog_bounds_a_gateway_supplied_detail() { - let long = "n".repeat(MAX_CATALOG_ERROR_BODY * 4); - let entry = |context: u32, thinking: Option<&str>| { - serde_json::json!({ "id": long, "object": "model", "kind": "chat", - "description": "d", "context": context, "thinking": thinking }) - }; - let rejections = [ - serde_json::json!([entry(0, Some("never"))]), - serde_json::json!([entry(4096, None)]), - serde_json::json!([entry(4096, Some("never")), entry(4096, Some("never"))]), - ]; - for data in rejections { - let err = fetch_rejection(data).await; - assert_eq!(err.code, ErrorCode::MalformedResponse); - let detail = appended(&err); - assert!( - detail.chars().count() <= MAX_CATALOG_ERROR_BODY, - "a Gateway-supplied detail must be bounded, got {} chars", - detail.chars().count() - ); - } -} - -#[tokio::test] -async fn fetch_model_catalog_keeps_an_invalid_id_out_of_the_message() { - let err = fetch_rejection(serde_json::json!([ - { "id": "smuggled\u{7}id", "object": "model", "kind": "chat", - "description": "d", "context": 4096, "thinking": "never" } - ])) - .await; - assert_eq!(err.code, ErrorCode::MalformedResponse); - let message = err.to_string(); - assert!(!message.contains("smuggled"), "got {err:?}"); - assert!(!message.contains('\u{7}'), "got {err:?}"); -} - -#[tokio::test] -async fn fetch_model_catalog_keeps_a_body_read_failure_in_the_message() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - // MODEL-010: a non-success response whose body cannot be fully read - // (the server promises a large body then drops the connection) must - // surface as a typed transport failure whose message, not its prose, - // holds the `reqwest::Error`. - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - if let Ok((mut sock, _)) = listener.accept().await { - let mut buf = [0u8; 1024]; - let _ = sock.read(&mut buf).await; - let header = "HTTP/1.1 500 Internal Server Error\r\n\ - Content-Length: 1000000\r\n\r\n"; - let _ = sock.write_all(header.as_bytes()).await; - let _ = sock.write_all(b"abc").await; - // Socket drops here: the promised body never completes. - } - }); - - let err = fetch_model_catalog(&format!("http://{addr}"), "tok") - .await - .expect_err("a truncated error body must surface as an error"); - assert_eq!(err.code, ErrorCode::Transport); - assert_eq!(err.prose, "the connection to the model backend failed"); - assert!( - !appended(&err).is_empty(), - "the message holds the reqwest read error, got {err:?}" - ); -} diff --git a/crates/harness-gateway-client/src/catalog.rs b/crates/harness-gateway-client/src/catalog.rs deleted file mode 100644 index 562412cef..000000000 --- a/crates/harness-gateway-client/src/catalog.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! Catalog transport: fetching and decoding gateway `GET /v1/models`. - -use std::num::NonZeroU32; - -use runvil::Error; -use runvil::model::{ModelCatalog, ModelId, ModelInfo, ThinkingMode}; -use serde::Deserialize; - -use crate::failure::{make_malformed_error, make_transport_failure_error, with_detail}; -use crate::wire::classify::make_classify_http_failure_error; -use crate::wire::stream::escape_controls; - -/// Wire shape of one entry from gateway `GET /v1/models`. -/// -/// The list mixes inference models with the gateway's speech-to-text models, -/// which have only `id`, `object`, and `kind` because they answer no -/// completion request. The inference fields are therefore optional at the -/// wire, and an entry without a context window is skipped rather than -/// failing the whole catalog. -#[derive(Debug, Deserialize)] -struct ModelsListEntry { - id: String, - #[serde(default)] - description: String, - #[serde(default)] - context: Option, - #[serde(default)] - thinking: Option, - /// The model's provider id, when the gateway names one. - #[serde(default)] - provider: Option, -} - -/// Wire shape of gateway `GET /v1/models`. -#[derive(Debug, Deserialize)] -struct ModelsListResponse { - data: Vec, -} - -/// The largest gateway error body kept for a catalog-fetch diagnostic, in bytes. -const MAX_CATALOG_ERROR_BODY: usize = 2000; - -/// The largest success-path model-catalog body accepted before decoding, in -/// bytes. A gateway that returns more than this is refused rather than buffered -/// unbounded, mirroring the bound the error path already applies. Sized well -/// above any realistic model list (16 MiB) so legitimate catalogs are unaffected. -const MAX_CATALOG_BODY: u64 = 16 * 1024 * 1024; - -/// Reads a success-path response body, refusing it once it would exceed `cap` -/// bytes so a decode cannot buffer an unbounded body first. -/// -/// The advertised `Content-Length` short-circuits an oversize body, and the -/// streamed chunks are bounded so a gateway that omits or lies about the length -/// still cannot force an unbounded allocation. -async fn read_catalog_body_capped( - mut response: reqwest::Response, - cap: u64, -) -> std::result::Result, Error> { - if let Some(len) = response.content_length() - && len > cap - { - return Err(make_malformed_error(format!( - "model list body of {len} bytes exceeds the {cap}-byte limit" - ))); - } - let mut body: Vec = Vec::new(); - while let Some(chunk) = response - .chunk() - .await - .map_err(make_transport_failure_error)? - { - if body.len() as u64 + chunk.len() as u64 > cap { - return Err(make_malformed_error(format!( - "model list body exceeds the {cap}-byte limit" - ))); - } - body.extend_from_slice(&chunk); - } - Ok(body) -} - -/// Reads at most `limit` bytes of a non-success response body, stopping early so -/// an oversized error body cannot exhaust memory. -/// -/// A read failure is returned as the concrete [`reqwest::Error`] (MODEL-010) so -/// the caller can append it and its cause chain to the failure's message, -/// rather than being flattened into display text that severs the cause. -async fn read_error_body_bounded( - mut response: reqwest::Response, - limit: usize, -) -> std::result::Result { - let mut buffer: Vec = Vec::new(); - while buffer.len() < limit { - match response.chunk().await? { - Some(chunk) => { - let take = (limit - buffer.len()).min(chunk.len()); - buffer.extend_from_slice(&chunk[..take]); - if take < chunk.len() { - break; - } - } - None => break, - } - } - if buffer.is_empty() { - return Ok("(empty body)".to_owned()); - } - // F5: escape control characters so a hostile catalog error body cannot forge - // log lines or smuggle terminal control sequences into a diagnostic. - let lossy = String::from_utf8_lossy(&buffer); - let mut escaped = String::with_capacity(lossy.len()); - for ch in lossy.chars() { - if ch.is_control() { - escaped.extend(ch.escape_default()); - } else { - escaped.push(ch); - } - } - Ok(escaped) -} - -/// Returns the process-wide catalog HTTP client, building it once on first use. -/// -/// A single reusable client (MODEL-018) lets catalog fetches share one -/// connection pool and transport configuration rather than each constructing a -/// throwaway client with its own pool. The returned handle is a cheap clone of -/// the shared client (its state is reference-counted internally). -fn catalog_client() -> reqwest::Client { - static CATALOG_CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); - CATALOG_CLIENT.get_or_init(reqwest::Client::new).clone() -} - -/// Sends a bearer-authed GET through the shared client (MODEL-018) and -/// returns the success response, classifying every failure the same way for -/// each gateway endpoint: `Transport` (or `Timeout`) when the send fails, the -/// classified code with a bounded, control-escaped body appended to its -/// message on a non-success status (MODEL-010: no unbounded buffering), and -/// `Transport` (or `Timeout`) when that error body cannot be read, appending -/// the [`reqwest::Error`] to the message under the same timeout marking as a -/// send failure. -async fn get_authed(url: String, token: &str) -> std::result::Result { - let response = catalog_client() - .get(url) - .bearer_auth(token) - .send() - .await - .map_err(make_transport_failure_error)?; - let status = response.status(); - if status.is_success() { - return Ok(response); - } - let body = match read_error_body_bounded(response, MAX_CATALOG_ERROR_BODY).await { - Ok(body) => body, - Err(source) => return Err(make_transport_failure_error(source)), - }; - Err(make_classify_http_failure_error(status.as_u16(), &body)) -} - -/// Fetches a [`ModelCatalog`] from the Gateway's `/models` endpoint. -/// -/// `base_url` is the root of the Gateway's OpenAI-compatible API, for -/// example `http://127.0.0.1:8081/v1`. The request sends `token` as a -/// bearer token. -/// -/// # Errors -/// Returns an [`Error`]. Its [`code`](Error::code) is: -/// -/// - `Transport` or `Timeout` when the HTTP request or a response read fails; -/// - the code [`make_classify_http_failure_error`] picks for the response when the -/// Gateway returns a status outside the 2xx range; -/// - `MalformedResponse` when the body is not a valid model list. -pub async fn fetch_model_catalog( - base_url: &str, - token: &str, -) -> std::result::Result { - let base = base_url.trim_end_matches('/'); - let response = get_authed(format!("{base}/models"), token).await?; - // Bound the success body BEFORE decoding so an oversized (or unbounded) - // model list cannot exhaust memory, matching the bound the error path applies. - let body = read_catalog_body_capped(response, MAX_CATALOG_BODY).await?; - // A body that does not decode as a model list is a malformed response, not a - // transport failure - matching this function's documented error contract. - let list: ModelsListResponse = serde_json::from_slice(&body).map_err(|error| { - // MODEL-009: keep the decode error in the message and out of the - // prose, while the classification stays `MalformedResponse`. - make_malformed_error("model list response was not valid JSON").with_cause(&error) - })?; - let mut models = Vec::with_capacity(list.data.len()); - for entry in list.data { - // An entry with no context window is not an inference model (the - // gateway lists its transcription models here too); it gets no - // model info and must not fail the catalog. - let Some(context) = entry.context else { - continue; - }; - let id = ModelId::gateway(entry.id).map_err(|error| { - make_malformed_error(format!("model catalog entry has an invalid id: {error}")) - })?; - let context = NonZeroU32::new(context).ok_or_else(|| { - with_detail( - make_malformed_error("a model declares a zero-token context window"), - &escape_controls(id.name(), MAX_CATALOG_ERROR_BODY), - ) - })?; - let thinking = entry.thinking.ok_or_else(|| { - with_detail( - make_malformed_error("a model declares a context window but no thinking mode"), - &escape_controls(id.name(), MAX_CATALOG_ERROR_BODY), - ) - })?; - let info = ModelInfo::new(id, entry.description, context, thinking); - models.push(match entry.provider { - Some(provider) => info.with_provider(provider), - None => info, - }); - } - ModelCatalog::new(models).map_err(|error| { - with_detail( - make_malformed_error("gateway returned an inconsistent model catalog"), - &escape_controls(&error.to_string(), MAX_CATALOG_ERROR_BODY), - ) - }) -} - -#[cfg(test)] -#[path = "catalog-tests.rs"] -mod tests; diff --git a/crates/harness-gateway-client/src/config.rs b/crates/harness-gateway-client/src/config.rs deleted file mode 100644 index 8d54a4e9c..000000000 --- a/crates/harness-gateway-client/src/config.rs +++ /dev/null @@ -1,188 +0,0 @@ -//! Client configuration: the redacted bearer secret, the validated gateway -//! endpoint, and the setup errors a bad setup reports. -//! -//! A setup error happens before the client sends its first request. It is -//! an [`InvalidValue`](ErrorCode::InvalidValue) whose message names the -//! environment variable or the rule that failed, never a key, and whose -//! `Reason` fact is `make_missing_env_error`, `make_invalid_env_error`, or `invalid_config`. - -use std::fmt; - -use runvil::{Error, ErrorCode, ErrorFact}; -use serde_json::Value; - -/// The setup error for a required environment variable that is not set. -pub(crate) fn make_missing_env_error(name: &str) -> Error { - let mut error = make_setup_error( - "missing_env", - format!("missing environment variable: {name}"), - ); - error.facts.insert(ErrorFact::Name, Value::from(name)); - error -} - -/// The setup error for an environment variable whose value is not valid -/// Unicode. -pub(crate) fn make_invalid_env_error(name: &str) -> Error { - let mut error = make_setup_error( - "invalid_env", - format!("environment variable is set but not valid Unicode: {name}"), - ); - error.facts.insert(ErrorFact::Name, Value::from(name)); - error -} - -/// A setup error with `reason`. Its prose is its message, which names the -/// variable or the rule to fix. -fn make_setup_error(reason: &'static str, message: String) -> Error { - let mut error = Error::new(ErrorCode::InvalidValue, message.clone(), message); - error.facts.insert(ErrorFact::Reason, Value::from(reason)); - error -} - -/// A bearer credential whose contents never appear in `Debug` or `Display` -/// output or in logs. -/// -/// Wrap a secret, such as the gateway bearer key, in a `SecretString` as soon -/// as you read it, so an accidental `{:?}` or log line cannot leak it. The -/// client reads the value only to set the `Authorization` header of its -/// requests. -#[derive(Clone)] -#[non_exhaustive] -pub struct SecretString(String); - -impl SecretString { - /// Wraps a secret so it is redacted everywhere it is formatted. - /// - /// # Errors - /// Returns an [`InvalidValue`](ErrorCode::InvalidValue) error with the - /// reason `empty_secret` when `secret` is empty, so a client can never be - /// built to authenticate with a blank bearer credential. - pub fn new(secret: impl Into) -> std::result::Result { - let secret = secret.into(); - if secret.is_empty() { - let mut error = Error::new( - ErrorCode::InvalidValue, - "secret must not be empty", - "The secret is empty. Supply a non-empty secret.", - ); - error - .facts - .insert(ErrorFact::Reason, Value::from("empty_secret")); - return Err(error); - } - Ok(SecretString(secret)) - } - - /// Borrows the raw secret. Crate-internal so no downstream code can read a - /// credential back out of the type. - pub(crate) fn expose(&self) -> &str { - &self.0 - } -} - -impl fmt::Debug for SecretString { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("SecretString()") - } -} - -impl fmt::Display for SecretString { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("") - } -} - -/// A validated base URL for the gateway's OpenAI-compatible API (its `/v1` -/// root). -/// -/// Construction requires an `http` or `https` scheme and a host, so a -/// client always points at a usable endpoint. Any trailing slash is removed -/// so request paths join cleanly. -#[non_exhaustive] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct GatewayEndpoint { - pub(crate) url: String, - /// Whether the host names the local machine: `localhost`, or an IP whose - /// `is_loopback()` holds. Decided at construction from the parsed host. - loopback: bool, -} - -impl GatewayEndpoint { - /// Validates and normalizes a gateway base URL. - /// - /// The URL is checked with a strict URL parser. - /// - /// # Errors - /// Returns an `invalid_config` setup error when `url` fails to parse as - /// an absolute URL, uses a scheme other than `http` or `https`, omits the - /// host, embeds credentials (a `user:pass@` component), or has a query or - /// fragment. - /// A query or fragment is rejected because an API root is a bare path. - /// No error includes `url`, because a URL can contain a credential. - pub fn new(url: &str) -> std::result::Result { - let reject = |message: String| make_setup_error("invalid_config", message); - let trimmed = url.trim(); - // The parse failure's own text names the rule, never the input. - let parsed = url::Url::parse(trimmed) - .map_err(|error| reject(format!("gateway URL is not a valid URL: {error}")))?; - if !matches!(parsed.scheme(), "http" | "https") { - return Err(reject( - "gateway URL must use the http or https scheme".to_owned(), - )); - } - let loopback = match parsed.host() { - None | Some(url::Host::Domain("")) => { - return Err(reject("gateway URL names no host".to_owned())); - } - // The URL parser lowercases the host of an http(s) URL, so the - // literal comparison covers `LOCALHOST` too. - Some(url::Host::Domain(domain)) => domain == "localhost", - Some(url::Host::Ipv4(ip)) => ip.is_loopback(), - Some(url::Host::Ipv6(ip)) => ip.is_loopback(), - }; - if !parsed.username().is_empty() || parsed.password().is_some() { - return Err(reject( - "gateway URL must not embed credentials (user:pass@)".to_owned(), - )); - } - if parsed.query().is_some() || parsed.fragment().is_some() { - return Err(reject( - "gateway URL must not include a query or fragment".to_owned(), - )); - } - Ok(GatewayEndpoint { - // Normalized by the URL parser; trim the trailing slash so request - // paths (`{base}/chat/completions`) join cleanly. - url: parsed.as_str().trim_end_matches('/').to_string(), - loopback, - }) - } - - /// Returns the normalized base URL. - #[must_use] - pub fn url(&self) -> &str { - &self.url - } - - /// Returns whether the endpoint's host is the local machine. - /// - /// It returns `true` for `localhost`, for `127.0.0.1` and the rest of - /// `127.0.0.0/8`, and for `::1`. It returns `false` for every other name - /// or address. By default, a gateway on the local machine accepts callers - /// that omit the key, so - /// [`GatewayChat::from_env`](crate::GatewayChat::from_env) makes the - /// bearer key optional exactly when this returns `true`. - #[must_use] - pub fn is_loopback(&self) -> bool { - self.loopback - } -} - -impl TryFrom<&str> for GatewayEndpoint { - type Error = Error; - - fn try_from(url: &str) -> std::result::Result { - GatewayEndpoint::new(url) - } -} diff --git a/crates/harness-gateway-client/src/failure.rs b/crates/harness-gateway-client/src/failure.rs deleted file mode 100644 index 84abe8965..000000000 --- a/crates/harness-gateway-client/src/failure.rs +++ /dev/null @@ -1,139 +0,0 @@ -//! The failures this crate builds itself: each is an [`Error`] made from a -//! model code and that code's fixed phrase. -//! -//! A non-success HTTP status is not built here; `make_classify_http_failure_error` -//! reads its status and bounded body. These helpers cover the rest: a -//! send or read that went wrong, a disabled client, and a reply this -//! crate cannot read. - -use std::fmt::Display; - -use runvil::model::{make_completion_error, phrase}; -use runvil::{Error, ErrorCode}; - -/// A send or read that failed: `Timeout` when `error` was a timeout and -/// `Transport` otherwise, with `error` appended to the message as its -/// cause. -#[expect( - clippy::needless_pass_by_value, - reason = "a `map_err` adapter takes the error by value" -)] -pub(crate) fn make_transport_failure_error(error: reqwest::Error) -> Error { - if error.is_timeout() { - return make_timeout_error(&error); - } - let code = ErrorCode::Transport; - make_completion_error(code, phrase(code)).with_cause(&error) -} - -/// A receive deadline that elapsed: a `Timeout` with the elapsed error -/// appended to the message as its cause. -#[expect( - clippy::needless_pass_by_value, - reason = "a `map_err` adapter takes the error by value" -)] -pub(crate) fn make_elapsed_error(error: tokio::time::error::Elapsed) -> Error { - make_timeout_error(&error) -} - -fn make_timeout_error(cause: &(dyn std::error::Error + 'static)) -> Error { - let code = ErrorCode::Timeout; - make_completion_error(code, phrase(code)).with_cause(cause) -} - -/// The failure of a client built with `GatewayChat::disabled`: model -/// access is off, so no round is sent. -pub(crate) fn make_unavailable_error() -> Error { - let code = ErrorCode::Unavailable; - make_completion_error(code, phrase(code)) -} - -/// A `MalformedResponse` failure: the code's fixed phrase extended with -/// `: ` and `specific`, which this crate's own code wrote. Provider text -/// never goes in `specific`. -pub(crate) fn make_malformed_error(specific: impl Display) -> Error { - let code = ErrorCode::MalformedResponse; - make_completion_error(code, format!("{}: {specific}", phrase(code))) -} - -/// Appends provider `text`, already bounded and control-escaped, to -/// `error`'s message after `: `. The prose stays as it was, so provider -/// text never reaches the model. -pub(crate) fn with_detail(mut error: Error, text: &str) -> Error { - error.message.push_str(": "); - error.message.push_str(text); - error -} - -/// A validating constructor's refusal of parts this crate decoded from a -/// reply: an `InvalidParts` refusal becomes the `MalformedResponse` the -/// backend's reply earned, keeping its specific, since another reply may -/// hold whole parts. Any other failure passes through. -pub(crate) fn decoded(error: Error) -> Error { - let code = ErrorCode::InvalidParts; - if error.code != code { - return error; - } - let prose = error.prose.as_str(); - let specific = prose - .strip_prefix(phrase(code)) - .and_then(|rest| rest.strip_prefix(": ")) - .unwrap_or(prose); - make_malformed_error(specific) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn each_helper_uses_the_fixed_phrase_for_its_code() { - let elapsed_error = tokio::time::timeout( - std::time::Duration::from_millis(1), - std::future::pending::<()>(), - ) - .await - .expect_err("a pending future elapses"); - let cause = elapsed_error.to_string(); - let timed_out = make_elapsed_error(elapsed_error); - assert_eq!(timed_out.code, ErrorCode::Timeout); - assert_eq!(timed_out.prose, "the model backend did not answer in time"); - assert!( - timed_out.message.contains(&cause) && !timed_out.prose.contains(&cause), - "the elapsed error is in the message only: {timed_out:?}" - ); - - let disabled = make_unavailable_error(); - assert_eq!(disabled.code, ErrorCode::Unavailable); - assert_eq!( - disabled.to_string(), - "model access is turned off or not configured" - ); - - let unreadable = make_malformed_error("model list body exceeds the 8-byte limit"); - assert_eq!(unreadable.code, ErrorCode::MalformedResponse); - assert_eq!( - unreadable.to_string(), - "the model backend sent a reply that could not be understood: \ - model list body exceeds the 8-byte limit" - ); - assert_eq!(unreadable.message, unreadable.prose); - } - - #[test] - fn a_decoded_parts_refusal_is_a_malformed_reply_with_its_specific() { - use runvil::model::ToolCall; - let refusal = ToolCall::from_parts(" ", "fetch", serde_json::json!({})) - .expect_err("a blank id is refused"); - assert_eq!(refusal.code, ErrorCode::InvalidParts); - let reply = decoded(refusal); - assert_eq!(reply.code, ErrorCode::MalformedResponse); - assert_eq!( - reply.to_string(), - "the model backend sent a reply that could not be understood: tool call id was blank" - ); - - let passed = decoded(make_unavailable_error()); - assert_eq!(passed.code, ErrorCode::Unavailable); - } -} diff --git a/crates/harness-gateway-client/src/lib.rs b/crates/harness-gateway-client/src/lib.rs deleted file mode 100644 index a72869a76..000000000 --- a/crates/harness-gateway-client/src/lib.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! The standard client a Host uses to run model rounds, list models, and -//! search the web through the Runvil Gateway. -//! -//! [`GatewayBroker`] is the [`runvil::harness::InferenceBroker`] that a Host using -//! the Gateway passes to `Harness::new`. It runs every model round on a -//! [`GatewayChat`] under the client's own request limits, and it lists the -//! Gateway's models through [`fetch_model_catalog`]. A Host that shows a -//! reply as it forms runs the round through -//! [`GatewayBroker::chat_streaming`], which hands each [`StreamDelta`] to -//! the Host's callback as it arrives. -//! -//! [`GatewayChat`] is the HTTP client that sends a `Chat` effect's round -//! to one Gateway URL. It presents the Gateway's shared bearer key when -//! built with one. Every request streams: the Gateway's -//! `/chat/completions` endpoint answers with server-sent events (SSE). -//! [`GatewayChat::complete`] sends the request body and reads the stream -//! within the client's byte cap and per-receive timeout. It calls the -//! caller's delta callback as each piece arrives and returns the one -//! completion the round produced. [`fetch_model_catalog`] fetches the -//! Gateway's model list as a typed `ModelCatalog`. The client holds no -//! credential except the Gateway's shared key. The model vendor's -//! credential stays in the Gateway. -//! -//! [`GatewaySearch`] is the [`plugin_web::SearchProvider`] that a Host -//! using the Gateway supplies for web search. It sends each search through -//! the Gateway's `/tools/web_search` relay with a 30-second deadline and -//! maps the reply into the provider's results. The search vendor's -//! credential also stays in the Gateway. -//! -//! Under the client sits the OpenAI chat-completions wire code. It turns a -//! round into a request body, a streamed reply into a -//! [`Completion`](runvil::model::Completion), and a failed response -//! into the [`Error`](runvil::Error) the round fails with. -//! -//! - [`build_request_body`] builds the JSON body that every round sends. -//! - [`read_completion_stream`] reads a streamed reply from a -//! [`ChunkSource`] the caller supplies, up to its `[DONE]` sentinel and -//! within a byte cap. It forwards each live delta, assembles the stream -//! into one completion, and checks that turn against one strict set of -//! rules. -//! - [`read_body_capped`] reads, within a byte cap, a body that the caller -//! decodes whole. -//! - [`escape_controls`] cuts a backend error body to a length limit and -//! escapes its control characters. -//! - [`make_classify_http_failure_error`] turns a failure status and its body into an -//! error of the matching model code. [`make_classify_stream_error`] does -//! the same for an error envelope that arrives inside the stream. -//! -//! The client, or another broker, owns the connection and supplies the -//! wire code's chunks and clock. -//! -//! ## Invariants -//! -//! - Every `Completion` and `ToolCall` is built through the Engine's public -//! validating constructors, so the Engine's model-independent reply -//! checks run on every decoded turn. -//! - A Gateway bearer key is never written to logs, `Debug`, `Display`, or -//! error text. -//! - A backend error body is bounded and control-escaped before it is -//! kept. A chat round keeps it only in the error's message, never in its -//! prose, and a search keeps it in its error's message and prose. -//! - A client sends requests without a key only when the caller builds it -//! with `GatewayChat::keyless`, which does not check the endpoint's -//! address, or when `GatewayChat::from_env` finds no key for a loopback -//! URL. A client built with `GatewayChat::disabled` also holds no key, -//! but it sends no requests. - -mod broker; -mod catalog; -mod config; -mod failure; -mod search; -mod transport; -mod wire; - -pub use broker::GatewayBroker; -pub use catalog::fetch_model_catalog; -pub use config::GatewayEndpoint; -pub use config::SecretString; -pub use search::GatewaySearch; -pub use transport::GatewayChat; -pub use wire::classify::make_classify_http_failure_error; -pub use wire::classify::make_classify_stream_error; -pub use wire::delta::StreamDelta; -pub use wire::read::ChunkSource; -pub use wire::read::read_body_capped; -pub use wire::read::read_completion_stream; -pub use wire::request::build_request_body; -pub use wire::stream::escape_controls; diff --git a/crates/harness-gateway-client/src/search-tests-provider.rs b/crates/harness-gateway-client/src/search-tests-provider.rs deleted file mode 100644 index 3cfa14bf1..000000000 --- a/crates/harness-gateway-client/src/search-tests-provider.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! The Gateway search as a Host's `SearchProvider`: a validated query -//! goes out as the Gateway's request body, the reply's rows come back -//! field by field, and a failed search keeps its kind with the Gateway's -//! error as the cause. - -use super::*; - -use std::sync::{Arc, Mutex}; - -use axum::http::StatusCode; -use plugin_web::{ - Freshness, SafeSearch, SearchError, SearchErrorKind, SearchProvider, SearchQuery, SearchResult, -}; -use runvil::{Error, ErrorCode}; - -/// Asserts `error`'s source is the Gateway's failure under `code`, whose -/// prose is `error`'s text and whose message holds that text and more. -fn assert_gateway_cause(error: &SearchError, code: ErrorCode) { - let source = std::error::Error::source(error).expect("the failure keeps a cause"); - let cause = source - .downcast_ref::() - .unwrap_or_else(|| panic!("the Gateway's error is the cause: {source}")); - assert_eq!(cause.code, code); - assert_eq!(cause.prose, error.to_string()); - assert!(cause.message.starts_with(&cause.prose)); -} - -/// The Authorization header and body of each search a mock received. -type Seen = Arc, Value)>>>; - -/// A mock Gateway that records each search and answers `reply`. -fn recording_gateway(seen: Seen, reply: Value) -> Router { - Router::new().route( - "/tools/web_search", - post(move |headers: HeaderMap, Json(body): Json| { - let seen = Arc::clone(&seen); - let reply = reply.clone(); - async move { - let auth = headers - .get("authorization") - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); - seen.lock() - .expect("the capture lock is healthy") - .push((auth, body)); - Json(reply) - } - }), - ) -} - -/// A provider query for `text` with every option left out. -fn provider_query(text: &str) -> SearchQuery { - SearchQuery { - query: text.to_owned(), - ..SearchQuery::default() - } -} - -#[tokio::test] -async fn a_gateway_reply_maps_into_results_and_the_query_into_its_request() { - let seen = Seen::default(); - let reply = serde_json::json!({ - "query": "hi", - "results": [ - { - "title": "T", "url": "https://e.com", "description": "D", - "age": "2 days ago", "site_name": "e.com", "extra_snippets": ["more"] - }, - { "url": "https://f.com" } - ] - }); - let mock = MockServer::spawn(recording_gateway(Arc::clone(&seen), reply)).await; - - let results = SearchProvider::search( - &search_at(&mock.url()), - SearchQuery { - count: Some(3), - freshness: Some(Freshness::Pw), - country: Some("de".to_owned()), - search_lang: Some("en".to_owned()), - safesearch: Some(SafeSearch::Strict), - include_domains: vec!["e.com".to_owned()], - exclude_domains: vec!["g.com".to_owned()], - ..provider_query("hi") - }, - ) - .await - .expect("the search succeeds"); - - assert_eq!(results.query, "hi"); - assert_eq!( - results.results, - [ - SearchResult { - title: "T".to_owned(), - url: "https://e.com".to_owned(), - description: "D".to_owned(), - age: Some("2 days ago".to_owned()), - site_name: Some("e.com".to_owned()), - extra_snippets: vec!["more".to_owned()], - }, - SearchResult { - url: "https://f.com".to_owned(), - ..SearchResult::default() - }, - ] - ); - let seen = seen.lock().expect("the capture lock is healthy"); - assert_eq!(seen.len(), 1, "one search reached the Gateway"); - assert_eq!(seen[0].0.as_deref(), Some("Bearer tok")); - assert_eq!( - seen[0].1, - serde_json::json!({ - "query": "hi", "count": 3, "freshness": "pw", "country": "de", - "search_lang": "en", "safesearch": "strict", - "include_domains": ["e.com"], "exclude_domains": ["g.com"] - }) - ); -} - -#[tokio::test] -async fn a_gateway_error_status_maps_as_backend_with_the_gateway_error_as_source() { - async fn web_search() -> (StatusCode, &'static str) { - (StatusCode::BAD_GATEWAY, "upstream down") - } - let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; - - let error = SearchProvider::search(&search_at(&mock.url()), provider_query("hi")) - .await - .expect_err("a 502 fails the search"); - assert_eq!(error.kind(), SearchErrorKind::Backend); - assert_eq!(error.to_string(), "backend returned 502: upstream down"); - assert_gateway_cause(&error, ErrorCode::ToolBackend); -} - -#[tokio::test] -async fn a_refused_connection_maps_as_transport_with_the_gateway_error_as_source() { - // Bind then drop the listener so the port is closed and the connection - // is refused deterministically. - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - drop(listener); - - let error = SearchProvider::search(&search_at(&format!("http://{addr}")), provider_query("hi")) - .await - .expect_err("a refused connection fails the search"); - assert_eq!(error.kind(), SearchErrorKind::Transport); - assert_eq!(error.to_string(), "request failed"); - assert_gateway_cause(&error, ErrorCode::ToolTransport); -} diff --git a/crates/harness-gateway-client/src/search-tests-responses.rs b/crates/harness-gateway-client/src/search-tests-responses.rs deleted file mode 100644 index 0e7733a89..000000000 --- a/crates/harness-gateway-client/src/search-tests-responses.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! Gateway response handling: a refused connection or a stalled gateway is -//! a transport failure with its cause, a success body with the wrong shape -//! or invalid UTF-8 is a backend failure with its cause, an oversized -//! success body is rejected rather than truncated, a success body that -//! fails mid-read is a transport failure with its cause, and an error body -//! is bounded, sanitized, and keeps a mid-read failure as the cause. A -//! cause is in the message after the prose, never in the prose. - -use super::*; - -use std::time::Duration; - -use runvil::{Error, ErrorCode}; - -use crate::search::{MAX_ERROR_BODY, MAX_RESPONSE_BODY}; - -/// Asserts `err` failed as `code` with `prose`, and that its message holds -/// the prose followed by a cause the prose leaves out. -fn assert_caused(err: &Error, code: ErrorCode, prose: &str) { - assert_eq!(err.code, code); - assert_eq!(err.prose, prose); - let cause = err.message.strip_prefix(&format!("{prose}: ")); - assert!( - cause.is_some_and(|cause| !cause.is_empty()), - "the cause follows the prose in the message only: {err:?}" - ); -} - -#[tokio::test] -async fn a_refused_connection_is_a_transport_failure_with_its_cause() { - // Bind then drop the listener so the port is closed and the connection - // is refused deterministically. - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - drop(listener); - - let err = search_at(&format!("http://{addr}")) - .search(&query("hi")) - .await - .expect_err("a refused connection must surface as an error"); - assert_caused(&err, ErrorCode::ToolTransport, "request failed"); -} - -#[tokio::test] -async fn stalling_gateway_times_out_as_transport() { - async fn web_search() -> Json { - std::future::pending().await - } - let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; - let search = GatewaySearch::with_timeout( - GatewayEndpoint::new(&mock.url()).expect("valid test endpoint"), - SecretString::new("tok").expect("non-empty test key"), - Duration::from_millis(200), - ); - - let err = search - .search(&query("hi")) - .await - .expect_err("a stalled gateway must surface as an error"); - assert_caused(&err, ErrorCode::ToolTransport, "request failed"); - assert!( - err.message.contains("timed out"), - "the timeout must be kept in the message: {err:?}" - ); -} - -#[tokio::test] -async fn malformed_success_json_is_a_backend_failure_with_its_cause() { - async fn web_search() -> Json { - // Missing the required `results` array: valid JSON, wrong shape. - Json(serde_json::json!({ "unexpected": true })) - } - let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; - - let err = search_at(&mock.url()) - .search(&query("hi")) - .await - .expect_err("a wrong-shaped success body must be rejected"); - assert_caused(&err, ErrorCode::ToolBackend, "malformed search response"); -} - -#[tokio::test] -async fn non_utf8_success_body_is_a_backend_failure_with_its_cause() { - async fn web_search() -> Vec { - vec![b'{', 0xff, 0xfe, b'}'] - } - let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; - - let err = search_at(&mock.url()) - .search(&query("hi")) - .await - .expect_err("a success body that is not UTF-8 must be rejected"); - assert_caused( - &err, - ErrorCode::ToolBackend, - "response body was not valid UTF-8", - ); -} - -#[tokio::test] -async fn oversized_success_body_is_rejected() { - async fn web_search() -> String { - "x".repeat(MAX_RESPONSE_BODY + 4096) - } - let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; - - let err = search_at(&mock.url()) - .search(&query("hi")) - .await - .expect_err("an oversized success body must be rejected, not truncated"); - assert_eq!(err.code, ErrorCode::ToolBackend); - assert_eq!( - err.prose, - format!("response body exceeded {MAX_RESPONSE_BODY} bytes"), - "the error must name the cap overflow" - ); - assert_eq!(err.message, err.prose); -} - -#[tokio::test] -async fn oversized_error_body_is_bounded_and_sanitized() { - async fn web_search() -> (axum::http::StatusCode, String) { - // Oversized and control-laden so both bounding and sanitization run. - let mut body = "line-one\nline-two\ttab".to_owned(); - body.push_str(&"e".repeat(MAX_ERROR_BODY * 4)); - (axum::http::StatusCode::INTERNAL_SERVER_ERROR, body) - } - let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; - - let err = search_at(&mock.url()) - .search(&query("hi")) - .await - .expect_err("a 500 response must surface as an error"); - assert_eq!(err.code, ErrorCode::ToolBackend); - assert_eq!(err.message, err.prose); - let message = err.prose; - assert!( - message.starts_with("backend returned 500: line-one\\nline-two\\ttab"), - "error must name the status and escape the body: {message}" - ); - assert!( - !message.contains('\n') && !message.contains('\t'), - "control characters must be escaped, got: {message}" - ); - assert!( - message.len() < MAX_ERROR_BODY + 128, - "the error-path body must be bounded, got {} bytes", - message.len() - ); -} - -/// A raw TCP mock that answers with `status`, promises a large body via -/// `Content-Length`, sends a few bytes, then drops the connection so the -/// body read fails partway. -async fn spawn_truncated_reply(status: &'static str) -> (SocketAddr, tokio::task::JoinHandle<()>) { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let handle = tokio::spawn(async move { - use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; - if let Ok((mut socket, _)) = listener.accept().await { - let mut buf = [0u8; 1024]; - let _ = socket.read(&mut buf).await; - let header = format!("HTTP/1.1 {status}\r\nContent-Length: 100000\r\n\r\n"); - let _ = socket.write_all(header.as_bytes()).await; - let _ = socket.write_all(b"partial").await; - let _ = socket.flush().await; - } - }); - (addr, handle) -} - -#[tokio::test] -async fn success_body_read_failure_is_a_transport_failure_with_its_cause() { - let (addr, handle) = spawn_truncated_reply("200 OK").await; - - let err = search_at(&format!("http://{addr}")) - .search(&query("hi")) - .await - .expect_err("a truncated 200 body must surface as an error"); - assert_caused(&err, ErrorCode::ToolTransport, "reading response failed"); - handle.abort(); -} - -#[tokio::test] -async fn error_body_read_failure_is_kept_as_the_cause() { - let (addr, handle) = spawn_truncated_reply("500 Internal Server Error").await; - - let err = search_at(&format!("http://{addr}")) - .search(&query("hi")) - .await - .expect_err("a truncated 500 body must surface as an error"); - assert_caused( - &err, - ErrorCode::ToolBackend, - "backend returned 500, and its error body could not be read", - ); - handle.abort(); -} diff --git a/crates/harness-gateway-client/src/search-tests.rs b/crates/harness-gateway-client/src/search-tests.rs deleted file mode 100644 index ff59c0704..000000000 --- a/crates/harness-gateway-client/src/search-tests.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! The Gateway search client against an axum mock gateway: the bearer and -//! the request body on the wire, the parsed reply, and the redacted key. - -use super::{GatewaySearch, GatewaySearchRequest, GatewaySearchResponse, GatewaySearchResult}; -use crate::config::{GatewayEndpoint, SecretString}; - -use std::net::SocketAddr; - -use axum::Json; -use axum::Router; -use axum::http::HeaderMap; -use axum::routing::post; -use serde_json::Value; - -/// A mock gateway whose task is owned by the test: dropping it aborts the -/// server task deterministically instead of leaking a detached task. -struct MockServer { - addr: SocketAddr, - handle: tokio::task::JoinHandle<()>, -} - -impl MockServer { - /// Binds an ephemeral port, serves `router`, and returns the address. - async fn spawn(router: Router) -> MockServer { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let handle = tokio::spawn(async move { - let _ = axum::serve(listener, router).await; - }); - MockServer { addr, handle } - } - - fn url(&self) -> String { - format!("http://{}", self.addr) - } -} - -impl Drop for MockServer { - fn drop(&mut self) { - self.handle.abort(); - } -} - -/// A search client keyed with `tok` and pointed at the API root `base`. -fn search_at(base: &str) -> GatewaySearch { - GatewaySearch::new( - GatewayEndpoint::new(base).expect("valid test endpoint"), - SecretString::new("tok").expect("non-empty test key"), - ) -} - -/// A request carrying only `query`. -fn query(text: &str) -> GatewaySearchRequest { - GatewaySearchRequest { - query: text.to_owned(), - ..GatewaySearchRequest::default() - } -} - -/// A router serving the canned success result at the search endpoint. -fn success_router() -> Router { - async fn web_search(headers: HeaderMap, Json(body): Json) -> Json { - let auth = headers - .get("authorization") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default(); - assert_eq!( - auth, "Bearer tok", - "expected the bearer token to be forwarded" - ); - assert_eq!( - body.get("query").and_then(Value::as_str), - Some("hi"), - "expected the query to be forwarded in the body" - ); - Json(serde_json::json!({ - "query": "hi", - "results": [ - { "title": "T", "url": "https://e.com", "description": "D" } - ] - })) - } - Router::new().route("/tools/web_search", post(web_search)) -} - -#[test] -fn debug_never_leaks_the_bearer_token() { - let search = GatewaySearch::new( - GatewayEndpoint::new("http://localhost").expect("valid test endpoint"), - SecretString::new("super-secret-token").expect("non-empty test key"), - ); - let rendered = format!("{search:?}"); - assert!( - !rendered.contains("super-secret-token"), - "the bearer token must never appear in Debug output, got: {rendered}" - ); - assert!( - rendered.contains(""), - "the token field must be redacted, got: {rendered}" - ); -} - -#[tokio::test] -async fn forwards_query_and_returns_parsed_results() { - let mock = MockServer::spawn(success_router()).await; - - let response = search_at(&mock.url()) - .search(&query("hi")) - .await - .expect("search should succeed"); - - assert_eq!( - response.results[0].title, "T", - "expected the canned result title to survive the round-trip" - ); -} - -#[tokio::test] -async fn forwards_validated_optional_fields() { - async fn web_search(Json(body): Json) -> Json { - assert_eq!(body.get("count").and_then(Value::as_u64), Some(5)); - assert_eq!(body.get("freshness").and_then(Value::as_str), Some("pw")); - assert_eq!( - body.get("safesearch").and_then(Value::as_str), - Some("strict") - ); - assert_eq!( - body.get("include_domains"), - Some(&serde_json::json!(["example.com"])) - ); - Json(serde_json::json!({ "query": "hi", "results": [{ "url": "https://e.com" }] })) - } - let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; - let request = GatewaySearchRequest { - query: "hi".to_owned(), - count: Some(5), - freshness: Some("pw".to_owned()), - safesearch: Some("strict".to_owned()), - include_domains: vec!["example.com".to_owned()], - ..GatewaySearchRequest::default() - }; - - search_at(&mock.url()) - .search(&request) - .await - .expect("a fully-specified valid request should succeed"); -} - -#[tokio::test] -async fn parses_every_gateway_result_field_and_defaults_the_optional_ones() { - async fn web_search() -> Json { - Json(serde_json::json!({ - "query": "boost", - "results": [ - { - "title": "T", - "url": "https://e.com", - "description": "D", - "age": "2 days ago", - "site_name": "e.com", - "extra_snippets": ["S1", "S2"] - }, - { "url": "https://f.com" } - ] - })) - } - let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; - - let response = search_at(&mock.url()) - .search(&query("boost")) - .await - .expect("search should succeed"); - - assert_eq!( - response, - GatewaySearchResponse { - query: "boost".to_owned(), - results: vec![ - GatewaySearchResult { - title: "T".to_owned(), - url: "https://e.com".to_owned(), - description: "D".to_owned(), - age: Some("2 days ago".to_owned()), - site_name: Some("e.com".to_owned()), - extra_snippets: vec!["S1".to_owned(), "S2".to_owned()], - }, - GatewaySearchResult { - title: String::new(), - url: "https://f.com".to_owned(), - description: String::new(), - age: None, - site_name: None, - extra_snippets: Vec::new(), - }, - ], - } - ); -} - -#[path = "search-tests-responses.rs"] -mod responses; - -#[path = "search-tests-provider.rs"] -mod provider; diff --git a/crates/harness-gateway-client/src/search.rs b/crates/harness-gateway-client/src/search.rs deleted file mode 100644 index 5646682d3..000000000 --- a/crates/harness-gateway-client/src/search.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! The Gateway web search provider: each search is one -//! `POST {api_root}/tools/web_search` with the bearer key under a fixed -//! deadline, a bounded and sanitized error body, and a capped success body -//! parsed into private wire types that mirror the Gateway's request and -//! response, then mapped into the provider's results and errors. - -use std::fmt; -use std::time::Duration; - -use plugin_web::{ - SearchError, SearchErrorKind, SearchProvider, SearchQuery, SearchResult, SearchResults, -}; -use runvil::{Error, ErrorCode, make_tool_error}; - -use crate::config::{GatewayEndpoint, SecretString}; - -/// The largest error body kept for diagnostics, in bytes. -const MAX_ERROR_BODY: usize = 2000; - -/// The largest successful response body accepted from the gateway, in bytes. -/// -/// Search results include third-party web content, so the body is bounded -/// to keep a hostile or misbehaving upstream from returning an unbounded -/// payload. A body past this cap is rejected rather than silently -/// truncated, since a truncated JSON document is not a valid result set. -const MAX_RESPONSE_BODY: usize = 256 * 1024; - -/// The deadline applied to every outbound request, body included, so a -/// stalled gateway cannot hang a search (and thus a run) indefinitely. -const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); - -/// The body of a Gateway web search, mirroring the Gateway's -/// `POST /v1/tools/web_search` request. -/// -/// Only `query` is required. An absent option or an empty domain list is -/// left out of the body, and the Gateway applies its own default. -#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] -struct GatewaySearchRequest { - /// The search query. - query: String, - /// The number of results wanted; the Gateway clamps it to its maximum. - #[serde(skip_serializing_if = "Option::is_none")] - count: Option, - /// The freshness filter: `pd`, `pw`, `pm`, `py`, or a - /// `YYYY-MM-DDtoYYYY-MM-DD` range. - #[serde(skip_serializing_if = "Option::is_none")] - freshness: Option, - /// The country code for the search. - #[serde(skip_serializing_if = "Option::is_none")] - country: Option, - /// The search language code. - #[serde(skip_serializing_if = "Option::is_none")] - search_lang: Option, - /// The SafeSearch level: `off`, `moderate`, or `strict`. - #[serde(skip_serializing_if = "Option::is_none")] - safesearch: Option, - /// Keep only results from these hostnames. - #[serde(skip_serializing_if = "Vec::is_empty")] - include_domains: Vec, - /// Drop results from these hostnames. - #[serde(skip_serializing_if = "Vec::is_empty")] - exclude_domains: Vec, -} - -/// The reply to a Gateway web search, mirroring the Gateway's -/// `POST /v1/tools/web_search` response. -/// -/// `query`, `results` and each result's `url` are required, so a reply -/// without them is a malformed response. Every other field defaults when -/// absent, and unknown fields are ignored so the Gateway can grow its reply. -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -struct GatewaySearchResponse { - /// The query the Gateway ran, after its trimming. - query: String, - /// The result rows, in the Gateway's order. - results: Vec, -} - -/// One row of a [`GatewaySearchResponse`]. -/// -/// The `url` is required but may be empty; judging an empty `url` is left -/// to the caller. -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -struct GatewaySearchResult { - /// The result's title. - #[serde(default)] - title: String, - /// The result's URL. - url: String, - /// A short description or snippet. - #[serde(default)] - description: String, - /// The result's age, when the provider reports one. - age: Option, - /// The hostname of `url`, when the Gateway could derive one. - site_name: Option, - /// Extra snippets from the provider. - #[serde(default)] - extra_snippets: Vec, -} - -/// The [`SearchProvider`] a Host supplies to search the web through the -/// Gateway. -/// -/// It sends every search to one Gateway API root with the Gateway's shared -/// bearer key. Each search is one `POST {api_root}/tools/web_search` -/// request under a 30-second deadline. The deadline covers the whole -/// exchange, including reading the reply body. The search vendor's -/// credential stays in the Gateway. This provider sends only the Gateway's -/// key. -#[derive(Clone)] -#[non_exhaustive] -pub struct GatewaySearch { - http: reqwest::Client, - base_url: String, - key: SecretString, - timeout: Duration, -} - -impl fmt::Debug for GatewaySearch { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("GatewaySearch") - .field("base_url", &self.base_url) - .field("key", &"") - .finish_non_exhaustive() - } -} - -impl GatewaySearch { - /// Builds a search provider from a validated [`GatewayEndpoint`] and a - /// redacted [`SecretString`] bearer key. - #[must_use] - pub fn new(endpoint: GatewayEndpoint, key: SecretString) -> GatewaySearch { - GatewaySearch::with_timeout(endpoint, key, REQUEST_TIMEOUT) - } - - fn with_timeout( - endpoint: GatewayEndpoint, - key: SecretString, - timeout: Duration, - ) -> GatewaySearch { - GatewaySearch { - http: reqwest::Client::new(), - base_url: endpoint.url, - key, - timeout, - } - } - - /// Runs one web search and returns the Gateway's parsed reply, failing - /// as the [`SearchProvider`] impl documents: `ToolTransport` where it - /// says `Transport` and `ToolBackend` where it says `Backend`, with the - /// text it names as the prose and any cause after it in the message. - async fn search(&self, request: &GatewaySearchRequest) -> Result { - let response = self - .http - .post(format!("{}/tools/web_search", self.base_url)) - .bearer_auth(self.key.expose()) - .timeout(self.timeout) - .json(request) - .send() - .await - .map_err(|source| { - make_tool_error(ErrorCode::ToolTransport, "request failed").with_cause(&source) - })?; - - let status = response.status(); - if !status.is_success() { - let code = status.as_u16(); - // The error body is external gateway content: bound the read and - // sanitize control characters. If the body itself cannot be read, - // keep the read failure in the returned error's message. - match read_bounded(response, MAX_ERROR_BODY).await { - Ok(body) => { - let body = if body.is_empty() { - "(empty body)".to_owned() - } else { - sanitize_diagnostic(&body) - }; - return Err(make_tool_error( - ErrorCode::ToolBackend, - format!("backend returned {code}: {body}"), - )); - } - Err(source) => { - return Err(make_tool_error( - ErrorCode::ToolBackend, - format!("backend returned {code}, and its error body could not be read"), - ) - .with_cause(&source)); - } - } - } - - // Success bodies hold third-party content: bound them (rejecting cap - // overflow), then parse the promised JSON shape. - let body = read_capped(response, MAX_RESPONSE_BODY).await?; - serde_json::from_str(&body).map_err(|source| { - make_tool_error(ErrorCode::ToolBackend, "malformed search response").with_cause(&source) - }) - } -} - -#[async_trait::async_trait] -impl SearchProvider for GatewaySearch { - /// Runs `query` as one Gateway web search and returns the Gateway's - /// results with every field copied as the Gateway sent it. - /// - /// # Errors - /// Returns a [`SearchError`] whose text is the prose of the [`Error`] - /// it keeps as its source. The Gateway's error body in that text is - /// truncated to a size limit and its control characters are escaped, - /// and the text never holds the bearer key. Its kind is: - /// - `Transport` when sending the request fails (`request failed`) or - /// reading a successful reply fails (`reading response failed`), - /// including when the deadline passes; - /// - `Backend` when the Gateway answers a failure status - /// (`backend returned {code}: {body}`, or `backend returned {code}, - /// and its error body could not be read`), or a success body that is - /// over 256 KiB (`response body exceeded {limit} bytes`), is invalid - /// UTF-8 (`response body was not valid UTF-8`), or fails to parse as a - /// search reply (`malformed search response`). - async fn search(&self, query: SearchQuery) -> Result { - let request = gateway_request(query); - let response = self.search(&request).await.map_err(search_error)?; - Ok(search_results(response)) - } -} - -/// The Gateway's request for a validated query. -fn gateway_request(query: SearchQuery) -> GatewaySearchRequest { - GatewaySearchRequest { - query: query.query, - count: query.count, - freshness: query - .freshness - .map(|freshness| freshness.as_str().to_owned()), - country: query.country, - search_lang: query.search_lang, - safesearch: query.safesearch.map(|level| level.as_str().to_owned()), - include_domains: query.include_domains, - exclude_domains: query.exclude_domains, - } -} - -/// The provider's results for the Gateway's reply. -fn search_results(response: GatewaySearchResponse) -> SearchResults { - SearchResults { - query: response.query, - results: response - .results - .into_iter() - .map(|result| SearchResult { - title: result.title, - url: result.url, - description: result.description, - age: result.age, - site_name: result.site_name, - extra_snippets: result.extra_snippets, - }) - .collect(), - } -} - -/// The provider's error for a failed Gateway search: its kind from the -/// code, its text from the prose, and the Gateway's error as the cause. -fn search_error(error: Error) -> SearchError { - let kind = if error.code == ErrorCode::ToolTransport { - SearchErrorKind::Transport - } else { - SearchErrorKind::Backend - }; - SearchError::with_source(kind, error.prose.clone(), error) -} - -/// Escapes control characters in an external diagnostic body so a hostile -/// gateway cannot inject terminal/log control sequences or forge multiline -/// records through an error `Display`. -fn sanitize_diagnostic(body: &str) -> String { - let mut out = String::with_capacity(body.len()); - for c in body.chars() { - match c { - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if c.is_control() => { - use std::fmt::Write as _; - let _ = write!(out, "\\u{{{:04x}}}", u32::from(c)); - } - c => out.push(c), - } - } - out -} - -/// Reads at most `limit` bytes of a diagnostic body, stopping early once the cap -/// is reached. Used for the error path, where a truncated, lossy rendering is an -/// acceptable diagnostic. -async fn read_bounded(mut response: reqwest::Response, limit: usize) -> Result { - let mut buffer: Vec = Vec::new(); - while buffer.len() < limit { - let chunk = response.chunk().await.map_err(|source| { - make_tool_error(ErrorCode::ToolTransport, "reading response failed").with_cause(&source) - })?; - let Some(chunk) = chunk else { break }; - let take = (limit - buffer.len()).min(chunk.len()); - buffer.extend_from_slice(&chunk[..take]); - if take < chunk.len() { - break; - } - } - Ok(String::from_utf8_lossy(&buffer).into_owned()) -} - -/// Reads a success body, rejecting it once it would exceed `limit` bytes rather -/// than truncating (a truncated JSON document is not a valid result set), and -/// requiring valid UTF-8. -async fn read_capped(mut response: reqwest::Response, limit: usize) -> Result { - let mut buffer: Vec = Vec::new(); - while let Some(chunk) = response.chunk().await.map_err(|source| { - make_tool_error(ErrorCode::ToolTransport, "reading response failed").with_cause(&source) - })? { - if buffer.len() + chunk.len() > limit { - return Err(make_tool_error( - ErrorCode::ToolBackend, - format!("response body exceeded {limit} bytes"), - )); - } - buffer.extend_from_slice(&chunk); - } - String::from_utf8(buffer).map_err(|source| { - make_tool_error(ErrorCode::ToolBackend, "response body was not valid UTF-8") - .with_cause(&source) - }) -} - -#[cfg(test)] -#[path = "search-tests.rs"] -mod tests; diff --git a/crates/harness-gateway-client/src/transport.rs b/crates/harness-gateway-client/src/transport.rs deleted file mode 100644 index 35c968f50..000000000 --- a/crates/harness-gateway-client/src/transport.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! The HTTP transport: the gateway client, bounded SSE response reading, -//! and environment loading. -//! -//! The request body, the stream reassembly, and the read loop that applies -//! the byte cap and measures the timing are the shared wire code in -//! `wire`; this file owns only what touches the wire: sending, the -//! per-receive timeout, the response as a chunk source, and the clock the -//! read loop is handed. - -use std::fmt; -use std::num::NonZeroU64; -use std::time::{Duration, Instant}; - -use runvil::Error; -use runvil::model::{Completion, CompletionOptions, Message, ToolSchema}; - -use crate::config::{ - GatewayEndpoint, SecretString, make_invalid_env_error, make_missing_env_error, -}; -use crate::failure::{make_elapsed_error, make_transport_failure_error, make_unavailable_error}; -use crate::wire::classify::make_classify_http_failure_error; -use crate::wire::delta::StreamDelta; -use crate::wire::read::{ChunkSource, read_body_capped, read_completion_stream}; -use crate::wire::request::build_request_body; -use crate::wire::stream::escape_controls; - -/// A client that sends chat completion requests to one gateway URL. -/// -/// The client usually presents the gateway's shared bearer key on every -/// request. The key is optional: by default, a gateway on the same machine -/// admits keyless loopback callers. A keyless client -/// ([`GatewayChat::keyless`]) omits the `Authorization` header entirely. -#[derive(Clone)] -#[non_exhaustive] -pub struct GatewayChat { - transport: GatewayTransport, - base_url: String, - /// The bearer presented on every request, or `None` to present nothing. - key: Option, - /// Longest wait for the response headers, and then for each next body - /// chunk; a stream that keeps arriving is never cut off. - receive_timeout: Duration, - /// Byte ceiling enforced on a response body before it is decoded. - max_response_bytes: u64, -} - -/// Default longest wait for the next receive. -const DEFAULT_RECEIVE_TIMEOUT: Duration = Duration::from_secs(120); -/// Default response-body ceiling. -const DEFAULT_MAX_RESPONSE_BYTES: u64 = 16 * 1024 * 1024; - -#[derive(Clone)] -enum GatewayTransport { - Http(reqwest::Client), - Disabled, -} - -/// A [`reqwest::Response`] body as the reassembly's chunk source, with each -/// receive bounded by the client's timeout. -struct ResponseChunks { - response: reqwest::Response, - timeout: Duration, -} - -impl ChunkSource for ResponseChunks { - type Chunk = bytes::Bytes; - - async fn next_chunk(&mut self) -> Result, Error> { - tokio::time::timeout(self.timeout, self.response.chunk()) - .await - .map_err(make_elapsed_error)? - .map_err(make_transport_failure_error) - } -} - -impl fmt::Debug for GatewayChat { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - // The bearer key is a credential and must never appear in Debug output, - // logs, or panic messages. It is redacted to a fixed marker regardless of - // whether one is set, so no length or presence signal leaks either. - f.debug_struct("GatewayChat") - .field("base_url", &self.base_url) - .field("key", &"") - .finish_non_exhaustive() - } -} - -impl GatewayChat { - /// Builds a client that sends requests to `endpoint` with `key` as the - /// bearer key. - /// - /// The endpoint is a validated [`GatewayEndpoint`], and the key is a - /// [`SecretString`], which keeps it redacted. [`GatewayChat::from_env`] - /// builds its client this way when a key is set. - #[must_use] - pub fn new(endpoint: GatewayEndpoint, key: SecretString) -> GatewayChat { - GatewayChat { - transport: GatewayTransport::Http(reqwest::Client::new()), - base_url: endpoint.url, - key: Some(key), - receive_timeout: DEFAULT_RECEIVE_TIMEOUT, - max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, - } - } - - /// Builds a client whose requests omit the `Authorization` header that - /// carries the bearer key. - /// - /// This client suits a gateway on the same machine, because such a - /// gateway trusts keyless loopback callers, unless its operator set - /// `trust_loopback = false`. On a shared machine, that trust also covers - /// every other OS account there. Against any other gateway, the requests - /// fail with an `Unavailable`-kind error when the gateway answers 401. - /// - /// This constructor accepts an endpoint with any host, so the caller - /// decides when a keyless client is appropriate. - /// [`GatewayChat::from_env`] decides by [`GatewayEndpoint::is_loopback`]. - #[must_use] - pub fn keyless(endpoint: GatewayEndpoint) -> GatewayChat { - GatewayChat { - transport: GatewayTransport::Http(reqwest::Client::new()), - base_url: endpoint.url, - key: None, - receive_timeout: DEFAULT_RECEIVE_TIMEOUT, - max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, - } - } - - /// Builds an offline client, for execution paths that must stay - /// hermetic. - /// - /// Any attempted model call fails with an [`Error`] with code - /// `Unavailable`. The client reads no gateway configuration and sends no - /// HTTP request. - #[must_use] - pub fn disabled() -> GatewayChat { - GatewayChat { - transport: GatewayTransport::Disabled, - base_url: String::new(), - key: None, - receive_timeout: DEFAULT_RECEIVE_TIMEOUT, - max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, - } - } - - /// Whether this client presents a bearer key; a test seam for the - /// environment constructor, which never exposes the key itself. - #[cfg(test)] - fn has_key(&self) -> bool { - self.key.is_some() - } - - /// Applies the client's request limits: a timeout for each receive and - /// a cap on the response size. - /// - /// `receive_timeout` is the longest a completion request waits for its - /// response headers, and then for each next body chunk. Every chunk that - /// arrives restarts the wait. A long stream that keeps arriving - /// completes, and a stream that stalls fails as a timeout. - /// - /// The client refuses the response body as soon as it would exceed - /// `max_response_bytes`, before any UTF-8 or JSON decoding runs. - #[must_use] - pub fn with_request_limits( - mut self, - receive_timeout: Duration, - max_response_bytes: NonZeroU64, - ) -> GatewayChat { - self.receive_timeout = receive_timeout; - self.max_response_bytes = max_response_bytes.get(); - self - } - - /// Builds a client from environment variables. - /// - /// - `RUNVIL_GATEWAY_URL` holds the gateway URL. It is required. - /// - `RUNVIL_GATEWAY_API_KEY` holds the gateway's shared bearer key. - /// An empty value counts as missing. The key is optional when the - /// URL's host is loopback (`127.0.0.1`, `::1`, `localhost`) and - /// required for every other host. By default, a loopback gateway - /// trusts keyless callers on the same machine, so for a loopback URL a - /// missing key yields a keyless client. - /// - /// That trust also admits every other OS account on a shared machine, so - /// the gateway's operator there sets `trust_loopback = false`. Then set - /// the key, or a keyless client's requests fail with an - /// `Unavailable`-kind error when the gateway answers 401. - /// - /// # Errors - /// Returns an [`InvalidValue`](runvil::ErrorCode::InvalidValue) - /// setup error when `RUNVIL_GATEWAY_URL` is missing or invalid, when - /// either variable is set to a value that is not valid Unicode, or when - /// the URL's host is not loopback (a LAN or remote gateway) and - /// `RUNVIL_GATEWAY_API_KEY` is missing or empty. - pub fn from_env() -> Result { - from_env_with(|name| match std::env::var(name) { - Ok(value) => Ok(Some(value)), - Err(std::env::VarError::NotPresent) => Ok(None), - // A set-but-non-Unicode value is a real misconfiguration, surfaced - // explicitly instead of being silently treated as "not set". - Err(std::env::VarError::NotUnicode(_)) => Err(make_invalid_env_error(name)), - }) - } - - /// Sends a list of messages to the gateway and returns the model's reply. - /// - /// The request always streams. It asks for server-sent events (SSE) and - /// sets `stream_options.include_usage`, so the stream ends with a - /// summary chunk that reports token usage. The client reassembles the - /// streamed fragments into the body a buffered chat completion response - /// would carry. It calls `on_delta` with each text or reasoning - /// fragment, as a [`StreamDelta`], as soon as the fragment arrives. A - /// caller that ignores the fragments passes an empty closure. - /// - /// The returned [`Completion`] holds the reassembled turn, the metadata - /// parsed from the stream's summary chunk, and a - /// [`ClientTiming`](runvil::metrics::ClientTiming) measured on this - /// client's own clock: time to first token, mean inter-token latency, - /// and end-to-end time. - /// - /// When `tools` is `Some` and holds at least one schema, the request - /// carries a `tools` array with one `OpenAI` function tool per schema: - /// an object whose `type` is `function` and whose `function` holds the - /// schema's name, description, and parameters. The request also sets - /// `tool_choice` to `auto`. Passing `None` or an empty slice omits the - /// `tools` field, so the request is a plain chat completion. - /// - /// The request names the model that `options` names, and the returned - /// completion is labeled with that name, whatever name the response - /// gave. The optional `temperature`, `max_tokens`, and `thinking` - /// settings in `options` extend the request when present. - /// - /// # Errors - /// Returns an [`Error`] whose [`code`](Error::code) is always one of the - /// following: - /// - `Unavailable` when this client was built with [`GatewayChat::disabled`], - /// or when the gateway answers 401 or 403. - /// - `Timeout` when the request timeout elapses before the response - /// headers or the next body chunk arrive. - /// - `Transport` on any other transport failure, such as a failed - /// connection, or when the stream carries a mid-stream error envelope - /// that names no known cause. - /// - `ContextOverflow`, `RateLimited`, `QuotaExhausted`, `Overloaded`, - /// `Refused`, `ServerError`, or `Rejected` when the gateway answers - /// with a status outside the 2xx range, as [`make_classify_http_failure_error`] - /// reads it. - /// - `MalformedResponse` when the stream exceeds the response size cap, - /// a chunk fails to parse as usable JSON (a JSON decode error is kept - /// in the error's message), or the stream ends before the `[DONE]` - /// sentinel. It is also the code when a `length` or `content_filter` - /// finish reason cuts off a batch of tool calls, because partial - /// arguments must not run. - /// - `EmptyReply` when the turn has zero tool calls and blank text. - pub async fn complete( - &self, - messages: &[Message], - tools: Option<&[ToolSchema]>, - options: &CompletionOptions, - on_delta: impl Fn(StreamDelta), - ) -> Result { - let GatewayTransport::Http(http) = &self.transport else { - return Err(make_unavailable_error()); - }; - let request_body = build_request_body(messages, tools, options); - - let started = Instant::now(); - // No reqwest `.timeout`: it caps the whole request including the - // body, which would cut off a long stream that is still arriving. - // The timeout bounds the headers here and each receive in - // `ResponseChunks`. - let mut request = http - .post(format!("{}/chat/completions", self.base_url)) - .json(&request_body); - if let Some(key) = &self.key { - request = request.bearer_auth(key.expose()); - } - let response = tokio::time::timeout(self.receive_timeout, request.send()) - .await - .map_err(make_elapsed_error)? - .map_err(make_transport_failure_error)?; - - let status = response.status(); - let content_length = response.content_length(); - let mut chunks = ResponseChunks { - response, - timeout: self.receive_timeout, - }; - if !status.is_success() { - let raw_body = - read_body_capped(&mut chunks, content_length, self.max_response_bytes).await?; - // F5: bound the body, then escape control characters so a hostile - // payload cannot forge log lines. The classifier appends the - // escaped body to the error's message only, never to its prose. - let body = String::from_utf8_lossy(&raw_body); - let body = escape_controls(&body, 2000); - return Err(make_classify_http_failure_error(status.as_u16(), &body)); - } - - // The byte cap, the `[DONE]` rule, the truncation rule, the strict - // turn normalizer, and the timing arithmetic all run inside the - // shared read loop: one rule set for every transport. This client - // contributes the chunks and the clock. - read_completion_stream( - &mut chunks, - request_body, - self.max_response_bytes, - on_delta, - started, - Instant::now, - ) - .await - } -} - -/// The environment-driven constructor behind [`GatewayChat::from_env`], -/// with the variable lookup injected so tests need not touch the process -/// environment. -/// -/// The key is optional exactly when the URL's host is loopback; an empty key -/// counts as unset ([`SecretString::new`] refuses only an empty secret, and -/// `Result::ok` folds that refusal into `None`). -fn from_env_with( - lookup: impl Fn(&str) -> Result, Error>, -) -> Result { - let base_url = lookup("RUNVIL_GATEWAY_URL")? - .ok_or_else(|| make_missing_env_error("RUNVIL_GATEWAY_URL"))?; - let endpoint = GatewayEndpoint::new(&base_url)?; - let key = lookup("RUNVIL_GATEWAY_API_KEY")? - .map(SecretString::new) - .and_then(Result::ok); - match key { - Some(key) => Ok(GatewayChat::new(endpoint, key)), - None if endpoint.is_loopback() => Ok(GatewayChat::keyless(endpoint)), - None => Err(make_missing_env_error("RUNVIL_GATEWAY_API_KEY")), - } -} - -#[cfg(test)] -pub(crate) mod tests; diff --git a/crates/harness-gateway-client/src/transport/tests.rs b/crates/harness-gateway-client/src/transport/tests.rs deleted file mode 100644 index d13abc9ae..000000000 --- a/crates/harness-gateway-client/src/transport/tests.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! The gateway client against an axum mock gateway: environment loading, -//! the bearer on the wire, the streamed round, and the bounds. - -use runvil::model::CompletionOptions; -use serde_json::Value; - -use super::*; - -mod env; -mod limits; -mod streaming; - -/// Serves `app` on a loopback port and returns its `/v1` root. -pub(crate) async fn serve(app: axum::Router) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - format!("http://{addr}/v1") -} - -/// Serves `app` on a loopback port and returns a keyed client pointed at -/// its `/v1` root. -async fn client_for(app: axum::Router) -> GatewayChat { - GatewayChat::new( - GatewayEndpoint::new(&serve(app).await).expect("valid test endpoint"), - SecretString::new("tok").expect("non-empty test key"), - ) -} - -/// Renders `events` as SSE `data:` lines closed by the `[DONE]` sentinel. -pub(crate) fn sse_body(events: &[Value]) -> String { - let mut body = String::new(); - for event in events { - body.push_str("data: "); - body.push_str(&event.to_string()); - body.push_str("\n\n"); - } - body.push_str("data: [DONE]\n\n"); - body -} - -/// A mock gateway that answers every completion with the given SSE body. -pub(crate) fn sse_app(body: String) -> axum::Router { - use axum::Router; - use axum::routing::post; - - Router::new().route( - "/v1/chat/completions", - post(move || { - let body = body.clone(); - async move { - ( - [(axum::http::header::CONTENT_TYPE, "text/event-stream")], - body, - ) - } - }), - ) -} - -/// A client pointed at a mock gateway that answers every completion with -/// the given SSE body. -async fn sse_client(body: String) -> GatewayChat { - client_for(sse_app(body)).await -} - -/// One streamed chunk with a content fragment. -pub(crate) fn content_chunk(text: &str) -> Value { - serde_json::json!({ - "model": "qwen3-30b", - "choices": [{ "index": 0, "delta": { "content": text }, "finish_reason": null }] - }) -} - -/// The minimal stop-finished stream: one content chunk and a finish chunk. -fn ok_stream() -> String { - sse_body(&[ - content_chunk("ok"), - serde_json::json!({ - "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] - }), - ]) -} - -fn openai_options() -> CompletionOptions { - CompletionOptions::new("m") -} - -/// Spawns a gateway that answers `/v1/chat/completions` with a fixed status -/// and raw body, returning its `/v1` base. -async fn spawn_raw_gateway(status: axum::http::StatusCode, body: &'static str) -> String { - use axum::Router; - use axum::routing::post; - use tokio::net::TcpListener; - - let app = Router::new().route( - "/v1/chat/completions", - post(move || async move { (status, body) }), - ); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - format!("http://{addr}/v1") -} - -/// A keyed client pointed at the `/v1` base `base`. -fn keyed_client(base: &str) -> GatewayChat { - GatewayChat::new( - GatewayEndpoint::new(base).expect("valid endpoint"), - SecretString::new("tok").expect("non-empty test key"), - ) -} - -fn lookup_from<'a>( - pairs: &'a [(&'a str, &'a str)], -) -> impl Fn(&str) -> Result, Error> + 'a { - let pairs: Vec<(String, String)> = pairs - .iter() - .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())) - .collect(); - move |name| { - Ok(pairs - .iter() - .find(|(key, _)| key == name) - .map(|(_, value)| value.clone())) - } -} diff --git a/crates/harness-gateway-client/src/transport/tests/env.rs b/crates/harness-gateway-client/src/transport/tests/env.rs deleted file mode 100644 index be2144db0..000000000 --- a/crates/harness-gateway-client/src/transport/tests/env.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! Environment loading, the bearer on the wire, and the credential and -//! endpoint guards. - -use runvil::model::Message; - -use runvil::{ErrorCode, ErrorFact}; -use serde_json::Value; - -use super::*; -use crate::config::make_invalid_env_error; - -/// Asserts `error` is a setup error for `reason`, naming `variable` when one -/// is given, whose prose is its message. -#[track_caller] -fn assert_setup_error(error: &Error, reason: &str, variable: Option<&str>) { - assert_eq!(error.code, ErrorCode::InvalidValue, "{error:?}"); - assert_eq!( - error.facts.get(&ErrorFact::Reason), - Some(&Value::from(reason)), - "{error:?}" - ); - assert_eq!( - error.facts.get(&ErrorFact::Name), - variable.map(Value::from).as_ref(), - "{error:?}" - ); - assert_eq!(error.prose, error.message, "{error:?}"); -} - -#[test] -fn from_env_surfaces_non_unicode_value_instead_of_dropping_it() { - let err = from_env_with(|name| { - if name == "RUNVIL_GATEWAY_URL" { - Err(make_invalid_env_error(name)) - } else { - Ok(Some("tok".to_owned())) - } - }) - .expect_err("a non-Unicode variable must be surfaced, not treated as missing"); - assert_setup_error(&err, "invalid_env", Some("RUNVIL_GATEWAY_URL")); - assert_eq!( - err.to_string(), - "environment variable is set but not valid Unicode: RUNVIL_GATEWAY_URL" - ); -} - -#[test] -fn from_env_missing_gateway_url() { - let err = from_env_with(lookup_from(&[("RUNVIL_GATEWAY_API_KEY", "tok")])) - .expect_err("missing URL must fail"); - assert_eq!( - err.to_string(), - "missing environment variable: RUNVIL_GATEWAY_URL" - ); - assert_setup_error(&err, "missing_env", Some("RUNVIL_GATEWAY_URL")); -} - -#[test] -fn from_env_missing_gateway_key() { - // A LAN gateway never trusts a keyless caller, so the key stays required - // there; an empty value is the same as no value. Only the exact name - // `localhost` is loopback: a name that merely contains it is not. - for key_pairs in [ - vec![("RUNVIL_GATEWAY_URL", "http://192.168.1.20:8081/v1")], - vec![ - ("RUNVIL_GATEWAY_URL", "http://192.168.1.20:8081/v1"), - ("RUNVIL_GATEWAY_API_KEY", ""), - ], - vec![("RUNVIL_GATEWAY_URL", "https://gateway.example.com/v1")], - vec![("RUNVIL_GATEWAY_URL", "http://localhost.evil.com:8081/v1")], - vec![("RUNVIL_GATEWAY_URL", "http://notlocalhost:8081/v1")], - ] { - let err = from_env_with(lookup_from(&key_pairs)) - .expect_err("missing key against a non-loopback gateway must fail"); - assert_setup_error(&err, "missing_env", Some("RUNVIL_GATEWAY_API_KEY")); - } -} - -#[test] -fn from_env_missing_gateway_key_is_fine_for_a_loopback_gateway() { - // A loopback gateway trusts keyless same-machine callers by default, so - // the key is optional for every loopback spelling; the built client is - // the keyless one, which the Debug form cannot distinguish (no presence - // signal leaks), so the header test below pins what it sends. - for url in [ - "http://127.0.0.1:8081/v1", - "http://127.0.0.2:8081/v1", - "http://[::1]:8081/v1", - "http://localhost:8081/v1", - "http://LOCALHOST:8081/v1", - ] { - let client = from_env_with(lookup_from(&[("RUNVIL_GATEWAY_URL", url)])) - .unwrap_or_else(|err| panic!("a loopback URL needs no key, got {err:?} for {url}")); - assert!( - !client.has_key(), - "the client built for {url} must leave the key unset" - ); - let empty_key = from_env_with(lookup_from(&[ - ("RUNVIL_GATEWAY_URL", url), - ("RUNVIL_GATEWAY_API_KEY", ""), - ])) - .unwrap_or_else(|err| panic!("an empty key on loopback is unset, got {err:?} for {url}")); - assert!(!empty_key.has_key()); - } - let keyed = from_env_with(lookup_from(&[ - ("RUNVIL_GATEWAY_URL", "http://127.0.0.1:8081/v1"), - ("RUNVIL_GATEWAY_API_KEY", "tok"), - ])) - .expect("a loopback URL with a key builds"); - assert!( - keyed.has_key(), - "a key that is set is kept even on loopback" - ); -} - -/// Spawns a gateway that records the `Authorization` header of each -/// completion request (as `Some(value)` or `None`) and answers a minimal -/// stop-finished stream, returning its `/v1` base and the capture slot. -async fn spawn_auth_capturing_gateway() -> ( - String, - std::sync::Arc>>>, -) { - use std::sync::{Arc, Mutex}; - - use axum::Router; - use axum::http::HeaderMap; - use axum::routing::post; - - let captured: Arc>>> = Arc::new(Mutex::new(None)); - let slot = Arc::clone(&captured); - let app = Router::new().route( - "/v1/chat/completions", - post(move |headers: HeaderMap| { - let slot = Arc::clone(&slot); - async move { - let auth = headers - .get(axum::http::header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); - *slot.lock().expect("capture lock") = Some(auth); - ( - [(axum::http::header::CONTENT_TYPE, "text/event-stream")], - ok_stream(), - ) - } - }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - (format!("http://{addr}/v1"), captured) -} - -#[tokio::test] -async fn keyless_client_sends_no_authorization_header() { - // The gateway's loopback trust admits only a request with NO - // Authorization header at all - a presented-but-wrong bearer is still - // 401 - so a keyless client must omit the header, not send an empty one. - let (base, captured) = spawn_auth_capturing_gateway().await; - let client = GatewayChat::keyless(GatewayEndpoint::new(&base).expect("valid endpoint")); - client - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect("the keyless completion succeeds"); - let seen = captured - .lock() - .expect("capture lock") - .clone() - .expect("the gateway saw the request"); - assert_eq!( - seen, None, - "a keyless client must send no Authorization header, got {seen:?}" - ); -} - -#[tokio::test] -async fn keyed_client_still_sends_the_bearer_header() { - let (base, captured) = spawn_auth_capturing_gateway().await; - let client = keyed_client(&base); - client - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect("the keyed completion succeeds"); - let seen = captured - .lock() - .expect("capture lock") - .clone() - .expect("the gateway saw the request"); - assert_eq!(seen.as_deref(), Some("Bearer tok")); -} - -#[test] -fn keyless_client_debug_is_indistinguishable_from_a_keyed_one() { - // No presence signal leaks through Debug either way. - let keyless = - GatewayChat::keyless(GatewayEndpoint::new("http://127.0.0.1:8081/v1").expect("valid")); - let rendered = format!("{keyless:?}"); - assert!(rendered.contains(""), "got: {rendered}"); - assert!(!rendered.contains("None"), "got: {rendered}"); -} - -#[test] -fn debug_redacts_the_bearer_key_and_never_leaks_it() { - let client = GatewayChat::new( - GatewayEndpoint::new("http://127.0.0.1:8081/v1").expect("valid test endpoint"), - SecretString::new("super-secret-token").expect("non-empty test key"), - ); - let rendered = format!("{client:?}"); - assert!( - !rendered.contains("super-secret-token"), - "the bearer key must never appear in Debug output, got: {rendered}" - ); - assert!( - rendered.contains(""), - "the key field must be redacted, got: {rendered}" - ); - assert!( - rendered.contains("http://127.0.0.1:8081/v1"), - "the base URL is not a secret and should still appear, got: {rendered}" - ); -} - -#[test] -fn secret_string_never_prints_its_contents() { - let secret = SecretString::new("super-secret-token").expect("non-empty test key"); - assert_eq!(format!("{secret:?}"), "SecretString()"); - assert_eq!(format!("{secret}"), ""); - assert_eq!(secret.expose(), "super-secret-token"); -} - -#[test] -fn secret_string_construction_rejects_an_empty_credential() { - // F12: an empty bearer credential is unrepresentable. - let error = SecretString::new("").expect_err("blank key is rejected"); - assert_eq!(error.code, ErrorCode::InvalidValue); - assert_eq!( - error.facts.get(&ErrorFact::Reason), - Some(&serde_json::Value::from("empty_secret")) - ); - assert_eq!(error.message, "secret must not be empty"); - assert_eq!( - error.prose, - "The secret is empty. Supply a non-empty secret." - ); - assert!(SecretString::new("tok").is_ok()); -} - -#[test] -fn gateway_endpoint_rejects_non_http_schemes_and_missing_host() { - for url in ["ftp://example.com/v1", "not-a-url", "http://", ""] { - let error = GatewayEndpoint::new(url).expect_err("invalid endpoint must be rejected"); - assert!( - error.to_string().starts_with("gateway URL"), - "the message names the rule that failed: {error}" - ); - assert!(!error.to_string().contains("missing environment variable")); - } -} - -#[test] -fn gateway_endpoint_keeps_the_url_parse_cause_in_its_message() { - let url_error = GatewayEndpoint::new("not a url").expect_err("malformed URL is rejected"); - assert_eq!( - url_error.message, - format!( - "gateway URL is not a valid URL: {}", - url::ParseError::RelativeUrlWithoutBase - ) - ); - assert_setup_error(&url_error, "invalid_config", None); -} - -#[test] -fn constructor_errors_do_not_leak_secrets() { - for url in [ - "not a url hunter2", - "ftp://alice:hunter2@host/v1", - "http://alice:hunter2@host/v1", - "http://host/v1?token=hunter2", - "http://host/v1#hunter2", - ] { - let error = GatewayEndpoint::new(url).expect_err("invalid endpoint must be rejected"); - for rendered in [error.to_string(), format!("{error:?}")] { - assert!( - !rendered.contains("hunter2") && !rendered.contains("alice"), - "the rejected URL must not be echoed into diagnostics: {rendered}" - ); - } - } -} - -#[test] -fn gateway_endpoint_rejects_credentials_query_and_fragment() { - // F12: the strict URL parse rejects embedded credentials and the - // query/fragment ambiguity a hand-rolled prefix scan let through. - for url in [ - "http://user:pass@host/v1", - "http://user@host/v1", - "http://host/v1?token=leak", - "http://host/v1#frag", - ] { - let error = GatewayEndpoint::new(url).expect_err("invalid endpoint must be rejected"); - assert!( - error.to_string().starts_with("gateway URL must"), - "the message names the rule that failed: {error}" - ); - assert!(!error.to_string().contains("missing environment variable")); - } - // A clean http(s) API root is still accepted and normalized. - assert_eq!( - GatewayEndpoint::new("http://host:8080/v1/") - .expect("clean URL") - .url(), - "http://host:8080/v1" - ); -} - -#[test] -fn gateway_endpoint_trims_trailing_slash_and_keeps_valid_urls() { - let endpoint = GatewayEndpoint::new("https://gateway.example.com/v1/") - .expect("a well-formed https URL is accepted"); - assert_eq!(endpoint.url(), "https://gateway.example.com/v1"); -} diff --git a/crates/harness-gateway-client/src/transport/tests/limits.rs b/crates/harness-gateway-client/src/transport/tests/limits.rs deleted file mode 100644 index 21a341bc4..000000000 --- a/crates/harness-gateway-client/src/transport/tests/limits.rs +++ /dev/null @@ -1,398 +0,0 @@ -//! The bounds and refusals: the disabled sentinel, the byte caps on both -//! paths, the per-receive timeout, and the malformed or cut-off stream. - -use std::num::NonZeroU64; -use std::time::Duration; - -use runvil::ErrorCode; -use runvil::model::{CompletionResult, Message}; - -use super::*; - -#[tokio::test] -async fn complete_on_a_disabled_client_is_an_unavailable_error() { - // F14: a disabled client never touches the network. - let client = GatewayChat::disabled(); - let err = client - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect_err("a disabled client cannot complete"); - assert_eq!(err.code, ErrorCode::Unavailable); - assert_eq!( - err.to_string(), - "model access is turned off or not configured" - ); -} - -#[tokio::test] -async fn backend_error_prose_is_body_free_and_the_message_holds_the_escaped_body() { - use axum::Router; - use axum::routing::post; - - // A non-success body holding control characters and a would-be secret. - async fn handler() -> (axum::http::StatusCode, String) { - ( - axum::http::StatusCode::BAD_GATEWAY, - "forged\nlog: super-secret".to_owned(), - ) - } - let app = Router::new().route("/v1/chat/completions", post(handler)); - let client = client_for(app).await; - let err = client - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect_err("a 502 must surface as a classified failure"); - - // F5: the prose the model reads names only the code and the status, - // never the raw body. - assert_eq!(err.code, ErrorCode::ServerError); - let shown = &err.prose; - assert_eq!( - shown, - "the model backend reported a fault of its own (status 502)" - ); - assert!( - !shown.contains("super-secret") && !shown.contains('\n'), - "the raw body must not appear in the prose, got {shown}" - ); - // The bounded, control-escaped body is in the message only. - let body = err - .message - .strip_prefix(&format!("{shown}: ")) - .expect("the message holds the body"); - assert!( - body.contains("\\n"), - "control chars must be escaped, got {body}" - ); - assert!( - !err.message.contains('\n'), - "no raw newline in the diagnostic message" - ); -} - -#[tokio::test] -async fn complete_refuses_a_success_stream_over_the_size_cap() { - // F14 (body-size, success path): a 200 stream larger than the cap is - // refused as the bytes arrive, before any further parsing. - let base = spawn_raw_gateway( - axum::http::StatusCode::OK, - "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"a long reply\"}}]}\n\n", - ) - .await; - let client = keyed_client(&base).with_request_limits( - DEFAULT_RECEIVE_TIMEOUT, - NonZeroU64::new(8).expect("non-zero cap"), - ); - let err = client - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect_err("an oversize stream must be refused"); - assert_eq!(err.code, ErrorCode::MalformedResponse); - assert_eq!( - err.to_string(), - "the model backend sent a reply that could not be understood: \ - response stream exceeds the 8-byte limit" - ); -} - -#[tokio::test] -async fn complete_refuses_a_backend_error_body_over_the_size_cap() { - // F14 (body-size, error path): a non-success body larger than the cap is - // also refused before it is buffered. - let base = spawn_raw_gateway( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "this backend error body is definitely longer than eight bytes", - ) - .await; - let client = keyed_client(&base).with_request_limits( - DEFAULT_RECEIVE_TIMEOUT, - NonZeroU64::new(8).expect("non-zero cap"), - ); - let err = client - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect_err("an oversize error body must be refused"); - assert_eq!(err.code, ErrorCode::MalformedResponse); - assert_eq!(err.message, err.prose, "the refused body is never kept"); -} - -#[tokio::test] -async fn a_request_past_the_timeout_is_a_timeout_failure() { - use axum::Router; - use axum::routing::post; - - // The timeout bounds the wait for the response headers; a gateway that - // never answers within it fails as Timeout. - async fn stall() -> (axum::http::StatusCode, String) { - std::future::pending().await - } - let app = Router::new().route("/v1/chat/completions", post(stall)); - let client = client_for(app).await.with_request_limits( - Duration::from_millis(50), - NonZeroU64::new(1024).expect("non-zero cap"), - ); - let err = client - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect_err("a stalled gateway must time out"); - assert_eq!( - err.code, - ErrorCode::Timeout, - "the timeout must be recognizable: {err:?}" - ); -} - -/// Reads one request through its body, so answering and closing never -/// resets a connection that still holds unread bytes. -async fn read_request(sock: &mut tokio::net::TcpStream) { - use tokio::io::AsyncReadExt; - - let mut request = Vec::new(); - let mut buf = [0u8; 1024]; - while let Ok(read @ 1..) = sock.read(&mut buf).await { - request.extend_from_slice(&buf[..read]); - let text = String::from_utf8_lossy(&request); - let Some(end) = text.find("\r\n\r\n") else { - continue; - }; - let length = text[..end] - .lines() - .filter_map(|line| line.split_once(':')) - .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) - .and_then(|(_, value)| value.trim().parse::().ok()) - .unwrap_or(0); - if request.len() >= end + 4 + length { - return; - } - } -} - -/// Serves one completion as a chunked SSE response written piece by piece, -/// each after its pause, and returns the `/v1` base. With `stall` the body -/// is never terminated and the socket stays open. -async fn spawn_paced_gateway(pieces: Vec<(Duration, String)>, stall: bool) -> String { - use tokio::io::AsyncWriteExt; - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - let Ok((mut sock, _)) = listener.accept().await else { - return; - }; - // Small paced writes must leave at once, not wait on Nagle. - let _ = sock.set_nodelay(true); - read_request(&mut sock).await; - let head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\ - Transfer-Encoding: chunked\r\n\r\n"; - if sock.write_all(head.as_bytes()).await.is_err() { - return; - } - for (pause, piece) in pieces { - tokio::time::sleep(pause).await; - let frame = format!("{:x}\r\n{piece}\r\n", piece.len()); - if sock.write_all(frame.as_bytes()).await.is_err() { - return; - } - } - if stall { - std::future::pending::<()>().await; - } - let _ = sock.write_all(b"0\r\n\r\n").await; - }); - format!("http://{addr}/v1") -} - -/// The stream's closing `stop` chunk and `[DONE]` sentinel. -fn stream_close() -> String { - sse_body(&[serde_json::json!({ - "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] - })]) -} - -/// A keyed client for `base` whose timeout is `budget`. -fn budgeted_client(base: &str, budget: Duration) -> GatewayChat { - keyed_client(base).with_request_limits(budget, NonZeroU64::new(1024 * 1024).expect("non-zero")) -} - -#[tokio::test] -async fn a_steady_stream_longer_than_the_timeout_completes() { - let gap = Duration::from_millis(50); - let mut pieces: Vec<(Duration, String)> = (0..5) - .map(|_| (gap, format!("data: {}\n\n", content_chunk("tick")))) - .collect(); - pieces.push((gap, stream_close())); - let base = spawn_paced_gateway(pieces, false).await; - let completion = budgeted_client(&base, Duration::from_millis(100)) - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect("a stream that keeps arriving is not timed out"); - match completion.result() { - CompletionResult::Text(text) => assert_eq!(text, "tick".repeat(5).as_str()), - other => panic!("expected text, got {other:?}"), - } -} - -#[tokio::test] -async fn a_single_event_trickled_past_the_timeout_completes() { - let event = format!("data: {}\n\n", content_chunk("trickled")); - let gap = Duration::from_millis(30); - let step = event.len().div_ceil(10); - let mut pieces: Vec<(Duration, String)> = event - .as_bytes() - .chunks(step) - .map(|piece| (gap, String::from_utf8(piece.to_vec()).expect("ASCII event"))) - .collect(); - assert!(pieces.len() >= 9, "the event arrives in about ten pieces"); - pieces.push((gap, stream_close())); - let base = spawn_paced_gateway(pieces, false).await; - let completion = budgeted_client(&base, Duration::from_millis(100)) - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect("every received piece restarts the timeout"); - match completion.result() { - CompletionResult::Text(text) => assert_eq!(text, "trickled"), - other => panic!("expected text, got {other:?}"), - } -} - -#[tokio::test] -async fn a_stream_that_stalls_after_the_headers_is_a_timeout_failure() { - let pieces = vec![( - Duration::ZERO, - format!("data: {}\n\n", content_chunk("half")), - )]; - let base = spawn_paced_gateway(pieces, true).await; - let err = budgeted_client(&base, Duration::from_millis(100)) - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect_err("a stream that stops arriving must time out"); - assert_eq!( - err.code, - ErrorCode::Timeout, - "the timeout must be recognizable: {err:?}" - ); -} - -#[tokio::test] -async fn a_body_read_timeout_is_a_timeout_failure() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - // The catalog fetch builds a failed error-body read through the same - // helper as a send failure, so a timeout during that read is still a - // `Timeout`. The server answers a 500 with a large promised body, then - // stalls. - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - if let Ok((mut sock, _)) = listener.accept().await { - let mut buf = [0u8; 1024]; - let _ = sock.read(&mut buf).await; - let header = "HTTP/1.1 500 Internal Server Error\r\n\ - Content-Length: 1000000\r\n\r\nabc"; - let _ = sock.write_all(header.as_bytes()).await; - // The stall never ends on its own: the client's read timeout - // is what ends the test, and the runtime's teardown drops - // the socket. - std::future::pending::<()>().await; - } - }); - let response = reqwest::Client::new() - .get(format!("http://{addr}/models")) - .timeout(Duration::from_millis(50)) - .send() - .await - .expect("the headers arrive before the stall"); - let read = response - .bytes() - .await - .expect_err("the body read stalls past the timeout"); - assert!(read.is_timeout(), "reqwest reports the read as a timeout"); - - let read_text = read.to_string(); - let err = make_transport_failure_error(read); - assert_eq!( - err.code, - ErrorCode::Timeout, - "a timed-out body read is a Timeout: {err:?}" - ); - assert_eq!(err.prose, "the model backend did not answer in time"); - assert!( - err.message.contains(&read_text) && !err.prose.contains(&read_text), - "the reqwest error is kept in the message only: {err:?}" - ); -} - -#[tokio::test] -async fn a_refused_connection_is_a_transport_failure() { - // Bind a port, then drop the listener so nothing accepts on it. - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - drop(listener); - let err = keyed_client(&format!("http://{addr}/v1")) - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect_err("nothing listens on the port"); - assert_eq!(err.code, ErrorCode::Transport); - assert_eq!(err.prose, "the connection to the model backend failed"); - assert!( - err.message.starts_with(&format!("{}: ", err.prose)), - "the connection error joins the message: {err:?}" - ); -} - -#[tokio::test] -async fn complete_refuses_a_malformed_stream_chunk() { - // F14: a 200 whose stream holds an undecodable chunk is - // MalformedResponse, and the decode failure is kept in the message. - let base = spawn_raw_gateway(axum::http::StatusCode::OK, "data: { not json\n\n").await; - let client = keyed_client(&base); - let err = client - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect_err("undecodable chunk must fail"); - assert_eq!(err.code, ErrorCode::MalformedResponse); - assert!( - err.message.starts_with(&format!("{}: ", err.prose)), - "the decode error joins the message: {err:?}" - ); -} - -#[tokio::test] -async fn complete_refuses_malformed_tool_call_fragments_at_the_boundary() { - // F14: a well-formed HTTP 200 whose streamed tool-call fragment has - // non-string arguments is rejected at the client boundary, not passed on. - let client = sse_client(sse_body(&[serde_json::json!({ - "choices": [{ "index": 0, "delta": { "tool_calls": [{ - "index": 0, "id": "c1", "type": "function", - "function": { "name": "t", "arguments": 123 } - }] } }] - })])) - .await; - let err = client - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect_err("malformed tool arguments must be rejected"); - assert_eq!(err.code, ErrorCode::MalformedResponse); -} - -#[tokio::test] -async fn stream_without_done_sentinel_is_malformed() { - // A stream cut off before [DONE] may be missing its tail; it must never - // pass for a complete turn. - let base = spawn_raw_gateway( - axum::http::StatusCode::OK, - "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"half\"}}]}\n\n", - ) - .await; - let client = keyed_client(&base); - let err = client - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect_err("a truncated stream must fail"); - assert_eq!(err.code, ErrorCode::MalformedResponse); - assert_eq!( - err.to_string(), - "the model backend sent a reply that could not be understood: \ - completion stream ended without the [DONE] sentinel" - ); -} diff --git a/crates/harness-gateway-client/src/transport/tests/streaming.rs b/crates/harness-gateway-client/src/transport/tests/streaming.rs deleted file mode 100644 index 85cb312c3..000000000 --- a/crates/harness-gateway-client/src/transport/tests/streaming.rs +++ /dev/null @@ -1,320 +0,0 @@ -//! The streamed round end to end: what goes on the wire, and how the -//! stream comes back as one completion. - -use runvil::model::{CompletionResult, Message}; -use runvil::{ErrorCode, ErrorFact}; - -use super::*; -use crate::StreamDelta; - -#[tokio::test] -async fn complete_sends_completion_options_and_stream_flags_on_the_wire() { - use std::sync::{Arc, Mutex}; - - use axum::Router; - use axum::extract::Json; - use axum::routing::post; - - let captured: Arc>> = Arc::new(Mutex::new(None)); - let slot = Arc::clone(&captured); - let app = Router::new().route( - "/v1/chat/completions", - post(move |Json(body): Json| { - let slot = Arc::clone(&slot); - async move { - *slot.lock().expect("capture lock") = Some(body); - ( - [(axum::http::header::CONTENT_TYPE, "text/event-stream")], - ok_stream(), - ) - } - }), - ); - let client = client_for(app).await; - let options = CompletionOptions::new("analyst") - .with_temperature(0.0) - .expect("0.0 is valid") - .with_max_tokens(std::num::NonZeroU32::new(128).expect("128 is non-zero")) - .with_thinking(false); - client - .complete(&[Message::user("hi")], None, &options, |_| {}) - .await - .unwrap(); - let body = captured.lock().expect("capture lock").clone().unwrap(); - assert_eq!(body["model"], "analyst"); - assert_eq!(body["temperature"], 0.0); - assert_eq!(body["max_tokens"], 128); - assert_eq!(body["chat_template_kwargs"]["enable_thinking"], false); - // The one completion method always streams and always asks for the - // final usage chunk. - assert_eq!(body["stream"], true); - assert_eq!(body["stream_options"]["include_usage"], true); -} - -#[tokio::test] -async fn complete_hard_fails_on_empty_model_reply() { - // A stream that holds only reasoning and a stop finish has no - // product; the accumulated turn must fail exactly like the buffered - // equivalent, with the finish_reason surviving. - let client = sse_client(sse_body(&[ - serde_json::json!({ "choices": [{ "index": 0, - "delta": { "reasoning_content": "ignored" } }] }), - serde_json::json!({ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] }), - ])) - .await; - let err = client - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect_err("empty product must fail"); - assert_eq!(err.code, ErrorCode::EmptyReply); - assert_eq!( - err.facts - .get(&ErrorFact::FinishReason) - .and_then(Value::as_str), - Some("stop"), - "the finish_reason must survive the conversion into the error" - ); - assert_eq!( - err.to_string(), - "the model replied with no text and no tool calls: \ - reasoning content was present but ignored" - ); - assert_eq!( - err.message, err.prose, - "the specific is ours, not provider text" - ); -} - -#[tokio::test] -async fn streamed_text_usage_timings_and_client_timing_accumulate() { - // The llama.cpp streamed shape: content fragments, a finish chunk, and - // the include_usage summary chunk holding usage plus timings. The - // accumulated completion must match the buffered equivalent while the - // deltas reach the callback in order, and the client's own clock must - // populate ClientTiming. - let client = sse_client(sse_body(&[ - content_chunk("Hel"), - content_chunk("lo!"), - serde_json::json!({ - "model": "qwen3-30b", - "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] - }), - serde_json::json!({ - "model": "qwen3-30b", - "choices": [], - "usage": { "completion_tokens": 3, "prompt_tokens": 7, "total_tokens": 10 }, - "timings": { - "prompt_n": 7, "prompt_ms": 12.5, "prompt_per_second": 560.0, - "predicted_n": 3, "predicted_ms": 30.5, "predicted_per_second": 98.5 - } - }), - ])) - .await; - let seen = std::sync::Mutex::new(Vec::new()); - let completion = client - .complete(&[Message::user("hi")], None, &openai_options(), |delta| { - seen.lock().expect("delta log").push(delta); - }) - .await - .expect("a streamed text turn completes"); - match completion.result() { - CompletionResult::Text(text) => assert_eq!(text, "Hello!"), - other => panic!("expected text, got {other:?}"), - } - assert_eq!( - *seen.lock().expect("delta log"), - vec![ - StreamDelta::Text("Hel".to_owned()), - StreamDelta::Text("lo!".to_owned()), - ], - "each content fragment reaches the callback live, in order" - ); - assert_eq!(completion.finish_reason(), Some("stop")); - assert_eq!( - completion.model(), - "m", - "the label is the requested model, not the body's `qwen3-30b`" - ); - let metrics = completion.metrics().expect("the round reported metrics"); - let usage = metrics.usage.as_ref().expect("usage from the final chunk"); - assert_eq!(usage.total_tokens, 10); - let timings = metrics - .llama - .as_ref() - .expect("timings from the final chunk"); - assert_eq!(timings.predicted_n, 3); - assert_eq!(metrics.vllm, None, "llama.cpp reports no vLLM section"); - let timing = metrics - .client - .as_ref() - .expect("the streaming transport measures its own clock"); - assert!( - timing.ttft_ms.is_some_and(|ttft| ttft >= 0.0), - "TTFT is measured once the first delta arrives: {timing:?}" - ); - assert!( - timing.mean_itl_ms.is_some_and(|itl| itl >= 0.0), - "mean ITL is measured with two delta chunks: {timing:?}" - ); - assert!(timing.e2e_ms >= 0.0); - let raw = completion - .raw() - .expect("the HTTP path attaches the raw exchange for debug capture"); - assert_eq!(raw.request()["messages"][0]["content"], "hi"); - assert_eq!(raw.request()["stream"], true); - assert_eq!( - raw.response()["choices"][0]["message"]["content"], - "Hello!", - "the response is the reassembled buffered body" - ); -} - -#[tokio::test] -async fn streamed_reasoning_stays_a_side_channel() { - let client = sse_client(sse_body(&[ - serde_json::json!({ "choices": [{ "index": 0, - "delta": { "reasoning_content": "scratch" } }] }), - content_chunk("answer"), - serde_json::json!({ - "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] - }), - ])) - .await; - let seen = std::sync::Mutex::new(Vec::new()); - let completion = client - .complete(&[Message::user("hi")], None, &openai_options(), |delta| { - seen.lock().expect("delta log").push(delta); - }) - .await - .expect("reasoning plus text completes"); - match completion.result() { - CompletionResult::Text(text) => { - assert_eq!( - text, "answer", - "reasoning is never promoted into the answer" - ); - } - other => panic!("expected text, got {other:?}"), - } - assert_eq!(completion.reasoning_content(), Some("scratch")); - assert_eq!( - *seen.lock().expect("delta log"), - vec![ - StreamDelta::Reasoning("scratch".to_owned()), - StreamDelta::Text("answer".to_owned()), - ], - "reasoning and text deltas arrive separated" - ); -} - -#[tokio::test] -async fn streamed_tool_call_fragments_reassemble_into_the_batch() { - let client = sse_client(sse_body(&[ - serde_json::json!({ "choices": [{ "index": 0, "delta": { "tool_calls": [{ - "index": 0, "id": "call_1", "type": "function", - "function": { "name": "web_search", "arguments": "{\"qu" } - }] } }] }), - serde_json::json!({ "choices": [{ "index": 0, "delta": { "tool_calls": [{ - "index": 0, "function": { "arguments": "ery\":\"rust\"}" } - }] } }] }), - serde_json::json!({ - "choices": [{ "index": 0, "delta": {}, "finish_reason": "tool_calls" }] - }), - ])) - .await; - let completion = client - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect("a streamed tool-call turn completes"); - match completion.result() { - CompletionResult::ToolCalls(calls) => { - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].id(), "call_1"); - assert_eq!(calls[0].name(), "web_search"); - assert_eq!( - calls[0].arguments().to_json_string(), - "{\"query\":\"rust\"}", - "argument fragments buffer until the batch is whole" - ); - } - other => panic!("expected tool calls, got {other:?}"), - } -} - -#[tokio::test] -async fn truncated_tool_call_batch_fails_the_completion() { - // A length or content_filter finish with tool calls means the batch may - // hold partial JSON arguments; the whole batch fails rather than - // executing a fragment. - for reason in ["length", "content_filter"] { - let client = sse_client(sse_body(&[ - serde_json::json!({ "choices": [{ "index": 0, "delta": { "tool_calls": [{ - "index": 0, "id": "c1", "type": "function", - "function": { "name": "t", "arguments": "{\"whole\":true}" } - }] } }] }), - serde_json::json!({ - "choices": [{ "index": 0, "delta": {}, "finish_reason": reason }] - }), - ])) - .await; - let err = client - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect_err("a truncated tool-call batch must fail"); - assert_eq!( - err.code, - ErrorCode::MalformedResponse, - "finish_reason {reason:?}" - ); - assert!( - err.to_string().contains("truncated"), - "the error's message names the truncation: {err:?}" - ); - } -} - -#[tokio::test] -async fn truncated_text_still_returns_with_its_finish_reason() { - // The truncation rule fails tool-call batches only: partial TEXT is - // returned with finish_reason "length" so the caller can report it. - let client = sse_client(sse_body(&[ - content_chunk("partial answ"), - serde_json::json!({ - "choices": [{ "index": 0, "delta": {}, "finish_reason": "length" }] - }), - ])) - .await; - let completion = client - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect("truncated text is still a product"); - match completion.result() { - CompletionResult::Text(text) => assert_eq!(text, "partial answ"), - other => panic!("expected text, got {other:?}"), - } - assert_eq!(completion.finish_reason(), Some("length")); -} - -#[tokio::test] -async fn mid_stream_error_envelope_is_a_transport_failure() { - // The gateway relays a mid-flight failure as a data: error envelope on - // an already-open 200 stream; the completion classifies it as a - // transport failure, never as model output. - let client = sse_client(sse_body(&[ - content_chunk("par"), - serde_json::json!({ "error": { - "message": "upstream died", "type": "upstream", "code": "upstream_transport" - } }), - ])) - .await; - let err = client - .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) - .await - .expect_err("an error envelope must fail the completion"); - assert_eq!(err.code, ErrorCode::Transport); - assert_eq!(err.prose, "the connection to the model backend failed"); - assert_eq!( - err.message, - "the connection to the model backend failed: upstream died" - ); -} diff --git a/crates/harness-gateway-client/src/wire.rs b/crates/harness-gateway-client/src/wire.rs deleted file mode 100644 index f7aeaf35f..000000000 --- a/crates/harness-gateway-client/src/wire.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! The OpenAI chat-completions wire code: the request body builder, the -//! read loop over a caller's chunk source and the live pieces it hands -//! out, the SSE reassembly and the strict turn parse it finishes through, -//! and the failure classifier. -//! -//! The scanner and accumulator in [`stream`] and the body walk in [`parse`] -//! stay crate-private, reached only through the read loop, so every -//! completion this crate returns passed the one byte cap, the one -//! `[DONE]` rule, and the one turn rule set. - -pub(crate) mod classify; -pub(crate) mod delta; -mod parse; -pub(crate) mod read; -pub(crate) mod request; -pub(crate) mod stream; diff --git a/crates/harness-gateway-client/src/wire/classify-tests.rs b/crates/harness-gateway-client/src/wire/classify-tests.rs deleted file mode 100644 index 5e6726589..000000000 --- a/crates/harness-gateway-client/src/wire/classify-tests.rs +++ /dev/null @@ -1,387 +0,0 @@ -//! Tests for the HTTP failure classifier: one per rule, per overflow -//! phrase, per token-count form, the in-stream envelope, and a body that -//! matches nothing. - -use runvil::model::phrase; -use runvil::{Error, ErrorCode, ErrorFact}; - -use super::{make_classify_http_failure_error, make_classify_stream_error}; - -fn code_of(status: u16, body: &str) -> ErrorCode { - make_classify_http_failure_error(status, body).code -} - -/// The `(prompt_tokens, window)` counts an overflow names as its `Count` -/// and `Limit` facts. -fn overflow(error: &Error) -> (Option, Option) { - let count = |key: ErrorFact| { - error - .facts - .get(&key) - .and_then(serde_json::Value::as_u64) - .and_then(|count| u32::try_from(count).ok()) - }; - (count(ErrorFact::Count), count(ErrorFact::Limit)) -} - -#[test] -fn every_overflow_phrase_classifies_a_400_as_context_overflow() { - let phrases = [ - "context length", - "context window", - "context size", - "context_length_exceeded", - "maximum context length", - "prompt is too long", - "too many tokens", - "exceeds the available context size", - "exceed_context_size", - "input is too long", - "exceeds the maximum number of tokens", - "too large for model", - ]; - assert_eq!(phrases.len(), 12, "the plan lists twelve phrases"); - for phrase in phrases { - let body = format!("{{\"error\":\"the request hit a limit: {phrase} reached\"}}"); - assert_eq!( - code_of(400, &body), - ErrorCode::ContextOverflow, - "400 with {phrase:?}" - ); - assert_eq!( - code_of(413, &body), - ErrorCode::ContextOverflow, - "413 with {phrase:?}" - ); - } -} - -#[test] -fn overflow_phrases_match_case_insensitively() { - assert_eq!( - code_of(400, "CONTEXT WINDOW exceeded"), - ErrorCode::ContextOverflow - ); - assert_eq!( - code_of(413, "Prompt Is Too Long"), - ErrorCode::ContextOverflow - ); -} - -#[test] -fn provider_overflow_detection_matches_known_signatures() { - let cases: &[(u16, &str, ErrorCode)] = &[ - ( - 400, - "This model's maximum context length is 4096 tokens.", - ErrorCode::ContextOverflow, - ), - (400, "context_length_exceeded", ErrorCode::ContextOverflow), - ( - 400, - "the request exceeds the available context size", - ErrorCode::ContextOverflow, - ), - (413, "prompt is too long", ErrorCode::ContextOverflow), - (400, "CONTEXT WINDOW exceeded", ErrorCode::ContextOverflow), - (400, "too many tokens in prompt", ErrorCode::ContextOverflow), - // A server fault never classifies as an overflow, even with - // overflow wording. - ( - 500, - "maximum context length is 4096 tokens", - ErrorCode::ServerError, - ), - // A client rejection without overflow wording stays a plain - // rejection. - ( - 400, - "invalid request: unknown field `stream`", - ErrorCode::Rejected, - ), - // Credentials win over overflow wording outside 400 and 413. - (401, "context length", ErrorCode::Unavailable), - ]; - for (status, body, expected) in cases { - assert_eq!( - code_of(*status, body), - *expected, - "status {status} with body {body:?}" - ); - } -} - -#[test] -fn an_overflow_phrase_on_another_status_is_not_an_overflow() { - assert_eq!(code_of(422, "context length exceeded"), ErrorCode::Rejected); - assert_eq!(code_of(429, "context length"), ErrorCode::RateLimited); -} - -#[test] -fn the_openai_token_counts_are_read_from_the_message() { - let error = make_classify_http_failure_error( - 400, - "This model's maximum context length is 4096 tokens. However, your messages \ - resulted in 5120 tokens. Please reduce the length of the messages.", - ); - assert_eq!(error.code, ErrorCode::ContextOverflow); - assert_eq!(overflow(&error), (Some(5120), Some(4096))); -} - -#[test] -fn the_greater_than_maximum_token_counts_are_read_from_the_message() { - let error = - make_classify_http_failure_error(400, "prompt is too long: 205000 tokens > 200000 maximum"); - assert_eq!(error.code, ErrorCode::ContextOverflow); - assert_eq!(overflow(&error), (Some(205_000), Some(200_000))); -} - -#[test] -fn an_overflow_that_states_no_counts_leaves_them_unset() { - let error = make_classify_http_failure_error(400, "context_length_exceeded"); - assert_eq!(error.code, ErrorCode::ContextOverflow); - assert_eq!(overflow(&error), (None, None)); -} - -#[test] -fn a_count_too_large_for_u32_is_left_unset() { - let error = make_classify_http_failure_error( - 400, - "maximum context length is 99999999999 tokens, however you sent 5 tokens", - ); - assert_eq!(error.code, ErrorCode::ContextOverflow); - assert_eq!(overflow(&error), (Some(5), None)); -} - -#[test] -fn a_429_naming_quota_or_billing_is_quota_exhausted() { - for word in ["quota", "billing", "insufficient_quota", "credit", "QUOTA"] { - let body = format!("{{\"error\":\"your {word} has run out\"}}"); - assert_eq!( - code_of(429, &body), - ErrorCode::QuotaExhausted, - "429 with {word:?}" - ); - } -} - -#[test] -fn any_other_429_is_rate_limited() { - assert_eq!( - code_of(429, "slow down: too many requests"), - ErrorCode::RateLimited - ); - assert_eq!(code_of(429, "(empty body)"), ErrorCode::RateLimited); -} - -#[test] -fn a_503_or_529_is_overloaded() { - assert_eq!(code_of(503, "service unavailable"), ErrorCode::Overloaded); - assert_eq!(code_of(529, "busy"), ErrorCode::Overloaded); -} - -#[test] -fn a_5xx_naming_overloaded_is_overloaded() { - assert_eq!( - code_of(500, "{\"type\":\"overloaded_error\"}"), - ErrorCode::Overloaded - ); - assert_eq!( - code_of(502, "The server is OVERLOADED"), - ErrorCode::Overloaded - ); -} - -#[test] -fn every_other_5xx_is_a_server_error() { - for status in [500, 501, 502, 504, 599] { - assert_eq!( - code_of(status, "internal failure"), - ErrorCode::ServerError, - "status {status}" - ); - } -} - -#[test] -fn a_401_or_403_is_unavailable_with_the_credentials_phrase() { - for status in [401, 403] { - let error = make_classify_http_failure_error(status, "bad key"); - assert_eq!(error.code, ErrorCode::Unavailable); - assert_eq!( - error.prose, - format!("the model backend did not accept the credentials (status {status})") - ); - } -} - -#[test] -fn a_400_naming_a_content_policy_term_is_refused() { - for word in ["content_filter", "content policy", "safety", "refus"] { - let body = format!("{{\"error\":\"blocked by {word} rules\"}}"); - assert_eq!(code_of(400, &body), ErrorCode::Refused, "400 with {word:?}"); - } -} - -#[test] -fn a_400_naming_both_a_context_limit_and_a_safety_term_is_an_overflow() { - assert_eq!( - code_of(400, "context length exceeded; safety settings unchanged"), - ErrorCode::ContextOverflow - ); -} - -#[test] -fn a_content_policy_term_on_another_status_is_rejected() { - assert_eq!(code_of(422, "content_filter"), ErrorCode::Rejected); -} - -#[test] -fn a_body_that_matches_no_rule_is_rejected_never_a_success() { - for (status, body) in [ - (400, "invalid request: unknown field `stream`"), - (404, "no such model"), - (422, "unprocessable"), - (418, "(empty body)"), - (302, "moved"), - (200, "ok"), - ] { - assert_eq!( - code_of(status, body), - ErrorCode::Rejected, - "status {status} with body {body:?}" - ); - } -} - -#[test] -fn the_prose_names_the_code_and_the_status_and_the_message_adds_the_body() { - let error = make_classify_http_failure_error(503, "upstream "); - assert_eq!(error.prose, "the model backend is overloaded (status 503)"); - assert_eq!( - error.message, - "the model backend is overloaded (status 503): upstream " - ); - assert!( - !error.prose.contains("upstream"), - "the prose never carries the body" - ); -} - -#[test] -fn each_http_code_carries_its_fixed_phrase_and_the_status_suffix() { - let cases: &[(u16, &str, &str)] = &[ - ( - 400, - "context length", - "the request is larger than the model's context window (status 400)", - ), - ( - 429, - "slow down", - "the model backend is limiting the request rate (status 429)", - ), - ( - 429, - "quota", - "the model backend says the usage quota is spent (status 429)", - ), - (503, "busy", "the model backend is overloaded (status 503)"), - ( - 400, - "content_filter", - "the model backend refused the request on content policy grounds (status 400)", - ), - ( - 500, - "boom", - "the model backend reported a fault of its own (status 500)", - ), - ( - 404, - "no model", - "the model backend rejected the request (status 404)", - ), - ( - 401, - "no key", - "the model backend did not accept the credentials (status 401)", - ), - ]; - for (status, body, prose) in cases { - let error = make_classify_http_failure_error(*status, body); - assert_eq!(error.prose, *prose, "status {status}, {body:?}"); - assert_eq!(error.message, format!("{prose}: {body}")); - } -} - -#[test] -fn a_429_is_rate_limited_unless_its_body_names_a_spent_quota() { - assert_eq!( - make_classify_http_failure_error(429, "slow down").code, - ErrorCode::RateLimited - ); - assert_eq!( - make_classify_http_failure_error(429, "insufficient_quota").code, - ErrorCode::QuotaExhausted - ); -} - -#[test] -fn provider_text_stays_in_the_message_and_out_of_the_prose() { - let error = make_classify_http_failure_error(503, "upstream "); - assert!(error.prose.starts_with(phrase(ErrorCode::Overloaded))); - assert!(!error.prose.contains("upstream")); - assert!(error.message.ends_with(": upstream ")); -} - -#[test] -fn the_classifier_keeps_only_the_body_it_was_given() { - let body = "x".repeat(2000); - let error = make_classify_http_failure_error(400, &body); - assert_eq!(error.message, format!("{}: {body}", error.prose)); -} - -#[test] -fn an_in_stream_envelope_naming_a_context_limit_is_an_overflow_without_a_status() { - let error = make_classify_stream_error( - "This model's maximum context length is 4096 tokens, however you sent 5000 tokens", - ); - assert_eq!(error.code, ErrorCode::ContextOverflow); - assert_eq!(overflow(&error), (Some(5000), Some(4096))); - assert_eq!( - error.prose, - "the request is larger than the model's context window" - ); - assert!( - !error.prose.contains("status"), - "an in-stream envelope carries no status" - ); -} - -#[test] -fn an_in_stream_envelope_applies_the_other_body_text_rules() { - assert_eq!( - make_classify_stream_error("overloaded_error: try again").code, - ErrorCode::Overloaded - ); - assert_eq!( - make_classify_stream_error("insufficient_quota").code, - ErrorCode::QuotaExhausted - ); - assert_eq!( - make_classify_stream_error("blocked by content_filter").code, - ErrorCode::Refused - ); -} - -#[test] -fn an_in_stream_envelope_that_matches_no_rule_stays_a_transport_failure() { - let error = make_classify_stream_error("upstream\\ndied"); - assert_eq!(error.code, ErrorCode::Transport); - assert_eq!(error.prose, "the connection to the model backend failed"); - assert_eq!( - error.message, - "the connection to the model backend failed: upstream\\ndied" - ); -} diff --git a/crates/harness-gateway-client/src/wire/classify.rs b/crates/harness-gateway-client/src/wire/classify.rs deleted file mode 100644 index 22a2e18db..000000000 --- a/crates/harness-gateway-client/src/wire/classify.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! The HTTP failure classifier: the one place that reads a status code and -//! response text and turns them into a model [`ErrorCode`]. -//! -//! [`make_classify_http_failure_error`] takes a non-success status and its bounded, -//! control-escaped body. [`make_classify_stream_error`] applies the same body-text -//! rules to an error envelope that arrives inside a 200 stream, where there -//! is no status. Both append the body to the error's message and never put -//! it in the prose. -//! -//! The rules run in this order, and the first match wins: -//! -//! 1. A 400 or 413 whose body names a context limit is `ContextOverflow`. -//! 2. A 429 whose body names quota or billing is `QuotaExhausted`; every -//! other 429 is `RateLimited`. -//! 3. A 503 or 529, or any 5xx whose body names `overloaded`, is -//! `Overloaded`; every other 5xx is `ServerError`. -//! 4. A 401 or 403 is `Unavailable`. -//! 5. A 400 whose body names a content policy term is `Refused`. -//! 6. Every other status is `Rejected`. - -use runvil::model::{make_completion_error, make_context_overflow_error, phrase}; -use runvil::{Error, ErrorCode}; - -use crate::failure::with_detail; - -/// Body phrases the known backends emit when a request exceeds the model's -/// context window (OpenAI and compatible gateways, Anthropic, llama.cpp, -/// vLLM), matched case-insensitively. -const OVERFLOW_PHRASES: &[&str] = &[ - "context length", - "context window", - "context size", - "context_length_exceeded", - "maximum context length", - "prompt is too long", - "too many tokens", - "exceeds the available context size", - "exceed_context_size", - "input is too long", - "exceeds the maximum number of tokens", - "too large for model", -]; - -/// Body words that mark a 429 as a spent quota rather than a rate limit. -const QUOTA_WORDS: &[&str] = &["quota", "billing", "insufficient_quota", "credit"]; - -/// Body words that mark a refusal on content policy grounds. -const REFUSAL_WORDS: &[&str] = &["content_filter", "content policy", "safety", "refus"]; - -/// The message for a 401 or 403, which says more than the generic -/// unavailable phrase. -const CREDENTIALS_PHRASE: &str = "the model backend did not accept the credentials"; - -/// Classifies a failed HTTP response into an [`Error`] under a model code. -/// -/// `body` must already be bounded and passed through -/// [`escape_controls`](crate::escape_controls). The error's message ends -/// with exactly that text after `: `. The prose never includes it. -/// -/// The prose is the code's fixed phrase with ` (status N)` appended. A 401 -/// or 403 is `Unavailable`, and its prose uses -/// `the model backend did not accept the credentials` in place of the -/// phrase. -/// -/// The result is always an error. When the body matches a rule, that rule -/// sets the code. Otherwise a 429 is `RateLimited`, a 503 or 529 is -/// `Overloaded`, any other 5xx is `ServerError`, and any other status except -/// 401 and 403 is `Rejected`. -#[must_use] -pub fn make_classify_http_failure_error(status: u16, body: &str) -> Error { - let lower = body.to_lowercase(); - let http = |code: ErrorCode| with_detail(make_http_error(code, phrase(code), status), body); - if matches!(status, 400 | 413) && names_any(&lower, OVERFLOW_PHRASES) { - let (prompt_tokens, window) = overflow_counts(&lower); - let overflow = make_context_overflow_error( - prompt_tokens, - window, - format!("{} (status {status})", phrase(ErrorCode::ContextOverflow)), - ); - return with_detail(overflow, body); - } - match status { - 429 if names_any(&lower, QUOTA_WORDS) => http(ErrorCode::QuotaExhausted), - 429 => http(ErrorCode::RateLimited), - 503 | 529 => http(ErrorCode::Overloaded), - 500..=599 if lower.contains("overloaded") => http(ErrorCode::Overloaded), - 500..=599 => http(ErrorCode::ServerError), - 401 | 403 => with_detail( - make_http_error(ErrorCode::Unavailable, CREDENTIALS_PHRASE, status), - body, - ), - 400 if names_any(&lower, REFUSAL_WORDS) => http(ErrorCode::Refused), - _ => http(ErrorCode::Rejected), - } -} - -/// Classifies an error envelope that arrives inside a 200 response stream. -/// -/// The envelope carries body text alone, so only the body-text rules apply, -/// in this order. Text that names a context limit is `ContextOverflow`. Text -/// that names quota or billing is `QuotaExhausted`. Text that contains -/// `overloaded` is `Overloaded`. Text that names a content policy term is -/// `Refused`. Any other text is `Transport`, because the stream died in -/// flight. -/// -/// The prose is the code's fixed phrase alone. `body` must already be -/// bounded and control-escaped. The error's message ends with it after -/// `: `. -#[must_use] -pub fn make_classify_stream_error(body: &str) -> Error { - let lower = body.to_lowercase(); - let error = if names_any(&lower, OVERFLOW_PHRASES) { - let (prompt_tokens, window) = overflow_counts(&lower); - make_context_overflow_error(prompt_tokens, window, phrase(ErrorCode::ContextOverflow)) - } else { - let code = if names_any(&lower, QUOTA_WORDS) { - ErrorCode::QuotaExhausted - } else if lower.contains("overloaded") { - ErrorCode::Overloaded - } else if names_any(&lower, REFUSAL_WORDS) { - ErrorCode::Refused - } else { - ErrorCode::Transport - }; - make_completion_error(code, phrase(code)) - }; - with_detail(error, body) -} - -/// Builds an HTTP failure: `phrase` with the status appended. -fn make_http_error(code: ErrorCode, phrase: &str, status: u16) -> Error { - make_completion_error(code, format!("{phrase} (status {status})")) -} - -fn names_any(lower: &str, words: &[&str]) -> bool { - words.iter().any(|word| lower.contains(word)) -} - -/// The prompt and window token counts a context-limit message states, each -/// `None` when the text does not give it. `lower` is the lowercased body. -/// -/// Two forms are read: "maximum context length is N tokens ... M tokens" -/// (window N, prompt M), and "N tokens > M maximum" (prompt N, window M). -fn overflow_counts(lower: &str) -> (Option, Option) { - if let Some(counts) = counts_from_maximum(lower) { - return counts; - } - counts_from_greater_than(lower).unwrap_or((None, None)) -} - -fn counts_from_maximum(lower: &str) -> Option<(Option, Option)> { - const LEAD: &str = "maximum context length is "; - let start = lower.find(LEAD)? + LEAD.len(); - let (window, rest) = leading_number(&lower[start..])?; - Some((first_count_before_tokens(rest), window)) -} - -fn counts_from_greater_than(lower: &str) -> Option<(Option, Option)> { - const MID: &str = " tokens > "; - let at = lower.find(MID)?; - let before = &lower[..at]; - let prompt = &before[before.trim_end_matches(|c: char| c.is_ascii_digit()).len()..]; - if prompt.is_empty() { - return None; - } - let (window, rest) = leading_number(&lower[at + MID.len()..])?; - rest.starts_with(" maximum") - .then(|| (prompt.parse().ok(), window)) -} - -/// Splits a leading run of ASCII digits off `text`. The number is `None` -/// when the run does not fit a `u32`; the whole function is `None` when -/// `text` starts with no digit. -fn leading_number(text: &str) -> Option<(Option, &str)> { - let end = text - .find(|c: char| !c.is_ascii_digit()) - .unwrap_or(text.len()); - if end == 0 { - return None; - } - Some((text[..end].parse().ok(), &text[end..])) -} - -/// The first number in `text` that is directly followed by " tokens". -fn first_count_before_tokens(text: &str) -> Option { - let mut rest = text; - while let Some(start) = rest.find(|c: char| c.is_ascii_digit()) { - let (number, after) = leading_number(&rest[start..])?; - if after.starts_with(" tokens") { - return number; - } - rest = after; - } - None -} - -#[cfg(test)] -#[path = "classify-tests.rs"] -mod tests; diff --git a/crates/harness-gateway-client/src/wire/delta.rs b/crates/harness-gateway-client/src/wire/delta.rs deleted file mode 100644 index 5c9586503..000000000 --- a/crates/harness-gateway-client/src/wire/delta.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! The live pieces of a streamed reply, as the read loop hands them out. - -/// One piece of a streamed model reply, delivered as it arrives. -/// -/// The client passes each piece to the caller's callback as soon as it -/// decodes the piece from the stream. Answer text and reasoning arrive as -/// separate variants, so a caller can show them differently. -/// -/// The client buffers tool-call fragments until the whole batch of tool -/// calls is complete and validated. They appear only in the finished -/// completion. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum StreamDelta { - /// A fragment of the assistant's answer text. - Text(String), - /// A fragment of the model's reasoning text. It stays separate from the - /// answer. - Reasoning(String), -} diff --git a/crates/harness-gateway-client/src/wire/parse-tests-metadata.rs b/crates/harness-gateway-client/src/wire/parse-tests-metadata.rs deleted file mode 100644 index ac36ac24f..000000000 --- a/crates/harness-gateway-client/src/wire/parse-tests-metadata.rs +++ /dev/null @@ -1,276 +0,0 @@ -//! Tests for the lenient call-metadata parse and its degrade policy. - -use super::*; - -/// Parses the metadata and counts its diagnostics, so the tests can pin -/// both halves of the degrade policy: malformed sections report, and -/// well-formed or absent sections stay silent. -fn with_warn_count(body: &Value) -> (ResponseMetadata, usize) { - let metadata = response_metadata(body); - let warnings = metadata.diagnostics.len(); - (metadata, warnings) -} - -/// One assistant text choice, shared by the metadata fixture bodies. -fn reply_choice() -> Value { - serde_json::json!([{ - "index": 0, - "message": { "role": "assistant", "content": "hi" }, - "finish_reason": "stop" - }]) -} - -#[test] -fn llama_body_parses_model_usage_and_timings() { - let body = serde_json::json!({ - "id": "chatcmpl-llama", - "object": "chat.completion", - "created": 1_726_000_000_u64, - "model": "qwen3-30b", - "choices": reply_choice(), - "usage": { "completion_tokens": 3, "prompt_tokens": 7, "total_tokens": 10 }, - "timings": { - "prompt_n": 7, - "prompt_ms": 12.5, - "prompt_per_token_ms": 1.75, - "prompt_per_second": 560.0, - "predicted_n": 3, - "predicted_ms": 30.5, - "predicted_per_token_ms": 10.25, - "predicted_per_second": 98.5, - "draft_n": 4, - "draft_n_accepted": 2 - } - }); - - let (metadata, warnings) = with_warn_count(&body); - assert_eq!(warnings, 0, "a well-formed body must not warn"); - assert_eq!(metadata.model, "qwen3-30b"); - assert_eq!( - metadata.metrics.usage, - Some(Usage::new(7, 3, 10)), - "flat llama.cpp usage reports the token counts only" - ); - assert_eq!( - metadata.metrics.llama, - Some( - LlamaTimings::new(7, 12.5, 560.0, 3, 30.5, 98.5) - .with_draft_n(4) - .with_draft_n_accepted(2) - ) - ); - assert_eq!(metadata.metrics.vllm, None); - assert_eq!( - metadata.metrics.client, None, - "only the transport's own clock fills the client section" - ); -} - -#[test] -fn llama_timings_without_draft_counters_default_to_zero() { - // Without a configured draft model llama.cpp omits the draft - // counters entirely; zero drafted tokens is the truthful reading, - // so the common non-speculative body must not degrade to None. - let body = serde_json::json!({ - "model": "qwen3-30b", - "choices": reply_choice(), - "timings": { - "prompt_n": 7, - "prompt_ms": 12.5, - "prompt_per_second": 560.0, - "predicted_n": 3, - "predicted_ms": 30.5, - "predicted_per_second": 98.5 - } - }); - - let (metadata, warnings) = with_warn_count(&body); - assert_eq!(warnings, 0); - let timings = metadata.metrics.llama.unwrap(); - assert_eq!(timings.draft_n, 0); - assert_eq!(timings.draft_n_accepted, 0); -} - -#[test] -fn vllm_body_parses_metrics_and_cached_tokens() { - let body = serde_json::json!({ - "id": "chatcmpl-vllm", - "object": "chat.completion", - "model": "meta-llama/Llama-3.1-8B-Instruct", - "choices": reply_choice(), - "usage": { - "prompt_tokens": 20, - "completion_tokens": 5, - "total_tokens": 25, - "prompt_tokens_details": { "cached_tokens": 16 } - }, - "metrics": { - "time_to_first_token_ms": 8.5, - "generation_time_ms": 22.5, - "queue_time_ms": 1.5, - "mean_itl_ms": 7.5, - "tokens_per_second": 133.5 - } - }); - - let (metadata, warnings) = with_warn_count(&body); - assert_eq!(warnings, 0, "a well-formed body must not warn"); - assert_eq!(metadata.model, "meta-llama/Llama-3.1-8B-Instruct"); - assert_eq!( - metadata.metrics.usage, - Some(Usage::new(20, 5, 25).with_cached_tokens(Some(16))), - "the prompt_tokens_details cache detail must flatten into usage" - ); - assert_eq!(metadata.metrics.llama, None); - assert_eq!( - metadata.metrics.vllm, - Some( - VllmMetrics::new() - .with_time_to_first_token_ms(Some(8.5)) - .with_generation_time_ms(Some(22.5)) - .with_queue_time_ms(Some(1.5)) - .with_mean_itl_ms(Some(7.5)) - .with_tokens_per_second(Some(133.5)) - ) - ); -} - -#[test] -fn vllm_metrics_omit_what_was_not_measured() { - let body = serde_json::json!({ - "model": "m", - "choices": reply_choice(), - "metrics": { "time_to_first_token_ms": 8.5 } - }); - - let (metadata, warnings) = with_warn_count(&body); - assert_eq!(warnings, 0); - assert_eq!( - metadata.metrics.vllm, - Some(VllmMetrics::new().with_time_to_first_token_ms(Some(8.5))), - "fields vLLM did not measure stay None inside a parsed section" - ); -} - -#[test] -fn frontier_body_parses_usage_detail_fields() { - let body = serde_json::json!({ - "id": "chatcmpl-frontier", - "object": "chat.completion", - "model": "gpt-5.2", - "choices": reply_choice(), - "usage": { - "prompt_tokens": 100, - "completion_tokens": 40, - "total_tokens": 140, - "prompt_tokens_details": { "cached_tokens": 64, "audio_tokens": 0 }, - "completion_tokens_details": { - "reasoning_tokens": 25, - "audio_tokens": 0, - "accepted_prediction_tokens": 0, - "rejected_prediction_tokens": 0 - } - } - }); - - let (metadata, warnings) = with_warn_count(&body); - assert_eq!(warnings, 0, "a well-formed body must not warn"); - assert_eq!(metadata.model, "gpt-5.2"); - assert_eq!( - metadata.metrics.usage, - Some( - Usage::new(100, 40, 140) - .with_cached_tokens(Some(64)) - .with_reasoning_tokens(Some(25)) - ) - ); - assert_eq!( - metadata.metrics.llama, None, - "frontier bodies have no timings" - ); - assert_eq!( - metadata.metrics.vllm, None, - "frontier bodies have no metrics" - ); -} - -#[test] -fn absent_metadata_sections_are_none_without_warning() { - let bare = serde_json::json!({ "model": "m", "choices": reply_choice() }); - let with_nulls = serde_json::json!({ - "model": "m", - "choices": reply_choice(), - "usage": null, - "timings": null, - "metrics": null - }); - - for body in [bare, with_nulls] { - let (metadata, warnings) = with_warn_count(&body); - assert_eq!(warnings, 0, "absence is normal, never a warning: {body}"); - assert_eq!(metadata.model, "m"); - assert_eq!(metadata.metrics.usage, None); - assert_eq!(metadata.metrics.llama, None); - assert_eq!(metadata.metrics.vllm, None); - } -} - -#[test] -fn malformed_metadata_degrades_to_none_with_a_warning() { - // The deliberate degrade path: every section malformed at once, each - // one warning and dropping to None, and the call still succeeds. - let body = serde_json::json!({ - "model": 7, - "choices": reply_choice(), - "usage": { "prompt_tokens": "seven" }, - "timings": { "prompt_n": 7 }, - "metrics": ["not", "an", "object"] - }); - - let (metadata, warnings) = with_warn_count(&body); - assert_eq!(metadata.model, "", "a non-string model records as empty"); - assert_eq!( - metadata.metrics.usage, None, - "non-numeric token counts degrade" - ); - assert_eq!( - metadata.metrics.llama, None, - "timings missing required fields degrade" - ); - assert_eq!(metadata.metrics.vllm, None, "a non-object metrics degrades"); - assert_eq!(warnings, 4, "each malformed section warns exactly once"); -} - -#[test] -fn metadata_sections_degrade_independently() { - let body = serde_json::json!({ - "model": "qwen3-30b", - "choices": reply_choice(), - "usage": "broken", - "timings": { - "prompt_n": 7, - "prompt_ms": 12.5, - "prompt_per_second": 560.0, - "predicted_n": 3, - "predicted_ms": 30.5, - "predicted_per_second": 98.5 - } - }); - - let (metadata, warnings) = with_warn_count(&body); - assert_eq!(warnings, 1, "only the broken section warns"); - assert_eq!(metadata.metrics.usage, None); - assert!( - metadata.metrics.llama.is_some(), - "a malformed sibling section must not take timings down with it" - ); -} - -#[test] -fn missing_model_records_empty_and_warns() { - let body = serde_json::json!({ "choices": reply_choice() }); - - let (metadata, warnings) = with_warn_count(&body); - assert_eq!(metadata.model, ""); - assert_eq!(warnings, 1, "an OpenAI-shaped body without a model warns"); -} diff --git a/crates/harness-gateway-client/src/wire/parse.rs b/crates/harness-gateway-client/src/wire/parse.rs deleted file mode 100644 index 5c4a3bc0d..000000000 --- a/crates/harness-gateway-client/src/wire/parse.rs +++ /dev/null @@ -1,817 +0,0 @@ -//! Parse OpenAI-shaped chat-completions JSON into a turn outcome. -//! -//! The wire canonicalization and the empty-response invariant live here so the -//! rest of the runtime can stay model-agnostic. A normalized turn must yield -//! either non-empty tool calls or non-empty text; anything else is -//! an `EmptyReply`-kind failure, holding the choice's `finish_reason` so the -//! tool loop can classify the empty turn. The loop may still accept such a -//! turn as its clean exit - empty text with `finish_reason == "stop"` after -//! at least one successful tool dispatch - but normalization always raises -//! and lets the loop decide. Reasoning fields are a side channel only and -//! are never promoted into the answer. -//! -//! Each parsed call is built with [`ToolCall::from_parts`], so the Engine's -//! neutral checks (blank id, blank name, non-object arguments) run on every -//! decoded call; the duplicate-id check runs when the read loop builds the -//! [`Completion`](runvil::model::Completion) from the batch. Either -//! refusal of decoded parts is reported as a `MalformedResponse`. -//! -//! Beside the strict turn parse, [`response_metadata`] leniently parses the -//! body's call metadata - the serving `model`, `usage` token accounting, -//! llama.cpp's `timings` extension, and vLLM's `metrics` extension - into the -//! canonical `runvil::metrics` vocabulary. Metadata never fails a -//! completion: a malformed section degrades to `None` with a returned -//! diagnostic naming it. - -use runvil::metrics::{CallMetrics, LlamaTimings, Usage, VllmMetrics}; -use runvil::model::{CompletionResult, ToolCall, make_empty_reply_error}; -use runvil::{Error, ErrorFact}; -use serde::Deserialize; -use serde_json::Value; - -use crate::failure::{decoded, make_malformed_error}; - -/// A parsed assistant turn: outcome plus payload-free metadata. -#[derive(Debug, Clone, PartialEq)] -pub(super) struct NormalizedTurn { - /// The text or tool-call product the tool loop consumes. - pub(super) outcome: CompletionResult, - /// The choice's `finish_reason`, when the backend supplied one. - pub(super) finish_reason: Option, - /// Reasoning text from the wire, never used as the answer. - pub(super) reasoning_content: Option, -} - -/// The shared per-turn context extracted from a chat-completions body: the -/// first choice's `message`, `finish_reason`, and reasoning side channel. -struct TurnContext<'a> { - /// The first choice's `message` object. - message: &'a Value, - /// The choice's `finish_reason`, when the backend supplied a string one. - finish_reason: Option, - /// Reasoning side-channel text, never promoted into the answer. - reasoning_content: Option, -} - -/// Extracts and shape-validates the first choice's per-turn context. -/// -/// # Errors -/// Returns a `MalformedResponse`-kind failure when `choices` is missing or not a -/// non-empty array of objects, `finish_reason` is a present non-string, -/// `message` is missing or not an object, or a reasoning field has the wrong -/// type. -fn turn_context(body: &Value) -> Result, Error> { - let choices = match body.get("choices") { - None => return Err(make_malformed_error("no choices in response")), - Some(Value::Array(choices)) => choices, - Some(_) => { - return Err(make_malformed_error( - "`choices` was present but not an array", - )); - } - }; - let choice = choices - .first() - .ok_or_else(|| make_malformed_error("response had zero choices"))?; - if !choice.is_object() { - return Err(make_malformed_error("`choices[0]` was not an object")); - } - let finish_reason = match choice.get("finish_reason") { - None | Some(Value::Null) => None, - Some(Value::String(reason)) => Some(reason.clone()), - Some(_) => { - return Err(make_malformed_error( - "`finish_reason` was present but not a string", - )); - } - }; - let message = choice - .get("message") - .ok_or_else(|| make_malformed_error("choice had no message"))?; - if !message.is_object() { - return Err(make_malformed_error( - "`message` was present but not an object", - )); - } - let reasoning_content = extract_reasoning(message)?; - Ok(TurnContext { - message, - finish_reason, - reasoning_content, - }) -} - -/// Turns a chat-completions response body into a [`NormalizedTurn`]. -/// -/// # Errors -/// Returns a `MalformedResponse`-kind failure when the body has no usable -/// choice shape, and an `EmptyReply`-kind one when the choice has neither -/// non-empty tool calls nor non-empty text. -pub(super) fn normalize(body: &Value) -> Result { - let TurnContext { - message, - finish_reason, - reasoning_content, - } = turn_context(body)?; - - // `tool_calls`, when present, must be an array; a present non-array is a - // malformed shape, not an absence. - let tool_calls = match message.get("tool_calls") { - None | Some(Value::Null) => None, - Some(Value::Array(calls)) => Some(calls), - Some(_) => { - return Err(make_malformed_error( - "`tool_calls` was present but not an array", - )); - } - }; - if let Some(raw_calls) = tool_calls.filter(|calls| !calls.is_empty()) { - let calls = parse_openai_tool_calls(raw_calls)?; - return Ok(NormalizedTurn { - outcome: CompletionResult::ToolCalls(calls), - finish_reason, - reasoning_content, - }); - } - - // `content`, when present, must be a string or JSON null; a present - // value of any other type is a malformed shape. - let content = match message.get("content") { - None | Some(Value::Null) => None, - Some(Value::String(text)) => Some(text.as_str()), - Some(_) => { - return Err(make_malformed_error( - "`content` was present but not a string", - )); - } - }; - // Whitespace-only content is not a product; classify with `trim`, but - // preserve the original nonblank payload verbatim. - if let Some(text) = content.filter(|text| !text.trim().is_empty()) { - return Ok(NormalizedTurn { - outcome: CompletionResult::Text(text.to_string()), - finish_reason, - reasoning_content, - }); - } - - let mut error = make_empty_reply_error(reasoning_content.is_some()); - if let Some(reason) = finish_reason { - error - .facts - .insert(ErrorFact::FinishReason, Value::from(reason)); - } - Err(error) -} - -/// Parses the OpenAI `message.tool_calls` array into [`ToolCall`]s. -/// -/// Each call must be an object with `"type": "function"`, a string `id`, -/// and an object `function` containing a string `name` and an `arguments` -/// field that is present, a JSON-encoded string, and valid JSON. Missing or -/// null arguments are rejected rather than coerced. [`ToolCall::from_parts`] -/// then refuses a blank id, a blank name, and arguments that do not decode -/// to an object. -fn parse_openai_tool_calls(raw_calls: &[Value]) -> Result, Error> { - let mut calls = Vec::with_capacity(raw_calls.len()); - for raw in raw_calls { - if !raw.is_object() { - return Err(make_malformed_error("tool call was not an object")); - } - // `type` must be present and name a function call: the OpenAI protocol - // invariant requires `"type": "function"`, so a missing, null, - // non-string, or other value is a malformed shape, not an absence. - match raw.get("type") { - Some(Value::String(kind)) if kind == "function" => {} - _ => { - return Err(make_malformed_error( - "tool call `type` must be the string \"function\"", - )); - } - } - let id = raw - .get("id") - .and_then(Value::as_str) - .ok_or_else(|| make_malformed_error("tool call had no string id"))?; - let function = raw - .get("function") - .ok_or_else(|| make_malformed_error("tool call had no function"))?; - if !function.is_object() { - return Err(make_malformed_error( - "tool call `function` was not an object", - )); - } - let name = function - .get("name") - .and_then(Value::as_str) - .ok_or_else(|| make_malformed_error("tool call had no string name"))?; - // OpenAI encodes `function.arguments` as a JSON string. It must be - // present, a string, and valid JSON; missing, null, non-string, and - // invalid-JSON values are all rejected rather than coerced. - let arguments = match function.get("arguments") { - Some(Value::String(raw_args)) => { - serde_json::from_str::(raw_args).map_err(|error| { - make_malformed_error(format!( - "tool call arguments were not valid JSON: {error}" - )) - })? - } - None | Some(Value::Null) => { - return Err(make_malformed_error("tool call arguments were missing")); - } - Some(_) => { - return Err(make_malformed_error( - "tool call arguments were not a JSON-encoded string", - )); - } - }; - calls.push(ToolCall::from_parts(id, name, arguments).map_err(decoded)?); - } - Ok(calls) -} - -/// First nonblank string among the known reasoning field synonyms. -/// -/// A reasoning synonym that is present but neither a string nor JSON null is a -/// malformed shape; whitespace-only strings are treated as absent. -/// -/// # Errors -/// Returns a `MalformedResponse`-kind failure when a present reasoning field is not a -/// string or null. -fn extract_reasoning(message: &Value) -> Result, Error> { - for key in ["reasoning_content", "reasoning", "thinking"] { - match message.get(key) { - None | Some(Value::Null) => {} - Some(Value::String(text)) => { - if !text.trim().is_empty() { - return Ok(Some(text.clone())); - } - } - Some(_) => { - return Err(make_malformed_error(format!( - "`{key}` reasoning field was present but not a string" - ))); - } - } - } - Ok(None) -} - -/// Call metadata parsed from a chat-completions body: the serving model and -/// every metrics family the backend reported. -/// -/// Parsing is infallible by design. The turn outcome has its own strict -/// parser ([`normalize`]); metadata must never fail a completion whose turn -/// was usable, so each family degrades to `None` independently. -#[derive(Debug, Clone, PartialEq)] -pub(super) struct ResponseMetadata { - /// The model that served the call, or empty when the body named none. - pub(super) model: String, - /// What the backend reported: `usage`, llama.cpp's `timings`, and vLLM's - /// `metrics`. The `client` section is always `None` here; only the - /// transport's own clock fills it. - pub(super) metrics: CallMetrics, - /// One line per section that was present but malformed and so - /// degraded to `None`, and one for a body naming no string `model`: - /// the Engine reports each as a `model_metadata_degraded` event, since - /// this crate reaches no logger. - pub(super) diagnostics: Vec, -} - -/// Parses the serving model and every metrics family from a response body. -/// -/// An absent or JSON-null section is `None` with no complaint; a present -/// section that does not parse degrades to `None` with a diagnostic naming -/// the section, so a backend with a broken metrics extension still -/// completes the call. -pub(super) fn response_metadata(body: &Value) -> ResponseMetadata { - let mut diagnostics = Vec::new(); - ResponseMetadata { - model: parse_model(body, &mut diagnostics), - metrics: CallMetrics::new() - .with_usage(parse_section(body, "usage", parse_usage, &mut diagnostics)) - .with_llama(parse_section( - body, - "timings", - parse_llama_timings, - &mut diagnostics, - )) - .with_vllm(parse_section( - body, - "metrics", - parse_vllm_metrics, - &mut diagnostics, - )), - diagnostics, - } -} - -/// The serving model from the body's top-level `model` field. -/// -/// Every backend that speaks this protocol names the model in its response, -/// so a missing or non-string value is anomalous: it records an empty -/// string and a diagnostic, never fails the call. The stream's finish -/// labels the completion with the model the request named either way, so -/// the diagnostic says the label came from the request. -fn parse_model(body: &Value, diagnostics: &mut Vec) -> String { - if let Some(Value::String(model)) = body.get("model") { - model.clone() - } else { - diagnostics.push( - "completion response named no string `model`; labeled with the requested model" - .to_owned(), - ); - String::new() - } -} - -/// Parses one top-level metadata section leniently. -/// -/// Absent or JSON-null is `None` silently - a frontier body has no `timings` -/// and that is not a defect. A present section that fails `parse` degrades to -/// `None` with a diagnostic naming the section and the parse failure. -fn parse_section( - body: &Value, - key: &str, - parse: impl FnOnce(&Value) -> Result, - diagnostics: &mut Vec, -) -> Option { - match body.get(key) { - None | Some(Value::Null) => None, - Some(value) => match parse(value) { - Ok(parsed) => Some(parsed), - Err(error) => { - diagnostics.push(format!( - "malformed `{key}` in completion response ignored: {error}" - )); - None - } - }, - } -} - -/// The wire shape of the `usage` object: the flat core every backend sends, -/// plus the nested detail objects frontier backends and vLLM add. -#[derive(Deserialize)] -struct WireUsage { - prompt_tokens: u32, - completion_tokens: u32, - total_tokens: u32, - #[serde(default)] - prompt_tokens_details: Option, - #[serde(default)] - completion_tokens_details: Option, -} - -/// The nested `prompt_tokens_details` object holding the cache detail. -#[derive(Deserialize)] -struct WirePromptTokensDetails { - #[serde(default)] - cached_tokens: Option, -} - -/// The nested `completion_tokens_details` object holding the reasoning -/// detail. -#[derive(Deserialize)] -struct WireCompletionTokensDetails { - #[serde(default)] - reasoning_tokens: Option, -} - -/// Parses the `usage` object, flattening the nested detail fields into the -/// canonical [`Usage`] shape. -fn parse_usage(value: &Value) -> Result { - let wire = WireUsage::deserialize(value)?; - Ok(Usage::new( - wire.prompt_tokens, - wire.completion_tokens, - wire.total_tokens, - ) - .with_cached_tokens( - wire.prompt_tokens_details - .and_then(|details| details.cached_tokens), - ) - .with_reasoning_tokens( - wire.completion_tokens_details - .and_then(|details| details.reasoning_tokens), - )) -} - -/// The wire shape of llama.cpp's top-level `timings` extension. -/// -/// The draft counters appear only when a speculative-decoding draft model -/// ran, so an absent counter means zero drafted tokens, not an unknown; the -/// per-token rates the server also sends are derivable and ignored. -#[derive(Deserialize)] -struct WireLlamaTimings { - prompt_n: u32, - prompt_ms: f64, - prompt_per_second: f64, - predicted_n: u32, - predicted_ms: f64, - predicted_per_second: f64, - #[serde(default)] - draft_n: u32, - #[serde(default)] - draft_n_accepted: u32, -} - -/// Parses llama.cpp's `timings` object into the canonical [`LlamaTimings`]. -fn parse_llama_timings(value: &Value) -> Result { - let wire = WireLlamaTimings::deserialize(value)?; - Ok(LlamaTimings::new( - wire.prompt_n, - wire.prompt_ms, - wire.prompt_per_second, - wire.predicted_n, - wire.predicted_ms, - wire.predicted_per_second, - ) - .with_draft_n(wire.draft_n) - .with_draft_n_accepted(wire.draft_n_accepted)) -} - -/// Parses vLLM's `metrics` object into the canonical [`VllmMetrics`]. -/// -/// The canonical type is its own wire shape: every field is optional because -/// vLLM omits what it did not measure, and unknown keys are ignored. -fn parse_vllm_metrics(value: &Value) -> Result { - VllmMetrics::deserialize(value) -} - -#[cfg(test)] -#[path = "parse-tests-metadata.rs"] -mod metadata_tests; - -#[cfg(test)] -mod tests { - //! Tests for the strict turn parse: tool calls, empty replies, and the - //! reasoning side channel. - - use super::*; - use runvil::ErrorCode; - - const EMPTY_REPLY_PHRASE: &str = "the model replied with no text and no tool calls"; - - /// Whether `result` failed as a `MalformedResponse`. - fn is_malformed(result: Result) -> bool { - result.is_err_and(|error| error.code == ErrorCode::MalformedResponse) - } - - /// Whether `result` failed as an `EmptyReply`. - fn is_empty_reply(result: Result) -> bool { - result.is_err_and(|error| error.code == ErrorCode::EmptyReply) - } - - /// The backend's finish reason an empty reply names, when it names one. - fn finish_reason(error: &Error) -> Option<&str> { - error - .facts - .get(&ErrorFact::FinishReason) - .and_then(Value::as_str) - } - - /// Wraps one assistant message in the gateway's one-choice envelope. - fn one_choice(message: impl Into) -> Value { - let message = message.into(); - serde_json::json!({ "choices": [{ "message": message }] }) - } - - /// Wraps one raw tool call in a null-content assistant message. - fn one_tool_call(call: impl Into) -> Value { - let call = call.into(); - one_choice(serde_json::json!({ - "role": "assistant", - "content": null, - "tool_calls": [call] - })) - } - - #[test] - fn answer_and_reasoning_keeps_side_channel() { - let body = serde_json::json!({ - "choices": [{ - "message": { - "role": "assistant", - "content": "answer", - "reasoning_content": "scratch work" - }, - "finish_reason": "stop" - }] - }); - - let turn = normalize(&body).unwrap(); - assert_eq!(turn.finish_reason.as_deref(), Some("stop")); - assert_eq!(turn.reasoning_content.as_deref(), Some("scratch work")); - match turn.outcome { - CompletionResult::Text(text) => assert_eq!(text, "answer"), - other => panic!("expected text, got {other:?}"), - } - } - - #[test] - fn tools_with_empty_content_succeed() { - let body = serde_json::json!({ - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "", - "tool_calls": [{ - "id": "call_1", - "type": "function", - "function": { - "name": "web_search", - "arguments": "{\"query\":\"rust\",\"count\":3}" - } - }] - }, - "finish_reason": "tool_calls" - }] - }); - - let turn = normalize(&body).unwrap(); - assert_eq!(turn.finish_reason.as_deref(), Some("tool_calls")); - match turn.outcome { - CompletionResult::ToolCalls(calls) => { - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].id(), "call_1"); - assert_eq!(calls[0].name(), "web_search"); - assert_eq!( - calls[0].arguments().to_json_string(), - serde_json::json!({ "query": "rust", "count": 3 }).to_string() - ); - } - other => panic!("expected tool calls, got {other:?}"), - } - } - - #[test] - fn tools_with_null_content_succeed() { - let body = one_tool_call(serde_json::json!({ - "id": "call_2", - "type": "function", - "function": { "name": "web_fetch", "arguments": "{\"url\":\"https://example.com\"}" } - })); - - let turn = normalize(&body).unwrap(); - match turn.outcome { - CompletionResult::ToolCalls(calls) => { - assert_eq!( - calls[0].arguments().to_json_string(), - serde_json::json!({ "url": "https://example.com" }).to_string() - ); - } - other => panic!("expected tool calls, got {other:?}"), - } - } - - #[test] - fn malformed_tool_arguments_are_rejected_not_coerced() { - let body = one_tool_call(serde_json::json!({ - "id": "call_bad", - "type": "function", - "function": { "name": "web_fetch", "arguments": "not json" } - })); - - assert!( - is_malformed(normalize(&body)), - "invalid-JSON tool arguments must be rejected, never coerced to a string" - ); - } - - #[test] - fn non_string_tool_arguments_are_rejected() { - let body = one_tool_call(serde_json::json!({ - "id": "call_obj", - "type": "function", - "function": { "name": "web_fetch", "arguments": { "url": "x" } } - })); - - assert!(is_malformed(normalize(&body))); - } - - #[test] - fn absent_tool_arguments_are_rejected() { - let body = one_tool_call(serde_json::json!({ - "id": "call_none", - "type": "function", - "function": { "name": "ping" } - })); - - assert!( - is_malformed(normalize(&body)), - "missing tool arguments must be rejected, not coerced to null" - ); - } - - #[test] - fn non_object_decoded_arguments_are_rejected() { - let body = one_tool_call(serde_json::json!({ - "id": "call_arr", - "type": "function", - "function": { "name": "ping", "arguments": "[1,2,3]" } - })); - - assert!( - is_malformed(normalize(&body)), - "arguments that decode to a non-object must be rejected" - ); - } - - #[test] - fn blank_tool_call_id_is_rejected() { - let body = one_tool_call(serde_json::json!({ - "id": " ", - "type": "function", - "function": { "name": "ping", "arguments": "{}" } - })); - assert!(is_malformed(normalize(&body))); - } - - #[test] - fn wrong_type_type_field_is_rejected() { - let body = one_tool_call(serde_json::json!({ - "id": "call_x", - "type": "not_function", - "function": { "name": "ping", "arguments": "{}" } - })); - assert!(is_malformed(normalize(&body))); - } - - #[test] - fn missing_or_null_type_field_is_rejected() { - // `type` is required to be exactly "function"; a missing or - // null value is malformed rather than tacitly accepted. - for type_field in [None, Some(serde_json::Value::Null)] { - let mut call = serde_json::json!({ - "id": "call_x", - "function": { "name": "ping", "arguments": "{}" } - }); - if let Some(value) = type_field { - call["type"] = value; - } - let body = one_tool_call(call); - assert!(is_malformed(normalize(&body))); - } - } - - #[test] - fn wrong_typed_top_level_fields_are_malformed() { - // choices not an array - assert!(is_malformed(normalize( - &serde_json::json!({ "choices": {} }) - ))); - // message not an object - assert!(is_malformed(normalize( - &serde_json::json!({ "choices": [{ "message": 7 }] }) - ))); - // finish_reason not a string - assert!(is_malformed(normalize(&serde_json::json!({ - "choices": [{ "message": { "content": "hi" }, "finish_reason": 3 }] - })))); - // content wrong type - assert!(is_malformed(normalize(&serde_json::json!({ - "choices": [{ "message": { "content": [] } }] - })))); - // tool_calls wrong type - assert!(is_malformed(normalize(&serde_json::json!({ - "choices": [{ "message": { "content": null, "tool_calls": {} } }] - })))); - // reasoning wrong type - assert!(is_malformed(normalize(&serde_json::json!({ - "choices": [{ "message": { "content": "hi", "reasoning_content": 5 } }] - })))); - } - - #[test] - fn whitespace_only_content_is_empty_reply() { - let body = one_choice(serde_json::json!({ "content": " \n\t " })); - assert!(is_empty_reply(normalize(&body))); - } - - #[test] - fn empty_content_with_reasoning_is_error() { - let body = serde_json::json!({ - "choices": [{ - "message": { - "role": "assistant", - "content": "", - "reasoning_content": "only thinking" - }, - "finish_reason": "stop" - }] - }); - - let error = normalize(&body).expect_err("an empty turn must fail"); - assert_eq!(error.code, ErrorCode::EmptyReply); - assert_eq!( - error.to_string(), - format!("{EMPTY_REPLY_PHRASE}: reasoning content was present but ignored") - ); - assert_eq!( - finish_reason(&error), - Some("stop"), - "the choice's finish_reason must survive on the error" - ); - } - - #[test] - fn empty_string_content_without_tools_is_error() { - let body = one_choice(serde_json::json!({ - "role": "assistant", - "content": "" - })); - - let error = normalize(&body).expect_err("an empty turn must fail"); - assert_eq!(error.code, ErrorCode::EmptyReply); - assert_eq!(error.to_string(), EMPTY_REPLY_PHRASE); - assert_eq!(finish_reason(&error), None, "no finish_reason on the wire"); - } - - #[test] - fn null_content_without_tools_is_error() { - let body = one_choice(serde_json::json!({ - "role": "assistant", - "content": null - })); - - let error = normalize(&body).expect_err("an empty turn must fail"); - assert_eq!(error.code, ErrorCode::EmptyReply); - assert_eq!(error.to_string(), EMPTY_REPLY_PHRASE); - } - - #[test] - fn synonym_reasoning_field_is_side_channel() { - let body = one_choice(serde_json::json!({ - "role": "assistant", - "content": "answer", - "reasoning": "via synonym" - })); - - let turn = normalize(&body).unwrap(); - assert_eq!(turn.reasoning_content.as_deref(), Some("via synonym")); - match turn.outcome { - CompletionResult::Text(text) => assert_eq!(text, "answer"), - other => panic!("expected text, got {other:?}"), - } - } - - #[test] - fn empty_reasoning_synonym_falls_through() { - let body = one_choice(serde_json::json!({ - "role": "assistant", - "content": "answer", - "reasoning_content": "", - "thinking": "from thinking" - })); - - let turn = normalize(&body).unwrap(); - assert_eq!(turn.reasoning_content.as_deref(), Some("from thinking")); - } - - #[test] - fn missing_content_and_tools_is_empty_model_reply() { - let body = one_choice(serde_json::json!({ "role": "assistant" })); - - assert!(is_empty_reply(normalize(&body))); - } - - #[test] - fn no_choices_is_malformed() { - let body = serde_json::json!({ "choices": [] }); - assert!(is_malformed(normalize(&body))); - } - - #[test] - fn tool_code_fence_stays_text_in_openai_normalizer() { - let content = "```tool_code\nsearch(query=\"C++ Alliance founder\")\n```"; - let body = serde_json::json!({ - "choices": [{ - "message": { "role": "assistant", "content": content }, - "finish_reason": "stop" - }] - }); - - let turn = normalize(&body).unwrap(); - match turn.outcome { - CompletionResult::Text(text) => assert_eq!(text, content), - other => panic!("OpenAI normalizer must not parse content fences: {other:?}"), - } - } - - #[test] - fn fenced_json_tool_calls_stays_text_in_openai_normalizer() { - let content = "```json\n{\"tool_calls\":[{\"id\":\"1\",\"type\":\"function\",\"function\":{\"name\":\"fetch\",\"arguments\":\"{\\\"url\\\":\\\"https://example.com\\\"}\"}}]}\n```"; - let body = one_choice(serde_json::json!({ - "role": "assistant", - "content": content - })); - - let turn = normalize(&body).unwrap(); - match turn.outcome { - CompletionResult::Text(text) => assert_eq!(text, content), - other => panic!("OpenAI normalizer must not parse content fences: {other:?}"), - } - } -} diff --git a/crates/harness-gateway-client/src/wire/read-tests.rs b/crates/harness-gateway-client/src/wire/read-tests.rs deleted file mode 100644 index aab13fed1..000000000 --- a/crates/harness-gateway-client/src/wire/read-tests.rs +++ /dev/null @@ -1,311 +0,0 @@ -//! Tests for capped body reads and SSE completion-stream reassembly. - -use std::cell::Cell; -use std::collections::VecDeque; -use std::future::Future; -use std::pin::pin; -use std::task::{Context, Poll, Waker}; -use std::time::{Duration, Instant}; - -use runvil::ErrorCode; -use runvil::model::{CompletionResult, RawExchange, make_completion_error}; -use serde_json::json; - -use super::*; - -/// A chunk source over canned chunks; it never pends, so the tests need -/// no executor. -struct Canned(VecDeque, Error>>); - -impl Canned { - fn of(chunks: &[&str]) -> Canned { - Canned( - chunks - .iter() - .map(|chunk| Ok(chunk.as_bytes().to_vec())) - .collect(), - ) - } -} - -impl ChunkSource for Canned { - type Chunk = Vec; - - fn next_chunk(&mut self) -> impl Future, Error>> + Send { - std::future::ready(self.0.pop_front().transpose()) - } -} - -/// Drives a future that never pends to its output. -fn block_on(future: F) -> F::Output { - let mut future = pin!(future); - let mut cx = Context::from_waker(Waker::noop()); - loop { - if let Poll::Ready(output) = future.as_mut().poll(&mut cx) { - return output; - } - } -} - -fn sse(chunks: &[serde_json::Value]) -> String { - let mut body = String::new(); - for chunk in chunks { - body.push_str("data: "); - body.push_str(&chunk.to_string()); - body.push_str("\n\n"); - } - body -} - -fn text_chunk(text: &str) -> serde_json::Value { - json!({ "choices": [{ "index": 0, "delta": { "content": text } }] }) -} - -#[test] -fn read_body_capped_refuses_an_advertised_oversize_length_before_reading() { - let mut source = Canned::of(&["never read"]); - let err = block_on(read_body_capped(&mut source, Some(100), 8)) - .expect_err("an advertised length over the cap is refused"); - assert_eq!(err.code, ErrorCode::MalformedResponse); - assert_eq!( - err.to_string(), - "the model backend sent a reply that could not be understood: \ -response body of 100 bytes exceeds the 8-byte limit" - ); - assert_eq!(err.message, err.prose); - assert_eq!(source.0.len(), 1, "nothing was read"); -} - -#[test] -fn read_body_capped_refuses_streamed_chunks_over_the_cap() { - let mut source = Canned::of(&["12345", "6789"]); - let err = block_on(read_body_capped(&mut source, None, 8)) - .expect_err("chunks past the cap are refused"); - assert_eq!(err.code, ErrorCode::MalformedResponse); - assert_eq!( - err.to_string(), - "the model backend sent a reply that could not be understood: \ -response body exceeds the 8-byte limit" - ); -} - -#[test] -fn read_body_capped_returns_a_body_within_the_cap() { - let mut source = Canned::of(&["12345", "678"]); - let body = block_on(read_body_capped(&mut source, Some(8), 8)).expect("within the cap"); - assert_eq!(body, b"12345678"); -} - -#[test] -fn read_completion_stream_reassembles_the_turn_and_times_it_on_the_injected_clock() { - let body = sse(&[ - json!({ "choices": [{ "index": 0, "delta": { "role": "assistant" } }] }), - text_chunk("hel"), - text_chunk("lo"), - json!({ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] }), - json!("[DONE]"), - ]) - .replace("data: \"[DONE]\"", "data: [DONE]"); - let (head, tail) = body.split_at(body.len() / 2); - let mut source = Canned::of(&[head, tail]); - let started = Instant::now(); - // The clock advances 10ms per reading: delta one at +10, delta two at - // +20, the end-to-end reading at +30. Nothing here reads a real clock. - let ticks = Cell::new(0_u32); - let now = || { - ticks.set(ticks.get() + 1); - started + Duration::from_millis(10 * u64::from(ticks.get())) - }; - let seen = std::cell::RefCell::new(Vec::new()); - let completion = block_on(read_completion_stream( - &mut source, - json!({ "model": "m" }), - 1024, - |delta| seen.borrow_mut().push(delta), - started, - now, - )) - .expect("a whole stream reassembles"); - assert_eq!( - completion.result(), - &CompletionResult::Text("hello".to_owned()) - ); - assert_eq!(completion.finish_reason(), Some("stop")); - assert_eq!(seen.borrow().len(), 2, "one live delta per text fragment"); - let timing = completion - .metrics() - .and_then(|metrics| metrics.client.as_ref()) - .expect("timing is measured"); - // Rounding makes every timing a whole microsecond, so the serialized - // text is exact and short: there is no epsilon left to choose. - assert_eq!( - serde_json::to_string(timing).expect("timing serializes"), - r#"{"ttft_ms":10.0,"mean_itl_ms":10.0,"e2e_ms":30.0}"# - ); - assert_eq!( - completion.raw().map(RawExchange::request), - Some(&json!({ "model": "m" })), - "the completion carries back the request body the transport sent" - ); -} - -#[test] -fn read_completion_stream_labels_the_completion_with_the_model_its_request_named() { - for body_model in [Some("served-elsewhere"), None] { - let mut chunks = vec![ - text_chunk("ok"), - json!({ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] }), - ]; - if let Some(model) = body_model { - for chunk in &mut chunks { - chunk["model"] = json!(model); - } - } - let body = sse(&chunks) + "data: [DONE]\n\n"; - let mut source = Canned::of(&[&body]); - let started = Instant::now(); - let completion = block_on(read_completion_stream( - &mut source, - json!({ "model": "routed-to" }), - 1024, - |_| {}, - started, - || started, - )) - .expect("a whole stream reassembles"); - assert_eq!( - completion.model(), - "routed-to", - "the label is the requested model, whatever the body named ({body_model:?})" - ); - if body_model.is_none() { - assert_eq!( - completion.metadata_diagnostics(), - ["completion response named no string `model`; labeled with the requested model"], - "the diagnostic says where the label came from" - ); - } - } -} - -#[test] -fn read_completion_stream_rounds_timings_to_microseconds_for_the_log() { - let body = sse(&[ - text_chunk("a"), - text_chunk("b"), - text_chunk("c"), - text_chunk("d"), - json!({ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] }), - json!("[DONE]"), - ]) - .replace("data: \"[DONE]\"", "data: [DONE]"); - let (head, tail) = body.split_at(body.len() / 2); - let mut source = Canned::of(&[head, tail]); - let started = Instant::now(); - // Every reading carries nanosecond noise: without rounding the logged - // text would be a long nanosecond expansion that need not survive. - // Four deltas make the mean divide by three, so its quotient is a long - // decimal only the mean's own rounding trims; with two deltas the - // divisor is one and the already-rounded gap would pass through whole. - let ticks = Cell::new(0_u32); - let now = || { - ticks.set(ticks.get() + 1); - match ticks.get() { - 1 => started + Duration::new(0, 1_234_567_891), - 2 => started + Duration::new(0, 1_600_000_000), - 3 => started + Duration::new(0, 1_900_000_000), - 4 => started + Duration::new(0, 2_234_567_891), - _ => started + Duration::new(0, 3_703_703_673), - } - }; - let completion = block_on(read_completion_stream( - &mut source, - json!({ "model": "m" }), - 1024, - |_| {}, - started, - now, - )) - .expect("a whole stream reassembles"); - let timing = completion - .metrics() - .and_then(|metrics| metrics.client.as_ref()) - .expect("timing is measured"); - let text = serde_json::to_string(timing).expect("timing serializes"); - assert_eq!( - text, r#"{"ttft_ms":1234.568,"mean_itl_ms":333.333,"e2e_ms":3703.704}"#, - "each timing is a whole microsecond, so its decimal text is short" - ); - let back: ClientTiming = serde_json::from_str(&text).expect("its own text parses"); - assert_eq!(&back, timing, "every field reads back bit-for-bit"); -} - -#[test] -fn read_completion_stream_refuses_a_stream_over_the_byte_cap() { - let body = sse(&[text_chunk("a long reply")]); - let mut source = Canned::of(&[&body]); - let started = Instant::now(); - let err = block_on(read_completion_stream( - &mut source, - json!({}), - 8, - |_| {}, - started, - || started, - )) - .expect_err("an oversize stream is refused"); - assert_eq!(err.code, ErrorCode::MalformedResponse); - assert_eq!( - err.to_string(), - "the model backend sent a reply that could not be understood: \ -response stream exceeds the 8-byte limit" - ); -} - -#[test] -fn read_completion_stream_refuses_a_stream_without_the_sentinel() { - let body = sse(&[text_chunk("half")]); - let mut source = Canned::of(&[&body]); - let started = Instant::now(); - let err = block_on(read_completion_stream( - &mut source, - json!({}), - 1024, - |_| {}, - started, - || started, - )) - .expect_err("a cut-off stream is refused"); - assert_eq!(err.code, ErrorCode::MalformedResponse); - assert_eq!( - err.to_string(), - "the model backend sent a reply that could not be understood: \ -completion stream ended without the [DONE] sentinel" - ); - assert_eq!(err.message, err.prose); -} - -#[test] -fn read_completion_stream_returns_the_source_failure_as_is() { - let timed_out = make_completion_error(ErrorCode::Timeout, "no chunk in time").with_cause( - &std::io::Error::new(std::io::ErrorKind::TimedOut, "deadline"), - ); - let mut source = Canned(VecDeque::from([ - Ok(sse(&[text_chunk("par")]).into_bytes()), - Err(timed_out.clone()), - ])); - let started = Instant::now(); - let err = block_on(read_completion_stream( - &mut source, - json!({}), - 1024, - |_| {}, - started, - || started, - )) - .expect_err("a read failure fails the round"); - assert_eq!(err, timed_out); - assert_eq!(err.code, ErrorCode::Timeout); - assert_eq!(err.prose, "no chunk in time"); - assert_eq!(err.message, "no chunk in time: deadline"); -} diff --git a/crates/harness-gateway-client/src/wire/read.rs b/crates/harness-gateway-client/src/wire/read.rs deleted file mode 100644 index 40d890553..000000000 --- a/crates/harness-gateway-client/src/wire/read.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! The transport-independent half of reading a completion off the wire: -//! the byte cap on a body, the SSE loop to the `[DONE]` sentinel, and the -//! client-side timing, over a caller-supplied [`ChunkSource`]. -//! -//! No HTTP happens here and no clock is read. The transport supplies the -//! chunks and the clock; this module applies the one rule set every -//! transport shares, so transports differ only in how they send. A -//! transport that grew its own copy of this loop would be one more place -//! the byte cap, the sentinel rule, and the timing arithmetic could drift. - -use std::future::Future; -use std::time::{Duration, Instant}; - -use runvil::Error; -use runvil::metrics::ClientTiming; -use runvil::model::Completion; -use serde_json::Value; - -use super::delta::StreamDelta; -use super::stream::{Applied, SseScanner, StreamAccumulator}; -use crate::failure::make_malformed_error; - -/// A response body that a transport supplies one chunk at a time. -/// -/// A transport is the code that sends a model request and receives the -/// reply, for example over HTTP. It answers a `Chat` effect, the Engine's -/// request for one model reply, in four steps: -/// -/// 1. It builds the request body with -/// [`build_request_body`](crate::build_request_body). It reads its -/// clock and then sends the request. -/// 2. It wraps the response body in a `ChunkSource`. -/// 3. On a status outside the 2xx range, it reads the error body whole with -/// [`read_body_capped`]. It bounds the body's length and escapes its -/// control characters with [`escape_controls`](crate::escape_controls). -/// It then fails the round with the error that -/// [`make_classify_http_failure_error`](crate::make_classify_http_failure_error) returns. -/// 4. Otherwise, it passes the source to [`read_completion_stream`]. The -/// returned [`Completion`] answers the effect. -/// -/// The transport opens the connection and supplies every clock reading. -/// The source is the only I/O that `read_body_capped` and -/// `read_completion_stream` touch. -pub trait ChunkSource { - /// One chunk of body bytes, in whatever buffer the transport yields. - type Chunk: AsRef<[u8]>; - - /// Returns the next chunk, or `None` once the body is exhausted. - /// - /// When a read fails, the implementation returns the [`Error`] that - /// the round fails with. Its code is `Timeout` when the read ran out of - /// time and `Transport` otherwise. Build it with - /// [`make_completion_error`](runvil::model::make_completion_error) and attach the - /// transport's own error with [`Error::with_cause`]. - fn next_chunk(&mut self) -> impl Future, Error>> + Send; -} - -/// Reads a whole response body from `source`, refusing it once it would -/// exceed `cap` bytes. -/// -/// Use it for a body the transport decodes whole, such as the error body -/// of a status outside the 2xx range or a JSON document like the -/// gateway's model list. -/// -/// `content_length` is the length the response advertises, when the -/// transport knows it. An advertised length over `cap` fails at once, -/// before any chunk is read. The function also counts bytes as the chunks -/// arrive, so a gateway that omits or misstates the length still cannot -/// force an unbounded allocation before the body is decoded. -/// -/// # Errors -/// Returns an [`Error`] with code `MalformedResponse` when the body would -/// exceed `cap`, and the source's own error when a read fails. -pub async fn read_body_capped( - source: &mut S, - content_length: Option, - cap: u64, -) -> Result, Error> { - if let Some(len) = content_length - && len > cap - { - return Err(make_malformed_error(format!( - "response body of {len} bytes exceeds the {cap}-byte limit" - ))); - } - let mut body: Vec = Vec::new(); - while let Some(chunk) = source.next_chunk().await? { - let bytes = chunk.as_ref(); - if body.len() as u64 + bytes.len() as u64 > cap { - return Err(make_malformed_error(format!( - "response body exceeds the {cap}-byte limit" - ))); - } - body.extend_from_slice(bytes); - } - Ok(body) -} - -/// Reads a streamed model reply from `source` and assembles it into a -/// [`Completion`]. -/// -/// The reply arrives as server-sent events (SSE). The function reads -/// events until the `[DONE]` sentinel and fails if the stream exceeds -/// `max_bytes` bytes. It passes each [`StreamDelta`] to `on_delta` as soon -/// as it is decoded, so a Host can show the reply as it arrives. The -/// returned completion holds the whole turn either way. -/// -/// `request_body` is the body the transport sent, as -/// [`build_request_body`](crate::build_request_body) returned it. The -/// completion carries it back, so a run's debug capture records exactly -/// what was sent. The completion is labeled with the model that -/// `request_body` names. -/// -/// `started` is the transport's clock reading from just before it sent -/// the request, and `now` reads that same clock. The completion's -/// [`ClientTiming`] holds three figures measured against them: time to -/// first token, mean inter-token latency, and end-to-end time. It takes -/// every clock reading from `started` and `now`. -/// -/// # Errors -/// Returns an [`Error`] with code `MalformedResponse` when the stream -/// exceeds `max_bytes` or ends before the sentinel, and the source's own -/// error when a read fails. Also returns the error that reassembling the -/// reply raises for a malformed chunk, a mid-stream error envelope, a -/// truncated tool-call batch, or an empty turn. -pub async fn read_completion_stream( - source: &mut S, - request_body: Value, - max_bytes: u64, - on_delta: impl Fn(StreamDelta), - started: Instant, - now: impl Fn() -> Instant, -) -> Result { - let mut scanner = SseScanner::new(); - let mut accumulator = StreamAccumulator::new(); - let mut received: u64 = 0; - let mut first_delta: Option = None; - let mut last_delta: Option = None; - let mut delta_chunks: u32 = 0; - let mut done = false; - 'read: while let Some(chunk) = source.next_chunk().await? { - let bytes = chunk.as_ref(); - received += bytes.len() as u64; - if received > max_bytes { - return Err(make_malformed_error(format!( - "response stream exceeds the {max_bytes}-byte limit" - ))); - } - scanner.extend(bytes); - while let Some(data) = scanner.next_data() { - match accumulator.apply(&data, &on_delta)? { - Applied::Done => { - done = true; - break 'read; - } - Applied::Chunk { delta: true } => { - let at = now(); - first_delta.get_or_insert(at); - last_delta = Some(at); - delta_chunks += 1; - } - Applied::Chunk { delta: false } => {} - } - } - } - // A stream that ends without the sentinel was cut off; its - // accumulation may be missing the tail, so it must never pass for a - // complete turn. - if !done { - return Err(make_malformed_error( - "completion stream ended without the [DONE] sentinel", - )); - } - let client_timing = ClientTiming::new(duration_ms(now().duration_since(started))) - .with_ttft_ms(first_delta.map(|at| duration_ms(at.duration_since(started)))) - .with_mean_itl_ms(match (first_delta, last_delta) { - (Some(first), Some(last)) if delta_chunks >= 2 => Some(round_to_microsecond( - duration_ms(last.duration_since(first)) / f64::from(delta_chunks - 1), - )), - _ => None, - }); - // The truncation rule, the strict turn normalizer, and the lenient - // metadata parser all run inside `finish`: one rule set for every - // transport. - accumulator.finish(request_body, Some(client_timing)) -} - -/// A duration as fractional milliseconds, rounded to a whole microsecond -/// so the text the run log stores parses back exactly. -fn duration_ms(duration: Duration) -> f64 { - round_to_microsecond(duration.as_secs_f64() * 1000.0) -} - -/// Rounds fractional-millisecond `ms` to the nearest whole microsecond. -/// -/// A run log stores a timing as JSON text and parses that text back. A -/// whole-microsecond value has a short decimal form the parser reproduces -/// exactly, so the parsed timing equals the recorded one. -fn round_to_microsecond(ms: f64) -> f64 { - (ms * 1000.0).round() / 1000.0 -} - -#[cfg(test)] -#[path = "read-tests.rs"] -mod tests; diff --git a/crates/harness-gateway-client/src/wire/request-tests.rs b/crates/harness-gateway-client/src/wire/request-tests.rs deleted file mode 100644 index 1f4eca780..000000000 --- a/crates/harness-gateway-client/src/wire/request-tests.rs +++ /dev/null @@ -1,369 +0,0 @@ -//! Tests for the request body: the OpenAI function-call shape of a replayed -//! tool turn, message order, tool wrapping, and the option fields. -//! -//! A `ToolSchema` and an assistant tool-call message have no public -//! constructor, so [`tool_round`] takes both from a facade `Run` that went -//! through one tool round. - -use std::collections::BTreeMap; -use std::fmt::Write as _; -use std::num::NonZeroU32; -use std::sync::Arc; - -use runvil_engine::effect::{Effect, EffectAnswer, IssuedEffect}; -use runvil_engine::model::{ - Completion, CompletionResult, ModelId, ModelInfo, ModelRole, ThinkingMode, ToolCall, -}; -use runvil_engine::{Prompt, Run, RunParams, RunSeed, Step, Timestamp}; -use runvil_engine::{ToolCatalog, ToolId, ToolInfo, ToolOutput}; -use serde_json::json; - -use super::*; - -/// What a run's second `Chat` effect sent, after one tool round. -struct Round { - messages: Vec, - tools: Vec, - options: CompletionOptions, -} - -/// One tool a round offers: its name, description, and parameters schema. -type Offered = (&'static str, &'static str, Value); - -/// Drives a facade `Run` through one tool round and returns what its second -/// `Chat` effect sent. -/// -/// The section offers every tool in `offered` as `example/test/`, so -/// the model sees it as `example_test_`, and runs `models.loop` over -/// one user message. The section's render is answered with its tools -/// unchanged; the first `Chat` effect with a call to the first tool, -/// `call_1` with `{"value":"one"}`; the tool call with the trusted output -/// `echoed: one`; and the second `Chat` effect, recorded here, with the -/// text `done`. -fn tool_round(offered: &[Offered]) -> Round { - let mut offers = String::new(); - for (name, _, _) in offered { - writeln!(offers, "tools.offer('example/test/{name}')").expect("a String takes every write"); - } - let source = format!( - "---\nname: round\ndescription: One tool round.\nrunvil: 0\n\ - ---\n\n# Round\n\n## Ask\n\n\ - ```lua\n{offers}messages:user('echo once')\n\ - models.loop()\nreturn messages[#messages].content\n```\n" - ); - let first_wire_name = format!("example_test_{}", offered[0].0); - let parsed = Prompt::parse(&|_: &str| Ok(source.clone()), "prompt"); - let prompt = parsed.expect("the round prompt parses"); - let tools: Vec = offered - .iter() - .map(|(name, description, parameters)| { - let id = ToolId::parse(&format!("example/test/{name}")).expect("a valid tool id"); - ToolInfo::new(id, *description, parameters.clone()) - }) - .collect(); - let model = ModelInfo::new( - ModelId::gateway("m").expect("a valid model id"), - "Plays the model from a script", - NonZeroU32::new(8_192).expect("8192 is non-zero"), - ThinkingMode::Never, - ); - let ctx = RunParams::new(RunSeed::from_bytes([7; 32]), Timestamp::UNIX_EPOCH); - let catalog = ToolCatalog::new(tools).expect("the offered tools are distinct"); - let ctx = ctx - .with_tools(catalog) - .with_models(BTreeMap::from([(ModelRole::Default, model)])); - let mut run = Run::new(Arc::new(prompt), "", ctx); - let mut rounds = Vec::new(); - let result = loop { - let effects = match run.step() { - Step::Pending { effects, .. } => effects, - Step::Done { result, .. } => break result, - }; - for IssuedEffect { id, effect, .. } in effects { - let answer = match effect { - Effect::Chat { - binding, - messages, - tools, - .. - } => { - let reply = if rounds.is_empty() { - let call = ToolCall::from_parts( - "call_1", - first_wire_name.as_str(), - json!({ "value": "one" }), - ) - .expect("a whole call"); - CompletionResult::ToolCalls(vec![call]) - } else { - CompletionResult::Text("done".to_owned()) - }; - rounds.push(Round { - messages, - tools, - options: binding.completion_options(), - }); - EffectAnswer::Chat(Completion::from_result(reply, "m").map(Box::new)) - } - Effect::Render { offered, .. } => EffectAnswer::Render(offered), - Effect::ToolCall { .. } => { - EffectAnswer::ToolCall(Ok(ToolOutput::trusted("echoed: one"))) - } - _ => EffectAnswer::Dropped, - }; - run.resume(id, answer); - } - }; - assert_eq!( - result, - Ok("done".to_owned()), - "the round runs to its terminal reply" - ); - assert_eq!(rounds.len(), 2, "one tool round, then the terminal round"); - rounds.pop().expect("the terminal round was recorded") -} - -/// The `echo` tool with a one-string-field schema. -fn echo() -> Offered { - ( - "echo", - "Echo a value.", - json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - }), - ) -} - -/// Checks every `messages[].tool_calls[]` entry against the OpenAI -/// function-call schema a strict endpoint enforces, returning the -/// offending path for the first violation. -fn check_openai_tool_calls(body: &Value) -> Result<(), String> { - let messages = body - .get("messages") - .and_then(Value::as_array) - .ok_or_else(|| "request body had no messages array".to_owned())?; - for (message_index, message) in messages.iter().enumerate() { - let Some(calls) = message.get("tool_calls").filter(|calls| !calls.is_null()) else { - continue; - }; - let calls = calls.as_array().ok_or_else(|| { - format!("messages[{message_index}].tool_calls was present but not an array") - })?; - for (call_index, call) in calls.iter().enumerate() { - let path = format!("messages[{message_index}].tool_calls[{call_index}]"); - if !call.is_object() { - return Err(format!("{path} was not an object")); - } - if call.get("type") != Some(&Value::String("function".to_owned())) { - return Err(format!("{path}.type must be the string \"function\"")); - } - let id = call - .get("id") - .and_then(Value::as_str) - .ok_or_else(|| format!("{path} had no string id"))?; - if id.trim().is_empty() { - return Err(format!("{path}.id was blank")); - } - let function = call - .get("function") - .filter(|function| function.is_object()) - .ok_or_else(|| format!("{path}.function was not an object"))?; - let name = function - .get("name") - .and_then(Value::as_str) - .ok_or_else(|| format!("{path}.function had no string name"))?; - if name.trim().is_empty() { - return Err(format!("{path}.function.name was blank")); - } - let raw = function - .get("arguments") - .and_then(Value::as_str) - .ok_or_else(|| { - format!("{path}.function.arguments was not a JSON-encoded string") - })?; - let decoded = serde_json::from_str::(raw).map_err(|error| { - format!("{path}.function.arguments was not valid JSON: {error}") - })?; - if !decoded.is_object() { - return Err(format!( - "{path}.function.arguments did not decode to a JSON object" - )); - } - } - } - Ok(()) -} - -#[test] -fn a_replayed_tool_call_turn_serializes_in_the_openai_function_shape() { - let round = tool_round(&[echo()]); - let body = build_request_body(&round.messages, None, &round.options); - assert_eq!(check_openai_tool_calls(&body), Ok(()), "{body}"); - let call = &body["messages"][1]["tool_calls"][0]; - assert_eq!(call["id"], "call_1"); - assert_eq!(call["type"], "function"); - assert_eq!(call["function"]["name"], "example_test_echo"); - assert_eq!(call["function"]["arguments"], "{\"value\":\"one\"}"); - assert_eq!(body["messages"][2]["role"], "tool"); - assert_eq!(body["messages"][2]["tool_call_id"], "call_1"); - assert_eq!(body["messages"][2]["content"], "echoed: one"); -} - -#[test] -fn the_openai_check_names_each_way_a_replayed_call_breaks_the_schema() { - let round = tool_round(&[echo()]); - let replayed = build_request_body(&round.messages, None, &round.options); - let cases = [ - ( - json!({ "id": "call_1", "function": { "name": "echo", "arguments": "{}" } }), - ".type must be the string \"function\"", - ), - ( - json!({ "id": " ", "type": "function", "function": { "name": "echo", "arguments": "{}" } }), - ".id was blank", - ), - ( - json!({ "type": "function", "function": { "name": "echo", "arguments": "{}" } }), - " had no string id", - ), - ( - json!({ "id": "call_1", "type": "function", "name": "echo", "arguments": "{}" }), - ".function was not an object", - ), - ( - json!({ "id": "call_1", "type": "function", "function": { "name": "", "arguments": "{}" } }), - ".function.name was blank", - ), - ( - json!({ "id": "call_1", "type": "function", "function": { "name": "echo", "arguments": { "value": "one" } } }), - ".function.arguments was not a JSON-encoded string", - ), - ( - json!({ "id": "call_1", "type": "function", "function": { "name": "echo", "arguments": "{oops" } }), - ".function.arguments was not valid JSON", - ), - ( - json!({ "id": "call_1", "type": "function", "function": { "name": "echo", "arguments": "[1]" } }), - ".function.arguments did not decode to a JSON object", - ), - ]; - for (call, expected) in cases { - let mut body = replayed.clone(); - body["messages"][1]["tool_calls"][0] = call; - let violation = check_openai_tool_calls(&body).expect_err("the call breaks the schema"); - assert!( - violation.starts_with("messages[1].tool_calls[0]") && violation.contains(expected), - "expected {expected:?}, got {violation:?}" - ); - } -} - -#[test] -fn messages_go_out_verbatim_in_order() { - let messages = [ - Message::user("draft text"), - Message::assistant("done"), - Message::tool("call_1", "echoed: one"), - ]; - let body = build_request_body(&messages, None, &CompletionOptions::new("m")); - assert_eq!( - body["messages"], - json!([ - { "role": "user", "content": "draft text" }, - { "role": "assistant", "content": "done" }, - { "role": "tool", "content": "echoed: one", "tool_call_id": "call_1" }, - ]) - ); -} - -#[test] -fn each_tool_is_wrapped_as_a_function_with_its_schema_in_order() { - let (_, _, parameters) = echo(); - let round = tool_round(&[ - echo(), - ("grab", "Grab a value", json!({ "type": "object" })), - ]); - let body = build_request_body( - &[Message::user("hi")], - Some(&round.tools), - &CompletionOptions::new("m"), - ); - assert_eq!( - body["tools"], - json!([ - { - "type": "function", - "function": { - "name": "example_test_echo", - "description": "Echo a value.", - "parameters": parameters, - }, - }, - { - "type": "function", - "function": { - "name": "example_test_grab", - "description": "Grab a value", - "parameters": { "type": "object" }, - }, - }, - ]) - ); -} - -#[test] -fn unset_options_send_no_option_fields() { - let body = build_request_body( - &[Message::user("hi")], - None, - &CompletionOptions::new("analyst"), - ); - assert_eq!(body["model"], "analyst"); - for field in ["temperature", "max_tokens", "chat_template_kwargs"] { - assert!(body.get(field).is_none(), "{field} is absent: {body}"); - } -} - -#[test] -fn the_body_always_streams_and_asks_for_usage() { - let body = build_request_body( - &[Message::user("hi")], - None, - &CompletionOptions::new("analyst"), - ); - assert_eq!(body["model"], "analyst"); - assert_eq!(body["stream"], true); - assert_eq!(body["stream_options"]["include_usage"], true); - assert!(body.get("tools").is_none(), "no tools field without tools"); - assert!(body.get("temperature").is_none()); -} - -#[test] -fn options_and_tools_reach_the_body() { - let options = CompletionOptions::new("analyst") - .with_temperature(0.0) - .expect("0.0 is valid") - .with_max_tokens(NonZeroU32::new(128).expect("128 is non-zero")) - .with_thinking(false); - let round = tool_round(&[("echo", "Echo.", json!({ "type": "object" }))]); - let body = build_request_body(&[Message::user("hi")], Some(&round.tools), &options); - assert_eq!(body["temperature"], 0.0); - assert_eq!(body["max_tokens"], 128); - assert_eq!(body["chat_template_kwargs"]["enable_thinking"], false); - assert_eq!(body["tool_choice"], "auto"); - assert_eq!(body["tools"][0]["type"], "function"); - assert_eq!(body["tools"][0]["function"]["name"], "example_test_echo"); -} - -#[test] -fn an_empty_tool_list_sends_no_tools_field() { - let body = build_request_body( - &[Message::user("hi")], - Some(&[]), - &CompletionOptions::new("m"), - ); - assert!(body.get("tools").is_none()); - assert!(body.get("tool_choice").is_none()); -} diff --git a/crates/harness-gateway-client/src/wire/request.rs b/crates/harness-gateway-client/src/wire/request.rs deleted file mode 100644 index 68a2af34f..000000000 --- a/crates/harness-gateway-client/src/wire/request.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! The chat-completions request body: the one JSON shape every transport -//! sends for a round, built from the wire types and the frozen options. - -use runvil::model::{CompletionOptions, Message, ToolSchema}; -use serde_json::Value; - -/// Builds the JSON body of a streaming chat-completions request. -/// -/// The body names the model given by `options.model()` and carries -/// `messages` as given. Every request streams, so `stream` is always true. -/// The body also sets `stream_options.include_usage`. That asks the backend -/// to send token usage in a final chunk with an empty `choices` list, so -/// token counts still arrive when the reply streams as server-sent events. -/// -/// When `tools` is `Some` and holds at least one schema, the body sends each -/// schema in its `tools` array as an `OpenAI` function tool. Each entry is an -/// object with `type` set to `"function"` and a `function` object that holds -/// the tool's `name`, `description`, and `parameters`. The body then also -/// sets `tool_choice` to `"auto"`. When `tools` is `None` or an empty slice, -/// the body omits both fields and the request is a plain chat completion. -/// -/// The optional settings in `options` extend the body only when set. -/// `temperature` and `max_tokens` become fields of the same name. `thinking` -/// becomes `chat_template_kwargs.enable_thinking`. -/// -/// A transport that performs a `Chat` effect must build its request body -/// here. It passes the effect's messages, tools, and options, sends the -/// result as the JSON body of its chat-completions request, and later hands -/// the same value to -/// [`read_completion_stream`](crate::read_completion_stream). One shared -/// builder keeps every transport sending the same request for one effect. -#[must_use] -pub fn build_request_body( - messages: &[Message], - tools: Option<&[ToolSchema]>, - options: &CompletionOptions, -) -> Value { - let mut body = serde_json::json!({ - "model": options.model(), - "messages": messages, - "stream": true, - "stream_options": { "include_usage": true }, - }); - if let Some(tools) = tools.filter(|tools| !tools.is_empty()) { - let wrapped: Vec = tools - .iter() - .map(|tool| { - serde_json::json!({ - "type": "function", - "function": { - "name": tool.name(), - "description": tool.description(), - "parameters": tool.parameters(), - }, - }) - }) - .collect(); - body["tools"] = Value::Array(wrapped); - body["tool_choice"] = Value::String("auto".into()); - } - if let Some(temperature) = options.temperature() { - body["temperature"] = serde_json::json!(temperature.get()); - } - if let Some(max_tokens) = options.max_tokens() { - body["max_tokens"] = serde_json::json!(max_tokens.get()); - } - if let Some(thinking) = options.thinking() { - body["chat_template_kwargs"] = serde_json::json!({ - "enable_thinking": thinking, - }); - } - body -} - -#[cfg(test)] -#[path = "request-tests.rs"] -mod tests; diff --git a/crates/harness-gateway-client/src/wire/stream-tests-metrics.rs b/crates/harness-gateway-client/src/wire/stream-tests-metrics.rs deleted file mode 100644 index 89c0feffd..000000000 --- a/crates/harness-gateway-client/src/wire/stream-tests-metrics.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! Tests for the call metrics and the raw exchange `finish` builds: the -//! backend sections and the client clock merge into one `CallMetrics`, and -//! the request and reassembled response bodies travel as the raw exchange. - -use runvil::metrics::{CallMetrics, LlamaTimings, Usage, VllmMetrics}; -use serde_json::{Value, json}; - -use super::*; - -/// Feeds every payload into a fresh accumulator, as the read loop does. -fn accumulate(payloads: &[Value]) -> StreamAccumulator { - let mut accumulator = StreamAccumulator::new(); - for payload in payloads { - accumulator - .apply(&payload.to_string(), &|_| {}) - .expect("fixture payloads are well-formed"); - } - accumulator -} - -fn text_and_stop() -> [Value; 2] { - [ - json!({ - "model": "qwen3-30b", - "choices": [{ "index": 0, "delta": { "content": "Hello!" }, "finish_reason": null }] - }), - json!({ - "model": "qwen3-30b", - "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] - }), - ] -} - -fn client_timing() -> ClientTiming { - ClientTiming::new(41.5).with_ttft_ms(Some(9.5)) -} - -#[test] -fn finish_merges_every_reported_section_and_the_client_clock_into_one_call_metrics() { - let [text, stop] = text_and_stop(); - let summary = json!({ - "model": "qwen3-30b", - "choices": [], - "usage": { - "prompt_tokens": 20, - "completion_tokens": 5, - "total_tokens": 25, - "prompt_tokens_details": { "cached_tokens": 16 }, - "completion_tokens_details": { "reasoning_tokens": 2 } - }, - "timings": { - "prompt_n": 20, "prompt_ms": 12.5, "prompt_per_second": 1600.0, - "predicted_n": 5, "predicted_ms": 30.5, "predicted_per_second": 98.5 - }, - "metrics": { "time_to_first_token_ms": 8.5 } - }); - let completion = accumulate(&[text, stop, summary]) - .finish(json!({ "model": "m" }), Some(client_timing())) - .expect("a complete turn finishes"); - assert_eq!( - completion.metrics(), - Some( - &CallMetrics::new() - .with_usage(Some( - Usage::new(20, 5, 25) - .with_cached_tokens(Some(16)) - .with_reasoning_tokens(Some(2)) - )) - .with_llama(Some(LlamaTimings::new(20, 12.5, 1600.0, 5, 30.5, 98.5))) - .with_vllm(Some( - VllmMetrics::new().with_time_to_first_token_ms(Some(8.5)) - )) - .with_client(Some(client_timing())) - ) - ); -} - -#[test] -fn a_stream_with_no_usage_chunk_yields_no_usage() { - let completion = accumulate(&text_and_stop()) - .finish(json!({ "model": "m" }), Some(client_timing())) - .expect("a complete turn finishes"); - let metrics = completion - .metrics() - .expect("the client clock alone still makes metrics"); - assert_eq!(metrics.usage, None); - assert_eq!(metrics.llama, None); - assert_eq!(metrics.vllm, None); - assert_eq!(metrics.client, Some(client_timing())); -} - -#[test] -fn a_turn_nothing_measured_has_no_metrics_at_all() { - let completion = accumulate(&text_and_stop()) - .finish(json!({ "model": "m" }), None) - .expect("a complete turn finishes"); - assert_eq!(completion.metrics(), None); -} - -#[test] -fn a_malformed_usage_section_degrades_and_leaves_the_other_sections() { - let [text, stop] = text_and_stop(); - let summary = json!({ - "choices": [], - "usage": "broken", - "timings": { - "prompt_n": 7, "prompt_ms": 12.5, "prompt_per_second": 560.0, - "predicted_n": 3, "predicted_ms": 30.5, "predicted_per_second": 98.5 - } - }); - let completion = accumulate(&[text, stop, summary]) - .finish(Value::Null, None) - .expect("metadata never fails a usable turn"); - let metrics = completion.metrics().expect("the timings were measured"); - assert_eq!(metrics.usage, None, "a malformed usage degrades"); - assert!(metrics.llama.is_some(), "its sibling section survives"); - assert_eq!(completion.metadata_diagnostics().len(), 1); -} - -#[test] -fn finish_attaches_the_request_and_the_reassembled_response_as_the_raw_exchange() { - let request = json!({ "model": "m", "messages": [{ "role": "user", "content": "hi" }] }); - let accumulator = accumulate(&text_and_stop()); - let response = accumulate(&text_and_stop()).into_body(); - let completion = accumulator - .finish(request.clone(), None) - .expect("a complete turn finishes"); - let raw = completion - .raw() - .expect("the stream reader attaches the raw exchange"); - assert_eq!(raw.request(), &request); - assert_eq!(raw.response(), &response); - assert_eq!( - raw.response().pointer("/choices/0/message/content"), - Some(&json!("Hello!")), - "the response is the buffered shape a non-streaming backend returns" - ); -} diff --git a/crates/harness-gateway-client/src/wire/stream-tests.rs b/crates/harness-gateway-client/src/wire/stream-tests.rs deleted file mode 100644 index c5619e698..000000000 --- a/crates/harness-gateway-client/src/wire/stream-tests.rs +++ /dev/null @@ -1,490 +0,0 @@ -//! Tests for the streaming accumulator and the SSE data-line scanner. - -use runvil::model::CompletionResult; -use runvil::{ErrorCode, ErrorFact}; -use serde_json::Value; - -use super::*; - -fn no_delta(_: StreamDelta) {} - -/// Feeds every `data:` payload into a fresh accumulator and returns it. -fn accumulate(payloads: &[Value]) -> StreamAccumulator { - let mut accumulator = StreamAccumulator::new(); - for payload in payloads { - accumulator - .apply(&payload.to_string(), &no_delta) - .expect("fixture payloads are well-formed"); - } - accumulator -} - -fn content_chunk(text: &str) -> Value { - serde_json::json!({ - "model": "qwen3-30b", - "choices": [{ "index": 0, "delta": { "content": text }, "finish_reason": null }] - }) -} - -#[test] -fn scanner_splits_data_lines_and_skips_noise() { - let mut scanner = SseScanner::new(); - scanner.extend(b": comment\nevent: message\ndata: {\"a\":1}\r\n\ndata: [DO"); - assert_eq!(scanner.next_data().as_deref(), Some("{\"a\":1}")); - assert_eq!(scanner.next_data(), None, "partial line stays buffered"); - scanner.extend(b"NE]\n"); - assert_eq!(scanner.next_data().as_deref(), Some("[DONE]")); -} - -#[test] -fn a_data_line_read_one_byte_at_a_time_is_returned_whole_once_its_newline_arrives() { - let stream = b"data: {\"a\":1}\r\n: keep-alive\ndata: [DONE]\n"; - let newlines: Vec = (0..stream.len()) - .filter(|index| stream[*index] == b'\n') - .collect(); - let mut scanner = SseScanner::new(); - let mut returned = Vec::new(); - for (index, byte) in stream.iter().enumerate() { - scanner.extend(std::slice::from_ref(byte)); - while let Some(data) = scanner.next_data() { - returned.push((index, data)); - } - } - assert_eq!( - returned, - [ - (newlines[0], "{\"a\":1}".to_owned()), - (newlines[2], "[DONE]".to_owned()), - ] - ); -} - -#[test] -fn a_chunk_with_a_null_error_parses_as_an_ordinary_chunk() { - let mut accumulator = StreamAccumulator::new(); - let chunk = serde_json::json!({ - "error": null, - "choices": [{ "index": 0, "delta": { "content": "hi" } }] - }); - let applied = accumulator - .apply(&chunk.to_string(), &no_delta) - .expect("a null error is not an error envelope"); - assert_eq!(applied, Applied::Chunk { delta: true }); - assert_eq!( - accumulator - .into_body() - .pointer("/choices/0/message/content") - .and_then(Value::as_str), - Some("hi") - ); -} - -#[test] -fn streamed_accumulation_matches_the_buffered_fixture_byte_for_byte() { - // The buffered llama.cpp fixture from the normalize suite, split - // into a streamed form: the reassembled body must normalize to the - // same turn and metadata, with the answer text byte-identical. - let usage = - serde_json::json!({ "completion_tokens": 3, "prompt_tokens": 7, "total_tokens": 10 }); - let timings = serde_json::json!({ - "prompt_n": 7, "prompt_ms": 12.5, "prompt_per_second": 560.0, - "predicted_n": 3, "predicted_ms": 30.5, "predicted_per_second": 98.5 - }); - let accumulator = accumulate(&[ - content_chunk("Hel"), - content_chunk("lo \u{1F980}"), - content_chunk("!"), - serde_json::json!({ - "model": "qwen3-30b", - "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] - }), - serde_json::json!({ - "model": "qwen3-30b", - "choices": [], - "usage": usage, - "timings": timings - }), - ]); - let body = accumulator.into_body(); - assert_eq!( - body.pointer("/choices/0/message/content") - .and_then(Value::as_str), - Some("Hello \u{1F980}!"), - "fragments concatenate byte-for-byte" - ); - assert_eq!( - body.pointer("/choices/0/finish_reason") - .and_then(Value::as_str), - Some("stop") - ); - assert_eq!(body.get("model").and_then(Value::as_str), Some("qwen3-30b")); - assert_eq!(body.get("usage"), Some(&usage), "usage kept verbatim"); - assert_eq!(body.get("timings"), Some(&timings), "timings kept verbatim"); -} - -#[test] -fn finish_normalizes_the_turn_and_returns_the_metadata() { - let accumulator = accumulate(&[ - content_chunk("Hel"), - content_chunk("lo!"), - serde_json::json!({ - "model": "qwen3-30b", - "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] - }), - serde_json::json!({ - "model": "qwen3-30b", - "choices": [], - "usage": { "completion_tokens": 3, "prompt_tokens": 7, "total_tokens": 10 } - }), - ]); - let request = serde_json::json!({ "model": "m" }); - let completion = accumulator - .finish(request.clone(), None) - .expect("a streamed text turn finishes"); - match completion.result() { - CompletionResult::Text(text) => assert_eq!(text, "Hello!"), - other => panic!("expected text, got {other:?}"), - } - assert_eq!(completion.finish_reason(), Some("stop")); - assert_eq!( - completion.model(), - "m", - "the label is the requested model, not the body's" - ); - let metrics = completion.metrics().expect("the usage chunk was measured"); - assert_eq!( - metrics.usage.as_ref().map(|usage| usage.total_tokens), - Some(10) - ); - assert!(metrics.client.is_none()); - assert_eq!(completion.raw().map(RawExchange::request), Some(&request)); -} - -#[test] -fn finish_fails_a_tool_call_batch_truncated_by_length_or_content_filter() { - // A length or content_filter finish with tool calls means the batch may - // hold partial JSON arguments; the whole batch fails rather than - // executing a fragment. - for reason in ["length", "content_filter"] { - let accumulator = accumulate(&[ - serde_json::json!({ "choices": [{ "index": 0, "delta": { "tool_calls": [{ - "index": 0, "id": "c1", "type": "function", - "function": { "name": "t", "arguments": "{\"whole\":true}" } - }] } }] }), - serde_json::json!({ - "choices": [{ "index": 0, "delta": {}, "finish_reason": reason }] - }), - ]); - let error = accumulator - .finish(Value::Null, None) - .expect_err("a truncated tool-call batch must fail"); - assert_eq!( - error.code, - ErrorCode::MalformedResponse, - "finish_reason {reason:?}" - ); - assert!( - error.to_string().contains("truncated"), - "the error's message names the truncation: {error:?}" - ); - assert_eq!(error.message, error.prose); - } -} - -#[test] -fn finish_fails_a_tool_call_batch_sharing_an_id_as_a_retryable_malformed_reply() { - // The body walk builds each call alone, so the duplicate id is refused - // only when `finish` builds the completion, and that refusal must reach - // the caller as the retryable kind a bad reply earns. - let error = accumulate(&[ - serde_json::json!({ "choices": [{ "index": 0, "delta": { "tool_calls": [ - { "index": 0, "id": "dup", "type": "function", - "function": { "name": "a", "arguments": "{}" } }, - { "index": 1, "id": "dup", "type": "function", - "function": { "name": "b", "arguments": "{}" } } - ] } }] }), - serde_json::json!({ - "choices": [{ "index": 0, "delta": {}, "finish_reason": "tool_calls" }] - }), - ]) - .finish(Value::Null, None) - .expect_err("two calls sharing an id must fail"); - assert_eq!(error.code, ErrorCode::MalformedResponse); -} - -#[test] -fn finish_returns_truncated_text_with_its_finish_reason() { - // The truncation rule fails tool-call batches only: partial TEXT is - // returned with finish_reason "length" so the caller can report it. - let completion = accumulate(&[ - content_chunk("partial answ"), - serde_json::json!({ - "choices": [{ "index": 0, "delta": {}, "finish_reason": "length" }] - }), - ]) - .finish(Value::Null, None) - .expect("truncated text is still a product"); - match completion.result() { - CompletionResult::Text(text) => assert_eq!(text, "partial answ"), - other => panic!("expected text, got {other:?}"), - } - assert_eq!(completion.finish_reason(), Some("length")); -} - -#[test] -fn finish_hard_fails_on_an_empty_model_reply() { - // A stream that holds only reasoning and a stop finish has no - // product; the accumulated turn must fail exactly like the buffered - // equivalent, with the finish_reason surviving. - let error = accumulate(&[ - serde_json::json!({ "choices": [{ "index": 0, - "delta": { "reasoning_content": "ignored" } }] }), - serde_json::json!({ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] }), - ]) - .finish(Value::Null, None) - .expect_err("empty product must fail"); - assert_eq!(error.code, ErrorCode::EmptyReply); - assert_eq!( - error - .facts - .get(&ErrorFact::FinishReason) - .and_then(Value::as_str), - Some("stop"), - "the finish_reason must survive the conversion into the error" - ); - assert_eq!( - error.to_string(), - "the model replied with no text and no tool calls: \ -reasoning content was present but ignored", - "the ignored-reasoning note extends the message" - ); - assert_eq!(error.message, error.prose); -} - -#[test] -fn tool_call_fragments_buffer_across_chunks_by_index() { - // OpenAI streams a call's name once and its arguments in pieces; - // interleaved fragments for two calls must land on their own - // buffers, keyed by `index`, and reassemble whole. - let accumulator = accumulate(&[ - serde_json::json!({ "choices": [{ "index": 0, "delta": { "tool_calls": [ - { "index": 0, "id": "call_a", "type": "function", - "function": { "name": "search", "arguments": "{\"qu" } } - ] } }] }), - serde_json::json!({ "choices": [{ "index": 0, "delta": { "tool_calls": [ - { "index": 1, "id": "call_b", "type": "function", - "function": { "name": "fetch", "arguments": "{\"url\":\"x\"}" } } - ] } }] }), - serde_json::json!({ "choices": [{ "index": 0, "delta": { "tool_calls": [ - { "index": 0, "function": { "arguments": "ery\":\"a\"}" } } - ] } }] }), - serde_json::json!({ - "choices": [{ "index": 0, "delta": {}, "finish_reason": "tool_calls" }] - }), - ]); - assert!(!accumulator.tool_calls.is_empty()); - let body = accumulator.into_body(); - let calls = body - .pointer("/choices/0/message/tool_calls") - .and_then(Value::as_array) - .expect("tool calls reassembled"); - assert_eq!(calls.len(), 2); - assert_eq!(calls[0]["id"], "call_a"); - assert_eq!(calls[0]["function"]["arguments"], "{\"query\":\"a\"}"); - assert_eq!(calls[1]["id"], "call_b"); - assert_eq!(calls[1]["function"]["name"], "fetch"); -} - -#[test] -fn reasoning_and_text_deltas_reach_the_callback_separated_in_order() { - let seen = std::sync::Mutex::new(Vec::new()); - let mut accumulator = StreamAccumulator::new(); - let record = |delta: StreamDelta| seen.lock().expect("delta log").push(delta); - for payload in [ - serde_json::json!({ "choices": [{ "index": 0, - "delta": { "reasoning_content": "think" } }] }), - serde_json::json!({ "choices": [{ "index": 0, "delta": { "content": "ans" } }] }), - serde_json::json!({ "choices": [{ "index": 0, "delta": { "content": "wer" } }] }), - ] { - accumulator - .apply(&payload.to_string(), &record) - .expect("well-formed"); - } - assert_eq!( - *seen.lock().expect("delta log"), - vec![ - StreamDelta::Reasoning("think".to_owned()), - StreamDelta::Text("ans".to_owned()), - StreamDelta::Text("wer".to_owned()), - ] - ); - let body = accumulator.into_body(); - assert_eq!( - body.pointer("/choices/0/message/reasoning_content") - .and_then(Value::as_str), - Some("think"), - "reasoning stays a side channel on the reassembled message" - ); - assert_eq!( - body.pointer("/choices/0/message/content") - .and_then(Value::as_str), - Some("answer") - ); -} - -#[test] -fn empty_choices_usage_chunk_is_metadata_not_a_turn() { - // The `stream_options.include_usage` summary chunk has an empty - // `choices` array; it must be consumed as metadata, never indexed - // for a choice and never counted as a content delta. - let mut accumulator = StreamAccumulator::new(); - let applied = accumulator - .apply( - &serde_json::json!({ "choices": [], "usage": { "prompt_tokens": 1, - "completion_tokens": 2, "total_tokens": 3 } }) - .to_string(), - &no_delta, - ) - .expect("summary chunk is well-formed"); - assert_eq!(applied, Applied::Chunk { delta: false }); - let body = accumulator.into_body(); - assert_eq!( - body.pointer("/usage/total_tokens").and_then(Value::as_u64), - Some(3) - ); -} - -#[test] -fn error_envelope_fails_the_stream_with_the_escaped_message_in_its_message_only() { - let mut accumulator = StreamAccumulator::new(); - let error = accumulator - .apply( - &serde_json::json!({ "error": { "message": "upstream\ndied", "code": "x" } }) - .to_string(), - &no_delta, - ) - .expect_err("an error envelope must fail the stream"); - assert_eq!(error.code, ErrorCode::Transport); - assert_eq!(error.prose, "the connection to the model backend failed"); - assert_eq!( - error.message, - "the connection to the model backend failed: upstream\\ndied" - ); -} - -#[test] -fn error_envelope_naming_a_context_limit_is_a_context_overflow() { - let mut accumulator = StreamAccumulator::new(); - let error = accumulator - .apply( - &serde_json::json!({ "error": { - "message": "This model's maximum context length is 4096 tokens, \ - however you requested 5000 tokens", - "type": "invalid_request_error" - } }) - .to_string(), - &no_delta, - ) - .expect_err("an error envelope must fail the stream"); - assert_eq!(error.code, ErrorCode::ContextOverflow); - assert_eq!( - ( - error.facts.get(&ErrorFact::Count), - error.facts.get(&ErrorFact::Limit) - ), - (Some(&Value::from(5000)), Some(&Value::from(4096))) - ); -} - -#[test] -fn malformed_chunks_are_rejected_not_skipped() { - let cases: [(&str, &str); 4] = [ - ("not json", "undecodable payload"), - ("{\"choices\":{}}", "non-array choices"), - ( - "{\"choices\":[{\"index\":0,\"delta\":{\"content\":7}}]}", - "non-string content", - ), - ( - "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"x\"}]}}]}", - "fragment without index", - ), - ]; - for (payload, label) in cases { - let mut accumulator = StreamAccumulator::new(); - let error = accumulator.apply(payload, &no_delta).expect_err(label); - assert_eq!( - error.code, - ErrorCode::MalformedResponse, - "{label}: {error:?}" - ); - assert!( - error - .prose - .starts_with("the model backend sent a reply that could not be understood: "), - "{label}: the prose extends the fixed phrase: {error:?}" - ); - } -} - -#[test] -fn a_malformed_chunk_keeps_the_decode_error_in_its_message_only() { - let decode = serde_json::from_str::("{ not json") - .expect_err("the payload is not JSON") - .to_string(); - let mut accumulator = StreamAccumulator::new(); - let error = accumulator - .apply("{ not json", &no_delta) - .expect_err("undecodable chunk must fail"); - assert!( - error.message.contains(&decode), - "the message holds the JSON decode error: {error:?}" - ); - assert!(!error.prose.contains(&decode), "{error:?}"); -} - -#[test] -fn non_first_choices_are_ignored_like_the_buffered_parser() { - let accumulator = accumulate(&[ - content_chunk("kept"), - serde_json::json!({ "choices": [{ "index": 1, - "delta": { "content": "dropped" } }] }), - ]); - let body = accumulator.into_body(); - assert_eq!( - body.pointer("/choices/0/message/content") - .and_then(Value::as_str), - Some("kept") - ); -} - -#[test] -fn no_content_at_all_reassembles_null_content() { - let accumulator = accumulate(&[serde_json::json!({ - "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] - })]); - let body = accumulator.into_body(); - assert_eq!( - body.pointer("/choices/0/message/content"), - Some(&Value::Null), - "a stream with no content fragments yields a null content" - ); -} - -#[test] -fn escape_controls_neutralizes_control_bytes_and_bounds_length() { - // Newlines and other control characters are escaped, not passed - // through, so a body cannot forge log lines. - let escaped = escape_controls("line1\nline2\r\u{7}end", 2000); - assert!(!escaped.contains('\n'), "raw newline must be escaped"); - assert!( - !escaped.contains('\r'), - "raw carriage return must be escaped" - ); - assert!( - escaped.contains("\\n"), - "escaped newline expected, got {escaped}" - ); - assert_eq!(escape_controls("", 2000), "(empty body)"); - assert_eq!(escape_controls("abcdef", 3), "abc"); -} diff --git a/crates/harness-gateway-client/src/wire/stream.rs b/crates/harness-gateway-client/src/wire/stream.rs deleted file mode 100644 index 2a1db18e2..000000000 --- a/crates/harness-gateway-client/src/wire/stream.rs +++ /dev/null @@ -1,496 +0,0 @@ -//! SSE reassembly for the always-streaming completion protocol. -//! -//! [`SseScanner`] splits the raw byte stream into `data:` payloads, and -//! [`StreamAccumulator`] folds those payloads back into the buffered -//! chat-completion body shape, then [`finishes`](StreamAccumulator::finish) -//! it into a [`Completion`]. The strict turn rules stay in [`super::parse`]: -//! the accumulator only reassembles, so streamed and buffered turns are -//! judged by one rule set. -//! -//! No HTTP happens here. The transport that reads the bytes off the wire -//! hands them to the read loop, which feeds the scanner, hands payloads to -//! the accumulator, and takes the completion from `finish`. -//! -//! The progress subscription in the model vocabulary deliberately has its -//! own SSE decoder, and neither can substitute for the other: that one -//! decodes blank-line-terminated event blocks into typed progress items and -//! stays lossy (an undecodable block is one `Err` item in a telemetry -//! stream), while this one hands raw `data:` payloads to a transport loop -//! that meters bytes and timing and hard-fails on the first malformed -//! chunk, because a completion's product must be whole. - -use std::collections::BTreeMap; - -use runvil::Error; -use runvil::metrics::ClientTiming; -use runvil::model::{Completion, RawExchange}; -use serde_json::{Map, Value}; - -use super::classify::make_classify_stream_error; -use super::delta::StreamDelta; -use super::parse::{normalize, response_metadata}; -use crate::failure::{decoded, make_malformed_error}; - -/// Splits a raw SSE byte stream into `data:` payloads. -/// -/// Blank lines, `:` comments, and non-`data:` fields (`event:`, `id:`, -/// `retry:`) are skipped; the caller sees only payload text. -/// -/// Crate-private: a transport reaches it only through -/// [`read_completion_stream`](crate::read_completion_stream). -#[derive(Debug, Default)] -pub(super) struct SseScanner { - buffer: Vec, - /// How much of `buffer` is already known to hold no `\n`. - scanned: usize, -} - -impl SseScanner { - /// A scanner with an empty buffer. - #[must_use] - pub(super) fn new() -> SseScanner { - SseScanner { - buffer: Vec::new(), - scanned: 0, - } - } - - /// Buffers freshly received bytes for line extraction. - pub(super) fn extend(&mut self, bytes: &[u8]) { - self.buffer.extend_from_slice(bytes); - } - - /// Returns the next complete `data:` payload, or `None` until one is - /// fully buffered. - pub(super) fn next_data(&mut self) -> Option { - loop { - let Some(offset) = self.buffer[self.scanned..] - .iter() - .position(|byte| *byte == b'\n') - else { - self.scanned = self.buffer.len(); - return None; - }; - let end = self.scanned + offset; - self.scanned = 0; - let line: Vec = self.buffer.drain(..=end).collect(); - let line = String::from_utf8_lossy(&line); - let line = line.trim_end_matches(['\r', '\n']); - if line.is_empty() || line.starts_with(':') { - continue; - } - let Some(data) = line.strip_prefix("data:") else { - continue; - }; - return Some(data.trim_start().to_owned()); - } - } -} - -/// The outcome of applying one `data:` payload. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum Applied { - /// The payload advanced the accumulation; `delta` is true when it - /// included answer text, reasoning, or a tool-call fragment (the - /// TTFT/ITL clock ticks on those, never on role or summary chunks). - Chunk { - /// Whether the chunk included generated content. - delta: bool, - }, - /// The payload was the terminal `[DONE]` sentinel. - Done, -} - -/// One tool call assembled from streamed fragments, keyed by the fragment -/// `index`. `id`, `name`, and `arguments` each grow by string concatenation -/// as fragments arrive, per the `OpenAI` streaming contract. -#[derive(Debug, Default)] -struct ToolCallParts { - id: String, - name: String, - arguments: String, -} - -/// Accumulates streamed chunks into the buffered chat-completion shape. -/// -/// Only the first choice (`index == 0`) is accumulated, mirroring the -/// buffered normalizer, which reads `choices[0]` alone. Metadata sections -/// (`usage`, llama.cpp `timings`, vLLM `metrics`) are kept verbatim from -/// whichever chunk held them last, including the empty-choices summary -/// chunk `stream_options.include_usage` appends, and are handed to the -/// lenient metadata parser unjudged. -/// -/// Crate-private: a transport reaches it only through -/// [`read_completion_stream`](crate::read_completion_stream). -#[derive(Debug, Default)] -pub(super) struct StreamAccumulator { - /// Answer text; `None` until the first `content` fragment arrives. - content: Option, - /// Reasoning side-channel text; `None` until the first fragment. - reasoning: Option, - tool_calls: BTreeMap, - finish_reason: Option, - model: Option, - /// Raw top-level metadata sections, latest occurrence wins. - sections: Map, -} - -impl StreamAccumulator { - /// An empty accumulator. - #[must_use] - pub(super) fn new() -> StreamAccumulator { - StreamAccumulator::default() - } - - /// Applies one `data:` payload, invoking `on_delta` for each text or - /// reasoning fragment it contains. - /// - /// # Errors - /// Returns an [`Error`] with code `MalformedResponse` when the - /// payload is not valid JSON or a recognized field has the wrong - /// shape, and the [`make_classify_stream_error`] result when the payload is - /// a mid-stream error envelope (`Transport` unless its text names a - /// known cause). - pub(super) fn apply( - &mut self, - data: &str, - on_delta: &impl Fn(StreamDelta), - ) -> Result { - if data == "[DONE]" { - return Ok(Applied::Done); - } - let chunk: Value = serde_json::from_str(data).map_err(|error| { - make_malformed_error("stream chunk was not valid JSON").with_cause(&error) - })?; - // A mid-stream `error` envelope is how the gateway (and llama.cpp) - // report a failure after the 200 has already been sent: the - // completion died in flight, so it is a transport failure unless - // the bounded, control-escaped message names a known cause. - if let Some(envelope) = chunk.get("error").filter(|error| !error.is_null()) { - let message = envelope - .get("message") - .and_then(Value::as_str) - .unwrap_or("stream error envelope omitted its message"); - return Err(make_classify_stream_error(&escape_controls(message, 2000))); - } - if let Some(Value::String(model)) = chunk.get("model") - && !model.is_empty() - { - self.model = Some(model.clone()); - } - for key in ["usage", "timings", "metrics"] { - if let Some(section) = chunk.get(key) - && !section.is_null() - { - self.sections.insert(key.to_owned(), section.clone()); - } - } - // Absent or empty `choices` is the summary-chunk shape - // (`stream_options.include_usage`): metadata only, nothing to index. - let choices = match chunk.get("choices") { - None | Some(Value::Null) => return Ok(Applied::Chunk { delta: false }), - Some(Value::Array(choices)) => choices, - Some(_) => { - return Err(make_malformed_error( - "stream chunk `choices` was present but not an array", - )); - } - }; - let mut held_delta = false; - for choice in choices { - if self.apply_choice(choice, on_delta)? { - held_delta = true; - } - } - Ok(Applied::Chunk { delta: held_delta }) - } - - /// Applies one streamed choice, returning whether it held content. - fn apply_choice( - &mut self, - choice: &Value, - on_delta: &impl Fn(StreamDelta), - ) -> Result { - let Some(index) = choice.get("index").and_then(Value::as_u64) else { - return Err(make_malformed_error("stream choice had no integer index")); - }; - // Mirror the buffered normalizer: the first choice is the turn. - if index != 0 { - return Ok(false); - } - match choice.get("finish_reason") { - None | Some(Value::Null) => {} - Some(Value::String(reason)) => self.finish_reason = Some(reason.clone()), - Some(_) => { - return Err(make_malformed_error( - "stream choice `finish_reason` was present but not a string", - )); - } - } - let delta = match choice.get("delta") { - // A finish-only chunk may omit the delta entirely. - None | Some(Value::Null) => return Ok(false), - Some(delta @ Value::Object(_)) => delta, - Some(_) => { - return Err(make_malformed_error( - "stream choice `delta` was present but not an object", - )); - } - }; - let mut held = false; - if let Some(text) = append_string_fragment(delta, "content", &mut self.content, "content")? - && !text.is_empty() - { - held = true; - on_delta(StreamDelta::Text(text)); - } - for key in ["reasoning_content", "reasoning", "thinking"] { - if let Some(text) = append_string_fragment(delta, key, &mut self.reasoning, key)? - && !text.is_empty() - { - held = true; - on_delta(StreamDelta::Reasoning(text)); - } - } - match delta.get("tool_calls") { - None | Some(Value::Null) => {} - Some(Value::Array(fragments)) => { - for fragment in fragments { - self.apply_tool_fragment(fragment)?; - } - if !fragments.is_empty() { - held = true; - } - } - Some(_) => { - return Err(make_malformed_error( - "stream delta `tool_calls` was present but not an array", - )); - } - } - Ok(held) - } - - /// Merges one tool-call fragment into its index-keyed buffer. - fn apply_tool_fragment(&mut self, fragment: &Value) -> Result<(), Error> { - let Some(index) = fragment.get("index").and_then(Value::as_u64) else { - return Err(make_malformed_error( - "stream tool-call fragment had no integer index", - )); - }; - let parts = self.tool_calls.entry(index).or_default(); - match fragment.get("id") { - None | Some(Value::Null) => {} - Some(Value::String(id)) => parts.id.push_str(id), - Some(_) => { - return Err(make_malformed_error( - "stream tool-call fragment `id` was not a string", - )); - } - } - let function = match fragment.get("function") { - None | Some(Value::Null) => return Ok(()), - Some(function @ Value::Object(_)) => function, - Some(_) => { - return Err(make_malformed_error( - "stream tool-call fragment `function` was not an object", - )); - } - }; - for (key, slot) in [ - ("name", &mut parts.name), - ("arguments", &mut parts.arguments), - ] { - match function.get(key) { - None | Some(Value::Null) => {} - Some(Value::String(piece)) => slot.push_str(piece), - Some(_) => { - return Err(make_malformed_error(format!( - "stream tool-call fragment `{key}` was not a string" - ))); - } - } - } - Ok(()) - } - - /// Finishes the accumulation into the [`Completion`] the turn produced: - /// the truncation rule, the strict turn normalizer, and the lenient - /// metadata parser, in that order. `request_body` is the body the - /// transport sent; the completion is labeled with the model it names, - /// in place of the name the response gave, and with the reassembled - /// response it becomes the completion's [`RawExchange`] for the debug - /// capture. `client_timing` - /// is what the transport measured on its own clock; it joins the - /// backend's sections in the completion's - /// [`CallMetrics`](runvil::metrics::CallMetrics), which is - /// absent when nothing was measured. - /// - /// # Errors - /// Returns an [`Error`] with code `MalformedResponse` when a - /// tool-call batch was cut short by a `length` or `content_filter` - /// finish (partial arguments must not execute), the normalizer's own - /// errors (`EmptyReply` for a turn with neither non-empty tool calls nor - /// non-empty text), and the validating constructor's refusal, reported - /// as a `MalformedResponse` (two calls sharing an id). - pub(super) fn finish( - self, - request_body: Value, - client_timing: Option, - ) -> Result { - // The truncation rule runs before normalization: a tool-call batch - // cut short by `length` or `content_filter` may hold partial JSON - // arguments, and partial arguments must not execute. - if !self.tool_calls.is_empty() - && matches!( - self.finish_reason.as_deref(), - Some("length" | "content_filter") - ) - { - let reason = self.finish_reason.unwrap_or_default(); - return Err(make_malformed_error(format!( - "tool-call batch truncated by finish_reason {reason:?}: \ - partial arguments must not execute" - ))); - } - let response_body = self.into_body(); - let turn = normalize(&response_body)?; - let metadata = response_metadata(&response_body); - let metrics = metadata.metrics.with_client(client_timing); - let measured = metrics.usage.is_some() - || metrics.llama.is_some() - || metrics.vllm.is_some() - || metrics.client.is_some(); - let mut completion = Completion::from_result(turn.outcome, metadata.model) - .map_err(decoded)? - .with_metadata_diagnostics(metadata.diagnostics); - if let Some(Value::String(model)) = request_body.get("model") { - completion = completion.with_model(model.clone()); - } - if let Some(reason) = turn.finish_reason { - completion = completion.with_finish_reason(reason); - } - if let Some(reasoning) = turn.reasoning_content { - completion = completion.with_reasoning_content(reasoning); - } - if measured { - completion = completion.with_metrics(metrics); - } - Ok(completion.with_raw(RawExchange::new(request_body, response_body))) - } - - /// Reassembles the accumulation into the buffered chat-completion body - /// shape, ready for the strict turn normalizer and the lenient metadata - /// parser. - fn into_body(self) -> Value { - let mut message = Map::new(); - message.insert("role".to_owned(), Value::String("assistant".to_owned())); - message.insert( - "content".to_owned(), - match self.content { - Some(text) => Value::String(text), - None => Value::Null, - }, - ); - if let Some(reasoning) = self.reasoning.filter(|text| !text.is_empty()) { - message.insert("reasoning_content".to_owned(), Value::String(reasoning)); - } - if !self.tool_calls.is_empty() { - let calls: Vec = self - .tool_calls - .into_values() - .map(|parts| { - serde_json::json!({ - "id": parts.id, - "type": "function", - "function": { "name": parts.name, "arguments": parts.arguments }, - }) - }) - .collect(); - message.insert("tool_calls".to_owned(), Value::Array(calls)); - } - let mut choice = Map::new(); - choice.insert("index".to_owned(), Value::from(0)); - choice.insert("message".to_owned(), Value::Object(message)); - if let Some(reason) = self.finish_reason { - choice.insert("finish_reason".to_owned(), Value::String(reason)); - } - let mut body = Map::new(); - if let Some(model) = self.model { - body.insert("model".to_owned(), Value::String(model)); - } - body.insert( - "choices".to_owned(), - Value::Array(vec![Value::Object(choice)]), - ); - for (key, value) in self.sections { - body.insert(key, value); - } - Value::Object(body) - } -} - -/// Appends a string fragment under `key` from `delta` into `slot`, -/// returning the fragment when one was present. -/// -/// Absent and JSON-null are no fragment; a present non-string is a -/// malformed shape named after `label`. -fn append_string_fragment( - delta: &Value, - key: &str, - slot: &mut Option, - label: &str, -) -> Result, Error> { - match delta.get(key) { - None | Some(Value::Null) => Ok(None), - Some(Value::String(text)) => { - slot.get_or_insert_with(String::new).push_str(text); - Ok(Some(text.clone())) - } - Some(_) => Err(make_malformed_error(format!( - "stream delta `{label}` was present but not a string" - ))), - } -} - -/// Truncates a backend response body and escapes its control characters so it -/// is safe to show in a diagnostic. -/// -/// The function keeps the first `max` characters of `body`. It replaces each -/// control character among them, including newlines and carriage returns, with -/// its escaped form, such as `\n` or `\u{1b}`. Escaping can make the result -/// longer than `max` characters. An empty body returns the fixed marker -/// `(empty body)`. -/// -/// The escaping stops a backend body from forging log lines or slipping -/// terminal control sequences into a diagnostic. -/// -/// A transport passes the error body of a response with a failure status -/// through this function before it hands the body to -/// [`make_classify_http_failure_error`](crate::make_classify_http_failure_error). Every transport -/// therefore truncates and escapes a backend body by the same rule. -#[must_use] -pub fn escape_controls(body: &str, max: usize) -> String { - if body.is_empty() { - return "(empty body)".to_owned(); - } - let mut escaped = String::with_capacity(body.len()); - for ch in body.chars().take(max) { - if ch.is_control() { - for part in ch.escape_default() { - escaped.push(part); - } - } else { - escaped.push(ch); - } - } - escaped -} - -#[cfg(test)] -#[path = "stream-tests.rs"] -mod tests; - -#[cfg(test)] -#[path = "stream-tests-metrics.rs"] -mod metrics_tests; diff --git a/crates/harness-internal/plugin-loader/Cargo.toml b/crates/harness-internal/plugin-loader/Cargo.toml deleted file mode 100644 index b29d99ab8..000000000 --- a/crates/harness-internal/plugin-loader/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "harness-plugin-loader" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -# Opens a Plugin's dynamic library and resolves its `runvil_plugin` symbol. -libloading.workspace = true -# The Pack registry the inserts take, `PackId`, which validates a Plugin's -# name, and the universal error a failed load is. -runvil-plugin.workspace = true -# The C contract every entry point follows, version 0.4, as Rust types. -runvil-plugin-sys.workspace = true -# A tool's parameters arrive as JSON text and its arguments leave as JSON -# text, because the C contract carries no JSON type. -serde_json.workspace = true - -[dev-dependencies] -# `testing::TestCall`, which lends the unit tests the context a tool call -# borrows. -runvil-plugin = { workspace = true, features = ["test-support"] } - -[lints] -workspace = true diff --git a/crates/harness-internal/plugin-loader/src/call-tests.rs b/crates/harness-internal/plugin-loader/src/call-tests.rs deleted file mode 100644 index 4c0a471d5..000000000 --- a/crates/harness-internal/plugin-loader/src/call-tests.rs +++ /dev/null @@ -1,492 +0,0 @@ -//! Tests for a tool call through a Plugin's entry point: what each step -//! is sent, how a result becomes an output or a failure, when the future -//! is pending, and that RV_TOOL_CANCEL comes once for a call whose last -//! step returned RV_PENDING and for no other call. - -use std::future::Future; -use std::pin::pin; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::task::{Context, Poll, Wake, Waker}; - -use runvil_plugin::{ErrorCode, OutputTrust}; -use serde_json::json; - -use crate::fakes::*; - -/// The message of a call that failed because its Plugin misused it. -macro_rules! misuse_reason { - ($detail:expr) => { - concat!("the tool's Plugin misused the tool call: ", $detail) - }; -} - -/// Counts how often it is woken. -#[derive(Default)] -struct Wakes(AtomicUsize); - -impl Wake for Wakes { - fn wake(self: Arc) { - self.0.fetch_add(1, Ordering::SeqCst); - } -} - -pack_fake!( - echo, - { - /// Whether every step carried the contract's fields. - pub(super) static WELL_FORMED: AtomicUsize = AtomicUsize::new(1); - }, - new: |call| { - set_pack_run(call, instance(PACK_TAG)); - add_tool(call, "echo"); - add_tool(call, "page"); - add_tool(call, "fails") - }, - step: |call| { - if !contract_holds(call, RV_TOOL_CALL) || pack_run(call) != instance(PACK_TAG) { - WELL_FORMED.store(0, Ordering::SeqCst); - } - let args = step_args(call); - let (tool, arguments) = (text(args.tool), text(args.arguments)); - match tool.as_str() { - "echo" => finish(call, RV_RESULT_TRUSTED, &format!("{tool} {arguments}")), - "page" => finish(call, RV_RESULT_UNTRUSTED, "text from outside"), - _ => finish( - call, - arguments.parse().unwrap_or(RV_RESULT_OTHER), - &format!("failed as {arguments}"), - ), - } - } -); - -#[test] -fn a_call_sends_the_tool_name_and_the_arguments_as_json_text() { - let pack = made(echo::entry); - let output = call_tool(&*pack, "echo", json!({ "b": [1, 2], "a": "x" })).expect("the call"); - assert_eq!(output.text(), r#"echo {"a":"x","b":[1,2]}"#); - assert_eq!(output.trust(), OutputTrust::Trusted); - assert_eq!(echo::WELL_FORMED.load(Ordering::SeqCst), 1); - assert_eq!(echo::CANCELS.load(Ordering::SeqCst), 0); -} - -#[test] -fn an_untrusted_result_is_an_untrusted_output() { - let pack = made(echo::entry); - let output = call_tool(&*pack, "page", json!({})).expect("the call"); - assert_eq!(output.text(), "text from outside"); - assert_eq!(output.trust(), OutputTrust::Untrusted); -} - -#[test] -fn each_failure_kind_is_a_tool_error_of_its_code_with_the_text_as_message_and_prose() { - let pack = made(echo::entry); - for (kind, code) in [ - (RV_RESULT_INVALID_ARGUMENTS, ErrorCode::ToolInvalidArguments), - (RV_RESULT_BACKEND, ErrorCode::ToolBackend), - (RV_RESULT_TRANSPORT, ErrorCode::ToolTransport), - (RV_RESULT_OTHER, ErrorCode::ToolOther), - ] { - let failure = call_tool(&*pack, "fails", json!(kind)).expect_err("the call fails"); - assert_eq!(failure.code, code, "kind {kind}"); - assert_eq!(failure.message, format!("failed as {kind}")); - assert_eq!(failure.prose, failure.message); - } -} - -pack_fake!( - slow, - { - /// The `tool_call` each step was sent, in order. - pub(super) static STATES: Mutex> = Mutex::new(Vec::new()); - }, - new: |call| { - set_pack_run(call, instance(PACK_TAG)); - add_tool(call, "slow") - }, - step: |call| { - let steps = tool_call(call).addr(); - if let Ok(mut states) = STATES.lock() { - states.push(steps); - } - if steps < 2 { - set_tool_call(call, ptr::without_provenance_mut(steps + 1)); - return RV_PENDING; - } - finish(call, RV_RESULT_TRUSTED, "done") - } -); - -#[test] -fn a_pending_step_wakes_its_task_and_the_next_poll_sends_the_next_step() { - let pack = made(slow::entry); - let call = test_call("slow"); - let mut future = pin!(pack.call(call.context(), json!({}))); - let count = Arc::new(Wakes::default()); - let waker = Waker::from(Arc::clone(&count)); - let mut cx = Context::from_waker(&waker); - - assert!(future.as_mut().poll(&mut cx).is_pending()); - assert_eq!(count.0.load(Ordering::SeqCst), 1); - assert!(future.as_mut().poll(&mut cx).is_pending()); - assert_eq!(count.0.load(Ordering::SeqCst), 2); - let Poll::Ready(result) = future.as_mut().poll(&mut cx) else { - panic!("the third step finishes the call"); - }; - assert_eq!(result.expect("the call succeeds").text(), "done"); - assert_eq!( - count.0.load(Ordering::SeqCst), - 2, - "the step that ends the call wakes nothing" - ); - assert_eq!( - *slow::STATES.lock().expect("the lock holds"), - [0, 1, 2], - "each step gets the state the last one left, and the first gets NULL" - ); - assert_eq!( - slow::CANCELS.load(Ordering::SeqCst), - 0, - "a call that ended gets no cancel" - ); -} - -pack_fake!( - abandoned, - {}, - new: |call| { - set_pack_run(call, instance(PACK_TAG)); - add_tool(call, "slow") - }, - step: |call| { - set_tool_call(call, instance(0x300)); - RV_PENDING - } -); - -#[test] -fn dropping_a_pending_call_sends_one_cancel_with_its_state_before_the_delete() { - let pack = made(abandoned::entry); - let call = test_call("slow"); - let mut future = Box::pin(pack.call(call.context(), json!({}))); - let mut cx = Context::from_waker(Waker::noop()); - assert!(future.as_mut().poll(&mut cx).is_pending()); - assert_eq!(abandoned::CANCELS.load(Ordering::SeqCst), 0); - drop(future); - assert_eq!(abandoned::CANCELS.load(Ordering::SeqCst), 1); - assert_eq!(abandoned::CANCELED.load(Ordering::SeqCst), instance(0x300)); - drop(pack); - assert_eq!( - *abandoned::ORDER.lock().expect("the lock holds"), - [RV_TOOL_CANCEL, RV_PACK_DELETE, RV_PLUGIN_UNLOAD] - ); -} - -pack_fake!( - unsent, - {}, - new: |call| { - set_pack_run(call, instance(PACK_TAG)); - add_tool(call, "slow") - }, - step: |_call| RV_PENDING -); - -#[test] -fn a_call_that_was_never_polled_sends_nothing() { - let pack = made(unsent::entry); - let call = test_call("slow"); - drop(pack.call(call.context(), json!({}))); - assert_eq!(unsent::CANCELS.load(Ordering::SeqCst), 0); -} - -pack_fake!( - stateless, - {}, - new: |call| { - set_pack_run(call, instance(PACK_TAG)); - add_tool(call, "slow") - }, - step: |_call| RV_PENDING -); - -#[test] -fn a_pending_call_that_set_no_state_is_canceled_with_null() { - let pack = made(stateless::entry); - let call = test_call("slow"); - let mut future = Box::pin(pack.call(call.context(), json!({}))); - let mut cx = Context::from_waker(Waker::noop()); - assert!(future.as_mut().poll(&mut cx).is_pending()); - drop(future); - assert_eq!(stateless::CANCELS.load(Ordering::SeqCst), 1); - assert!(stateless::CANCELED.load(Ordering::SeqCst).is_null()); -} - -/// Defines a test whose fake step runs `$step`, which returns the step's -/// status. The call must fail as a `ToolOther` error whose message is -/// `$message`, and must get `$cancels` RV_TOOL_CANCEL calls. -macro_rules! failed_call_test { - ($test:ident, $message:expr, $cancels:expr, |$call:ident| $step:expr) => { - #[test] - fn $test() { - pack_fake!( - plug, - {}, - new: |call| { - set_pack_run(call, instance(PACK_TAG)); - add_tool(call, "tool") - }, - step: |$call| $step - ); - let pack = made(plug::entry); - let failure = call_tool(&*pack, "tool", json!({})).expect_err("the call fails"); - assert_eq!(failure.code, ErrorCode::ToolOther); - assert_eq!(failure.message, $message); - assert_eq!(plug::CANCELS.load(Ordering::SeqCst), $cancels); - } - }; -} - -failed_call_test!( - a_step_that_returns_ok_without_finishing_fails_the_call, - "the tool's Plugin answered RV_TOOL_CALL with RV_OK without finishing the call", - 0, - |_call| RV_OK -); - -failed_call_test!( - a_step_that_returns_another_status_fails_the_call_with_it, - "the tool's Plugin answered RV_TOOL_CALL with status 77", - 0, - |_call| ODD_STATUS -); - -failed_call_test!( - a_status_other_than_ok_wins_over_a_result, - "the tool's Plugin answered RV_TOOL_CALL with status 77", - 0, - |call| { - finish(call, RV_RESULT_TRUSTED, "done"); - ODD_STATUS - } -); - -failed_call_test!( - a_step_that_finishes_and_returns_pending_fails_the_call_and_is_canceled, - misuse_reason!("a step called finish and then returned RV_PENDING"), - 1, - |call| { - finish(call, RV_RESULT_TRUSTED, "done"); - RV_PENDING - } -); - -failed_call_test!( - a_misuse_fails_the_call_even_when_a_good_result_follows, - misuse_reason!("the result's kind is not one of the RV_RESULT constants"), - 0, - |call| { - finish(call, 6, "what kind is this"); - finish(call, RV_RESULT_TRUSTED, "done") - } -); - -failed_call_test!( - a_misuse_in_a_step_that_returns_pending_fails_the_call_and_is_canceled, - misuse_reason!("finish was handed a NULL result"), - 1, - |call| { - finish_as(call, step_args(call).ctx, ptr::null()); - RV_PENDING - } -); - -failed_call_test!( - a_second_finish_is_misuse, - misuse_reason!("finish was called twice"), - 0, - |call| { - finish(call, RV_RESULT_TRUSTED, "first"); - finish(call, RV_RESULT_TRUSTED, "second") - } -); - -failed_call_test!( - a_finish_through_another_ctx_is_misuse, - misuse_reason!("a tool call function was handed a ctx other than the open RV_TOOL_CALL step's"), - 0, - |call| { - let done = result(RV_RESULT_TRUSTED, rv_str("done")); - finish_as(call, ptr::null_mut(), &raw const done); - RV_OK - } -); - -failed_call_test!( - a_misaligned_result_is_misuse, - misuse_reason!("finish was handed a misaligned result"), - 0, - |call| { - finish_as(call, step_args(call).ctx, ptr::without_provenance(1)); - RV_OK - } -); - -failed_call_test!( - a_short_result_size_is_misuse, - misuse_reason!("the result's size is shorter than RvToolResult"), - 0, - |call| { - finish_result( - call, - &RvToolResult { - size: 23, - ..result(RV_RESULT_TRUSTED, rv_str("done")) - }, - ); - RV_OK - } -); - -failed_call_test!( - a_result_text_that_is_not_utf8_is_misuse, - misuse_reason!("the result's text is not UTF-8"), - 0, - |call| { - finish_result( - call, - &result(RV_RESULT_TRUSTED, rv_str_bytes(&[0xff, 0xfe])), - ); - RV_OK - } -); - -failed_call_test!( - a_result_text_with_a_null_pointer_and_a_length_is_misuse, - misuse_reason!("the result's text has a NULL pointer and a nonzero length"), - 0, - |call| { - finish_result( - call, - &result( - RV_RESULT_TRUSTED, - RvStr { - ptr: ptr::null(), - len: 4, - }, - ), - ); - RV_OK - } -); - -failed_call_test!( - a_result_text_over_16_mebibytes_is_misuse, - misuse_reason!("the result's text is longer than 16777216 bytes"), - 0, - |call| { - // The length is refused before any byte is read, so the text - // behind it need not be that long. - finish_result( - call, - &result( - RV_RESULT_TRUSTED, - RvStr { - ptr: "x".as_ptr().cast(), - len: 16_777_217, - }, - ), - ); - RV_OK - } -); - -failed_call_test!( - a_load_function_called_during_a_step_is_misuse, - misuse_reason!("a load function was called outside RV_PLUGIN_LOAD"), - 0, - |call| { - describe_with(step_args(call).ctx, "acme-url", "1.0.0"); - RV_OK - } -); - -#[test] -fn an_empty_result_text_is_accepted() { - pack_fake!( - plug, - {}, - new: |call| { - set_pack_run(call, instance(PACK_TAG)); - add_tool(call, "tool") - }, - step: |call| finish_result( - call, - &result( - RV_RESULT_TRUSTED, - RvStr { - ptr: ptr::null(), - len: 0, - }, - ), - ) - ); - let pack = made(plug::entry); - let output = call_tool(&*pack, "tool", json!({})).expect("the call succeeds"); - assert_eq!(output.text(), ""); -} - -#[test] -fn a_call_polled_after_it_ended_fails_and_sends_no_step() { - pack_fake!( - plug, - { - pub(super) static STEPS: AtomicUsize = AtomicUsize::new(0); - }, - new: |call| { - set_pack_run(call, instance(PACK_TAG)); - add_tool(call, "tool") - }, - step: |call| { - STEPS.fetch_add(1, Ordering::SeqCst); - finish(call, RV_RESULT_TRUSTED, "done") - } - ); - let pack = made(plug::entry); - let call = test_call("tool"); - let mut future = pin!(pack.call(call.context(), json!({}))); - let mut cx = Context::from_waker(Waker::noop()); - assert!(future.as_mut().poll(&mut cx).is_ready()); - let Poll::Ready(Err(failure)) = future.as_mut().poll(&mut cx) else { - panic!("a second poll after the end fails"); - }; - assert_eq!(failure.message, "the tool call was polled after it ended"); - assert_eq!(plug::STEPS.load(Ordering::SeqCst), 1); -} - -#[test] -fn a_late_finish_through_a_stashed_ctx_is_misuse() { - pack_fake!( - plug, - { - pub(super) static STEP_CTX: AtomicPtr = AtomicPtr::new(ptr::null_mut()); - }, - new: |call| { - set_pack_run(call, instance(PACK_TAG)); - add_tool(call, "tool") - }, - step: |call| { - STEP_CTX.store(step_args(call).ctx, Ordering::SeqCst); - finish(call, RV_RESULT_TRUSTED, "done") - } - ); - let pack = made(plug::entry); - call_tool(&*pack, "tool", json!({})).expect("the call succeeds"); - let stashed = plug::STEP_CTX.load(Ordering::SeqCst); - let late = result(RV_RESULT_TRUSTED, rv_str("late")); - // SAFETY: the loader's function compares a `ctx` with the open call's - // before it reads through it, and no call is open on this thread. - let status = unsafe { crate::call::finish(stashed, &raw const late) }; - assert_eq!(status, RV_ERR_MISUSE); -} diff --git a/crates/harness-internal/plugin-loader/src/call.rs b/crates/harness-internal/plugin-loader/src/call.rs deleted file mode 100644 index 76a684bb5..000000000 --- a/crates/harness-internal/plugin-loader/src/call.rs +++ /dev/null @@ -1,230 +0,0 @@ -//! One tool call of a Pack instance that a Plugin's entry point made: the -//! future that sends RV_TOOL_CALL once each time it is polled, the -//! function an `RvToolCallArgs` carries to end the call, and the sender -//! of RV_TOOL_CANCEL. - -use std::ffi::c_void; -use std::future::Future; -use std::pin::Pin; -use std::ptr; -use std::task::{Context, Poll}; - -use runvil_plugin::{Access, Error, ErrorCode, ToolOutput, make_tool_error}; -use runvil_plugin_sys::{ - RV_MAX_RESULT, RV_OK, RV_PENDING, RV_RESULT_BACKEND, RV_RESULT_INVALID_ARGUMENTS, - RV_RESULT_OTHER, RV_RESULT_TRANSPORT, RV_RESULT_TRUSTED, RV_RESULT_UNTRUSTED, RV_TOOL_CALL, - RV_TOOL_CANCEL, RvCall, RvStatus, RvToolCallArgs, RvToolResult, -}; -use serde_json::Value; - -use crate::file::{file_list, file_read, file_stat}; -use crate::pack::EntryPack; -use crate::session::{ - self, Field, Session, Shape, TOOL_CALL, answer, field, rv_str, shape, size_field, -}; - -#[cfg(test)] -#[path = "call-tests.rs"] -mod tests; - -const RESULT: Shape = shape!("finish", "result", "RvToolResult"); -const TEXT: Field = field!("the result's text", 16_777_216, RV_MAX_RESULT); - -/// One tool call in flight. Each poll sends one RV_TOOL_CALL step. A step -/// that returns RV_PENDING wakes the future's own task and leaves it -/// pending, so the Host's executor gives the run's other work a turn and -/// then polls again. Dropping the future while its last step returned -/// RV_PENDING sends RV_TOOL_CANCEL, so the Plugin frees the call's state. -/// -/// It borrows the Pack instance, so the instance is deleted only after -/// every one of its calls has ended. -pub(crate) struct Steps<'a> { - pack: &'a EntryPack, - /// The call's files, which each step's file functions read. - access: &'a Access, - /// The tool's name within its Pack. - tool: String, - /// The JSON text of the call's arguments. - arguments: String, - /// What the last step left in `call->tool_call`, which the loader - /// passes back and never reads through. - state: *mut c_void, - /// Whether the last step returned RV_PENDING, so the Plugin still - /// holds state for the call. - pending: bool, - /// Whether the call has ended. - ended: bool, -} - -// SAFETY: the `# Threads and lifetime` section of `runvil-plugin-sys` lets -// the steps of one tool call, and its RV_TOOL_CANCEL, come on different -// threads, and the loader never reads through `state`. Every other field -// is `Send`, which the assertion below checks for the two borrows. -unsafe impl Send for Steps<'_> {} - -const _: () = { - const fn is_send() {} - is_send::<(&EntryPack, &Access)>(); -}; - -impl<'a> Steps<'a> { - /// A call of the tool named `tool` on `pack` with `arguments`, over - /// the files `access` reaches. Nothing is sent until the first poll. - pub(crate) fn new( - pack: &'a EntryPack, - tool: &str, - arguments: &Value, - access: &'a Access, - ) -> Steps<'a> { - Steps { - pack, - access, - tool: tool.to_owned(), - arguments: arguments.to_string(), - state: ptr::null_mut(), - pending: false, - ended: false, - } - } - - /// An `RvCall` that sends `verb` for this call. - fn new_call(&self, verb: u32) -> RvCall { - RvCall { - tool_call: self.state, - ..self.pack.new_call(verb) - } - } - - /// Sends one RV_TOOL_CALL step, and returns the call's result once it - /// has ended. - fn step(&mut self) -> Option> { - let mut call = self.new_call(RV_TOOL_CALL); - let (status, mut session) = session::open_step(self.access, |ctx| { - let args = RvToolCallArgs { - size: size_field::(), - reserved: 0, - ctx, - tool: rv_str(&self.tool), - arguments: rv_str(&self.arguments), - finish, - file_stat, - file_list, - file_read, - }; - call.args = (&raw const args).cast(); - // SAFETY: `call` carries this call's own pointers and a live - // `RvToolCallArgs`, and no other step of the call runs, - // because a step needs `&mut self`. - unsafe { self.pack.send(&mut call) } - }); - self.state = call.tool_call; - self.pending = status == RV_PENDING; - let outcome = judge(status, &mut session); - if outcome.is_some() { - self.end(); - } - outcome - } - - /// Ends the call: sends RV_TOOL_CANCEL when its last step returned - /// RV_PENDING, so the Plugin frees the state it still holds. - fn end(&mut self) { - self.ended = true; - if !std::mem::take(&mut self.pending) { - return; - } - let mut call = self.new_call(RV_TOOL_CANCEL); - // SAFETY: the call's last step returned RV_PENDING and no step - // runs, so this is the one cancel the contract allows it. - let _status = session::closed(|| unsafe { self.pack.send(&mut call) }); - } -} - -impl Future for Steps<'_> { - type Output = Result; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let this = self.get_mut(); - if this.ended { - return Poll::Ready(Err(failure( - "the tool call was polled after it ended".to_owned(), - ))); - } - if let Some(result) = this.step() { - return Poll::Ready(result); - } - // The contract has no wake function: a pending call gets its next - // step as soon as the executor polls again. - cx.waker().wake_by_ref(); - Poll::Pending - } -} - -impl Drop for Steps<'_> { - fn drop(&mut self) { - self.end(); - } -} - -/// A failed call that is the Plugin's fault or the loader's, with -/// `message` as the text a model reads. -fn failure(message: String) -> Error { - make_tool_error(ErrorCode::ToolOther, message) -} - -/// What a step that returned `status` means: `None` for a call that needs -/// another step, and otherwise how the call ended. The checks come in -/// this order: a misuse, then a result beside RV_PENDING, then a status -/// other than RV_OK, then no result. -fn judge(status: RvStatus, session: &mut Session) -> Option> { - if let Some(misuse) = session.misuse.take() { - return Some(Err(failure(format!( - "the tool's Plugin misused the tool call: {misuse}" - )))); - } - match (status, session.result.take()) { - (RV_PENDING, None) => None, - (RV_PENDING, Some(_)) => Some(Err(failure( - "the tool's Plugin misused the tool call: a step called finish and then returned \ - RV_PENDING" - .to_owned(), - ))), - (RV_OK, Some(result)) => Some(result), - (RV_OK, None) => Some(Err(failure( - "the tool's Plugin answered RV_TOOL_CALL with RV_OK without finishing the call" - .to_owned(), - ))), - (status, _) => Some(Err(failure(format!( - "the tool's Plugin answered RV_TOOL_CALL with status {status}" - )))), - } -} - -/// Records the result a step ends its call with, validated; returns RV_OK -/// or RV_ERR_MISUSE. -pub(crate) unsafe extern "C" fn finish(ctx: *mut c_void, result: *const RvToolResult) -> RvStatus { - answer(ctx, &TOOL_CALL, |session| { - if session.result.is_some() { - return Err("finish was called twice"); - } - // SAFETY: the contract makes `result` point at a readable - // `RvToolResult` during this call. - let result = unsafe { RESULT.read(result) }?; - let code = match result.kind { - RV_RESULT_UNTRUSTED | RV_RESULT_TRUSTED => None, - RV_RESULT_INVALID_ARGUMENTS => Some(ErrorCode::ToolInvalidArguments), - RV_RESULT_BACKEND => Some(ErrorCode::ToolBackend), - RV_RESULT_TRANSPORT => Some(ErrorCode::ToolTransport), - RV_RESULT_OTHER => Some(ErrorCode::ToolOther), - _ => return Err("the result's kind is not one of the RV_RESULT constants"), - }; - // SAFETY: the contract makes the text valid for its length. - let text = unsafe { TEXT.read(result.text) }?; - session.result = Some(match code { - Some(code) => Err(make_tool_error(code, text)), - None if result.kind == RV_RESULT_TRUSTED => Ok(ToolOutput::trusted(text)), - None => Ok(ToolOutput::untrusted(text)), - }); - Ok(RV_OK) - }) -} diff --git a/crates/harness-internal/plugin-loader/src/fakes.rs b/crates/harness-internal/plugin-loader/src/fakes.rs deleted file mode 100644 index 32dd431df..000000000 --- a/crates/harness-internal/plugin-loader/src/fakes.rs +++ /dev/null @@ -1,707 +0,0 @@ -//! Fake entry points for the unit tests: plain `extern "C" fn`s written -//! against `runvil-plugin-sys`, and the helpers that call the Host's -//! functions the way a Plugin does. Each test defines its own fake with -//! `fake!` or `pack_fake!`, so its statics belong to it alone. - -use std::ffi::c_void; -use std::future::Future; -use std::pin::pin; -use std::ptr; -use std::slice; -use std::sync::Arc; -use std::task::{Context, Poll, Waker}; - -use runvil_plugin::testing::TestCall; -use runvil_plugin::{Error, Pack, PackRegistry, ToolId, ToolOutput}; -pub(crate) use runvil_plugin_sys::{ - PluginEntry, RV_ERR_MISUSE, RV_ERR_REFUSED, RV_ERR_UNSUPPORTED, RV_FILE_ERROR_IS_A_DIRECTORY, - RV_FILE_ERROR_NOT_A_DIRECTORY, RV_FILE_ERROR_NOT_FOUND, RV_FILE_ERROR_OTHER, - RV_FILE_TYPE_DIRECTORY, RV_FILE_TYPE_FILE, RV_OK, RV_PACK_DELETE, RV_PACK_NEW, RV_PENDING, - RV_PLUGIN_LOAD, RV_PLUGIN_UNLOAD, RV_RESULT_BACKEND, RV_RESULT_INVALID_ARGUMENTS, - RV_RESULT_OTHER, RV_RESULT_TRANSPORT, RV_RESULT_TRUSTED, RV_RESULT_UNTRUSTED, RV_TOOL_CALL, - RV_TOOL_CANCEL, RvBytes, RvCall, RvDirEntries, RvFileError, RvHost, RvLoadArgs, RvPackInfo, - RvPackNewArgs, RvPluginInfo, RvResultKind, RvStat, RvStatus, RvStr, RvToolCallArgs, RvToolInfo, - RvToolResult, RvVerb, -}; -use serde_json::Value; - -use crate::host::{self, HOST}; -use crate::plugin::{Plugin, insert_plugin}; - -/// A status no verb returns, for a fake that fails with its own code. -pub(crate) const ODD_STATUS: RvStatus = 77; - -/// The name of the Pack a `pack_fake!` inserts. -pub(crate) const FAKE_PACK: &str = "acme/fake"; - -/// The parameters of a fake's tools: an object with no properties. -pub(crate) const NO_PARAMETERS: &str = r#"{"type":"object","properties":{}}"#; - -/// Defines `mod $name` holding a fake entry point and its statics. Its -/// `RV_PLUGIN_LOAD` stores the `ctx` it was handed in `LOAD_CTX`, then runs -/// `$load`, which sees the call as `$call` and returns the load's status. -/// Its `RV_PLUGIN_UNLOAD` counts itself in `UNLOADS`, stores the instance -/// pointer it was sent in `UNLOADED`, records whether the call carried the -/// contract's fields in `UNLOAD_WELL_FORMED`, and stores the status of a -/// `describe_plugin` it then tries through `LOAD_CTX` in `UNLOAD_STATUS`. -/// The second form adds the items a test needs the load body to share with -/// it, such as a `pub(super) static`. -macro_rules! fake { - ($name:ident, |$call:ident| $load:expr) => { - $crate::fakes::fake!($name, {}, |$call| $load); - }; - ($name:ident, { $($items:item)* }, |$call:ident| $load:expr) => { - mod $name { - use std::ffi::c_void; - use std::ptr; - use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicUsize, Ordering}; - - use $crate::fakes::*; - - /// How many RV_PLUGIN_UNLOAD calls this fake got. - pub(super) static UNLOADS: AtomicUsize = AtomicUsize::new(0); - /// The instance pointer its last RV_PLUGIN_UNLOAD carried. - pub(super) static UNLOADED: AtomicPtr = AtomicPtr::new(ptr::null_mut()); - /// Whether its last RV_PLUGIN_UNLOAD carried the contract's fields. - pub(super) static UNLOAD_WELL_FORMED: AtomicBool = AtomicBool::new(false); - /// The status a `describe_plugin` during RV_PLUGIN_UNLOAD got, - /// plus one, so zero means it never tried. - pub(super) static UNLOAD_STATUS: AtomicUsize = AtomicUsize::new(0); - /// The `ctx` its last RV_PLUGIN_LOAD was handed, which a test - /// reads to call a load function after that load returned. - pub(super) static LOAD_CTX: AtomicPtr = AtomicPtr::new(ptr::null_mut()); - - $($items)* - - pub(super) extern "C" fn entry($call: *mut RvCall) -> RvStatus { - match verb($call) { - RV_PLUGIN_LOAD => { - LOAD_CTX.store(ctx($call), Ordering::SeqCst); - $load - } - RV_PLUGIN_UNLOAD => { - UNLOADS.fetch_add(1, Ordering::SeqCst); - UNLOADED.store(plugin($call), Ordering::SeqCst); - UNLOAD_WELL_FORMED - .store(contract_holds($call, RV_PLUGIN_UNLOAD), Ordering::SeqCst); - let status = - describe_with(LOAD_CTX.load(Ordering::SeqCst), "acme-url", "1.0.0"); - UNLOAD_STATUS.store(status as usize + 1, Ordering::SeqCst); - RV_OK - } - _ => RV_ERR_UNSUPPORTED, - } - } - } - }; -} -pub(crate) use fake; - -/// Defines `mod $name` holding a fake entry point whose Plugin, named -/// `fake`, inserts the one Pack [`FAKE_PACK`] and sets the instance -/// [`PLUGIN_TAG`]. Its `RV_PACK_NEW` runs `$new` and its `RV_TOOL_CALL` -/// runs `$step`, each seeing the call under the name it gives and -/// returning the call's status. It counts its `RV_PLUGIN_UNLOAD` calls in -/// `UNLOADS`, its `RV_PACK_DELETE` calls in `DELETES`, and its -/// `RV_TOOL_CANCEL` calls in `CANCELS`, keeps the pointer the last delete -/// and the last cancel carried in `DELETED` and `CANCELED`, and keeps in -/// `ORDER` the verbs of those three kinds, in the order they came. The -/// items in the braces are what a test needs the bodies to share with it. -macro_rules! pack_fake { - ( - $name:ident, - { $($items:item)* }, - new: |$new_call:ident| $new:expr, - step: |$step_call:ident| $step:expr - ) => { - mod $name { - use std::ffi::c_void; - use std::ptr; - use std::sync::Mutex; - use std::sync::atomic::{AtomicPtr, AtomicUsize, Ordering}; - - use $crate::fakes::*; - - /// How many RV_PLUGIN_UNLOAD calls this fake got. - pub(super) static UNLOADS: AtomicUsize = AtomicUsize::new(0); - /// How many RV_PACK_DELETE calls this fake got. - pub(super) static DELETES: AtomicUsize = AtomicUsize::new(0); - /// The `pack_run` its last RV_PACK_DELETE carried. - pub(super) static DELETED: AtomicPtr = AtomicPtr::new(ptr::null_mut()); - /// How many RV_TOOL_CANCEL calls this fake got. - pub(super) static CANCELS: AtomicUsize = AtomicUsize::new(0); - /// The `tool_call` its last RV_TOOL_CANCEL carried. - pub(super) static CANCELED: AtomicPtr = AtomicPtr::new(ptr::null_mut()); - /// The cancels, deletes, and unloads it got, in order. - pub(super) static ORDER: Mutex> = Mutex::new(Vec::new()); - - $($items)* - - /// Notes that `verb` came. - fn note(verb: RvVerb) { - if let Ok(mut order) = ORDER.lock() { - order.push(verb); - } - } - - pub(super) extern "C" fn entry(call: *mut RvCall) -> RvStatus { - match verb(call) { - RV_PLUGIN_LOAD => { - set_plugin(call, instance(PLUGIN_TAG)); - describe(call, "fake", "1.0.0"); - insert_pack(call, FAKE_PACK, "A fake Pack") - } - RV_PLUGIN_UNLOAD => { - UNLOADS.fetch_add(1, Ordering::SeqCst); - note(RV_PLUGIN_UNLOAD); - RV_OK - } - RV_PACK_NEW => { - let $new_call = call; - $new - } - RV_PACK_DELETE => { - DELETES.fetch_add(1, Ordering::SeqCst); - DELETED.store(pack_run(call), Ordering::SeqCst); - note(RV_PACK_DELETE); - RV_OK - } - RV_TOOL_CALL => { - let $step_call = call; - $step - } - RV_TOOL_CANCEL => { - CANCELS.fetch_add(1, Ordering::SeqCst); - CANCELED.store(tool_call(call), Ordering::SeqCst); - note(RV_TOOL_CANCEL); - RV_OK - } - _ => RV_ERR_UNSUPPORTED, - } - } - } - }; -} -pub(crate) use pack_fake; - -/// The instance a `pack_fake!` Plugin sets. -pub(crate) const PLUGIN_TAG: usize = 0x100; - -/// The `pack_run` a well-behaved fake `RV_PACK_NEW` sets. -pub(crate) const PACK_TAG: usize = 0x200; - -/// Inserts the linked Plugin `entry` into a fresh Pack registry. -pub(crate) fn insert(entry: PluginEntry) -> Result, Error> { - insert_plugin(&mut PackRegistry::new(), entry) -} - -/// Inserts `entry`, which must load. -pub(crate) fn loaded(entry: PluginEntry) -> Arc { - insert(entry).expect("the Plugin loads") -} - -/// Inserts `entry`, which must fail to load. -pub(crate) fn failure(entry: PluginEntry) -> Error { - insert(entry).expect_err("the Plugin fails to load") -} - -/// The `Reason` fact of a load failure. -pub(crate) fn reason(failure: &Error) -> Option<&str> { - failure - .facts - .get(&runvil_plugin::ErrorFact::Reason)? - .as_str() -} - -/// Inserts the `pack_fake!` Plugin `entry` into a fresh registry, drops -/// the registry's hold on the Plugin's `Arc`, and asks the factory of -/// [`FAKE_PACK`] for one run's instance. -pub(crate) fn new_pack(entry: PluginEntry) -> Result, Error> { - let mut packs = PackRegistry::new(); - drop(insert_plugin(&mut packs, entry).expect("the Plugin loads")); - let (_info, id, factory) = packs - .into_entries() - .find(|(info, _, _)| info.fqn == FAKE_PACK) - .expect("the Plugin inserted its Pack"); - factory.expect("the Pack is usable").new_pack(&id) -} - -/// One run's instance of the Pack the `pack_fake!` Plugin `entry` inserts, -/// which must be made. -pub(crate) fn made(entry: PluginEntry) -> Arc { - new_pack(entry).expect("the Pack instance is made") -} - -/// A call of the fake Pack's tool `name`, for a test to lend its context. -pub(crate) fn test_call(name: &str) -> TestCall { - TestCall::new(ToolId::parse(&format!("fake/{name}")).expect("a two-segment tool id parses")) -} - -/// Drives `future` to its output, polling again after each `Pending`. -pub(crate) fn block_on(future: F) -> F::Output { - let mut future = pin!(future); - let mut cx = Context::from_waker(Waker::noop()); - loop { - if let Poll::Ready(output) = future.as_mut().poll(&mut cx) { - return output; - } - } -} - -/// Runs one call of the tool `name` on `pack` with `args` to its end. -pub(crate) fn call_tool(pack: &dyn Pack, name: &str, args: Value) -> Result { - let call = test_call(name); - block_on(pack.call(call.context(), args)) -} - -/// `size_of::()` as a contract struct's `u32` size field. -pub(crate) fn size_u32() -> u32 { - u32::try_from(size_of::()).unwrap_or(0) -} - -/// Whether `call` carries the fields the contract gives a call that sends -/// `verb` in version 0.4, and its table says the contract's version. Each -/// verb carries the Plugin's pointers the contract names for it and no -/// others, and its own arguments struct, with a `ctx`, or no arguments. -pub(crate) fn contract_holds(call: *mut RvCall, verb: RvVerb) -> bool { - // SAFETY: a fake runs only inside an entry point call, where `call` is - // the loader's live `RvCall` and its table is the loader's static. - let (call, table) = unsafe { (&*call, &*(*call).host) }; - let has_plugin = verb != RV_PLUGIN_LOAD; - let has_pack = matches!(verb, RV_PACK_DELETE | RV_TOOL_CALL | RV_TOOL_CANCEL); - // The first field of each arguments struct is its size, then a zero - // `reserved`, then the `ctx`, as `RvLoadArgs` lays them out. - let args_size = match verb { - RV_PLUGIN_LOAD => Some(size_u32::()), - RV_PACK_NEW => Some(size_u32::()), - RV_TOOL_CALL => Some(size_u32::()), - _ => None, - }; - let args_hold = match args_size { - Some(size) if !call.args.is_null() => { - // SAFETY: each arguments struct begins with the three fields - // of an `RvLoadArgs` this reads, and is live for the call. - let args = unsafe { &*call.args.cast::() }; - args.size == size && args.reserved == 0 && !args.ctx.is_null() - } - Some(_) => false, - None => call.args.is_null(), - }; - call.size == size_u32::() - && call.verb == verb - && ptr::eq(call.host, &raw const HOST) - && call.plugin.is_null() != has_plugin - && (has_pack || call.pack_run.is_null()) - && (matches!(verb, RV_TOOL_CALL | RV_TOOL_CANCEL) || call.tool_call.is_null()) - && args_hold - && table.size == size_u32::() - && table.api_major == 0 - && table.api_minor == 4 - && table.reserved == 0 -} - -/// The verb a call carries. -pub(crate) fn verb(call: *mut RvCall) -> RvVerb { - // SAFETY: as in `contract_holds`. - unsafe { (*call).verb } -} - -/// The instance pointer a call carries. -pub(crate) fn plugin(call: *mut RvCall) -> *mut c_void { - // SAFETY: as in `contract_holds`. - unsafe { (*call).plugin } -} - -/// Sets the instance pointer, as RV_PLUGIN_LOAD may. -pub(crate) fn set_plugin(call: *mut RvCall, plugin: *mut c_void) { - // SAFETY: as in `contract_holds`, and nothing else reads the call - // meanwhile. - unsafe { (*call).plugin = plugin }; -} - -/// The Pack instance pointer a call carries. -pub(crate) fn pack_run(call: *mut RvCall) -> *mut c_void { - // SAFETY: as in `contract_holds`. - unsafe { (*call).pack_run } -} - -/// Sets the Pack instance pointer, as RV_PACK_NEW may. -pub(crate) fn set_pack_run(call: *mut RvCall, pack_run: *mut c_void) { - // SAFETY: as in `set_plugin`. - unsafe { (*call).pack_run = pack_run }; -} - -/// The tool call state a call carries. -pub(crate) fn tool_call(call: *mut RvCall) -> *mut c_void { - // SAFETY: as in `contract_holds`. - unsafe { (*call).tool_call } -} - -/// Sets the tool call state, as a step may. -pub(crate) fn set_tool_call(call: *mut RvCall, state: *mut c_void) { - // SAFETY: as in `set_plugin`. - unsafe { (*call).tool_call = state }; -} - -/// An address that is never null, which a fake hands out as its instance. -pub(crate) fn instance(tag: usize) -> *mut c_void { - ptr::without_provenance_mut(tag) -} - -/// The `RvLoadArgs` a load call carries. -fn args<'call>(call: *mut RvCall) -> &'call RvLoadArgs { - // SAFETY: as in `contract_holds`, and a load call's `args` is the - // loader's `RvLoadArgs`, live for the whole call, which is as long as - // a fake holds this reference. - unsafe { &*(*call).args.cast::() } -} - -/// The `RvPackNewArgs` an RV_PACK_NEW call carries. -pub(crate) fn pack_args<'call>(call: *mut RvCall) -> &'call RvPackNewArgs { - // SAFETY: as in `args`, for the verb's own arguments struct. - unsafe { &*(*call).args.cast::() } -} - -/// The `RvToolCallArgs` an RV_TOOL_CALL step carries. -pub(crate) fn step_args<'call>(call: *mut RvCall) -> &'call RvToolCallArgs { - // SAFETY: as in `args`, for the verb's own arguments struct. - unsafe { &*(*call).args.cast::() } -} - -/// The `ctx` a load call's `RvLoadArgs` carries. -pub(crate) fn ctx(call: *mut RvCall) -> *mut c_void { - args(call).ctx -} - -/// A string for the C side, borrowed from `text`. -pub(crate) fn rv_str(text: &str) -> RvStr { - RvStr { - ptr: text.as_ptr().cast(), - len: text.len(), - } -} - -/// A string for the C side, borrowed from `bytes`. -pub(crate) fn rv_str_bytes(bytes: &[u8]) -> RvStr { - RvStr { - ptr: bytes.as_ptr().cast(), - len: bytes.len(), - } -} - -/// The text a string from the loader holds. -pub(crate) fn text(s: RvStr) -> String { - if s.len == 0 { - return String::new(); - } - // SAFETY: the loader hands a fake strings that are valid for their - // length during the call. - let bytes = unsafe { slice::from_raw_parts(s.ptr.cast::(), s.len) }; - String::from_utf8_lossy(bytes).into_owned() -} - -/// A well-formed `RvPluginInfo`. -pub(crate) fn info(name: RvStr, version: RvStr) -> RvPluginInfo { - RvPluginInfo { - size: size_u32::(), - reserved: 0, - name, - version, - } -} - -/// Calls the call's `describe_plugin` with its own `ctx` and `info`. -pub(crate) fn describe_info(call: *mut RvCall, info: &RvPluginInfo) -> RvStatus { - describe_as(call, ctx(call), info) -} - -/// Calls the `describe_plugin` of `args_from`'s `RvLoadArgs`, handing it -/// `as_ctx`. -pub(crate) fn describe_as( - args_from: *mut RvCall, - as_ctx: *mut c_void, - info: &RvPluginInfo, -) -> RvStatus { - // SAFETY: `info` is a live `RvPluginInfo` whose strings outlive the - // call, and the function reads through `as_ctx` only when it is the - // `ctx` of the load open on this thread. - unsafe { (args(args_from).describe_plugin)(as_ctx, info) } -} - -/// Describes the Plugin with `name` and `version`. -pub(crate) fn describe(call: *mut RvCall, name: &str, version: &str) -> RvStatus { - describe_info(call, &info(rv_str(name), rv_str(version))) -} - -/// Calls the `refuse` of `args_from`'s `RvLoadArgs`, handing it `as_ctx`. -pub(crate) fn refuse_as(args_from: *mut RvCall, as_ctx: *mut c_void, reason: RvStr) -> RvStatus { - // SAFETY: `reason` outlives the call, and the function reads through - // `as_ctx` only when it is the `ctx` of the load open on this thread. - unsafe { (args(args_from).refuse)(as_ctx, reason) } -} - -/// Calls the call's `refuse` with its own `ctx` and `reason`. -pub(crate) fn refuse_str(call: *mut RvCall, reason: RvStr) -> RvStatus { - refuse_as(call, ctx(call), reason) -} - -/// Refuses the load with `reason`. -pub(crate) fn refuse(call: *mut RvCall, reason: &str) -> RvStatus { - refuse_str(call, rv_str(reason)) -} - -/// Calls the loader's `describe_plugin` with `ctx`, as a Plugin that kept -/// the load functions and a `ctx` past its load would, from any thread. -pub(crate) fn describe_with(ctx: *mut c_void, name: &str, version: &str) -> RvStatus { - let info = info(rv_str(name), rv_str(version)); - // SAFETY: `info` is live, and the function reads through `ctx` only - // when it is the `ctx` of the load open on this thread. - unsafe { host::describe_plugin(ctx, &raw const info) } -} - -/// Calls the loader's `refuse` with `ctx`, as `describe_with` does. -pub(crate) fn refuse_with(ctx: *mut c_void, reason: &str) -> RvStatus { - // SAFETY: as in `describe_with`. - unsafe { host::refuse(ctx, rv_str(reason)) } -} - -/// A well-formed `RvPackInfo`. -pub(crate) fn pack_info(name: RvStr, description: RvStr) -> RvPackInfo { - RvPackInfo { - size: size_u32::(), - reserved: 0, - name, - description, - } -} - -/// Calls the load call's `insert_pack` with its own `ctx` and `info`. -pub(crate) fn insert_pack_info(call: *mut RvCall, info: &RvPackInfo) -> RvStatus { - // SAFETY: as in `describe_as`. - unsafe { (args(call).insert_pack)(ctx(call), info) } -} - -/// Inserts the Pack `name` with `description`. -pub(crate) fn insert_pack(call: *mut RvCall, name: &str, description: &str) -> RvStatus { - insert_pack_info(call, &pack_info(rv_str(name), rv_str(description))) -} - -/// A well-formed `RvToolInfo` for the tool `name`, with no wire name, a -/// one-line description, and [`NO_PARAMETERS`]. -pub(crate) fn tool_info(name: &str) -> RvToolInfo { - RvToolInfo { - size: size_u32::(), - reserved: 0, - name: rv_str(name), - wire_name: rv_str(""), - description: rv_str("A fake tool."), - parameters: rv_str(NO_PARAMETERS), - } -} - -/// Calls the `add_tool` of an RV_PACK_NEW call, handing it `as_ctx`. -pub(crate) fn add_tool_as(call: *mut RvCall, as_ctx: *mut c_void, info: &RvToolInfo) -> RvStatus { - // SAFETY: `info` is a live `RvToolInfo` whose strings outlive the - // call, and the function reads through `as_ctx` only when it is the - // `ctx` of the call open on this thread. - unsafe { (pack_args(call).add_tool)(as_ctx, info) } -} - -/// Calls the call's `add_tool` with its own `ctx` and `info`. -pub(crate) fn add_tool_info(call: *mut RvCall, info: &RvToolInfo) -> RvStatus { - add_tool_as(call, pack_args(call).ctx, info) -} - -/// Adds the tool `name` to the instance being made. -pub(crate) fn add_tool(call: *mut RvCall, name: &str) -> RvStatus { - add_tool_info(call, &tool_info(name)) -} - -/// Declines the RV_PACK_NEW call with `reason`. -pub(crate) fn refuse_pack(call: *mut RvCall, reason: &str) -> RvStatus { - let args = pack_args(call); - // SAFETY: `reason` outlives the call, and `ctx` is the open call's. - unsafe { (args.refuse)(args.ctx, rv_str(reason)) } -} - -/// A well-formed `RvToolResult`. -pub(crate) fn result(kind: RvResultKind, text: RvStr) -> RvToolResult { - RvToolResult { - size: size_u32::(), - kind, - text, - } -} - -/// Calls the `finish` of an RV_TOOL_CALL step, handing it `as_ctx`. -pub(crate) fn finish_as( - call: *mut RvCall, - as_ctx: *mut c_void, - result: *const RvToolResult, -) -> RvStatus { - // SAFETY: `result` is NULL, misaligned on purpose, or a live - // `RvToolResult` whose text outlives the call, and the function reads - // through `as_ctx` only when it is the `ctx` of the step open on this - // thread. - unsafe { (step_args(call).finish)(as_ctx, result) } -} - -/// Calls the step's `finish` with its own `ctx` and `result`. -pub(crate) fn finish_result(call: *mut RvCall, result: &RvToolResult) -> RvStatus { - finish_as(call, step_args(call).ctx, result) -} - -/// Finishes the call with `kind` and `text`. -pub(crate) fn finish(call: *mut RvCall, kind: RvResultKind, text: &str) -> RvStatus { - finish_result(call, &result(kind, rv_str(text))) -} - -/// An `RvStat` whose every field is 9, so a test sees whether a file -/// function wrote it. -pub(crate) fn unset_stat() -> RvStat { - RvStat { - file_type: 9, - reserved: 9, - len: 9, - } -} - -/// An `RvFileError` of kind 9 that says `untouched`, so a test sees -/// whether a file function wrote it. -pub(crate) fn unset_error() -> RvFileError { - RvFileError { - kind: 9, - reserved: 9, - text: rv_str("untouched"), - } -} - -/// An `RvBytes` with no pointer and a length of 9, so a test sees whether -/// a file function wrote it. -pub(crate) fn unset_bytes() -> RvBytes { - RvBytes { - ptr: ptr::null(), - len: 9, - } -} - -/// An `RvDirEntries` with no pointer and a length of 9, so a test sees -/// whether a file function wrote it. -pub(crate) fn unset_entries() -> RvDirEntries { - RvDirEntries { - ptr: ptr::null(), - len: 9, - } -} - -/// Calls the step's `file_stat` with `as_ctx`, as a Plugin would or as it -/// must not. -pub(crate) fn stat_as( - call: *mut RvCall, - as_ctx: *mut c_void, - path: RvStr, - stat: *mut RvStat, - error: *mut RvFileError, -) -> RvStatus { - // SAFETY: the function refuses a `ctx` that is not the open step's, a - // path it cannot read, and a pointer that is NULL or misaligned, - // before it reads or writes through any of them. Every other pointer - // a test hands over is a live struct of its own. - unsafe { (step_args(call).file_stat)(as_ctx, path, stat, error) } -} - -/// Calls the step's `file_list` with `as_ctx`, as `stat_as` calls -/// `file_stat`. -pub(crate) fn list_as( - call: *mut RvCall, - as_ctx: *mut c_void, - path: RvStr, - entries: *mut RvDirEntries, - error: *mut RvFileError, -) -> RvStatus { - // SAFETY: as in `stat_as`. - unsafe { (step_args(call).file_list)(as_ctx, path, entries, error) } -} - -/// Calls the step's `file_read` with `as_ctx`, as `stat_as` calls -/// `file_stat`. -pub(crate) fn read_as( - call: *mut RvCall, - as_ctx: *mut c_void, - path: RvStr, - range: (u64, u64), - bytes: *mut RvBytes, - error: *mut RvFileError, -) -> RvStatus { - // SAFETY: as in `stat_as`. - unsafe { (step_args(call).file_read)(as_ctx, path, range.0, range.1, bytes, error) } -} - -/// What a file function of a step answered: what it handed back, or the -/// kind and the text of its failure. -pub(crate) type Answer = Result; - -/// What the step's `file_stat` says of `path`: the file type and the -/// length, or the kind and the text of the failure. -pub(crate) fn stat_of(call: *mut RvCall, path: &str) -> Answer<(u32, u64)> { - let (mut stat, mut error) = (unset_stat(), unset_error()); - let ctx = step_args(call).ctx; - match stat_as(call, ctx, rv_str(path), &raw mut stat, &raw mut error) { - RV_OK => Ok((stat.file_type, stat.len)), - _ => Err((error.kind, text(error.text))), - } -} - -/// What the step's `file_list` says of `path`: each entry's name, file -/// type, and length, or the kind and the text of the failure. -pub(crate) fn list_of(call: *mut RvCall, path: &str) -> Answer> { - let (mut entries, mut error) = (unset_entries(), unset_error()); - let ctx = step_args(call).ctx; - match list_as(call, ctx, rv_str(path), &raw mut entries, &raw mut error) { - RV_OK if entries.len == 0 => Ok(Vec::new()), - RV_OK => { - // SAFETY: the loader keeps the entries and their names valid - // until the step's next file function. - let listed = unsafe { slice::from_raw_parts(entries.ptr, entries.len) }; - Ok(listed - .iter() - .map(|entry| (text(entry.name), entry.stat.file_type, entry.stat.len)) - .collect()) - } - _ => Err((error.kind, text(error.text))), - } -} - -/// What the step's `file_read` hands back for `path` and the range -/// `offset` and `len`, or the kind and the text of the failure. -pub(crate) fn read_of(call: *mut RvCall, path: &str, range: (u64, u64)) -> Answer> { - let (mut bytes, mut error) = (unset_bytes(), unset_error()); - let ctx = step_args(call).ctx; - match read_as( - call, - ctx, - rv_str(path), - range, - &raw mut bytes, - &raw mut error, - ) { - RV_OK if bytes.len == 0 => Ok(Vec::new()), - // SAFETY: the loader keeps the bytes valid until the step's next - // file function. - RV_OK => Ok(unsafe { slice::from_raw_parts(bytes.ptr, bytes.len) }.to_vec()), - _ => Err((error.kind, text(error.text))), - } -} - -/// A call of the fake Pack's tool `name` over an in-memory filesystem -/// that holds each of `files`, a path beside its text. -pub(crate) fn seeded_call(name: &str, files: &[(&str, &str)]) -> TestCall { - let call = test_call(name); - for (path, text) in files { - call.context() - .access() - .write(path, text.as_bytes()) - .expect("a seeded file is written"); - } - call -} diff --git a/crates/harness-internal/plugin-loader/src/file-tests.rs b/crates/harness-internal/plugin-loader/src/file-tests.rs deleted file mode 100644 index 9bf6abcc5..000000000 --- a/crates/harness-internal/plugin-loader/src/file-tests.rs +++ /dev/null @@ -1,700 +0,0 @@ -//! Tests for the file functions of a tool call's step: what each hands -//! back from the call's access, how a `VfsError` reads to a Plugin, that a -//! failed file function leaves the call running, and what each is refused -//! for as a misuse. - -use std::io; -use std::sync::atomic::Ordering; - -use runvil_plugin::{FileType, PathReason, Stat, VfsError}; -use runvil_plugin_sys::{ - RV_FILE_ERROR_CONFLICT, RV_FILE_ERROR_DENIED, RV_FILE_ERROR_IO, RV_FILE_TYPE_OTHER, - RV_FILE_TYPE_SYMLINK, -}; -use serde_json::json; - -use super::{Lent, contained, describe, raw_stat}; -use crate::fakes::*; - -/// The files every call below reads. -const FILES: &[(&str, &str)] = &[("/notes/a.md", "hello"), ("/notes/b.md", "")]; - -pack_fake!( - reader, - {}, - new: |call| { - set_pack_run(call, instance(PACK_TAG)); - add_tool(call, "stat"); - add_tool(call, "list"); - add_tool(call, "read"); - add_tool(call, "tail"); - add_tool(call, "past") - }, - step: |call| { - let args = step_args(call); - // The arguments are one JSON string, the path. - let path: String = serde_json::from_str(&text(args.arguments)).unwrap_or_default(); - let said = match text(args.tool).as_str() { - "stat" => format!("{:?}", stat_of(call, &path)), - "list" => format!("{:?}", list_of(call, &path)), - "read" => format!("{:?}", read_of(call, &path, (0, u64::MAX))), - "tail" => format!("{:?}", read_of(call, &path, (3, 100))), - _ => format!("{:?}", read_of(call, &path, (u64::MAX, u64::MAX))), - }; - finish(call, RV_RESULT_TRUSTED, &said) - } -); - -/// What the `reader` Pack's tool `tool` says of `path` over [`FILES`]. -fn said(tool: &str, path: &str) -> String { - let pack = made(reader::entry); - let call = seeded_call(tool, FILES); - block_on(pack.call(call.context(), json!(path))) - .expect("the call succeeds") - .text() - .to_owned() -} - -/// The text a fake's helper formats for a failure of `kind` that says -/// `text`. -fn failed(kind: u32, text: &str) -> String { - format!("Err(({kind}, {text:?}))") -} - -#[test] -fn file_stat_says_what_a_path_names() { - assert_eq!( - said("stat", "/notes/a.md"), - format!("Ok(({RV_FILE_TYPE_FILE}, 5))") - ); - assert!( - said("stat", "/notes").starts_with(&format!("Ok(({RV_FILE_TYPE_DIRECTORY}, ")), - "{}", - said("stat", "/notes") - ); -} - -#[test] -fn file_list_hands_back_each_entry_with_its_name_and_metadata() { - assert_eq!( - said("list", "/notes"), - format!("Ok([(\"a.md\", {RV_FILE_TYPE_FILE}, 5), (\"b.md\", {RV_FILE_TYPE_FILE}, 0)])") - ); -} - -#[test] -fn file_read_hands_back_the_whole_file_or_the_bytes_inside_a_range() { - assert_eq!(said("read", "/notes/a.md"), format!("Ok({:?})", b"hello")); - assert_eq!(said("read", "/notes/b.md"), "Ok([])"); - assert_eq!(said("tail", "/notes/a.md"), format!("Ok({:?})", b"lo")); - assert_eq!( - said("past", "/notes/a.md"), - "Ok([])", - "the largest offset is past the end, not a failure" - ); -} - -#[test] -fn a_path_without_a_leading_slash_is_taken_from_the_root() { - assert_eq!(said("read", "notes/a.md"), format!("Ok({:?})", b"hello")); -} - -#[test] -fn a_failed_file_function_fills_the_error_and_leaves_the_call_running() { - assert_eq!( - said("stat", "/absent"), - failed(RV_FILE_ERROR_NOT_FOUND, "not found: /absent") - ); - assert_eq!( - said("list", "/notes/a.md"), - failed( - RV_FILE_ERROR_NOT_A_DIRECTORY, - "not a directory: /notes/a.md" - ) - ); - assert_eq!( - said("read", "/notes"), - failed(RV_FILE_ERROR_IS_A_DIRECTORY, "is a directory: /notes") - ); - assert_eq!( - said("stat", ""), - failed(RV_FILE_ERROR_OTHER, "path is empty") - ); -} - -#[test] -fn a_path_of_exactly_4096_bytes_is_within_the_bound() { - let longest = format!("/{}", "a".repeat(4095)); - assert_eq!( - said("stat", &longest), - failed(RV_FILE_ERROR_NOT_FOUND, &format!("not found: {longest}")) - ); -} - -pack_fake!( - twice, - {}, - new: |call| { - set_pack_run(call, instance(PACK_TAG)); - add_tool(call, "twice") - }, - step: |call| { - let read = read_of(call, "/notes/a.md", (0, u64::MAX)); - if tool_call(call).is_null() { - set_tool_call(call, instance(0x300)); - return if read.is_ok() { RV_PENDING } else { ODD_STATUS }; - } - finish(call, RV_RESULT_TRUSTED, &format!("{read:?}")) - } -); - -#[test] -fn each_step_of_a_call_reads_through_its_own_ctx() { - let pack = made(twice::entry); - let call = seeded_call("twice", FILES); - let output = block_on(pack.call(call.context(), json!({}))).expect("the call succeeds"); - assert_eq!(output.text(), format!("Ok({:?})", b"hello")); -} - -#[test] -fn each_kind_of_node_keeps_its_number_and_any_other_kind_is_other() { - for (file_type, raw) in [ - (FileType::File, RV_FILE_TYPE_FILE), - (FileType::Directory, RV_FILE_TYPE_DIRECTORY), - (FileType::Symlink, RV_FILE_TYPE_SYMLINK), - (FileType::Fifo, RV_FILE_TYPE_OTHER), - (FileType::Socket, RV_FILE_TYPE_OTHER), - (FileType::CharDevice, RV_FILE_TYPE_OTHER), - (FileType::BlockDevice, RV_FILE_TYPE_OTHER), - ] { - let stat = raw_stat(&Stat::new(file_type, 7)); - assert_eq!( - (stat.file_type, stat.reserved, stat.len), - (raw, 0, 7), - "{file_type:?}" - ); - } -} - -#[test] -fn a_panic_inside_a_file_operation_is_a_failure_and_never_an_unwind() { - let caught: Result<(), VfsError> = contained(|| panic!("the policy broke")); - let failure = caught.expect_err("the panic is a failure"); - assert_eq!( - describe(&failure), - ( - RV_FILE_ERROR_OTHER, - "the file operation panicked".to_owned() - ) - ); - assert_eq!(contained(|| Ok(7)), Ok(7), "any other outcome passes"); -} - -#[test] -fn each_error_reads_as_its_kind_and_a_text_a_model_can_read() { - let path = || "/x".to_owned(); - for (error, kind, text) in [ - ( - VfsError::NotFound { path: path() }, - RV_FILE_ERROR_NOT_FOUND, - "not found: /x", - ), - ( - VfsError::NotADirectory { path: path() }, - RV_FILE_ERROR_NOT_A_DIRECTORY, - "not a directory: /x", - ), - ( - VfsError::IsADirectory { path: path() }, - RV_FILE_ERROR_IS_A_DIRECTORY, - "is a directory: /x", - ), - ( - VfsError::PermissionDenied { - path: path(), - reason: "the mode allows only markdown".to_owned(), - }, - RV_FILE_ERROR_DENIED, - "the mode allows only markdown", - ), - ( - VfsError::ApprovalRequired { - path: path(), - reason: "reading /x needs approval".to_owned(), - }, - RV_FILE_ERROR_DENIED, - "reading /x needs approval", - ), - ( - VfsError::Conflict { - path: path(), - detail: "a write by another call".to_owned(), - }, - RV_FILE_ERROR_CONFLICT, - "another tool call of the run changed /x, or is changing it", - ), - ( - VfsError::Io { - path: path(), - kind: io::ErrorKind::PermissionDenied, - raw_os_error: Some(13), - message: "access denied".to_owned(), - }, - RV_FILE_ERROR_IO, - "permission denied: access denied", - ), - ( - VfsError::InvalidPath { - path: String::new(), - reason: PathReason::Empty, - }, - RV_FILE_ERROR_OTHER, - "path is empty", - ), - ( - // The path holds the `": "` that ends a label. - VfsError::InvalidPath { - path: "a: b/../..".to_owned(), - reason: PathReason::Traversal, - }, - RV_FILE_ERROR_OTHER, - "path contains a traversal segment", - ), - ( - VfsError::NotUtf8 { - path: path(), - valid_up_to: 3, - }, - RV_FILE_ERROR_OTHER, - "/x holds a name or contents that are not UTF-8", - ), - ( - VfsError::ScopeEnded { path: path() }, - RV_FILE_ERROR_OTHER, - "the run or tool call that owned this access has ended, so /x can no longer be \ - touched through it", - ), - ( - VfsError::Backend { - message: "the backend is gone".to_owned(), - }, - RV_FILE_ERROR_OTHER, - "the backend is gone", - ), - ( - VfsError::NoStore, - RV_FILE_ERROR_OTHER, - "the handle declares no store", - ), - ] { - assert_eq!(describe(&error), (kind, text.to_owned()), "{error:?}"); - } -} - -#[test] -fn each_file_function_keeps_only_what_it_hands_back() { - let mut lent = Lent::default(); - let bytes = lent.bytes(b"hello".to_vec()); - assert_eq!((bytes.len, lent.bytes.len()), (5, 5)); - - let failure = lent.error(&VfsError::NotFound { - path: "/x".to_owned(), - }); - assert_eq!( - (failure.kind, failure.reserved, text(failure.text)), - (RV_FILE_ERROR_NOT_FOUND, 0, "not found: /x".to_owned()) - ); - assert!(lent.bytes.is_empty(), "the bytes of the read are gone"); - - let listed = lent.entries(vec![runvil_plugin::Entry::new( - "a.md", - Stat::new(FileType::File, 5), - )]); - assert_eq!((listed.len, lent.entries.len()), (1, 1)); - assert!(lent.text.is_empty(), "the text of the failure is gone"); -} - -/// The message of a call that failed because its Plugin misused it. -macro_rules! misuse_reason { - ($detail:expr) => { - concat!("the tool's Plugin misused the tool call: ", $detail) - }; -} - -/// Defines a test whose fake step runs `$step`, which calls a file -/// function wrongly and returns the step's status. The call must fail as -/// a `ToolOther` error that names the misuse `$detail`. -macro_rules! file_misuse_test { - ($test:ident, $detail:expr, |$call:ident| $step:expr) => { - #[test] - fn $test() { - pack_fake!( - plug, - {}, - new: |call| { - set_pack_run(call, instance(PACK_TAG)); - add_tool(call, "tool") - }, - step: |$call| $step - ); - let pack = made(plug::entry); - let call = seeded_call("tool", FILES); - let failure = block_on(pack.call(call.context(), json!({}))) - .expect_err("the call fails"); - assert_eq!(failure.code, runvil_plugin::ErrorCode::ToolOther); - assert_eq!(failure.message, misuse_reason!($detail)); - } - }; -} - -file_misuse_test!( - a_path_over_4096_bytes_is_misuse, - "the path is longer than 4096 bytes", - |call| { - let (mut stat, mut error) = (unset_stat(), unset_error()); - // The length is refused before any byte is read, so the text - // behind it need not be that long. - let long = RvStr { - ptr: "x".as_ptr().cast(), - len: 4097, - }; - stat_as( - call, - step_args(call).ctx, - long, - &raw mut stat, - &raw mut error, - ) - } -); - -file_misuse_test!( - a_path_with_a_null_pointer_and_a_length_is_misuse, - "the path has a NULL pointer and a nonzero length", - |call| { - let (mut stat, mut error) = (unset_stat(), unset_error()); - let null = RvStr { - ptr: ptr::null(), - len: 4, - }; - stat_as( - call, - step_args(call).ctx, - null, - &raw mut stat, - &raw mut error, - ) - } -); - -file_misuse_test!( - a_path_that_is_not_utf8_is_misuse, - "the path is not UTF-8", - |call| { - let (mut stat, mut error) = (unset_stat(), unset_error()); - let path = rv_str_bytes(&[0xff, 0xfe]); - stat_as( - call, - step_args(call).ctx, - path, - &raw mut stat, - &raw mut error, - ) - } -); - -file_misuse_test!( - a_file_function_through_another_ctx_is_misuse, - "a tool call function was handed a ctx other than the open RV_TOOL_CALL step's", - |call| { - let (mut stat, mut error) = (unset_stat(), unset_error()); - stat_as( - call, - ptr::null_mut(), - rv_str("/notes/a.md"), - &raw mut stat, - &raw mut error, - ) - } -); - -file_misuse_test!( - a_null_stat_is_misuse, - "file_stat was handed a NULL stat pointer", - |call| { - let mut error = unset_error(); - stat_as( - call, - step_args(call).ctx, - rv_str("/notes/a.md"), - ptr::null_mut(), - &raw mut error, - ) - } -); - -file_misuse_test!( - a_misaligned_stat_is_misuse, - "file_stat was handed a misaligned stat pointer", - |call| { - let mut error = unset_error(); - let odd = ptr::without_provenance_mut(1); - stat_as( - call, - step_args(call).ctx, - rv_str("/notes/a.md"), - odd, - &raw mut error, - ) - } -); - -file_misuse_test!( - a_null_error_of_a_stat_is_misuse, - "file_stat was handed a NULL error pointer", - |call| { - let mut stat = unset_stat(); - stat_as( - call, - step_args(call).ctx, - rv_str("/notes/a.md"), - &raw mut stat, - ptr::null_mut(), - ) - } -); - -file_misuse_test!( - a_misaligned_error_of_a_stat_is_misuse, - "file_stat was handed a misaligned error pointer", - |call| { - let mut stat = unset_stat(); - let odd = ptr::without_provenance_mut(1); - stat_as( - call, - step_args(call).ctx, - rv_str("/notes/a.md"), - &raw mut stat, - odd, - ) - } -); - -file_misuse_test!( - a_null_entries_is_misuse, - "file_list was handed a NULL entries pointer", - |call| { - let mut error = unset_error(); - list_as( - call, - step_args(call).ctx, - rv_str("/notes"), - ptr::null_mut(), - &raw mut error, - ) - } -); - -file_misuse_test!( - a_misaligned_entries_is_misuse, - "file_list was handed a misaligned entries pointer", - |call| { - let mut error = unset_error(); - let odd = ptr::without_provenance_mut(1); - list_as( - call, - step_args(call).ctx, - rv_str("/notes"), - odd, - &raw mut error, - ) - } -); - -file_misuse_test!( - a_null_error_of_a_list_is_misuse, - "file_list was handed a NULL error pointer", - |call| { - let mut entries = unset_entries(); - list_as( - call, - step_args(call).ctx, - rv_str("/notes"), - &raw mut entries, - ptr::null_mut(), - ) - } -); - -file_misuse_test!( - a_misaligned_error_of_a_list_is_misuse, - "file_list was handed a misaligned error pointer", - |call| { - let mut entries = unset_entries(); - let odd = ptr::without_provenance_mut(1); - list_as( - call, - step_args(call).ctx, - rv_str("/notes"), - &raw mut entries, - odd, - ) - } -); - -file_misuse_test!( - a_null_bytes_is_misuse, - "file_read was handed a NULL bytes pointer", - |call| { - let mut error = unset_error(); - read_as( - call, - step_args(call).ctx, - rv_str("/notes/a.md"), - (0, 1), - ptr::null_mut(), - &raw mut error, - ) - } -); - -file_misuse_test!( - a_misaligned_bytes_is_misuse, - "file_read was handed a misaligned bytes pointer", - |call| { - let mut error = unset_error(); - let odd = ptr::without_provenance_mut(1); - read_as( - call, - step_args(call).ctx, - rv_str("/notes/a.md"), - (0, 1), - odd, - &raw mut error, - ) - } -); - -file_misuse_test!( - a_null_error_of_a_read_is_misuse, - "file_read was handed a NULL error pointer", - |call| { - let mut bytes = unset_bytes(); - read_as( - call, - step_args(call).ctx, - rv_str("/notes/a.md"), - (0, 1), - &raw mut bytes, - ptr::null_mut(), - ) - } -); - -file_misuse_test!( - a_misaligned_error_of_a_read_is_misuse, - "file_read was handed a misaligned error pointer", - |call| { - let mut bytes = unset_bytes(); - let odd = ptr::without_provenance_mut(1); - read_as( - call, - step_args(call).ctx, - rv_str("/notes/a.md"), - (0, 1), - &raw mut bytes, - odd, - ) - } -); - -#[test] -fn a_misuse_writes_neither_the_result_nor_the_error() { - pack_fake!( - plug, - { - /// The file type and the error kind after a refused call. - pub(super) static LEFT: AtomicUsize = AtomicUsize::new(0); - }, - new: |call| { - set_pack_run(call, instance(PACK_TAG)); - add_tool(call, "tool") - }, - step: |call| { - let (mut stat, mut error) = (unset_stat(), unset_error()); - let status = - stat_as(call, ptr::null_mut(), rv_str("/notes/a.md"), &raw mut stat, &raw mut error); - LEFT.store((stat.file_type + error.kind) as usize, Ordering::SeqCst); - status - } - ); - let pack = made(plug::entry); - let call = seeded_call("tool", FILES); - block_on(pack.call(call.context(), json!({}))).expect_err("the call fails"); - assert_eq!(plug::LEFT.load(Ordering::SeqCst), 18, "both still hold 9"); -} - -#[test] -fn a_file_function_called_while_a_pack_instance_is_made_is_misuse() { - pack_fake!( - plug, - {}, - new: |call| { - let (mut stat, mut error) = (unset_stat(), unset_error()); - // SAFETY: the function refuses a call that is not a step - // before it reads or writes through any pointer. - unsafe { - crate::file::file_stat( - pack_args(call).ctx, - rv_str("/notes/a.md"), - &raw mut stat, - &raw mut error, - ) - } - }, - step: |_call| RV_OK - ); - let failure = new_pack(plug::entry) - .err() - .expect("the instance is not made"); - assert_eq!( - failure.message, - "the Plugin fake misused the Pack functions: a tool call function was called outside \ - RV_TOOL_CALL" - ); -} - -#[test] -fn a_late_file_function_through_a_stashed_ctx_is_misuse_and_writes_nothing() { - pack_fake!( - plug, - { - pub(super) static STEP_CTX: AtomicPtr = AtomicPtr::new(ptr::null_mut()); - }, - new: |call| { - set_pack_run(call, instance(PACK_TAG)); - add_tool(call, "tool") - }, - step: |call| { - STEP_CTX.store(step_args(call).ctx, Ordering::SeqCst); - finish(call, RV_RESULT_TRUSTED, "done") - } - ); - let pack = made(plug::entry); - let call = seeded_call("tool", FILES); - block_on(pack.call(call.context(), json!({}))).expect("the call succeeds"); - let stashed = plug::STEP_CTX.load(Ordering::SeqCst); - let (mut stat, mut error) = (unset_stat(), unset_error()); - // SAFETY: the loader's function compares a `ctx` with the open call's - // before it reads through it, and no call is open on this thread. - let status = unsafe { - crate::file::file_stat( - stashed, - rv_str("/notes/a.md"), - &raw mut stat, - &raw mut error, - ) - }; - assert_eq!(status, RV_ERR_MISUSE); - assert_eq!((stat.file_type, error.kind), (9, 9)); -} diff --git a/crates/harness-internal/plugin-loader/src/file.rs b/crates/harness-internal/plugin-loader/src/file.rs deleted file mode 100644 index 6f92ad959..000000000 --- a/crates/harness-internal/plugin-loader/src/file.rs +++ /dev/null @@ -1,279 +0,0 @@ -//! The file functions an `RvToolCallArgs` carries: `file_stat`, -//! `file_list`, and `file_read`, each answered from the access of the -//! step's tool call, so the run's policy applies to every read, and -//! [`Lent`], what the last of them handed back. - -use std::ffi::c_void; -use std::panic::{AssertUnwindSafe, catch_unwind}; - -use runvil_plugin::{Entry, FileType, Stat, VfsError}; -use runvil_plugin_sys::{ - RV_ERR_FILE, RV_FILE_ERROR_CONFLICT, RV_FILE_ERROR_DENIED, RV_FILE_ERROR_IO, - RV_FILE_ERROR_IS_A_DIRECTORY, RV_FILE_ERROR_NOT_A_DIRECTORY, RV_FILE_ERROR_NOT_FOUND, - RV_FILE_ERROR_OTHER, RV_FILE_TYPE_DIRECTORY, RV_FILE_TYPE_FILE, RV_FILE_TYPE_OTHER, - RV_FILE_TYPE_SYMLINK, RV_MAX_PATH, RV_OK, RvBytes, RvDirEntries, RvDirEntry, RvFileError, - RvFileErrorKind, RvStat, RvStatus, RvStr, -}; - -use crate::session::{Field, Out, answer_file, field, out, rv_str}; - -#[cfg(test)] -#[path = "file-tests.rs"] -mod tests; - -const PATH: Field = field!("the path", 4096, RV_MAX_PATH); -const STAT: Out = out!("file_stat", "stat"); -const STAT_ERROR: Out = out!("file_stat", "error"); -const ENTRIES: Out = out!("file_list", "entries"); -const LIST_ERROR: Out = out!("file_list", "error"); -const BYTES: Out = out!("file_read", "bytes"); -const READ_ERROR: Out = out!("file_read", "error"); - -/// What a step's last file function handed the Plugin. The contract -/// keeps it valid until the step's next file function or the end of the -/// step, so each file function replaces it, and the step's end frees it. -#[derive(Default)] -pub(crate) struct Lent { - /// The bytes of a read. - bytes: Vec, - /// The names the entries of a listing point at, kept only so that - /// they stay alive. - _names: Vec, - /// The entries of a listing. - entries: Vec, - /// The text of a failure. - text: String, -} - -impl Lent { - /// Keeps `bytes` and returns them as the contract hands them back. - fn bytes(&mut self, bytes: Vec) -> RvBytes { - *self = Lent { - bytes, - ..Lent::default() - }; - RvBytes { - ptr: self.bytes.as_ptr(), - len: self.bytes.len(), - } - } - - /// Keeps `entries` and returns them as the contract hands them back. - fn entries(&mut self, entries: Vec) -> RvDirEntries { - let stats: Vec = entries.iter().map(|entry| raw_stat(&entry.stat)).collect(); - let names: Vec = entries.into_iter().map(|entry| entry.name).collect(); - // Each name's bytes sit in its own allocation, so moving `names` - // into `self` leaves the pointers below valid. - let entries = names - .iter() - .zip(stats) - .map(|(name, stat)| RvDirEntry { - name: rv_str(name), - stat, - }) - .collect(); - *self = Lent { - _names: names, - entries, - ..Lent::default() - }; - RvDirEntries { - ptr: self.entries.as_ptr(), - len: self.entries.len(), - } - } - - /// Keeps the text of `error` and returns the failure as the contract - /// hands it back. - fn error(&mut self, error: &VfsError) -> RvFileError { - let (kind, text) = describe(error); - *self = Lent { - text, - ..Lent::default() - }; - RvFileError { - kind, - reserved: 0, - text: rv_str(&self.text), - } - } -} - -/// `stat` as the contract hands it back. A kind of node the contract -/// does not name is `RV_FILE_TYPE_OTHER`. -fn raw_stat(stat: &Stat) -> RvStat { - RvStat { - file_type: match stat.file_type { - FileType::File => RV_FILE_TYPE_FILE, - FileType::Directory => RV_FILE_TYPE_DIRECTORY, - FileType::Symlink => RV_FILE_TYPE_SYMLINK, - _ => RV_FILE_TYPE_OTHER, - }, - reserved: 0, - len: stat.size, - } -} - -/// The contract's kind for `error`, and the text a model reads. -/// -/// A missing path, a path that is not a directory, and a path that is one -/// keep the error's own sentence. A policy denial carries the policy's -/// own reason. A conflict names the path and not the two identities that -/// claimed it, which mean nothing outside the Harness. An I/O failure -/// carries the kind of the failure and the system's description. Of the -/// errors the contract has no kind for, a refused path carries the rule -/// it broke, and every other one its sentence without the leading label. -fn describe(error: &VfsError) -> (RvFileErrorKind, String) { - match error { - VfsError::NotFound { .. } => (RV_FILE_ERROR_NOT_FOUND, error.to_string()), - VfsError::NotADirectory { .. } => (RV_FILE_ERROR_NOT_A_DIRECTORY, error.to_string()), - VfsError::IsADirectory { .. } => (RV_FILE_ERROR_IS_A_DIRECTORY, error.to_string()), - VfsError::PermissionDenied { reason, .. } | VfsError::ApprovalRequired { reason, .. } => { - (RV_FILE_ERROR_DENIED, reason.clone()) - } - VfsError::Conflict { path, .. } => ( - RV_FILE_ERROR_CONFLICT, - format!("another tool call of the run changed {path}, or is changing it"), - ), - VfsError::Io { kind, message, .. } => (RV_FILE_ERROR_IO, format!("{kind}: {message}")), - // The path is quoted in the sentence and may hold `": "` itself, - // so the rule is taken from the error, not cut out of the text. - VfsError::InvalidPath { reason, .. } => (RV_FILE_ERROR_OTHER, reason.to_string()), - VfsError::NotUtf8 { path, .. } => ( - RV_FILE_ERROR_OTHER, - format!("{path} holds a name or contents that are not UTF-8"), - ), - other => (RV_FILE_ERROR_OTHER, without_label(&other.to_string())), - } -} - -/// Runs one operation of a tool call's access, and turns a panic inside -/// it into a failure. The operation runs under a C function the Plugin -/// called, which a panic must not unwind through, and the policy and the -/// backend behind an access are the Host's own code. -fn contained(operation: impl FnOnce() -> Result) -> Result { - catch_unwind(AssertUnwindSafe(operation)).unwrap_or_else(|_| { - Err(VfsError::Backend { - message: "the file operation panicked".to_owned(), - }) - }) -} - -/// `text` without its leading label: the text after the first `": "`, or -/// the whole text when it has none. -fn without_label(text: &str) -> String { - text.split_once(": ") - .map_or(text, |(_, rest)| rest) - .to_owned() -} - -/// Says what `path` names; returns RV_OK with `stat` filled, RV_ERR_FILE -/// with `error` filled, or RV_ERR_MISUSE. -pub(crate) unsafe extern "C" fn file_stat( - ctx: *mut c_void, - path: RvStr, - stat: *mut RvStat, - error: *mut RvFileError, -) -> RvStatus { - answer_file(ctx, |access, lent| { - let stat = STAT.check(stat)?; - let error = STAT_ERROR.check(error)?; - // SAFETY: the contract makes `path` valid for its length during - // this call. - let path = unsafe { PATH.read(path) }?; - Ok(match contained(|| access.stat(&path)) { - Ok(found) => { - *lent = Lent::default(); - // SAFETY: `stat` is not NULL and is aligned, and the - // contract makes it a struct the Plugin owns. - unsafe { stat.write(raw_stat(&found)) }; - RV_OK - } - // SAFETY: as above, for `error`. - Err(failure) => unsafe { fail(lent, error, &failure) }, - }) - }) -} - -/// Lists the directory at `path`; returns RV_OK with `entries` filled, -/// RV_ERR_FILE with `error` filled, or RV_ERR_MISUSE. -pub(crate) unsafe extern "C" fn file_list( - ctx: *mut c_void, - path: RvStr, - entries: *mut RvDirEntries, - error: *mut RvFileError, -) -> RvStatus { - answer_file(ctx, |access, lent| { - let entries = ENTRIES.check(entries)?; - let error = LIST_ERROR.check(error)?; - // SAFETY: the contract makes `path` valid for its length during - // this call. - let path = unsafe { PATH.read(path) }?; - Ok(match contained(|| access.list(&path)) { - Ok(listed) => { - let listed = lent.entries(listed); - // SAFETY: `entries` is not NULL and is aligned, and the - // contract makes it a struct the Plugin owns. - unsafe { entries.write(listed) }; - RV_OK - } - // SAFETY: as above, for `error`. - Err(failure) => unsafe { fail(lent, error, &failure) }, - }) - }) -} - -/// Reads up to `len` bytes of the file at `path` from the byte `offset` -/// on; returns RV_OK with `bytes` filled, RV_ERR_FILE with `error` filled, -/// or RV_ERR_MISUSE. -pub(crate) unsafe extern "C" fn file_read( - ctx: *mut c_void, - path: RvStr, - offset: u64, - len: u64, - bytes: *mut RvBytes, - error: *mut RvFileError, -) -> RvStatus { - answer_file(ctx, |access, lent| { - let bytes = BYTES.check(bytes)?; - let error = READ_ERROR.check(error)?; - // SAFETY: the contract makes `path` valid for its length during - // this call. - let path = unsafe { PATH.read(path) }?; - // No read returns more than memory can hold, and a backend that - // slices a whole file refuses a length it cannot address. - let len = usize::try_from(len).unwrap_or(usize::MAX); - // A real file cannot be positioned past this offset, and no file - // is that long, so the read finds the end and returns no bytes. - let offset = offset.min(i64::MAX.unsigned_abs()); - let wanted = u64::try_from(len).unwrap_or(u64::MAX); - Ok( - match contained(|| access.read_range(&path, offset, wanted)) { - Ok(mut read) => { - // The contract promises at most `len` bytes, whatever - // the backend returned. - read.truncate(len); - let read = lent.bytes(read); - // SAFETY: `bytes` is not NULL and is aligned, and the - // contract makes it a struct the Plugin owns. - unsafe { bytes.write(read) }; - RV_OK - } - // SAFETY: as above, for `error`. - Err(failure) => unsafe { fail(lent, error, &failure) }, - }, - ) - }) -} - -/// Hands `failure` back through `error`, with its text kept in `lent`, -/// and returns RV_ERR_FILE. -/// -/// # Safety -/// `error` must be valid for a write of an `RvFileError`. -unsafe fn fail(lent: &mut Lent, error: *mut RvFileError, failure: &VfsError) -> RvStatus { - let raw = lent.error(failure); - // SAFETY: the caller makes `error` valid for the write. - unsafe { error.write(raw) }; - RV_ERR_FILE -} diff --git a/crates/harness-internal/plugin-loader/src/host-tests.rs b/crates/harness-internal/plugin-loader/src/host-tests.rs deleted file mode 100644 index 3faf08761..000000000 --- a/crates/harness-internal/plugin-loader/src/host-tests.rs +++ /dev/null @@ -1,626 +0,0 @@ -//! Tests for the load boundary: the `RvHost` table, what `describe_plugin` -//! and `refuse` accept, what they answer RV_ERR_MISUSE, which `ctx` they -//! answer, and which failure the loader reports when a Plugin misuses -//! them. - -use std::sync::atomic::Ordering; - -use crate::fakes::*; -use crate::host::HOST; - -/// What a name that is not one name segment is reported as. -macro_rules! bad_name { - () => { - "the name is not one name segment, such as `acme-url`" - }; -} - -/// What a Pack name that is not a vendor and a Pack id is reported as. -macro_rules! bad_pack_name { - () => { - "the Pack's name is not a vendor and a Pack id joined by `/`, such as `acme/url`" - }; -} - -/// The reason of a failure caused by misused load functions. -macro_rules! misuse_reason { - ($detail:expr) => { - concat!("the Plugin misused the load functions: ", $detail) - }; -} - -/// What a load function handed a `ctx` other than the open load's is -/// reported as. -macro_rules! other_ctx { - () => { - "a load function was handed a ctx other than the open RV_PLUGIN_LOAD call's" - }; -} - -/// Defines a test whose fake load runs `$misuse`, a load function call -/// that must answer RV_ERR_MISUSE, and then returns RV_OK. The load must -/// fail with `$detail`, and a failed load is never unloaded. -macro_rules! misuse_test { - ($test:ident, $detail:expr, |$call:ident| $misuse:expr) => { - #[test] - fn $test() { - fake!( - plug, - { - pub(super) static SEEN: AtomicUsize = AtomicUsize::new(0); - }, - |$call| { - let status: RvStatus = $misuse; - SEEN.store(status as usize, Ordering::SeqCst); - RV_OK - } - ); - let failure = failure(plug::entry); - assert_eq!(failure.message, misuse_reason!($detail)); - assert_eq!(reason(&failure), Some("misuse")); - assert_eq!(plug::SEEN.load(Ordering::SeqCst), RV_ERR_MISUSE as usize); - assert_eq!(plug::UNLOADS.load(Ordering::SeqCst), 0); - } - }; -} - -misuse_test!( - a_name_over_64_bytes_is_misuse, - "the name is longer than 64 bytes", - |call| describe(call, &"a".repeat(65), "1.0.0") -); - -misuse_test!( - a_name_with_a_null_pointer_and_a_length_is_misuse, - "the name has a NULL pointer and a nonzero length", - |call| describe_info( - call, - &info( - RvStr { - ptr: ptr::null(), - len: 4 - }, - rv_str("1.0.0") - ) - ) -); - -misuse_test!(a_null_empty_name_is_misuse, bad_name!(), |call| { - describe_info( - call, - &info( - RvStr { - ptr: ptr::null(), - len: 0, - }, - rv_str("1.0.0"), - ), - ) -}); - -misuse_test!(a_name_with_two_segments_is_misuse, bad_name!(), |call| { - describe(call, "acme/url", "1.0.0") -}); - -misuse_test!( - a_name_with_an_uppercase_letter_is_misuse, - bad_name!(), - |call| describe(call, "Acme", "1.0.0") -); - -misuse_test!( - a_name_that_is_not_utf8_is_misuse, - "the name is not UTF-8", - |call| describe_info(call, &info(rv_str_bytes(&[0xff, 0xfe]), rv_str("1.0.0"))) -); - -misuse_test!(an_empty_version_is_misuse, "the version is empty", |call| { - describe(call, "acme-url", "") -}); - -misuse_test!( - a_version_over_64_bytes_is_misuse, - "the version is longer than 64 bytes", - |call| describe(call, "acme-url", &"1".repeat(65)) -); - -misuse_test!( - a_version_with_a_control_character_is_misuse, - "the version has a control character", - |call| describe(call, "acme-url", "1.0\n0") -); - -misuse_test!( - a_version_with_a_null_pointer_and_a_length_is_misuse, - "the version has a NULL pointer and a nonzero length", - |call| describe_info( - call, - &info( - rv_str("acme-url"), - RvStr { - ptr: ptr::null(), - len: 3 - } - ) - ) -); - -misuse_test!( - a_short_info_size_is_misuse, - "the info's size is shorter than RvPluginInfo", - |call| describe_info( - call, - &RvPluginInfo { - size: 0, - ..info(rv_str("acme-url"), rv_str("1.0.0")) - } - ) -); - -misuse_test!( - an_info_size_one_byte_short_is_misuse, - "the info's size is shorter than RvPluginInfo", - |call| { - let whole = info(rv_str("acme-url"), rv_str("1.0.0")); - describe_info( - call, - &RvPluginInfo { - size: whole.size - 1, - ..whole - }, - ) - } -); - -misuse_test!( - a_nonzero_reserved_field_is_misuse, - "the info's reserved field is not zero", - |call| describe_info( - call, - &RvPluginInfo { - reserved: 1, - ..info(rv_str("acme-url"), rv_str("1.0.0")) - } - ) -); - -misuse_test!( - a_null_info_is_misuse, - "describe_plugin was handed a NULL info", - |call| { - // SAFETY: the loader's function accepts a NULL info and answers - // misuse; the call's `ctx` is the open load's. - unsafe { crate::host::describe_plugin(ctx(call), ptr::null()) } - } -); - -misuse_test!( - a_misaligned_info_is_misuse, - "describe_plugin was handed a misaligned info", - |call| { - // SAFETY: the loader's function answers misuse for a misaligned - // info without reading it, so address 1 is safe to hand it; the - // call's `ctx` is the open load's. - unsafe { crate::host::describe_plugin(ctx(call), ptr::without_provenance(1)) } - } -); - -misuse_test!( - a_second_description_is_misuse, - "describe_plugin was called twice", - |call| { - describe(call, "acme-url", "1.0.0"); - describe(call, "acme-url", "1.0.0") - } -); - -misuse_test!( - a_refusal_reason_over_1024_bytes_is_misuse, - "the refusal reason is longer than 1024 bytes", - |call| refuse(call, &"r".repeat(1025)) -); - -misuse_test!( - a_refusal_reason_that_is_not_utf8_is_misuse, - "the refusal reason is not UTF-8", - |call| refuse_str(call, rv_str_bytes(&[0xc3, 0x28])) -); - -misuse_test!( - a_refusal_reason_with_a_null_pointer_and_a_length_is_misuse, - "the refusal reason has a NULL pointer and a nonzero length", - |call| refuse_str( - call, - RvStr { - ptr: ptr::null(), - len: 5 - } - ) -); - -misuse_test!( - a_ctx_other_than_the_open_loads_is_misuse, - other_ctx!(), - |call| { - let mut other = 0_u64; - let other: *mut c_void = (&raw mut other).cast(); - describe_as(call, other, &info(rv_str("acme-url"), rv_str("1.0.0"))) - } -); - -misuse_test!(a_null_ctx_is_misuse, other_ctx!(), |call| refuse_as( - call, - ptr::null_mut(), - rv_str("no") -)); - -// A Plugin that stays loaded while `the_ctx_of_another_loaded_plugin_is_misuse` -// loads a second one, which hands this one's `ctx` to its load functions. -fake!(kept, |call| describe(call, "kept-one", "1.0.0")); - -#[test] -fn the_ctx_of_another_loaded_plugin_is_misuse() { - let kept = loaded(kept::entry); - fake!( - plug, - { - pub(super) static SEEN: AtomicUsize = AtomicUsize::new(0); - }, - |call| { - let kept_ctx = super::kept::LOAD_CTX.load(Ordering::SeqCst); - let status = describe_as(call, kept_ctx, &info(rv_str("acme-url"), rv_str("1.0.0"))); - SEEN.store(status as usize, Ordering::SeqCst); - RV_OK - } - ); - let failure = failure(plug::entry); - assert_eq!(failure.message, misuse_reason!(other_ctx!())); - assert_eq!(reason(&failure), Some("misuse")); - assert_eq!(plug::SEEN.load(Ordering::SeqCst), RV_ERR_MISUSE as usize); - assert_ne!( - plug::LOAD_CTX.load(Ordering::SeqCst), - kept::LOAD_CTX.load(Ordering::SeqCst), - "two loads with a Plugin still loaded get two ctx pointers" - ); - assert_eq!(kept.name(), "kept-one", "the kept Plugin is untouched"); - assert_eq!(kept::UNLOADS.load(Ordering::SeqCst), 0); -} - -#[test] -fn a_name_and_a_version_of_exactly_64_bytes_are_accepted() { - fake!(plug, |call| describe( - call, - &"a".repeat(64), - &"1".repeat(64) - )); - let plugin = loaded(plug::entry); - assert_eq!(plugin.name(), "a".repeat(64)); - assert_eq!(plugin.version(), "1".repeat(64)); -} - -#[test] -fn a_refusal_reason_of_exactly_1024_bytes_is_accepted() { - fake!(plug, |call| refuse(call, &"r".repeat(1024))); - let failure = failure(plug::entry); - assert_eq!( - failure.message, - format!("the Plugin refused: {}", "r".repeat(1024)) - ); -} - -#[test] -fn a_refusal_with_no_reason_is_accepted() { - fake!( - plug, - { - pub(super) static SEEN: AtomicUsize = AtomicUsize::new(0); - }, - |call| { - let status = refuse_str( - call, - RvStr { - ptr: ptr::null(), - len: 0, - }, - ); - SEEN.store(status as usize, Ordering::SeqCst); - RV_ERR_REFUSED - } - ); - let failure = failure(plug::entry); - assert_eq!(failure.message, "the Plugin refused: "); - assert_eq!(plug::SEEN.load(Ordering::SeqCst), RV_ERR_REFUSED as usize); -} - -#[test] -fn a_second_refusal_is_misuse_and_the_first_is_reported() { - fake!( - plug, - { - pub(super) static SEEN: AtomicUsize = AtomicUsize::new(0); - }, - |call| { - refuse(call, "first"); - let second = refuse(call, "second"); - SEEN.store(second as usize, Ordering::SeqCst); - RV_ERR_REFUSED - } - ); - let failure = failure(plug::entry); - assert_eq!(failure.message, "the Plugin refused: first"); - assert_eq!(plug::SEEN.load(Ordering::SeqCst), RV_ERR_MISUSE as usize); -} - -#[test] -fn a_refusal_wins_over_a_misuse_made_before_it() { - fake!(plug, |call| { - describe(call, "", "1.0.0"); - refuse(call, "no thanks") - }); - assert_eq!( - failure(plug::entry).message, - "the Plugin refused: no thanks" - ); -} - -#[test] -fn a_misuse_followed_by_a_non_ok_status_reports_the_misuse() { - fake!(plug, |call| { - describe(call, "", "1.0.0"); - ODD_STATUS - }); - assert_eq!(failure(plug::entry).message, misuse_reason!(bad_name!())); -} - -#[test] -fn a_misuse_is_reported_even_when_a_good_description_follows() { - fake!( - plug, - { - pub(super) static SEEN: AtomicUsize = AtomicUsize::new(0); - }, - |call| { - let status = describe(call, "Not A Name", "1.0.0"); - SEEN.store(status as usize, Ordering::SeqCst); - describe(call, "acme-url", "1.0.0") - } - ); - let failure = failure(plug::entry); - assert_eq!(failure.message, misuse_reason!(bad_name!())); - assert_eq!(plug::SEEN.load(Ordering::SeqCst), RV_ERR_MISUSE as usize); -} - -#[test] -fn a_load_function_called_from_another_thread_during_the_load_is_misuse() { - fake!( - plug, - { - pub(super) static SEEN: AtomicUsize = AtomicUsize::new(0); - }, - |call| { - let address = ctx(call) as usize; - let (described, refused) = std::thread::spawn(move || { - let ctx = ptr::with_exposed_provenance_mut::(address); - ( - describe_with(ctx, "acme-url", "1.0.0"), - refuse_with(ctx, "from another thread"), - ) - }) - .join() - .expect("the thread finishes"); - SEEN.store( - usize::from(described == RV_ERR_MISUSE && refused == RV_ERR_MISUSE), - Ordering::SeqCst, - ); - describe(call, "acme-url", "1.0.0") - } - ); - let plugin = loaded(plug::entry); - assert_eq!( - plug::SEEN.load(Ordering::SeqCst), - 1, - "both calls from the other thread got misuse" - ); - assert_eq!(plugin.name(), "acme-url"); -} - -#[test] -fn a_late_call_through_a_ctx_stashed_during_a_successful_load_is_misuse() { - fake!(plug, |call| describe(call, "acme-url", "1.0.0")); - let plugin = loaded(plug::entry); - let stashed = plug::LOAD_CTX.load(Ordering::SeqCst); - assert!(!stashed.is_null(), "the load was handed a ctx"); - assert_eq!(describe_with(stashed, "other", "2.0.0"), RV_ERR_MISUSE); - assert_eq!(refuse_with(stashed, "too late"), RV_ERR_MISUSE); - assert_eq!(plugin.name(), "acme-url", "the late calls changed nothing"); - assert_eq!(plug::UNLOADS.load(Ordering::SeqCst), 0); -} - -#[test] -fn a_late_call_through_a_rejected_loads_ctx_is_misuse() { - fake!(plug, |call| refuse(call, "no")); - drop(failure(plug::entry)); - let stashed = plug::LOAD_CTX.load(Ordering::SeqCst); - assert!(!stashed.is_null(), "the load was handed a ctx"); - assert_eq!(describe_with(stashed, "acme-url", "1.0.0"), RV_ERR_MISUSE); -} - -#[test] -fn the_load_call_carries_the_contract_fields() { - fake!( - plug, - { - pub(super) static SEEN: AtomicUsize = AtomicUsize::new(0); - }, - |call| { - SEEN.store( - usize::from(contract_holds(call, RV_PLUGIN_LOAD)), - Ordering::SeqCst, - ); - describe(call, "acme-url", "1.0.0") - } - ); - let _plugin = loaded(plug::entry); - assert_eq!(plug::SEEN.load(Ordering::SeqCst), 1); -} - -#[test] -fn the_table_reports_version_0_4_in_16_bytes() { - assert_eq!(HOST.api_major, 0); - assert_eq!(HOST.api_minor, 4); - assert_eq!(HOST.reserved, 0); - assert_eq!(HOST.size, 16); - assert_eq!(usize::try_from(HOST.size), Ok(size_of::())); -} - -/// Defines a test whose fake load describes itself, runs `$misuse`, an -/// `insert_pack` call that must answer RV_ERR_MISUSE, and then returns -/// RV_OK. The load must fail with `$detail`, and a failed load is never -/// unloaded. -macro_rules! pack_misuse_test { - ($test:ident, $detail:expr, |$call:ident| $misuse:expr) => { - misuse_test!($test, $detail, |$call| { - describe($call, "acme-url", "1.0.0"); - $misuse - }); - }; -} - -pack_misuse_test!( - a_pack_name_without_a_vendor_is_misuse, - bad_pack_name!(), - |call| insert_pack(call, "url", "") -); - -pack_misuse_test!( - a_pack_name_with_three_segments_is_misuse, - bad_pack_name!(), - |call| insert_pack(call, "acme/url/extra", "") -); - -pack_misuse_test!( - a_pack_name_with_an_empty_segment_is_misuse, - bad_pack_name!(), - |call| insert_pack(call, "acme/", "") -); - -pack_misuse_test!( - a_pack_name_with_an_uppercase_letter_is_misuse, - bad_pack_name!(), - |call| insert_pack(call, "acme/Url", "") -); - -pack_misuse_test!( - a_pack_name_with_a_segment_over_64_bytes_is_misuse, - bad_pack_name!(), - |call| insert_pack(call, &format!("acme/{}", "a".repeat(65)), "") -); - -pack_misuse_test!( - a_pack_name_over_129_bytes_is_misuse, - "the Pack's name is longer than 129 bytes", - |call| insert_pack(call, &"a".repeat(130), "") -); - -pack_misuse_test!( - a_pack_name_that_is_not_utf8_is_misuse, - "the Pack's name is not UTF-8", - |call| insert_pack_info(call, &pack_info(rv_str_bytes(&[0xff, 0xfe]), rv_str(""))) -); - -pack_misuse_test!( - a_pack_inserted_twice_is_misuse, - "two Packs of one Plugin share a name", - |call| { - insert_pack(call, "acme/url", ""); - insert_pack(call, "acme/url", "again") - } -); - -pack_misuse_test!( - a_pack_description_over_1024_bytes_is_misuse, - "the Pack's description is longer than 1024 bytes", - |call| insert_pack(call, "acme/url", &"d".repeat(1025)) -); - -pack_misuse_test!( - a_pack_description_with_a_control_character_is_misuse, - "the Pack's description has a control character", - |call| insert_pack(call, "acme/url", "two\nlines") -); - -pack_misuse_test!( - a_short_pack_info_size_is_misuse, - "the info's size is shorter than RvPackInfo", - |call| insert_pack_info( - call, - &RvPackInfo { - size: 8, - ..pack_info(rv_str("acme/url"), rv_str("")) - } - ) -); - -pack_misuse_test!( - a_nonzero_reserved_field_in_a_pack_info_is_misuse, - "the info's reserved field is not zero", - |call| insert_pack_info( - call, - &RvPackInfo { - reserved: 1, - ..pack_info(rv_str("acme/url"), rv_str("")) - } - ) -); - -pack_misuse_test!( - a_null_pack_info_is_misuse, - "insert_pack was handed a NULL info", - |call| { - // SAFETY: the loader's function accepts a NULL info and answers - // misuse; the call's `ctx` is the open load's. - unsafe { crate::host::insert_pack(ctx(call), ptr::null()) } - } -); - -pack_misuse_test!( - a_pack_function_called_during_a_load_is_misuse, - "a Pack function was called outside RV_PACK_NEW", - |call| { - let tool = tool_info("fetch"); - // SAFETY: the loader's function reads through `ctx` only when it - // is the open call's, and answers misuse for a call of another - // verb before it reads `tool`. - unsafe { crate::pack::add_tool(ctx(call), &raw const tool) } - } -); - -pack_misuse_test!( - finish_called_during_a_load_is_misuse, - "a tool call function was called outside RV_TOOL_CALL", - |call| { - let result = result(RV_RESULT_TRUSTED, rv_str("done")); - // SAFETY: as in the test above. - unsafe { crate::call::finish(ctx(call), &raw const result) } - } -); - -#[test] -fn a_pack_name_and_a_description_at_their_bounds_are_accepted() { - fake!(plug, |call| { - describe(call, "acme-url", "1.0.0"); - insert_pack( - call, - &format!("{}/{}", "v".repeat(64), "p".repeat(64)), - &"d".repeat(1024), - ) - }); - let mut packs = runvil_plugin::PackRegistry::new(); - let _plugin = crate::plugin::insert_plugin(&mut packs, plug::entry).expect("the Plugin loads"); - let fqn = format!("{}/{}", "v".repeat(64), "p".repeat(64)); - let description = packs - .list() - .find(|(info, _)| info.fqn == fqn) - .map(|(info, _)| info.description); - assert_eq!(description, Some("d".repeat(1024).as_str())); -} diff --git a/crates/harness-internal/plugin-loader/src/host.rs b/crates/harness-internal/plugin-loader/src/host.rs deleted file mode 100644 index 8f2868dc2..000000000 --- a/crates/harness-internal/plugin-loader/src/host.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! The `RvHost` table every call hands a Plugin, the functions each -//! RV_PLUGIN_LOAD's `RvLoadArgs` carries, and the senders of -//! RV_PLUGIN_LOAD and RV_PLUGIN_UNLOAD. - -use std::ffi::c_void; -use std::{mem, ptr}; - -use runvil_plugin::{Error, PackId}; -use runvil_plugin_sys::{ - PluginEntry, RV_API_MAJOR, RV_API_MINOR, RV_MAX_LINE, RV_MAX_NAME, RV_OK, RV_PLUGIN_LOAD, - RV_PLUGIN_UNLOAD, RvCall, RvHost, RvLoadArgs, RvPackInfo, RvPluginInfo, RvStatus, RvStr, - RvVerb, -}; - -use crate::load::make_load_error; -use crate::session::{ - self, Described, Field, InsertedPack, LOAD, RESERVED, Session, Shape, Verb, answer, field, - shape, size_field, -}; - -#[cfg(test)] -#[path = "host-tests.rs"] -mod tests; - -/// The `RvHost` table every call hands the Plugin. -pub(crate) static HOST: RvHost = RvHost { - size: size_field::(), - api_major: RV_API_MAJOR, - api_minor: RV_API_MINOR, - reserved: 0, -}; - -/// The bound on one name segment, in bytes. -pub(crate) const MAX_SEGMENT: usize = 64; - -const PLUGIN_INFO: Shape = shape!("describe_plugin", "info", "RvPluginInfo"); -const NAME: Field = field!("the name", 64, RV_MAX_NAME); -const VERSION: Field = field!("the version", 64, RV_MAX_NAME); - -const PACK_INFO: Shape = shape!("insert_pack", "info", "RvPackInfo"); -/// Two segments of 64 bytes and the `/` between them. -const PACK_NAME: Field = field!("the Pack's name", 129, 2 * RV_MAX_NAME + 1); -const PACK_DESCRIPTION: Field = field!("the Pack's description", 1024, RV_MAX_LINE); - -/// An `RvCall` that sends `verb` to the Plugin instance `plugin`, with the -/// loader's `RvHost` table and nothing else set. -pub(crate) fn new_call(verb: RvVerb, plugin: *mut c_void) -> RvCall { - RvCall { - size: size_field::(), - verb, - host: &raw const HOST, - plugin, - pack_run: ptr::null_mut(), - args: ptr::null(), - tool_call: ptr::null_mut(), - } -} - -/// Whether `name` is one name segment: a Pack id segment of at most 64 -/// bytes. -pub(crate) fn is_segment(name: &str) -> bool { - name.len() <= MAX_SEGMENT && PackId::parse(name).is_ok() -} - -/// Records the Plugin's refusal to load, validated; returns -/// RV_ERR_REFUSED, or RV_ERR_MISUSE for a `ctx` it doesn't answer or a -/// second refusal. -pub(crate) unsafe extern "C" fn refuse(ctx: *mut c_void, reason: RvStr) -> RvStatus { - session::refuse(ctx, &LOAD, reason) -} - -/// Records the Plugin's description once, validated; returns RV_OK or -/// RV_ERR_MISUSE. -pub(crate) unsafe extern "C" fn describe_plugin( - ctx: *mut c_void, - info: *const RvPluginInfo, -) -> RvStatus { - answer(ctx, &LOAD, |session| { - if session.described.is_some() { - return Err("describe_plugin was called twice"); - } - // SAFETY: the contract makes `info` point at a readable - // `RvPluginInfo` during this call. - let info = unsafe { PLUGIN_INFO.read(info) }?; - if info.reserved != 0 { - return Err(RESERVED); - } - // SAFETY: the contract makes each string valid for its length. - let name = unsafe { NAME.read(info.name) }?; - if !is_segment(&name) { - return Err("the name is not one name segment, such as `acme-url`"); - } - // SAFETY: as above. - let version = unsafe { VERSION.read(info.version) }?; - if version.is_empty() { - return Err("the version is empty"); - } - if version.chars().any(char::is_control) { - return Err("the version has a control character"); - } - session.described = Some(Described { name, version }); - Ok(RV_OK) - }) -} - -/// Records one Pack the Plugin inserts, validated; returns RV_OK or -/// RV_ERR_MISUSE. -pub(crate) unsafe extern "C" fn insert_pack(ctx: *mut c_void, info: *const RvPackInfo) -> RvStatus { - answer(ctx, &LOAD, |session| { - // SAFETY: the contract makes `info` point at a readable - // `RvPackInfo` during this call. - let info = unsafe { PACK_INFO.read(info) }?; - if info.reserved != 0 { - return Err(RESERVED); - } - // SAFETY: the contract makes each string valid for its length. - let name = unsafe { PACK_NAME.read(info.name) }?; - let two_segments = name - .split_once('/') - .is_some_and(|(vendor, id)| is_segment(vendor) && is_segment(id)); - if !two_segments { - return Err( - "the Pack's name is not a vendor and a Pack id joined by `/`, such as `acme/url`", - ); - } - if session.packs.iter().any(|pack| pack.name == name) { - return Err("two Packs of one Plugin share a name"); - } - // SAFETY: as above. - let description = unsafe { PACK_DESCRIPTION.read(info.description) }?; - if description.chars().any(char::is_control) { - return Err("the Pack's description has a control character"); - } - session.packs.push(InsertedPack { name, description }); - Ok(RV_OK) - }) -} - -/// What a successful RV_PLUGIN_LOAD left: the Plugin's description, the -/// Packs it inserted, the instance pointer it set, and the session its -/// `ctx` points at, which the caller keeps until RV_PLUGIN_UNLOAD returns. -pub(crate) struct Loading { - pub(crate) described: Described, - pub(crate) packs: Vec, - pub(crate) instance: *mut c_void, - pub(crate) ctx: Box, -} - -/// Sends RV_PLUGIN_LOAD through `entry` with a fresh `ctx` open on this -/// thread, then closes it. On failure, the `ctx` is freed before this -/// returns. -/// -/// # Safety -/// `entry` must follow the C contract in `runvil-plugin-sys`, and must not -/// unwind. -pub(crate) unsafe fn send_load(entry: PluginEntry) -> Result { - let mut call = new_call(RV_PLUGIN_LOAD, ptr::null_mut()); - let (status, mut session) = session::open(Verb::Load, |ctx| { - let args = RvLoadArgs { - size: size_field::(), - reserved: 0, - ctx, - refuse, - describe_plugin, - insert_pack, - }; - call.args = (&raw const args).cast(); - // SAFETY: the caller guarantees `entry` follows the contract, and - // `call` is a live `RvCall` that nothing else uses, whose `args` - // is live for the call. - unsafe { entry(&raw mut call) } - }); - if let Some(reason) = session.refusal.take() { - return Err(make_load_error( - "refused", - format!("the Plugin refused: {reason}"), - )); - } - if let Some(reason) = session.misuse.take() { - return Err(make_load_error( - "misuse", - format!("the Plugin misused the load functions: {reason}"), - )); - } - if status != RV_OK { - return Err(make_load_error( - "status", - format!("RV_PLUGIN_LOAD returned status {status}"), - )); - } - let Some(described) = session.described.take() else { - return Err(make_load_error( - "undescribed", - "RV_PLUGIN_LOAD returned without describing the Plugin".to_owned(), - )); - }; - Ok(Loading { - described, - packs: mem::take(&mut session.packs), - instance: call.plugin, - ctx: session, - }) -} - -/// Sends RV_PLUGIN_UNLOAD through `entry` with `plugin` and NULL `args`. No -/// call is open on this thread meanwhile, so any function the Plugin calls -/// answers RV_ERR_MISUSE. The status is ignored, because a Plugin that is -/// going away has no one to tell. -/// -/// # Safety -/// `entry` must follow the C contract and must not unwind, and `plugin` -/// must be the pointer its RV_PLUGIN_LOAD set, not yet unloaded, with -/// every Pack instance it made deleted. -pub(crate) unsafe fn send_unload(entry: PluginEntry, plugin: *mut c_void) { - let mut call = new_call(RV_PLUGIN_UNLOAD, plugin); - // SAFETY: the caller guarantees `entry` follows the contract and - // `plugin` is the live instance, and `call` is a live `RvCall`. - let _status = session::closed(|| unsafe { entry(&raw mut call) }); -} diff --git a/crates/harness-internal/plugin-loader/src/lib.rs b/crates/harness-internal/plugin-loader/src/lib.rs deleted file mode 100644 index 429de3ac3..000000000 --- a/crates/harness-internal/plugin-loader/src/lib.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! `harness-plugin-loader` - inserts Plugins into a Host's Pack registry, -//! from dynamic libraries in a Plugin directory or linked into the Host. -//! Each Plugin loads as it is inserted, and each Pack it offers goes into -//! the registry with a factory that makes one instance of the Pack for -//! each run and performs that instance's tool calls, all through the -//! Plugin's entry point. -//! -//! ## Invariants -//! -//! - May depend on: `runvil-plugin` among workspace crates, and -//! `runvil-plugin-sys`, the C contract at version 0.4, from its own -//! repository. -//! - Every entry point gets the same calls, whether it came from a library -//! or was linked in. The only difference is that a library is opened -//! when it is inserted and closed after its Plugin is dropped. -//! - Dropping a `Plugin` sends `RV_PLUGIN_UNLOAD` once, with the instance -//! pointer its `RV_PLUGIN_LOAD` set, then closes its library, if it has -//! one. Each Pack in the registry and each Pack instance of a run holds -//! the `Plugin`, so the unload comes after every `RV_PACK_DELETE`. -//! - An entry point whose `RV_PLUGIN_LOAD` fails gets no -//! `RV_PLUGIN_UNLOAD`, and its library is closed at once; a failing load -//! must leave nothing running. None of its Packs go into the registry. -//! - Dropping a Pack instance sends `RV_PACK_DELETE` once. A tool call -//! borrows its instance, so the delete comes after every call of the -//! instance has ended. An `RV_PACK_NEW` that fails gets no -//! `RV_PACK_DELETE`. -//! - A tool call sends one `RV_TOOL_CALL` each time its future is polled, -//! and a step that returns `RV_PENDING` wakes the future's own task. A -//! call whose last step returned `RV_PENDING` gets one `RV_TOOL_CANCEL` -//! when it fails or its future is dropped, and no other call does. -//! - The functions in a call's arguments struct answer only that call's -//! `ctx`, on its thread, while it runs. -//! - A step's file functions read through the `Access` of the step's tool -//! call and nothing else, so the run's mode and policy apply to every -//! read. What a file function hands back is freed by the step's next -//! file function, or when the step returns. -//! - Each `RV_PLUGIN_LOAD` gets its own `ctx`, a loader-owned allocation -//! freed when a rejected load returns or after its Plugin's -//! `RV_PLUGIN_UNLOAD` returns. The `ctx` of every other call is freed -//! when the call returns. -//! - Nothing the loader keeps points into a library except its entry -//! point and the pointers the Plugin set for the loader to pass back, -//! which the loader never reads through; every text is copied. -//! - What a Plugin hands the loader is checked as `testing` in -//! `runvil-plugin-sdk` checks it: the same order, the same bounds, and -//! the same words for each misuse. -#![expect( - unsafe_code, - reason = "opens dynamic libraries and calls C entry points" -)] - -mod call; -#[cfg(test)] -mod fakes; -mod file; -mod host; -mod load; -mod pack; -mod plugin; -mod session; - -pub use plugin::{Plugin, PluginReport, PluginSource, insert_plugin, insert_plugin_dir}; diff --git a/crates/harness-internal/plugin-loader/src/load-tests.rs b/crates/harness-internal/plugin-loader/src/load-tests.rs deleted file mode 100644 index bf8513436..000000000 --- a/crates/harness-internal/plugin-loader/src/load-tests.rs +++ /dev/null @@ -1,265 +0,0 @@ -//! Tests for listing and opening Plugin libraries: which files in a Plugin -//! directory count, in what order, with what paths, and how a library -//! without `runvil_plugin` fails. - -use std::env::consts::DLL_EXTENSION; -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -use crate::load::{is_regular_file, list_plugin_files, plugin_files}; - -/// Tells apart the directories one process makes; nextest runs each test -/// in its own process, so the process id tells those apart. -static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); - -/// A directory made for one test at `/