From 8a7671b3f35f62c4943145bac915529f9d7d40fa Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 15:53:29 +0000 Subject: [PATCH] validate-pins.py and repo_shape.py: `--root` and gh's stderr are spelled, what stands in a pin's, the manifest's or a shape copy's place is named with a remedy that fits it, and a leg's `.git` gitfile this user cannot open is refused by name, so no ESC or line break reaches the terminal raw, a directory is no longer "does not exist", and a gitfile at mode 000 no longer ends in `pin-commit-unresolvable` (#919) U176. `find_repo_root` names `start` with `shown_path`: as it is, unless a character in it is not printable, and then `repr`'d. `--root '/x[31mRED'` outside any repository put the ESC on stderr from every tool that asks it. Only the message changed; the `.git` guard is #430's. U177. `tree_digest_from_gh`'s `gh-unreadable` detail spells what gh printed with `spelled`, as ONE line, and names the pin's repository with `shown_path`, as `git_out` spells its own two parts (#610). `validate-pins.py` prints the detail in its `skip` line, where an ESC was the control it is and each line break began a line. U178. A directory, a FIFO, a socket, a device, a symlink to nothing, a symlink loop or a symlink to a directory in the place of a leg pin, the shape pin, a neutral-product pin or `project.yaml` was refused under "does not exist" and the lockstep remedy; in a shape copy's place it was "pinned but absent". `_not_a_file` asks `lstat` what stands there, and `_no_file` keeps each code and the words before "does not exist", names it (`is a directory, not a file`), and gives `_remedy`'s first sentence with `NOT_A_FILE`: move it aside, or take it away, and restore the file. The copy's finding keeps `: is pinned but` and gets a line under it. Nothing at the name keeps today's words byte for byte, and a site with no remedy of its own still builds its refusal with none, so a root beside a `repo_shape.py` older than #370 prints what it printed. U179. A leg's `.git` that is a regular file is opened (`_gitfile_opens`), its errors read by `_is_a`'s own errno set: nothing there is an uninitialized leg, and a refused open is `pin-submodule-unreadable` through `_unreadable_file`, as a refused `stat` already was. A directory or a FIFO is not opened. Both files are pinned copies; `validate-pins.py` imports nothing new from `repo_shape`. `tests/test_validate_pins_spelled_and_named.py` holds the four parts, and `tests/test_doctor_validator_rows.py`'s two-line gh test says the skip embedded gh's stderr verbatim before #919. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- scripts/repo_shape.py | 30 +- .../assembly-root/scripts/validate-pins.py | 188 +++++-- tests/test_doctor_validator_rows.py | 14 +- tests/test_validate_pins_spelled_and_named.py | 462 ++++++++++++++++++ 4 files changed, 657 insertions(+), 37 deletions(-) create mode 100644 tests/test_validate_pins_spelled_and_named.py diff --git a/scripts/repo_shape.py b/scripts/repo_shape.py index 66b1b592..5e72d358 100644 --- a/scripts/repo_shape.py +++ b/scripts/repo_shape.py @@ -1265,6 +1265,19 @@ def tree_digest_from_gh(repository: str, commit: str) -> str: also fails, always catches this rather than letting it reach a user directly: an unanswerable neutral-product pin is a gap in the check, not a reason to fail a project that only lacks a `gh` login. + + `gh-unreadable`'S TWO UNTRUSTED PARTS ARE SPELLED (#919), as `git_out` + spells its own (#610), because a root's `validate-pins.py` prints the + detail inside its `skip` line. The repository is the pin's + `source_repository`, named with `shown_path`: as it is, unless a + character in it is not printable. What `gh` printed is `spelled`, as ONE + line: it was put in raw, so an ESC in it reached the terminal as the + 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. """ try: proc = subprocess.run( @@ -1279,8 +1292,8 @@ def tree_digest_from_gh(repository: str, commit: str) -> str: if proc.returncode != 0: raise Refusal( "gh-unreadable", - f"`gh api` could not read {repository} @ {commit}: " - f"{proc.stderr.strip()}", + f"`gh api` could not read {shown_path(repository)} @ {commit}: " + f"{spelled(proc.stderr.strip())}", ) try: payload = json.loads(proc.stdout) @@ -3296,11 +3309,22 @@ def link_pins_from_trees(links, root: Path | None = None, def find_repo_root(start: Path) -> Path: + """The nearest directory at or above `start` that holds a `.git`, or + `not-a-git-repo` naming `start`. + + `start` IS NAMED AS `shown_path` NAMES A PATH (#919): as it is, unless a + character in it is not printable, and then as Python's `repr` spells + it. It is a `--root` as a person typed it, and printed raw, an ESC in it + reached the terminal as the control it is from every tool that asks + this, and a line break began a line of its choosing. A printable path + is named byte for byte as it always was. + """ path = Path(start).resolve() for candidate in [path, *path.parents]: if (candidate / ".git").exists(): return candidate - raise Refusal("not-a-git-repo", f"no .git found at or above {start}") + raise Refusal("not-a-git-repo", + f"no .git found at or above {shown_path(start)}") def die(exc: Refusal, stream=sys.stderr) -> int: diff --git a/templates/assembly-root/scripts/validate-pins.py b/templates/assembly-root/scripts/validate-pins.py index 65607ce8..e9419d41 100755 --- a/templates/assembly-root/scripts/validate-pins.py +++ b/templates/assembly-root/scripts/validate-pins.py @@ -318,6 +318,15 @@ def _cannot_read(named: str, exc: OSError) -> str: "project.yaml and every copy the shape pin lists") +def _remedy(advice: str, reads: str = FILES_READ) -> str: + """The remedy of a refusal of a file this validator must read and + cannot (#712, #919): what it reads (`reads`), that one it cannot read is + never a pass, `advice` for the cause, and `RUN_AGAIN`.""" + return (f"Remediation: this validator reads {reads}, and one it cannot " + "read is a question it cannot answer, never a pass. " + advice + + RUN_AGAIN) + + def _unreadable_file(code: str, named: str, exc: OSError, reads: str = FILES_READ) -> Refusal: """The refusal for a pin, the manifest or a pinned shape copy that this @@ -342,10 +351,7 @@ def _unreadable_file(code: str, named: str, exc: OSError, "on a file, `chmod u+rx` on a directory (and on every directory above " "it inside the root), or fix who owns it. " if isinstance(exc, PermissionError) else OTHER_REASON) - return Refusal( - code, _cannot_read(named, exc), - f"Remediation: this validator reads {reads}, and one it cannot read " - "is a question it cannot answer, never a pass. " + advice + RUN_AGAIN) + return Refusal(code, _cannot_read(named, exc), _remedy(advice, reads)) def _is_file(root: Path, path: Path, code: str = "yaml-unreadable", @@ -360,7 +366,8 @@ def _is_file(root: Path, path: Path, code: str = "yaml-unreadable", `PermissionError` traceback, exit 1; on 3.14 it answered False, and the run refused `pin-missing`, a present file said not to exist, under the lockstep remedy. A name the operating system cannot take at all (a NUL - in a role or a product) is no file, as `is_file()` answered it.""" + in a role or a product) is no file, as `is_file()` answered it. Where + this is False, each caller asks `_not_a_file` what stands there (#919).""" try: return _is_a(path, stat.S_ISREG) except OSError as exc: @@ -387,6 +394,116 @@ def _is_there(path: Path) -> bool: return False +def _gitfile_opens(path: Path) -> bool: + """Whether a leg's `.git` that is a regular file, a gitfile, can be + opened (#919), asked as `_is_a` asks a `stat`: False where nothing is + there to open (`_ABSENT_ERRNOS`, `_ABSENT_WINERRORS`), the `OSError` of + an open the operating system REFUSED raised, and True for a `.git` that + is no regular file, a checkout's own directory, which git reads itself. + + A GITFILE AT MODE 000 STATS, so `_is_there` read the leg as a checkout: + git could not read where the leg's objects are, and the run ended in + `pin-commit-unresolvable` under THE LOCKSTEP RULE, which says the + pinned commit is missing (RV827's N6). Only a regular file is opened, + since the open of a FIFO waits for a writer.""" + if not _is_a(path, stat.S_ISREG): + return True + try: + path.open("rb").close() + except OSError as exc: + if (exc.errno in _ABSENT_ERRNOS + or getattr(exc, "winerror", None) in _ABSENT_WINERRORS): + return False + raise + 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): + 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" + + +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. + + 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. + """ + try: + mode = path.lstat().st_mode + except (OSError, ValueError): + 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") + + +#: What a pin, the shape pin, a neutral-product pin or the manifest is told +#: where something that is not a file stands in its place (#919), after +#: `_remedy`'s first sentence. It is not the file moved or edited, so THE +#: LOCKSTEP RULE, `git submodule update` and a re-scaffold do not put it +#: right, and it may hold something nobody committed, so it is moved aside. +NOT_A_FILE = ( + "Something that is not a file stands in its place: move it aside, or " + "take it away if nobody wants it, and restore the file from the " + "project's history. ") + +#: The line under a `shape-copy-missing` finding for a copy whose place +#: something that is not a file takes (#919), as `shape-copy-drift` has one. +COPY_NOT_A_FILE = ( + "A shape file's place is taken by something that is not a file. Move it " + "aside, or take it away if nobody wants it, and restore the copy from " + "the project's history.") + + +def _no_file(code: str, 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 + `absent`, and `remediation` is the refusal's own, if it has one: the + 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. + + 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)) + + def _load(root: Path, path: Path): """`load_yaml(path)`, with every refusal it raises naming the file as `_named` does (#712). @@ -518,7 +635,9 @@ def _load_leg_pin(root: Path, role, path: str, repository: str, A pin that cannot be reached or read is refused by name (#712): see `_is_file` and `_load`. Every name is spelled, the leg's role and - repository in the lines below as the pin's path is. + repository in the lines below as the pin's path is. Something that is + not a file in the pin's place is `pin-missing` naming what it is, with + a remedy of its own (#919): see `_no_file`. """ pin_path = root / "contracts" / f"{role}-pin.yaml" # POSIX, on every platform: a finding names a path in THIS repository, @@ -526,11 +645,10 @@ def _load_leg_pin(root: Path, role, path: str, repository: str, # a path the reader cannot paste back into git. rel = _named(root, pin_path) if not _is_file(root, pin_path): - raise Refusal( - "pin-missing", + raise _no_file( + "pin-missing", pin_path, f"leg {role!r} declares {_shown(repository)} at {path!r} but " - f"{rel} does not exist", - ) + f"{rel}", " does not exist") pin = _load(root, pin_path) if not isinstance(pin, dict): raise Refusal("pin-unreadable", f"{rel}: not a mapping") @@ -1008,15 +1126,17 @@ def _check_pin_digest(root: Path, path: str, commit: str, pin: dict, rel: str, A CHECKOUT THIS USER CANNOT SEARCH IS REFUSED BY NAME (#712, after 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. + Python 3.14's `exists()` would have said of it. So is a `.git` gitfile + this user cannot open (#919): see `_gitfile_opens`. """ submodule = root / path named = _shown(path) + dotgit = submodule / ".git" try: - initialized = _is_there(submodule / ".git") + initialized = _is_there(dotgit) and _gitfile_opens(dotgit) except OSError as exc: raise _unreadable_file( - "pin-submodule-unreadable", _named(root, submodule / ".git"), exc, + "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 if not initialized: @@ -1128,12 +1248,13 @@ def _check_shape_pin_header(root: Path, manifest: dict, report: Report Named relative to the root in every line, the two refusals included, which named it absolute, and refused by name where it cannot be reached or read (#712). The two commits it compares are spelled: neither is - asked to be 40 hex before the finding that names both. + asked to be 40 hex before the finding that names both. Something that + is not a file in its place is named, with a remedy of its own (#919). """ pin_path = root / "contracts" / "shape-pin.yaml" rel = _named(root, pin_path) if not _is_file(root, pin_path): - raise Refusal("shape-pin-missing", f"{rel} does not exist") + raise _no_file("shape-pin-missing", 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") @@ -1161,6 +1282,13 @@ def _check_shape_copy(root: Path, row, rel: str, report: Report) -> None: is absent, empty or no string is `shape-pin-row`, as `update-shape.py` refuses it `update-pin-row`: its `path:` was read as a path, so a row with none was `None is pinned but absent` (#681). + + SOMETHING THAT IS NOT A FILE IN THE COPY'S PLACE IS NAMED (#919): `: + is pinned but is a directory, not a file` (`_not_a_file`), with + a line under it saying what to do, where it was `pinned but absent`. A + copy that is not there keeps those words byte for byte: `shape-doctor.py` + reads them to find a merged-away `shape/` copy, which only a path with + nothing at it can be (`update-shape.py`'s `merged_into`). """ if not isinstance(row, dict): report.finding("shape-pin-row", f"{rel}: a files row is not a mapping") @@ -1179,8 +1307,10 @@ def _check_shape_copy(root: Path, row, rel: str, report: Report) -> None: # spelled, as the pin spells them. named = _shown(path) if not _is_file(root, target, "shape-copy-unreadable", named): - report.finding("shape-copy-missing", - f"{rel}: {named} is pinned but absent") + what = _not_a_file(target) + report.finding("shape-copy-missing", f"{rel}: {named} is pinned but " + + ("absent" if what is None + else f"{what}\n {COPY_NOT_A_FILE}")) return try: actual = file_sha256(target) @@ -1446,7 +1576,8 @@ def _check_neutral_pin_entry_and_load(root: Path, product, report: Report built from it was not, so `["\\x1b[31mRED"]` put an ESC on stderr inside `contracts/[31mred-pin.yaml`, and in the remedy's `--pin`. The pin is named relative to the root in every line, and refused by name where - it cannot be reached or read. + it cannot be reached or read. Something that is not a file in its place + is named, with a remedy of its own (#919): see `_no_file`. """ if not isinstance(product, str) or not product: report.finding( @@ -1458,10 +1589,10 @@ def _check_neutral_pin_entry_and_load(root: Path, product, report: Report pin_path = root / "contracts" / f"{product.lower()}-pin.yaml" rel = _named(root, pin_path) if not _is_file(root, pin_path): - raise Refusal( - "neutral-pin-missing", - f"project.yaml declares a pin on {product!r} but {rel} does not " - "exist", + raise _no_file( + "neutral-pin-missing", pin_path, + f"project.yaml declares a pin on {product!r} but {rel}", + " does not exist", "Remediation: `scaffold-project.py --pin " f"{_shown(product)}@` writes that file. A declaration in " "project.yaml with no pin file beside it is a claim with no " @@ -1765,6 +1896,8 @@ def _load_legs_to_check(root: Path, report: Report `project.yaml` is named relative to the root, as every file this validator names is, where three refusals named its absolute path raw, and it is refused by name where it cannot be reached or read (#712). + Something that is not a file in its place is `manifest-missing` naming + what it is, with a remedy of its own (#919): see `_no_file`. A `legs:` THAT IS NOT A LIST IS A FINDING, NOT THAT REFUSAL (#714), in `validate-manifest.py`'s words for it (`manifest-legs`), and no leg is @@ -1785,12 +1918,11 @@ def _load_legs_to_check(root: Path, report: Report """ manifest_path = root / "project.yaml" if not _is_file(root, manifest_path): - raise Refusal( - "manifest-missing", - "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.", - ) + raise _no_file( + "manifest-missing", 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.") manifest = _load(root, manifest_path) if not isinstance(manifest, dict): raise Refusal("manifest-unreadable", "project.yaml: not a mapping") diff --git a/tests/test_doctor_validator_rows.py b/tests/test_doctor_validator_rows.py index 31ca4a63..82d9dde2 100644 --- a/tests/test_doctor_validator_rows.py +++ b/tests/test_doctor_validator_rows.py @@ -509,12 +509,14 @@ def test_only_the_machine_rows_gh_keeps_the_login_and_the_proxies( @WINDOWS_SKIP def test_a_two_line_gh_stderr_in_a_skip_keeps_the_ways_to_give_a_source( descendant_run, standard, tmp_path): - """`validate-pins.py`'s skip embeds `gh`'s stderr verbatim, so a `gh` whose - help is two lines splits the skip in two: the row's reason used to stop at - the first, which told the reader to `gh auth login` -- a login the - doctor's own offline environment would then undo -- and dropped the - `--pin-source`, `SHAPE_PIN_SOURCE_` and `../` ways to - give the product a source, which are why the row has no NEXT. + """`validate-pins.py`'s skip embedded `gh`'s stderr verbatim, as a root + whose pinned copies predate #919 still does, so a `gh` whose help is two + lines split the skip in two: the row's reason used to stop at the first, + which told the reader to `gh auth login` -- a login the doctor's own + offline environment would then undo -- and dropped the `--pin-source`, + `SHAPE_PIN_SOURCE_` and `../` ways to give the product + a source, which are why the row has no NEXT. Since #919 the skip is one + line, the break in gh's help an escape, and the row keeps the same ways. The same descendant, the same clone, with a stub that prints that help for every call and exits 4. The summary line the validator ends on is not diff --git a/tests/test_validate_pins_spelled_and_named.py b/tests/test_validate_pins_spelled_and_named.py new file mode 100644 index 00000000..314122d8 --- /dev/null +++ b/tests/test_validate_pins_spelled_and_named.py @@ -0,0 +1,462 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`validate-pins.py` and `repo_shape.py` spell `--root` and gh's stderr, +name what stands where a pin, the shape pin, a neutral-product pin, the +manifest or a shape copy should be, and refuse by name a leg's `.git` +gitfile that this user cannot open (#919). + +The issue's four parts, from lane 3's triage of #459 at the v1.1.1 release +commit `5017830`: + + * U176. `find_repo_root` refused `not-a-git-repo` naming `--root` raw, so + an ESC in it reached the terminal, from every tool that asks it. The + path is `shown_path`'s spelling now. + * U177. `tree_digest_from_gh` put gh's stderr into `gh-unreadable`'s + detail raw, and `validate-pins.py` prints that detail in its `skip` + line: an ESC was the control it is, and each line break began a line. + gh's text is `spelled`, as ONE line, as `git_out` spells git's (#610), + and the pin's repository in the same detail is `shown_path`'s. + * U178. A directory, a FIFO, a symlink to nothing or a symlink loop in a + pin's place was refused under "does not exist" and the lockstep remedy, + and in a shape copy's place was "pinned but absent". Each site names + what is there, with a remedy that fits it. The code and the line's form + stay, and a file that is not there keeps its words byte for byte. + * U179. A leg's `.git` gitfile at mode 000 stats, so the leg read as a + checkout and the run ended in `pin-commit-unresolvable` under THE + LOCKSTEP RULE. It is `pin-submodule-unreadable`, by name, as #827 + refuses a leg checkout this user cannot search. + +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 +row skips on Windows; a FIFO needs `os.mkfifo`, and a symlink one Windows +makes only with a privilege, so those rows skip there too; a mode row skips +on Windows and as root. Every in-process row runs everywhere. + +Every control character here is built with `chr`, never typed, and so is +the backslash of each escape the gate prints. +""" + +from __future__ import annotations + +import errno +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from conftest import REPO, WINDOWS_SKIP, run_script +from test_validate_pins_named_and_spelled import ( + AGAIN, CHECKOUT_READS, COMMIT, OTHER, PERMISSION, READS, SHAPE_PIN, + UNPRINTABLE, declare_openxdox, failing, os_error, refusal_of_the_run, +) +from test_validate_pins_unreadable_workflow import CANNOT_RUN_AS_ROOT, validate +from test_validate_pins_unreadable_workflows_dir import mode_of, tree_bytes +# A fixture is used by being named in this module's namespace, which is what +# this import is for: the project's own `validate-pins.py`, loaded as a module. +from test_validate_pins_without_git import validator # noqa: F401 + +sys.path.insert(0, str(REPO / "scripts")) +from repo_shape import REMEDIATION # noqa: E402 + +#: An ESC, a carriage return, a line feed, and the backslash an escape +#: starts with. +ESC, CR, LF, BS = chr(27), chr(13), chr(10), chr(92) + +#: The neutral-product pin `declare_openxdox` declares. +NEUTRAL_PIN = "contracts/openxdox-pin.yaml" + + +# --- U176: `--root` outside any repository ----------------------------------- + +def outside_any_repository(tmp_path: Path) -> Path: + """`tmp_path`, with no `.git` at it or above it, so `find_repo_root` + walks to the top and refuses; a skip on a machine whose temporary + directory is inside a repository.""" + for here in (tmp_path, *tmp_path.resolve().parents): + if (here / ".git").exists(): + pytest.skip(f"{here} holds a .git, so nothing below it is outside " + "a repository") + return tmp_path + + +def refused_outside(project: Path, where: Path) -> subprocess.CompletedProcess: + """The project's own gate, `--root` at `where`.""" + return run_script(project / "scripts" / "validate-pins.py", "--root", + str(where), cwd=project) + + +def test_a_root_outside_any_repository_is_named_spelled(project, tmp_path): + """U176, THE ISSUE'S PROBE: `--root '/x[31mRED'` outside any + repository. The path is named as `shown_path` names one that is not + printable, `repr`'d, so the ESC is its escape and no control reaches the + terminal. It is still `not-a-git-repo`, exit 2, under the lockstep + remedy the gate gives a refusal that names none of its own.""" + where = outside_any_repository(tmp_path) / ("x" + ESC + "[31mRED") + result = refused_outside(project, where) + line = f"REFUSED not-a-git-repo: no .git found at or above {str(where)!r}" + assert result.stderr == f"{line}\n{REMEDIATION}\n", result.stderr + assert result.stderr.splitlines()[0].endswith( + "x" + BS + "x1b[31mRED'"), result.stderr + assert ESC not in result.stderr, result.stderr + assert result.returncode == 2, result.stdout + result.stderr + + +def test_a_printable_root_outside_any_repository_keeps_its_words(project, + tmp_path): + """The control: a printable `--root` is named byte for byte as it + always was.""" + where = outside_any_repository(tmp_path) / "plain" + result = refused_outside(project, where) + assert result.stderr == ( + f"REFUSED not-a-git-repo: no .git found at or above {where}\n" + f"{REMEDIATION}\n"), result.stderr + assert result.returncode == 2, result.stdout + result.stderr + + +@pytest.mark.parametrize("name", [ + param for param in UNPRINTABLE if param.id != "undecodable-byte"]) +def test_every_tool_that_asks_for_the_root_names_it_spelled(validator, + tmp_path, name): + """The library's own refusal, for every tool that calls `find_repo_root`: + a newline, a bidi control, a C1 control and an ESC each print as `repr` + spells them, and none raw. (A byte that is not UTF-8 is not a name every + platform's paths can hold.)""" + where = outside_any_repository(tmp_path) / name + with pytest.raises(validator.Refusal) as caught: + validator.find_repo_root(where) + assert caught.value.code == "not-a-git-repo" + assert caught.value.detail == ( + f"no .git found at or above {str(where)!r}") + assert name not in caught.value.detail, caught.value.detail + + +# --- U177: gh's stderr in the `skip` line ------------------------------------ + +def declared_with_a_pin(project: Path) -> None: + """`project.yaml` declares `openXdox`, and its pin's fields are well + formed, so the gate asks `gh api` for the digest: no `--pin-source`, no + variable (`run_script` blanks every one) and no `../openXdox`.""" + declare_openxdox(project) + (project / NEUTRAL_PIN).write_bytes(( + "kind: pinned_contract_manifest\nrevision_kind: commit\n" + f"commit: {COMMIT}\nsource_repository: testorg/openXdox\n" + f"digests:\n tree_sha256: {'a' * 64}\n" + "digest_definition: sorted-ls-tree-r-v1\n").encode("utf-8")) + + +def gh_saying(where: Path, said: str, 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.""" + bin_dir = where / "gh-bin" + bin_dir.mkdir() + gh = bin_dir / "gh" + gh.write_bytes(b"#!/bin/sh\nprintf '%s' '" + said.encode("utf-8") + + b"' >&2\nexit " + str(status).encode("ascii") + b"\n") + gh.chmod(0o755) + return {"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}"} + + +@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.""" + 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 + + +def gh_answering(monkeypatch, status: int, stderr: str) -> None: + """`subprocess.run` answering as a `gh` that exits `status` and prints + `stderr`, in-process, so a row runs on every platform.""" + def gh(argv, **_kwargs): + return subprocess.CompletedProcess(argv, status, "", stderr) + + monkeypatch.setattr(subprocess, "run", gh) + + +#: gh's stderr, and how `gh-unreadable` names it. +GH_SAID = [ + pytest.param("HTTP 404: Not Found" + LF, "HTTP 404: Not Found", + id="one-printable-line"), + pytest.param("To get started with GitHub CLI, please run: gh auth login" + + LF + "Alternatively, populate the GH_TOKEN variable." + LF, + "To get started with GitHub CLI, please run: gh auth login" + + BS + "nAlternatively, populate the GH_TOKEN variable.", + id="the-not-logged-in-help"), + pytest.param("no" + ESC + "[2Jt" + chr(0x85) + "x" + chr(0x202e) + "y", + "no" + BS + "x1b[2Jt" + BS + "x85x" + BS + "u202ey", + id="controls"), + pytest.param("caf" + chr(0xe9) + " " + chr(0x65e5) + chr(0x672c), + "caf" + chr(0xe9) + " " + chr(0x65e5) + chr(0x672c), + id="utf-8-unchanged"), +] + + +@pytest.mark.parametrize("said, named", GH_SAID) +def test_gh_unreadable_spells_what_gh_printed_as_one_line( + validator, monkeypatch, said, named): + """The detail itself, on every platform: what gh printed is `spelled`, + as `git_out` spells git's stderr (#610), so a control or a bidi control + is its escape and a line break between two lines of gh's help is one + too, while printable text, UTF-8 included, is as it was.""" + gh_answering(monkeypatch, 1, said) + with pytest.raises(validator.Refusal) as caught: + validator.tree_digest_from_gh("testorg/openXdox", COMMIT) + assert caught.value.code == "gh-unreadable" + assert caught.value.detail == ( + f"`gh api` could not read testorg/openXdox @ {COMMIT}: {named}") + + +@pytest.mark.parametrize("repository", [ + param for param in UNPRINTABLE if param.id != "undecodable-byte"]) +def test_gh_unreadable_names_the_repository_spelled(validator, monkeypatch, + repository): + """The pin's `source_repository`, which the skip line also names, is + `shown_path`'s in the same detail: `repr`'d where a character in it is + not printable, as `git_out` names its directory.""" + gh_answering(monkeypatch, 1, "HTTP 404: Not Found" + LF) + with pytest.raises(validator.Refusal) as caught: + validator.tree_digest_from_gh(repository, COMMIT) + assert caught.value.detail == ( + f"`gh api` could not read {repository!r} @ {COMMIT}: " + "HTTP 404: Not Found") + + +# --- U178: what stands where a file should be -------------------------------- + +def made_directory(path: Path) -> None: + path.mkdir() + + +def made_fifo(path: Path) -> None: + os.mkfifo(path) + + +def made_link_to_nothing(path: Path) -> None: + os.symlink("nothing-here-w919", path) + + +def made_loop(path: Path) -> None: + os.symlink(path.name, path) + + +def made_link_to_a_directory(path: Path) -> None: + (path.parent / "directory-w919").mkdir() + os.symlink("directory-w919", path) + + +POSIX_NODES = pytest.mark.skipif( + os.name == "nt", + reason="a FIFO needs os.mkfifo, 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 = [ + pytest.param(made_directory, "is a directory, not a file", + id="directory"), + pytest.param(made_fifo, "is a FIFO, not a file", id="fifo", + 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", + id="symlink-loop", marks=POSIX_NODES), + pytest.param(made_link_to_a_directory, + "is a symlink to a directory, not a file", + id="symlink-to-a-directory", marks=POSIX_NODES), +] + +#: 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(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 " + f"{NEUTRAL_PIN}", id="neutral-pin"), + pytest.param("project.yaml", "manifest-missing", "project.yaml", + id="manifest"), +] + +#: The remedy of each of those, where something that is not a file stands +#: in the file's place: no lockstep move puts it right. +NOT_A_FILE_REMEDY = ( + READS + "Something that is not a file stands in its place: move it " + "aside, or take it away if nobody wants it, and restore the file from the " + "project's history. " + AGAIN) + +#: The line under a shape copy's finding, in the same case. +COPY_REMEDY = ( + "A shape file's place is taken by something that is not a file. Move it " + "aside, or take it away if nobody wants it, and restore the copy from the " + "project's history.") + + +def in_its_place(project: Path, relative: str, make) -> None: + """`relative` taken away, and `make` putting what it makes at its name. + For the neutral-product pin, `project.yaml` declares `openXdox` first, + whose pin file is not there.""" + if relative == NEUTRAL_PIN: + declare_openxdox(project) + (project / relative).unlink(missing_ok=True) + make(project / relative) + + +@pytest.mark.parametrize("make, what", NOT_FILES) +@pytest.mark.parametrize("relative, code, said", REFUSALS) +def test_what_stands_in_a_files_place_is_named_with_a_remedy_that_fits( + project, relative, code, said, make, what): + """U178, the refusals: each was "does not exist" under the lockstep + remedy, which moves a pin and runs `git submodule update`, and neither + takes a directory or a FIFO away. The code and the words before stay; + what stands there is named, and the remedy says to move it aside and + restore the file. The whole of stderr, exit 2, and nothing on stdout.""" + in_its_place(project, relative, make) + result = validate(project) + assert result.stderr == ( + f"REFUSED {code}: {said} {what}\n{NOT_A_FILE_REMEDY}\n"), \ + result.stderr + assert result.returncode == 2, result.stdout + result.stderr + assert result.stdout == "", result.stdout + + +@pytest.mark.parametrize("make, what", NOT_FILES) +def test_what_stands_in_a_shape_copys_place_is_named(project, make, what): + """U178, the finding: a shape copy so replaced was "pinned but absent". + The code and `: is pinned but` stay, what stands there is + named, and a line under it says what to do, as `shape-copy-drift` has + one. Still a finding the lockstep rule is not about: the count alone.""" + in_its_place(project, "Makefile", make) + result = validate(project) + assert result.stderr == ( + f"FINDING shape-copy-missing: {SHAPE_PIN}: Makefile is pinned but " + f"{what}\n {COPY_REMEDY}\n\n1 finding(s).\n"), result.stderr + assert result.returncode == 1, result.stdout + result.stderr + + +#: A file that is not there, the whole of stderr, and the exit: the words +#: 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"{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"), + pytest.param(NEUTRAL_PIN, + "REFUSED neutral-pin-missing: project.yaml declares a pin on " + f"'openXdox' but {NEUTRAL_PIN} does not exist\n" + "Remediation: `scaffold-project.py --pin openXdox@` " + "writes that file. A declaration in project.yaml with no pin " + "file beside it is a claim with no referent — either restore " + "the file or remove the declaration.\n", 2, id="neutral-pin"), + pytest.param("project.yaml", + "REFUSED manifest-missing: 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 " + f"validator.\n{REMEDIATION}\n", 2, id="manifest"), + pytest.param("Makefile", + f"FINDING shape-copy-missing: {SHAPE_PIN}: Makefile is " + "pinned but absent\n\n1 finding(s).\n", 1, id="shape-copy"), +] + + +@pytest.mark.parametrize("relative, stderr, code", ABSENT) +def test_a_file_that_is_not_there_keeps_its_words_byte_for_byte( + project, relative, stderr, code): + """The control: nothing at the name is what each site always said, the + lockstep remedy included where it was given. `shape-doctor.py` reads a + copy's `is pinned but absent` to find a merged-away `shape/` copy.""" + in_its_place(project, relative, lambda _path: None) + result = validate(project) + assert result.stderr == stderr, result.stderr + assert result.returncode == code, result.stdout + result.stderr + + +# --- U179: a leg's `.git` gitfile this user cannot open ---------------------- + +@CANNOT_RUN_AS_ROOT +def test_a_leg_gitfile_this_user_cannot_open_is_refused_by_name(project): + """U179, RV827'S N6: `spec/.git`, the gitfile a recursive clone gives a + leg, at mode 000. It stats, so the leg read as a checkout and the run + ended in `pin-commit-unresolvable` under THE LOCKSTEP RULE. It is + `pin-submodule-unreadable`, naming the gitfile, with the permission + 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" + before = tree_bytes(project) + with mode_of(project, "spec/.git", 0): + result = validate(project) + assert result.stderr == ( + "REFUSED pin-submodule-unreadable: spec/.git: 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", [ + pytest.param(PermissionError(errno.EACCES, "Permission denied"), + PERMISSION, id="permission"), + pytest.param(os_error(errno.EIO), OTHER, id="io-error"), +]) +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 + open refused, each cause with its own advice.""" + failing(monkeypatch, "open", {".git": failure}) + refusal = refusal_of_the_run(validator, project) + assert refusal.code == "pin-submodule-unreadable" + assert refusal.detail == f"spec/.git: cannot be read ({failure.strerror})" + assert refusal.remediation == CHECKOUT_READS + advice + AGAIN + + +def test_a_gitfile_gone_when_it_is_opened_is_an_uninitialized_leg( + validator, project, monkeypatch): + """Asked as `_is_a` asks a `stat`: an open that finds nothing at the + name is the leg with no `.git`, `pin-submodule-uninitialized`, and not a + file this user cannot read.""" + failing(monkeypatch, "open", {".git": os_error(errno.ENOENT)}) + refusal = refusal_of_the_run(validator, project) + assert refusal.code == "pin-submodule-uninitialized" + assert refusal.detail.startswith( + "spec is not an initialized submodule checkout"), refusal.detail + + +@pytest.mark.parametrize("make", [ + pytest.param(made_directory, id="directory"), + pytest.param(made_fifo, id="fifo", marks=POSIX_NODES), +]) +def test_a_git_that_is_no_regular_file_is_not_opened(validator, tmp_path, + monkeypatch, make): + """Only a gitfile is opened: a checkout's own `.git` directory is git's + to read, and the open of a FIFO would wait for a writer. An open of + either fails the row rather than hanging it.""" + dotgit = tmp_path / "leg" / ".git" + dotgit.parent.mkdir() + make(dotgit) + failing(monkeypatch, "open", {".git": AssertionError("opened")}) + assert validator._gitfile_opens(dotgit) is True