From d07589f3fe3ec832c959221d96ae470433ee590f Mon Sep 17 00:00:00 2001 From: skylar Date: Mon, 26 Oct 2026 14:13:00 -0600 Subject: [PATCH 1/2] ci: pin nextest to 0.9.150 Use the fixed release for GHSA-vc25-qg58-w57c. --- .github/workflows/gate.yml | 4 +- .github/workflows/sonarcloud.yml | 4 +- README.md | 19 ++++- tests/test_nextest_versions.py | 116 +++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 tests/test_nextest_versions.py diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 0070fd1..24e6ee4 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -47,10 +47,10 @@ on: required: false default: "wasm32-unknown-unknown" tools: - description: "Prebuilt cargo tools from taiki-e/install-action, comma-separated, each with its version (e.g. cargo-nextest@0.9.149,cargo-deny@0.20.2); empty installs none" + description: "Prebuilt cargo tools from taiki-e/install-action, comma-separated, each with its version (e.g. cargo-nextest@0.9.150,cargo-deny@0.20.2); empty installs none" type: string required: false - default: "cargo-nextest@0.9.149" + default: "cargo-nextest@0.9.150" env: description: "KEY=VALUE lines added to the environment of every leg before the cache is restored" type: string diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 317c294..39f7301 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -121,7 +121,7 @@ jobs: rustup default stable - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 with: - tool: cargo-llvm-cov,cargo-nextest + tool: cargo-llvm-cov,cargo-nextest@0.9.150 - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: save-if: ${{ github.ref == 'refs/heads/main' }} @@ -186,7 +186,7 @@ jobs: libgl1-mesa-dev mesa-vulkan-drivers - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 with: - tool: cargo-nextest + tool: cargo-nextest@0.9.150 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: nextest-archive diff --git a/README.md b/README.md index 76f5f06..8542c1c 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,7 @@ The quality gate for an app's pull requests and pushes to main. It runs `command persisted credentials, and craft-i18n when the app has `craft-i18n.rev`; - `packages` installed through `actions/install-apt-packages` on Linux legs; - the channel from `rust-toolchain.toml`, or `toolchain`, with clippy, rustfmt and `targets`; -- prebuilt cargo tools from `tools` (by default `cargo-nextest`); +- prebuilt cargo tools from `tools` (by default `cargo-nextest@0.9.150`); - `CARGO_PROFILE_DEV_DEBUG=line-tables-only` (set `dev-debug: ""` to keep the repository's profile) and the caller's `env`, `linux-env` and `windows-env` lines, each `KEY=VALUE`; - a rust-cache dependency cache that only the default branch saves, so pull requests restore @@ -382,6 +382,21 @@ jobs: Concurrency belongs to the caller: a reusable workflow cannot set it for the run. +The gate default and sharded SonarCloud coverage pin cargo-nextest to **0.9.150**, which fixes +[GHSA-vc25-qg58-w57c](https://github.com/nextest-rs/nextest/security/advisories/GHSA-vc25-qg58-w57c). +A caller overriding `tools` must also update its own nextest pin; changing the shared default +cannot override it. Set a required minimum at the top of the caller's `.config/nextest.toml`, +keeping its existing profiles and test groups: + +```toml +nextest-version = "0.9.150" +``` + +A `recommended` minimum only warns; it does not enforce this requirement. The minimum-version +check does not upgrade a developer's installed executable, and can be explicitly overridden. +Do not enable experimental run recording or rerun untrusted portable recordings with an +affected version. Ordinary compiled test archives used by coverage are a different feature. + An app that also checks Windows on main, on manual runs and on pull requests labelled `full-ci`, with per-OS environment and a shorter Windows gate (PDFCraft's call): @@ -395,7 +410,7 @@ jobs: packages: >- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev qpdf poppler-utils - tools: cargo-nextest@0.9.149,cargo-deny@0.20.2 + tools: cargo-nextest@0.9.150,cargo-deny@0.20.2 linux-env: PDFCRAFT_ORACLES_REQUIRED=1 windows-env: PDFCRAFT_REQUIRE_WINRES=1 # Windows checks what differs by platform: the build (with resources) and the tests. diff --git a/tests/test_nextest_versions.py b/tests/test_nextest_versions.py new file mode 100644 index 0000000..2f0912b --- /dev/null +++ b/tests/test_nextest_versions.py @@ -0,0 +1,116 @@ +"""Keep shared nextest installers on the reviewed fix for GHSA-vc25-qg58-w57c. + +Only YAML data is inspected. Negative controls mutate actual parsed installer values in memory; +no workflow scripts, nextest executable, test binary or recording are executed. +""" +from copy import deepcopy +from pathlib import Path +import re +import unittest + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +PIN = 'cargo-nextest@0.9.150' +GATE = '.github/workflows/gate.yml' +SONAR = '.github/workflows/sonarcloud.yml' + + +def workflow_documents(): + documents = {} + for directory in (ROOT / '.github/workflows', ROOT / 'actions'): + for path in sorted(directory.rglob('*')): + if path.is_file() and path.suffix in ('.yml', '.yaml'): + documents[path.relative_to(ROOT).as_posix()] = yaml.load( + path.read_text(), Loader=yaml.BaseLoader) + return documents + + +def tool_values(value, path=()): + """Find literal installer tool lists and reusable workflow tools defaults.""" + if isinstance(value, dict): + for key, child in value.items(): + child_path = path + (key,) + if key in ('tool', 'tools') and isinstance(child, str): + yield child_path, child + elif key == 'tools' and isinstance(child, dict) and 'default' in child: + yield child_path + ('default',), child['default'] + yield from tool_values(child, child_path) + elif isinstance(value, list): + for index, child in enumerate(value): + yield from tool_values(child, path + (index,)) + + +def nextest_tool_values(documents): + for filename, document in documents.items(): + for path, value in tool_values(document): + if isinstance(value, str): + specs = re.split(r'[,\s]+', value.strip()) + if any(spec.split('@', 1)[0] == 'cargo-nextest' for spec in specs): + yield filename, path, value, specs + + +def replace_value(document, path, value): + for key in path[:-1]: + document = document[key] + document[path[-1]] = value + + +class NextestVersionTests(unittest.TestCase): + def setUp(self): + self.documents = workflow_documents() + + def assert_fixed_installers(self, documents): + # Discover every tool/default site, not just fixtures for the three known sites. + found = list(nextest_tool_values(documents)) + self.assertEqual(len(found), 3, 'expected gate default and both coverage installers') + self.assertEqual(sorted(filename for filename, _, _, _ in found), [GATE, SONAR, SONAR]) + for filename, path, _, specs in found: + nextest = [spec for spec in specs if spec.split('@', 1)[0] == 'cargo-nextest'] + # Do not wrap this assertion in subTest: negative controls must catch the exception. + self.assertEqual(nextest, [PIN], + f'{filename}:{path}: nextest must use the exact reviewed fixed release') + + def test_every_shared_nextest_installer_is_fixed(self): + self.assert_fixed_installers(self.documents) + + def test_gate_default_still_reaches_the_installer_and_allows_caller_overrides(self): + gate = self.documents[GATE] + self.assertEqual(gate['on']['workflow_call']['inputs']['tools']['default'], PIN) + installers = [step for step in gate['jobs']['gate']['steps'] + if step.get('uses', '').startswith('taiki-e/install-action@')] + self.assertEqual(len(installers), 1) + self.assertEqual(installers[0]['with']['tool'], '${{ inputs.tools }}') + self.assertEqual(installers[0]['if'], "inputs.tools != ''") + + def test_coverage_keeps_llvm_cov_and_the_two_nextest_installers(self): + jobs = self.documents[SONAR]['jobs'] + for job, expected in [('build', 'cargo-llvm-cov,' + PIN), ('coverage', PIN)]: + with self.subTest(job=job): + installers = [step for step in jobs[job]['steps'] + if step.get('uses', '').startswith('taiki-e/install-action@')] + self.assertEqual(len(installers), 1) + self.assertEqual(installers[0]['with']['tool'], expected) + + def test_rejects_vulnerable_floating_and_prerelease_pins_at_each_real_site(self): + for filename, path, value, _ in nextest_tool_values(self.documents): + for bad in ('cargo-nextest@0.9.149', 'cargo-nextest', + 'cargo-nextest@latest', 'cargo-nextest@0.9.150-rc.1'): + with self.subTest(filename=filename, path=path, bad=bad): + changed = deepcopy(self.documents) + replace_value(changed[filename], path, value.replace(PIN, bad)) + with self.assertRaises(AssertionError): + self.assert_fixed_installers(changed) + + def test_rejects_an_additional_vulnerable_installer(self): + changed = deepcopy(self.documents) + changed[SONAR]['jobs']['coverage']['steps'].append({ + 'uses': 'taiki-e/install-action@' + '0' * 40, + 'with': {'tool': 'cargo-nextest@0.9.149'}, + }) + with self.assertRaises(AssertionError): + self.assert_fixed_installers(changed) + + +if __name__ == '__main__': + unittest.main() From b97d27ffa3fed8ef628ed0cc96a744b5af25e7c1 Mon Sep 17 00:00:00 2001 From: skylar Date: Mon, 26 Oct 2026 16:20:05 -0600 Subject: [PATCH 2/2] test: cover nextest installer aliases Reject affected alias installers and duplicate selectors in the pin inventory. --- tests/test_nextest_versions.py | 56 ++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/tests/test_nextest_versions.py b/tests/test_nextest_versions.py index 2f0912b..34efbbd 100644 --- a/tests/test_nextest_versions.py +++ b/tests/test_nextest_versions.py @@ -12,6 +12,9 @@ import yaml ROOT = Path(__file__).resolve().parents[1] PIN = 'cargo-nextest@0.9.150' +# install-action installs this one tool under two names, `cargo-nextest` and the documented alias +# `nextest`; matching the crate name alone would count an alias installer as nothing at all. +NEXTEST_NAMES = ('cargo-nextest', 'nextest') GATE = '.github/workflows/gate.yml' SONAR = '.github/workflows/sonarcloud.yml' @@ -41,12 +44,17 @@ def tool_values(value, path=()): yield from tool_values(child, path + (index,)) +def is_nextest(spec): + """Whether a taiki-e/install-action spec installs nextest, under either of its two names.""" + return spec.split('@', 1)[0] in NEXTEST_NAMES + + def nextest_tool_values(documents): for filename, document in documents.items(): for path, value in tool_values(document): if isinstance(value, str): specs = re.split(r'[,\s]+', value.strip()) - if any(spec.split('@', 1)[0] == 'cargo-nextest' for spec in specs): + if any(is_nextest(spec) for spec in specs): yield filename, path, value, specs @@ -66,7 +74,8 @@ class NextestVersionTests(unittest.TestCase): self.assertEqual(len(found), 3, 'expected gate default and both coverage installers') self.assertEqual(sorted(filename for filename, _, _, _ in found), [GATE, SONAR, SONAR]) for filename, path, _, specs in found: - nextest = [spec for spec in specs if spec.split('@', 1)[0] == 'cargo-nextest'] + # Either name installs the tool, so either name has to reach this assertion. + nextest = [spec for spec in specs if is_nextest(spec)] # Do not wrap this assertion in subTest: negative controls must catch the exception. self.assertEqual(nextest, [PIN], f'{filename}:{path}: nextest must use the exact reviewed fixed release') @@ -74,6 +83,18 @@ class NextestVersionTests(unittest.TestCase): def test_every_shared_nextest_installer_is_fixed(self): self.assert_fixed_installers(self.documents) + def test_discovers_the_documented_alias_at_every_site(self): + # Spelling every installer with the alias must still inventory the same three sites, so an + # alias installer is examined rather than skipped. + changed = deepcopy(self.documents) + for filename, path, value, _ in nextest_tool_values(self.documents): + replace_value(changed[filename], path, value.replace('cargo-nextest', 'nextest')) + found = list(nextest_tool_values(changed)) + self.assertEqual(sorted((filename, value) for filename, _, value, _ in found), + sorted([(GATE, 'nextest@0.9.150'), + (SONAR, 'cargo-llvm-cov,nextest@0.9.150'), + (SONAR, 'nextest@0.9.150')])) + def test_gate_default_still_reaches_the_installer_and_allows_caller_overrides(self): gate = self.documents[GATE] self.assertEqual(gate['on']['workflow_call']['inputs']['tools']['default'], PIN) @@ -92,10 +113,17 @@ class NextestVersionTests(unittest.TestCase): self.assertEqual(len(installers), 1) self.assertEqual(installers[0]['with']['tool'], expected) - def test_rejects_vulnerable_floating_and_prerelease_pins_at_each_real_site(self): + def test_rejects_vulnerable_floating_prerelease_and_duplicate_pins_at_each_real_site(self): + # The duplicates keep one fixed selector, so the site is still discovered and still + # counted as one site: only the validation filter can reject the vulnerable selector + # beside it, which is what pins the alias there independently of discovery. + bad_pins = ('cargo-nextest@0.9.149', 'cargo-nextest', + 'cargo-nextest@latest', 'cargo-nextest@0.9.150-rc.1', + 'nextest@0.9.149', 'nextest', + 'nextest@latest', 'nextest@0.9.150-rc.1', + PIN + ',cargo-nextest@0.9.149', PIN + ',nextest@0.9.149') for filename, path, value, _ in nextest_tool_values(self.documents): - for bad in ('cargo-nextest@0.9.149', 'cargo-nextest', - 'cargo-nextest@latest', 'cargo-nextest@0.9.150-rc.1'): + for bad in bad_pins: with self.subTest(filename=filename, path=path, bad=bad): changed = deepcopy(self.documents) replace_value(changed[filename], path, value.replace(PIN, bad)) @@ -103,13 +131,17 @@ class NextestVersionTests(unittest.TestCase): self.assert_fixed_installers(changed) def test_rejects_an_additional_vulnerable_installer(self): - changed = deepcopy(self.documents) - changed[SONAR]['jobs']['coverage']['steps'].append({ - 'uses': 'taiki-e/install-action@' + '0' * 40, - 'with': {'tool': 'cargo-nextest@0.9.149'}, - }) - with self.assertRaises(AssertionError): - self.assert_fixed_installers(changed) + # One control per name: the alias installs the same tool, so an extra alias installer must + # be counted instead of passing the inventory unnoticed. + for tool in ('cargo-nextest@0.9.149', 'nextest@0.9.149'): + with self.subTest(tool=tool): + changed = deepcopy(self.documents) + changed[SONAR]['jobs']['coverage']['steps'].append({ + 'uses': 'taiki-e/install-action@' + '0' * 40, + 'with': {'tool': tool}, + }) + with self.assertRaises(AssertionError): + self.assert_fixed_installers(changed) if __name__ == '__main__':