Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/scripts/check-cargo-sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Reject Cargo source/path overrides in tracked repository configuration (Python 3.11+)."""

from pathlib import Path
import os
import subprocess
import sys
import tomllib


def check(root: Path) -> list[str]:
"""Inspect tracked modern and legacy configs, including those in nested workspaces."""
paths = os.fsdecode(subprocess.check_output(["git", "ls-files", "-z"], cwd=root)).split("\0")
errors = []
for name in paths:
path = Path(name)
if path.name.lower() == ".cargo" and (root / path).is_symlink():
errors.append(f"{name}: symlinked Cargo config directories are not allowed")
continue
if path.parent.name.lower() != ".cargo" or path.name.lower() not in {"config", "config.toml"}:
continue
try:
with (root / path).open("rb") as source:
config = tomllib.load(source)
except (OSError, ValueError):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Symlinked .cargo directory bypasses the check (confidence: medium-high, about 75%; I could not run this offline).

git ls-files lists a tracked symlink by its own path and does not list the files behind it. Take a tracked symlink .cargo -> ci/cargo and a tracked ci/cargo/config.toml containing [source.crates-io] replace-with = "mirror":

  • .cargo is listed, but it is a symlink, so it is not a config path and nothing is reported.
  • ci/cargo/config.toml is listed, but its parent is cargo, not .cargo, so the filter on line 22 skips it.
  • Cargo follows the symlink and reads .cargo/config.toml, so the source replacement applies. cargo deny sources still passes, because Cargo.lock keeps the crates.io source URL when a replacement is in use.

So the docs' statement that these checks "cannot be bypassed" is too strong. A small fix is to reject any tracked symlink named .cargo. With no such symlink, every .cargo component is a real directory, and the existing filter sees every config file Cargo can read:

Suggested change
except (OSError, ValueError):
path = Path(name)
if path.name.lower() == ".cargo" and (root / path).is_symlink():
errors.append(f"{name}: symlinked Cargo config directories are not allowed")
continue

Please add a regression test next to test_invalid_toml_is_rejected. It should track a .cargo symlink to a directory holding a [source] config and expect one error for the symlink path. Tests need git to track symlinks, so check_config may need a variant that creates the link.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 17ef3ef: tracked symlinked .cargo directories are rejected, including nested and mixed-case names. The root and nested symlink regressions pass.

errors.append(f"{name}: cannot read valid TOML")
continue
if any(config.get(key) for key in ("source", "paths", "patch", "include")):
errors.append(f"{name}: Cargo source/path/patch overrides and included configs are not allowed")
return errors


if __name__ == "__main__":
repo = Path(__file__).resolve().parents[2]
try:
problems = check(repo)
except subprocess.CalledProcessError:
sys.exit("cannot list tracked Cargo configuration")
for problem in problems:
print(problem, file=sys.stderr)
sys.exit(bool(problems))
70 changes: 70 additions & 0 deletions .github/scripts/test_cargo_sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Regression checks for Cargo configuration that can bypass registry source checks."""

import importlib.util
import os
from pathlib import Path
import subprocess
import tempfile
import unittest

SPEC = importlib.util.spec_from_file_location("cargo_sources", Path(__file__).with_name("check-cargo-sources.py"))
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)


class CargoSources(unittest.TestCase):
def check_config(self, name, text, directory_link=None):
"""Track a config and optional directory link, with no dependency downloads or builds."""
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
subprocess.run(["git", "init", "-q", folder], check=True)
path = root / name
path.parent.mkdir(parents=True)
path.write_text(text)
tracked = [name]
if directory_link is not None:
link = root / directory_link
link.parent.mkdir(parents=True, exist_ok=True)
link.symlink_to(path.parent, target_is_directory=True)
tracked.append(directory_link)
subprocess.run(["git", "add", "--", *tracked], cwd=root, check=True)
return MODULE.check(root)

def test_build_options_are_allowed(self):
for name in [".cargo/config.toml", ".Cargo/Config.toml"]:
with self.subTest(name=name):
self.assertEqual(self.check_config(name, '[alias]\nxtask = "run -p xtask --"\n'), [])

