From fff421456a8414dd31472f13b0fa9486cbcbf934 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 21:40:10 +0000 Subject: [PATCH 1/5] validate-pins.py: a leg's git-directory `HEAD` this user cannot open, under `.git/modules/` or in a `.git` directory, is refused `pin-submodule-unreadable` by name, where it ended `pin-commit-unresolvable` (#1222) `_refuse_an_unreadable_git_dir` asked the `HEAD` of a leg's git directory only by `stat`, which a `HEAD` at mode 000 passes, so git, which could not read it, ended the run in `pin-commit-unresolvable` under THE LOCKSTEP RULE. A `HEAD` that is there is now opened as `_gitfile_opens` opens a gitfile: only where it is a regular file, and an open that finds nothing is a git directory with no `HEAD`, left to git. One this user cannot open is named, `.git/modules/spec/HEAD` or `spec/.git/HEAD`, with the leg checkout's remedy. The gate half of #1222; the doctor half waits for #1200. Co-Authored-By: Claude Opus 5.5 --- .../assembly-root/scripts/validate-pins.py | 24 ++++- tests/test_validate_pins_spelled_and_named.py | 91 +++++++++++++++++++ 2 files changed, 111 insertions(+), 4 deletions(-) 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 From 2eada6794c792eb02d26957c8e3176eeb1a2372b Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 21:41:52 +0000 Subject: [PATCH 2/5] validate-family.py: a member whose `.git` file this user cannot open, or whose git directory (its own `.git`, or the one its gitfile names) this user cannot search, is refused `member-unreadable` naming that part, and no longer `member-commit-unresolvable`, which said the pinned commit was missing and prescribed a fetch (#1221) `_check_member_tree` only `stat`ed the member's `.git` before running `git rev-parse` in it. A gitfile at mode 000 stats; a `.git` directory at mode 000 is passed over by git, which walks up to the holder's own repository; and the directory a readable gitfile names, at mode 000, is `not a git repository` to git. Each ended `member-commit-unresolvable`. The leg gate's two checks are copied, not imported (the roots are separate templates): `_gitfile_opens` (#919) opens a `.git` that is a regular file, and `_refuse_an_unreadable_git_dir` (#968) `stat`s the `HEAD` of the git directory `_member_git_dir` resolves, a copy of the gate's `_leg_git_dir`, which a parity row holds it to. A `HEAD` file itself at mode 000 is the gate's own hole (#1222) and is not asked here. Co-Authored-By: Claude Opus 5.5 --- .../family-root/scripts/validate-family.py | 103 ++++++- tests/test_validate_family_unreadable.py | 286 +++++++++++++++++- 2 files changed, 386 insertions(+), 3 deletions(-) diff --git a/templates/family-root/scripts/validate-family.py b/templates/family-root/scripts/validate-family.py index 172647b3..f80e1bfe 100755 --- a/templates/family-root/scripts/validate-family.py +++ b/templates/family-root/scripts/validate-family.py @@ -539,6 +539,99 @@ def _checked_out(checkout: Path, path: str) -> bool: f"{path}/.git") +# A member's `.git` and the git directory it names, asked before git is +# (#1221). `_checked_out` only `stat`s the `.git`, and three states pass +# that `stat` and reach the pinned commit's lookup, which git then failed: +# a gitfile at mode 000, which stats; a `.git` directory at mode 000, which +# git passes over, walking up to the holder's own repository; and the +# directory a readable gitfile names, at mode 000. Each was refused +# `member-commit-unresolvable`, "the pinned commit ... is not in that +# member's object store", with a fetch that cannot help. Each is now +# `member-unreadable`, naming the part, with its `chmod`. +# +# `validate-pins.py`'s `_gitfile_opens` (#919), `_leg_git_dir` (#968) and +# `_refuse_an_unreadable_git_dir` (#968), COPIED, NOT SHARED: the two roots +# are separate templates, and this file is a pinned copy a holder re-copies +# one row at a time (#610's skew rule). `tests/test_validate_family_ +# unreadable.py` holds `_member_git_dir` to the gate's `_leg_git_dir` over +# every shape of `gitdir: ` line the gate's rows write. A `HEAD` file itself +# at mode 000 is not asked here, since the gate does not ask it either. + +def _gitfile_opens(path: Path) -> bool: + """Whether a member's `.git` that is a regular file, a gitfile, can be + opened (#1221), as `validate-pins.py` asks a leg's (#919): 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. 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 + + +def _gitfile_read(checkout: Path, path: str) -> bool: + """`_gitfile_opens` of the `.git` in `checkout`, the gitlink path `path`, + or `member-unreadable` naming `/.git` where its open was refused + (#1221).""" + try: + return _gitfile_opens(checkout / ".git") + except OSError as exc: + raise _unreadable(MEMBER_UNREADABLE, f"{path}/.git", exc) from exc + + +def _member_git_dir(dotgit: Path) -> Path | None: + """The git directory of a member whose `.git` is `dotgit`, as + `validate-pins.py`'s `_leg_git_dir` finds a leg's (#968, #1221): `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 + return dotgit.parent / os.fsdecode(data[len(b"gitdir: "):].rstrip(b"\r\n")) + + +def _refuse_an_unreadable_git_dir(checkout: Path, path: str) -> None: + """`member-unreadable`, by name, for a member at the gitlink path `path` + whose git directory this user cannot search (#1221), asked as + `validate-pins.py` asks a leg's (#968): the `stat` of its `HEAD`, which + a git directory at mode 000 or r-- refuses. + + NAMED AS THE GATE NAMES IT, relative to the holder: `/.git` for a + checkout's own directory, `.git/modules/` for the one an absorbed + member's gitfile names, and whole where it is outside the holder. A git + directory with no `HEAD`, or a gitfile that names none, is left to git, + which answers for it as it always did.""" + dotgit = checkout / ".git" + named = f"{path}/.git" + try: + gitdir = _member_git_dir(dotgit) + if gitdir is not None: + holder = Path(os.path.normpath( + checkout.parents[len(Path(path).parts) - 1])) + found = Path(os.path.normpath(gitdir)) + try: + named = found.relative_to(holder).as_posix() + except ValueError: + named = str(found) + _is_a(gitdir / "HEAD", _any_kind) + except OSError as exc: + raise _unreadable(MEMBER_UNREADABLE, named, exc) from exc + + def _checkout_gone(root: Path, path: str) -> bool: """`_gone` of the `.git` at the gitlink path `path`, or `member-unreadable` naming it where its `stat` was refused for any @@ -1394,14 +1487,19 @@ def _check_member_tree(member: Path, pin: dict, path: str, commit: str, `.git` (`_checked_out`, #430): it ended the run in a traceback up to Python 3.13, and on 3.14 was `member-uninitialized`, whose update then failed (`could not open '/.git' for writing`) on the checkout - that is there. + that is there. So is a `.git` gitfile this user cannot open + (`_gitfile_read`, #1221), and a git directory this user cannot search, + the `.git` itself or the one a gitfile names + (`_refuse_an_unreadable_git_dir`, #1221): each passed the `stat` and + reached git, and the run ended `member-commit-unresolvable`, which says + the pinned commit is missing and prescribes a fetch. The pinned commit's lookup and the digest are read inside `_git_runs`, and nothing else here is (#928). All of this ran inside one, the `stat` of the checkout's `.git` too: on Python 3.12, a refused one met here was `git-not-runnable`, which says to put git on PATH. """ - if not _checked_out(member, path): + if not (_checked_out(member, path) and _gitfile_read(member, path)): raise Refusal( "member-uninitialized", f"{path} is not an initialized submodule checkout, so neither the " @@ -1411,6 +1509,7 @@ def _check_member_tree(member: Path, pin: dict, path: str, commit: str, f"Remediation: {stop}`git submodule update --init --recursive`, " "or `make bootstrap`, which resolves a credential for private " "members first.") + _refuse_an_unreadable_git_dir(member, path) unread = (f"{project}'s pinned commit and its tree digest could not be " f"read in {path}") # `_git_runs` OUTSIDE the `try`, as `validate-pins.py` has it: the diff --git a/tests/test_validate_family_unreadable.py b/tests/test_validate_family_unreadable.py index de452614..42663177 100644 --- a/tests/test_validate_family_unreadable.py +++ b/tests/test_validate_family_unreadable.py @@ -36,6 +36,16 @@ rm` is said to refuse a staged gitlink, and `_hand_cached` and `_cached_left` (#936), which decide whether it is `git rm --cached`. +A MEMBER'S `.git` AND THE GIT DIRECTORY IT NAMES (#1221), asked by +`_check_member_tree` before git is, as `validate-pins.py` asks a leg's +(#919, #968): a gitfile this user cannot open, a `.git` directory at mode +000 or r--, and the directory a readable gitfile names at mode 000 or r-- +each passed the `stat` and were refused `member-commit-unresolvable`, which +says the pinned commit is missing. Each is `member-unreadable` now, naming +the part, and a parity row holds `_member_git_dir` to the gate's +`_leg_git_dir`. A `HEAD` file itself at mode 000 is not asked, since the +gate does not ask it either. + WHAT IS ON DISK IS ASKED ONLY WHERE IT IS SAID (round 2, r956's SHOULD 1 on #956): `_forced` and `_staged_over` ask git first, and where HEAD records a gitlink at the index's own commit, the disk under it is never @@ -56,15 +66,17 @@ import contextlib import errno +import os import re import shutil +import stat import sys from pathlib import Path from types import SimpleNamespace import pytest -from conftest import git, load_without_bytecode +from conftest import REPO, git, load_without_bytecode from test_bump_leg_unopenable import ( made_unopenable, needs_a_mode, refusal_of, refused_stat, ) @@ -712,3 +724,275 @@ def test_a_file_behind_a_folder_this_user_cannot_search_is_named( assert result.stderr.splitlines()[0].startswith(first), result.stderr assert "Traceback" not in result.stderr assert validate(holder).returncode == 0 + + +# --- #1221: a member's `.git` and the git directory it names ---------------- + +#: Where IRRS's git directory is, as the gitfile `family.py add` wrote names +#: it, relative to the holder. +MEMBER_GIT_DIR = f".git/modules/{ROW}" + +#: The template this file's holder copies, and the gate whose helpers it +#: copies (#919, #968). +FAMILY_TEMPLATE = (REPO / "templates" / "family-root" / "scripts" + / "validate-family.py") +GATE = REPO / "templates" / "assembly-root" / "scripts" / "validate-pins.py" + +#: The causes a refused open or `stat` is told by, as `assert_refused` +#: checks each cure. +CAUSES = [ + pytest.param(PermissionError(errno.EACCES, "Permission denied"), + id="permission"), + pytest.param(OSError(errno.EIO, "Input/output error"), id="io-error"), +] + + +def own_git_directory(holder: Path, request) -> tuple[str, ...]: + """IRRS's git directory moved from `.git/modules/members/IRRS` into the + checkout as its own `.git`, where a checkout git has not absorbed keeps + it, its `worktree` line dropped, since the checkout is that directory's + own work tree now. As the gate's rows make a leg's: the file is edited + as text, and no `git config` is run.""" + dotgit = holder / ROW / ".git" + dotgit.unlink() + (holder / MEMBER_GIT_DIR).rename(dotgit) + config = dotgit / "config" + lines = config.read_bytes().split(b"\n") + config.write_bytes(b"\n".join( + line for line in lines if not line.strip().startswith(b"worktree"))) + return () + + +def a_worktree(holder: Path, request) -> tuple[str, ...]: + """IRRS checked out at the commit the holder records as a linked + worktree of a clone outside the holder, its checkout moved aside: its + `.git` is a gitfile naming `/.git/worktrees/IRRS`, a git + directory outside the holder.""" + tmp_path = request.getfixturevalue("tmp_path") + remotes = request.getfixturevalue("family")["remotes"] + commit = git("rev-parse", f"HEAD:{ROW}", cwd=holder).stdout.strip() + clone = tmp_path / "IRRS-clone" + git("clone", "-q", str(remotes / "IRRS.git"), str(clone), cwd=tmp_path) + shutil.move(str(holder / ROW), str(tmp_path / "IRRS-aside")) + git("worktree", "add", "-q", "--detach", str(holder / ROW), commit, + cwd=clone) + return () + + +def failing(monkeypatch, method: str, name: str, error: OSError) -> None: + """`Path.` failing with `error` for every path called `name`.""" + real = getattr(Path, method) + + def refused(self, *args, **kwargs): + if self.name == name: + raise type(error)(error.errno, error.strerror, str(self)) + return real(self, *args, **kwargs) + + monkeypatch.setattr(Path, method, refused) + + +@contextlib.contextmanager +def mode_of(path: Path, mode: int): + """`path` at `mode`, its own mode given back in `finally`.""" + kept = stat.S_IMODE(path.stat().st_mode) + path.chmod(mode) + try: + yield + finally: + path.chmod(kept) + + +@pytest.mark.parametrize("made", [ + pytest.param(as_made, id="gitfile"), + pytest.param(own_git_directory, id="git-directory"), + pytest.param(a_worktree, id="worktree"), +]) +def test_a_member_whose_git_can_be_read_passes_as_it_did(holder, request, + made): + """THE CONTROLS (#1221): a member whose `.git` is a gitfile naming its + git directory under `.git/modules/`, its own `.git` directory, or a + gitfile naming a linked worktree's outside the holder, each readable: + the gate passes, as it did.""" + result = validate(holder, *made(holder, request)) + assert result.returncode == 0, result.stderr + result.stdout + assert result.stderr == "", result.stderr + + +@needs_a_mode +@pytest.mark.parametrize("made,target,mode,named", [ + pytest.param(as_made, f"{ROW}/.git", 0, f"{ROW}/.git", + id="gitfile-000"), + pytest.param(pins_alone, f"{ROW}/.git", 0, f"{ROW}/.git", + id="gitfile-000-pins"), + pytest.param(own_git_directory, f"{ROW}/.git", 0, f"{ROW}/.git", + id="git-directory-000"), + pytest.param(own_git_directory, f"{ROW}/.git", 0o444, f"{ROW}/.git", + id="git-directory-444"), + pytest.param(as_made, MEMBER_GIT_DIR, 0, MEMBER_GIT_DIR, + id="named-git-directory-000"), + pytest.param(as_made, MEMBER_GIT_DIR, 0o444, MEMBER_GIT_DIR, + id="named-git-directory-444"), +]) +def test_a_member_git_this_user_cannot_read_is_refused_by_name( + holder, request, made, target, mode, named): + """THE ISSUE'S CELLS (#1221), on this Python: a gitfile this user cannot + open, a `.git` directory it cannot search, which git passes over for + the holder's own repository, and the git directory a readable gitfile + names, which git calls `not a git repository`. Each passed the `stat` + of the `.git` and was `member-commit-unresolvable`, "the pinned commit + ... is not in that member's object store", with a fetch. Each is + `member-unreadable`, exit 2, naming the part with the `chmod` cure, and + with the mode back the gate passes, so the cure is the whole of it.""" + extra = made(holder, request) + with mode_of(holder / target, mode): + result = validate(holder, *extra) + assert_refused(result.returncode, result.stderr, MEMBER_UNREADABLE, + named) + assert "member-commit-unresolvable" not in result.stderr + again = validate(holder, *extra) + assert again.returncode == 0, again.stderr + + +@pytest.mark.parametrize("error", CAUSES) +@pytest.mark.parametrize("made,method,name,named", [ + pytest.param(as_made, "open", ".git", f"{ROW}/.git", id="gitfile"), + pytest.param(own_git_directory, "stat", "HEAD", f"{ROW}/.git", + id="git-directory"), + pytest.param(as_made, "stat", "HEAD", MEMBER_GIT_DIR, + id="named-git-directory"), +]) +def test_a_member_git_refused_is_refused_by_name_on_every_python( + holder, request, monkeypatch, capsys, error, made, method, name, + named): + """The same in-process, so on every platform and as root: the open of + the gitfile, or the `stat` of the `HEAD` in the git directory, refused, + each cause with its own cure (`assert_refused`).""" + extra = made(holder, request) + module = gate(holder, monkeypatch) + failing(monkeypatch, method, name, error) + code = module.main(["--root", str(holder), *extra]) + err = capsys.readouterr().err + assert_refused(code, err, MEMBER_UNREADABLE, named, error.strerror) + assert "member-commit-unresolvable" not in err + + +def test_the_member_tree_asked_alone_names_a_git_directory_whole_outside( + holder, request, monkeypatch): + """`_check_member_tree` asked alone: the git directory a gitfile names + is named relative to the holder, as the gate names a leg's, and whole + where it is outside the holder, a linked worktree's.""" + module = gate(holder, monkeypatch) + failing(monkeypatch, "stat", "HEAD", + PermissionError(errno.EACCES, "Permission denied")) + args = (holder / ROW, {}, ROW, "0" * 40, "IRRS", module.Report(), "") + refusal = refusal_of(module, module._check_member_tree, *args) + assert refusal.code == MEMBER_UNREADABLE + assert refusal.detail == (f"{MEMBER_GIT_DIR}: cannot be read " + "(Permission denied)") + a_worktree(holder, request) + line = (holder / ROW / ".git").read_bytes().decode().strip() + outside = os.path.normpath(line[len("gitdir: "):]) + refusal = refusal_of(module, module._check_member_tree, *args) + assert refusal.detail == (f"{module.spelled(outside)}: cannot be read " + "(Permission denied)") + + +def test_a_gitfile_gone_when_it_is_opened_is_a_member_not_checked_out( + holder, monkeypatch, capsys): + """Asked as `_is_a` asks a `stat`: an open that finds nothing at the + name is the member with no `.git`, `member-uninitialized`, and not a + file this user cannot read.""" + module = gate(holder, monkeypatch) + failing(monkeypatch, "open", ".git", + FileNotFoundError(errno.ENOENT, "No such file or directory")) + code = module.main(["--root", str(holder)]) + err = capsys.readouterr().err + assert code == 2, err + assert err.startswith(f"REFUSED member-uninitialized: {ROW} is not an " + "initialized submodule checkout"), err + + +def test_a_member_git_directory_with_no_head_is_left_to_git( + holder, monkeypatch, capsys): + """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 passes.""" + module = gate(holder, monkeypatch) + failing(monkeypatch, "stat", "HEAD", + FileNotFoundError(errno.ENOENT, "No such file or directory")) + code = module.main(["--root", str(holder)]) + assert code == 0, capsys.readouterr().err + + +@pytest.fixture(scope="module") +def sources(): + """This working tree's `validate-family.py` and the gate it copies, each + loaded by path with no bytecode written beside it, and `sys.path` put + back after.""" + with pytest.MonkeyPatch.context() as patch: + patch.setattr(sys, "path", list(sys.path)) + return SimpleNamespace( + family=load_without_bytecode(FAMILY_TEMPLATE, + "validate_family_git_dir_parity"), + gate=load_without_bytecode(GATE, "gate_git_dir_parity")) + + +#: What a `.git` may hold, as the gate's own rows write a leg's (#968), and +#: more: a blank before the line end, which git keeps, an empty file and a +#: line naming a directory whole. +GITFILES = [ + pytest.param(b"gitdir: ../../.git/modules/" + ROW.encode() + b"\n", + id="relative"), + pytest.param(b"gitdir: ../m\r\n", id="cr-lf"), + pytest.param(b"gitdir: ../m", id="no-line-end"), + pytest.param(b"gitdir: ../m\n\n", id="blank-line-after"), + pytest.param(b"gitdir: ../m \n", id="blank-before-line-end"), + pytest.param(b"not a gitfile\n", id="names-none"), + pytest.param(b"", id="empty"), + pytest.param("absolute", id="absolute"), + pytest.param(None, id="a-directory"), +] + + +@pytest.mark.parametrize("written", GITFILES) +def test_a_member_git_directory_is_found_as_the_gate_finds_a_leg_s( + sources, tmp_path, written): + """PARITY (#1221): `_member_git_dir` is the gate's `_leg_git_dir` + (#968), copied, not imported, since the two roots are separate + templates: the same directory, or None, for each gitfile and for a + `.git` that is a directory, so the family gate asks the `HEAD` the leg + gate asks.""" + dotgit = tmp_path / "member" / ".git" + if written is None: + dotgit.mkdir(parents=True) + else: + dotgit.parent.mkdir() + if written == "absolute": + written = (b"gitdir: " + os.fsencode(str(tmp_path / "elsewhere")) + + b"\n") + dotgit.write_bytes(written) + assert sources.family._member_git_dir(dotgit) == \ + sources.gate._leg_git_dir(dotgit) + + +@pytest.mark.parametrize("make", [ + pytest.param(lambda dotgit: dotgit.write_bytes(b"gitdir: ../m\n"), + id="gitfile"), + pytest.param(lambda dotgit: dotgit.mkdir(), id="directory"), + pytest.param(lambda dotgit: os.mkfifo(dotgit), id="fifo", + marks=pytest.mark.skipif(not hasattr(os, "mkfifo"), + reason="no FIFO on this platform")), +]) +def test_a_member_git_is_opened_as_the_gate_opens_a_leg_s( + sources, tmp_path, monkeypatch, make): + """PARITY (#1221): `_gitfile_opens` answers as the gate's (#919), and + only a regular file is opened: a checkout's own `.git` directory is + git's to read, and the open of a FIFO would wait for a writer, so an + open of either fails the row rather than hanging it.""" + dotgit = tmp_path / "member" / ".git" + dotgit.parent.mkdir() + make(dotgit) + if not stat.S_ISREG(dotgit.stat().st_mode): + failing(monkeypatch, "open", ".git", OSError(errno.EIO, "opened")) + assert sources.family._gitfile_opens(dotgit) is True + assert sources.gate._gitfile_opens(dotgit) is True From 0deac3a6581b308e5761c420456bac51f0fe5f11 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 14:43:11 +0000 Subject: [PATCH 3/5] test_validate_family_unreadable.py: a member whose `.git` gitfile opens and then cannot be read, an I/O error or a race after `_gitfile_opens` passed it, is refused `member-unreadable` naming `/.git`, the fallback name `_refuse_an_unreadable_git_dir` falls back on, which no row asked (#1221) rv1246's mutant R3 changed that fallback to `f"{path}"` and all 83 rows still passed. One in-process row refuses `Path.read_bytes` for the member's `.git` with EIO after the open succeeds and expects `members/IRRS/.git: cannot be read (Input/output error)` with the other-reason cure. With R3 applied the row fails; on main's bytes of validate-family.py it fails too. No code in validate-family.py changes. Co-Authored-By: Claude Sonnet 5.5 --- tests/test_validate_family_unreadable.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_validate_family_unreadable.py b/tests/test_validate_family_unreadable.py index 42663177..1f899981 100644 --- a/tests/test_validate_family_unreadable.py +++ b/tests/test_validate_family_unreadable.py @@ -897,6 +897,24 @@ def test_the_member_tree_asked_alone_names_a_git_directory_whole_outside( "(Permission denied)") +def test_a_gitfile_that_opens_and_cannot_be_read_is_named_at_its_own( + holder, monkeypatch, capsys): + """The name a refusal falls back on (#1221): the gitfile OPENS, so + `_gitfile_opens` passes it, and its read is then refused, which only an + I/O error or a race does. `_member_git_dir` raises before it has found + a git directory to name, so the part named is the `.git` itself, + `/.git`, and not the member's folder, with the cure for a reason + that is no permission.""" + module = gate(holder, monkeypatch) + failing(monkeypatch, "read_bytes", ".git", + OSError(errno.EIO, "Input/output error")) + code = module.main(["--root", str(holder)]) + err = capsys.readouterr().err + assert_refused(code, err, MEMBER_UNREADABLE, f"{ROW}/.git", + "Input/output error") + assert "member-commit-unresolvable" not in err + + def test_a_gitfile_gone_when_it_is_opened_is_a_member_not_checked_out( holder, monkeypatch, capsys): """Asked as `_is_a` asks a `stat`: an open that finds nothing at the From 9ef8f0ab5670bd022e190f41a6eb4fe8bcd35ab4 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 15:56:42 +0000 Subject: [PATCH 4/5] validate-family.py: a member whose git directory's `HEAD` this user cannot open, under `.git/modules/` or in a `.git` directory, is refused `member-unreadable` by name, where it ended `member-commit-unresolvable` with a fetch (#1256) A `HEAD` at mode 000 stats, so #1221's `stat` of it passed, and git, which could not read it, ended the run in `member-commit-unresolvable`, "the pinned commit ... is not in that member's object store", with a fetch that cannot help. `_refuse_an_unreadable_git_dir` now opens a `HEAD` that is there through `_gitfile_opens`, as validate-pins.py opens a leg's since #1222 (#1239): only a regular file is opened, an open that finds nothing is left to git, and a refused open is `member-unreadable` naming `.git/modules//HEAD`, `/.git/HEAD`, or the whole path of a linked worktree's `HEAD` outside the holder, with the member checkout's remedy. A git directory with no `HEAD` stays git's. The copied helpers stay word for word with validate-pins.py's: `_gitfile_opens` and `_member_git_dir` (`_leg_git_dir`) compare equal by `ast.unparse` with docstrings dropped. Rows in tests/test_validate_family_unreadable.py: mode rows for a gitfile member, a `.git`-directory member and a linked worktree (skipped under root and on Windows), in-process stand-ins for each cause, a race that finds no `HEAD`, a directory or FIFO `HEAD` never opened, a NUL name left to git, and a parity row holding the line to the gate's. Co-Authored-By: Claude Opus 5.5 --- .../family-root/scripts/validate-family.py | 43 +++- tests/test_validate_family_unreadable.py | 199 +++++++++++++++++- 2 files changed, 232 insertions(+), 10 deletions(-) diff --git a/templates/family-root/scripts/validate-family.py b/templates/family-root/scripts/validate-family.py index d2f2f936..c111dcca 100755 --- a/templates/family-root/scripts/validate-family.py +++ b/templates/family-root/scripts/validate-family.py @@ -558,8 +558,14 @@ def _checked_out(checkout: Path, path: str) -> bool: # are separate templates, and this file is a pinned copy a holder re-copies # one row at a time (#610's skew rule). `tests/test_validate_family_ # unreadable.py` holds `_member_git_dir` to the gate's `_leg_git_dir` over -# every shape of `gitdir: ` line the gate's rows write. A `HEAD` file itself -# at mode 000 is not asked here, since the gate does not ask it either. +# every shape of `gitdir: ` line the gate's rows write, and holds the open +# of a member's `HEAD` to the gate's open of a leg's. +# +# A `HEAD` FILE ITSELF AT MODE 000 STATS (#1256), so the `stat` of `HEAD` +# passed it to git, which could not read it: the run ended +# `member-commit-unresolvable`, with a fetch that cannot help. It is opened +# as the gate opens a leg's (#1222), and one this user cannot open is +# `member-unreadable`, naming that `HEAD`. def _gitfile_opens(path: Path) -> bool: """Whether a member's `.git` that is a regular file, a gitfile, can be @@ -568,7 +574,11 @@ def _gitfile_opens(path: Path) -> bool: `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. Only a regular file is opened, since the open of a FIFO - waits for a writer.""" + waits for a writer. + + A MEMBER'S GIT DIRECTORY'S `HEAD` IS ASKED THE SAME WAY, for the same + reason (#1256), as the gate asks a leg's (#1222): see + `_refuse_an_unreadable_git_dir`.""" if not _is_a(path, stat.S_ISREG): return True try: @@ -618,7 +628,18 @@ def _refuse_an_unreadable_git_dir(checkout: Path, path: str) -> None: checkout's own directory, `.git/modules/` for the one an absorbed member's gitfile names, and whole where it is outside the holder. A git directory with no `HEAD`, or a gitfile that names none, is left to git, - which answers for it as it always did.""" + which answers for it as it always did. + + A `HEAD` THIS USER CANNOT OPEN STATS (#1256, as #1222 found of a leg's), + so a `HEAD` at mode 000 passed, and git, which could not read it, ended + the run in `member-commit-unresolvable`, which says the pinned commit + is not in that member's object store and prescribes a fetch. A `HEAD` + that is there is opened as a gitfile is (`_gitfile_opens`), as the + gate opens a leg's: only where it is a regular file, since the open of + a FIFO waits for a writer, and an open that finds nothing is a git + directory with no `HEAD`. One this user cannot open is named, + `.git/modules//HEAD` or `/.git/HEAD`, with the same + remedy.""" dotgit = checkout / ".git" named = f"{path}/.git" try: @@ -629,9 +650,14 @@ def _refuse_an_unreadable_git_dir(checkout: Path, path: str) -> None: found = Path(os.path.normpath(gitdir)) try: named = found.relative_to(holder).as_posix() + named_head = f"{named}/HEAD" except ValueError: named = str(found) - _is_a(gitdir / "HEAD", _any_kind) + named_head = str(found / "HEAD") + head = gitdir / "HEAD" + if _is_a(head, _any_kind): + named = named_head + _gitfile_opens(head) except OSError as exc: raise _unreadable(MEMBER_UNREADABLE, named, exc) from exc @@ -1499,9 +1525,10 @@ def _check_member_tree(member: Path, pin: dict, path: str, commit: str, that is there. So is a `.git` gitfile this user cannot open (`_gitfile_read`, #1221), and a git directory this user cannot search, the `.git` itself or the one a gitfile names - (`_refuse_an_unreadable_git_dir`, #1221): each passed the `stat` and - reached git, and the run ended `member-commit-unresolvable`, which says - the pinned commit is missing and prescribes a fetch. + (`_refuse_an_unreadable_git_dir`, #1221), or whose `HEAD` it cannot + open (#1256): each passed the `stat` and reached git, and the run ended + `member-commit-unresolvable`, which says the pinned commit is missing + and prescribes a fetch. The pinned commit's lookup and the digest are read inside `_git_runs`, and nothing else here is (#928). All of this ran inside one, the `stat` diff --git a/tests/test_validate_family_unreadable.py b/tests/test_validate_family_unreadable.py index 1f899981..15272583 100644 --- a/tests/test_validate_family_unreadable.py +++ b/tests/test_validate_family_unreadable.py @@ -43,8 +43,15 @@ each passed the `stat` and were refused `member-commit-unresolvable`, which says the pinned commit is missing. Each is `member-unreadable` now, naming the part, and a parity row holds `_member_git_dir` to the gate's -`_leg_git_dir`. A `HEAD` file itself at mode 000 is not asked, since the -gate does not ask it either. +`_leg_git_dir`. + +A MEMBER GIT DIRECTORY'S `HEAD` THIS USER CANNOT OPEN (#1256), as #1222 +found of a leg's in the gate: a `HEAD` at mode 000, under `.git/modules/` +or in a `.git` directory, stats, so #1221's `stat` of it passed, and git, +which could not read it, ended the run in `member-commit-unresolvable` +with a fetch. It is opened as the gate opens a leg's, only where it is a +regular file, and refused `member-unreadable`, naming that `HEAD`; a +parity row holds the name to the gate's. WHAT IS ON DISK IS ASKED ONLY WHERE IT IS SAID (round 2, r956's SHOULD 1 on #956): `_forced` and `_staged_over` ask git first, and where HEAD @@ -942,6 +949,107 @@ def test_a_member_git_directory_with_no_head_is_left_to_git( assert code == 0, capsys.readouterr().err +# --- #1256: a member git directory's `HEAD` this user cannot open ----------- + +#: `(the state, its member git directory's `HEAD`)`: a gitfile member's, +#: under `.git/modules/`, and a `.git` directory's (#1256). Each `HEAD` is +#: the path the gate names, relative to the holder. +HEADS = [ + pytest.param(as_made, f"{MEMBER_GIT_DIR}/HEAD", id="gitfile-member"), + pytest.param(own_git_directory, f"{ROW}/.git/HEAD", + id="git-directory-member"), +] + + +def worktree_head(holder: Path) -> str: + """The `HEAD` of the git directory `a_worktree`'s gitfile names, outside + the holder, as the gate names it: whole.""" + line = (holder / ROW / ".git").read_bytes().decode().strip() + return os.path.join(os.path.normpath(line[len("gitdir: "):]), "HEAD") + + +@needs_a_mode +@pytest.mark.parametrize("made,head", HEADS) +def test_a_member_git_directory_head_this_user_cannot_open_is_refused( + holder, request, made, head): + """THE ISSUE'S CELLS (#1256), on this Python: the `HEAD` of the member's + git directory at mode 000. It stats, so #1221's `stat` of it passed, + and git, which could not read it, ended the run in + `member-commit-unresolvable`, "the pinned commit ... is not in that + member's object store", with a fetch. It is `member-unreadable`, exit + 2, naming that `HEAD` with the `chmod` cure, and with the mode back the + gate passes, so the cure is the whole of it.""" + extra = made(holder, request) + with mode_of(holder / head, 0): + result = validate(holder, *extra) + assert_refused(result.returncode, result.stderr, MEMBER_UNREADABLE, head) + assert "member-commit-unresolvable" not in result.stderr + again = validate(holder, *extra) + assert again.returncode == 0, again.stderr + + +@needs_a_mode +def test_a_linked_worktree_head_this_user_cannot_open_is_named_whole( + holder, request): + """The same for a linked worktree's `HEAD`, in the git directory its + gitfile names outside the holder: named whole, as the gate names that + directory (#1221).""" + a_worktree(holder, request) + head = worktree_head(holder) + with mode_of(Path(head), 0): + result = validate(holder) + assert_refused(result.returncode, result.stderr, MEMBER_UNREADABLE, head) + assert "member-commit-unresolvable" not in result.stderr + assert validate(holder).returncode == 0 + + +@pytest.mark.parametrize("error", CAUSES) +@pytest.mark.parametrize("made,head", HEADS) +def test_a_member_git_directory_head_whose_open_is_refused_is_refused( + holder, request, monkeypatch, capsys, error, made, head): + """The same in-process, so on every platform and as root: the open of + the `HEAD` refused, each cause with its own cure (`assert_refused`).""" + extra = made(holder, request) + module = gate(holder, monkeypatch) + failing(monkeypatch, "open", "HEAD", error) + code = module.main(["--root", str(holder), *extra]) + err = capsys.readouterr().err + assert_refused(code, err, MEMBER_UNREADABLE, head, error.strerror) + assert "member-commit-unresolvable" not in err + + +def test_the_member_tree_asked_alone_names_a_head_whole_outside( + holder, request, monkeypatch): + """`_check_member_tree` asked alone, the open of the `HEAD` refused: it + is named relative to the holder, and whole where its git directory is + outside the holder, a linked worktree's.""" + module = gate(holder, monkeypatch) + failing(monkeypatch, "open", "HEAD", + PermissionError(errno.EACCES, "Permission denied")) + args = (holder / ROW, {}, ROW, "0" * 40, "IRRS", module.Report(), "") + refusal = refusal_of(module, module._check_member_tree, *args) + assert refusal.code == MEMBER_UNREADABLE + assert refusal.detail == (f"{MEMBER_GIT_DIR}/HEAD: cannot be read " + "(Permission denied)") + a_worktree(holder, request) + refusal = refusal_of(module, module._check_member_tree, *args) + assert refusal.detail == (f"{module.spelled(worktree_head(holder))}: " + "cannot be read (Permission denied)") + + +def test_a_member_head_gone_when_it_is_opened_is_left_to_git( + holder, monkeypatch, capsys): + """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 + passes.""" + module = gate(holder, monkeypatch) + failing(monkeypatch, "open", "HEAD", + FileNotFoundError(errno.ENOENT, "No such file or directory")) + code = module.main(["--root", str(holder)]) + assert code == 0, capsys.readouterr().err + + @pytest.fixture(scope="module") def sources(): """This working tree's `validate-family.py` and the gate it copies, each @@ -1014,3 +1122,90 @@ def test_a_member_git_is_opened_as_the_gate_opens_a_leg_s( failing(monkeypatch, "open", ".git", OSError(errno.EIO, "opened")) assert sources.family._gitfile_opens(dotgit) is True assert sources.gate._gitfile_opens(dotgit) is True + + +def member_git_directory(tmp_path: Path, own_directory: bool) -> Path: + """A member checkout at `ROW` under `tmp_path`, the holder, and its git + directory, which holds a `HEAD`: its own `.git` directory, or the one + under `.git/modules/` its gitfile names, as `family.py add` writes it. + Returns the git directory.""" + checkout = tmp_path / ROW + checkout.mkdir(parents=True) + if own_directory: + gitdir = checkout / ".git" + gitdir.mkdir() + else: + gitdir = tmp_path / MEMBER_GIT_DIR + gitdir.mkdir(parents=True) + (checkout / ".git").write_bytes( + b"gitdir: ../../" + MEMBER_GIT_DIR.encode() + b"\n") + (gitdir / "HEAD").write_bytes(b"ref: refs/heads/main\n") + return gitdir + + +#: Each shape of member git directory `member_git_directory` makes. +OWN_DIRECTORY = [ + pytest.param(False, id="gitfile-member"), + pytest.param(True, id="git-directory-member"), +] + + +@pytest.mark.parametrize("error", CAUSES) +@pytest.mark.parametrize("own_directory", OWN_DIRECTORY) +def test_a_member_head_is_named_as_the_gate_names_a_leg_s( + sources, tmp_path, monkeypatch, own_directory, error): + """PARITY (#1256): the open of the `HEAD` refused, the family gate's + line is the leg gate's (#1222), naming the same `HEAD` relative to the + holder, `.git/modules/members/IRRS/HEAD` or `members/IRRS/.git/HEAD`, + with the system's reason. Each code and remedy is its own.""" + gitdir = member_git_directory(tmp_path, own_directory) + failing(monkeypatch, "open", "HEAD", error) + family_refusal = refusal_of( + sources.family, sources.family._refuse_an_unreadable_git_dir, + tmp_path / ROW, ROW) + gate_refusal = refusal_of( + sources.gate, sources.gate._refuse_an_unreadable_git_dir, tmp_path, + tmp_path / ROW / ".git") + head = (gitdir / "HEAD").relative_to(tmp_path).as_posix() + assert family_refusal.code == MEMBER_UNREADABLE + assert family_refusal.detail == ( + f"{head}: cannot be read ({error.strerror})") + assert gate_refusal.detail == family_refusal.detail + + +@pytest.mark.parametrize("own_directory", OWN_DIRECTORY) +@pytest.mark.parametrize("make", [ + pytest.param(lambda head: head.mkdir(), id="directory"), + pytest.param(lambda head: os.mkfifo(head), id="fifo", + marks=pytest.mark.skipif(not hasattr(os, "mkfifo"), + reason="no FIFO on this platform")), +]) +def test_a_member_head_that_is_no_regular_file_is_not_opened( + sources, tmp_path, monkeypatch, own_directory, make): + """Only a `HEAD` that is a regular file is opened (#1256), in the family + gate as in the leg gate (#1222): 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.""" + gitdir = member_git_directory(tmp_path, own_directory) + head = gitdir / "HEAD" + head.rename(gitdir / "HEAD-aside") + make(head) + failing(monkeypatch, "open", "HEAD", OSError(errno.EIO, "opened")) + assert sources.family._refuse_an_unreadable_git_dir(tmp_path / ROW, + ROW) is None + assert sources.gate._refuse_an_unreadable_git_dir( + tmp_path, tmp_path / ROW / ".git") is None + + +def test_a_member_head_no_system_call_can_name_is_left_to_git(sources, + 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_a` answers a + name the system cannot take: nothing is opened, no `ValueError` ends + the run, and git refuses the gitfile as it always did (#1256, as the + gate's #1222 row asks a leg's).""" + dotgit = tmp_path / ROW / ".git" + dotgit.parent.mkdir(parents=True) + dotgit.write_bytes(b"gitdir: ../m" + chr(0).encode("ascii") + b"x") + assert sources.family._refuse_an_unreadable_git_dir(tmp_path / ROW, + ROW) is None From be62b7043b24fd75393dbea7fa0ca95db8873df7 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 10 Nov 2026 16:32:23 +0000 Subject: [PATCH 5/5] validate-pins.py and validate-family.py: a leg's or member's git-directory `HEAD` that is a FIFO, a socket or a device, or a symlink to one, is refused by name before git is asked, where a FIFO left the gate with no answer at all (#1290) #1222's and #1256's guard opens a `HEAD` only where it is a regular file, since the open of a FIFO waits for a writer; but git's own open of it, in the pinned commit's `git rev-parse`, waited, and `git_out` has no timeout, so the assembly gate and the family holder's gate gave no answer (RV1239 on #1239, W1256 on #1263). A `HEAD` that `stat` finds neither a file nor a directory is now `pin-submodule-unreadable` or `member-unreadable`, `: cannot be read (a FIFO, not a file)`, with the remedy for a cause that is no permission. A socket, or a symlink to a FIFO or a device, which git does not wait on but cannot read as a `HEAD`, ended the run in `*-commit-unresolvable` and is named the same way. The assembly root's own `.git/HEAD`, asked by the same function (#1146), is `git-repo-unreadable`. Choice (a) of #1290, not a timeout in `git_out`: measured, a timed-out call is a traceback (exit 1) or, made a `git-failed` refusal, `*-commit-unresolvable` again, and `scripts/repo_shape.py` is a shape copy a root may hold older than its gate. Co-Authored-By: Claude Opus 5.5 --- .../assembly-root/scripts/validate-pins.py | 45 ++++- .../family-root/scripts/validate-family.py | 53 +++++- tests/test_validate_family_unreadable.py | 77 +++++++-- tests/test_validate_pins_spelled_and_named.py | 156 ++++++++++++++++-- 4 files changed, 306 insertions(+), 25 deletions(-) diff --git a/templates/assembly-root/scripts/validate-pins.py b/templates/assembly-root/scripts/validate-pins.py index f389008c..005d1350 100755 --- a/templates/assembly-root/scripts/validate-pins.py +++ b/templates/assembly-root/scripts/validate-pins.py @@ -470,7 +470,8 @@ def _refuse_an_unreadable_git_dir(root: Path, dotgit: Path, (`_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.""" + same remedy. So is one git cannot read as a `HEAD`, a FIFO among them + (#1290): see `_a_head_git_cannot_read`.""" named = dotgit try: gitdir = _leg_git_dir(dotgit) @@ -480,10 +481,52 @@ def _refuse_an_unreadable_git_dir(root: Path, dotgit: Path, if _is_there(head): named = named / "HEAD" _gitfile_opens(head) + _a_head_git_cannot_read(head) except OSError as exc: raise _unreadable_file(code, _named(root, named), exc, reads) from exc +# A GIT DIRECTORY'S `HEAD` THAT GIT CANNOT READ AS ONE (#1290, RV1239 on +# #1239, W1256 on #1263). The guard above opens a `HEAD` only where it is a +# regular file, since the open of a FIFO waits for a writer; but git's own +# open of it, in the pinned commit's `git rev-parse`, waited, and `git_out` +# has no timeout, so the gate gave no answer at all. A `HEAD` that `stat`, +# which follows a link, finds neither a file nor a directory (a FIFO, a +# socket or a device, or a symlink to one) is refused by name before git +# is asked. A socket, or a symlink to a FIFO or a device, which git does +# not wait on, ended the run in `pin-commit-unresolvable`, which says the +# pinned commit is missing; git reads a symlinked `HEAD` only where it +# names `refs/`. A directory, a symlink to a file or a directory, and a +# symlink that leads nowhere are left to git, as they were. The root's own +# `.git/HEAD`, asked here too (`_refuse_an_unreadable_root`, #1146), is +# `git-repo-unreadable` the same way. +# +# HERE, AND NOT AS A TIMEOUT IN `git_out` (#1290's choice (b)). `git_out` is +# `scripts/repo_shape.py`'s, a shape copy every root pins on its own and +# may hold older than this file (#370, #610), so a timeout there reaches no +# root whose copy predates it; it is shared by every tool, `tree_digest`'s +# `ls-tree -r` over a whole leg among them, with no flag to say how long is +# too long; and a call that timed out raises `subprocess.TimeoutExpired`, +# which no caller here catches (a traceback, exit 1, a finding's code), or, +# made a `git-failed` refusal, reads as `pin-commit-unresolvable` again. +# What else in a git directory git waits on, its `config` a FIFO among +# them (measured: no answer), is not asked here. + +def _a_head_git_cannot_read(head: Path) -> None: + """Raise an `OSError` whose reason says what stands there, as + `_what_stands` names it, for a git directory's `HEAD` that is neither a + file nor a directory (#1290), so `_refuse_an_unreadable_git_dir` names + it as it names a `HEAD` this user cannot open: `: cannot be read + (a FIFO, not a file)`, or `(a symlink to a device, not a file)`, with + the remedy for a cause that is no permission, which says to put a + regular file back in its place. `family-root`'s `validate-family.py` + raises the same words for a member's.""" + what = _what_stands( + head, lambda mode: stat.S_ISREG(mode) or stat.S_ISDIR(mode)) + if what is not None: + raise OSError(None, f"{what}, not a file" if what else "not a file") + + #: The refusal of a root whose own repository this user cannot read (#1146), #: and what that repository is read for, as `_unreadable_file`'s remedy #: names it. `validate-manifest.py` refuses its `.git` by the same code. diff --git a/templates/family-root/scripts/validate-family.py b/templates/family-root/scripts/validate-family.py index 5b13ae2a..a58fc935 100755 --- a/templates/family-root/scripts/validate-family.py +++ b/templates/family-root/scripts/validate-family.py @@ -715,7 +715,8 @@ def _refuse_an_unreadable_git_dir(checkout: Path, path: str) -> None: a FIFO waits for a writer, and an open that finds nothing is a git directory with no `HEAD`. One this user cannot open is named, `.git/modules//HEAD` or `/.git/HEAD`, with the same - remedy.""" + remedy. So is one git cannot read as a `HEAD`, a FIFO among them + (#1290): see `_a_head_git_cannot_read`.""" dotgit = checkout / ".git" named = f"{path}/.git" try: @@ -734,10 +735,60 @@ def _refuse_an_unreadable_git_dir(checkout: Path, path: str) -> None: if _is_a(head, _any_kind): named = named_head _gitfile_opens(head) + _a_head_git_cannot_read(head) except OSError as exc: raise _unreadable(MEMBER_UNREADABLE, named, exc) from exc +# A GIT DIRECTORY'S `HEAD` THAT GIT CANNOT READ AS ONE (#1290, RV1239 on +# #1239, W1256 on #1263). The guard above opens a `HEAD` only where it is a +# regular file, since the open of a FIFO waits for a writer; but git's own +# open of it, in the pinned commit's `git rev-parse`, waited, and `git_out` +# has no timeout, so the gate gave no answer at all. A `HEAD` that `stat`, +# which follows a link, finds neither a file nor a directory (a FIFO, a +# socket or a device, or a symlink to one) is refused by name before git +# is asked. A socket, or a symlink to a FIFO or a device, which git does +# not wait on, ended the run in `member-commit-unresolvable`, which says the +# pinned commit is missing; git reads a symlinked `HEAD` only where it +# names `refs/`. A directory, a symlink to a file or a directory, and a +# symlink that leads nowhere are left to git, as they were. The holder's +# own `HEAD` is not asked here (#1172's). +# +# HERE, AND NOT AS A TIMEOUT IN `git_out` (#1290's choice (b)). `git_out` is +# `scripts/repo_shape.py`'s, a shape copy every root pins on its own and +# may hold older than this file (#370, #610), so a timeout there reaches no +# root whose copy predates it; it is shared by every tool, `tree_digest`'s +# `ls-tree -r` over a whole leg among them, with no flag to say how long is +# too long; and a call that timed out raises `subprocess.TimeoutExpired`, +# which no caller here catches (a traceback, exit 1, a finding's code), or, +# made a `git-failed` refusal, reads as `member-commit-unresolvable` again. +# What else in a git directory git waits on, its `config` a FIFO among +# them (measured: no answer), is not asked here. + +#: What a `stat` mode that is neither a file nor a directory names, as +#: `validate-pins.py`'s `_KINDS` names it (#1290). A device is either kind. +_HEAD_KINDS = ( + (stat.S_ISFIFO, "a FIFO"), (stat.S_ISSOCK, "a socket"), + (stat.S_ISCHR, "a device"), (stat.S_ISBLK, "a device")) + + +def _a_head_git_cannot_read(head: Path) -> None: + """Raise an `OSError` whose reason says what stands there, in + `validate-pins.py`'s words for a leg's, for a member git directory's + `HEAD` that is neither a file nor a directory (#1290), so + `_refuse_an_unreadable_git_dir` names it as it names a `HEAD` this user + cannot open: `: cannot be read (a FIFO, not a file)`, or `(a + symlink to a device, not a file)`, with the remedy for a cause that is + no permission, which says to put a regular file back in its place. Its + `stat`, then its own `lstat`, as that gate's `_what_stands` asks.""" + mode = head.stat().st_mode + if stat.S_ISREG(mode) or stat.S_ISDIR(mode): + return + link = "a symlink to " if stat.S_ISLNK(head.lstat().st_mode) else "" + kind = next((name for test, name in _HEAD_KINDS if test(mode)), None) + raise OSError(None, f"{link}{kind}, not a file" if kind else "not a file") + + def _checkout_gone(root: Path, path: str) -> bool: """`_gone` of the `.git` at the gitlink path `path`, or `member-unreadable` naming it where its `stat` was refused for any diff --git a/tests/test_validate_family_unreadable.py b/tests/test_validate_family_unreadable.py index c22d021f..f321c88e 100644 --- a/tests/test_validate_family_unreadable.py +++ b/tests/test_validate_family_unreadable.py @@ -53,6 +53,14 @@ regular file, and refused `member-unreadable`, naming that `HEAD`; a parity row holds the name to the gate's. +A MEMBER GIT DIRECTORY'S `HEAD` THAT GIT CANNOT READ AS ONE (#1290), as +the gate's leg's: a FIFO is not opened by #1256's guard, but git's open of +it waits for a writer, and the gate gave no answer in 60 s. A `HEAD` that +is neither a file nor a directory (a FIFO, a socket or a device, or a +symlink to one) is `member-unreadable`, naming it and what stands there, +before git is asked; each run over a FIFO is bounded, and a parity row +holds the line to the gate's. + WHAT IS ON DISK IS ASKED ONLY WHERE IT IS SAID (round 2, r956's SHOULD 1 on #956): `_forced` and `_staged_over` ask git first, and where HEAD records a gitlink at the index's own commit, the disk under it is never @@ -98,6 +106,9 @@ from test_validate_family_unnamed_mounts import ( codes, commands, findings, spare_commits, stage_gitlink, ) +from test_validate_pins_spelled_and_named import ( + FIFO_HEADS, NOT_HEADS, bounded, +) OTHER_ROW = f"members/{OTHER}" HAND = f"members/{SPARE}" @@ -1027,6 +1038,26 @@ def test_a_member_git_directory_head_whose_open_is_refused_is_refused( assert "member-commit-unresolvable" not in err +@pytest.mark.parametrize("make, reason", FIFO_HEADS) +@pytest.mark.parametrize("made,head", HEADS) +def test_a_member_fifo_head_is_refused_by_name_within_a_bound( + holder, request, made, head, make, reason): + """#1290, W1256 on #1263: the holder's own gate, the member's git + directory's `HEAD` a FIFO, or a symlink to one. #1256's guard does not + open it, but `git rev-parse` did, and waited for a writer: the gate gave + no answer in 60 s. It is `member-unreadable`, exit 2, naming that `HEAD` + and what stands there, with the advice for a cause that is no + permission, before git is asked.""" + extra = made(holder, request) + (holder / head).rename(holder / (head + "-aside")) + make(holder / head) + result = bounded(holder / "scripts" / "validate-family.py", holder, + *extra) + assert_refused(result.returncode, result.stderr, MEMBER_UNREADABLE, head, + reason) + assert "member-commit-unresolvable" not in result.stderr + + def test_the_member_tree_asked_alone_names_a_head_whole_outside( holder, request, monkeypatch): """`_check_member_tree` asked alone, the open of the `HEAD` refused: it @@ -1183,22 +1214,16 @@ def test_a_member_head_is_named_as_the_gate_names_a_leg_s( @pytest.mark.parametrize("own_directory", OWN_DIRECTORY) -@pytest.mark.parametrize("make", [ - pytest.param(lambda head: head.mkdir(), id="directory"), - pytest.param(lambda head: os.mkfifo(head), id="fifo", - marks=pytest.mark.skipif(not hasattr(os, "mkfifo"), - reason="no FIFO on this platform")), -]) -def test_a_member_head_that_is_no_regular_file_is_not_opened( - sources, tmp_path, monkeypatch, own_directory, make): +def test_a_member_head_that_is_a_directory_is_left_to_git( + sources, tmp_path, monkeypatch, own_directory): """Only a `HEAD` that is a regular file is opened (#1256), in the family - gate as in the leg gate (#1222): 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.""" + gate as in the leg gate (#1222), and one that is a directory is git's to + answer for, as it always was: the control for #1290's rows. An open + fails the row.""" gitdir = member_git_directory(tmp_path, own_directory) head = gitdir / "HEAD" head.rename(gitdir / "HEAD-aside") - make(head) + head.mkdir() failing(monkeypatch, "open", "HEAD", OSError(errno.EIO, "opened")) assert sources.family._refuse_an_unreadable_git_dir(tmp_path / ROW, ROW) is None @@ -1206,6 +1231,34 @@ def test_a_member_head_that_is_no_regular_file_is_not_opened( tmp_path, tmp_path / ROW / ".git") is None +@pytest.mark.parametrize("own_directory", OWN_DIRECTORY) +@pytest.mark.parametrize("make, reason", NOT_HEADS) +def test_a_member_head_git_cannot_read_is_named_as_the_gate_names_a_leg_s( + sources, tmp_path, monkeypatch, own_directory, make, reason): + """PARITY (#1290): a `HEAD` that `stat`, which follows a link, finds + neither a file nor a directory, a FIFO, a socket, or a symlink to a FIFO + or a device, is refused unopened, and the family gate's line is the leg + gate's, naming the same `HEAD` and what stands there. The guards are + asked alone, so that where one lets it through, the row fails and git + is never started on a FIFO.""" + gitdir = member_git_directory(tmp_path, own_directory) + head = gitdir / "HEAD" + head.rename(gitdir / "HEAD-aside") + make(head) + failing(monkeypatch, "open", "HEAD", OSError(errno.EIO, "opened")) + family_refusal = refusal_of( + sources.family, sources.family._refuse_an_unreadable_git_dir, + tmp_path / ROW, ROW) + gate_refusal = refusal_of( + sources.gate, sources.gate._refuse_an_unreadable_git_dir, tmp_path, + tmp_path / ROW / ".git") + named = head.relative_to(tmp_path).as_posix() + assert family_refusal.code == MEMBER_UNREADABLE + assert family_refusal.detail == f"{named}: cannot be read ({reason})" + assert gate_refusal.code == "pin-submodule-unreadable" + assert gate_refusal.detail == family_refusal.detail + + def test_a_member_head_no_system_call_can_name_is_left_to_git(sources, tmp_path): """A `gitdir: ` line that holds a NUL names a git directory no system diff --git a/tests/test_validate_pins_spelled_and_named.py b/tests/test_validate_pins_spelled_and_named.py index da69389d..24f97420 100644 --- a/tests/test_validate_pins_spelled_and_named.py +++ b/tests/test_validate_pins_spelled_and_named.py @@ -51,6 +51,16 @@ under THE LOCKSTEP RULE. It is opened as a gitfile is, and refused `pin-submodule-unreadable`, naming that `HEAD`. +What #1222 left, #1290, from RV1239's review of #1239: + + * A `HEAD` that is a FIFO is not opened by #1222's guard, but git's own + open of it waits for a writer, and `git_out` has no timeout, so the + gate gave no answer in 45 s. A `HEAD` that is neither a file nor a + directory (a FIFO, a socket or a device, or a symlink to one) is + refused `pin-submodule-unreadable` by name, before git is asked. Each + run of the gate over a FIFO is bounded (`bounded`), so a hang fails + its row and not the run. + 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 @@ -68,6 +78,7 @@ import hashlib import json import os +import signal import socket import stat import subprocess @@ -76,7 +87,9 @@ import pytest -from conftest import REPO, WINDOWS_SKIP, run_script +from conftest import ( + REPO, WINDOWS_SKIP, blank_unnamed_pin_sources, 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, @@ -977,22 +990,143 @@ def test_a_head_gone_when_it_is_opened_is_left_to_git(validator, project, 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.""" +def test_a_head_that_is_a_directory_is_left_to_git(validator, tmp_path, + monkeypatch): + """Only a `HEAD` that is a regular file is opened, and one that is a + directory is git's to answer for, as it always was (#1222, #1290): the + control for #1290's rows below. An open fails the row.""" dotgit = tmp_path / "leg" / ".git" dotgit.mkdir(parents=True) - make(dotgit / "HEAD") + made_directory(dotgit / "HEAD") failing(monkeypatch, "open", {"HEAD": AssertionError("opened")}) assert validator._refuse_an_unreadable_git_dir(tmp_path, dotgit) is None +# --- #1290: a leg git directory's `HEAD` that git cannot read as one --------- + +#: How long one run of the gate may take before its row calls it hung +#: (#1290). The gate answers this project in a second or two; over a FIFO +#: `HEAD` it gave no answer in 45 s (RV1239) or in 60 s (W1256). +BOUND = 30 + + +def bounded(script: Path, root: Path, + *args: str) -> subprocess.CompletedProcess: + """`script` run in `root` as `run_script` runs a tool, but in a session + of its own and for `BOUND` seconds at most (#1290). A run that has not + ended by then is killed with every process in its session, the git it + waits in among them, so no git is left waiting on a FIFO, and the row + fails: a hang fails its row, never the whole run. POSIX only, as a + FIFO is.""" + env = blank_unnamed_pin_sources({**os.environ, "LANES_LANE": ""}, {}) + with subprocess.Popen( + [sys.executable, str(script), *args], cwd=str(root), + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, env=env, start_new_session=True) as proc: + try: + out, err = proc.communicate(timeout=BOUND) + except subprocess.TimeoutExpired: + os.killpg(proc.pid, signal.SIGKILL) + proc.communicate() + pytest.fail(f"{script.name} gave no answer in {BOUND} s") + return subprocess.CompletedProcess( + proc.args, proc.returncode, out.decode("utf-8", "replace"), + err.decode("utf-8", "replace")) + + +def made_link_to_a_fifo(path: Path) -> None: + os.mkfifo(path.with_name(path.name + "-fifo")) + os.symlink(path.name + "-fifo", path) + + +def made_link_to_a_device(path: Path) -> None: + os.symlink(os.devnull, path) + + +#: What can stand where a git directory's `HEAD` should be that git cannot +#: read as one, and the reason the gate names it by (#1290). A device is +#: made only by root, so its row is a symlink to one. +NOT_HEADS = [ + pytest.param(made_fifo, "a FIFO, not a file", id="fifo", + marks=POSIX_NODES), + pytest.param(made_socket, "a socket, not a file", id="socket", + marks=POSIX_NODES), + pytest.param(made_link_to_a_fifo, "a symlink to a FIFO, not a file", + id="link-to-fifo", marks=POSIX_NODES), + pytest.param(made_link_to_a_device, "a symlink to a device, not a file", + id="link-to-device", marks=POSIX_NODES), +] + +#: The two of them git waits on. +FIFO_HEADS = NOT_HEADS[0::2] + + +def in_head_s_place(project: Path, own_directory: bool, head: str, + make) -> None: + """`make` where the leg's git directory's `HEAD` is, the `HEAD` itself + taken out of the way: the leg's own `.git` directory's, or the one + under `.git/modules/` its gitfile names.""" + if own_directory: + leg_with_its_own_git_directory(project) + (project / head).rename(project / (head + "-aside")) + make(project / head) + + +@pytest.mark.parametrize("own_directory, head", HEADS) +@pytest.mark.parametrize("make, reason", FIFO_HEADS) +def test_a_fifo_head_is_refused_by_name_within_a_bound( + project, own_directory, head, make, reason): + """#1290, RV1239 on #1239: the project's own gate, the leg's git + directory's `HEAD` a FIFO, or a symlink to one. #1222's guard does not + open it, but `git rev-parse` did, and waited for a writer: the gate gave + no answer. It is `pin-submodule-unreadable`, naming that `HEAD` and what + stands there, with the advice for a cause that is no permission, exit 2, + before git is asked, and nothing is written.""" + in_head_s_place(project, own_directory, head, make) + before = tree_bytes(project) + result = bounded(project / "scripts" / "validate-pins.py", project) + assert result.stderr == ( + f"REFUSED pin-submodule-unreadable: {head}: cannot be read " + f"({reason})\n{CHECKOUT_READS}{OTHER}{AGAIN}\n"), result.stderr + assert result.returncode == 2, result.stdout + result.stderr + assert tree_bytes(project) == before, "a validator writes nothing" + + +@pytest.mark.parametrize("own_directory, head", HEADS) +@pytest.mark.parametrize("make, reason", NOT_HEADS) +def test_a_head_git_cannot_read_is_refused_by_name_unopened( + validator, project, monkeypatch, own_directory, head, make, reason): + """#1290 in-process, each kind: a FIFO, a socket, and a symlink to a + FIFO or a device, `stat`, which follows a link, finds no file and no + directory. Each is refused by name, as a `HEAD` this user cannot open + is, and none is opened. The guard is asked alone, so that where it lets + one through, the row fails and git is never started on a FIFO.""" + in_head_s_place(project, own_directory, head, make) + failing(monkeypatch, "open", {"HEAD": AssertionError("opened")}) + with pytest.raises(validator.Refusal) as caught: + validator._refuse_an_unreadable_git_dir(project, + project / "spec" / ".git") + assert caught.value.code == "pin-submodule-unreadable" + assert caught.value.detail == f"{head}: cannot be read ({reason})" + assert caught.value.remediation == CHECKOUT_READS + OTHER + AGAIN + + +@POSIX_NODES +def test_the_root_s_own_fifo_head_is_refused_by_name_within_a_bound(project): + """#1290: the root's own `.git/HEAD` a FIFO. `_refuse_an_unreadable_root` + asks the root's `.git` what it asks a leg's (#1146), so it is + `git-repo-unreadable`, naming `.git/HEAD`, where git waited on it and + the gate gave no answer.""" + head = project / ".git" / "HEAD" + head.rename(project / ".git" / "HEAD-aside") + made_fifo(head) + result = bounded(project / "scripts" / "validate-pins.py", project) + assert result.stderr.splitlines()[0] == ( + "REFUSED git-repo-unreadable: .git/HEAD: cannot be read " + "(a FIFO, not a file)"), result.stderr + assert result.returncode == 2, result.stdout + result.stderr + + 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