diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5197143..7523425 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,6 +157,12 @@ jobs: - name: Test the update manifest builder run: scripts/test-build-update-manifest.sh + # The security gate has to tell "nothing found" from "could not look": + # a missing jq, no report, or a report that does not parse once read as + # a pass (#729). + - name: Test the cargo-audit gate + run: scripts/test-cargo-audit-check.sh + # ARCHITECTURE section 5 is the reference for the Tauri command surface, and # it had drifted into describing ten commands that were never written — a # reader following it wrote calls that fail at runtime with "command not @@ -228,17 +234,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Read pinned Rust version - id: rust-toolchain-file - # bash explicitly: the default shell on a Windows runner is PowerShell, - # where `$(...)` and `$GITHUB_OUTPUT` mean nothing, so the step passes - # having written no output — and the toolchain action then fails with - # "toolchain is a required input", eight steps from the real cause. + # rustup ships on GitHub-hosted runners and reads rust-toolchain.toml + # itself, so the version and components live in that one file and no + # third-party action sits between it and the build. + - name: Install Rust toolchain shell: bash - run: echo "channel=$(sed -n 's/^channel *= *"\(.*\)"/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT" - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 - with: - toolchain: ${{ steps.rust-toolchain-file.outputs.channel }} + run: rustup toolchain install --profile minimal --no-self-update - name: Install jq run: sudo apt-get install -y jq - name: Install cargo-audit @@ -294,18 +295,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Read pinned Rust version - id: rust-toolchain-file - # bash explicitly: the default shell on a Windows runner is PowerShell, - # where `$(...)` and `$GITHUB_OUTPUT` mean nothing, so the step passes - # having written no output — and the toolchain action then fails with - # "toolchain is a required input", eight steps from the real cause. + # rustup ships on GitHub-hosted runners and reads rust-toolchain.toml + # itself, so the version and components live in that one file and no + # third-party action sits between it and the build. + - name: Install Rust toolchain shell: bash - run: echo "channel=$(sed -n 's/^channel *= *"\(.*\)"/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT" - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 - with: - toolchain: ${{ steps.rust-toolchain-file.outputs.channel }} - components: rustfmt, clippy + run: rustup toolchain install --profile minimal --no-self-update - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: src-tauri @@ -340,17 +335,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Read pinned Rust version - id: rust-toolchain-file - # bash explicitly: the default shell on a Windows runner is PowerShell, - # where `$(...)` and `$GITHUB_OUTPUT` mean nothing, so the step passes - # having written no output — and the toolchain action then fails with - # "toolchain is a required input", eight steps from the real cause. + # rustup ships on GitHub-hosted runners and reads rust-toolchain.toml + # itself, so the version and components live in that one file and no + # third-party action sits between it and the build. + - name: Install Rust toolchain shell: bash - run: echo "channel=$(sed -n 's/^channel *= *"\(.*\)"/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT" - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 - with: - toolchain: ${{ steps.rust-toolchain-file.outputs.channel }} + run: rustup toolchain install --profile minimal --no-self-update - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: src-tauri diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77e9f98..f322c08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,17 +83,12 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Read pinned Rust version - id: rust-toolchain-file - # bash explicitly: the default shell on a Windows runner is PowerShell, - # where `$(...)` and `$GITHUB_OUTPUT` mean nothing, so the step passes - # having written no output — and the toolchain action then fails with - # "toolchain is a required input", eight steps from the real cause. + # rustup ships on GitHub-hosted runners and reads rust-toolchain.toml + # itself, so the version and components live in that one file and no + # third-party action sits between it and the build. + - name: Install Rust toolchain shell: bash - run: echo "channel=$(sed -n 's/^channel *= *"\(.*\)"/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT" - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 - with: - toolchain: ${{ steps.rust-toolchain-file.outputs.channel }} + run: rustup toolchain install --profile minimal --no-self-update - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: src-tauri @@ -181,22 +176,16 @@ jobs: node-version-file: ".node-version" cache: "npm" - - name: Read pinned Rust version - id: rust-toolchain-file - # bash explicitly: the default shell on a Windows runner is PowerShell, - # where `$(...)` and `$GITHUB_OUTPUT` mean nothing, so the step passes - # having written no output — and the toolchain action then fails with - # "toolchain is a required input", eight steps from the real cause. - shell: bash - run: echo "channel=$(sed -n 's/^channel *= *"\(.*\)"/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT" - + # From rust-toolchain.toml, so releases build on the same version CI + # tests against. rustup ships on GitHub-hosted runners and reads the + # file itself; bash, because the Windows default shell is PowerShell. - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 - with: - # From rust-toolchain.toml, so releases build on the same version - # CI tests against. - toolchain: ${{ steps.rust-toolchain-file.outputs.channel }} - targets: ${{ matrix.rust_target }} + shell: bash + env: + RUST_TARGET: ${{ matrix.rust_target }} + run: | + rustup toolchain install --profile minimal --no-self-update + rustup target add "$RUST_TARGET" - name: Rust cache uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 diff --git a/justfile b/justfile index 0af82b0..e9cc56b 100644 --- a/justfile +++ b/justfile @@ -105,6 +105,7 @@ lint-workflows: actionlint -no-color -oneline ./scripts/check-action-pins.sh ./scripts/test-check-action-pins.sh + ./scripts/test-cargo-audit-check.sh # Format Rust and everything dprint owns. fmt: diff --git a/mise.toml b/mise.toml index 54f3d4a..ff970b1 100644 --- a/mise.toml +++ b/mise.toml @@ -2,8 +2,8 @@ # contributor runs before `just `. # # CI does NOT depend on mise — it reads .node-version and rust-toolchain.toml -# directly via actions/setup-node and dtolnay/rust-toolchain, so those two -# files stay the source of truth and nothing here can drift from them. +# directly via actions/setup-node and rustup, so those two files stay the +# source of truth and nothing here can drift from them. # # Note: mise can read those idiomatic files itself, but that is off by # default (idiomatic_version_file_enable_tools), so the versions are diff --git a/rust-toolchain.toml b/rust-toolchain.toml index cf5a3ab..c4d89be 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,6 +1,6 @@ # Single source of truth for the Rust toolchain. # -# Read natively by rustup, by dtolnay/rust-toolchain in CI, and by mise. +# Read natively by rustup (locally and in CI) and by mise. # Pinned to an exact version rather than "stable": SQLPilot is an # application, not a published library, so a reproducible build matters # more than a wide MSRV — and an unpinned toolchain means a new stable diff --git a/scripts/cargo-audit-check.sh b/scripts/cargo-audit-check.sh index d230482..5d60f4c 100755 --- a/scripts/cargo-audit-check.sh +++ b/scripts/cargo-audit-check.sh @@ -82,10 +82,30 @@ if ! echo "$JSON_OUTPUT" | jq -e 'type == "object" and has("vulnerabilities")' > exit 2 fi +# Say how fresh the advisory database is. cargo audit fetches it before each +# run, but a --no-fetch run or `fetch = false` in audit.toml answers from +# whatever copy is on disk, and an old copy reports clean. +DB_UPDATED="$(echo "$JSON_OUTPUT" | jq -r '.database."last-updated" // empty')" +if [ -z "$DB_UPDATED" ]; then + echo "::warning::cargo-audit-check: the report does not say when the advisory database was last updated." >&2 +elif DB_EPOCH="$(date -d "$DB_UPDATED" +%s 2>/dev/null)"; then + DB_AGE_DAYS=$(( ($(date +%s) - DB_EPOCH) / 86400 )) + echo "Advisory database last updated $DB_UPDATED ($DB_AGE_DAYS day(s) ago)." + if [ "$DB_AGE_DAYS" -gt 7 ]; then + echo "::warning::cargo-audit-check: the advisory database is $DB_AGE_DAYS days old, so newer advisories are not checked. Run without --no-fetch to update it." >&2 + fi +else + # BSD date (macOS) has no -d; show the timestamp and let the reader judge. + echo "Advisory database last updated $DB_UPDATED." +fi +echo "" + # Pretty-print the full advisory list to job logs (visible, not hidden). echo "=== cargo audit findings ===" -echo "$JSON_OUTPUT" | jq -r ' +# Parse first, then print: a parse failure must stop the gate, while the +# display pipeline below may legitimately end early (head closing the pipe). +if ! FINDINGS="$(echo "$JSON_OUTPUT" | jq -r ' (.vulnerabilities.list // []) as $vulns | (.warnings.unmaintained // []) as $unm | (.warnings.unsound // []) as $uns | @@ -96,7 +116,13 @@ echo "$JSON_OUTPUT" | jq -r ' (.advisory.package // "?"), .advisory.title ] | @tsv -' | column -t -s $'\t' 2>/dev/null | head -50 || echo "(parse failed)" +')"; then + echo "::error::cargo-audit-check: could not parse the cargo audit report; refusing to call this a pass." >&2 + exit 2 +fi +if [ -n "$FINDINGS" ]; then + printf '%s\n' "$FINDINGS" | column -t -s $'\t' 2>/dev/null | head -50 || true +fi # Always show the known-accepted list — even after they clear from # cargo audit output, the list serves as a reminder of past accepted. diff --git a/scripts/test-cargo-audit-check.sh b/scripts/test-cargo-audit-check.sh new file mode 100755 index 0000000..e931216 --- /dev/null +++ b/scripts/test-cargo-audit-check.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# scripts/test-cargo-audit-check.sh +# +# Tests for cargo-audit-check.sh. +# +# The gate's job is to tell "nothing found" from "could not look" (#729), so +# most cases here are ways of not looking: a missing tool, no report, a report +# that does not parse, a stale advisory database. A fake `cargo` on PATH plays +# back a canned `cargo audit --json` report per case, so no network or real +# advisory database is needed. + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +checker="$script_dir/cargo-audit-check.sh" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +passed=0 +failed=0 +ok() { printf ' ok %s\n' "$1"; passed=$((passed + 1)); } +bad() { printf ' FAIL %s\n %s\n' "$1" "$2"; failed=$((failed + 1)); } + +# A bin directory holding every tool on the real PATH except jq, so the +# "jq is missing" case can be run without uninstalling anything. +nojq_bin="$work/nojq-bin" +mkdir -p "$nojq_bin" +IFS=: read -r -a path_dirs <<< "$PATH" +for dir in "${path_dirs[@]}"; do + [[ -d "$dir" ]] || continue + for tool in "$dir"/*; do + name="${tool##*/}" + if [[ "$name" != jq && ! -e "$nojq_bin/$name" && -x "$tool" ]]; then + ln -s "$tool" "$nojq_bin/$name" + fi + done +done + +# Fakes for cargo and cargo-audit: `cargo audit --json` prints $REPORT_FILE. +fake_bin="$work/fake-bin" +mkdir -p "$fake_bin" +cat > "$fake_bin/cargo" <<'EOF' +#!/usr/bin/env bash +[[ "${1:-}" == audit ]] || { echo "fake cargo: only 'audit' is faked" >&2; exit 99; } +cat "$REPORT_FILE" +exit "${AUDIT_EXIT:-0}" +EOF +printf '#!/usr/bin/env bash\nexit 0\n' > "$fake_bin/cargo-audit" +chmod +x "$fake_bin/cargo" "$fake_bin/cargo-audit" + +# Run the checker against report `$2`, with `$3` as the base PATH. +run_case() { + local name="$1" report="$2" base_path="${3:-$PATH}" + local dir="$work/$name" + mkdir -p "$dir/ws" + printf '%s' "$report" > "$dir/report.json" + ( + cd "$dir" + unset GITHUB_STEP_SUMMARY + REPORT_FILE="$dir/report.json" CARGO_WORKSPACE_DIR=ws \ + PATH="$fake_bin:$base_path" bash "$checker" 2>&1 + ) +} + +expect_status() { + local name="$1" want="$2" report="$3" needle="$4" base_path="${5:-$PATH}" output status + output=$(run_case "$name" "$report" "$base_path") && status=0 || status=$? + if [[ $status -ne $want ]]; then + bad "$name" "expected exit $want, got $status: $output" + elif [[ "$output" != *"$needle"* ]]; then + bad "$name" "expected the output to mention '$needle', got: $output" + else + ok "$name" + fi +} + +today="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +old="2020-01-01T00:00:00Z" + +report() { # report + printf '{"database":{"last-updated":"%s"},"vulnerabilities":{"found":false,"count":0,"list":%s},"warnings":{"unmaintained":%s}}' \ + "$1" "$2" "$3" +} +advisory() { # advisory + printf '{"advisory":{"id":"%s","package":"pkg","title":"a title"}}' "$1" +} + +many_warnings="[$(for i in $(seq 1 80); do advisory "RUSTSEC-0000-$i"; if [[ $i -lt 80 ]]; then printf ','; fi; done)]" + +echo "cargo-audit-check.sh" + +expect_status "a clean report passes" 0 \ + "$(report "$today" '[]' '[]')" "0 real vulnerabilities" + +expect_status "a real vulnerability fails" 1 \ + "$(report "$today" "[$(advisory RUSTSEC-2099-0001)]" '[]')" "RUSTSEC-2099-0001" + +expect_status "warnings alone do not fail" 0 \ + "$(report "$today" '[]' "[$(advisory RUSTSEC-2099-0002)]")" "1 non-blocking" + +expect_status "more findings than the display shows still passes" 0 \ + "$(report "$today" '[]' "$many_warnings")" "80 non-blocking" + +expect_status "jq missing fails" 2 \ + "$(report "$today" '[]' '[]')" "'jq' is not installed" "$nojq_bin" + +expect_status "no report fails" 2 \ + "" "did not produce a report" + +expect_status "a report that is not JSON fails" 2 \ + "error: failed to fetch advisory database" "did not produce a report" + +expect_status "a report whose findings do not parse fails" 2 \ + "$(report "$today" '"not-a-list"' '[]')" "could not parse" + +expect_status "a stale advisory database warns" 0 \ + "$(report "$old" '[]' '[]')" "days old" + +expect_status "the database age is shown" 0 \ + "$(report "$today" '[]' '[]')" "0 day(s) ago" + +echo "" +echo "$passed passed, $failed failed" +[[ $failed -eq 0 ]]