diff --git a/templates/family-root/scripts/validate-family.py b/templates/family-root/scripts/validate-family.py index cd5dd1a5..49dc35b2 100755 --- a/templates/family-root/scripts/validate-family.py +++ b/templates/family-root/scripts/validate-family.py @@ -138,6 +138,17 @@ whose `.git` file names that member's store, is named, with its `git -C` status, before the update that points the store at the row's path. +A PATH THIS USER CANNOT READ IS NEVER ABSENT (#430). Every guard asks the +operating system for the path's `stat` (`_is_a`), the same answer on every +Python, and one it refuses is refused by name, `: cannot be read +()`, with a cure for the cause, exit 2: a member's checkout or what +is on disk at any gitlink `member-unreadable`, the shape pin +`shape-pin-unreadable`, a shape copy `shape-copy-unreadable` and +`family.yaml` `family-manifest-unreadable`. A member's `project.yaml` is +the finding `member-manifest-unreadable`, exit 1, as one that cannot be +opened already was. Python 3.14's `is_file()` and `exists()` read each as +not there. + EXIT CODES 0 the manifest is valid, every member is in lockstep, every copy matches 1 a FINDING: drift a reviewer can act on @@ -147,21 +158,23 @@ `.gitmodules` gives a value git dies on: those are `member-gitmodules-absent`, `member-gitmodules-url-absent`, `member-gitlink-removal-staged` and `gitmodules-value-refused`, - exit 1), an unreadable pin, no git to run. An unanswerable question - is never an implicit pass. + exit 1), an unreadable pin, a path this user cannot read (#430), no + git to run. An unanswerable question is never an implicit pass. """ from __future__ import annotations import argparse +import errno import os import re import shlex +import stat import sys from collections import Counter from contextlib import contextmanager from pathlib import Path -from typing import Iterator, NamedTuple +from typing import Callable, Iterator, NamedTuple sys.path.insert(0, str(Path(__file__).resolve().parent)) from repo_shape import ( # noqa: E402 @@ -322,6 +335,199 @@ def _git_runs(unread: str) -> Iterator[None]: "or fetch its members without git either.") from exc +# --------------------------------------------------------------------------- +# A path this user cannot read (#430) +# --------------------------------------------------------------------------- +# +# `Path.is_file()`, `is_dir()` and `exists()` answer differently by Python: +# up to 3.13 they raised a refused `stat` (`PermissionError`) and answered +# False only where nothing was there; from 3.14 they call `os.path.isfile` +# and the like, which answer False to ANY `OSError`. So a member checkout +# this user cannot search ended this run in a traceback on 3.12, and on +# 3.14 was refused `member-uninitialized`, whose `git submodule update` +# then failed on the checkout that was there; a shape copy in a folder +# this user cannot search was `pinned but absent`, the shape pin and +# `family.yaml` `does not exist`, and a staged removal's lines ended on an +# update of a checkout that was there. Every guard here asks the operating +# system itself now, as `bump-leg.py` and `validate-pins.py` do, and a +# `stat` it refuses is refused by name. +# +# THE `os.path.lexists` OF `.gitmodules` STAY, deliberately: it is a name +# at the holder's root, whose `lstat` needs no access the read of +# `family.yaml` before it did not already have, and git reads that file +# by the same `lstat`. + +#: The `OSError`s that `Path.is_file()` and `Path.is_dir()` answered False to +#: on Python 3.9 to 3.13 (pathlib's `_ignore_error`), because each means there +#: is nothing at the name to open: no such file, a part of the path that is +#: not a directory, a bad descriptor, a symlink loop, and Windows' "not +#: ready", "invalid name" and "cannot resolve". Every other one was raised. +_ABSENT_ERRNOS = (errno.ENOENT, errno.ENOTDIR, errno.EBADF, errno.ELOOP) +_ABSENT_WINERRORS = (21, 123, 1921) + + +def _is_a(path: Path, kind: Callable[[int], bool]) -> bool: + """Whether `path` is there and of `kind` (`stat.S_ISREG`, `stat.S_ISDIR`, + `_any_kind`), raising the `OSError` of a `stat` the operating system + REFUSED (#430). + + NOT `Path.is_file()`, `is_dir()` OR `exists()`, whose answer depends on + the Python. Up to 3.13 they raised a refused `stat` (`PermissionError`) + and answered False only when nothing was there; from 3.14 they call + `os.path.isfile` and the like, which answer False to ANY `OSError`. On + 3.14 a member checkout under a `members/` this user cannot search was + "not an initialized submodule checkout" (`member-uninitialized`, whose + update then failed `could not open ... for writing`), and a shape copy + in a folder it can list but not search was "pinned but absent". Asked + here, every Python answers as 3.9 to 3.13 did, and a refused `stat` is + a refusal that names the path. `bump-leg.py`'s, word for word. + """ + try: + mode = path.stat().st_mode + except OSError as exc: + if (exc.errno in _ABSENT_ERRNOS + or getattr(exc, "winerror", None) in _ABSENT_WINERRORS): + return False + raise + except ValueError: + # a name the operating system cannot take at all (a NUL in it) + return False + return kind(mode) + + +def _any_kind(_mode: int) -> bool: + """`_is_a`'s kind for anything at all, what `exists()` asked.""" + return True + + +def _gone(path: Path) -> bool: + """Whether NOTHING is at `path` (`ENOENT`), asked BEFORE A LINE THAT PUTS + SOMETHING THERE OR NAMES WHAT TO DELETE (#430): the staged removal's + `git submodule update` that checks a member out at its path + (`_put_back_lines`), and the checkouts it names to delete by hand once + none is at that path (`_moved_checkouts`). + + STRICTER THAN `_is_a`: a part of the path that is not a directory + (`ENOTDIR`, a file where `members/

` should be), a symlink loop and + every other `OSError` is raised, since each says something IS on the + way, which that update fails on and which no checkout moved away. As + `family.py` asks before its clean-ups (#430's slice G). A name the + operating system cannot take at all holds nothing, as `exists()` + answered it.""" + try: + path.stat() + except FileNotFoundError: + return True + except ValueError: + return True + return False + + +#: What a path the operating system refused for ANOTHER reason than +#: permission is told: an I/O error, a name longer than the filesystem +#: takes, a part of the path that is a file. No `chmod` puts one right. +#: `validate-pins.py`'s and `bump-leg.py`'s words for the same refusal +#: (#383, #712). +OTHER_REASON = ( + "Where the operating system gave another reason, put a regular file this " + "user can open back in its place, or take away one nobody wants, and make " + "sure nothing is changing the directory while this runs. ") + +#: The sentence every refusal of a path this validator cannot read ends on. +RUN_AGAIN = "Then run the same command again." + +#: What this validator reads, as `_unreadable`'s remedy says it. +PATHS_READ = (f"{MANIFEST}, the shape pin, every copy it lists, each " + "member's checkout and its project.yaml, and what is on disk " + "at each gitlink this holder records") + +#: A checkout, or what is on disk at a gitlink, whose `stat` the operating +#: system refused (#430): a member's `.git`, a case variant or a stray the +#: holder records, the checkout `git mv` moved. The one new code: no code +#: here meant "a checkout cannot be read", and `member-uninitialized`, what +#: Python 3.14 made of it, sent the person after a checkout that is there. +MEMBER_UNREADABLE = "member-unreadable" + + +def _advice(exc: OSError) -> str: + """What to do about `exc`, by its cause, in `validate-pins.py`'s words + (#712): a permission (`PermissionError`, which is `EACCES` and `EPERM`) + is put right by the mode or the owner, and any other cause is + `OTHER_REASON`, which is not told to `chmod`.""" + return ("Make what this user may not open readable by this user: `chmod " + "u+r` 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) + + +def _cannot_read(named: str, exc: OSError) -> str: + """`: cannot be read ()`, the line + for a path whose `stat` or open the operating system refused, in + `validate-pins.py`'s words (#359, #712). `named` is relative to the + holder, and spelled (`spelled`).""" + return (f"{spelled(named)}: cannot be read " + f"({exc.strerror or type(exc).__name__})") + + +def _unreadable(code: str, named: str, exc: OSError) -> Refusal: + """`code`'s refusal, exit 2, for the path `named` whose `stat` or open + the operating system refused (#430): `_cannot_read`'s line and a remedy + that follows the cause (`_advice`), as `validate-pins.py`'s + `_unreadable_file` says it. A path this validator must read and cannot + is a question it cannot answer, never "absent".""" + return Refusal( + code, _cannot_read(named, exc), + f"Remediation: this validator reads {PATHS_READ}, and one it cannot " + "read is a question it cannot answer, never a pass. " + + _advice(exc) + RUN_AGAIN) + + +def _asked(path: Path, kind: Callable[[int], bool], code: str, + named: str) -> bool: + """`_is_a(path, kind)`, or `code`'s refusal naming `path` as `named` + (`_unreadable`) where its `stat` was refused (#430).""" + try: + return _is_a(path, kind) + except OSError as exc: + raise _unreadable(code, named, exc) from exc + + +def _checked_out(checkout: Path, path: str) -> bool: + """Whether a `.git` entry is in `checkout`, the gitlink path `path` + (what `exists()` asked of it), or `member-unreadable` naming + `/.git` where its `stat` was refused (#430).""" + return _asked(checkout / ".git", _any_kind, MEMBER_UNREADABLE, + f"{path}/.git") + + +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 + reason but `ENOENT` (#430).""" + try: + return _gone(root / path / ".git") + except OSError as exc: + raise _unreadable(MEMBER_UNREADABLE, f"{path}/.git", exc) from exc + + +def _anything_at(where: Path, named: str) -> bool: + """Whether anything is at `where`, a symbolic link that leads nowhere + included, as `os.path.lexists` answered it, or `member-unreadable` + naming it as `named` where its `stat` was refused (#430). + + `os.path.lexists` answered False to ANY `OSError`, on every Python, so + a gitlink path in a folder this user cannot search was "nothing on + disk" (#430). A symbolic link there is on disk, asked by its own + `lstat` first (`os.path.islink`), as `lexists` asks it: where it leads + is not asked, so a link that leads nowhere, or into a folder this user + cannot search, is something on disk and no refusal (r956's trial on + #956). Anything else is asked by its `stat`, through `_asked`; where + the `lstat` was refused, so is that `stat`, and the path is refused by + name.""" + return (os.path.islink(where) + or _asked(where, _any_kind, MEMBER_UNREADABLE, named)) + + def _check_schema_kind_and_id(manifest: dict, report: Report) -> None: """`schema_version`, `kind` and `id` — three fixed-shape fields that carry no lookup and no branch of their own, split from `_check_envelope` for @@ -851,11 +1057,17 @@ def _forced(root: Path, path: str, commit: str) -> str | None: that holds files and no `.git`, a file or a symbolic link, and takes it over an empty directory or nothing (git 2.43, rv882's NIT 2 on #882). A gitlink HEAD alone records, at a staged removal or a conflict, is - read at HEAD's commit and takes none.""" - if not _on_disk(root, path): - return None + read at HEAD's commit and takes none. + + GIT IS ASKED FIRST, AND THE DISK ONLY WHERE THE ANSWER TURNS ON IT + (#430, r956's SHOULD 1 on #956): where HEAD records the gitlink at the + index's own commit, no `-f` is printed whatever is on disk, so a path + in a folder this user cannot search is not asked there, and the + finding is said as it always was, not refused `member-unreadable`.""" head = _head_records(root, path) - return None if head == commit else head + if head == commit or not _on_disk(root, path): + return None + return head def _head_records(root: Path, path: str) -> str: @@ -884,8 +1096,14 @@ def _staged_over(root: Path, path: str, commit: str) -> bool: where it differs too, a commit or an untracked file in the checkout `git mv` moved, a directory holding a file, or a file (lane 1's SHOULD on #889, round 2, and its NITs 1 and 2, round 3).""" - return (os.path.lexists(root / path) - and _head_records(root, path) != commit) + # Git first (r956's SHOULD 1 on #956): where HEAD records the gitlink + # at the index's own commit, nothing is said of the disk, so it is not + # asked. Where the answer turns on it, a path whose `stat` is refused, + # in a folder this user cannot search, is `member-unreadable` + # (`_anything_at`, #430): `os.path.lexists` read it as nothing there, + # and the refusal went unsaid. + return (_head_records(root, path) != commit + and _anything_at(root / path, path)) def _on_disk(root: Path, path: str) -> bool: @@ -895,11 +1113,16 @@ def _on_disk(root: Path, path: str) -> bool: and no `.git` (rv882's NIT 2 on #882), a file and a symbolic link, and took it over an empty directory and over nothing. A directory this user cannot list is taken to hold something: `-f`, with `--cached`, touches - no file.""" + no file. A path whose `stat` is refused, in a folder this user cannot + search, is `member-unreadable` (`_anything_at`, #430): asked with + `os.path.lexists`, it was nothing on disk, and the `-f` git needs there + was not printed.""" where = root / path - if not os.path.lexists(where): + if not _anything_at(where, path): return False - if where.is_symlink() or not where.is_dir(): + # After that `stat`, `lstat` needs no more access: a link is on disk. + if os.path.islink(where) or not _asked(where, stat.S_ISDIR, + MEMBER_UNREADABLE, path): return True try: with os.scandir(where) as entries: @@ -910,11 +1133,22 @@ def _on_disk(root: Path, path: str) -> bool: def _held_as(root: Path, path: str) -> str: """What the `-f` sentence says is on disk at `path` (`_forced_said`): - `CHECKOUT` where a `.git` entry is there, else `NO_CHECKOUT`.""" - return (CHECKOUT if os.path.lexists(root / path / ".git") + `CHECKOUT` where a `.git` entry is there, else `NO_CHECKOUT`, and + `member-unreadable` where its `stat` was refused (`_anything_at`, + #430): a checkout this user cannot search was said to be no checkout. + Asked only where the `-f` is printed.""" + return (CHECKOUT if _anything_at(root / path / ".git", f"{path}/.git") else NO_CHECKOUT) +def _held_if_forced(root: Path, path: str, head: str | None) -> str: + """`_held_as` where `_forced` answered `head` for the gitlink at `path` + and the `-f` sentence says it, else `CHECKOUT`, which `_forced_said` + does not read: a `.git` this user cannot search is refused only where + what is said of it is printed (#430).""" + return CHECKOUT if head is None else _held_as(root, path) + + def _wrong_case_move(path: str, commit: str, variants: list[str], context: MoveContext = MoveContext()) -> str: """The index move for a member recorded only at `variants`, case @@ -1111,8 +1345,14 @@ def _check_member_tree(member: Path, pin: dict, path: str, commit: str, ref`, and THE LOCKSTEP RULE's `bump` for a pin no remote holds (#762, rv681's P2: on the remote, the fetch put it in the store and the gate passed; on none, the fetch and `git submodule update` both exit 128). + + A CHECKOUT THIS USER CANNOT SEARCH is `member-unreadable`, naming its + `.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. """ - if not (member / ".git").exists(): + if not _checked_out(member, path): raise Refusal( "member-uninitialized", f"{path} is not an initialized submodule checkout, so neither the " @@ -1236,19 +1476,22 @@ def _check_member_checkout(root: Path, asked: Gitlinked, report: Report, section gives no url, and one whose removal is staged (#762), each the finding of its own. `stop` is what `member-uninitialized` says first where git's init would stop at a gitlink it has no url for - (`_init_stop_said`). + (`_init_stop_said`). A checkout this user cannot search is + `member-unreadable` (`_checked_out`, #430): on Python 3.14 it was + one not checked out, and its digest and identity were not asked. """ member = root / asked.path - if asked.path in unregistered and not (member / ".git").exists(): + if asked.path in unregistered and not _checked_out(member, asked.path): return with _git_runs(f"{asked.project}'s pinned commit and its tree digest " f"could not be read in {asked.path}"): _check_member_tree(member, asked.pin, asked.path, asked.commit, asked.project, report, asked.rule, stop) - _check_member_identity(member, asked.row, asked.project, report) + _check_member_identity(member, asked.row, asked.project, asked.path, + report) -def _check_member_identity(member: Path, row: dict, project: str, +def _check_member_identity(member: Path, row: dict, project: str, path: str, report: Report) -> None: """The mounted tree is the PROJECT this row claims, not just a commit. @@ -1256,24 +1499,45 @@ def _check_member_identity(member: Path, row: dict, project: str, is the SOURCE of what that project is. The row here is a pin of it. Where the two disagree the row is wrong, because a family cannot rename a project by describing it differently. + + A `project.yaml` WHOSE `stat` IS REFUSED is `member-manifest-unreadable`, + the finding one that cannot be opened already is, naming it and the + cure (#430): on Python 3.14 it was `member-not-a-project`, "a family + pins assembly roots, never legs", of a project that is one. ONE CODE, + ONE TEXT: one that cannot be OPENED says the same, `: cannot be + read ()` and the cure for that cause (r956's SHOULD 2 on #956), + where it named the file twice, relative and then absolute + (`load_yaml`'s `yaml-unreadable` line), and gave no cure. `path` is the + row's path as `_check_member_repository_and_path` resolved it, so a + row that gives none is named at `members/

