diff --git a/.github/workflows/case-collision-check.yml b/.github/workflows/case-collision-check.yml new file mode 100644 index 0000000000..946feb0a34 --- /dev/null +++ b/.github/workflows/case-collision-check.yml @@ -0,0 +1,33 @@ +name: Case Collision Check + +# Rejects PRs that track two files whose paths differ only by case +# (README.md vs readme.md, src/Foo.py vs SRC/foo.py). +# +# Linux is case-sensitive; Windows and macOS (default) are not. A +# case-colliding pair lives fine in a Linux checkout and silently breaks +# every clone on a case-insensitive host — the filesystem can hold only +# one of them, so checkout fails or whichever wins overwrites the other. +# Git won't prevent the pair from landing (it only warns at checkout time, +# on a case-insensitive FS, for the client doing the checkout), so the only +# enforcement point is CI, on Linux, against the index. +# +# Runs unconditionally (no change-classifier gate): a collision can ship in +# any kind of PR — docs, JS, config, not just Python — so gating on a +# language lane would be the same "passive rule that cannot enforce a +# policy" trap the infographic check exists to close. + +on: + workflow_call: + +permissions: + contents: read + +jobs: + check-case-collisions: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run case-collision checker + run: python3 scripts/check-case-collisions.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3cc6b24d62..1521388267 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -170,6 +170,11 @@ jobs: needs: detect uses: ./.github/workflows/profile-artifact-check.yml + case-collision-check: + name: Check no case-colliding filenames + needs: detect + uses: ./.github/workflows/case-collision-check.yml + lockfile-diff: name: package-lock.json diff needs: detect @@ -232,6 +237,7 @@ jobs: - history-check - contributor-check - uv-lockfile + - case-collision-check - lockfile-diff - docker-lint - profile-artifact-check diff --git a/scripts/check-case-collisions.py b/scripts/check-case-collisions.py new file mode 100644 index 0000000000..0ef0becfb8 --- /dev/null +++ b/scripts/check-case-collisions.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +Blocking check for tracked files whose paths collide when case is ignored. + +Linux is case-sensitive; Windows and macOS (default) are not. Two tracked +paths that differ only by case — ``README.md`` and ``readme.md``, or +``src/Foo.py`` and ``SRC/foo.py`` — coexist happily in a Linux checkout and +silently break every clone on a case-insensitive host: the filesystem can +hold only one of them, so checkout either refuses or whichever file is +written last wins and clobbers the other. Git itself won't stop the pair +from landing — it only warns at checkout time, on a case-insensitive FS, +for whichever client happens to do the checkout, and the collision is +invisible on Linux. This check is the enforcement point: scan the index, +fail the build, name the offenders. + +Usage: + # Check the checkout this script lives in (CI + the common local case) + python scripts/check-case-collisions.py + + # Check an arbitrary git checkout (tests, other worktrees) + python scripts/check-case-collisions.py /path/to/other/repo + +Exit status: + 0 — no case-colliding tracked paths + 1 — at least one collision group (paths printed to stdout) + 2 — not in a git repository / git failed + +Comparison key: the casefolded FULL path (``str.casefold``), not the +basename — on a case-insensitive filesystem the entire path is +case-insensitive, so ``dir/Foo.txt`` and ``DIR/foo.txt`` collide just like +same-directory pairs. ``casefold`` (not ``lower``) is used because it +matches how the OSes fold case for non-ASCII text (straße vs strasse, +sigma variants); a pair it flags is a genuine collision on macOS/Windows +even when Linux disagrees. + +Deliberately out of scope: Unicode NFC/NFD normalization collisions (macOS +stores NFD, Linux NFC). git already handles those at checkout via +``core.precomposeunicode``; this check is strictly about case. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from collections import defaultdict +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "root", + nargs="?", + default=str(REPO_ROOT), + help="git checkout to scan (default: the repo this script lives in)", + ) + args = parser.parse_args() + + try: + os.chdir(args.root) + except OSError as exc: + print(f"::error::cannot enter {args.root}: {exc}") + return 2 + + proc = subprocess.run(["git", "ls-files", "-z"], capture_output=True) + if proc.returncode != 0: + msg = proc.stderr.decode("utf-8", errors="replace").strip() + print(f"::error::git ls-files failed in {args.root}: {msg}") + return 2 + + paths = [ + p.decode("utf-8", errors="surrogateescape") + for p in proc.stdout.split(b"\0") + if p + ] + + by_casefold: dict[str, list[str]] = defaultdict(list) + for path in paths: + by_casefold[path.casefold()].append(path) + + collisions = {key: group for key, group in by_casefold.items() if len(group) > 1} + + if not collisions: + print(f"::notice::{len(paths)} tracked files, no case-colliding paths.") + return 0 + + print( + f"::error::Found {len(collisions)} case-collision group(s) among " + f"{len(paths)} tracked files." + ) + print( + "Paths that differ only by case are ONE file on Windows/macOS but " + "several on Linux - the pair breaks every clone on a case-insensitive " + "host. Rename one member of each group so the paths differ beyond case." + ) + print() + for key, group in sorted(collisions.items()): + for path in sorted(group): + print(f" {path}") + print() + print( + "Fix: `git mv` one path in each group to a name that doesn't collide. " + "On Windows/macOS you may need two steps (`git mv a.txt tmp && git mv " + "tmp A.txt`) because the filesystem can't hold both spellings at once." + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/scripts/test_case_collision_check.py b/tests/scripts/test_case_collision_check.py new file mode 100644 index 0000000000..4546919b9e --- /dev/null +++ b/tests/scripts/test_case_collision_check.py @@ -0,0 +1,118 @@ +"""Wrappers for scripts/check-case-collisions.py. + +Same pattern as tests/scripts/test_windows_footguns_full_repo_scan.py: run +the real checker and assert its outcomes, so a normal pytest run catches a +regression — someone committing a case-colliding pair — without anyone +having to remember to run the script by hand. + +The collision cases are built with ``git update-index --cacheinfo`` (index +only, never touching the working tree), so they exercise the same index the +checker reads and work even on a case-insensitive filesystem, where the two +spellings cannot coexist on disk. +""" + +from __future__ import annotations + +import hashlib +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT = REPO_ROOT / "scripts" / "check-case-collisions.py" + + +def _git_blob_sha(data: bytes) -> str: + """The git object hash for a blob with ``data`` as its content.""" + header = f"blob {len(data)}\0".encode("ascii") + return hashlib.sha1(header + data).hexdigest() + + +def _run_check(*args, root=None): + cmd = [sys.executable, str(SCRIPT)] + list(args) + if root is not None: + cmd.append(str(root)) + return subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=60, + stdin=subprocess.DEVNULL, + cwd=REPO_ROOT, + ) + + +def _git_init(tmp_path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + return repo + + +def test_full_repo_has_no_case_colliding_paths(): + """The real checker against the whole tracked tree must exit clean.""" + result = _run_check() + assert result.returncode == 0, ( + f"Case-collision check failed:\n{result.stdout}\n{result.stderr}" + ) + + +def test_detects_case_colliding_paths(tmp_path): + """Same-directory Foo.txt + foo.txt must fail, naming both paths.""" + repo = _git_init(tmp_path) + subprocess.run( + [ + "git", "update-index", "--add", "--cacheinfo", + f"100644,{_git_blob_sha(b'a')},Foo.txt", + ], + cwd=repo, check=True, + ) + subprocess.run( + [ + "git", "update-index", "--add", "--cacheinfo", + f"100644,{_git_blob_sha(b'b')},foo.txt", + ], + cwd=repo, check=True, + ) + + result = _run_check(root=repo) + assert result.returncode == 1, f"expected failure, got:\n{result.stdout}" + assert "Foo.txt" in result.stdout + assert "foo.txt" in result.stdout + + +def test_detects_directory_case_collisions(tmp_path): + """The comparison is on the FULL path — dir/Foo.txt vs DIR/foo.txt too.""" + repo = _git_init(tmp_path) + subprocess.run( + [ + "git", "update-index", "--add", "--cacheinfo", + f"100644,{_git_blob_sha(b'a')},src/Helper.py", + ], + cwd=repo, check=True, + ) + subprocess.run( + [ + "git", "update-index", "--add", "--cacheinfo", + f"100644,{_git_blob_sha(b'b')},SRC/helper.py", + ], + cwd=repo, check=True, + ) + + result = _run_check(root=repo) + assert result.returncode == 1, f"expected failure, got:\n{result.stdout}" + assert "src/Helper.py" in result.stdout + assert "SRC/helper.py" in result.stdout + + +def test_same_name_in_different_dirs_is_not_a_collision(tmp_path): + """a/Readme.txt and b/readme.txt share a basename but not a path.""" + repo = _git_init(tmp_path) + (repo / "a").mkdir() + (repo / "b").mkdir() + (repo / "a" / "Readme.txt").write_text("a", encoding="utf-8") + (repo / "b" / "readme.txt").write_text("b", encoding="utf-8") + subprocess.run(["git", "add", "-A"], cwd=repo, check=True) + + result = _run_check(root=repo) + assert result.returncode == 0, f"expected clean, got:\n{result.stdout}"