From d2bad8bae94a2bab0a7df202c311d628dec655d3 Mon Sep 17 00:00:00 2001 From: Rafael Amador Date: Wed, 30 Sep 2026 19:03:24 -0600 Subject: [PATCH 1/2] Add CI on three platforms, a cargo-deny workflow and publish on release CI runs fmt, clippy, the all-features check and the tests on Linux, Windows and macOS, plus a ci-ok gate and a coverage report. Security runs cargo-deny weekly and on dependency changes. Release checks the tag against Cargo.toml, dry-runs the package and publishes with crates.io trusted publishing instead of a stored token. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 131 ++++++++++++++++++++++++++++++--- .github/workflows/release.yml | 64 ++++++++++++++-- .github/workflows/security.yml | 33 +++++++++ deny.toml | 88 ++++++++++++++++++++++ 4 files changed, 299 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/security.yml create mode 100644 deny.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe224f8..f10cb01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,128 @@ name: CI +# One workflow for the three operating systems. Per system a single job runs +# clippy, the all-features check and the tests one after the other, so the +# expensive part of the build (reqwest/rustls, jsonwebtoken) +# is compiled once and shared. Pull requests only read the build cache; it is +# written by pushes to master. +# +# `Cargo.lock` is not versioned (this is a library), so there is no `--locked`: +# CI builds against the newest dependencies that `Cargo.toml` allows, which is +# what a consumer of the crate gets. on: + pull_request: + branches: [master] + paths-ignore: + - "**.md" push: - branches: [ master ] + branches: [master] + paths-ignore: + - "**.md" + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + # A new push to a pull request replaces the run in progress; a run on master + # always finishes. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + CARGO_INCREMENTAL: 0 + CARGO_NET_RETRY: 10 + CARGO_TERM_COLOR: always + CARGO_HTTP_MULTIPLEXING: false + # Backtraces with line numbers only: smaller artifacts and a faster build. + CARGO_PROFILE_DEV_DEBUG: line-tables-only + RUST_BACKTRACE: 1 jobs: - build: + fmt: + name: fmt + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all -- --check + + # Every system: some code is platform-specific, so a library is checked + # natively on each one. + test: + name: clippy and test on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/master' }} + # A failed run still leaves the dependencies compiled for the next one. + cache-on-failure: true + # Library, tests, examples and doc tests. The tests that need EVE + # credentials are `#[ignore]`d (see `.env.example`). + - run: cargo clippy --all-targets -- -D warnings + # All features: compiled, not run. + - run: cargo check --all-targets --all-features + - run: cargo test + + # The one check to require if branch protection is turned on. + ci-ok: + name: ci-ok + if: always() + needs: [fmt, test] + runs-on: ubuntu-latest + steps: + - name: Fail if a job failed or was cancelled + run: | + if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then + echo "::error::a CI job did not pass" + exit 1 + fi + # Line coverage; kept as an artifact, no external service. + coverage: + name: coverage + if: github.event_name != 'pull_request' runs-on: ubuntu-latest - if: | - !(github.event_name == 'push' && contains(github.event.head_commit.message, '[skip ci]')) + timeout-minutes: 45 steps: - - uses: actions/checkout@v3 - - uses: Swatinem/rust-cache@v2 - - name: Install clippy - run: rustup component add clippy - - name: Run tests - run: cargo test --verbose --all - - name: Run clippy - run: cargo clippy -- -D warnings + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + - uses: Swatinem/rust-cache@v2 + with: + shared-key: coverage + save-if: ${{ github.ref == 'refs/heads/master' }} + cache-on-failure: true + - uses: taiki-e/install-action@cargo-llvm-cov + - name: Run the tests with coverage + run: cargo llvm-cov --no-report + - name: Write the report + env: + IGNORE: '(cargo/registry|rustc)' + run: | + cargo llvm-cov report --ignore-filename-regex "$IGNORE" --lcov --output-path lcov.info + { + echo '## Line coverage' + echo '```' + cargo llvm-cov report --ignore-filename-regex "$IGNORE" --summary-only + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@v7 + with: + name: coverage-lcov + path: lcov.info + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d14f395..0db7072 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,67 @@ -name: Release +name: Publish to crates.io +# Publishes the crate when a GitHub release is published. The release tag has +# to match the version in `Cargo.toml` (`v0.1.1` or `0.1.1` for version +# 0.1.1), otherwise nothing is published. +# +# Run by hand ("Run workflow") it only packages the crate and runs +# `cargo publish --dry-run`, to try the release without publishing anything. +# +# Authentication is crates.io "trusted publishing": no token is stored in the +# repository. It has to be configured once on crates.io, under the crate's +# Settings > Trusted Publishing: repository owner `rafaga`, this repository, +# workflow file `release.yml`. on: release: - types: [created] + types: [published] + workflow_dispatch: + +# One publish at a time; a second run waits instead of racing the first. +concurrency: + group: publish + cancel-in-progress: false + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CARGO_NET_RETRY: 10 jobs: - build: + publish: + name: publish runs-on: ubuntu-latest + timeout-minutes: 30 + # The token that trusted publishing exchanges is minted from this one. + permissions: + contents: read + id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + + - name: Check that the tag matches the version in Cargo.toml + if: github.event_name == 'release' + run: | + version=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version') + tag="${GITHUB_REF_NAME#v}" + if [ "$version" != "$tag" ]; then + echo "::error::the release tag is '$GITHUB_REF_NAME' but Cargo.toml says version $version" + exit 1 + fi + + # Packages the crate and builds it from the package, as crates.io would. + - name: Dry run + run: cargo publish --dry-run + + - name: Authenticate with crates.io + if: github.event_name == 'release' + id: auth + uses: rust-lang/crates-io-auth-action@v1 + - name: Publish - run: cargo publish --token ${{ secrets.CARGO_TOKEN }} + if: github.event_name == 'release' + run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..18c72a7 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,33 @@ +name: Security + +# Advisories (RustSec), licenses, banned or duplicated crates and unknown +# sources, from `deny.toml`. It runs weekly too, because a new advisory can +# appear against dependencies that have not changed. +on: + schedule: + - cron: "0 6 * * 1" + pull_request: + branches: [master] + paths: + - "Cargo.toml" + - "deny.toml" + - ".github/workflows/security.yml" + workflow_dispatch: + +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + deny: + name: cargo-deny + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..ba1fa06 --- /dev/null +++ b/deny.toml @@ -0,0 +1,88 @@ +# Copied from https://github.com/rerun-io/rerun_template +# +# https://github.com/EmbarkStudios/cargo-deny +# +# cargo-deny checks our dependency tree for copy-left licenses, +# duplicate dependencies, and rustsec advisories (https://rustsec.org/advisories). +# +# Install: `cargo install cargo-deny` +# Check: `cargo deny check`. + + +# Note: running just `cargo deny check` without a `--target` can result in +# false positives due to https://github.com/EmbarkStudios/cargo-deny/issues/324 +[graph] +targets = [ + { triple = "aarch64-apple-darwin" }, + { triple = "i686-pc-windows-msvc" }, + { triple = "i686-unknown-linux-gnu" }, + { triple = "x86_64-apple-darwin" }, + { triple = "x86_64-pc-windows-msvc" }, + { triple = "x86_64-unknown-linux-gnu" }, +] +all-features = true + + +[advisories] +version = 2 +ignore = [ + # `rsa` comes through `jsonwebtoken`, which this crate uses to validate EVE SSO + # tokens (feature `validate_jwt`). The Marvin attack is a timing side channel in + # RSA private-key operations (decryption/signing); only public-key signature + # verification happens here. No fixed release exists (`patched = []`). Revisit + # when `jsonwebtoken` moves to another backend or `rsa` ships a fix. + { id = "RUSTSEC-2023-0071", reason = "rsa timing side channel; only reached through JWT validation, no private key operations, no fix available" }, +] + + +[bans] +multiple-versions = "warn" +wildcards = "warn" +deny = [ + #{ name = "openssl", reason = "Use rustls" }, + #{ name = "openssl-sys", reason = "Use rustls" }, +] +skip = [] +skip-tree = [] + + +[licenses] +version = 2 +private = { ignore = true } +confidence-threshold = 0.93 # We want really high confidence when inferring licenses from text +allow = [ + "Apache-2.0 WITH LLVM-exception", # https://spdx.org/licenses/LLVM-exception.html + "Apache-2.0", # https://tldrlegal.com/license/apache-license-2.0-(apache-2.0) + "BSD-2-Clause", # https://tldrlegal.com/license/bsd-2-clause-license-(freebsd) + "BSD-3-Clause", # https://tldrlegal.com/license/bsd-3-clause-license-(revised) + "BSL-1.0", # https://tldrlegal.com/license/boost-software-license-1.0-explained + "CC0-1.0", # https://creativecommons.org/publicdomain/zero/1.0/ + "ISC", # https://www.tldrlegal.com/license/isc-license + "CDLA-Permissive-2.0", # https://spdx.org/licenses/CDLA-Permissive-2.0.html - data crates (webpki-roots) + "MIT", # https://tldrlegal.com/license/mit-license + "MPL-2.0", # https://www.mozilla.org/en-US/MPL/2.0/FAQ/ - see Q11. Used by webpki-roots on Linux. + "OFL-1.1", # https://spdx.org/licenses/OFL-1.1.html + "Ubuntu-font-1.0", # https://spdx.org/licenses/Ubuntu-font-1.0.html - fonts of epaint_default_fonts + "Unicode-3.0", # https://spdx.org/licenses/Unicode-3.0.html - icu / unicode-ident data + "Zlib", # https://tldrlegal.com/license/zlib-libpng-license-(zlib) +] +exceptions = [] + +[[licenses.clarify]] +name = "webpki" +expression = "ISC" +license-files = [{ path = "LICENSE", hash = 0x001c7e6c }] + +[[licenses.clarify]] +name = "ring" +expression = "MIT AND ISC AND OpenSSL" +license-files = [{ path = "LICENSE", hash = 0xbd0eed23 }] + + +[sources] +unknown-registry = "deny" +unknown-git = "deny" + +[sources.allow-org] +# 1 or more github.com organizations to allow git sources for +github = ["rafaga"] From a20ad5a93e98f6791371e793495d3d43c1404192 Mon Sep 17 00:00:00 2001 From: Rafael Amador Date: Wed, 30 Sep 2026 19:03:27 -0600 Subject: [PATCH 2/2] Split the request checks out of query Moves the authentication check and the request headers into their own helpers, bringing query below a cyclomatic complexity of 15 (was 18). Co-Authored-By: Claude Sonnet 5.5 --- src/client.rs | 66 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/src/client.rs b/src/client.rs index b98bf21..9661c58 100644 --- a/src/client.rs +++ b/src/client.rs @@ -609,33 +609,8 @@ impl Esi { ) -> EsiResult { debug!("Making {request_type:?} {method} request to {endpoint} with query: {query:?}"); self.assert_not_error_limited().await?; - if request_type == RequestType::Authenticated { - if self.access_token.is_none() { - return Err(EsiError::MissingAuthentication); - } - if self.access_expiration.unwrap() < current_time_millis()? { - return Err(EsiError::AccessTokenExpired); - } - } - let headers = { - let mut map = HeaderMap::new(); - // The 'user-agent' and 'content-type' headers are set in the default headers - // from the builder, so all that's required here is to set the authorization - // header, if present, and the compatibility date. - if request_type == RequestType::Authenticated { - if let Some(at) = &self.access_token { - map.insert( - header::AUTHORIZATION, - HeaderValue::from_str(&format!("Bearer {at}"))?, - ); - } - } - map.insert( - COMPATIBILITY_HEADER, - HeaderValue::from_str(&self.compatibility_date)?, - ); - map - }; + self.check_authentication(&request_type)?; + let headers = self.request_headers(&request_type)?; let url = format!("{}{endpoint}", self.base_api_url); let mut req_builder = self .client @@ -657,6 +632,43 @@ impl Esi { Ok(data) } + /// For an authenticated request, fails unless there is a valid, unexpired + /// access token. + fn check_authentication(&self, request_type: &RequestType) -> EsiResult<()> { + if *request_type != RequestType::Authenticated { + return Ok(()); + } + if self.access_token.is_none() { + return Err(EsiError::MissingAuthentication); + } + if self.access_expiration.unwrap() < current_time_millis()? { + return Err(EsiError::AccessTokenExpired); + } + Ok(()) + } + + /// The per-request headers: the authorization header, if authenticated, + /// and the compatibility date. + fn request_headers(&self, request_type: &RequestType) -> EsiResult { + let mut map = HeaderMap::new(); + // The 'user-agent' and 'content-type' headers are set in the default headers + // from the builder, so all that's required here is to set the authorization + // header, if present, and the compatibility date. + if *request_type == RequestType::Authenticated { + if let Some(at) = &self.access_token { + map.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {at}"))?, + ); + } + } + map.insert( + COMPATIBILITY_HEADER, + HeaderValue::from_str(&self.compatibility_date)?, + ); + Ok(map) + } + /// Resolve an `operationId` to a URL path utilizing the OpenAPI spec. /// /// Operation IDs are those of the ESI OpenAPI spec, e.g.