diff --git a/adopt-project.py b/adopt-project.py index fd70d264..792a0f56 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -217,7 +217,7 @@ import sys import tempfile from pathlib import Path, PurePosixPath -from typing import NamedTuple +from typing import Callable, NamedTuple SHAPE_ROOT = Path(__file__).resolve().parent sys.path.insert(0, str(SHAPE_ROOT / "scripts")) @@ -1653,6 +1653,71 @@ def _read_for_the_history(source: "Source", tree: list, return None if gitlink is None else InTheHistory(words, gitlink) +# --------------------------------------------------------------------------- +# Whether something is at a path, or the system would not say (#430) +# --------------------------------------------------------------------------- + +#: 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. +#: `scripts/bump-leg.py`'s set (#383), and the one every copy is held to. +_ABSENT_ERRNOS = (errno.ENOENT, errno.ENOTDIR, errno.EBADF, errno.ELOOP) +_ABSENT_WINERRORS = (21, 123, 1921) + + +def _absent(exc: OSError) -> bool: + """Whether `exc`, from a `stat`, means nothing is at the name + (`_ABSENT_ERRNOS`, `_ABSENT_WINERRORS`), and not that the operating + system would not say (#430).""" + 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`), + raising the `OSError` of a `stat` the operating system REFUSED (#430), + as `scripts/bump-leg.py`'s `_is_a` asks it (#383). + + NOT `Path.exists()`, `is_file()` OR `is_dir()`, 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.exists` and the like, which answer False to ANY `OSError`. So a + `--source` under a directory this user cannot search ended `plan` in a + traceback on 3.12, and on 3.14 was "neither a path that exists nor an + `org/repo`", or, spelled `org/repo`, was cloned from GitHub; and a plan's + recorded clone there "no longer exists" and was cloned again. Asked + here, every Python answers as 3.9 to 3.13 did, and the caller names a + refused `stat`. A name the operating system cannot take at all (a NUL + in it) is nothing there, as `exists()` answered it. + """ + 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 _is_there(path: Path) -> bool: + """Whether anything is at `path`, following a symlink as `Path.exists()` + does, asked as `_is_a` asks it: the `OSError` of a refused `stat` is + raised, never read as nothing there (#430).""" + return _is_a(path, lambda _mode: True) + + +def _os_reason(exc: OSError) -> str: + """The operating system's own words for `exc` (`Permission denied`), or + the exception's name where it gave none, as `validate-pins.py`'s + `_cannot_read` words them.""" + return exc.strerror or type(exc).__name__ + + # --------------------------------------------------------------------------- # What a `--source` names, and where its clone goes (#652) # --------------------------------------------------------------------------- @@ -1676,6 +1741,20 @@ def _read_for_the_history(source: "Source", tree: list, #: The refusal of a local source that is no repository's top-level #: directory: a file, or a directory inside a repository (#796). SOURCE_NOT_TOP_LEVEL = "source-not-top-level" +#: The refusal of a `--source`, or a plan's `source.local_path`, whose path +#: this user cannot reach: its `stat` refused for another reason than that +#: nothing is there (#430). Never read as a path that is not there. +SOURCE_UNREADABLE = "source-unreadable" +#: What `source-unreadable` says to do, by the cause: a permission is put +#: right by the mode or the owner, in `validate-pins.py`'s words for a +#: directory (`_unreadable_file`), and no `chmod` puts right any other +#: reason the operating system gave (an I/O error, a name too long). +SOURCE_UNREADABLE_PERMISSION = ( + "Make it readable by this user: `chmod u+rx` on the directory and on " + "every directory above it, or fix who owns them. ") +SOURCE_UNREADABLE_OTHER = ( + "The operating system gave another reason than permission, which no " + "`chmod` puts right: put right what it names. ") #: What `source-not-top-level` says a source is, and its way out, for a #: `--source` and a plan's `source.local_path` alike. TOP_LEVEL_ONLY = "a source is the top-level directory of a repository" @@ -1749,6 +1828,56 @@ def _ambiguous(spec: str, name: str) -> Refusal: "clone the repository from GitHub.") +def _source_unreadable(named: str, exc: OSError, untold: str, + instead: str = "") -> Refusal: + """`source-unreadable` (#430): `named` (`--source ''`, or the plan's + `source.local_path`) is a path whose `stat` the operating system refused + for another reason than that nothing is there, `: cannot be read + ()`, as `validate-pins.py`'s `_cannot_read` words it; `untold` is + what that leaves unknown, which is not guessed. The way out follows the + cause (`SOURCE_UNREADABLE_PERMISSION`, `SOURCE_UNREADABLE_OTHER`), and + `instead`, another way to name the source, ends it.""" + advice = (SOURCE_UNREADABLE_PERMISSION if isinstance(exc, PermissionError) + else SOURCE_UNREADABLE_OTHER) + return Refusal( + SOURCE_UNREADABLE, + f"{named}: cannot be read ({_os_reason(exc)}), so {untold} cannot be " + "told", + "Remediation: a path this user cannot reach is never read as one " + "that is not there, and nothing was cloned. " + advice + + "Then run the same command again" + + (f"; or {instead}." if instead else ".")) + + +def _source_there(spec: str, candidate: Path, name: str | None) -> bool: + """Whether anything is at `candidate`, the path `--source spec` names + here, asked as `_is_a` asks it; `name` is the GitHub repository the + same spelling names, or None (#430). + + A REFUSED `stat` IS `source-unreadable`, NEVER "NOT THERE". On Python + 3.9 to 3.13 `Path.exists()` raised it out of `plan`, `check` and + `execute`, a `PermissionError` traceback; on 3.14 it answered False, so + a path under a directory this user cannot search was "neither a path + that exists nor an `org/repo`", and a spelling that is also a name + (`acme/Atlas` beside an unsearchable `./acme`) was cloned from GitHub, + where `source-ambiguous` refuses it once the path can be seen. Which of + the two it names cannot be told, so neither is read, and the url is + offered for the repository, as `source-ambiguous` offers it.""" + try: + return _is_there(candidate) + except OSError as exc: + if name is None: + raise _source_unreadable(f"--source {spec!r}", exc, + "whether a repository is there") from exc + here = _spelled("./" + spec.rstrip("/")) + raise _source_unreadable( + f"--source {spec!r}", exc, + f"whether it names the path {here} here, or only the GitHub " + f"repository `{name}`,", + f"pass `https://{GITHUB_HOST}/{name}` to clone the repository " + "from GitHub") from exc + + def _repository_in_url(spec: str, url: re.Match, candidate: Path) -> str: """The `owner/repo` a url `--source` names (`SOURCE_URL_RE`'s `url`): refused where its host is not GitHub's, where it names no `owner/repo`, @@ -1770,7 +1899,7 @@ def _repository_in_url(spec: str, url: re.Match, candidate: Path) -> str: SOURCE_UNRESOLVABLE, f"--source {spec!r} names no `owner/repo` on {GITHUB_HOST}", "Remediation: pass a local clone's path, or `org/repo`.") - if url["bare"] and candidate.exists(): + if url["bare"] and _source_there(spec, candidate, name): raise _ambiguous(spec, name) return name @@ -1792,6 +1921,11 @@ def _source_spec(spec: str) -> tuple[Path | None, str | None]: ALSO a path here, which is refused as `source-ambiguous`, naming both spellings: guessing either reads a repository nobody named; - anything else is a path, read in place where it exists. + Whether a path is there is asked of the disk by `_source_there`, which + refuses a `stat` the operating system refused for another reason than + that nothing is there as `source-unreadable` (#430): a path this user + cannot reach is neither "a path that does not exist" nor proof that a + name is only GitHub's. A DRIVE-LETTER PATH IS A PATH THAT IS NOT THERE, never an `org/repo`. `D:/work/Thing` carries a `/` and would otherwise be handed to `gh repo @@ -1831,7 +1965,7 @@ def _source_spec(spec: str) -> tuple[Path | None, str | None]: return None, _repository_in_url(spec, url, candidate) name = (None if written_as_a_path or "\\" in spec else _github_name(spec)) - if candidate.exists(): + if _source_there(spec, candidate, name): if name is not None: raise _ambiguous(spec, name) return _top_level(candidate, f"--source {spec!r}"), None @@ -5916,9 +6050,28 @@ def _recorded_source(self, local_path: str, work_root: Path, ended `check` and `execute` in a traceback, and a directory inside a repository was read as the source. A `~user` this machine has not is a path that is not there (`os.path.expanduser`): `Path.expanduser` - raised `RuntimeError` for it.""" + raised `RuntimeError` for it. + + A PATH THIS USER CANNOT REACH IS NEITHER GONE NOR CLONED AGAIN (#430). + Whether it is there is asked as `_is_a` asks it, and a `stat` the + operating system refused for another reason than that nothing is + there (a directory above it this user cannot search) is + `source-unreadable`, naming it. On Python 3.9 to 3.13 `Path.exists()` + raised it, a `PermissionError` traceback; on 3.14 it answered False, + so the path was `plan-source-gone`, "names nothing that exists", or, + a clone `plan` made, "no longer exists" and was cloned again from + GitHub beside the one still there.""" path = Path(os.path.expanduser(local_path)) - there = path.exists() + try: + there = _is_there(path) + except OSError as exc: + raise _source_unreadable( + f"the plan's `source.local_path`, {_spelled(local_path)}", exc, + ("whether `plan`'s clone is still there, to be read, or gone, " + "to be cloned again," if self.source.get("cloned") is True + else "whether the source is still there"), + "pass the source again with --source, which is held to the " + "plan's `commit:`") from exc if self.source.get("cloned") is not True: if there: return Source.in_place(_top_level( @@ -9019,6 +9172,29 @@ def _check_split_branch(plan: Plan, source: Source return found, [SPLIT_BRANCH_REMEDIATION] if found else [] +def _a_leg_may_be_on_disk(urls: dict) -> bool: + """Whether, under `--local-remote-dir`, a leg's bare repository is at + its url, or may be: its `stat` refused for another reason than that + nothing is there (a `--local-remote-dir` this user cannot search), asked + as `_is_a` asks it (#430). + + Either way the split branch's two refusals, which defer to a leg still + on disk, defer: `_refuse_what_stands_in_the_legs_way` names both, before + either leg is made, a leg already there as `leg-remote-exists` and a + directory this user cannot search or write in as + `adopt-folder-cannot-be-made`. `Path.exists()` raised the refused `stat` + out of `execute` on Python 3.9 to 3.13, a `PermissionError` traceback + before either question was asked, and on 3.14 answered False, and the + split branch was asked about first.""" + for role in EXTRACTED_LEGS: + try: + if _is_there(Path(urls[role])): + return True + except OSError: + return True + return False + + def _refuse_a_branch_in_the_split_branchs_way( plan: Plan, source: Source, repositories: dict, urls: dict, branch: str, local: bool, extracting: bool) -> None: @@ -9044,7 +9220,7 @@ def _refuse_a_branch_in_the_split_branchs_way( meet it, which `check` reads as the source's `origin` last fetched it, if at all. """ - if local and any(Path(urls[role]).exists() for role in EXTRACTED_LEGS): + if local and _a_leg_may_be_on_disk(urls): return the_source = f"the source {_shown(str(source.path))}" if local: @@ -9540,7 +9716,7 @@ def _through(self, walk: _Walk, link: tuple[str, str], ends: tuple, leads = (f"the symlink {_spelled(at)} leads to " f"{_spelled(target)}, {ON_THIS_MACHINE}") if last: - self._outside(walk, target, f"{leads}, and nothing is there") + self._outside(walk, target, leads, ", and nothing is there") else: walk.problem = leads return [] @@ -9549,15 +9725,37 @@ def _through(self, walk: _Walk, link: tuple[str, str], ends: tuple, + [(parts[-1], at, last, ((at, target), *ends))] + queue) @staticmethod - def _outside(walk: _Walk, place: str, missing: str) -> None: + def _outside(walk: _Walk, place: str, lead: str, nothing: str) -> None: """`walk` leaves the assembly for `place` on this machine's disk: - taken where something is there, else `missing` is what stops it. - Windows may spell a place with backslashes; it is named with - slashes, as every other path is.""" + taken where something is there, else `lead` and `nothing`, which + says nothing is there, are what stops it. Windows may spell a place + with backslashes; it is named with slashes, as every other path is. + + A `stat` THE OPERATING SYSTEM REFUSED for another reason than that + nothing is there (a directory above `place` this user cannot + search) stops it too, `: cannot be read ()` after + `lead` (#430): `os.path.exists` answered False to it on every + Python, and the finding said "nothing is there" of a place that + holds something. `place` is asked as the text it is, as + `os.path.exists` asked it: a `Path` drops a trailing `/`, which a + `stat` takes only from a directory.""" walk.outside, walk.landed = True, Path(place).as_posix() - walk.taken = os.path.exists(place) - if not walk.taken: - walk.problem = missing + walk.taken = False + try: + os.stat(place) + except OSError as exc: + walk.problem = lead + (nothing if _absent(exc) else ( + f", and {_spelled(walk.landed)}: cannot be read " + f"({_os_reason(exc)}), so whether anything is there cannot " + "be told" + + (" until this user can search every directory above it " + "(`chmod u+rx`)" if isinstance(exc, PermissionError) + else ""))) + except ValueError: + # a name the operating system cannot take at all (a NUL in it) + walk.problem = lead + nothing + else: + walk.taken = True def _above(self, walk: _Walk, origin: str, last: bool, queue: list) -> list: @@ -9588,8 +9786,9 @@ def _above(self, walk: _Walk, origin: str, last: bool, # Joined as text, not as a `Path`: a `Path` drops a trailing `/`, # which `open()` takes only from a directory. place = os.path.join(str(work), *names) - self._outside(walk, place, f"{climbs}, to " - f"{_spelled(Path(place).as_posix())}, where nothing is") + self._outside(walk, place, + f"{climbs}, to {_spelled(Path(place).as_posix())}", + ", where nothing is") return [] def _named(self, name: str, made: str) -> bool: @@ -11813,7 +12012,7 @@ def _refuse_a_split_branch_left_behind(source: Source, urls: dict, The assembly and the branch are `_spelled`, as the notice's split line names them (#212); the deletion is a command, quoted for `sh`. """ - if local and any(Path(urls[role]).exists() for role in EXTRACTED_LEGS): + if local and _a_leg_may_be_on_disk(urls): return # Under `--local-remote-dir` the push goes into the source, and meets its # own refs, a branch it hides from `git ls-remote` too (#520). @@ -15690,7 +15889,7 @@ def _work_dir_help(does: str, refused: str, but: str = "") -> str: "is a file or a symlink, or holds `` already, even " "empty, is refused as `adopt-work-dir-occupied`.") #: `--source`'s help on `check` and `execute`: how the source is read with -#: it and without it (#652), and a recorded path refused (#796). +#: it and without it (#652), and a recorded path refused (#796, #430). SOURCE_OVERRIDE_HELP = ( "override the source the plan names, read as `plan --source` reads it. " "Without it, the plan's `source.local_path` is read; where that is a " @@ -15698,7 +15897,8 @@ def _work_dir_help(does: str, refused: str, but: str = "") -> str: "whole, its `source.repository` is cloned again, and any other " "`local_path` that is gone is refused as `plan-source-gone`; one that " "is a file, or a directory inside a repository, as " - "`source-not-top-level`.") + "`source-not-top-level`; and one this user cannot reach, a clone " + "`plan` made included, as `source-unreadable`, never cloned again.") def main(argv: list[str] | None = None) -> int: @@ -15724,7 +15924,10 @@ def main(argv: list[str] | None = None) -> int: "directory inside a repository. " "`source-unresolvable` refuses an empty value, " "and one that is neither a path that exists nor " - "an `org/repo`.") + "an `org/repo`; `source-unreadable`, a path this " + "user cannot reach (under a directory it may not " + "search), which is never read as one that is not " + "there, nor cloned as an `org/repo`.") plan.add_argument("--project", required=True, help="the assembly-root name; for an in-place adoption " "it is the source repository's own name. " diff --git a/docs/cli.md b/docs/cli.md index 96c12492..5fbb50b1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -448,7 +448,11 @@ options: --show-toplevel` prints it): `source-not-top-level` refuses a file, or a directory inside a repository. `source-unresolvable` refuses an empty value, and one - that is neither a path that exists nor an `org/repo`. + that is neither a path that exists nor an `org/repo`; + `source-unreadable`, a path this user cannot reach + (under a directory it may not search), which is never + read as one that is not there, nor cloned as an + `org/repo`. --project PROJECT the assembly-root name; for an in-place adoption it is the source repository's own name. `execute` refuses one over 95 characters without --local-remote-dir: @@ -615,7 +619,9 @@ options: longer whole, its `source.repository` is cloned again, and any other `local_path` that is gone is refused as `plan-source-gone`; one that is a file, or a directory - inside a repository, as `source-not-top-level`. + inside a repository, as `source-not-top-level`; and + one this user cannot reach, a clone `plan` made + included, as `source-unreadable`, never cloned again. --local-remote-dir LOCAL_REMOTE_DIR the directory `execute --local-remote-dir` will be given (the TEST path): each relative submodule url the @@ -672,7 +678,9 @@ options: longer whole, its `source.repository` is cloned again, and any other `local_path` that is gone is refused as `plan-source-gone`; one that is a file, or a directory - inside a repository, as `source-not-top-level`. + inside a repository, as `source-not-top-level`; and + one this user cannot reach, a clone `plan` made + included, as `source-unreadable`, never cloned again. --local-remote-dir LOCAL_REMOTE_DIR create the legs as bare repositories here instead of calling `gh` (the TEST path). Both are asked about diff --git a/tests/test_adopt_unsearchable_source.py b/tests/test_adopt_unsearchable_source.py new file mode 100644 index 00000000..a742f5be --- /dev/null +++ b/tests/test_adopt_unsearchable_source.py @@ -0,0 +1,593 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`adopt-project.py`: a path this user cannot reach is refused by name, never +read as one that is not there (slice H of #430). + +`Path.exists()`, `is_file()` and `is_dir()` raised a refused `stat` up to +Python 3.13 and answer False to ANY `OSError` from 3.14; `os.path.exists` +has always answered False to one. So where `adopt-project.py` asked one of +them about a path the person can make unreadable, the same command gave a +traceback on one Python and a false sentence on the other. ON 5017830 +(2026-10-18), Python 3.12.3, and the same Python with a `sitecustomize` that +makes those four answer as 3.14's do, a non-root user, local remotes and a +stub `gh` that refuses every call: + +- `plan --source /Thing`, `` mode 000: a `PermissionError` + traceback at `_source_spec`, exit 1; as 3.14, `REFUSED source-unresolvable: + --source '...' is neither a path that exists nor an `org/repo` name`. +- `plan --source testorg/Thing` beside `./testorg` at mode 000, and + `github.com/testorg/Thing` beside `./github.com`: the same traceback; as + 3.14, `cloning testorg/Thing (read only)`, `gh repo clone` asked, where + `source-ambiguous` refuses the spelling once the path can be seen. +- `check` of a plan whose `source.local_path` is under a mode-000 directory: + the traceback, at `_recorded_source`; as 3.14, `plan-source-gone`, "names + nothing that exists", and, recorded as a clone `plan` made, `NOTE ... no + longer exists, so its source.repository, testorg/Thing, is cloned again` + and `gh repo clone` asked. +- `execute --yes --local-remote-dir `: the traceback at + `_refuse_a_split_branch_left_behind`, before the leg question that names + such a directory (`adopt-folder-cannot-be-made`, which 3.14 reached). +- `check` of a plan that keeps `AGENTS.md` in the root, a symlink to a file + under a mode-000 directory: on both, `FINDING plan-root-symlink: ... and + nothing is there`, of a place that holds a file. + +Now each is `source-unreadable`, naming the path and the operating system's +reason, with `chmod u+rx` as the cure, on every Python; the leg question +names the unsearchable `--local-remote-dir`; and the symlink's finding says +its destination cannot be read. + +TWO WAYS TO REFUSE A `stat`, as `tests/test_bump_leg_unopenable.py` has them. +A real mode (`chmod 000`), restored in a `finally`, run in a child: skipped on +Windows and as root, who reads through every mode. And an in-process stand-in +(`refused_stat`) that refuses `Path.stat` for named paths and makes +`Path.exists`, `is_file` and `is_dir` answer as 3.14's do, so a tool that +leans on them fails on every Python, root and Windows included. + +NO NETWORK AND NO GITHUB: a child runs with a `gh` first on PATH that records +each call and refuses it, and a test asserts it was never asked; in-process, +`gh repo clone` is a `git clone` of a repository on disk (`gh_clones`). +""" + +from __future__ import annotations + +import contextlib +import errno +import os +import shlex +import stat +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from conftest import (ADOPT, git, load_without_bytecode, make_source_repo, + run_script, spelled) +from test_adopt_temp_work_roots import ( + ORG, PLANNED, PROJECT, REPOSITORY, SMALL_TREE, needs_filter_repo, + planned_from_github, recorded, +) + +needs_a_mode = pytest.mark.skipif( + sys.platform == "win32" or (hasattr(os, "geteuid") and os.geteuid() == 0), + reason="a mode that refuses the owner is a POSIX mode, and root reads " + "through every one (tests/test_bump_leg_unopenable.py)") +needs_a_symlink = pytest.mark.skipif( + sys.platform == "win32", + reason="the source keeps a symlink, which Windows makes only with a " + "privilege CI does not grant") + +UNREADABLE = "REFUSED source-unreadable: " +DENIED = "cannot be read (Permission denied)" +CURE = ("Make it readable by this user: `chmod u+rx` on the directory and on " + "every directory above it, or fix who owns them. Then run the same " + "command again") +#: What the code before #430's slice H said, on one Python or the other. +FALSE_ANSWERS = ("Traceback", "neither a path that exists", + "names nothing that exists", "no longer exists", + "is cloned again", "cloning ") +URL = f"https://github.com/{REPOSITORY}" + + +@pytest.fixture(scope="module") +def adopter(): + return load_without_bytecode(ADOPT, "adopt_unsearchable_source") + + +@contextlib.contextmanager +def unsearchable(directory: Path): + """`directory` at mode 000 (no listing, no search) for the `with`, its + mode given back in a `finally`, so the temporary tree can be removed. + The mode is asserted to bite: a file system that ignores modes would + pass every test here for the wrong reason.""" + before = stat.S_IMODE(directory.stat().st_mode) + directory.chmod(0) + try: + assert not os.access(directory, os.X_OK), directory + yield directory + finally: + directory.chmod(before) + + +def refused_stat(monkeypatch, *names: str) -> None: + """`Path.stat` refused (`Permission denied`) for each path whose last + part is one of `names`, as it is for a name inside a directory this user + may not search; and `Path.is_file`, `is_dir` and `exists` answering as + Python 3.14's do, False to ANY `OSError`. In-process, so it runs on every + Python, every platform and as root (`tests/test_bump_leg_unopenable.py`'s + `refused_stat`).""" + real = Path.stat + + def refusing(self, *args, **kwargs): + if self.name in names: + raise PermissionError(errno.EACCES, "Permission denied", str(self)) + return real(self, *args, **kwargs) + + def as_python_314(kind): + def answer(self, *args, **kwargs): + try: + return kind(self.stat(*args, **kwargs).st_mode) + except (OSError, ValueError): + return False + return answer + + monkeypatch.setattr(Path, "stat", refusing) + monkeypatch.setattr(Path, "is_file", as_python_314(stat.S_ISREG)) + monkeypatch.setattr(Path, "is_dir", as_python_314(stat.S_ISDIR)) + monkeypatch.setattr(Path, "exists", as_python_314(lambda mode: True)) + + +def recording_gh(directory: Path) -> tuple[dict, Path]: + """A `gh` that records each call in a log and refuses it, put first on + PATH: `(the environment a child is run with, the log)`. Ahead of the + suite's own guard, so a call is this test's to assert on.""" + directory.mkdir() + log = directory / "gh-calls.log" + log.write_bytes(b"") + script = directory / "gh" + script.write_bytes( + f"#!/bin/sh\nprintf '%s\\n' \"$*\" >> {shlex.quote(str(log))}\n" + "echo 'w430h: gh is not to be asked here' >&2\nexit 4\n" + .encode("utf-8")) + script.chmod(0o755) + return {"PATH": f"{directory}{os.pathsep}{os.environ['PATH']}"}, log + + +def said(proc) -> str: + """What a child printed, stdout then stderr.""" + return proc.stdout + proc.stderr + + +def assert_no_false_answer(text: str) -> None: + """None of the words the code before the fix said, on either Python.""" + for words in FALSE_ANSWERS: + assert words not in text, (words, text) + + +def run(adopter, capsys, *argv: str) -> tuple[int, str, str]: + """`adopt-project.py ` in this process: its exit, stdout, stderr.""" + capsys.readouterr() + code = adopter.main(list(argv)) + out = capsys.readouterr() + return code, out.out, out.err + + +def planned_locally(tmp_path: Path, source: Path, cloned: bool = False + ) -> Path: + """`plan --source ` in a child, with a work directory of its own, + and the plan it wrote; recorded as a clone `plan` made where `cloned` + says so, as `plan --source ` records its clone.""" + plan = tmp_path / "adoption-plan.yaml" + written = run_script(ADOPT, "plan", "--source", str(source), *PLANNED, + "--out", str(plan), "--work-dir", + str(tmp_path / "plan-work")) + assert written.returncode == 0, said(written) + if cloned: + text = plan.read_bytes() + assert text.count(b"\n cloned: false\n") == 1, text + plan.write_bytes(text.replace(b"\n cloned: false\n", + b"\n cloned: true\n")) + return plan + + +# --- the helper, asked as Python 3.9 to 3.13 asked it ------------------------ + +@pytest.mark.parametrize("code", [errno.ENOENT, errno.ENOTDIR, errno.EBADF, + errno.ELOOP]) +def test_a_stat_that_finds_nothing_is_nothing_there(adopter, monkeypatch, + tmp_path, code): + """The four `OSError`s that `Path.exists()` answered False to on 3.9 to + 3.13, and that `scripts/bump-leg.py`'s `_is_a` reads as absent (#383).""" + def refusing(self, *args, **kwargs): + raise OSError(code, os.strerror(code), str(self)) + + monkeypatch.setattr(Path, "stat", refusing) + assert adopter._is_there(tmp_path / "x") is False + + +@pytest.mark.parametrize("code", [errno.EACCES, errno.EPERM, errno.EIO, + errno.ENAMETOOLONG]) +def test_any_other_refused_stat_is_raised_never_read_as_absent( + adopter, monkeypatch, tmp_path, code): + """Every other `OSError` is the operating system declining to say, which + 3.14's `Path.exists()` answers False to, and `_is_there` raises.""" + def refusing(self, *args, **kwargs): + raise OSError(code, os.strerror(code), str(self)) + + monkeypatch.setattr(Path, "stat", refusing) + with pytest.raises(OSError) as raised: + adopter._is_there(tmp_path / "x") + assert raised.value.errno == code + + +def test_a_name_the_system_cannot_take_is_nothing_there(adopter): + """A NUL in a name is no path at all, as `Path.exists()` answered it.""" + assert adopter._is_there(Path("no\0such")) is False + + +# --- `--source` under a directory this user cannot search (:1826) ------------ + +@needs_a_mode +def test_a_source_under_an_unsearchable_parent_is_refused_by_name(tmp_path): + """A local `--source` whose parent is mode 000: a traceback on 3.12 and + `source-unresolvable`, "neither a path that exists nor an `org/repo`", + on 3.14. Now `source-unreadable`, naming it, with the cure, and no plan + is written.""" + locked = tmp_path / "locked" + source = make_source_repo(locked / PROJECT, tree=SMALL_TREE, edits=()) + plan = tmp_path / "adoption-plan.yaml" + env, log = recording_gh(tmp_path / "gh-bin") + + with unsearchable(locked): + proc = run_script(ADOPT, "plan", "--source", str(source), *PLANNED, + "--out", str(plan), "--work-dir", + str(tmp_path / "work"), env=env) + + assert proc.returncode == 2, said(proc) + assert (f"{UNREADABLE}--source {str(source)!r}: {DENIED}, so whether a " + "repository is there cannot be told") in proc.stderr + assert CURE in proc.stderr + assert_no_false_answer(said(proc)) + assert not plan.exists() + assert log.read_bytes() == b"" + + +@pytest.mark.parametrize(("spec", "untold"), [ + pytest.param("{locked}", "whether a repository is there", id="a-path"), + pytest.param(REPOSITORY, "whether it names the path `./testorg/Thing` " + f"here, or only the GitHub repository `{REPOSITORY}`,", + id="org-repo"), + pytest.param(f"{REPOSITORY}/", "whether it names the path " + f"`./testorg/Thing` here, or only the GitHub repository " + f"`{REPOSITORY}`,", id="org-repo-slash"), + pytest.param(f"github.com/{REPOSITORY}", "whether it names the path " + f"`./github.com/testorg/Thing` here, or only the GitHub " + f"repository `{REPOSITORY}`,", id="bare-github-url"), +]) +def test_a_source_whose_stat_is_refused_is_refused_on_every_python( + adopter, monkeypatch, tmp_path, spec, untold): + """The same question with the `stat` refused in-process, as 3.14 answers + `exists()`: a path is never "neither a path that exists nor an + `org/repo`", and a spelling that may be a path here is never handed to + `gh repo clone`; `_source_spec` returning `(None, "testorg/Thing")` was + that clone. The url is offered, as `source-ambiguous` offers it.""" + spec = spec.format(locked=tmp_path / "locked" / PROJECT) + monkeypatch.chdir(tmp_path) + refused_stat(monkeypatch, PROJECT) + + with pytest.raises(adopter.Refusal) as raised: + adopter._source_spec(spec) + + refusal = raised.value + assert refusal.code == "source-unreadable" + assert refusal.detail == (f"--source {spec!r}: {DENIED}, so {untold} " + "cannot be told") + assert CURE in refusal.remediation + assert (f"pass `{URL}` to clone the repository from GitHub" in + refusal.remediation) == (spec != str(tmp_path / "locked" / + PROJECT)) + + +def test_a_stat_refused_for_another_reason_is_not_told_to_chmod( + adopter, monkeypatch, tmp_path): + """A `stat` refused for another reason than permission (an I/O error) + is `source-unreadable` too, told to put right what it names: no `chmod` + puts that right.""" + def refusing(self, *args, **kwargs): + raise OSError(errno.EIO, os.strerror(errno.EIO), str(self)) + + monkeypatch.setattr(Path, "stat", refusing) + with pytest.raises(adopter.Refusal) as raised: + adopter._source_spec(str(tmp_path / PROJECT)) + + refusal = raised.value + assert refusal.code == "source-unreadable" + assert f"cannot be read ({os.strerror(errno.EIO)})" in refusal.detail + assert ("which no `chmod` puts right: put right what it names" + in refusal.remediation) + assert "chmod u+rx" not in refusal.remediation + + +# --- an `org/repo`-shaped directory here that cannot be searched ------------- + +@needs_a_mode +@pytest.mark.parametrize(("spec", "holder"), [ + (REPOSITORY, ORG), + (f"github.com/{REPOSITORY}", "github.com"), +]) +def test_an_org_repo_beside_an_unsearchable_directory_is_not_sent_to_github( + tmp_path, spec, holder): + """`./testorg/Thing` is a repository here, under a `./testorg` this user + cannot search, and `--source testorg/Thing` names it as well as the + GitHub repository. On 3.14 it was cloned from GitHub, where the same + spelling beside a directory that can be searched is `source-ambiguous`. + Now `source-unreadable`, and `gh` is never asked.""" + here = tmp_path / "here" + make_source_repo(here.joinpath(*spec.split("/")), tree=SMALL_TREE, + edits=()) + env, log = recording_gh(tmp_path / "gh-bin") + + with unsearchable(here / holder): + proc = run_script(ADOPT, "plan", "--source", spec, *PLANNED, "--out", + str(tmp_path / "adoption-plan.yaml"), "--work-dir", + str(tmp_path / "work"), cwd=here, env=env) + + assert proc.returncode == 2, said(proc) + assert (f"{UNREADABLE}--source {spec!r}: {DENIED}, so whether it names " + f"the path `./{spec}` here, or only the GitHub repository " + f"`{REPOSITORY}`, cannot be told") in proc.stderr + assert f"pass `{URL}` to clone the repository from GitHub" in proc.stderr + assert_no_false_answer(said(proc)) + assert log.read_bytes() == b"" + assert not (tmp_path / "work" / "source-readonly").exists() + + +# --- a recorded source this user cannot reach (:5735) ------------------------ + +def test_a_recorded_clone_whose_stat_is_refused_is_not_cloned_again( + adopter, monkeypatch, capsys, tmp_path): + """`plan --source testorg/Thing` records its clone, and `check` finds the + clone's `stat` refused. As 3.14 answered, it "no longer exists" and was + cloned again from GitHub beside the one still there. Now it is + `source-unreadable`, and no second clone is asked for.""" + planned = planned_from_github(adopter, monkeypatch, tmp_path) + assert len(planned.calls) == 1, planned.calls + refused_stat(monkeypatch, Path(planned.clone).name) + + code, out, err = run(adopter, capsys, "check", "--plan", + str(planned.plan), "--work-dir", + str(tmp_path / "check-work"), "--local-remote-dir", + str(tmp_path / "remotes")) + + assert code == 2, out + err + assert (f"{UNREADABLE}the plan's `source.local_path`, " + f"{spelled(planned.clone)}: {DENIED}, so whether `plan`'s clone " + "is still there, to be read, or gone, to be cloned again, cannot " + "be told") in err + assert CURE in err + assert_no_false_answer(out + err) + assert len(planned.calls) == 1, planned.calls + + +@needs_a_mode +@pytest.mark.parametrize(("verb", "cloned", "untold"), [ + ("check", False, "whether the source is still there"), + ("check", True, "whether `plan`'s clone is still there, to be read, or " + "gone, to be cloned again,"), + ("execute", True, "whether `plan`'s clone is still there, to be read, " + "or gone, to be cloned again,"), +]) +def test_a_recorded_source_under_an_unsearchable_parent_is_refused_by_name( + tmp_path, verb, cloned, untold): + """The plan's `source.local_path` under a mode-000 directory: on 3.12 a + traceback; on 3.14 `plan-source-gone`, "names nothing that exists", or, + a clone `plan` made, "no longer exists" and `gh repo clone` asked. Now + `source-unreadable` in `check` and `execute` alike, nothing cloned and + `gh` never asked.""" + locked = tmp_path / "locked" + source = make_source_repo(locked / PROJECT, tree=SMALL_TREE, edits=()) + git("remote", "add", "origin", f"{URL}.git", cwd=source) + plan = planned_locally(tmp_path, source, cloned) + local_path = str(recorded(plan)["local_path"]) + env, log = recording_gh(tmp_path / "gh-bin") + work = tmp_path / f"{verb}-work" + + with unsearchable(locked): + proc = run_script(ADOPT, verb, "--plan", str(plan), "--work-dir", + str(work), "--local-remote-dir", + str(tmp_path / "remotes"), + *(["--yes"] if verb == "execute" else []), env=env) + + assert proc.returncode == 2, said(proc) + assert (f"{UNREADABLE}the plan's `source.local_path`, " + f"{spelled(local_path)}: {DENIED}, so {untold} cannot be told" + ) in proc.stderr + assert CURE in proc.stderr + assert ("or pass the source again with --source, which is held to the " + "plan's `commit:`.") in proc.stderr + assert "plan-source-gone" not in said(proc) + assert_no_false_answer(said(proc)) + assert log.read_bytes() == b"" + assert not (work / "source-readonly").exists() + assert not (tmp_path / "remotes").exists() + + +# --- a `--local-remote-dir` this user cannot search (:11216, :8538) ---------- + +@needs_a_mode +@needs_filter_repo +def test_execute_into_an_unsearchable_local_remote_dir_names_it(tmp_path): + """`execute --local-remote-dir` given a directory at mode 000: on 3.12 a + `PermissionError` traceback at the split branch's question, which asked + `Path.exists()` whether a leg was there. Now that question defers, as it + does to a leg already there, and the leg question names the directory, + before either leg is made.""" + source = make_source_repo(tmp_path / PROJECT, tree=SMALL_TREE, edits=()) + plan = planned_locally(tmp_path, source) + remotes = tmp_path / "remotes" + remotes.mkdir() + + with unsearchable(remotes): + proc = run_script(ADOPT, "execute", "--plan", str(plan), "--yes", + "--local-remote-dir", str(remotes), "--work-dir", + str(tmp_path / "execute-work")) + + assert proc.returncode == 2, said(proc) + named = spelled(os.path.realpath(remotes)) + assert "REFUSED adopt-folder-cannot-be-made: " in proc.stderr + assert (f"--local-remote-dir {named} cannot hold the two leg " + f"repositories: {named} is a folder this user cannot write in" + ) in proc.stderr + assert "Traceback" not in said(proc) + assert list(remotes.iterdir()) == [] + + +def _split_branch_left_behind(adopter, source, urls) -> None: + adopter._refuse_a_split_branch_left_behind(source, urls, "w430h-left", + True) + + +def _branch_in_the_split_branchs_way(adopter, source, urls) -> None: + adopter._refuse_a_branch_in_the_split_branchs_way( + SimpleNamespace(path=Path("adoption-plan.yaml")), source, {}, urls, + "w430h-in-the-way/split", True, False) + + +@pytest.mark.parametrize(("ask", "code"), [ + (_split_branch_left_behind, "adopt-branch-exists"), + (_branch_in_the_split_branchs_way, "plan-split-branch-taken"), +], ids=["left-behind", "in-the-way"]) +def test_a_leg_whose_stat_is_refused_defers_the_split_branch_questions( + adopter, monkeypatch, tmp_path, ask, code): + """Each split-branch question defers to the leg question where a leg is + on disk under `--local-remote-dir`; where the leg's `stat` is refused it + may be, and it defers too. The source holds what each would refuse: a + branch of the split's name at a commit not behind the source's + (`adopt-branch-exists`), and one whose name git cannot hold beside the + split branch (`plan-split-branch-taken`). As 3.14 answered `exists()`, + each was refused first; on 3.12 the `stat` raised out of `execute`.""" + source_path = make_source_repo(tmp_path / PROJECT, tree=SMALL_TREE, + edits=()) + tree = git("rev-parse", "HEAD^{tree}", cwd=source_path).stdout.strip() + orphan = git("commit-tree", tree, "-m", "elsewhere", + cwd=source_path).stdout.strip() + git("branch", "w430h-left", orphan, cwd=source_path) + git("branch", "w430h-in-the-way", cwd=source_path) + work = tmp_path / "work" + work.mkdir() + source = adopter.Source.in_place(Path(os.path.realpath(source_path)), + work) + remotes = tmp_path / "remotes" + urls = {"assembly": str(source.path), + "spec": str(remotes / f"{PROJECT}-spec.git"), + "code": str(remotes / f"{PROJECT}-code.git")} + with pytest.raises(adopter.Refusal) as control: + # The control: with nothing refused, each question refuses. + ask(adopter, source, urls) + assert control.value.code == code + refused_stat(monkeypatch, f"{PROJECT}-spec.git", f"{PROJECT}-code.git") + + assert ask(adopter, source, urls) is None + + +# --- a symlink the root keeps, to a place this user cannot search (:9017) --- + +def linked_out(tmp_path: Path) -> tuple[Path, Path]: + """A source that keeps `AGENTS.md` in the root as a symlink to a file + outside it, by absolute path, and the plan for it: `(the file, plan)`. + `AGENTS.md` is a path the shape writes, and `place` writes its copy + beside a link to something that is there.""" + outside = tmp_path / "outside" + outside.mkdir() + target = outside / "AGENTS.md" + target.write_text("theirs\n", encoding="utf-8") + source = make_source_repo(tmp_path / PROJECT, tree=SMALL_TREE, edits=()) + os.symlink(str(target), source / "AGENTS.md") + git("add", "-A", cwd=source) + git("commit", "-qm", "Link AGENTS.md out", cwd=source) + return target, planned_locally(tmp_path, source) + + +def checked(plan: Path, tmp_path: Path, name: str): + """`check --plan ` in a child, asking the disk about the legs.""" + return run_script(ADOPT, "check", "--plan", str(plan), "--work-dir", + str(tmp_path / name), "--local-remote-dir", + str(tmp_path / "remotes")) + + +@needs_a_mode +def test_a_symlink_to_an_unsearchable_place_is_not_called_empty(tmp_path): + """The link's destination is under a mode-000 directory: `check` said + "and nothing is there", on every Python (`os.path.exists`), of a place + that holds a file. It is still a finding, because the shape cannot ask + what is there either; and the finding says why, with the cure.""" + target, plan = linked_out(tmp_path) + control = checked(plan, tmp_path, "control-work") + assert control.returncode == 0, said(control) + assert "plan-root-symlink" not in said(control) + + with unsearchable(target.parent): + proc = checked(plan, tmp_path, "check-work") + + assert proc.returncode == 1, said(proc) + finding = next(line for line in proc.stderr.splitlines() + if line.startswith("FINDING plan-root-symlink: ")) + assert (f"the symlink `AGENTS.md` leads to {spelled(target)}, an absolute " + "path: a place on this machine, not in the assembly, and " + f"{spelled(target)}: {DENIED}, so whether anything is there " + "cannot be told until this user can search every directory " + "above it (`chmod u+rx`). ") in finding + assert "nothing is there" not in said(proc) + assert "Traceback" not in said(proc) + + +@needs_a_symlink +def test_a_symlink_to_a_place_whose_stat_is_refused_is_named( + adopter, monkeypatch, capsys, tmp_path): + """The same, in-process, with the `stat` of the link's destination + refused, so it runs as root too: `os.path.exists` read the refusal as + nothing there.""" + target, plan = linked_out(tmp_path) + real = os.stat + + def refusing(path, *args, **kwargs): + if os.fspath(path) == str(target): + raise PermissionError(errno.EACCES, "Permission denied", + os.fspath(path)) + return real(path, *args, **kwargs) + + monkeypatch.setattr(os, "stat", refusing) + code, out, err = run(adopter, capsys, "check", "--plan", str(plan), + "--work-dir", str(tmp_path / "check-work"), + "--local-remote-dir", str(tmp_path / "remotes")) + + assert code == 1, out + err + assert (f"and {spelled(target)}: {DENIED}, so whether anything is there " + "cannot be told until this user can search every directory above " + "it (`chmod u+rx`). Send `AGENTS.md` to a leg or `drop` it") in err + assert "nothing is there" not in out + err + + +@needs_a_symlink +def test_a_symlink_place_refused_for_another_reason_is_not_told_to_chmod( + adopter, monkeypatch, capsys, tmp_path): + """The same, in-process, with the `stat` of the link's destination + refused for another reason than permission (an I/O error): the finding + names that reason and stops there, since no `chmod` puts it right. + `(chmod u+rx)` is said of a permission only.""" + target, plan = linked_out(tmp_path) + real = os.stat + + def refusing(path, *args, **kwargs): + if os.fspath(path) == str(target): + raise OSError(errno.EIO, os.strerror(errno.EIO), os.fspath(path)) + return real(path, *args, **kwargs) + + monkeypatch.setattr(os, "stat", refusing) + code, out, err = run(adopter, capsys, "check", "--plan", str(plan), + "--work-dir", str(tmp_path / "check-work"), + "--local-remote-dir", str(tmp_path / "remotes")) + + assert code == 1, out + err + assert (f"and {spelled(target)}: cannot be read " + f"({os.strerror(errno.EIO)}), so whether anything is there " + "cannot be told. Send `AGENTS.md`") in err