From c8b6d0fa67d08da9fd47a69f547034e625e6b449 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 19:11:52 +0000 Subject: [PATCH] validate-pins.py and repo_shape.py: gh's stderr that is not UTF-8 is a skip and no traceback, JSON that is not a tree listing is `gh-response-unreadable`, every gh detail spells the repository, a leg's git directory this user cannot search is refused by name, and what stands where `contracts` should be is named (#968) Item 1. `tree_digest_from_gh` reads gh's two streams as bytes and decodes each with `_child_text`: stderr with `errors="replace"`, as `git_out` reads git's, and stdout strictly, as #217 asks of a listing that becomes a digest. A CR is still a line break, as text mode made it. One byte of gh's stderr that is not UTF-8 ended the run in a `UnicodeDecodeError` traceback, exit 1, where a skip belongs; it is U+FFFD in the skip now. A byte that is not UTF-8 on stdout, and JSON that is not the listing's object (a list, `null`, a row that is no mapping or lacks a column, a path holding a lone surrogate), is `gh-response-unreadable`, where each was a traceback. Item 2. `_check_pin_digest` asks `_refuse_an_unreadable_git_dir` after the uninitialized check: the `stat` of `HEAD` in the leg's git directory, the `.git` itself where it is a directory, or the one its gitfile's `gitdir: ` line names (`_leg_git_dir`, line ends stripped as git strips them). A refused `stat` is `pin-submodule-unreadable` through `_unreadable_file`, with the leg checkout's remedy and `_is_a`'s errno set. `spec/.git` at mode 000 ended `pin-commit-unresolvable` with an empty reason, and `.git/modules/spec` at mode 000 in `fatal: not a git repository`, both under THE LOCKSTEP RULE. A git directory with no `HEAD`, or a gitfile that names none, is left to git as before. Item 3. `_no_file` asks `_not_a_directory` of the directory the file is in where nothing stands at the file's name: `contracts` replaced by a file, a FIFO, a socket or a symlink that leads to no directory is named (`... is under contracts, which is a file, not a directory`), with `NOT_A_DIRECTORY`'s remedy, where every pin read "does not exist" under the lockstep remedy. `_what_stands` is #919's `_not_a_file` reading, shared by both. Item 4. `gh-response-unreadable` and `gh-tree-truncated` name the repository with `shown_path`, as #919 named it in `gh-unreadable`. Item 5. `tests/test_validate_pins_spelled_and_named.py` gains a socket in each file's place (rv919's r2) and a `Refusal` that defaults to a remedy, as one older than #370 does, at the three sites that give none (r4), with the tests of items 1 to 4. Its `subprocess.run` stand-in hands bytes, and `tests/test_git_output_decoding.py` drops the `EXEMPT` row for this call, which asks for no text now. Both files are pinned copies; `validate-pins.py` imports nothing new from `repo_shape`, and every state #968 does not name prints byte for byte what it printed, beside the current and the pre-#370 `repo_shape.py`. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- scripts/repo_shape.py | 72 ++- .../assembly-root/scripts/validate-pins.py | 181 ++++-- tests/test_git_output_decoding.py | 10 +- tests/test_validate_pins_spelled_and_named.py | 525 ++++++++++++++++-- 4 files changed, 682 insertions(+), 106 deletions(-) diff --git a/scripts/repo_shape.py b/scripts/repo_shape.py index 5e72d358..cc7238d3 100644 --- a/scripts/repo_shape.py +++ b/scripts/repo_shape.py @@ -1245,6 +1245,15 @@ def tree_digest(repo: Path, rev: str) -> str: return digest.hexdigest() +def _child_text(data: bytes, errors: str) -> str: + """A child's bytes as text mode reads them (#968): UTF-8 with `errors`, + and each carriage return, alone or before a line feed, a line feed, as + `subprocess` translates them. `tree_digest_from_gh` reads gh's two + streams as bytes so that each takes its own `errors`.""" + return (data.decode("utf-8", errors) + .replace("\r\n", "\n").replace("\r", "\n")) + + def tree_digest_from_gh(repository: str, commit: str) -> str: """The same `sorted-ls-tree-r-v1` digest, read from the forge's own recursive tree listing — no clone, no working tree, just `gh api`. @@ -1275,40 +1284,63 @@ def tree_digest_from_gh(repository: str, commit: str) -> str: control it is and each line break began a line of the skip that the validator never printed. `gh`'s not-logged-in help is two lines (four under `GITHUB_ACTIONS`), and the break between them is an escape here, - as a break git prints is in `git_out`. The stderr is read in text mode, - so a carriage return is a line break by then. Printable text, UTF-8 - included, is unchanged. + as a break git prints is in `git_out`. A carriage return is a line break + by then, as text mode reads one. Printable text, UTF-8 included, is + unchanged. The repository is named the same way in `gh-response- + unreadable` and `gh-tree-truncated` (#968), which end in that line too. + + THE TWO STREAMS ARE DECODED APART (#968), by `_child_text`, because they + are read for two purposes. Read together in text mode, both strictly, a + byte of gh's stderr that is not UTF-8 ended the validator in a + `UnicodeDecodeError` traceback, exit 1, where a skip belongs. gh's + stderr is words for a person, so a byte that is not UTF-8 is replaced + (U+FFFD), as `git_out` reads git's. The JSON on stdout stays strict, and + a byte that is not UTF-8 there is `gh-response-unreadable`, a refusal. + So is JSON that is not the listing's object, where `.get` on a list or + a missing column ended the run in a traceback too. """ try: proc = subprocess.run( ["gh", "api", GH_TREE_API.format(repo=repository, commit=commit)], - # UTF-8 and STRICT, not `errors="replace"`: this JSON becomes a - # digest, and a replaced byte would be a wrong digest with a - # confident tone (#217). - capture_output=True, text=True, encoding="utf-8", check=False) + capture_output=True, check=False) except FileNotFoundError as exc: raise Refusal("gh-not-found", f"the `gh` CLI is not on PATH: {exc}") from exc + named = shown_path(repository) if proc.returncode != 0: raise Refusal( "gh-unreadable", - f"`gh api` could not read {shown_path(repository)} @ {commit}: " - f"{spelled(proc.stderr.strip())}", + f"`gh api` could not read {named} @ {commit}: " + f"{spelled(_child_text(proc.stderr, 'replace').strip())}", ) try: - payload = json.loads(proc.stdout) - except json.JSONDecodeError as exc: + # UTF-8 and STRICT, not `errors="replace"`: this JSON becomes a + # digest, and a replaced byte would be a wrong digest with a + # confident tone (#217). + payload = json.loads(_child_text(proc.stdout, "strict")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise Refusal("gh-response-unreadable", - f"{repository} @ {commit}: {exc}") from exc - if payload.get("truncated"): + f"{named} @ {commit}: {exc}") from exc + try: + if payload.get("truncated"): + raise Refusal( + "gh-tree-truncated", + f"the forge truncated its tree listing for {named} @ " + f"{commit}, so the digest would be computed over a PARTIAL " + "tree", + ) + records = sorted( + f"{row['mode']} {row['type']} {row['sha']}\t{row['path']}" + .encode() + for row in payload.get("tree") or [] if row.get("type") != "tree") + except (AttributeError, KeyError, TypeError, UnicodeEncodeError) as exc: + # JSON that is not the listing's object (a list, a row that is no + # mapping or lacks a column, a column holding a lone surrogate) is + # what gh said, and no traceback (#968). raise Refusal( - "gh-tree-truncated", - f"the forge truncated its tree listing for {repository} @ " - f"{commit}, so the digest would be computed over a PARTIAL tree", - ) - records = sorted( - f"{row['mode']} {row['type']} {row['sha']}\t{row['path']}".encode() - for row in payload.get("tree") or [] if row.get("type") != "tree") + "gh-response-unreadable", + f"{named} @ {commit}: the answer is not a tree listing " + f"({type(exc).__name__}: {exc})") from exc digest = hashlib.sha256() for record in records: digest.update(record) diff --git a/templates/assembly-root/scripts/validate-pins.py b/templates/assembly-root/scripts/validate-pins.py index e9419d41..62d53ecf 100755 --- a/templates/assembly-root/scripts/validate-pins.py +++ b/templates/assembly-root/scripts/validate-pins.py @@ -418,54 +418,123 @@ def _gitfile_opens(path: Path) -> bool: return True -#: What a `stat` mode that is no regular file names (#919), as the line -#: about the file whose place it takes says it. A device is either kind. -_NOT_A_FILE_KINDS = ( - (stat.S_ISDIR, "a directory"), (stat.S_ISFIFO, "a FIFO"), - (stat.S_ISSOCK, "a socket"), (stat.S_ISCHR, "a device"), - (stat.S_ISBLK, "a device")) - - -def _named_kind(mode: int, link: str = "") -> str | None: - """`is , not a file` for a `mode` that is no regular file, - `is not a file` where `_NOT_A_FILE_KINDS` has no name for it, and None - for a regular file, which stands in no file's place.""" - if stat.S_ISREG(mode): +#: What a leg's checkout is read for, as `_unreadable_file`'s remedy names +#: it (#712, #968). +CHECKOUT_READ = ("each leg's checkout, to recompute its pinned tree digest " + "from the leg's own objects") + + +def _leg_git_dir(dotgit: Path) -> Path | None: + """The git directory of a leg whose `.git` is `dotgit` (#968): `dotgit` + itself where it is no regular file (a checkout's own directory); the + directory a gitfile's `gitdir: ` line names, joined to the gitfile's own + directory as git joins it, its `..` left for the system to follow; and + None where the gitfile names none, which git refuses as it always did. + Raises the `OSError` of a `stat` or a read the operating system + refused.""" + if not _is_a(dotgit, stat.S_ISREG): + return dotgit + data = dotgit.read_bytes() + if not data.startswith(b"gitdir: "): return None - kind = next((name for test, name in _NOT_A_FILE_KINDS if test(mode)), - None) - return f"is {link}{kind}, not a file" if kind else "is not a file" + return dotgit.parent / os.fsdecode(data[len(b"gitdir: "):].rstrip(b"\r\n")) + + +def _refuse_an_unreadable_git_dir(root: Path, dotgit: Path) -> None: + """`pin-submodule-unreadable`, by name, for a leg whose git directory + this user cannot search (#968), asked as `_is_there` asks: the `stat` + of its `HEAD`, which a git directory at mode 000 or r-- refuses. + + TWO STATES REACHED GIT, AND GIT SAID THE WRONG THING. A `.git` that is a + directory at mode 000 is passed over by git, which walks up to the + root's own repository, so the run ended `pin-commit-unresolvable` with + an empty reason; and the directory a readable gitfile names, under + `.git/modules/`, at mode 000, ended in `fatal: not a git repository`. + Both were under THE LOCKSTEP RULE, which says the pinned commit is + missing. Each is named, `spec/.git` or `.git/modules/spec`, with the + leg checkout's remedy that #712 gives a checkout this user cannot + search. A git directory with no `HEAD`, or a gitfile that names none, + is left to git, which answers for it as it always did.""" + named = dotgit + try: + gitdir = _leg_git_dir(dotgit) + if gitdir is not None: + named = Path(os.path.normpath(gitdir)) + _is_there(gitdir / "HEAD") + except OSError as exc: + raise _unreadable_file("pin-submodule-unreadable", _named(root, named), + exc, CHECKOUT_READ) from exc + + +#: What a `stat` mode names (#919, #968), as the line about the file or the +#: directory whose place it takes says it. A device is either kind. +_KINDS = ( + (stat.S_ISDIR, "a directory"), (stat.S_ISREG, "a file"), + (stat.S_ISFIFO, "a FIFO"), (stat.S_ISSOCK, "a socket"), + (stat.S_ISCHR, "a device"), (stat.S_ISBLK, "a device")) + + +def _what_stands(path: Path, wanted: Callable[[int], bool]) -> str | None: + """What stands at `path` where it is not what `wanted` asks of a `stat` + mode (#919, #968): `a directory`, `a file`, `a FIFO`, `a socket` or `a + device`, each also as `a symlink to` one; `a symlink to nothing`; `a + symlink loop`; and `""` for a kind `_KINDS` has no name for. None where + NOTHING is there (`lstat` finds no entry at the name, or the system + cannot take the name at all), and where what is there is `wanted`. + + `lstat` asks about the name itself. It runs only after `stat` answered, + inside the guard, and opens nothing.""" + try: + mode = path.lstat().st_mode + except (OSError, ValueError): + return None + link = "" + if stat.S_ISLNK(mode): + try: + mode = path.stat().st_mode + except OSError as exc: + loop = (exc.errno == errno.ELOOP + or getattr(exc, "winerror", None) == 1921) + return "a symlink loop" if loop else "a symlink to nothing" + link = "a symlink to " + if wanted(mode): + return None + kind = next((name for test, name in _KINDS if test(mode)), None) + return link + kind if kind else "" def _not_a_file(path: Path) -> str | None: """What stands at `path` where `_is_file` found no regular file there, as the words after its name (#919): `is a directory, not a file`, `is a FIFO, not a file`, `is a symlink to nothing, not a file`, `is a symlink - loop, not a file`, `is a symlink to a directory, not a file`, and the - same for a socket or a device. None where NOTHING is there (`lstat` - finds no entry at the name, or the system cannot take the name at all), - whose refusal keeps the words it always had. + loop, not a file`, `is a symlink to a directory, not a file`, the same + for a socket or a device, and `is not a file` for a kind with no name. + None where NOTHING is there, whose refusal keeps the words it always + had: see `_what_stands`. WHAT IS THERE WAS CALLED ABSENT: `_is_file` asks `stat`, which answers "not a regular file" for a directory or a FIFO and "nothing there" for a symlink that leads nowhere or to itself, so each was refused under "does not exist" and given a remedy that moves a pin, and a shape copy so - replaced was "pinned but absent". `lstat` asks about the name itself. - It runs only after `stat` answered, inside the guard, and opens nothing. + replaced was "pinned but absent". """ - try: - mode = path.lstat().st_mode - except (OSError, ValueError): + what = _what_stands(path, stat.S_ISREG) + if what is None: return None - if not stat.S_ISLNK(mode): - return _named_kind(mode) - try: - return _named_kind(path.stat().st_mode, "a symlink to ") - except OSError as exc: - loop = (exc.errno == errno.ELOOP - or getattr(exc, "winerror", None) == 1921) - return ("is a symlink loop, not a file" if loop - else "is a symlink to nothing, not a file") + return f"is {what}, not a file" if what else "is not a file" + + +def _not_a_directory(path: Path) -> str | None: + """What stands at `path`, the directory a file should be in, where it is + no directory (#968): `which is a file, not a directory`, `which is a + FIFO, not a directory`, `which is a symlink to nothing, not a + directory`, and so on, as `_what_stands` names it. None where it is a + directory, or a symlink to one, and where nothing is there.""" + what = _what_stands(path, stat.S_ISDIR) + if what is None: + return None + return (f"which is {what}, not a directory" if what + else "which is not a directory") #: What a pin, the shape pin, a neutral-product pin or the manifest is told @@ -486,7 +555,15 @@ def _not_a_file(path: Path) -> str | None: "the project's history.") -def _no_file(code: str, path: Path, said: str, absent: str, +#: What the same files are told where the directory that holds one is not a +#: directory (#968): `contracts` replaced by a file, a FIFO or a symlink. +NOT_A_DIRECTORY = ( + "Something that is not a directory stands where the directory that " + "holds it should be: move it aside, or take it away if nobody wants it, " + "and restore the directory from the project's history. ") + + +def _no_file(code: str, root: Path, path: Path, said: str, absent: str, *remediation: str) -> Refusal: """`code`'s refusal where `_is_file` found no regular file at `path` (#919). `said` names the file. Where nothing is there it goes on with @@ -494,14 +571,25 @@ def _no_file(code: str, path: Path, said: str, absent: str, words it always had, byte for byte. Where something that is not a file stands there, `_not_a_file` says what, and the remedy is `NOT_A_FILE`'s. + WHERE THE DIRECTORY THAT HOLDS IT IS NO DIRECTORY (#968), `contracts` + replaced by a regular file, a FIFO or a symlink that leads to none, + nothing can be at the name, and every pin read "does not exist" under + the lockstep remedy. `_not_a_directory` names what stands there, and + the remedy is `NOT_A_DIRECTORY`'s. Only the directory the file is in is + asked: a pin's is `contracts`, and the manifest's is the root. + A refusal that had no remedy of its own is built with none, as it always was, and not with an empty one: beside a `repo_shape.py` older than #370, `Refusal`'s own default is the lockstep remedy, and an empty one would print a blank line where it stood (#370, #610).""" what = _not_a_file(path) - if what is None: - return Refusal(code, said + absent, *remediation) - return Refusal(code, f"{said} {what}", _remedy(NOT_A_FILE)) + if what is not None: + return Refusal(code, f"{said} {what}", _remedy(NOT_A_FILE)) + holder = _not_a_directory(path.parent) + if holder is not None: + return Refusal(code, f"{said} is under {_named(root, path.parent)}, " + f"{holder}", _remedy(NOT_A_DIRECTORY)) + return Refusal(code, said + absent, *remediation) def _load(root: Path, path: Path): @@ -646,7 +734,7 @@ def _load_leg_pin(root: Path, role, path: str, repository: str, rel = _named(root, pin_path) if not _is_file(root, pin_path): raise _no_file( - "pin-missing", pin_path, + "pin-missing", root, pin_path, f"leg {role!r} declares {_shown(repository)} at {path!r} but " f"{rel}", " does not exist") pin = _load(root, pin_path) @@ -1127,7 +1215,9 @@ def _check_pin_digest(root: Path, path: str, commit: str, pin: dict, rel: str, RV827's S1), `pin-submodule-unreadable`, naming its `.git`: the digest cannot be recomputed, and the leg is not uninitialized, which is what Python 3.14's `exists()` would have said of it. So is a `.git` gitfile - this user cannot open (#919): see `_gitfile_opens`. + this user cannot open (#919): see `_gitfile_opens`; and a git directory + this user cannot search, the `.git` itself or the one a gitfile names + (#968): see `_refuse_an_unreadable_git_dir`. """ submodule = root / path named = _shown(path) @@ -1137,8 +1227,7 @@ def _check_pin_digest(root: Path, path: str, commit: str, pin: dict, rel: str, except OSError as exc: raise _unreadable_file( "pin-submodule-unreadable", _named(root, dotgit), exc, - "each leg's checkout, to recompute its pinned tree digest from " - "the leg's own objects") from exc + CHECKOUT_READ) from exc if not initialized: raise Refusal( "pin-submodule-uninitialized", @@ -1146,6 +1235,7 @@ def _check_pin_digest(root: Path, path: str, commit: str, pin: dict, rel: str, "digest cannot be recomputed. Presence is not identity and an " "unreadable surface must fail rather than degrade.", ) + _refuse_an_unreadable_git_dir(root, dotgit) # `_git_runs` OUTSIDE the `try`: the refusal it raises is a `Refusal`, and # inside, the `except Refusal` below would turn "no git to run" into # `pin-commit-unresolvable`, which says the commit is missing. @@ -1254,7 +1344,8 @@ def _check_shape_pin_header(root: Path, manifest: dict, report: Report pin_path = root / "contracts" / "shape-pin.yaml" rel = _named(root, pin_path) if not _is_file(root, pin_path): - raise _no_file("shape-pin-missing", pin_path, rel, " does not exist") + raise _no_file("shape-pin-missing", root, pin_path, rel, + " does not exist") pin = _load(root, pin_path) if not isinstance(pin, dict): raise Refusal("shape-pin-unreadable", f"{rel}: not a mapping") @@ -1590,7 +1681,7 @@ def _check_neutral_pin_entry_and_load(root: Path, product, report: Report rel = _named(root, pin_path) if not _is_file(root, pin_path): raise _no_file( - "neutral-pin-missing", pin_path, + "neutral-pin-missing", root, pin_path, f"project.yaml declares a pin on {product!r} but {rel}", " does not exist", "Remediation: `scaffold-project.py --pin " @@ -1919,7 +2010,7 @@ def _load_legs_to_check(root: Path, report: Report manifest_path = root / "project.yaml" if not _is_file(root, manifest_path): raise _no_file( - "manifest-missing", manifest_path, "project.yaml", + "manifest-missing", root, manifest_path, "project.yaml", " does not exist, so there is no declaration of which legs to " "check. A one-repository project has no legs and does not run " "this validator.") diff --git a/tests/test_git_output_decoding.py b/tests/test_git_output_decoding.py index cf3bc5f8..c431765f 100644 --- a/tests/test_git_output_decoding.py +++ b/tests/test_git_output_decoding.py @@ -134,12 +134,14 @@ def test_run_names_the_path_git_complained_of_under_a_c_locale( #: name, and the reason it differs. EXEMPT = { # The `gh api` tree listing becomes a digest: UTF-8 yes, `replace` no, for - # a replaced byte would be a wrong digest with a confident tone. The - # first is `scaffold-project.py --pin`'s and `adopt-project.py plan - # --pin`'s, moved out of the scaffold for #273. + # a replaced byte would be a wrong digest with a confident tone. It is + # `scaffold-project.py --pin`'s and `adopt-project.py plan --pin`'s, + # moved out of the scaffold for #273. `repo_shape.py`'s + # `tree_digest_from_gh` needs no row: it reads gh's two streams as bytes + # and decodes each itself, stdout strictly and stderr with `replace` + # (#968). ("scripts/shape_materialize.py", "pin_digest_from_gh"): frozenset({"encoding"}), - ("scripts/repo_shape.py", "tree_digest_from_gh"): frozenset({"encoding"}), } #: A call the guard cannot read the keywords of: one that expands `**options` diff --git a/tests/test_validate_pins_spelled_and_named.py b/tests/test_validate_pins_spelled_and_named.py index 314122d8..02834ac1 100644 --- a/tests/test_validate_pins_spelled_and_named.py +++ b/tests/test_validate_pins_spelled_and_named.py @@ -25,6 +25,24 @@ LOCKSTEP RULE. It is `pin-submodule-unreadable`, by name, as #827 refuses a leg checkout this user cannot search. +What #919 left, #968, from rv919's review of #952 at `8a7671b3`: + + * gh's stderr that is not UTF-8 ended the run in a `UnicodeDecodeError` + traceback. It is read with `errors="replace"`, as `git_out` reads git's, + and the JSON on stdout stays strict: a byte that is not UTF-8 there, or + JSON that is not a tree listing, is `gh-response-unreadable`. + * `gh-response-unreadable` and `gh-tree-truncated` name the repository + as `gh-unreadable` does. + * A leg's git directory this user cannot search, a `.git` directory or + the one a gitfile names under `.git/modules/`, ended in + `pin-commit-unresolvable` under THE LOCKSTEP RULE. It is + `pin-submodule-unreadable`, by name. + * `contracts` replaced by a file, a FIFO or a symlink that leads to no + directory made every pin "does not exist" under THE LOCKSTEP RULE. The + refusal names what stands there, with a remedy of its own. + * rv919's surviving mutants r2 (a socket named as a FIFO) and r4 (an + empty remedy where none was given) each fail a row now. + THE HARNESS is `tests/test_validate_pins_named_and_spelled.py`'s: the gate runs as the copy a real scaffold wrote (`validate`), and in-process as that copy loaded as a module (`validator`). A stub `gh` is a `#!` script, so its @@ -39,7 +57,11 @@ from __future__ import annotations import errno +import hashlib +import json import os +import socket +import stat import subprocess import sys from pathlib import Path @@ -146,52 +168,84 @@ def declared_with_a_pin(project: Path) -> None: "digest_definition: sorted-ls-tree-r-v1\n").encode("utf-8")) -def gh_saying(where: Path, said: str, status: int) -> dict: +def as_bytes(said: str | bytes) -> bytes: + """`said` as the bytes a child writes: UTF-8 where it is text.""" + return said if isinstance(said, bytes) else said.encode("utf-8") + + +def gh_saying(where: Path, said: str | bytes, status: int) -> dict: """The environment of a run whose `gh`, first on PATH, prints `said` on stderr and exits `status`. The script is written as bytes, so its ESC - and CR are the bytes themselves.""" + and CR, and a byte that is not UTF-8, are the bytes themselves.""" bin_dir = where / "gh-bin" bin_dir.mkdir() gh = bin_dir / "gh" - gh.write_bytes(b"#!/bin/sh\nprintf '%s' '" + said.encode("utf-8") + gh.write_bytes(b"#!/bin/sh\nprintf '%s' '" + as_bytes(said) + b"' >&2\nexit " + str(status).encode("ascii") + b"\n") gh.chmod(0o755) return {"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}"} +def skips_of_a_run(project: Path, where: Path, said: str | bytes) -> list: + """The `skip` lines of the project's own gate, run with a `gh` that + prints `said` on stderr and exits 1, failing the row on a traceback.""" + result = run_script(project / "scripts" / "validate-pins.py", + cwd=project, env=gh_saying(where, said, 1)) + assert "Traceback" not in result.stderr, result.stderr + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.endswith(LF + "pins ok" + LF), result.stdout + return [line for line in result.stdout.split(LF) + if line.startswith(" skip ")] + + +def the_skip(named: str) -> str: + """The `skip` line for the pin `declared_with_a_pin` writes, where `gh` + could not read it and printed what the detail names as `named`.""" + return (f" skip openXdox: pin commit {COMMIT[:12]} digest NOT rechecked " + "— no local checkout (gh-unreadable: `gh api` could not read " + f"testorg/openXdox @ {COMMIT}: {named}). A --pin-source, " + "SHAPE_PIN_SOURCE_OPENXDOX, or a checkout at ../openXdox beside " + "this assembly root would verify it offline.") + + @WINDOWS_SKIP def test_gh_s_stderr_in_the_skip_line_is_spelled_on_one_line(project, tmp_path): """U177: a `gh` that exits 1 printing an ESC, a CR and two line feeds. The skip is ONE line, gh's text spelled: the ESC and each break are - escapes, the CR's too, since gh's stderr is read in text mode and the - CR is a line break by then. No control reaches stdout, and the run - still passes: a skip is never a finding.""" + escapes, the CR's too, since a CR is read as a line break, as text mode + read one. No control reaches stdout, and the run still passes: a skip + is never a finding.""" declared_with_a_pin(project) - env = gh_saying(tmp_path, "one" + ESC + "[31mred" + CR + "two" + LF - + "three" + LF, 1) - result = run_script(project / "scripts" / "validate-pins.py", - cwd=project, env=env) - skips = [line for line in result.stdout.split(LF) - if line.startswith(" skip ")] - assert skips == [ - f" skip openXdox: pin commit {COMMIT[:12]} digest NOT rechecked — " - "no local checkout (gh-unreadable: `gh api` could not read " - f"testorg/openXdox @ {COMMIT}: one{BS}x1b[31mred{BS}ntwo{BS}nthree)." - " A --pin-source, SHAPE_PIN_SOURCE_OPENXDOX, or a checkout at " - "../openXdox beside this assembly root would verify it offline."], \ - result.stdout - assert ESC not in result.stdout, result.stdout - assert CR not in result.stdout, result.stdout - assert result.returncode == 0, result.stdout + result.stderr - assert result.stdout.endswith(LF + "pins ok" + LF), result.stdout + skips = skips_of_a_run(project, tmp_path, "one" + ESC + "[31mred" + CR + + "two" + LF + "three" + LF) + assert skips == [the_skip(f"one{BS}x1b[31mred{BS}ntwo{BS}nthree")] + assert ESC not in LF.join(skips), skips + assert CR not in LF.join(skips), skips -def gh_answering(monkeypatch, status: int, stderr: str) -> None: +@WINDOWS_SKIP +def test_gh_s_stderr_that_is_not_utf_8_is_a_skip_and_no_traceback(project, + tmp_path): + """#968, rv919's probe: a `gh` that exits 1 printing a byte that is not + UTF-8. Both of gh's streams were read in text mode, strictly, so the + run ended in a `UnicodeDecodeError` traceback, exit 1, where a skip + belongs. gh's stderr is words for a person: the byte is U+FFFD, as + `git_out` replaces one of git's, and the run passes with its skip.""" + declared_with_a_pin(project) + skips = skips_of_a_run(project, tmp_path, + b"bad" + bytes([0xFF]) + b"byte") + assert skips == [the_skip("bad" + chr(0xFFFD) + "byte")] + + +def gh_answering(monkeypatch, status: int, stderr: str | bytes, + stdout: str | bytes = b"") -> None: """`subprocess.run` answering as a `gh` that exits `status` and prints - `stderr`, in-process, so a row runs on every platform.""" + `stdout` and `stderr`, in-process, so a row runs on every platform. The + streams are bytes, as `tree_digest_from_gh` reads them (#968).""" def gh(argv, **_kwargs): - return subprocess.CompletedProcess(argv, status, "", stderr) + return subprocess.CompletedProcess(argv, status, as_bytes(stdout), + as_bytes(stderr)) monkeypatch.setattr(subprocess, "run", gh) @@ -211,6 +265,9 @@ def gh(argv, **_kwargs): pytest.param("caf" + chr(0xe9) + " " + chr(0x65e5) + chr(0x672c), "caf" + chr(0xe9) + " " + chr(0x65e5) + chr(0x672c), id="utf-8-unchanged"), + pytest.param(b"caf" + bytes([0xE9]) + b" ok" + CR.encode("ascii") + + LF.encode("ascii"), "caf" + chr(0xFFFD) + " ok", + id="not-utf-8"), ] @@ -244,6 +301,115 @@ def test_gh_unreadable_names_the_repository_spelled(validator, monkeypatch, "HTTP 404: Not Found") +def refusal_of_gh_saying(validator, monkeypatch, stdout: bytes, + repository: str = "testorg/openXdox"): + """The `Refusal` of `tree_digest_from_gh` where `gh` exits 0 printing + `stdout`, failing the row if it returns a digest or raises anything + else.""" + gh_answering(monkeypatch, 0, b"", stdout) + with pytest.raises(validator.Refusal) as caught: + validator.tree_digest_from_gh(repository, COMMIT) + return caught.value + + +def test_gh_json_that_is_not_utf_8_is_unreadable_and_no_traceback( + validator, monkeypatch): + """#968: the JSON stays strict, as #217 asks, since it becomes a digest + and a replaced byte would be a wrong one. A byte that is not UTF-8 there + is `gh-response-unreadable`, naming the decoder's reason, where it was a + `UnicodeDecodeError` traceback.""" + said = b'{"tree": [], "x": "' + bytes([0xFF]) + b'"}' + with pytest.raises(UnicodeDecodeError) as decoding: + said.decode("utf-8") + refusal = refusal_of_gh_saying(validator, monkeypatch, said) + assert refusal.code == "gh-response-unreadable" + assert refusal.detail == f"testorg/openXdox @ {COMMIT}: {decoding.value}" + + +#: A repository, and how every gh refusal names it: `repr`'d where a +#: character in it is not printable, and as it is where none is. +REPOSITORIES = [ + *(pytest.param(param.values[0], repr(param.values[0]), id=param.id) + for param in UNPRINTABLE if param.id != "undecodable-byte"), + pytest.param("testorg/openXdox", "testorg/openXdox", id="printable"), +] + + +@pytest.mark.parametrize("repository, named", REPOSITORIES) +@pytest.mark.parametrize("stdout, code, detail", [ + pytest.param(b"not json", "gh-response-unreadable", + "{named} @ {commit}: Expecting value: line 1 column 1 " + "(char 0)", id="not-json"), + pytest.param(b'{"truncated": true, "tree": []}', "gh-tree-truncated", + "the forge truncated its tree listing for {named} @ " + "{commit}, so the digest would be computed over a PARTIAL " + "tree", id="truncated"), + pytest.param(b"[]", "gh-response-unreadable", + "{named} @ {commit}: the answer is not a tree listing " + "(AttributeError: 'list' object has no attribute 'get')", + id="not-a-listing"), +]) +def test_every_gh_refusal_names_the_repository_spelled( + validator, monkeypatch, repository, named, stdout, code, detail): + """#968: `gh-response-unreadable` and `gh-tree-truncated` named the + repository raw, where `gh-unreadable` spelled it (#919), and a + `validate-pins.py` skip line prints each detail. Each is `shown_path`'s + now, and so is JSON that is not a listing; a printable repository is + named byte for byte as it was.""" + refusal = refusal_of_gh_saying(validator, monkeypatch, stdout, repository) + assert refusal.code == code + assert refusal.detail == detail.format(named=named, commit=COMMIT) + + +#: JSON that is not gh's tree listing, and the error that ended the run in a +#: traceback for it. +NOT_A_LISTING = [ + pytest.param(b"[]", "AttributeError", id="a-list"), + pytest.param(b"null", "AttributeError", id="null"), + pytest.param(b'{"tree": 5}', "TypeError", id="tree-a-number"), + pytest.param(b'{"tree": ["x"]}', "AttributeError", id="a-row-no-mapping"), + pytest.param(b'{"tree": [{"type": "blob", "sha": "a", "path": "p"}]}', + "KeyError", id="a-row-without-its-mode"), + pytest.param(b'{"tree": [{"mode": "100644", "type": "blob", "sha": "a", ' + b'"path": "' + BS.encode("ascii") + b'ud800"}]}', + "UnicodeEncodeError", id="a-lone-surrogate"), +] + + +@pytest.mark.parametrize("stdout, error", NOT_A_LISTING) +def test_json_that_is_not_a_tree_listing_is_unreadable(validator, monkeypatch, + stdout, error): + """#968: JSON that is not the listing's object (a list, a row that is + no mapping or lacks a column, a path holding a lone surrogate) is what + gh said, `gh-response-unreadable` naming the repository, where `.get`, + a subscript or `.encode` ended the run in a traceback.""" + refusal = refusal_of_gh_saying(validator, monkeypatch, stdout) + assert refusal.code == "gh-response-unreadable" + assert refusal.detail.startswith( + f"testorg/openXdox @ {COMMIT}: the answer is not a tree listing " + f"({error}: "), refusal.detail + + +def test_a_tree_listing_digests_as_it_always_did(validator, monkeypatch): + """The control: a listing, CR LF between its lines, digests as the + definition says: each row that is no tree as ` `, a + TAB and its path, UTF-8, sorted, and each ended by a line feed.""" + rows = [{"mode": "100644", "type": "blob", "sha": "b" * 40, + "path": "caf" + chr(0xE9) + ".txt"}, + {"mode": "040000", "type": "tree", "sha": "c" * 40, "path": "d"}, + {"mode": "100755", "type": "blob", "sha": "a" * 40, + "path": "d/run.sh"}] + listing = (CR + LF).join(["{", '"truncated": false,', '"tree": ' + + json.dumps(rows, ensure_ascii=False), "}", ""]) + gh_answering(monkeypatch, 0, b"", listing) + records = sorted( + f"{row['mode']} {row['type']} {row['sha']}{chr(9)}{row['path']}" + .encode("utf-8") for row in rows if row["type"] != "tree") + assert validator.tree_digest_from_gh("testorg/openXdox", COMMIT) == ( + hashlib.sha256(b"".join(record + LF.encode("ascii") + for record in records)).hexdigest()) + + # --- U178: what stands where a file should be -------------------------------- def made_directory(path: Path) -> None: @@ -267,10 +433,23 @@ def made_link_to_a_directory(path: Path) -> None: os.symlink("directory-w919", path) +def made_socket(path: Path) -> None: + """A socket bound at `path` by its name, from inside its directory: the + whole path of a socket must fit in about a hundred bytes, and a + temporary directory's need not (rv919's r2). Closed, it stays.""" + here = Path.cwd() + os.chdir(path.parent) + try: + with socket.socket(socket.AF_UNIX) as bound: + bound.bind(path.name) + finally: + os.chdir(here) + + POSIX_NODES = pytest.mark.skipif( os.name == "nt", - reason="a FIFO needs os.mkfifo, and Windows makes a symlink only with a " - "privilege") + reason="a FIFO needs os.mkfifo, a socket's name AF_UNIX, and Windows " + "makes a symlink only with a privilege") #: What can stand in a file's place, and the words after the file's name. NOT_FILES = [ @@ -278,6 +457,8 @@ def made_link_to_a_directory(path: Path) -> None: id="directory"), pytest.param(made_fifo, "is a FIFO, not a file", id="fifo", marks=POSIX_NODES), + pytest.param(made_socket, "is a socket, not a file", id="socket", + marks=POSIX_NODES), pytest.param(made_link_to_nothing, "is a symlink to nothing, not a file", id="symlink-to-nothing", marks=POSIX_NODES), pytest.param(made_loop, "is a symlink loop, not a file", @@ -287,12 +468,15 @@ def made_link_to_a_directory(path: Path) -> None: id="symlink-to-a-directory", marks=POSIX_NODES), ] +#: What the leg pin's refusal says before "does not exist". +LEG_PIN_SAID = ("leg 'spec' declares testorg/Atlas-spec at 'spec' but " + "contracts/spec-pin.yaml") + #: Each site that REFUSES, as `(the file, the code, what its line says before #: what stands there)`: the words before "does not exist", which stay. REFUSALS = [ - pytest.param("contracts/spec-pin.yaml", "pin-missing", - "leg 'spec' declares testorg/Atlas-spec at 'spec' but " - "contracts/spec-pin.yaml", id="leg-pin"), + pytest.param("contracts/spec-pin.yaml", "pin-missing", LEG_PIN_SAID, + id="leg-pin"), pytest.param(SHAPE_PIN, "shape-pin-missing", SHAPE_PIN, id="shape-pin"), pytest.param(NEUTRAL_PIN, "neutral-pin-missing", f"project.yaml declares a pin on 'openXdox' but " @@ -361,8 +545,7 @@ def test_what_stands_in_a_shape_copys_place_is_named(project, make, what): #: each site always had. ABSENT = [ pytest.param("contracts/spec-pin.yaml", - "REFUSED pin-missing: leg 'spec' declares testorg/Atlas-spec " - "at 'spec' but contracts/spec-pin.yaml does not exist\n" + f"REFUSED pin-missing: {LEG_PIN_SAID} does not exist\n" f"{REMEDIATION}\n", 2, id="leg-pin"), pytest.param(SHAPE_PIN, f"REFUSED shape-pin-missing: {SHAPE_PIN} does not " f"exist\n{REMEDIATION}\n", 2, id="shape-pin"), @@ -396,6 +579,145 @@ def test_a_file_that_is_not_there_keeps_its_words_byte_for_byte( assert result.returncode == code, result.stdout + result.stderr +#: A remedy no site gives, standing for the lockstep one that `Refusal` gave +#: every refusal built without one before #370. +OLD_DEFAULT = "Remediation: the default of a repo_shape.py older than #370." + + +def refusals_defaulting(monkeypatch, validator) -> None: + """`Refusal`, where the gate builds one, as a `repo_shape.py` older than + #370 builds it: with a remedy of its own where none is given.""" + class Defaulting(validator.Refusal): + def __init__(self, code, detail, remediation=OLD_DEFAULT): + super().__init__(code, detail, remediation) + + monkeypatch.setattr(validator, "Refusal", Defaulting) + + +@pytest.mark.parametrize("relative, code", [ + pytest.param("contracts/spec-pin.yaml", "pin-missing", id="leg-pin"), + pytest.param(SHAPE_PIN, "shape-pin-missing", id="shape-pin"), + pytest.param("project.yaml", "manifest-missing", id="manifest"), +]) +def test_a_refusal_with_no_remedy_of_its_own_is_built_with_none( + validator, project, monkeypatch, relative, code): + """rv919's r4: a file that is not there, at a site that names no remedy + of its own, is refused with none, and not with an empty one. Beside the + `repo_shape.py` of today the two print the same, which is why the + mutant lived; beside one older than #370, the default is the lockstep + remedy, and an empty one printed a blank line in its place (#370, + #610).""" + (project / relative).unlink() + refusals_defaulting(monkeypatch, validator) + refusal = refusal_of_the_run(validator, project) + assert refusal.code == code + assert refusal.remediation == OLD_DEFAULT + + +# --- #968: what stands where `contracts` should be --------------------------- + +def made_file(path: Path) -> None: + path.write_bytes(b"not a directory") + + +def made_link_to_a_file(path: Path) -> None: + (path.parent / "file-w968").write_bytes(b"not a directory") + os.symlink("file-w968", path) + + +#: What can stand where the directory that holds a pin should be, and the +#: words after its name. +NOT_DIRECTORIES = [ + pytest.param(made_file, "which is a file, not a directory", id="file"), + pytest.param(made_fifo, "which is a FIFO, not a directory", id="fifo", + marks=POSIX_NODES), + pytest.param(made_socket, "which is a socket, not a directory", + id="socket", marks=POSIX_NODES), + pytest.param(made_link_to_nothing, + "which is a symlink to nothing, not a directory", + id="symlink-to-nothing", marks=POSIX_NODES), + pytest.param(made_loop, "which is a symlink loop, not a directory", + id="symlink-loop", marks=POSIX_NODES), + pytest.param(made_link_to_a_file, + "which is a symlink to a file, not a directory", + id="symlink-to-a-file", marks=POSIX_NODES), +] + +#: The remedy where the directory that holds a pin is not a directory. +NOT_A_DIRECTORY_REMEDY = ( + READS + "Something that is not a directory stands where the directory " + "that holds it should be: move it aside, or take it away if nobody wants " + "it, and restore the directory from the project's history. " + AGAIN) + + +def contracts_replaced(project: Path, make) -> None: + """`contracts` moved aside, and `make` putting what it makes at its + name.""" + (project / "contracts").rename(project / "contracts-aside-w968") + make(project / "contracts") + + +@pytest.mark.parametrize("make, what", NOT_DIRECTORIES) +def test_what_stands_where_contracts_should_be_is_named(project, make, what): + """#968: `contracts` replaced by a file, a FIFO, a socket or a symlink + that leads to no directory. Nothing can be at any pin's name, so each + was "does not exist" under the lockstep remedy, which moves a pin and + puts back no directory. The first pin asked, the leg's, names what + stands there, with a remedy that says to move it aside and restore the + directory. The whole of stderr, exit 2, nothing on stdout, and nothing + written.""" + contracts_replaced(project, make) + before = tree_bytes(project) + result = validate(project) + assert result.stderr == ( + f"REFUSED pin-missing: {LEG_PIN_SAID} is under contracts, {what}\n" + f"{NOT_A_DIRECTORY_REMEDY}\n"), result.stderr + assert result.returncode == 2, result.stdout + result.stderr + assert result.stdout == "", result.stdout + assert tree_bytes(project) == before, "a validator writes nothing" + + +def shape_pin_checked(validator, root: Path) -> None: + validator._check_shape_pin_header(root, {}, validator.Report()) + + +def neutral_pin_checked(validator, root: Path) -> None: + validator._check_neutral_pin_entry_and_load(root, "openXdox", + validator.Report()) + + +@pytest.mark.parametrize("check, code, said", [ + pytest.param(shape_pin_checked, "shape-pin-missing", SHAPE_PIN, + id="shape-pin"), + pytest.param(neutral_pin_checked, "neutral-pin-missing", + f"project.yaml declares a pin on 'openXdox' but " + f"{NEUTRAL_PIN}", id="neutral-pin"), +]) +def test_every_pin_site_names_what_stands_where_contracts_should_be( + validator, project, check, code, said): + """The shape pin's and a neutral-product pin's sites, which a run + reaches only after every leg's, each asked in-process with `contracts` + a file, on every platform.""" + contracts_replaced(project, made_file) + with pytest.raises(validator.Refusal) as caught: + check(validator, project) + assert caught.value.code == code + assert caught.value.detail == ( + f"{said} is under contracts, which is a file, not a directory") + assert caught.value.remediation == NOT_A_DIRECTORY_REMEDY + + +def test_contracts_that_is_not_there_keeps_its_words(project): + """The control: no `contracts` at all is the leg pin that "does not + exist", under the lockstep remedy, byte for byte as it always was.""" + contracts_replaced(project, lambda _path: None) + result = validate(project) + assert result.stderr == ( + f"REFUSED pin-missing: {LEG_PIN_SAID} does not exist\n" + f"{REMEDIATION}\n"), result.stderr + assert result.returncode == 2, result.stdout + result.stderr + + # --- U179: a leg's `.git` gitfile this user cannot open ---------------------- @CANNOT_RUN_AS_ROOT @@ -407,22 +729,34 @@ def test_a_leg_gitfile_this_user_cannot_open_is_refused_by_name(project): advice; nothing is written, and the mode is put back in a `finally`.""" assert (project / "spec" / ".git").is_file(), \ "fixture drift: a recursive clone's leg holds a gitfile" + assert_the_checkout_refused(project, "spec/.git", 0, "spec/.git") + + +def assert_the_checkout_refused(project: Path, relative: str, mode: int, + named: str) -> None: + """The project's own gate, `relative` at `mode` (put back after): ONE + `pin-submodule-unreadable` naming `named`, with the leg checkout's + remedy and the permission advice, exit 2, and nothing written.""" before = tree_bytes(project) - with mode_of(project, "spec/.git", 0): + with mode_of(project, relative, mode): result = validate(project) assert result.stderr == ( - "REFUSED pin-submodule-unreadable: spec/.git: cannot be read " + f"REFUSED pin-submodule-unreadable: {named}: cannot be read " f"(Permission denied)\n{CHECKOUT_READS}{PERMISSION}{AGAIN}\n"), \ result.stderr assert result.returncode == 2, result.stdout + result.stderr assert tree_bytes(project) == before, "a validator writes nothing" -@pytest.mark.parametrize("failure, advice", [ +#: A refused `stat` or open, and the advice its refusal gives. +CAUSES = [ pytest.param(PermissionError(errno.EACCES, "Permission denied"), PERMISSION, id="permission"), pytest.param(os_error(errno.EIO), OTHER, id="io-error"), -]) +] + + +@pytest.mark.parametrize("failure, advice", CAUSES) def test_a_leg_gitfile_whose_open_is_refused_is_refused_on_every_python( validator, project, monkeypatch, failure, advice): """The same gitfile in-process, so on every platform and as root: its @@ -460,3 +794,120 @@ def test_a_git_that_is_no_regular_file_is_not_opened(validator, tmp_path, make(dotgit) failing(monkeypatch, "open", {".git": AssertionError("opened")}) assert validator._gitfile_opens(dotgit) is True + + +# --- #968: a leg's git directory this user cannot search --------------------- + +#: A directory this user cannot search: no mode bit at all, and read alone. +UNSEARCHABLE = pytest.mark.parametrize("mode", [0, stat.S_IRUSR], + ids=["000", "r--"]) + + +@CANNOT_RUN_AS_ROOT +@UNSEARCHABLE +def test_the_git_directory_a_leg_gitfile_names_unsearchable_is_refused( + project, mode): + """#968: `.git/modules/spec`, where the leg's readable gitfile says its + git directory is, at mode 000 or r--. Git said `fatal: not a git + repository`, and the run ended `pin-commit-unresolvable` under THE + LOCKSTEP RULE, which says the pinned commit is missing. It is + `pin-submodule-unreadable`, naming that directory.""" + assert_the_checkout_refused(project, ".git/modules/spec", mode, + ".git/modules/spec") + + +def leg_with_its_own_git_directory(project: Path) -> None: + """The leg's git directory moved from `.git/modules/spec` into the leg as + `spec/.git`, where a checkout git has not absorbed keeps it, and its + `worktree` line dropped, since the leg is that directory's own work tree + now. The file is edited as text: no `git config` is run.""" + dotgit = project / "spec" / ".git" + dotgit.unlink() + (project / ".git" / "modules" / "spec").rename(dotgit) + config = dotgit / "config" + lines = config.read_bytes().split(LF.encode("ascii")) + config.write_bytes(LF.encode("ascii").join( + line for line in lines if not line.strip().startswith(b"worktree"))) + + +@CANNOT_RUN_AS_ROOT +@UNSEARCHABLE +def test_a_leg_git_directory_this_user_cannot_search_is_refused(project, + mode): + """#968: `spec/.git` a directory, the leg's own, at mode 000 or r--. Git + passed over it and walked up to the root's own repository, so the run + ended `pin-commit-unresolvable` with an empty reason, under THE LOCKSTEP + RULE. It is `pin-submodule-unreadable`, naming `spec/.git`. The control + first: searchable, the same leg passes.""" + leg_with_its_own_git_directory(project) + passed = validate(project) + assert passed.returncode == 0, passed.stdout + passed.stderr + assert_the_checkout_refused(project, "spec/.git", mode, "spec/.git") + + +@pytest.mark.parametrize("failure, advice", CAUSES) +def test_a_leg_git_directory_whose_stat_is_refused_is_refused_everywhere( + validator, project, monkeypatch, failure, advice): + """The same in-process, so on every platform and as root: the `stat` of + the `HEAD` in the git directory the gitfile names, refused, each cause + with its own advice.""" + failing(monkeypatch, "stat", {"HEAD": failure}) + refusal = refusal_of_the_run(validator, project) + assert refusal.code == "pin-submodule-unreadable" + assert refusal.detail == ( + f".git/modules/spec: cannot be read ({failure.strerror})") + assert refusal.remediation == CHECKOUT_READS + advice + AGAIN + + +def test_a_leg_git_directory_with_no_head_is_left_to_git(validator, project, + monkeypatch): + """A `HEAD` that is not there is no refusal of this check's: git answers + for that git directory, as it always did, and here git reads it, so the + run finds nothing.""" + failing(monkeypatch, "stat", {"HEAD": os_error(errno.ENOENT)}) + report = validator.Report() + validator._run_all_checks(project, report, []) + assert report.findings == [] + + +@pytest.mark.parametrize("written, named", [ + pytest.param(b"gitdir: ../.git/modules/spec" + LF.encode("ascii"), + "../.git/modules/spec", id="relative"), + pytest.param(b"gitdir: ../m" + (CR + LF).encode("ascii"), "../m", + id="cr-lf"), + pytest.param(b"gitdir: ../m", "../m", id="no-line-end"), + pytest.param(b"gitdir: ../m" + (LF + LF).encode("ascii"), "../m", + id="blank-line-after"), +]) +def test_a_gitfile_names_its_git_directory_as_git_reads_it( + validator, tmp_path, written, named): + """The `gitdir: ` line, its line ends stripped as git strips them, joined + to the gitfile's own directory, its `..` left for the system to follow + as it follows git's.""" + dotgit = tmp_path / "leg" / ".git" + dotgit.parent.mkdir() + dotgit.write_bytes(written) + assert validator._leg_git_dir(dotgit) == dotgit.parent / named + + +def test_a_gitfile_may_name_its_git_directory_whole(validator, tmp_path): + """An absolute `gitdir: ` is that directory, wherever the gitfile is.""" + dotgit = tmp_path / "leg" / ".git" + dotgit.parent.mkdir() + elsewhere = tmp_path / "elsewhere" + dotgit.write_bytes(b"gitdir: " + os.fsencode(str(elsewhere))) + assert validator._leg_git_dir(dotgit) == elsewhere + + +def test_a_git_directory_is_its_own_and_a_gitfile_naming_none_is_gits( + validator, tmp_path): + """A `.git` that is a directory is the leg's git directory; a gitfile + with no `gitdir: ` line names none, and git refuses it as it always + did.""" + leg = tmp_path / "leg" + (leg / ".git").mkdir(parents=True) + assert validator._leg_git_dir(leg / ".git") == leg / ".git" + other = tmp_path / "other" / ".git" + other.parent.mkdir() + other.write_bytes(b"not a gitfile" + LF.encode("ascii")) + assert validator._leg_git_dir(other) is None