def test_overrides_and_includes_are_rejected_in_both_formats(self):
for name in [".cargo/config", "fuzz/.cargo/config.toml", ".Cargo/Config.toml", "fuzz/.CARGO/CONFIG"]:
for text in [
'[source.crates-io]\nreplace-with = "mirror"\n',
'paths = ["../dependency"]\n',
'[patch.crates-io]\nexample = { path = "../dependency" }\n',
'include = ["other.toml"]\n',
'include = [{ path = "other.toml", optional = true }]\n',
]:
with self.subTest(name=name, text=text):
problems = self.check_config(name, text)
self.assertEqual(len(problems), 1)
self.assertTrue(problems[0].startswith(name + ":"))

def test_cargo_directory_symlinks_are_rejected(self):
for name in [".cargo", "fuzz/.Cargo"]:
with self.subTest(name=name):
problems = self.check_config("ci/cargo/config.toml", '[source.crates-io]\nreplace-with = "mirror"\n', name)
self.assertEqual(problems, [name + ": symlinked Cargo config directories are not allowed"])

@unittest.skipUnless(os.name == "posix", "non-UTF-8 filename fixture requires POSIX")
def test_non_utf8_filenames_do_not_stop_config_checks(self):
self.assertEqual(self.check_config(os.fsdecode(b"unrelated/other-\xff"), "ordinary tracked file"), [])
name = os.fsdecode(b"other-\xff/.cargo/config.toml")
problems = self.check_config(name, '[source.crates-io]\nreplace-with = "mirror"\n')
self.assertEqual(problems, [name + ": Cargo source/path/patch overrides and included configs are not allowed"])

