diff --git a/scripts/family.py b/scripts/family.py index 2c533c64..3d743289 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -163,6 +163,7 @@ import argparse import datetime as _dt +import errno import json import os import re @@ -173,7 +174,7 @@ import sys import tempfile from pathlib import Path -from typing import NamedTuple +from typing import Callable, NamedTuple SHAPE_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(SHAPE_ROOT / "scripts")) @@ -221,6 +222,9 @@ # a path is quoted (#610), as it is when printable and as Python's `repr` # spells it when it is not. from repo_shape import shown_path # noqa: E402 +# The operating system's own words for a path it would not let this read +# (#430), as `load_yaml` gives them for a file it cannot open. +from repo_shape import _os_reason # noqa: E402 # A reused holder's own visibility and the Free-plan secrets note, asked as # the scaffold asks them of its root (#660). from shape_materialize import ( # noqa: E402 @@ -295,6 +299,224 @@ LEG_SUFFIX_RE = re.compile(r"-(?:spec|code)$") +# --------------------------------------------------------------------------- +# A path this tool cannot read is not a path that is not there (#430) +# --------------------------------------------------------------------------- +# +# `Path.is_file()`, `is_dir()` and `exists()` RAISED a refused `stat` on +# Python 3.9 to 3.13 (`PermissionError`) and answered False only where nothing +# was at the name; from 3.14 they answer False to ANY `OSError`. So a holder +# this user may not search was "not a family root" on 3.14 and a traceback on +# 3.12, a member checkout this user may not enter was `member-uninitialized`, +# and `remove`'s rollback said the object store it could not read was "not +# there" and made the member's directory again, empty. `os.path.lexists`, the +# question several guards here asked, has answered False to any `OSError` on +# every version. Asked as below, every Python answers alike, and a refused +# `stat` is a refusal that names the path, exit 2. `bump-leg.py`'s `_is_a` +# (#383) is the model, under the same names, which #430's hygiene test holds +# to one errno set; `repo_shape.py` is a shape copy and stays as it is. + +#: 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 _absent(exc: OSError) -> bool: + """Whether `exc`, raised by a `stat` or `lstat`, says nothing is at the + name (`_ABSENT_ERRNOS`, `_ABSENT_WINERRORS`).""" + return (exc.errno in _ABSENT_ERRNOS + or getattr(exc, "winerror", None) in _ABSENT_WINERRORS) + + +def _is_a(path: Path, kind: Callable[[int], bool]) -> bool: + """Whether `path` is there and of `kind` (`stat.S_ISREG`, `stat.S_ISDIR`, + or any mode at all), following a link, raising the `OSError` of a `stat` + the operating system REFUSED (#430): `bump-leg.py`'s `_is_a` (#383). + + NOT `Path.is_file()`, `is_dir()` OR `exists()`, whose answer depends on + the Python. Asked here, every Python answers as 3.9 to 3.13 did, and the + caller names the refused path instead of calling it absent. + """ + try: + mode = path.stat().st_mode + except OSError as exc: + if _absent(exc): + 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_mode(mode: int) -> bool: + """`_is_a`'s `kind` for a question about any file at all, as + `Path.exists()` asks it.""" + return True + + +def _is_there(path: Path) -> bool: + """Whether anything is at `path`, NOT following a link (a dangling link + is there): `os.path.lexists`, asked as `_is_a` asks, so a refused `lstat` + is raised and not answered False (#430).""" + try: + path.lstat() + except OSError as exc: + if _absent(exc): + return False + raise + except ValueError: + return False + return True + + +def _gone(path: Path) -> bool: + """Whether NOTHING is at `path`, not following a link, asked before a + removal or a rollback decides on it (#430): True ONLY where the `lstat` + says `ENOENT`, and any other `OSError` raised. + + STRICTER THAN `_is_there`, ON PURPOSE. A part of the path that is not a + directory (`ENOTDIR`, a file where a folder was) or a link that loops + (`ELOOP`) says nothing is at the name for the question "is there a file + to read", and so `_is_a` answers. For "is there anything here to remove, + or was there nothing here before this run made it" it is not an answer: + a rollback that took it for one recorded a path as made by this run, or + skipped one, past something it never looked at. + """ + try: + path.lstat() + except FileNotFoundError: + return True + return False + + +#: What a path this tool must read and cannot is told, by cause (#430), in +#: `validate-pins.py`'s words (`_unreadable_file`, #712) and `bump-leg.py`'s +#: (#383): a refused permission is put right by the mode or the owner, and any +#: other cause is not told to `chmod`. +PERMISSION_ADVICE = ( + "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{where}), or fix who owns it. ") +#: The cure for a cause other than a permission, as a clause, which +#: `OTHER_REASON` is the sentence of and the rollbacks' sentences and +#: `remove`'s NOTE end on (#430). +OTHER_CURE = ( + "no `chmod` puts it right: put back in its place what belongs there, or " + "take away what nobody wants, and make sure nothing is changing the " + "holder while this runs") +OTHER_REASON = ( + "Where the operating system gave another reason (a part of the path that " + "is a file where a directory belongs, a link that loops, an I/O error), " + + OTHER_CURE + ". ") +#: The cure for a permission, as a clause, for a sentence that names a path +#: and goes on: a rollback step the operating system refused (#430). The +#: step may have read the path or written it (an `rmdir` needs the folder +#: above writable), so the cure gives both (r955's SHOULD-R1 on #955). +PERMISSION_CURE = ( + "give this user access to it (`chmod u+rw` on a file, `chmod u+rwx` on a " + "directory and on every directory above it inside the holder)") +RUN_AGAIN = "Then run the same command again." + + +def _cure(exc: OSError) -> str: + """`PERMISSION_CURE` for a `PermissionError`, else `OTHER_CURE`: the + advice follows the cause, as `_unreadable`'s does (#430).""" + return PERMISSION_CURE if isinstance(exc, PermissionError) else OTHER_CURE + + +def _cannot_read(named: str, exc: OSError) -> str: + """`: cannot be read ()`, the line + every refusal of a path this tool cannot read names it with (#430).""" + return f"{named}: cannot be read ({_os_reason(exc)})" + + +def _named_in(root: Path, path: Path) -> str: + """`path` as a line names it: relative to the holder `root`, in POSIX, + where it is under it, else whole; as `shown_path` shows a path (#766).""" + try: + return shown_path(path.relative_to(root).as_posix()) + except ValueError: + return shown_path(str(path)) + + +def _unreadable(code: str, named: str, exc: OSError, reads: str, *, + where: str = " inside the holder") -> Refusal: + """`code`'s refusal of a path this tool must read and the operating + system will not let it (#430): `_cannot_read`'s line, and a remedy that + says what was being read (`reads`, a sentence of its own) and the cure + for the cause, `PERMISSION_ADVICE` naming `where` the directories above + are, or `OTHER_REASON`.""" + advice = (PERMISSION_ADVICE.format(where=where) + if isinstance(exc, PermissionError) else OTHER_REASON) + return Refusal(code, _cannot_read(named, exc), + f"Remediation: {reads} A path it cannot read is refused, " + "never taken for one that is not there. " + advice + + RUN_AGAIN) + + +def _refused(exc: OSError, asked: Path) -> Path: + """The path the operating system names in `exc` (its `filename`), else + `asked`: which of several paths a record asked about was refused.""" + return Path(exc.filename) if isinstance(exc.filename, str) else asked + + +def _member_unreadable(root: Path, path: Path, exc: OSError) -> Refusal: + """`member-unreadable`: a member's mount, its checkout's `.git` or its + object store in the holder, whose `stat` the operating system refused, + named relative to the holder, before anything is written (#430).""" + return _unreadable( + "member-unreadable", _named_in(root, path), exc, + "family.py reads what it would change in the holder, a member's " + "mount, its `.git` and its object store among them, before it " + "writes anything, to say what it would change and to put back what " + "it changed.") + + +def _undo_reason(root: Path, exc: BaseException, asked: Path) -> str: + """Why a rollback step failed, for the sentence that names what is left: + `_reason`, but for an `OSError` `: ; `, the path the + operating system refused, relative to the holder `root` (`asked` where + it names none), its reason and its cure (#430). A step that met + `Permission denied` and said only that left the person to find which + path, and the commands typed after it failed until the mode was given + back. + + NOT "CANNOT BE READ" (r955's SHOULD-R1 on #955): a step reads and + writes, and what failed may be the write (`rmdir`, `unlink`, `mkdir`), so + the line gives the operating system's reason as it is, and the cure for + a permission names reading and writing both.""" + if isinstance(exc, OSError): + return (f"{_named_in(root, _refused(exc, asked))}: " + f"{_os_reason(exc)}; " + _cure(exc)) + return _reason(exc) + + +def _manifest_there(root: Path) -> bool: + """Whether the holder `root` has a `family.yaml` that is a file, asked + by `Family.open` and `siblings` before anything else; where its `stat` + is refused, `family-root-unreadable` (#430). + + A HOLDER THIS USER MAY NOT SEARCH (`r--` or `---`) WAS "NOT A FAMILY + ROOT" on Python 3.14, `family-root-missing`, whose remedy runs `init` + over a holder that exists, and a `PermissionError` traceback on 3.12. + The holder is named whole, as `family-root-missing` names it: it is the + directory the person pointed at, and the one whose mode is the cure. + """ + path = root / MANIFEST + try: + return _is_a(path, stat.S_ISREG) + except OSError as exc: + raise _unreadable( + "family-root-unreadable", shown_path(str(path)), exc, + f"family.py reads the holder's {MANIFEST} before anything else, " + "to know that it is a family root.", where="") from exc + + # --------------------------------------------------------------------------- # family.yaml: reading it, and rewriting the members block # --------------------------------------------------------------------------- @@ -312,7 +534,7 @@ def __init__(self, root: Path, data: dict): def open(cls, root_spec) -> "Family": root = Path(root_spec).expanduser().resolve() path = root / MANIFEST - if not path.is_file(): + if not _manifest_there(root): raise Refusal( "family-root-missing", f"{path} does not exist, so this is not a family root", @@ -838,7 +1060,20 @@ def _read_member_project(path: Path, project: str, repository: str, every refusal here is the same either way. """ manifest_path = path / "project.yaml" - if not manifest_path.is_file(): + try: + there = _is_a(manifest_path, stat.S_ISREG) + except OSError as exc: + # A `project.yaml` WHOSE `stat` IS REFUSED, a link to a place this + # user may not search, was "no project.yaml" on Python 3.14, so + # `member-not-a-project` sent the person to scaffold a project that + # is one; a traceback on 3.12 (#430). It is the manifest that cannot + # be read, under that refusal's code and named as `shown` names it. + raise _unreadable( + "member-manifest-unreadable", + shown or shown_path(str(manifest_path)), exc, + "family.py reads the member's own project.yaml for the `id:` its " + "row records.", where="") from exc + if not there: # A LEG IS NAMED BY ITS ROOT (#203). The commonest way here is # `--member /IRRS-spec`, and the legs of `IRRS-spec` are # `IRRS-spec-spec` and `IRRS-spec-code`, which no naming rule makes @@ -6253,9 +6488,17 @@ def _checked_mount_path(root: Path, project: str, path: str, if spelled: raise _spellings_refusal(path, spelled, _gitmodules_first(root, spelled)) - # A DIRECTORY NO LISTING COULD READ hides nothing: `lexists` asks the - # path itself, as it always did, after every listing has answered. - if os.path.lexists(root / path): + # A DIRECTORY NO LISTING COULD READ hides nothing: the path itself is + # asked, as it always was, after every listing has answered. Asked as + # `lexists` asks it and NOT with `os.path.lexists`, which answers False + # to a refused `lstat` (#430): a `members/` this user may not search + # passed as free, and the mount died at git's clone, or, on 3.12, the + # rollback's record raised a traceback before it. + try: + there = _is_there(root / path) + except OSError as exc: + raise _member_unreadable(root, root / path, exc) from exc + if there: raise taken try: run(["git", "--literal-pathspecs", "add", "--dry-run", @@ -6395,7 +6638,20 @@ def _checked_add_target(family: Family, repository: str, project: str, # NAMED IN POSIX: git spells this `.git/modules/`, and so does the # human who retypes the remediation below. _, cached = _module_dir(family.root, path) - if cached.exists(): + # A STORE THIS USER MAY NOT REACH is named, not passed as absent (#430): + # `exists()` said "none" on Python 3.14, and the mount then died in git's + # clone, and raised a traceback on 3.12. + # A FILE WHERE A FOLDER ABOVE IT BELONGS is no answer either (r955's + # NIT 4 on #955): `_is_a` takes it for nothing there, and the dry run + # planned a mount the real run's record then refused. `_gone`, which + # `MountRollback` records the store with, is asked of what reads absent. + try: + there = _is_a(cached, _any_mode) + if not there: + _gone(cached) + except OSError as exc: + raise _member_unreadable(family.root, cached, exc) from exc + if there: raise Refusal( "member-git-dir-cached", f"{cached.as_posix()} still holds the object store of a " @@ -6754,18 +7010,27 @@ def __init__(self, root: Path, path: str) -> None: #: steps (#302). self.index = _index_records(root) self.gitlink = self._entries(path) - self.files = {name: (self._bytes(name), self._entries(name)) - for name in (GITMODULES, MANIFEST)} self.registration = self._registration() module_shown, module = _module_dir(root, path) - #: `(directory, as it is named, its parents that do not exist yet)` - #: for each of the two directories the mount makes, if it is absent - #: now. Parents nearest first, which is the order they empty in. - self.trees = [(tree, shown, [p for p in tree.parents - if not p.exists()]) - for tree, shown in ((root / path, path), - (module, module_shown)) - if not tree.exists()] + try: + self.files = {name: (self._bytes(name), self._entries(name)) + for name in (GITMODULES, MANIFEST)} + #: `(directory, as it is named, its parents that do not exist + #: yet)` for each of the two directories the mount makes, if it + #: is absent now. Parents nearest first, which is the order they + #: empty in. ABSENT IS `_gone`'s `ENOENT` AND NOTHING ELSE (#430): + #: `undo` deletes what this records, so a directory whose `stat` + #: was refused, recorded as made by this run, was one `undo` + #: would delete once it could be read; it is refused instead, + #: before the mount, and nothing is written. + self.trees = [(tree, shown, [p for p in tree.parents + if _gone(p)]) + for tree, shown in ((root / path, path), + (module, module_shown)) + if _gone(tree)] + except OSError as exc: + raise _member_unreadable(root, _refused(exc, root / path), + exc) from exc def _head(self) -> str | None: """The commit HEAD names, or None if it names none — read ONE way, @@ -6786,8 +7051,14 @@ def _head(self) -> str | None: return None def _bytes(self, name: str) -> bytes | None: + """`name`'s bytes in the holder, or None for no file. A file there + was none of is the one `undo` deletes, so "none" is `_gone`'s + `ENOENT`, or something there that is no file (a directory, a dangling + link), and a refused `stat` is raised (#430).""" target = self.root / name - return target.read_bytes() if target.is_file() else None + if _gone(target) or not _is_a(target, stat.S_ISREG): + return None + return target.read_bytes() def _entries(self, name: str) -> list[bytes]: """The index records for exactly `name`, one per stage, in the form @@ -6842,11 +7113,17 @@ def _undo_file(self, name: str, data: bytes | None, raise _EntryNotRestored(name, changed, exc) from exc def _undo_tree(self, tree: Path, parents: list[Path]) -> bool: - removed = tree.exists() + """`tree`, which this run made, deleted, and each of `parents` it + made that is empty. ASKED WITH `_gone` (#430): what cannot be read, + a file where a parent directory was, or a link that loops is raised, + and `undo` names the step as not done, instead of passing it as + nothing there and calling the holder as it was.""" + removed = not _gone(tree) if removed: _rmtree(tree) for parent in parents: - if parent.is_dir() and not any(parent.iterdir()): + if not _gone(parent) and _is_a(parent, stat.S_ISDIR) and not any( + parent.iterdir()): parent.rmdir() return removed @@ -6961,7 +7238,9 @@ def undo(self) -> str: failed.append(f"the index entry of {exc.name} put back as it " f"was: {_reason(exc.cause)}") except (CommandFailed, Refusal, OSError) as exc: - failed.append(f"{said}: {_reason(exc)}") + failed.append(f"{said}: " + + _undo_reason(self.root, exc, + self.root / self.path)) # THE WHOLE INDEX, once every step has been asked (#302). A partial # unwind already says it is one, and names `git status`. An index # that cannot be read back leaves the steps undone but unconfirmed, @@ -7506,6 +7785,17 @@ def _store_git_uses(root: Path, path: str) -> tuple[str, Path] | None: return None if name is None else _module_dir(root, name) +#: The code `_unrestorable` raises for an object store this user may not +#: read (#430), which the rollback tells apart from a store that is gone. +#: `PinRollback._undo_work_tree` (which makes the mount again for it) and +#: `PinRollback._work_tree_by_hand` (which types the store's update for it, +#: and calls no store-less checkout) route on this code; the only refusal +#: either can meet with it is `_unrestorable`'s, since `_storeless` reads +#: nothing else of the disk. `tests/test_family_py_unreadable.py` pins the +#: two to one value. +STORE_UNREADABLE = "member-unreadable" + + def _unrestorable(root: Path, path: str, commit: str | None) -> str | None: """Why `git submodule update --init --no-fetch -- `, run in `root`, could NOT check `commit` out of an object store git already @@ -7519,12 +7809,27 @@ def _unrestorable(root: Path, path: str, commit: str | None) -> str | None: `deinit` moves an embedded `.git` into the name's own, which a check of the recorded one called gone. A store that does not hold `commit` is not used either: `update` would fetch into it. + + A STORE THIS USER MAY NOT READ IS RAISED, `member-unreadable` (#430), and + is no answer either way. `is_dir()` called it "not there, and clone it + there" on Python 3.14, so the sentence said the checkout came back only + from its remote, and raised a traceback out of the rollback on 3.12. It + is there to come back from once this user can read it. """ found = _store_git_uses(root, path) if found is None: return f"no section of {GITMODULES} gives git {path}" shown, store = found - if not store.is_dir(): + try: + there = _is_a(store, stat.S_ISDIR) + except OSError as exc: + cure = ("`chmod u+rx` on it, and on every directory above it inside " + "the holder, lets git read it" + if isinstance(exc, PermissionError) else OTHER_CURE) + raise Refusal(STORE_UNREADABLE, + f"git would check it out of {shown_path(shown)}, which " + f"cannot be read ({_os_reason(exc)}): {cure}") from exc + if not there: return (f"git would check it out of {shown}, which is not there, and " "clone it there") if commit is not None and not _store_holds(store, commit, root): @@ -7541,9 +7846,10 @@ def _member_head(member: Path) -> tuple[str | None, str | None]: submodule deinit` has emptied is a plain directory inside the holder, and git asked there answers with the HOLDER's HEAD (git 2.43, probed for #365): the rollback would have taken the holder's commit for the - member's. + member's. A `.git` whose `lstat` is refused is raised (`_is_there`, + #430), not read as no checkout. """ - if not os.path.lexists(member / ".git"): + if not _is_there(member / ".git"): return None, None try: at = git_out(["rev-parse", "-q", "--verify", "HEAD^{commit}"], @@ -7714,12 +8020,25 @@ def __init__(self, root: Path, path: str, verb: str, self.head = self._head() self.index = _index_records(root) self.gitlink = self.index.get(path.encode("utf-8"), []) - self.files = {name: (self._bytes(name), - self.index.get(name.encode("utf-8"), [])) - for name in files} - self.at, self.branch = _member_head(self.member) self.registration = self._registration() if unmount else [] - self.existed = self.member.is_dir() + # WHAT IS ON DISK IS READ AS `_is_a` AND `_gone` READ IT (#430): a + # member this user may not search was a checkout with no `.git` and + # no directory on Python 3.14, so a failed `remove` made it again, + # empty, and said so, and a traceback on 3.12. It is refused here, + # before the first write; `bump` and `remove` ask the same paths + # first, so this is what a path made unreadable in between meets. + try: + self.files = {name: (self._bytes(name), + self.index.get(name.encode("utf-8"), [])) + for name in files} + self.at, self.branch = _member_head(self.member) + self.existed = _is_a(self.member, stat.S_ISDIR) + #: Whether `remove` has a checkout to bring back: one with a + #: `.git` of its own. + self.checkout = unmount and _is_there(self.member / ".git") + except OSError as exc: + raise _member_unreadable(root, _refused(exc, self.member), + exc) from exc #: The commit the gitlink names, which `git submodule update` checks #: out: its stage-0 record's, or None for one the index holds #: conflicted. @@ -7727,9 +8046,6 @@ def __init__(self, root: Path, path: str, verb: str, (record.partition(b"\t")[0].split() for record in self.gitlink) if fields[2:] == [b"0"]), None) - #: Whether `remove` has a checkout to bring back: one with a `.git` - #: of its own. - self.checkout = unmount and os.path.lexists(self.member / ".git") #: The object store the checkout's own `.git` named. For the #: sentence only: the restore asks which store git will use. self.store: Path | None = None @@ -7754,6 +8070,9 @@ def __init__(self, root: Path, path: str, verb: str, except CommandFailed as exc: self.unread = _reason(exc) self.store, self.legs, self.discards = None, [], False + except OSError as exc: + raise _member_unreadable(root, _refused(exc, self.member), + exc) from exc #: Set once `remove`'s checkout has been brought back, which is when #: what `deinit -f` discarded is known to be gone. self.emptied = False @@ -7791,7 +8110,7 @@ def _legs(self) -> list[tuple[str, str, str | None, str | None]]: head, _, name = record.partition(b"\t") leg = name.decode("utf-8", "surrogateescape") if head.startswith(b"160000 ") and leg not in legs and \ - os.path.lexists(self.member / leg / ".git"): + _is_there(self.member / leg / ".git"): legs[leg] = (leg, head.split()[1].decode("ascii"), *_member_head(self.member / leg)) return list(legs.values()) @@ -7856,7 +8175,9 @@ def _undo_registration(self) -> str | None: def _storeless(self) -> str | None: """Why the member's checkout cannot come back from an object store - git has, in the sentence's words; None when it can (#365).""" + git has, in the sentence's words; None when it can (#365). A store + this user may not read is raised, as `_unrestorable` raises it: it + is no store that is gone (#430).""" if self.unread is not None: return ("git could not read its checkout before this command " f"({self.unread}), so no object store here is known to " @@ -7870,10 +8191,20 @@ def _store_kept(self) -> str: that store is there and is not the one git looks in: what a human who renamed the section by hand needs to know to bring it back.""" found = _store_git_uses(self.root, self.path) - if self.store is None or not self.store.is_dir() or ( + if self.store is None or ( found is not None and found[1].resolve() == self.store.resolve()): return "" + # A STORE THIS USER MAY NOT READ IS NAMED AS ONE (#430), not left + # out as gone: once it can be read, the checkout comes back from it. + try: + there = _is_a(self.store, stat.S_ISDIR) + except OSError as exc: + self.store_said = True + return ("; the object store its checkout had, " + + _cannot_read(self.store.as_posix(), exc)) + if not there: + return "" self.store_said = True return (f"; the object store its checkout had, " f"{self.store.as_posix()}, is still there") @@ -7882,22 +8213,45 @@ def _made_again(self) -> bool: """The member's directory made again, empty, if it was there before and is not now: a gitlink with no directory reads as deleted, which a later `git commit -a` would record, and an empty one is how a member - never checked out stands.""" - if self.existed and not self.member.is_dir(): + never checked out stands. A directory whose `stat` is refused is + raised (#430), not made again over.""" + if self._dir_gone(): self.member.mkdir(parents=True) return True return False + def _made_again_if_can(self) -> bool: + """`_made_again`, where the directory can be read: one that cannot + is left as it is, and the refusal already being raised names what + the step met first (#430).""" + try: + return self._made_again() + except OSError: + return False + + def _dir_gone(self) -> bool: + """Whether the member's directory was there before and is not now: + `_is_a`'s answer, a refused `stat` raised (#430).""" + return self.existed and not _is_a(self.member, stat.S_ISDIR) + def _undo_work_tree(self) -> str | None: if not self.checkout: return (f"{self.path} made again, empty, as it was" if self._made_again() else None) - if os.path.lexists(self.member / ".git") and \ + if _is_there(self.member / ".git") and \ _member_head(self.member) == (self.at, self.branch): return None # THE STORE GIT WILL USE IS ASKED FIRST, and `update` is not run - # without it: it would clone (#365). - why = self._storeless() + # without it: it would clone (#365). One this user may not read is + # raised from here, named, and the commands typed by hand bring the + # checkout back from it once it can be read (#430); the directory is + # made again first, as for a store that is gone. + try: + why = self._storeless() + except Refusal as exc: + if exc.code == STORE_UNREADABLE and self._made_again_if_can(): + exc.detail += f", and {self.path} was made again, empty" + raise if why is not None: self.storeless = True if self._made_again(): @@ -7964,7 +8318,13 @@ def _file_by_hand(self, name: str) -> list[str]: sentence's `git status` shows them.""" data, entries = self.files[name] typed = [_entries_by_hand(name, entries)] - if self._bytes(name) == data: + try: + same = self._bytes(name) == data + except OSError: + # Named by the step that met it (#430); the index entry is still + # this command's to type, and the bytes are `git status`'s. + return typed + if same: return typed if data is None: return typed + [f"rm -f -- {shlex.quote(name)}"] @@ -8004,13 +8364,21 @@ def _work_tree_by_hand(self) -> list[str]: rollback runs it with. Where git does not, the directory is made again, empty, and the sentence says where the checkout comes back from (`undo`). Nothing already back is typed again.""" - made = ([f"mkdir {shlex.quote(self.path)}"] - if self.existed and not self.member.is_dir() else []) + try: + gone = self._dir_gone() + except OSError: + # Named by the step that met it; no command here reaches it. + gone = False + made = [f"mkdir {shlex.quote(self.path)}"] if gone else [] if not self.checkout: return made try: storeless = self._storeless() is not None - except (CommandFailed, Refusal): + except Refusal as exc: + # A store this user may not read is one the checkout comes back + # from once it can (#430): its update is typed, after the cure. + storeless = exc.code != STORE_UNREADABLE + except CommandFailed: storeless = True if storeless: self.storeless = True @@ -8153,7 +8521,8 @@ def undo(self) -> str: continue except (CommandFailed, Refusal, OSError) as exc: missing.append(key) - failed.append(f"{said}: {_reason(exc)}") + failed.append(f"{said}: " + + _undo_reason(self.root, exc, self.member)) typed += by_hand() continue if done: @@ -9105,7 +9474,15 @@ def cmd_bump(args) -> int: _checked_no_operation_in_progress(family.root, "bump") _checked_manifest_committed(family.root, "bump") _checked_inside_sparse_rules(family.root, "bump", [path, MANIFEST]) - if not (member / ".git").exists(): + # A MEMBER THIS USER MAY NOT ENTER IS NOT AN UNINITIALIZED ONE (#430): + # `exists()` said "no `.git`" on Python 3.14, and the remedy ran `git + # submodule update --init` over a checkout that is there; a traceback on + # 3.12. Its `.git` is named, `member-unreadable`, under `--dry-run` too. + try: + initialized = _is_a(member / ".git", _any_mode) + except OSError as exc: + raise _member_unreadable(family.root, member / ".git", exc) from exc + if not initialized: raise Refusal( "member-uninitialized", f"{path} is not an initialized submodule checkout, so the new " @@ -9713,6 +10090,58 @@ def _checked_index_unlocked(root: Path, path: str) -> None: "again.") +def _checked_mount_readable(root: Path, path: str) -> bool: + """Whether the member's mount `path` is on disk, asked by `remove` + before it writes anything and under `--dry-run` alike; where it, or its + checkout's `.git`, cannot be read, `member-unreadable` (#430). + + ASKED AS A REMOVAL ASKS, WITH `_gone`: `remove` empties the mount and + deletes it, and its rollback records what to bring back from what is + there. Nothing at the name (`ENOENT`) is a mount not on disk, as it + always was. Anything else that is not an answer is refused, a part of + the path that is a file (`ENOTDIR`) included: with a file where + `members/` was, the dry run said "not on disk", exit 0, and the real run + ran `git submodule deinit -f`, met `git rm`'s exit 128 and was rolled + back, exit 2. A `members/` this user may not search was "not on disk" + in the dry run, then a traceback on Python 3.12 and, on 3.14, a `git + submodule deinit -f` that ran before `git rm` failed. + + THE OBJECT STORE GIT KEEPS FOR IT TOO (r955's SHOULD-1 on #955): a + store this user may not read was passed, `remove` committed, and a + rollback after its first write said no store held the checkout and it + came back only from its remote. Its `HEAD` is asked, as `_is_a` asks: a + store that is gone (`ENOENT`) is the member #365 removes as it always + has, and one that cannot be read is refused, named. The store is asked + whether the mount is on disk or not. + + A `HEAD` THIS USER MAY NOT READ, in a store it may search, is refused + too (r955's NIT-R1 on #955), named: `stat` asks no read permission of + the file, so `remove` landed, exit 0, past a store that `git submodule + update`, the rollback's way back, cannot read. `os.access` asks it. + """ + mount = root / path + try: + if _gone(mount): + on_disk = False + else: + on_disk = True + if _is_a(mount, stat.S_ISDIR): + _gone(mount / ".git") + except OSError as exc: + raise _member_unreadable(root, _refused(exc, mount), exc) from exc + found = _store_git_uses(root, path) + if found is not None: + head = found[1] / "HEAD" + try: + there = _is_a(head, stat.S_ISREG) + except OSError as exc: + raise _member_unreadable(root, found[1], exc) from exc + if there and not os.access(head, os.R_OK): + raise _member_unreadable(root, head, PermissionError( + errno.EACCES, os.strerror(errno.EACCES), str(head))) + return on_disk + + def _unmount_and_drop(family: Family, row: dict, path: str) -> int: """The part of `remove` that writes: the unmount, the row and the ONE commit. Returns the exit code. Runs inside the caller's `PinRollback` @@ -9748,7 +10177,24 @@ def _unmount_and_drop(family: Family, row: dict, path: str) -> int: # POSIX for the same reason it is POSIX in `add`: git spells this path # `.git/modules/`, and so does the human who deletes it. cached = family.root / ".git" / "modules" / path - if cached.exists(): + # THE COMMIT HAS LANDED, so a store this user may not read is a NOTE + # that says so, exit 0, and never a silence (Python 3.14's `exists()`, + # False) or a traceback over a commit that is made (3.12's) (#430). + try: + left = _is_a(cached, _any_mode) + except OSError as exc: + # The cure follows the cause (#430): a permission is the mode, and + # any other cause is `OTHER_CURE`, never told to `chmod`. + then = ("once this user can read the directory, look" + if isinstance(exc, PermissionError) else + OTHER_CURE + "; then look") + print(" NOTE " + _cannot_read(_named_in(family.root, cached), exc) + + ", so whether `git rm` left that member's object store " + "behind is not known. It leaves one on purpose, in case " + f"somebody committed inside the mount: {then}, and delete it " + "before adding this member back.") + return 0 + if left: # The directory alone on a line, then the command, quoted (#486), # exactly as `add`'s `member-git-dir-cached` names the same store. print(" NOTE that member's object store is left behind. `git rm` " @@ -9759,7 +10205,7 @@ def _unmount_and_drop(family: Family, row: dict, path: str) -> int: def _remove_dry_run(family: Family, row: dict, path: str, - suggested: str) -> int: + suggested: str, on_disk: bool) -> int: """`remove --dry-run` (#326): what a real `remove` would take out, and no git command that writes. @@ -9793,6 +10239,11 @@ def _remove_dry_run(family: Family, row: dict, path: str, (`_checked_gitmodules_as_staged`). So by here the work tree's file is the index's copy, or none of the work tree, the index and HEAD holds one, and `deinit` reads what this reads. + + `on_disk` IS `_checked_mount_readable`'s ANSWER (#430): `os.path.lexists` + said "not on disk" of a mount in a `members/` this user may not search, + and the real run then raised a traceback on Python 3.12, or, on 3.14, + ran `git submodule deinit -f` before `git rm` failed. """ project = str(row["project"]) root = family.root @@ -9838,7 +10289,7 @@ def _remove_dry_run(family: Family, row: dict, path: str, print(f"directory {path} " + ( "deleted with everything in it: `git submodule deinit -f` discards " "uncommitted work in the member, and `git rm` the directory" - if os.path.lexists(root / path) else "not on disk")) + if on_disk else "not on disk")) for line in _branch_said(family, "remove", suggested): print(line) print("\n--dry-run: nothing was changed: the index, .gitmodules, " @@ -9889,12 +10340,16 @@ def cmd_remove(args) -> int: # gitlink is staged by `git rm`, which refuses it the same way. _checked_inside_sparse_rules(family.root, "remove", [GITMODULES, path, MANIFEST]) + # The mount and its `.git`, which `deinit -f` empties and `git rm` + # deletes and the rollback reads to put them back, asked as a removal + # asks (#430). + on_disk = _checked_mount_readable(family.root, path) # Every refusal above is the same under `--dry-run`, which leaves here, # before the unmount, the real run's first write (#326), and reports the # branch the real run refuses (#487) rather than refusing it. suggested = _suggested_branch("remove", str(row.get("id") or project)) if args.dry_run: - return _remove_dry_run(family, row, path, suggested) + return _remove_dry_run(family, row, path, suggested, on_disk) branch = _checked_branch(family, "remove", suggested) # WHAT CAN BE ASKED BEFORE THE UNMOUNT IS (#365); what fails after it is # put back (`PinRollback`). A `members:` block this tool will not rewrite @@ -9945,7 +10400,7 @@ def cmd_siblings(args) -> int: the Makefile's words, which name `update-shape.py` and its `--add`. """ root = Path(args.family_root).expanduser().resolve() - if not (root / MANIFEST).is_file(): + if not _manifest_there(root): raise Refusal( "family-root-missing", f"{root / MANIFEST} does not exist, so this is not a family root", @@ -9953,7 +10408,18 @@ def cmd_siblings(args) -> int: "one; point --family-root at the HOLDER (the `/` " "clone), not at the family folder around it.") script = root / SIBLINGS_SCRIPT - if not script.is_file(): + # A SCRIPT THIS USER MAY NOT REACH IS NOT ONE THE HOLDER LACKS (#430): + # `is_file()` said so on Python 3.14, under a remedy that adds a file + # the holder has with `update-shape.py`, and raised a traceback on 3.12. + try: + there = _is_a(script, stat.S_ISREG) + except OSError as exc: + raise _unreadable( + "siblings-script-unreadable", SIBLINGS_SCRIPT, exc, + f"family.py siblings runs the holder's own {SIBLINGS_SCRIPT}, as " + "`make siblings` does, and has no second implementation to fall " + "back on, so nothing was cloned or fetched.") from exc + if not there: raise Refusal( "siblings-script-missing", f"family.py siblings needs {SIBLINGS_SCRIPT}, which this holder " diff --git a/tests/test_family_py_unreadable.py b/tests/test_family_py_unreadable.py new file mode 100644 index 00000000..878bf87b --- /dev/null +++ b/tests/test_family_py_unreadable.py @@ -0,0 +1,1191 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`scripts/family.py`: a path it must read and this user cannot is refused +by name, never taken for a path that is not there (#430, slice G). + +WHAT WAS WRONG. `Path.is_file()`, `is_dir()` and `exists()` RAISED a refused +`stat` on Python 3.9 to 3.13 (`PermissionError`, a traceback, exit 1) and +answer False to ANY `OSError` from 3.14; `os.path.lexists` has answered False +to one on every version. So on 3.14 a holder this user could not search was +`family-root-missing`, "this is not a family root"; an unsearchable +`scripts/` was `siblings-script-missing`, whose remedy adds a file the holder +has; a member checkout this user could not enter was `member-uninitialized`; +`remove --dry-run` said "not on disk" of a mount in an unsearchable +`members/`; the NOTE `remove` prints about the object store `git rm` leaves +behind was dropped without a word; and `remove`'s rollback said the store it +could not read was "not there, and clone it there". On 3.12 each was a +traceback, some of them after the commit had landed or in the middle of a +rollback. + +NOW every guard asks the operating system itself, as `bump-leg.py`'s `_is_a` +does (#383): `ENOENT`, `ENOTDIR`, `EBADF` and `ELOOP` are "not there", any +other `OSError` is a refusal, `: cannot be read ()`, exit 2, +with `chmod` for a permission and no `chmod` for any other cause. BEFORE A +REMOVAL "not there" is `ENOENT` alone (`_gone`): `remove` deletes the mount, +and the rollbacks of `add`, `bump` and `remove` delete or make again what +they recorded, so a file where a folder was is refused, never removed past. + +EACH SITE HAS ITS ROWS: the control, where nothing is there and the answer is +the one it always was; a refused `stat`, made in-process by +`test_bump_leg_unopenable.refused_stat`, which answers as 3.14 does on every +Python and platform and as root; and a real mode where one can be made, by +`made_unopenable` or a hook, skipped on Windows and as root +(`needs_a_mode`). `refused_stat` patches `Path.stat`; `refused` routes +`Path.lstat` through it as well, which 3.12 and 3.13 do of themselves and a +Python whose `lstat` calls `os.lstat` directly does not, so the `lstat` +questions (`_gone`, `_is_there`) meet the same refusal everywhere. +""" + +from __future__ import annotations + +import errno +import os +import shutil +import stat +import sys +from pathlib import Path + +import pytest + +from conftest import git, refusal_detail, rmtree, run_script +from test_family import FAMILY, MEMBERS, ORG, SPARE, family # noqa: F401 +from test_family import family_module +from test_family_add_refusals import holder, offline # noqa: F401 +from test_family_bump_remove_rollback import ( + facts, in_process, remove, typed_by_hand, +) +from test_family_dry_run import isolated +from test_bump_leg_unopenable import ( + made_unopenable, needs_a_mode, refusal_of, refused_stat, +) + +MEMBER = MEMBERS[0] +PATH = f"members/{MEMBER}" +STORE = f".git/modules/{PATH}" +SPARE_PATH = f"members/{SPARE}" +ZEROS = "0" * 40 + +#: How each refusal of a path this user may not read begins its remedy's +#: cure, and how one for any other cause does: the two never mix. +CHMOD = "`chmod u+rx` on a directory" +NO_CHMOD = "no `chmod` puts it right" +#: How a rollback step the operating system refused ends, after the path +#: and its reason: the cure for a permission, which names reading and +#: writing both, since the step may have done either (#955's rounds 1 and 2). +STEP_CURE = ("give this user access to it (`chmod u+rw` on a file, `chmod " + "u+rwx` on a directory and on every directory above it inside " + "the holder)") + + +def refused(monkeypatch, *names: str) -> None: + """`refused_stat`, with `Path.lstat` asked through the `Path.stat` it + patches, as Python 3.9 to 3.13 ask it, so a name it refuses is refused + to an `lstat` too on a Python whose `lstat` calls `os.lstat` itself.""" + refused_stat(monkeypatch, *names) + monkeypatch.setattr(Path, "lstat", + lambda self: self.stat(follow_symlinks=False)) + + +def remedy(stderr: str) -> str: + """The `Remediation:` line a refusal prints.""" + lines = [line for line in stderr.splitlines() + if line.startswith("Remediation:")] + assert len(lines) == 1, stderr + return lines[0] + + +def run(env: dict, *argv: str): + return run_script(FAMILY, *argv, env=env) + + +def hook(root: Path, tmp_path: Path, name: str, body: str) -> None: + """A `name` hook in `root` running `body`, set LOCALLY, so a + `core.hooksPath` in the runner's own configuration cannot point git + somewhere it is not.""" + hooks = tmp_path / "hooks" + hooks.mkdir(exist_ok=True) + (hooks / name).write_text("#!/bin/sh\n" + body, encoding="utf-8") + (hooks / name).chmod(0o755) + git("config", "--local", "core.hooksPath", hooks.as_posix(), cwd=root) + + +def aside(path: Path, tmp_path: Path) -> Path: + """`path` moved out of the holder, and where it went.""" + moved = tmp_path / f"aside-{path.name}" + shutil.move(str(path), str(moved)) + return moved + + +def file_where_members_was(holder: Path, tmp_path: Path) -> bytes: + """`members/` moved out of the holder and a FILE written at its name, so + every path under it fails `ENOTDIR`; returns the file's bytes.""" + aside(holder / "members", tmp_path) + data = b"a file where the members folder was\n" + (holder / "members").write_bytes(data) + return data + + +# --- the family root: `Family.open` and `siblings` (:305, :9530) ------------ + +ROOT_ARGV = { + "bump": lambda root: ["bump", "--dry-run", "--family-root", str(root), + "--member", MEMBER, "--to", ZEROS], + "siblings": lambda root: ["siblings", "--dry-run", "--family-root", + str(root)], +} + + +def root_refusal(root: Path) -> list[str]: + return ["REFUSED family-root-unreadable: " + f"{root.resolve() / 'family.yaml'}: cannot be read " + "(Permission denied)"] + + +@pytest.mark.parametrize("verb", sorted(ROOT_ARGV)) +def test_a_folder_with_no_family_yaml_is_still_not_a_family_root( + verb, offline, tmp_path): + """The control: nothing at `family.yaml` is `family-root-missing`.""" + empty = tmp_path / "not-a-holder" + empty.mkdir() + + result = run(isolated(offline, tmp_path), *ROOT_ARGV[verb](empty)) + + assert result.returncode == 2, result.stdout + result.stderr + assert refusal_detail(result.stderr, "family-root-missing") == [ + f"REFUSED family-root-missing: {empty.resolve() / 'family.yaml'} " + "does not exist, so this is not a family root"] + + +@pytest.mark.parametrize("verb", sorted(ROOT_ARGV)) +def test_a_family_yaml_whose_stat_is_refused_is_not_a_missing_one( + verb, holder, offline, tmp_path, monkeypatch, capsys): + """Python 3.14's answer, on every Python: the holder is named as one + this user cannot read, with the permission's cure, never "not a family + root", whose remedy runs `init` over a holder that exists.""" + refused(monkeypatch, "family.yaml") + + code = in_process(family_module(), monkeypatch, + isolated(offline, tmp_path), ROOT_ARGV[verb](holder)) + + err = capsys.readouterr().err + assert code == 2, err + assert refusal_detail(err, "family-root-unreadable") == root_refusal( + holder) + assert CHMOD in remedy(err) + assert "family-root-missing" not in err + + +@needs_a_mode +@pytest.mark.parametrize("verb", sorted(ROOT_ARGV)) +def test_a_holder_this_user_cannot_search_is_named_not_a_traceback( + verb, holder, offline, tmp_path): + """A holder listable and not searchable (`r--`): a `PermissionError` + traceback on 3.12, `family-root-missing` on 3.14, now the one refusal.""" + env = isolated(offline, tmp_path) + with made_unopenable(holder, ".", mode=0o444): + result = run(env, *ROOT_ARGV[verb](holder)) + + assert result.returncode == 2, result.stdout + result.stderr + assert "Traceback" not in result.stderr + assert refusal_detail(result.stderr, "family-root-unreadable") == ( + root_refusal(holder)) + assert CHMOD in remedy(result.stderr) + + +# --- `siblings`: the holder's own script (:9538) --------------------------- + +SIBLINGS = ["siblings", "--dry-run", "--family-root"] + + +def test_a_holder_with_no_siblings_script_is_still_told_to_add_it( + holder, offline, tmp_path): + """The control: no `scripts/siblings.py` is `siblings-script-missing`.""" + aside(holder / "scripts" / "siblings.py", tmp_path) + + result = run(isolated(offline, tmp_path), *SIBLINGS, str(holder)) + + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr.startswith("REFUSED siblings-script-missing: ") + + +def assert_script_unreadable(code: int, err: str) -> None: + assert code == 2, err + assert refusal_detail(err, "siblings-script-unreadable") == [ + "REFUSED siblings-script-unreadable: scripts/siblings.py: cannot be " + "read (Permission denied)"] + assert CHMOD in remedy(err) + assert "nothing was cloned or fetched" in remedy(err) + + +def test_a_siblings_script_whose_stat_is_refused_is_not_a_missing_one( + holder, offline, tmp_path, monkeypatch, capsys): + """Not `siblings-script-missing`, whose remedy adds with + `update-shape.py` a file the holder has, and nothing run or cloned.""" + refused(monkeypatch, "siblings.py") + + code = in_process(family_module(), monkeypatch, + isolated(offline, tmp_path), [*SIBLINGS, str(holder)]) + + assert_script_unreadable(code, capsys.readouterr().err) + offline.assert_left_nothing() + + +@needs_a_mode +def test_a_scripts_folder_this_user_cannot_search_is_named(holder, offline, + tmp_path): + with made_unopenable(holder, "scripts"): + result = run(isolated(offline, tmp_path), *SIBLINGS, str(holder)) + + assert "Traceback" not in result.stderr + assert_script_unreadable(result.returncode, result.stderr) + + +@pytest.mark.skipif(sys.platform == "win32", + reason="a link to itself is a POSIX symbolic link") +def test_a_siblings_script_that_is_a_link_loop_is_missing(holder, offline, + tmp_path): + """THE OTHER HALF, `ELOOP`: a link to itself is no file to run, as + every Python up to 3.13 answered, and stays `siblings-script-missing`, + not a file this user cannot read (r955's M19 on #955).""" + script = holder / "scripts" / "siblings.py" + aside(script, tmp_path) + os.symlink("siblings.py", script) + + result = run(isolated(offline, tmp_path), *SIBLINGS, str(holder)) + + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr.startswith("REFUSED siblings-script-missing: ") + + +# --- `bump`: the member's `.git` (:8704) ----------------------------------- + +def bump_argv(holder: Path, dry: bool) -> list[str]: + to = git("rev-parse", "HEAD~1", cwd=holder / PATH).stdout.strip() + return ["bump", "--family-root", str(holder), "--member", MEMBER, + "--to", to, *(["--dry-run"] if dry else [])] + + +#: What `bump` says of a member whose `.git` it cannot read. +GIT_UNREADABLE = [f"REFUSED member-unreadable: {PATH}/.git: cannot be read " + "(Permission denied)"] + + +def test_a_member_with_no_git_is_still_uninitialized(holder, offline, + tmp_path): + """The control: nothing at the member's `.git` is `member-uninitialized`, + as it always was.""" + argv = bump_argv(holder, dry=True) + aside(holder / PATH / ".git", tmp_path) + + result = run(isolated(offline, tmp_path), *argv) + + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr.startswith("REFUSED member-uninitialized: ") + + +@pytest.mark.parametrize("dry", [True, False], ids=["dry-run", "real"]) +def test_a_member_git_whose_stat_is_refused_is_not_uninitialized( + dry, holder, offline, tmp_path, monkeypatch, capsys): + """Not `member-uninitialized`, whose remedy runs `git submodule update + --init` over a checkout that is there; refused before the fetch, and + the holder as it was.""" + env = isolated(offline, tmp_path) + argv = bump_argv(holder, dry) + before = facts(holder, env) + refused(monkeypatch, ".git") + + code = in_process(family_module(), monkeypatch, env, argv) + + err = capsys.readouterr().err + assert code == 2, err + assert refusal_detail(err, "member-unreadable") == GIT_UNREADABLE + assert CHMOD in remedy(err) + monkeypatch.undo() + assert facts(holder, env) == before + + +@needs_a_mode +@pytest.mark.parametrize("where", [PATH, "members"]) +def test_a_member_this_user_cannot_enter_is_named_by_bump( + where, holder, offline, tmp_path): + env = isolated(offline, tmp_path) + argv = bump_argv(holder, dry=False) + before = facts(holder, env) + + with made_unopenable(holder, where): + result = run(env, *argv) + + assert result.returncode == 2, result.stdout + result.stderr + assert "Traceback" not in result.stderr + assert refusal_detail(result.stderr, "member-unreadable") == ( + GIT_UNREADABLE) + assert facts(holder, env) == before + + +def test_a_member_under_a_file_is_still_uninitialized(holder, offline, + tmp_path): + """THE OTHER HALF, `ENOTDIR`: a file where the member's mount was has no + `.git` under it, which `bump`, removing nothing, reads as absent, as + every Python up to 3.13 did: `member-uninitialized`, never a member + this user cannot read (r955's M18 on #955).""" + argv = bump_argv(holder, dry=True) + aside(holder / PATH, tmp_path) + (holder / PATH).write_bytes(b"a file where the mount was\n") + + result = run(isolated(offline, tmp_path), *argv) + + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr.startswith("REFUSED member-uninitialized: ") + + +# --- `remove`: the mount it deletes (:9423, and the real run's record) ------- + +MOUNT_UNREADABLE = [f"REFUSED member-unreadable: {PATH}: cannot be read " + "(Permission denied)"] + + +def test_a_mount_that_is_not_there_is_still_not_on_disk(holder, offline, + tmp_path): + """The control, `ENOENT`: the dry run says the directory is not on + disk, as it always did.""" + aside(holder / PATH, tmp_path) + + result = remove(holder, isolated(offline, tmp_path), "--dry-run") + + assert result.returncode == 0, result.stdout + result.stderr + assert f"directory {PATH} not on disk" in result.stdout.splitlines() + + +@pytest.mark.parametrize("dry", [True, False], ids=["dry-run", "real"]) +def test_a_mount_under_a_file_is_refused_not_removed_past( + dry, holder, offline, tmp_path): + """THE REMOVAL ROW, `ENOTDIR`: a file where `members/` was. The dry run + said "not on disk", exit 0, and the real run ran `git submodule deinit + -f` past a file it never looked at, met `git rm`'s exit 128 and was + rolled back, exit 2. Now both refuse it before any git command that + writes, by the system's reason and with no `chmod`, and change + nothing.""" + env = isolated(offline, tmp_path) + before = facts(holder, env) + data = file_where_members_was(holder, tmp_path) + + result = remove(holder, env, *(["--dry-run"] if dry else [])) + + assert result.returncode == 2, result.stdout + result.stderr + assert refusal_detail(result.stderr, "member-unreadable") == [ + f"REFUSED member-unreadable: {PATH}: cannot be read (Not a " + "directory)"] + assert NO_CHMOD in remedy(result.stderr) + assert "chmod u+r" not in remedy(result.stderr) + assert (holder / "members").read_bytes() == data + assert facts(holder, env) == before + + +@pytest.mark.parametrize("dry", [True, False], ids=["dry-run", "real"]) +def test_a_mount_whose_stat_is_refused_is_named_before_anything( + dry, holder, offline, tmp_path, monkeypatch, capsys): + env = isolated(offline, tmp_path) + before = facts(holder, env) + refused(monkeypatch, MEMBER) + + code = in_process(family_module(), monkeypatch, env, [ + "remove", "--family-root", str(holder), "--member", MEMBER, + *(["--dry-run"] if dry else [])]) + + err = capsys.readouterr().err + assert code == 2, err + assert refusal_detail(err, "member-unreadable") == MOUNT_UNREADABLE + monkeypatch.undo() + assert facts(holder, env) == before + + +@needs_a_mode +@pytest.mark.parametrize("dry", [True, False], ids=["dry-run", "real"]) +def test_a_members_folder_this_user_cannot_search_is_named_by_remove( + dry, holder, offline, tmp_path): + """The dry run said "not on disk"; the real run raised a traceback on + 3.12 and, on 3.14, ran `git submodule deinit -f` before `git rm` + failed. Now both refuse first.""" + env = isolated(offline, tmp_path) + before = facts(holder, env) + + with made_unopenable(holder, "members"): + result = remove(holder, env, *(["--dry-run"] if dry else [])) + + assert result.returncode == 2, result.stdout + result.stderr + assert "Traceback" not in result.stderr + assert refusal_detail(result.stderr, "member-unreadable") == ( + MOUNT_UNREADABLE) + assert facts(holder, env) == before + + +# The next two are r955's rows on #955 (SHOULD-2), as r955 wrote them: the +# mount's `.git`, which `remove` asks after the mount itself. `50178305`'s +# dry run said "deleted with everything in it", exit 0. + +@pytest.mark.parametrize("dry", [True, False], ids=["dry-run", "real"]) +def test_a_mount_git_whose_stat_is_refused_is_named_by_remove( + dry, holder, offline, tmp_path, monkeypatch, capsys): + env = isolated(offline, tmp_path) + before = facts(holder, env) + refused(monkeypatch, ".git") + + code = in_process(family_module(), monkeypatch, env, [ + "remove", "--family-root", str(holder), "--member", MEMBER, + *(["--dry-run"] if dry else [])]) + + err = capsys.readouterr().err + assert code == 2, err + assert refusal_detail(err, "member-unreadable") == GIT_UNREADABLE + monkeypatch.undo() + assert facts(holder, env) == before + + +@needs_a_mode +@pytest.mark.parametrize("dry", [True, False], ids=["dry-run", "real"]) +def test_a_member_this_user_cannot_enter_is_named_by_remove( + dry, holder, offline, tmp_path): + env = isolated(offline, tmp_path) + before = facts(holder, env) + + with made_unopenable(holder, PATH): + result = remove(holder, env, *(["--dry-run"] if dry else [])) + + assert result.returncode == 2, result.stdout + result.stderr + assert refusal_detail(result.stderr, "member-unreadable") == ( + GIT_UNREADABLE) + assert facts(holder, env) == before + + +#: What `remove` says of the object store git keeps for the member, when +#: this user cannot read it (r955's SHOULD-1 on #955). +STORE_REFUSED = [f"REFUSED member-unreadable: {STORE}: cannot be read " + "(Permission denied)"] + + +@pytest.mark.parametrize("dry", [True, False], ids=["dry-run", "real"]) +def test_a_store_whose_stat_is_refused_is_named_by_remove( + dry, holder, offline, tmp_path, monkeypatch, capsys): + """The store's `HEAD`, asked before anything is written: `remove` + committed past it, and a rollback after its first write said no store + held the checkout and it came back only from its remote.""" + env = isolated(offline, tmp_path) + before = facts(holder, env) + refused(monkeypatch, "HEAD") + + code = in_process(family_module(), monkeypatch, env, [ + "remove", "--family-root", str(holder), "--member", MEMBER, + *(["--dry-run"] if dry else [])]) + + err = capsys.readouterr().err + assert code == 2, err + assert refusal_detail(err, "member-unreadable") == STORE_REFUSED + assert CHMOD in remedy(err) + monkeypatch.undo() + assert facts(holder, env) == before + + +@needs_a_mode +@pytest.mark.parametrize("dry", [True, False], ids=["dry-run", "real"]) +@pytest.mark.parametrize("where", [".git/modules/members", STORE], + ids=["modules", "store"]) +def test_a_store_this_user_cannot_read_is_refused_by_remove( + where, dry, holder, offline, tmp_path): + """r955's SHOULD-1 rows: `remove` ran `deinit -f` and `git rm` and + committed, exit 0, over a store this user could not read.""" + env = isolated(offline, tmp_path) + before = facts(holder, env) + + with made_unopenable(holder, where): + result = remove(holder, env, *(["--dry-run"] if dry else [])) + + assert result.returncode == 2, result.stdout + result.stderr + assert "Traceback" not in result.stderr + assert refusal_detail(result.stderr, "member-unreadable") == ( + STORE_REFUSED) + assert facts(holder, env) == before + + +@needs_a_mode +@pytest.mark.parametrize("dry", [True, False], ids=["dry-run", "real"]) +def test_a_store_head_this_user_cannot_read_is_refused_by_remove( + dry, holder, offline, tmp_path): + """r955's NIT-R1 on #955: the store's `HEAD` at 000, in a store this + user may search. `stat` asks no read permission of the file, so + `remove` landed, exit 0, past a store its rollback could not read.""" + env = isolated(offline, tmp_path) + before = facts(holder, env) + + with made_unopenable(holder, f"{STORE}/HEAD"): + result = remove(holder, env, *(["--dry-run"] if dry else [])) + + assert result.returncode == 2, result.stdout + result.stderr + assert "Traceback" not in result.stderr + assert refusal_detail(result.stderr, "member-unreadable") == [ + f"REFUSED member-unreadable: {STORE}/HEAD: cannot be read " + "(Permission denied)"] + assert facts(holder, env) == before + + +@pytest.mark.parametrize("dry", [True, False], ids=["dry-run", "real"]) +def test_a_missing_mount_whose_store_is_refused_is_named_by_remove( + dry, holder, offline, tmp_path, monkeypatch, capsys): + """X2 in r955's re-check of #955: the store is asked whether the mount + is on disk or not. A mount that is gone, beside a store whose `HEAD` + is refused, is refused, not planned as "not on disk".""" + env = isolated(offline, tmp_path) + aside(holder / PATH, tmp_path) + before = facts(holder, env) + refused(monkeypatch, "HEAD") + + code = in_process(family_module(), monkeypatch, env, [ + "remove", "--family-root", str(holder), "--member", MEMBER, + *(["--dry-run"] if dry else [])]) + + err = capsys.readouterr().err + assert code == 2, err + assert refusal_detail(err, "member-unreadable") == STORE_REFUSED + monkeypatch.undo() + assert facts(holder, env) == before + + +def test_a_member_whose_store_is_gone_is_still_planned_for_removal( + holder, offline, tmp_path): + """The control, `ENOENT`: a member whose store is gone is removed as + #365 removes it, and the dry run plans it so.""" + rmtree(holder / STORE) + + result = remove(holder, isolated(offline, tmp_path), "--dry-run") + + assert result.returncode == 0, result.stdout + result.stderr + assert f"directory {PATH} deleted with everything in it" in ( + result.stdout) + + +# --- `remove`: the NOTE about the store left behind (:9333) ------------------ + +#: The NOTE a landed `remove` prints when the store cannot be read. +STORE_NOTE = (f" NOTE {STORE}: cannot be read (Permission denied), so " + "whether `git rm` left that member's object store behind is " + "not known") + + +def test_a_store_left_behind_is_still_noted(holder, offline, tmp_path): + """The control: the store `git rm` keeps is named for deletion.""" + result = remove(holder, isolated(offline, tmp_path)) + + assert result.returncode == 0, result.stdout + result.stderr + assert (" NOTE that member's object store is left behind." + in result.stdout) + + +def test_a_store_whose_stat_is_refused_is_noted_not_dropped( + holder, offline, tmp_path, monkeypatch, capsys): + """After the commit: the NOTE says the store could not be read, exit 0, + where 3.14 dropped it without a word and 3.12 raised a traceback over a + commit that had landed. Asked of the step itself, since a refused + `stat` of the member's name refuses the mount, earlier, too.""" + for key, value in isolated(offline, tmp_path).items(): + monkeypatch.setenv(key, value) + module = family_module() + family_open = module.Family.open(holder) + row = family_open.member(MEMBER) + refused(monkeypatch, MEMBER) + + code = module._unmount_and_drop(family_open, row, PATH) + + out = capsys.readouterr().out + assert code == 0, out + assert STORE_NOTE in out + assert "is left behind" not in out + + +@needs_a_mode +def test_a_store_this_user_cannot_read_after_the_commit_is_noted( + holder, offline, tmp_path): + """A `post-commit` hook takes the store's folder away from this user + between the commit and the question.""" + modules = holder / ".git" / "modules" / "members" + hook(holder, tmp_path, "post-commit", + f"chmod 000 '{modules.as_posix()}'\n") + try: + result = remove(holder, isolated(offline, tmp_path)) + finally: + modules.chmod(0o755) + + assert result.returncode == 0, result.stdout + result.stderr + assert "Traceback" not in result.stderr + assert STORE_NOTE in result.stdout + assert git("log", "-1", "--format=%s", cwd=holder).stdout.startswith( + f"Remove {MEMBER} from the ") + + +# --- `remove`'s rollback: the store the checkout comes back from (:7179) -- + +def pinned(holder: Path) -> str: + return git("rev-parse", f"HEAD:{PATH}", cwd=holder).stdout.strip() + + +def test_a_store_that_is_there_or_gone_is_answered_as_it_was(holder): + """The control: a store that holds the pin answers None; one that is + not there (`ENOENT`) is the clone the rollback will not make.""" + module = family_module() + commit = pinned(holder) + assert module._unrestorable(holder, PATH, commit) is None + + rmtree(holder / STORE) + + assert module._unrestorable(holder, PATH, commit) == ( + f"git would check it out of {STORE}, which is not there, and clone " + "it there") + + +#: What the rollback says of a store it cannot read. +STORE_UNREAD = (f"git would check it out of {STORE}, which cannot be read " + "(Permission denied): `chmod u+rx` on it, and on every " + "directory above it inside the holder, lets git read it") + + +def test_a_store_whose_stat_is_refused_is_no_store_that_is_gone( + holder, monkeypatch): + """Not "not there, and clone it there": `member-unreadable`.""" + module = family_module() + commit = pinned(holder) + refused(monkeypatch, MEMBER) + + refusal = refusal_of(module, module._unrestorable, holder, PATH, commit) + + assert refusal.code == "member-unreadable" + assert refusal.code == module.STORE_UNREADABLE + assert refusal.detail == STORE_UNREAD + + +def stat_failing(monkeypatch, name: str, error: OSError) -> None: + """`Path.stat` of `name` failing with `error`, a cause that is not a + permission, which `refused_stat` does not make.""" + real = Path.stat + + def failing(self, *args, **kwargs): + if self.name == name: + raise error + return real(self, *args, **kwargs) + + monkeypatch.setattr(Path, "stat", failing) + + +#: An I/O error: a cause no `chmod` puts right. +EIO = OSError(errno.EIO, "Input/output error") + + +def test_a_store_that_fails_for_another_cause_is_told_no_chmod( + holder, monkeypatch): + """r955's NIT 6 on #955: the cure follows the cause. An I/O error is + not told the mode (r955's M27).""" + module = family_module() + commit = pinned(holder) + stat_failing(monkeypatch, MEMBER, EIO) + + refusal = refusal_of(module, module._unrestorable, holder, PATH, commit) + + assert refusal.detail == ( + f"git would check it out of {STORE}, which cannot be read " + f"(Input/output error): {module.OTHER_CURE}") + assert "chmod u+r" not in refusal.detail + + +def test_a_store_that_fails_for_another_cause_is_noted_no_chmod( + holder, offline, tmp_path, monkeypatch, capsys): + """The NOTE after `remove`'s commit, for an I/O error: #430's + other-reason words, never "once this user can read the directory".""" + for key, value in isolated(offline, tmp_path).items(): + monkeypatch.setenv(key, value) + module = family_module() + family_open = module.Family.open(holder) + row = family_open.member(MEMBER) + stat_failing(monkeypatch, MEMBER, EIO) + + code = module._unmount_and_drop(family_open, row, PATH) + + out = capsys.readouterr().out + assert code == 0, out + assert (f" NOTE {STORE}: cannot be read (Input/output error), so whether" + in out) + assert module.OTHER_CURE in out + assert "once this user can read" not in out + + +def assert_store_unread_said(sentence: str) -> None: + """The rollback's sentence names the store as unreadable, types the + update that brings the checkout back from it, and never sends the + person to the member's remote.""" + assert STORE_UNREAD in sentence + assert ("`git -c protocol.allow=never submodule update --init --no-fetch " + f"-- {PATH}`") in sentence + assert "comes back only from its remote" not in sentence + assert "not there, and clone it there" not in sentence + + +def test_a_rollback_meeting_a_refused_store_types_the_restore( + holder, offline, tmp_path, monkeypatch, capsys): + """In-process: the commit fails and, by then, the store's `stat` is + refused. The step is named, and its commands are typed.""" + module = family_module() + fail = module.CommandFailed(["git", "commit"], holder, 1, + "pre-commit: refusing (injected)") + + def refused_then_failed(*args, **kwargs): + refused(monkeypatch, MEMBER) + raise fail + + monkeypatch.setattr(module, "_commit_one", refused_then_failed) + + code = in_process(module, monkeypatch, isolated(offline, tmp_path), [ + "remove", "--family-root", str(holder), "--member", MEMBER]) + + err = capsys.readouterr().err + assert code == 2, err + assert "Traceback" not in err + sentence = err.splitlines()[-1] + assert_store_unread_said(sentence) + # The mount's own `stat` is refused too, so it is not made again over + # (r955's M30), and no `mkdir` is typed for it. + assert "was made again, empty" not in sentence + assert "`mkdir " not in sentence + + +@needs_a_mode +def test_a_rollback_meeting_an_unreadable_store_is_put_right_as_typed( + holder, offline, tmp_path): + """A `pre-commit` hook takes the store's folder away and refuses. The + rollback names it, makes the mount again, empty, and types the update + that brings the checkout back from the store with no network: 3.12 + raised a traceback in the middle of the rollback, and 3.14 said the + store was not there and the checkout came back only from its remote. + With the mode given back, the commands, typed as printed, put the + member back on its branch at its commit.""" + env = isolated(offline, tmp_path) + at = git("rev-parse", "HEAD", cwd=holder / PATH).stdout.strip() + modules = holder / ".git" / "modules" / "members" + hook(holder, tmp_path, "pre-commit", + f"chmod 000 '{modules.as_posix()}'\nexit 1\n") + try: + result = remove(holder, env) + finally: + modules.chmod(0o755) + + assert result.returncode == 2, result.stdout + result.stderr + assert "Traceback" not in result.stderr + sentence = result.stderr.splitlines()[-1] + assert_store_unread_said(sentence) + assert f", and {PATH} was made again, empty" in sentence + git("config", "--local", "--unset", "core.hooksPath", cwd=holder) + typed_by_hand(holder, sentence) + assert_back_on_main(holder, at) + + +def assert_back_on_main(holder: Path, at: str) -> None: + """The member checked out on `main` at `at`, the holder clean.""" + assert git("rev-parse", "HEAD", cwd=holder / PATH).stdout.strip() == at + assert git("symbolic-ref", "--short", "HEAD", + cwd=holder / PATH).stdout.strip() == "main" + assert git("status", "--porcelain", cwd=holder).stdout == "" + + +@needs_a_mode +def test_a_rollback_step_that_cannot_read_names_the_path_and_the_cure( + holder, offline, tmp_path): + """r955's SHOULD-3 on #955: a `pre-commit` hook takes `members/` away + from this user and refuses. The step that brings the checkout back + said only "Permission denied", and the commands typed after it failed + until the mode was given back. Now it names the path and the cure; with + the mode given back, the commands, typed as printed, put the member + back.""" + env = isolated(offline, tmp_path) + at = git("rev-parse", "HEAD", cwd=holder / PATH).stdout.strip() + members = holder / "members" + hook(holder, tmp_path, "pre-commit", + f"chmod 000 '{members.as_posix()}'\nexit 1\n") + try: + result = remove(holder, env) + finally: + members.chmod(0o755) + + assert result.returncode == 2, result.stdout + result.stderr + assert "Traceback" not in result.stderr + sentence = result.stderr.splitlines()[-1] + assert (f" from the object store `git rm` keeps: {PATH}/.git: Permission " + f"denied; {STEP_CURE}") in sentence + git("config", "--local", "--unset", "core.hooksPath", cwd=holder) + typed_by_hand(holder, sentence) + assert_back_on_main(holder, at) + + +# --- `add`'s rollback: what it records and what it deletes (:6536-:6620) ---- + +def mount_rollback(holder: Path): + """`MountRollback` of the spare member's mount, recorded now.""" + module = family_module() + return module, module.MountRollback(holder, SPARE_PATH) + + +def test_a_mount_not_there_yet_is_recorded_as_made_by_the_run(holder): + """The control, `ENOENT`: both trees the mount makes are recorded.""" + _, rollback = mount_rollback(holder) + + assert [shown for _, shown, _ in rollback.trees] == [ + SPARE_PATH, f".git/modules/{SPARE_PATH}"] + assert rollback.files[".gitmodules"][0] == ( + holder / ".gitmodules").read_bytes() + + +@pytest.mark.parametrize("name, shown", [ + (SPARE, SPARE_PATH), + (".gitmodules", ".gitmodules"), +], ids=["mount", "gitmodules"]) +def test_a_record_whose_stat_is_refused_is_refused_before_the_mount( + name, shown, holder, monkeypatch): + """A tree recorded as absent is one `undo` deletes: a refused `stat` is + refused, not recorded as made by this run; so is a file `undo` would + delete as one there was none of.""" + module = family_module() + refused(monkeypatch, name) + + refusal = refusal_of(module, module.MountRollback, holder, SPARE_PATH) + + assert refusal.code == "member-unreadable" + assert refusal.detail == f"{shown}: cannot be read (Permission denied)" + + +@needs_a_mode +def test_a_record_under_a_folder_this_user_cannot_search_is_refused(holder): + module = family_module() + with made_unopenable(holder, "members"): + refusal = refusal_of(module, module.MountRollback, holder, SPARE_PATH) + + assert refusal.detail == (f"{SPARE_PATH}: cannot be read (Permission " + "denied)") + + +def test_an_undo_with_nothing_made_changes_nothing(holder): + """The control: nothing was made, nothing is deleted.""" + _, rollback = mount_rollback(holder) + + assert rollback.undo() == (" Nothing was added, and nothing in the holder " + "had changed.") + + +def test_an_undo_meeting_a_file_where_a_folder_was_says_so(holder, tmp_path): + """THE REMOVAL ROW, `ENOTDIR`: `exists()` said "nothing there" of the + mount under a file where `members/` was, and `undo` called the holder + unchanged. Now the step is named as not done, and the file is kept.""" + _, rollback = mount_rollback(holder) + data = file_where_members_was(holder, tmp_path) + + said = rollback.undo() + + assert (f"; NOT {SPARE_PATH} removed: {SPARE_PATH}: Not a directory; " + f"{NO_CHMOD}") in said + assert (holder / "members").read_bytes() == data + + +def test_an_undo_meeting_a_refused_stat_deletes_nothing(holder, monkeypatch): + """A mount the run made, whose `stat` is refused by the time `undo` + asks: named as not removed, and left in place, where 3.14 called the + holder unchanged.""" + _, rollback = mount_rollback(holder) + (holder / SPARE_PATH).mkdir() + refused(monkeypatch, SPARE) + + said = rollback.undo() + + assert (f"; NOT {SPARE_PATH} removed: {SPARE_PATH}: Permission denied; " + f"{STEP_CURE}") in said + monkeypatch.undo() + assert (holder / SPARE_PATH).is_dir() + + +@needs_a_mode +def test_an_undo_under_a_folder_this_user_cannot_search_says_so(holder): + _, rollback = mount_rollback(holder) + (holder / SPARE_PATH).mkdir() + + with made_unopenable(holder, "members"): + said = rollback.undo() + + assert (f"; NOT {SPARE_PATH} removed: {SPARE_PATH}: Permission denied; " + f"{STEP_CURE}") in said + assert (holder / SPARE_PATH).is_dir() + + +@needs_a_mode +@pytest.mark.parametrize("folder", ["members", ".git/modules/members"], + ids=["mount", "store"]) +def test_an_add_rollback_that_cannot_write_is_not_told_cannot_be_read( + folder, family, holder, offline, tmp_path): + """r955's SHOULD-R1 on #955: a `pre-commit` hook takes write away from + the folder above what `add` made, and refuses. The rollback's `rmdir` + failed, and the step said the path "cannot be read" and to "make it + readable". Now it gives the operating system's reason and a cure for + reading and writing both.""" + env = isolated(offline, tmp_path) + above = holder / folder + made = above / SPARE + hook(holder, tmp_path, "pre-commit", + f"chmod 555 '{above.as_posix()}'\nexit 1\n") + try: + result = run(env, *add_argv(holder, family)) + finally: + above.chmod(0o755) + if made.is_dir(): + # `_rmtree` leaves a folder it could not remove at 0200 (r955's + # FYI on #955, from before it), which nothing could list. + made.chmod(0o755) + + assert result.returncode == 2, result.stdout + result.stderr + assert "Traceback" not in result.stderr + shown = f"{folder}/{SPARE}" + assert (f"; NOT {shown} removed: {shown}: Permission denied; " + f"{STEP_CURE}") in result.stderr + assert "cannot be read" not in result.stderr + + +# --- `bump`'s and `remove`'s rollback: what it records (:7198-:7446) --------- + +def pin_rollback(holder: Path): + module = family_module() + return module, module.PinRollback(holder, PATH, "remove", + (".gitmodules", "family.yaml"), + unmount=True) + + +def test_a_checked_out_member_is_recorded_as_one(holder): + """The control: a checkout, its directory and its commit.""" + _, rollback = pin_rollback(holder) + + assert rollback.checkout is True + assert rollback.existed is True + assert rollback.at == git("rev-parse", "HEAD", + cwd=holder / PATH).stdout.strip() + + +@pytest.mark.parametrize("name, shown", [ + (".git", f"{PATH}/.git"), + (MEMBER, PATH), +], ids=["git", "mount"]) +def test_a_pin_record_whose_stat_is_refused_is_refused(name, shown, holder, + monkeypatch): + """Not "no checkout", which a failed `remove` made again, empty, and + called as it was.""" + module = family_module() + refused(monkeypatch, name) + + refusal = refusal_of(module, module.PinRollback, holder, PATH, "remove", + (".gitmodules", "family.yaml")) + + assert refusal.code == "member-unreadable" + assert refusal.detail == f"{shown}: cannot be read (Permission denied)" + + +@needs_a_mode +def test_a_pin_record_of_a_member_this_user_cannot_enter_is_refused(holder): + module = family_module() + with made_unopenable(holder, PATH): + refusal = refusal_of(module, module.PinRollback, holder, PATH, + "remove", (".gitmodules", "family.yaml")) + + assert refusal.detail == (f"{PATH}/.git: cannot be read (Permission " + "denied)") + + +def test_a_kept_store_is_named_there_gone_or_unreadable(holder, tmp_path, + monkeypatch): + """`_store_kept`, the store the checkout's own `.git` named where git + looks in another: still there, named; gone, unsaid; its `stat` refused, + named as unreadable, never left out as gone.""" + _, rollback = pin_rollback(holder) + kept = tmp_path / "kept-store" + kept.mkdir() + rollback.store = kept + assert rollback._store_kept() == ( + f"; the object store its checkout had, {kept.as_posix()}, is still " + "there") + rollback.store = tmp_path / "no-store" + assert rollback._store_kept() == "" + + rollback.store = kept + refused(monkeypatch, kept.name) + + assert rollback._store_kept() == ( + f"; the object store its checkout had, {kept.as_posix()}: cannot be " + "read (Permission denied)") + assert rollback.store_said is True + + +# --- `add`: the mount path and the cached store (:6031, :6171) -------------- + +def add_argv(holder: Path, family: dict, *extra: str) -> list[str]: + return ["add", "--family-root", str(holder), "--member", + f"{ORG}/{SPARE}", "--local-remote-dir", str(family["remotes"]), + *extra] + + +def test_a_free_mount_path_is_still_free(family, holder, offline, tmp_path): + """The control: nothing at the path, so `add --dry-run` plans the + mount.""" + result = run(isolated(offline, tmp_path), + *add_argv(holder, family, "--dry-run")) + + assert result.returncode == 0, result.stdout + result.stderr + + +def test_a_cached_store_is_still_refused_as_one(family, holder, offline, + tmp_path): + """The control: a store a removed member left behind is + `member-git-dir-cached`.""" + (holder / ".git" / "modules" / SPARE_PATH).mkdir(parents=True) + + result = run(isolated(offline, tmp_path), *add_argv(holder, family)) + + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr.startswith("REFUSED member-git-dir-cached: ") + + +def add_in_process(module, family, holder, offline, tmp_path, monkeypatch, + capsys) -> tuple[int, str]: + code = in_process(module, monkeypatch, isolated(offline, tmp_path), + add_argv(holder, family)) + return code, capsys.readouterr().err + + +def test_a_mount_path_whose_stat_is_refused_is_not_free( + family, holder, offline, tmp_path, monkeypatch, capsys): + """`os.path.lexists` called it free, and the mount died in git's clone + after the member was read; on 3.12 the rollback's record raised first.""" + env = isolated(offline, tmp_path) + before = facts(holder, env) + refused(monkeypatch, SPARE) + + code, err = add_in_process(family_module(), family, holder, offline, + tmp_path, monkeypatch, capsys) + + assert code == 2, err + assert refusal_detail(err, "member-unreadable") == [ + f"REFUSED member-unreadable: {SPARE_PATH}: cannot be read " + "(Permission denied)"] + monkeypatch.undo() + assert facts(holder, env) == before + offline.assert_left_nothing() + + +def test_a_cached_store_whose_stat_is_refused_is_named( + family, holder, offline, tmp_path, monkeypatch, capsys): + """Asked past the mount path's own question, which a refused `stat` of + the member's name answers first.""" + module = family_module() + monkeypatch.setattr(module, "_checked_mount_path", + lambda *args, **kwargs: None) + refused(monkeypatch, SPARE) + + code, err = add_in_process(module, family, holder, offline, tmp_path, + monkeypatch, capsys) + + assert code == 2, err + assert refusal_detail(err, "member-unreadable") == [ + f"REFUSED member-unreadable: .git/modules/{SPARE_PATH}: cannot be " + "read (Permission denied)"] + + +@needs_a_mode +@pytest.mark.parametrize("where, shown", [ + ("members", SPARE_PATH), + (".git/modules/members", f".git/modules/{SPARE_PATH}"), +], ids=["mount", "store"]) +def test_add_names_a_folder_this_user_cannot_search( + where, shown, family, holder, offline, tmp_path): + """3.12 raised a traceback; 3.14 mounted, and git's clone died.""" + env = isolated(offline, tmp_path) + before = facts(holder, env) + + with made_unopenable(holder, where): + result = run(env, *add_argv(holder, family)) + + assert result.returncode == 2, result.stdout + result.stderr + assert "Traceback" not in result.stderr + assert refusal_detail(result.stderr, "member-unreadable") == [ + f"REFUSED member-unreadable: {shown}: cannot be read (Permission " + "denied)"] + assert facts(holder, env) == before + + +@pytest.mark.parametrize("dry", [True, False], ids=["dry-run", "real"]) +def test_add_refuses_a_file_where_the_stores_folder_belongs( + dry, family, holder, offline, tmp_path): + """r955's NIT 4 on #955: with a FILE at `.git/modules/members` the dry + run planned a mount, exit 0, that the real run's record refused. Both + refuse it now, before the member is read, with no `chmod`.""" + env = isolated(offline, tmp_path) + modules = holder / ".git" / "modules" / "members" + aside(modules, tmp_path) + modules.write_bytes(b"a file where the stores' folder was\n") + before = facts(holder, env) + + result = run(env, *add_argv(holder, family, + *(["--dry-run"] if dry else []))) + + assert result.returncode == 2, result.stdout + result.stderr + assert refusal_detail(result.stderr, "member-unreadable") == [ + f"REFUSED member-unreadable: .git/modules/{SPARE_PATH}: cannot be " + "read (Not a directory)"] + assert NO_CHMOD in remedy(result.stderr) + assert facts(holder, env) == before + offline.assert_left_nothing() + + +# --- the member's own `project.yaml` (:778) -------------------------------- + +def test_a_member_with_no_manifest_is_still_not_a_project(tmp_path): + """The control: nothing at `project.yaml` is `member-not-a-project`.""" + module = family_module() + + refusal = refusal_of(module, module._read_member_project, tmp_path, + SPARE, f"{ORG}/{SPARE}") + + assert refusal.code == "member-not-a-project" + + +@pytest.mark.parametrize("shown", [None, f"{ORG}/{SPARE} @ abc:project.yaml"], + ids=["mount", "objects"]) +def test_a_manifest_whose_stat_is_refused_is_unreadable_not_absent( + shown, tmp_path, monkeypatch): + """Not `member-not-a-project`, which sends the person to scaffold a + project that is one: the manifest is named, as `shown` names it.""" + module = family_module() + refused(monkeypatch, "project.yaml") + + refusal = refusal_of(module, module._read_member_project, tmp_path, + SPARE, f"{ORG}/{SPARE}", shown) + + assert refusal.code == "member-manifest-unreadable" + named = shown or str(tmp_path / "project.yaml") + assert refusal.detail == f"{named}: cannot be read (Permission denied)" + + +@needs_a_mode +def test_a_manifest_linked_into_a_folder_this_user_cannot_search(tmp_path): + """A `project.yaml` that is a link into a folder this user may not + search.""" + locked = tmp_path / "locked" + locked.mkdir() + (locked / "project.yaml").write_text("id: irqs\n", encoding="utf-8") + member = tmp_path / "member" + member.mkdir() + os.symlink(locked / "project.yaml", member / "project.yaml") + module = family_module() + mode = stat.S_IMODE(locked.stat().st_mode) + locked.chmod(0) + try: + refusal = refusal_of(module, module._read_member_project, member, + SPARE, f"{ORG}/{SPARE}") + finally: + locked.chmod(mode) + + assert refusal.code == "member-manifest-unreadable"