`, not `None` (r956's NIT 1). """ manifest_path = member / "project.yaml" - if not manifest_path.is_file(): + named = f"{path}/project.yaml" + try: + is_file = _is_a(manifest_path, stat.S_ISREG) + except OSError as exc: + report.finding("member-manifest-unreadable", + f"{_cannot_read(named, exc)}. {_advice(exc)}" + + RUN_AGAIN) + return + if not is_file: report.finding( "member-not-a-project", - f"{row.get('path')}: no project.yaml in the mounted tree. A member " + f"{path}: no project.yaml in the mounted tree. A member " "is a project's ASSEMBLY ROOT — the repository an engineer clones " "— and a family pins assembly roots, never legs.") return try: manifest = load_yaml(manifest_path) except Refusal as exc: - report.finding("member-manifest-unreadable", - f"{row.get('path')}/project.yaml: {exc.detail}") + cause = exc.__cause__ + said = (f"{_cannot_read(named, cause)}. {_advice(cause)}{RUN_AGAIN}" + if isinstance(cause, OSError) else f"{named}: {exc.detail}") + report.finding("member-manifest-unreadable", said) return if not isinstance(manifest, dict): report.finding("member-manifest-unreadable", - f"{row.get('path')}/project.yaml: not a mapping") + f"{named}: not a mapping") return recorded_id = row.get("id") actual_id = manifest.get("id") @@ -1285,11 +1549,11 @@ def _check_member_identity(member: Path, row: dict, project: str, elif str(recorded_id) != str(actual_id): report.finding( "member-id-mismatch", - f"{row.get('path')}: project.yaml declares id {actual_id!r} but " + f"{path}: project.yaml declares id {actual_id!r} but " f"{MANIFEST} records {recorded_id!r} for {project}. The project's " "own manifest is the source; this row is a pin of it.") else: - report.note(f"{row.get('path')}: project.yaml id {actual_id}") + report.note(f"{path}: project.yaml id {actual_id}") def _load_shape_pin(root: Path) -> tuple[dict, str]: @@ -1297,10 +1561,14 @@ def _load_shape_pin(root: Path) -> tuple[dict, str]: Split from `_check_shape_pin` for #137. Raises `Refusal` for a pin this validator cannot read at all — absent, or not a mapping — the same two - ways every other REFUSAL in this file is unreadable. + ways every other REFUSAL in this file is unreadable. A pin whose `stat` + is refused, in a `contracts/` this user cannot search, is + `shape-pin-unreadable` by name (`_asked`, #430): on Python 3.14 it was + `shape-pin-missing`, a present file said not to exist. """ pin_path = root / "contracts" / "shape-pin.yaml" - if not pin_path.is_file(): + if not _asked(pin_path, stat.S_ISREG, "shape-pin-unreadable", + "contracts/shape-pin.yaml"): raise Refusal("shape-pin-missing", f"{pin_path} does not exist") pin = load_yaml(pin_path) # POSIX, on every platform: a finding names a path in THIS repository, @@ -1344,6 +1612,13 @@ def _check_one_shape_file(root: Path, rel: str, row, report: Report) -> None: 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). + + A copy whose `stat` or read is refused is `shape-copy-unreadable`, a + refusal naming it as the row does (#430), `validate-pins.py`'s code for + the same copy (#712): `is_file()` raised a refused `stat` out of the + run up to Python 3.13, and called the copy "pinned but absent" on 3.14; + a copy at mode 000 ended in `file_sha256`'s `PermissionError` on every + Python. """ if not isinstance(row, dict): report.finding("shape-pin-row", f"{rel}: a files row is not a mapping") @@ -1354,11 +1629,14 @@ def _check_one_shape_file(root: Path, rel: str, row, report: Report) -> None: f"{path!r}, not the path of a file") return target = root / path - if not target.is_file(): + if not _asked(target, stat.S_ISREG, "shape-copy-unreadable", path): report.finding("shape-copy-missing", f"{rel}: {row.get('path')} is pinned but absent") return - actual = file_sha256(target) + try: + actual = file_sha256(target) + except OSError as exc: + raise _unreadable("shape-copy-unreadable", path, exc) from exc if actual != str(row.get("sha256", "")).lower(): report.finding( "shape-copy-drift", @@ -2367,7 +2645,7 @@ def _second_path_out(root: Path, path: str, commit: str, else "Take") + " it out of the index alone, which touches no file" + _forced_said(head, "the second line" if stage_first - else "the line", _held_as(root, path)) + else "the line", _held_if_forced(root, path, head)) + ":\n " + (f"{STAGE_LINE}\n " if stage_first else "") + _cached_removal(path, head)) @@ -2767,7 +3045,7 @@ def _gitlink_out_first(root: Path, name: str, keys: dict[str, str], "any member: " + (STAGE_SAID if stage else "") + f"take it out of the index alone, which touches no file, with " f"{nth}" - + _forced_said(head, nth, _held_as(root, path)) + "." + + _forced_said(head, nth, _held_if_forced(root, path, head)) + "." + _section_out(name, keys, "Then, with the lines after it, take", then, (f"\n {STAGE_LINE}" if stage else "") + f"\n {_cached_removal(path, head)}")) @@ -3037,16 +3315,23 @@ def _moved_checkouts(root: Path, path: str, gitlinks: dict[str, str], whose `.git` is a DIRECTORY, a clone of its own, which holds its commits there and nowhere else. It was said to be the checkout `git mv` moved, to delete by hand, which lost a commit only it held (lane - 1's SHOULD 2 on #889).""" - if (root / path / ".git").exists(): + 1's SHOULD 2 on #889). + + NONE IS AT `path` ONLY WHERE NOTHING IS (`_checkout_gone`, ENOENT + alone), and a `.git` this user cannot search, at `path` or at another + gitlink, is `member-unreadable` (#430): on Python 3.14 a checkout + under a folder it cannot search was none, or named nothing, so the + checkout `git mv` moved went unnamed again.""" + if not _checkout_gone(root, path): return [], [] store = _store(root, path, sections) others = [other for other in sorted(gitlinks) if other != path] moved = [other for other in others - if _names_store(root / other / ".git", store)] + if _names_store(root, other, store)] clones = [other for other in others if other.casefold() == path.casefold() - and (root / other / ".git").is_dir()] + and _asked(root / other / ".git", stat.S_ISDIR, + MEMBER_UNREADABLE, f"{other}/.git")] return moved, clones @@ -3071,16 +3356,22 @@ def _store(root: Path, path: str, common_dir / "modules" / (names[-1] if names else path))) -def _names_store(gitfile: Path, store: str) -> bool: - """Whether `gitfile` is a `.git` FILE that names `store` (`_store`), read - as git reads one: `gitdir: `, trailing blanks stripped, the path - relative to the file's directory unless it is absolute.""" - if not store or not gitfile.is_file(): +def _names_store(root: Path, path: str, store: str) -> bool: + """Whether the `.git` at the gitlink path `path` is a FILE that names + `store` (`_store`), read as git reads one: `gitdir: `, trailing + blanks stripped, the path relative to the file's directory unless it is + absolute. One whose `stat` or read is refused is `member-unreadable` + (#430): it was a `.git` that named no store, so the checkout `git mv` + moved there went unnamed.""" + gitfile = root / path / ".git" + named = f"{path}/.git" + if not store or not _asked(gitfile, stat.S_ISREG, MEMBER_UNREADABLE, + named): return False try: text = gitfile.read_bytes().decode("utf-8", "surrogateescape") - except OSError: - return False + except OSError as exc: + raise _unreadable(MEMBER_UNREADABLE, named, exc) from exc text = text.rstrip() if not text.startswith("gitdir: "): return False @@ -3208,10 +3499,14 @@ def _put_back_lines(root: Path, path: str, """The lines `member-gitlink-removal-staged` prints that keep the member at `path`, each group with what it puts back (#762): its gitlink; `section`, the lines for `.gitmodules` (`_section_back`, #879); and - its checkout, where none is there.""" + its checkout, where none is there: where NOTHING is there + (`_checkout_gone`, ENOENT alone), and a `.git` this user cannot search + is `member-unreadable` (#430). On Python 3.14 that checkout was none, + and the update printed for it failed (`could not open '/.git' + for writing`) on the checkout that is there.""" lines = [([f"git reset -q -- {shlex.quote(path)}"], "its gitlink"), *section] - if not (root / path / ".git").exists(): + if _checkout_gone(root, path): lines.append((["git submodule update --init --recursive -- " f"{shlex.quote(path)}"], "its checkout")) return lines @@ -3542,7 +3837,7 @@ def _check_members(root: Path, members: list, no_members: bool, stop = (_init_stop_said(root, sections, named) if not refused and any( asked.path not in unregistered - and not (root / asked.path / ".git").exists() + and not _checked_out(root / asked.path, asked.path) for asked in gitlinked) else "") for asked in gitlinked: _check_member_checkout(root, asked, report, @@ -3566,7 +3861,11 @@ def _run_checks(args: argparse.Namespace, """ root = find_repo_root(args.root or Path(__file__).resolve().parents[1]) manifest_path = root / MANIFEST - if not manifest_path.is_file(): + # A `family.yaml` whose `stat` is refused (a link into a folder this + # user cannot search) is refused by name (#430): on Python 3.14 it was + # `family-manifest-missing`, whose remedy makes a family that is here. + if not _asked(manifest_path, stat.S_ISREG, "family-manifest-unreadable", + MANIFEST): raise Refusal( "family-manifest-missing", f"{manifest_path} does not exist. A family holder declares " diff --git a/tests/test_validate_family_unreadable.py b/tests/test_validate_family_unreadable.py new file mode 100644 index 00000000..9f5ec8e1 --- /dev/null +++ b/tests/test_validate_family_unreadable.py @@ -0,0 +1,695 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The family holder's pinned `scripts/validate-family.py`: a path this user +cannot read is refused by name, and never read as absent (#430, slice E of +lane 1's plan, issuecomment-6251069236). + +`Path.is_file()`, `is_dir()` and `exists()` raised a refused `stat` up to +Python 3.13 and answer False to ANY `OSError` from 3.14, and +`os.path.lexists` always did. So each guard here ended the run in a +`PermissionError` traceback on 3.12, exit 1, or said a false thing on 3.14: +a member checkout under a folder this user cannot search was +`member-uninitialized`, whose `git submodule update` then failed `could not +open '/.git' for writing`; a member's `project.yaml` behind one was +`member-not-a-project`; the shape pin `shape-pin-missing`, a shape copy +`shape-copy-missing` and `family.yaml` `family-manifest-missing`; a staged +removal's lines ended on that same failing update; a case variant's `git rm +--cached` lost the `-f` git needs there, or called a checkout none; and the +checkout `git mv` moved went unnamed again (#879's C4). + +EACH SITE HAS THREE ROWS: +- ABSENT, the control: nothing there is said as it always was. +- A REFUSED `stat`, in-process (`refused_stat`, from + `tests/test_bump_leg_unopenable.py`), which answers as 3.14 does on every + Python and platform, and as root: the refusal names the path, exit 2. +- MODE 000 or 444 on the folder above it, in a subprocess, skipped on + Windows and as root (`needs_a_mode`): the same refusal, and once the mode + is back the gate answers as it did before, so the cure is the whole of it. + +The sites are the member checkout's `.git` (`_check_members`' stop, +`_check_member_checkout`, `_check_member_tree`), `_check_member_identity`'s +`project.yaml`, `_load_shape_pin`, `_check_one_shape_file`, `_run_checks`' +`family.yaml`, the staged removal's `_put_back_lines` and `_moved_checkouts` +(`_gone`: nothing there is ENOENT alone, so a file where the checkout should +be is refused, never "none there"), `_names_store`, `_on_disk` and +`_held_as`, which decide the `-f` of a gitlink's `git rm --cached`, and +`_staged_over` (#889's round 2), which decides whether a hand mount's `git +rm` is said to refuse a staged gitlink. + +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 +asked, so a folder this user cannot search there changes nothing. And ONE +CODE HAS ONE TEXT (r956's SHOULD 2): a member's `project.yaml` that cannot +be opened says what one whose `stat` is refused says. + +A refused `stat` of `.git` refuses the holder's own `.git` too, which the +pinned `repo_shape.find_repo_root` asks; #430's plan leaves that function +out, so the gate run in-process is given the holder as its root (`gate`). +""" + +from __future__ import annotations + +import contextlib +import errno +import re +import shutil +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from conftest import git, load_without_bytecode +from test_bump_leg_unopenable import ( + made_unopenable, needs_a_mode, refusal_of, refused_stat, +) +from test_family import SPARE, family, validate # noqa: F401 +from test_family_add_refusals import holder # noqa: F401 +from test_family_head_lost_gitmodules import case_sensitive_disk # noqa: F401 +from test_validate_family_typed_states import OTHER, ROW, VARIANT_PATH +from test_validate_family_unnamed_mounts import ( + codes, commands, findings, spare_commits, stage_gitlink, +) + +OTHER_ROW = f"members/{OTHER}" +HAND = f"members/{SPARE}" +MEMBER_UNREADABLE = "member-unreadable" + +#: What the remedy of a refusal for want of permission says, and what one +#: for any other reason says in its place, never told to `chmod`. +CURE = "chmod u+rx" +OTHER_REASON_SAID = "Where the operating system gave another reason" + +#: The line a staged removal prints to put its gitlink back, and the update +#: that checks the member out where none is there (`_put_back_lines`). +RESET = f"git reset -q -- {ROW}" +UPDATE = f"git submodule update --init --recursive -- {ROW}" + + +def gate(holder: Path, monkeypatch): + """The holder's own `scripts/validate-family.py`, the template's bytes + (`family.py init` copies it verbatim), loaded in-process with + `find_repo_root` answering the holder; `sys.path`, which the module + extends, is put back afterwards.""" + monkeypatch.setattr(sys, "path", list(sys.path)) + module = load_without_bytecode(holder / "scripts" / "validate-family.py", + "validate_family_unreadable") + monkeypatch.setattr(module, "find_repo_root", lambda start: holder) + return module + + +def assert_refused(code: int, err: str, refusal: str, named: str, + reason: str = "Permission denied") -> None: + """Exit 2, `refusal`'s line naming `named` and the system's reason + first, no traceback, and the cure for that cause: `chmod` for a + permission, `OTHER_REASON_SAID` for anything else.""" + assert code == 2, err + assert err.splitlines()[0] == ( + f"REFUSED {refusal}: {named}: cannot be read ({reason})"), err + assert "Traceback" not in err + permission = reason == "Permission denied" + assert (CURE in err) is permission, err + assert (OTHER_REASON_SAID in err) is not permission, err + + +# --- the states each row starts from ----------------------------------------- + +def as_made(holder: Path, request) -> tuple[str, ...]: + """The holder as `family.py` made it.""" + return () + + +def pins_alone(holder: Path, request) -> tuple[str, ...]: + """The holder as made, asked `--pins`, what `make pins` runs.""" + return ("--pins",) + + +def not_checked_out(holder: Path, request) -> tuple[str, ...]: + """IRRS's checkout moved out of the holder whole, and the empty + directory a plain clone leaves in its place.""" + shutil.move(str(holder / ROW), str(request.getfixturevalue("tmp_path") + / "aside")) + (holder / ROW).mkdir() + return () + + +def sections_out(*paths: str): + """A state: each of `paths`' section taken out of `.gitmodules` and + committed, `member-gitmodules-absent` for each, so `_check_members`' + stop asks nothing of its checkout and `_check_member_checkout` does.""" + def made(holder: Path, request) -> tuple[str, ...]: + for path in paths: + git("config", "-f", ".gitmodules", "--remove-section", + f"submodule.{path}", cwd=holder) + git("add", "--", ".gitmodules", cwd=holder) + git("commit", "-qm", "Sections taken out", cwd=holder) + return () + return made + + +def unregistered_not_checked_out(holder: Path, request) -> tuple[str, ...]: + sections_out(ROW)(holder, request) + return not_checked_out(holder, request) + + +def removal_staged(holder: Path, request) -> tuple[str, ...]: + """IRRS's gitlink taken out of the index alone, its checkout kept: + `member-gitlink-removal-staged`, under `--no-members`.""" + git("rm", "-q", "--cached", "--", ROW, cwd=holder) + return ("--no-members",) + + +def removal_staged_not_checked_out(holder: Path, request) -> tuple[str, ...]: + extra = removal_staged(holder, request) + not_checked_out(holder, request) + return extra + + +def moved_to_the_variant(holder: Path, request) -> tuple[str, ...]: + """`git mv members/IRRS Members/IRRS`, staged: a stray whose `git rm + --cached` takes `-f` over a checkout (`_on_disk`, `_held_as`), on a disk + that holds the two names apart.""" + request.getfixturevalue("case_sensitive_disk") + (holder / "Members").mkdir() + git("mv", "--", ROW, VARIANT_PATH, cwd=holder) + return ("--no-members",) + + +def hand_mount_staged(holder: Path, request) -> tuple[str, ...]: + """SPARE's gitlink staged by hand at `members/IRQS`, which no row + names, over an empty directory, as #889's round 2 measures it: a plain + `git rm` refuses it as staged, which `_staged_over` asks.""" + stage_gitlink(holder, HAND, + spare_commits(request.getfixturevalue("family"))[1]) + (holder / HAND).mkdir() + return ("--no-members",) + + +def hand_mount_committed(holder: Path, request) -> tuple[str, ...]: + """The hand mount committed: HEAD records SPARE's gitlink at the + index's own commit, so a plain `git rm` takes it, no `-f` is said and + no staged clause, whatever is on disk (r956's C19 on #956).""" + extra = hand_mount_staged(holder, request) + git("commit", "-qm", "A hand mount", cwd=holder) + return extra + + +def variant_committed(holder: Path, request) -> tuple[str, ...]: + """A second path to IRRS, `Members/IRRS`, committed at IRRS's commit + over a directory holding a file: its `git rm --cached` takes no `-f`, + so what is on disk there is never said (r956's C20 on #956).""" + request.getfixturevalue("case_sensitive_disk") + commit = git("rev-parse", f"HEAD:{ROW}", cwd=holder).stdout.strip() + git("update-index", "--add", "--cacheinfo", + f"160000,{commit},{VARIANT_PATH}", cwd=holder) + git("commit", "-qm", "A second path to IRRS", cwd=holder) + (holder / VARIANT_PATH).mkdir(parents=True) + (holder / VARIANT_PATH / "kept.txt").write_text("kept\n", + encoding="utf-8") + return ("--no-members",) + + +def policy_elsewhere(holder: Path, request) -> tuple[str, ...]: + """The naming policy read from a copy outside the holder, so a + `contracts/` this user cannot search reaches the shape pin.""" + policy = request.getfixturevalue("tmp_path") / "policy.yaml" + shutil.copyfile(holder / "contracts" / "repository-naming.yaml", policy) + return ("--policy", str(policy)) + + +# --- absent: the control --------------------------------------------------- + +def unlinked(name: str): + def made(holder: Path, request) -> tuple[str, ...]: + (holder / name).unlink() + return () + return made + + +@pytest.mark.parametrize("made,exit_code,first", [ + pytest.param(not_checked_out, 2, + f"REFUSED member-uninitialized: {ROW} is not an initialized " + "submodule checkout, so neither", id="member-checkout"), + pytest.param(unregistered_not_checked_out, 1, + f"FINDING member-gitmodules-absent: {ROW} is a gitlink", + id="unregistered-member-checkout"), + pytest.param(unlinked(f"{ROW}/project.yaml"), 1, + f"FINDING member-not-a-project: {ROW}: no project.yaml", + id="member-project-yaml"), + pytest.param(unlinked("contracts/shape-pin.yaml"), 2, + "REFUSED shape-pin-missing: ", id="shape-pin"), + pytest.param(unlinked(".github/workflows/validate.yml"), 1, + "FINDING shape-copy-missing: contracts/shape-pin.yaml: " + ".github/workflows/validate.yml is pinned but absent", + id="shape-copy"), + pytest.param(unlinked("family.yaml"), 2, + "REFUSED family-manifest-missing: ", id="family-yaml"), + pytest.param(hand_mount_staged, 1, + f"FINDING member-gitlink-unnamed: {HAND} is a gitlink", + id="hand-mount-staged"), +]) +def test_what_is_not_there_is_said_as_it_always_was(holder, request, made, + exit_code, first): + """THE CONTROLS: nothing at the name (ENOENT) is the absence it always + was, word for word, and never `cannot be read`.""" + result = validate(holder, *made(holder, request)) + assert result.returncode == exit_code, result.stderr + result.stdout + assert result.stderr.startswith(first), result.stderr + assert "cannot be read" not in result.stderr + + +# --- a refused `stat`, as Python 3.14 answers it (everywhere, root too) ----- + +@pytest.mark.parametrize("made,names,refusal,named", [ + pytest.param(as_made, (".git",), MEMBER_UNREADABLE, f"{ROW}/.git", + id="member-checkout"), + pytest.param(pins_alone, (".git",), MEMBER_UNREADABLE, f"{ROW}/.git", + id="member-checkout-pins"), + pytest.param(sections_out(ROW, OTHER_ROW), (".git",), MEMBER_UNREADABLE, + f"{ROW}/.git", id="unregistered-member-checkout"), + pytest.param(as_made, ("shape-pin.yaml",), "shape-pin-unreadable", + "contracts/shape-pin.yaml", id="shape-pin"), + pytest.param(as_made, ("validate.yml",), "shape-copy-unreadable", + ".github/workflows/validate.yml", id="shape-copy"), + pytest.param(as_made, ("family.yaml",), "family-manifest-unreadable", + "family.yaml", id="family-yaml"), + pytest.param(removal_staged, (".git",), MEMBER_UNREADABLE, f"{ROW}/.git", + id="staged-removal-checkout"), + pytest.param(moved_to_the_variant, ("IRRS",), MEMBER_UNREADABLE, + VARIANT_PATH, id="variant-on-disk"), + pytest.param(moved_to_the_variant, (".git",), MEMBER_UNREADABLE, + f"{VARIANT_PATH}/.git", id="variant-checkout"), + pytest.param(hand_mount_staged, (SPARE,), MEMBER_UNREADABLE, HAND, + id="hand-mount-staged"), +]) +def test_a_path_whose_stat_is_refused_is_refused_by_name( + holder, request, monkeypatch, capsys, made, names, refusal, named): + """On every Python: the path the gate asks first is refused by name, + `: cannot be read (Permission denied)`, with the `chmod` cure, + where 3.14 read it as not there: `member-uninitialized` for a member's + checkout, in members mode and `--pins` alike.""" + extra = made(holder, request) + module = gate(holder, monkeypatch) + refused_stat(monkeypatch, *names) + code = module.main(["--root", str(holder), *extra]) + err = capsys.readouterr().err + assert_refused(code, err, refusal, named) + assert "member-uninitialized" not in err + + +def test_a_project_yaml_whose_stat_is_refused_is_named_not_a_leg( + holder, monkeypatch, capsys): + """`member-manifest-unreadable`, the finding a `project.yaml` that + cannot be opened already is, naming the file and the cure, exit 1: + 3.14 called the member `member-not-a-project`, "a family pins assembly + roots, never legs", of an assembly root.""" + module = gate(holder, monkeypatch) + refused_stat(monkeypatch, "project.yaml") + code = module.main(["--root", str(holder)]) + result = SimpleNamespace(stderr=capsys.readouterr().err) + assert code == 1, result.stderr + assert codes(result) == ["member-manifest-unreadable"] * 2 + assert findings(result)[0].startswith( + f"FINDING member-manifest-unreadable: {ROW}/project.yaml: cannot be " + "read (Permission denied). Make what this user may not open " + "readable by this user: `chmod u+r` on a file"), result.stderr + + +@pytest.mark.parametrize("kind,number,reason,cure", [ + pytest.param(PermissionError, errno.EACCES, "Permission denied", + "Make what this user may not open readable by this user: " + "`chmod u+r` on a file", id="permission"), + pytest.param(OSError, errno.EIO, "Input/output error", OTHER_REASON_SAID, + id="io-error"), +]) +def test_a_project_yaml_that_cannot_be_opened_says_what_its_stat_says( + holder, monkeypatch, capsys, kind, number, reason, cure): + """ONE CODE, ONE TEXT (r956's SHOULD 2 on #956): a `project.yaml` whose + open fails is `: cannot be read ()` with the cure for + that cause, as one whose `stat` is refused. It named the file twice, + relative and then absolute, as `load_yaml`'s `yaml-unreadable` line + has it, and gave no cure.""" + module = gate(holder, monkeypatch) + real = Path.read_text + + def failing(self, *args, **kwargs): + if self.name == "project.yaml": + raise kind(number, reason, str(self)) + return real(self, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", failing) + code = module.main(["--root", str(holder)]) + result = SimpleNamespace(stderr=capsys.readouterr().err) + assert code == 1, result.stderr + assert findings(result)[0].startswith( + f"FINDING member-manifest-unreadable: {ROW}/project.yaml: cannot be " + f"read ({reason}). {cure}"), result.stderr + assert str(holder) not in result.stderr + + +def test_a_row_that_gives_no_path_is_named_at_its_own( + holder, monkeypatch, capsys): + """A ROW THAT GIVES NO `path:` is read at `members/

