diff --git a/templates/assembly-root/scripts/validate-pins.py b/templates/assembly-root/scripts/validate-pins.py index e50d6bef..04c3f81b 100755 --- a/templates/assembly-root/scripts/validate-pins.py +++ b/templates/assembly-root/scripts/validate-pins.py @@ -405,7 +405,10 @@ def _gitfile_opens(path: Path) -> bool: 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.""" + since the open of a FIFO waits for a writer. + + A LEG'S GIT DIRECTORY'S `HEAD` IS ASKED THE SAME WAY, for the same + reason (#1222): see `_refuse_an_unreadable_git_dir`.""" if not _is_a(path, stat.S_ISREG): return True try: @@ -454,13 +457,25 @@ def _refuse_an_unreadable_git_dir(root: Path, dotgit: Path) -> None: 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.""" + is left to git, which answers for it as it always did. + + A `HEAD` THIS USER CANNOT OPEN STATS (#1222, RV1200's N2), so a `HEAD` + at mode 000 passed, and git, which could not read it, ended the run in + `pin-commit-unresolvable`, which says the pinned commit is not in the + leg's object store. A `HEAD` that is there is opened as a gitfile is + (`_gitfile_opens`): only where it is a regular file, and an open that + finds nothing is a git directory with no `HEAD`. One this user cannot + open is named, `.git/modules/spec/HEAD` or `spec/.git/HEAD`, with the + same remedy.""" named = dotgit try: gitdir = _leg_git_dir(dotgit) if gitdir is not None: named = Path(os.path.normpath(gitdir)) - _is_there(gitdir / "HEAD") + head = gitdir / "HEAD" + if _is_there(head): + named = named / "HEAD" + _gitfile_opens(head) except OSError as exc: raise _unreadable_file("pin-submodule-unreadable", _named(root, named), exc, CHECKOUT_READ) from exc @@ -1228,7 +1243,8 @@ def _check_pin_digest(root: Path, path: str, commit: str, pin: dict, rel: str, Python 3.14's `exists()` would have said of it. So is a `.git` gitfile 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`. + (#968), or whose `HEAD` it cannot open (#1222): see + `_refuse_an_unreadable_git_dir`. """ submodule = root / path named = _shown(path) diff --git a/tests/test_validate_pins_spelled_and_named.py b/tests/test_validate_pins_spelled_and_named.py index 02834ac1..da69389d 100644 --- a/tests/test_validate_pins_spelled_and_named.py +++ b/tests/test_validate_pins_spelled_and_named.py @@ -43,6 +43,14 @@ * 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. +What #968 left, #1222, from RV1200's review of #1200 (its N2): + + * The `HEAD` of a leg's git directory at mode 000, under `.git/modules/` + or in a `.git` directory, stats, so #968's `stat` passed it, and git, + which could not read it, ended the run in `pin-commit-unresolvable` + under THE LOCKSTEP RULE. It is opened as a gitfile is, and refused + `pin-submodule-unreadable`, naming that `HEAD`. + 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 @@ -911,3 +919,86 @@ def test_a_git_directory_is_its_own_and_a_gitfile_naming_none_is_gits( other.parent.mkdir() other.write_bytes(b"not a gitfile" + LF.encode("ascii")) assert validator._leg_git_dir(other) is None + + +# --- #1222: a leg git directory's `HEAD` this user cannot open --------------- + +#: `(whether the leg's git directory is its own, its `HEAD`)`: a gitfile +#: leg's, under `.git/modules/`, and a `.git` directory's (#1222). Each +#: `HEAD` is the path the gate names. +HEADS = [ + pytest.param(False, ".git/modules/spec/HEAD", id="gitfile-leg"), + pytest.param(True, "spec/.git/HEAD", id="git-directory-leg"), +] + + +@CANNOT_RUN_AS_ROOT +@pytest.mark.parametrize("own_directory, head", HEADS) +def test_a_leg_git_directory_head_this_user_cannot_open_is_refused( + project, own_directory, head): + """#1222, RV1200's N2: the `HEAD` of the leg's git directory at mode + 000. It stats, so #968's `stat` of it passed, and git, which could not + read it, ended the run in `pin-commit-unresolvable` under THE LOCKSTEP + RULE, which says the pinned commit is not in the leg's object store. It + is `pin-submodule-unreadable`, naming that `HEAD`. The control first: + readable, the same leg passes.""" + if own_directory: + leg_with_its_own_git_directory(project) + passed = validate(project) + assert passed.returncode == 0, passed.stdout + passed.stderr + assert_the_checkout_refused(project, head, 0, head) + + +@pytest.mark.parametrize("own_directory, head", HEADS) +@pytest.mark.parametrize("failure, advice", CAUSES) +def test_a_leg_git_directory_head_whose_open_is_refused_is_refused_everywhere( + validator, project, monkeypatch, own_directory, head, failure, + advice): + """The same in-process, so on every platform and as root: the open of + the `HEAD` refused, each cause with its own advice.""" + if own_directory: + leg_with_its_own_git_directory(project) + failing(monkeypatch, "open", {"HEAD": failure}) + refusal = refusal_of_the_run(validator, project) + assert refusal.code == "pin-submodule-unreadable" + assert refusal.detail == f"{head}: cannot be read ({failure.strerror})" + assert refusal.remediation == CHECKOUT_READS + advice + AGAIN + + +def test_a_head_gone_when_it_is_opened_is_left_to_git(validator, project, + monkeypatch): + """Asked as `_is_a` asks a `stat`: an open of the `HEAD` that finds + nothing at the name is a git directory with no `HEAD`, which git answers + for, and not a file this user cannot read. Here git reads it, so the run + finds nothing.""" + failing(monkeypatch, "open", {"HEAD": os_error(errno.ENOENT)}) + report = validator.Report() + validator._run_all_checks(project, report, []) + assert report.findings == [] + + +@pytest.mark.parametrize("make", [ + pytest.param(made_directory, id="directory"), + pytest.param(made_fifo, id="fifo", marks=POSIX_NODES), +]) +def test_a_head_that_is_no_regular_file_is_not_opened(validator, tmp_path, + monkeypatch, make): + """Only a `HEAD` that is a regular file is opened: one that is anything + else is git's to answer for, 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.mkdir(parents=True) + make(dotgit / "HEAD") + failing(monkeypatch, "open", {"HEAD": AssertionError("opened")}) + assert validator._refuse_an_unreadable_git_dir(tmp_path, dotgit) is None + + +def test_a_head_no_system_call_can_name_is_left_to_git(validator, tmp_path): + """A `gitdir: ` line that holds a NUL names a git directory no system + call can take, so its `HEAD` is nothing there, as `_is_there` answers a + name the system cannot take: nothing is opened, no `ValueError` ends + the run, and git refuses the gitfile as it always did.""" + dotgit = tmp_path / "leg" / ".git" + dotgit.parent.mkdir() + dotgit.write_bytes(b"gitdir: ../m" + chr(0).encode("ascii") + b"x") + assert validator._refuse_an_unreadable_git_dir(tmp_path, dotgit) is None