def test_invalid_toml_is_rejected(self):
self.assertEqual(self.check_config(".cargo/config.toml", "[broken"), [".cargo/config.toml: cannot read valid TOML"])


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion .github/workflows/ci-label.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
outputs:
os: ${{ steps.os.outputs.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
sparse-checkout: .github/scripts
- id: os
Expand Down
34 changes: 19 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,28 +24,28 @@ jobs:
name: Lint (pre-commit)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (confidence: medium, about 70%; I could not run this offline): dtolnay/rust-toolchain takes its toolchain channel from the git ref it is called with. @stable and @nightly name the channel, but @b725eda… names a commit, so the action may fail to find a toolchain or install the wrong one. The PR's claim that the channels "retain their existing update behaviour" is not established by the diff. Please check the action's action.yml and add an explicit input to each pinned call, for example:

Suggested change
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
toolchain: stable

The same applies to the 19 dtolnay/rust-toolchain pins across the workflows (including the nightly pin in fuzz.yml), and the components/targets keyed steps need the toolchain: line merged into their existing with: block.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pinned action definitions set the toolchain input default explicitly: stable at b725eda and nightly at 2c76c03. These generated channel-specific definitions retain their channel when pinned. The lint, licence and wasm jobs also passed on the initial head 68f4ffc with the stable pin, so an additional input is unnecessary.

with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
with:
# Only main saves the cache (see test-rust.yml).
save-if: ${{ github.ref == 'refs/heads/main' }}
- uses: actions/setup-python@v7
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
with:
python-version: "3.12"
- uses: pre-commit/action@v3.0.1
- uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1

wasm:
name: Web app (wasm32)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
with:
# Only main saves the cache (see test-rust.yml).
save-if: ${{ github.ref == 'refs/heads/main' }}
Expand All @@ -59,17 +59,21 @@ jobs:
name: Licences
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
with:
# Only main saves the cache (see test-rust.yml).
save-if: ${{ github.ref == 'refs/heads/main' }}
- uses: taiki-e/install-action@v2
- uses: taiki-e/install-action@c509219cf42fba86913069efd0ade2458c59337b # v2
with:
tool: cargo-deny,cargo-about@0.9.2
- name: Dependency licences
run: cargo deny --locked check licenses
- name: Cargo source replacements
run: |
python3 -m unittest discover -s .github/scripts -p 'test_*.py'
python3 .github/scripts/check-cargo-sources.py
- name: Dependency licences and sources
run: cargo deny --locked check licenses sources
- name: Third-party notices
# Exit code 2 is stale notices (a warning); anything else (cargo-about failing, a licence
# it can't work out) fails the job.
Expand All @@ -88,7 +92,7 @@ jobs:
outputs:
os: ${{ steps.os.outputs.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
sparse-checkout: .github/scripts
- id: os
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ jobs:
echo "sha=$sha" >> "$GITHUB_OUTPUT"

- name: Checkout PR head for review context (read-only; never executed)
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# Check out the PR head so Read/Grep/Glob show the *proposed* file
# contents (and newly added files) that Claude is reviewing — the base
Expand Down
34 changes: 17 additions & 17 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,15 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false

- uses: docker/setup-buildx-action@v4
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4

- name: Image tags and labels
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
env:
# The registry shows the index's annotations for a multi-platform image.
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
Expand All @@ -103,7 +103,7 @@ jobs:
# The image for the runner's platform, loaded into Docker to check that it serves the app.
- name: Build for the smoke test
uses: docker/build-push-action@v7
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
platforms: linux/amd64
Expand Down Expand Up @@ -132,7 +132,7 @@ jobs:

- name: Sign in to the GitHub Container Registry
if: env.PUBLISH == 'true'
uses: docker/login-action@v4
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
Expand All @@ -141,7 +141,7 @@ jobs:
# Both platforms; the build stage is the smoke test's, from the builder's cache.
- name: Publish
if: env.PUBLISH == 'true'
uses: docker/build-push-action@v7
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
platforms: linux/amd64,linux/arm64
Expand Down Expand Up @@ -169,15 +169,15 @@ jobs:
runner: ubuntu-24.04-arm
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false

- uses: docker/setup-buildx-action@v4
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4

- name: Image labels
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
with:
images: ${{ env.CLI_IMAGE }}
flavor: latest=false
Expand All @@ -189,7 +189,7 @@ jobs:
org.opencontainers.image.url=https://rustgis.app
- name: Build for the smoke test
uses: docker/build-push-action@v7
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
file: packaging/docker/cli.Dockerfile
Expand All @@ -214,7 +214,7 @@ jobs:
- name: Sign in to the GitHub Container Registry
if: env.PUBLISH == 'true'
uses: docker/login-action@v4
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
Expand All @@ -224,7 +224,7 @@ jobs:
- name: Publish by digest
id: push
if: env.PUBLISH == 'true'
uses: docker/build-push-action@v7
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
file: packaging/docker/cli.Dockerfile
Expand All @@ -244,7 +244,7 @@ jobs:
- name: Upload the digest
if: env.PUBLISH == 'true'
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: cli-digest-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
Expand All @@ -262,24 +262,24 @@ jobs:
packages: write
steps:
- name: Download the digests
uses: actions/download-artifact@v8
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
path: ${{ runner.temp }}/digests
pattern: cli-digest-*
merge-multiple: true

- uses: docker/setup-buildx-action@v4
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4

- name: Image tags
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
with:
images: ${{ env.CLI_IMAGE }}
flavor: latest=false
tags: ${{ env.TAGS }}

- name: Sign in to the GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/docs-preview-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ jobs:
echo "has_pr=$(jq 'index("preview-pr") != null' <<< "$names")" >> "$GITHUB_OUTPUT"
echo "has_site=$(jq 'index("preview-site") != null' <<< "$names")" >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
if: steps.artifacts.outputs.has_pr == 'true'
with:
name: preview-pr
Expand Down Expand Up @@ -144,7 +144,7 @@ jobs:
echo "fork=${fork}"
} >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
if: steps.pr.outputs.mode == 'deploy'
with:
name: preview-site
Expand Down Expand Up @@ -240,7 +240,7 @@ jobs:
if: >-
(steps.deploy.outcome == 'success' && steps.pr.outputs.mode == 'deploy') ||
steps.pr.outputs.mode == 'awaiting'
uses: actions/github-script@v9
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
PR: ${{ steps.pr.outputs.number }}
MODE: ${{ steps.pr.outputs.mode }}
Expand Down
Loading
Loading