`, as + `_check_member_repository_and_path` resolves it, and named there: its + `project.yaml` was `None/project.yaml` (r956's NIT 1 on #956).""" + manifest = holder / "family.yaml" + text = manifest.read_text(encoding="utf-8") + line = re.compile(rf"^ *path: {re.escape(ROW)}\n", re.MULTILINE) + assert len(line.findall(text)) == 1, text + manifest.write_text(line.sub("", text), encoding="utf-8") + module = gate(holder, monkeypatch) + refused_stat(monkeypatch, "project.yaml") + code = module.main(["--root", str(holder)]) + result = SimpleNamespace(stderr=capsys.readouterr().err) + assert code == 1, result.stderr + assert findings(result)[0].startswith( + f"FINDING member-manifest-unreadable: {ROW}/project.yaml: cannot be " + "read (Permission denied)"), result.stderr + + +def test_a_shape_copy_that_fails_for_another_reason_is_refused_sans_chmod( + holder, monkeypatch, capsys): + """A copy whose read fails with an I/O error: the same refusal, the + system's reason, and no `chmod`. It ended in `file_sha256`'s + traceback on every Python.""" + module = gate(holder, monkeypatch) + real = module.file_sha256 + + def failing(path): + if path.name == "validate.yml": + raise OSError(errno.EIO, "Input/output error", str(path)) + return real(path) + + monkeypatch.setattr(module, "file_sha256", failing) + code = module.main(["--root", str(holder)]) + assert_refused(code, capsys.readouterr().err, "shape-copy-unreadable", + ".github/workflows/validate.yml", "Input/output error") + + +def test_the_member_tree_asked_alone_names_the_checkout(holder, monkeypatch): + """`_check_member_tree`, which `main` reaches only after the stop has + asked the same `.git`: asked alone, the same refusal.""" + module = gate(holder, monkeypatch) + refused_stat(monkeypatch, ".git") + refusal = refusal_of(module, module._check_member_tree, holder / ROW, {}, + ROW, "0" * 40, "IRRS", module.Report(), "") + assert refusal.code == MEMBER_UNREADABLE + assert refusal.detail == f"{ROW}/.git: cannot be read (Permission denied)" + + +@pytest.mark.parametrize("asked,there", [ + ("_put_back_lines", [([RESET], "its gitlink")]), + ("_moved_checkouts", ([], [])), +]) +def test_the_staged_removal_asks_the_row_s_own_checkout_alone( + holder, request, monkeypatch, asked, there): + """`_put_back_lines` and `_moved_checkouts`, each asked alone, since in + `main` the second meets a `.git` the first has asked: a checkout there + prints no update and names nothing (the control), and one whose `stat` + is refused is refused, not "none there", which printed the update and + searched the other gitlinks for where it went.""" + removal_staged(holder, request) + module = gate(holder, monkeypatch) + args = {"_put_back_lines": (holder, ROW, []), + "_moved_checkouts": (holder, ROW, + module._member_gitlinks(holder), {})}[asked] + assert getattr(module, asked)(*args) == there + refused_stat(monkeypatch, ".git") + refusal = refusal_of(module, getattr(module, asked), *args) + assert refusal.code == MEMBER_UNREADABLE + assert refusal.detail == f"{ROW}/.git: cannot be read (Permission denied)" + + +def test_a_git_file_that_cannot_be_read_names_no_store_by_name( + holder, monkeypatch): + """`_names_store`: another gitlink's `.git` FILE names its own store + (the control), and one whose `stat` is refused, or whose read fails for + another reason, is refused naming it. It answered "names no store", and + the checkout `git mv` moved there went unnamed (#879's C4).""" + module = gate(holder, monkeypatch) + store = module._store(holder, OTHER_ROW, {}) + assert module._names_store(holder, OTHER_ROW, store) + real = Path.read_bytes + + def failing(self): + if self.name == ".git": + raise OSError(errno.EIO, "Input/output error", str(self)) + return real(self) + + with monkeypatch.context() as patch: + patch.setattr(Path, "read_bytes", failing) + refusal = refusal_of(module, module._names_store, holder, OTHER_ROW, + store) + assert refusal.detail == (f"{OTHER_ROW}/.git: cannot be read " + "(Input/output error)") + assert "chmod" not in refusal.remediation + refused_stat(monkeypatch, ".git") + refusal = refusal_of(module, module._names_store, holder, OTHER_ROW, + store) + assert refusal.code == MEMBER_UNREADABLE + assert CURE in refusal.remediation + + +# --- the staged removal's lines: `_gone`, ENOENT alone ---------------------- + +@pytest.mark.parametrize("made,lines", [ + pytest.param(removal_staged, [RESET], id="checkout-there"), + pytest.param(removal_staged_not_checked_out, [RESET, UPDATE], + id="nothing-there"), +]) +def test_the_staged_removal_updates_only_where_nothing_is(holder, request, + made, lines): + """THE CONTROLS of `_put_back_lines`: the update that checks the member + out is printed where nothing is at its path, and not over a checkout.""" + result = validate(holder, *made(holder, request)) + assert codes(result) == ["member-gitlink-removal-staged"] + assert commands(findings(result)[0]) == lines + + +@pytest.mark.skipif(sys.platform == "win32", + reason="Windows answers a name under a file " + "ERROR_PATH_NOT_FOUND, which is ENOENT there") +def test_a_file_where_the_checkout_should_be_is_no_place_to_update( + holder, request): + """`_gone` IS ENOENT ALONE: a FILE at `members/IRRS`, the checkout moved + away, makes its `.git` ENOTDIR, which `exists()` read as nothing there, + so the lines ended on an update git cannot check out over a file. It is + refused naming the `.git`, with the system's reason and no `chmod`.""" + removal_staged_not_checked_out(holder, request) + (holder / ROW).rmdir() + (holder / ROW).write_text("in the way\n", encoding="utf-8") + result = validate(holder, "--no-members") + assert_refused(result.returncode, result.stderr, MEMBER_UNREADABLE, + f"{ROW}/.git", "Not a directory") + + +def test_a_stray_whose_rm_takes_no_f_asks_nothing_of_its_git( + holder, request, monkeypatch, capsys): + """WHAT IS ON DISK IS ASKED ONLY WHERE IT IS SAID (`_held_if_forced`): + a second path to IRRS, committed at IRRS's commit over a directory, is + taken out with no `-f`, so the sentence that names what `-f` goes over + is not printed, and a `.git` whose `stat` would be refused is never + asked: the finding, not a refusal.""" + extra = variant_committed(holder, request) + module = gate(holder, monkeypatch) + refused_stat(monkeypatch, ".git") + code = module.main(["--root", str(holder), *extra]) + result = SimpleNamespace(stderr=capsys.readouterr().err) + assert code == 1, result.stderr + assert codes(result) == ["member-gitlink-unnamed"] + assert commands(findings(result)[0]) == [ + f"git rm --cached -- {VARIANT_PATH}"] + + +def said(module, holder: Path, capsys, extra: tuple[str, ...]) -> tuple: + """`(exit code, stderr)` of the gate run in-process over `holder`.""" + code = module.main(["--root", str(holder), *extra]) + return code, capsys.readouterr().err + + +@pytest.mark.parametrize("made,names", [ + pytest.param(hand_mount_committed, (SPARE,), id="hand-mount-committed"), + pytest.param(variant_committed, ("IRRS",), id="variant-committed"), +]) +def test_a_gitlink_head_records_as_staged_asks_nothing_of_the_disk( + holder, request, monkeypatch, capsys, made, names): + """GIT FIRST (r956's SHOULD 1 on #956): where HEAD records a gitlink at + the index's own commit, a plain `git rm --cached` takes it, so neither + the `-f` (`_forced`) nor the staged clause (`_staged_over`) is said, + whatever is on disk, and a path whose `stat` would be refused is never + asked. The finding is the control's, word for word, on every Python: + asked first, the disk refused it `member-unreadable`, exit 2.""" + extra = made(holder, request) + module = gate(holder, monkeypatch) + control = said(module, holder, capsys, extra) + assert control[0] == 1, control[1] + refused_stat(monkeypatch, *names) + assert said(module, holder, capsys, extra) == control + + +@pytest.mark.skipif(sys.platform == "win32", + reason="a symbolic link needs a privilege on Windows") +def test_a_link_at_a_gitlink_path_is_on_disk_wherever_it_leads( + tmp_path, holder, monkeypatch): + """`_anything_at` asks a link's own `lstat` first, as `os.path.lexists` + asked it: a link that leads nowhere is something on disk, and so is one + whose target this user cannot reach, which its `stat` would refuse + (r956's NIT 2 on #956). A name whose own `stat` is refused is refused + by name.""" + module = gate(holder, monkeypatch) + nowhere = tmp_path / "nowhere" + nowhere.symlink_to(tmp_path / "missing") + beyond = tmp_path / "beyond" + beyond.symlink_to(tmp_path) + plain = tmp_path / "plain" + plain.mkdir() + refused_stat(monkeypatch, "beyond", "plain") + assert module._anything_at(nowhere, "nowhere") + assert module._anything_at(beyond, "beyond") + refusal = refusal_of(module, module._anything_at, plain, "plain") + assert refusal.detail == "plain: cannot be read (Permission denied)" + + +def test_a_name_that_needs_spelling_is_spelled_where_it_cannot_be_read( + holder, monkeypatch): + """`_cannot_read` prints the name `spelled`, as `validate-pins.py`'s + line does (#610): a control character in a path is never printed raw + (r956's NIT 2 on #956).""" + module = gate(holder, monkeypatch) + named = f"members/A{chr(27)}[31mB/.git" + line = module._cannot_read(named, PermissionError(errno.EACCES, + "Permission denied")) + assert line == (f"{module.spelled(named)}: cannot be read " + "(Permission denied)") + assert chr(27) not in line + + +# --- mode 000 and 444 (POSIX, not as root) ---------------------------------- + +@needs_a_mode +@pytest.mark.parametrize("made,folder,mode,refusal,named,after", [ + pytest.param(as_made, ROW, 0, MEMBER_UNREADABLE, f"{ROW}/.git", [], + id="member-000"), + pytest.param(as_made, ROW, 0o444, MEMBER_UNREADABLE, f"{ROW}/.git", [], + id="member-444"), + pytest.param(as_made, "members", 0, MEMBER_UNREADABLE, f"{ROW}/.git", [], + id="members-000"), + pytest.param(sections_out(ROW), ROW, 0, MEMBER_UNREADABLE, f"{ROW}/.git", + ["member-gitmodules-absent"], id="unregistered-member-000"), + pytest.param(policy_elsewhere, "contracts", 0, "shape-pin-unreadable", + "contracts/shape-pin.yaml", [], id="contracts-000"), + pytest.param(as_made, ".github/workflows", 0, "shape-copy-unreadable", + ".github/workflows/validate.yml", [], id="workflows-000"), + pytest.param(as_made, ".github/workflows", 0o444, + "shape-copy-unreadable", ".github/workflows/validate.yml", + [], id="workflows-444"), + pytest.param(as_made, ".github/workflows/validate.yml", 0, + "shape-copy-unreadable", ".github/workflows/validate.yml", + [], id="shape-copy-000"), + pytest.param(removal_staged, ROW, 0, MEMBER_UNREADABLE, f"{ROW}/.git", + ["member-gitlink-removal-staged"], + id="staged-removal-000"), + pytest.param(removal_staged_not_checked_out, OTHER_ROW, 0, + MEMBER_UNREADABLE, f"{OTHER_ROW}/.git", + ["member-gitlink-removal-staged"], + id="other-checkout-000"), + pytest.param(moved_to_the_variant, "Members", 0, MEMBER_UNREADABLE, + VARIANT_PATH, + ["member-gitlink-unnamed", "gitmodules-path-unnamed", + "member-gitlink-removal-staged"], id="variant-folder-000"), + pytest.param(moved_to_the_variant, VARIANT_PATH, 0, MEMBER_UNREADABLE, + f"{VARIANT_PATH}/.git", + ["member-gitlink-unnamed", "gitmodules-path-unnamed", + "member-gitlink-removal-staged"], id="variant-000"), + pytest.param(hand_mount_staged, "members", 0, MEMBER_UNREADABLE, HAND, + ["member-gitlink-unnamed"], id="hand-mount-folder-000"), +]) +def test_a_folder_this_user_cannot_search_is_refused_by_name( + holder, request, made, folder, mode, refusal, named, after): + """THE ISSUE'S CELLS, on this Python: a traceback up to 3.13, and on + 3.14 a false "not there". Now the refusal of the path asked first, exit + 2, and with the mode back the gate answers as it did before the mode + was changed (`after`, the codes of its findings), so the cure it names + is the whole of it.""" + extra = made(holder, request) + with made_unopenable(holder, folder, mode=mode): + result = validate(holder, *extra) + assert_refused(result.returncode, result.stderr, refusal, named) + again = validate(holder, *extra) + assert codes(again) == after + assert again.returncode == (1 if after else 0), again.stderr + + +@needs_a_mode +@pytest.mark.parametrize("made,folder", [ + pytest.param(hand_mount_committed, "members", id="hand-mount-committed"), + pytest.param(variant_committed, "Members", id="variant-committed"), +]) +def test_a_folder_under_a_gitlink_git_takes_is_never_asked( + holder, request, made, folder): + """r956's C19 and C20 on #956, on this Python: with the folder at mode + 000, the gate says what it says with the folder open, word for word, + since nothing it says turns on what is in that folder.""" + extra = made(holder, request) + control = validate(holder, *extra) + with made_unopenable(holder, folder): + result = validate(holder, *extra) + assert (result.returncode, result.stderr) == (control.returncode, + control.stderr) + + +@needs_a_mode +def test_a_project_yaml_at_mode_000_is_named_once_with_its_cure(holder): + """The file itself at mode 000, the common case of r956's SHOULD 2 on + #956: one name, the reason and the cure, where it was named twice.""" + manifest = holder / ROW / "project.yaml" + manifest.chmod(0) + try: + result = validate(holder) + finally: + manifest.chmod(0o644) + assert findings(result)[0].startswith( + f"FINDING member-manifest-unreadable: {ROW}/project.yaml: cannot be " + "read (Permission denied). Make what this user may not open " + "readable"), result.stderr + assert validate(holder).returncode == 0 + + +@contextlib.contextmanager +def behind_a_locked_folder(path: Path, tmp_path: Path): + """`path` replaced by a symbolic link to a copy of it in a folder of + mode 000, which this user cannot search, given its mode back in + `finally`: the one way a name whose own folder is searchable has a + `stat` that is refused.""" + locked = tmp_path / "locked" + locked.mkdir() + shutil.copyfile(path, locked / path.name) + path.unlink() + path.symlink_to(locked / path.name) + locked.chmod(0) + try: + yield + finally: + locked.chmod(0o755) + + +@needs_a_mode +@pytest.mark.parametrize("name,first", [ + (f"{ROW}/project.yaml", + f"FINDING member-manifest-unreadable: {ROW}/project.yaml: cannot be " + "read (Permission denied). Make what this user may not open readable"), + ("family.yaml", + "REFUSED family-manifest-unreadable: family.yaml: cannot be read " + "(Permission denied)"), +], ids=["member-project-yaml", "family-yaml"]) +def test_a_file_behind_a_folder_this_user_cannot_search_is_named( + holder, tmp_path, name, first): + """A member's `project.yaml`, and `family.yaml`, each a link into a + folder this user cannot search: named, where 3.14 said + `member-not-a-project` and `family-manifest-missing`, and with the mode + back the gate passes through the link.""" + with behind_a_locked_folder(holder / name, tmp_path): + result = validate(holder) + assert result.stderr.splitlines()[0].startswith(first), result.stderr + assert "Traceback" not in result.stderr + assert validate(holder).returncode == 0