From a3cde999c3822b2b74911146dce5f79b4631ec07 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 14:07:24 +0000 Subject: [PATCH] install.sh and openRepoShape: a Windows-spelled `$OPENREPOSHAPE_BIN_DIR` is respelled with `cygpath -u` or refused before install.sh's fetch, and the PATH note single-quotes a directory bash would still read, and `--install` refuses one holding `:` or a newline, so install.sh names `/d/tools/bin` as the command does and the `export PATH` line does what it says (#667) #649's two leftovers, both from #656's review. install.sh read `$OPENREPOSHAPE_BIN_DIR` as typed, so with `D:\tools\bin` its `preflight:` and `done:` lines (and `install:` for an old release) said `D:\tools\bin/openRepoShape` while the command's own lines said `/d/tools/bin/openRepoShape`, and where no `cygpath` answers the command's `--install` refused (exit 2) only after install.sh had fetched it. install.sh now carries a copy of the command's rule where it reads the variable, before the symlink check and the fetch: a value that starts `[A-Za-z]:/` or `[A-Za-z]:\` is spelled with `cygpath -u --`, and is refused with exit 2 where no `cygpath` answers, fails, or answers a drive spelling again. The refusal does not echo the value. The command's PATH note printed `export PATH="$dir:$PATH"` with the directory between double quotes as it was, so `a"b$HOME c` printed a line `eval` could not parse, and a `$`, a backquote or a `\` in a name was read, not kept. `export_path_line` keeps today's line byte for byte for a directory that double quotes hold (a space and an apostrophe included) and, for one holding a `"`, `$`, backquote, `\` or `!` (bash reads that at a prompt as history), prints it between single quotes with `'\''` for an apostrophe. `--install` also refuses a directory holding a `:` or a newline, which no `$PATH` line can name, before anything is placed; the newline's refusal does not echo it. Tests feed one value through install.sh and through the command, in each spelling and each state of `cygpath`, and pin every `install:`, `preflight:` and `done:` line; the PATH note is checked as printed, by `eval` and by `bash -i`. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- install.sh | 36 ++++- openRepoShape | 58 ++++++- tests/test_install_sh.py | 157 ++++++++++++++++++- tests/test_openreposhape_command.py | 229 ++++++++++++++++++++++++++-- 4 files changed, 460 insertions(+), 20 deletions(-) diff --git a/install.sh b/install.sh index 9a8a78fb..fa0fbd57 100755 --- a/install.sh +++ b/install.sh @@ -41,8 +41,10 @@ # does not stay on the release tag (one older than v1.0.1 cannot record # it): the lines above say which; # 2 refused before anything was installed: a version that is not a ref, no -# release found, a fetch that failed, a file that is not the command, or -# a command already there as a symbolic link (pipx's, #563). +# release found, a fetch that failed, a file that is not the command, a +# command already there as a symbolic link (pipx's, #563), or a +# `$OPENREPOSHAPE_BIN_DIR` spelled for Windows where no `cygpath` can +# spell it the way bash does (#667). # git-filter-repo never changes it: only adopting needs it, and # `adopt-project.py` refuses with its own install command where it is missing. # What it changes is the summary (#595): `adoption:` says whether it is there @@ -334,7 +336,7 @@ filter_repo_step() { } main() { - local version="" asked launcher_url bin_dir target shown status=0 pinned=1 + local version="" asked launcher_url bin_dir spelled target shown status=0 pinned=1 local machine="this machine is ready" # NOTHING A CHILD READS IS THIS SCRIPT. Piped, stdin is the pipe the @@ -362,6 +364,33 @@ main() { command -v curl >/dev/null 2>&1 || die "curl is not installed, and this installer fetches with it. Install curl, or install by hand with: gh api repos/$REPO/contents/openRepoShape -H 'Accept: application/vnd.github.raw' | bash -s -- --install" + bin_dir="${OPENREPOSHAPE_BIN_DIR:-$HOME/.local/bin}" + # A WINDOWS SPELLING IS SPELLED THE WAY BASH SPELLS IT, THE COMMAND'S OWN + # RULE (#667, after #649), before it is named in a line or looked at: a + # value typed `D:\tools\bin` was echoed as typed, in the `install:`, + # `preflight:` and `done:` lines, while the command's own lines said + # `/d/tools/bin`, and where no `cygpath` answers the command refuses it + # (exit 2), which this file learnt only after the fetch. So the same + # `cygpath -u` respells it here, and the same refusal comes first, before + # anything is asked of the network. The `case` is `install_commands`'s in + # `openRepoShape`, copied: this file arrives alone down a pipe, and + # `tests/test_install_sh.py` feeds one value through both. The refusal + # does not echo the value: it may hold a newline that forges a line. + case "$bin_dir" in + [A-Za-z]:/* | [A-Za-z]:\\*) + spelled="" + if command -v cygpath >/dev/null 2>&1; then + spelled="$(cygpath -u -- "$bin_dir" 2>/dev/null)" || spelled="" + fi + case "$spelled" in + "" | [A-Za-z]:*) + die "\$OPENREPOSHAPE_BIN_DIR is spelled for Windows, and no cygpath here spells it the way bash does, so the directory could not go on \$PATH: the drive's colon splits it. Nothing was installed. Set \$OPENREPOSHAPE_BIN_DIR to the directory as this shell spells it, then run this line again." + ;; + esac + bin_dir="$spelled" + ;; + esac + # A LINK IS NOT A PLACE TO INSTALL TO (#563), and this installer says so # itself, before anything is asked of the network. pipx places the # openreposhape package's `openRepoShape` as a symbolic link in this same @@ -369,7 +398,6 @@ main() { # through a link into the package's own environment. The command refuses # such a target too, but only from the release that added the check: the # release named here may be older, so its refusal cannot be relied on. - bin_dir="${OPENREPOSHAPE_BIN_DIR:-$HOME/.local/bin}" target="$bin_dir/openRepoShape" [[ ! -L "$target" ]] || die "$target is a symbolic link, so nothing was installed: pipx places the openreposhape package's openRepoShape that way, and installing this one through the link would overwrite the package's command. Use one installer or the other: diff --git a/openRepoShape b/openRepoShape index 61291e26..da550aee 100755 --- a/openRepoShape +++ b/openRepoShape @@ -403,9 +403,60 @@ record_ref() { fi } +# THE `export PATH` LINE THE PATH NOTE PRINTS, QUOTED FOR BASH (#667). It is +# a line somebody copies and pastes, so it has to do what it says for any +# directory that can be on a `$PATH`. Between double quotes bash still reads +# `"`, `$`, a backquote and `\`, and an interactive bash `!` too (history), +# so a directory holding one of them is printed between single quotes, where +# nothing is read but the quote itself, which is written `'\''`. Every other +# directory keeps the line this command has always printed, byte for byte: +# README says `export PATH=…`, and a space or an apostrophe is safe in +# double quotes. A `:` splits a `$PATH` whatever the quoting, and a newline +# breaks the printed line in two, so `install_commands` refuses both before +# this is asked. +export_path_line() { + local dir="$1" backslash='\' quote="'" rest quoted="" + case "$dir" in + *[\"\$\`\\!]*) + # The apostrophes are walked, not replaced with `${dir//…}`: how a + # pattern substitution reads a quote in its replacement has changed + # between bash releases (this runs on 3.2 as well), and `sed` is + # not one of the programs `--install` uses. + rest="$dir" + while :; do + case "$rest" in + *"$quote"*) + quoted="$quoted${rest%%"$quote"*}$quote$backslash$quote$quote" + rest="${rest#*"$quote"}" + ;; + *) + quoted="$quoted$rest" + break + ;; + esac + done + printf 'export PATH=%s%s%s:"$PATH"' "$quote" "$quoted" "$quote" + ;; + *) printf 'export PATH="%s:$PATH"' "$dir" ;; + esac +} + install_commands() { local dir="${OPENREPOSHAPE_BIN_DIR:-$HOME/.local/bin}" local name target source verb placed=0 recording=0 record spelled="" + # NO LINE ABOUT `$PATH` CAN NAME A DIRECTORY HOLDING A NEWLINE OR A `:` + # (#667): the colon is what splits a `$PATH`, and a newline breaks the + # line the note prints in two. Refused before anything is placed, as the + # Windows spelling below is. The newline goes first, and its refusal says + # nothing of the value, so that no refusal after it echoes one that can + # forge a line of output. The colon is checked after the Windows + # spelling, which holds one by definition, is respelled without one, and + # refuses what it cannot respell. + case "$dir" in + *$'\n'*) + die "--install would place this command in a directory whose name holds a newline, which no line about \$PATH can name: it would break in two. Nothing was placed. Set \$OPENREPOSHAPE_BIN_DIR to a directory without one, then run this again." + ;; + esac # A WINDOWS SPELLING IS SPELLED AGAIN THE WAY BASH SPELLS IT (#649), with # `cygpath -u`, which Git Bash has, before anything is checked or placed: # `D:\tools\bin` becomes `/d/tools/bin`. Kept as typed, every line below @@ -428,6 +479,11 @@ install_commands() { dir="$spelled" ;; esac + case "$dir" in + *:*) + die "--install would place this command in '$dir', which holds a ':', and a ':' is what splits a \$PATH into its directories, so no line about \$PATH can name it. Nothing was placed. Set \$OPENREPOSHAPE_BIN_DIR to a directory without one, then run this again." + ;; + esac if [ "$REF_FROM_RECORD" -eq 1 ] || { [ "$REF_NAMED" -eq 1 ] && [ "${OPENREPOSHAPE_RECORD_REF:-}" = 1 ]; }; then recording=1 @@ -498,7 +554,7 @@ install_commands() { *":$dir:"*) ;; *) say " $dir is not on \$PATH. Add it:" - say " export PATH=\"$dir:\$PATH\"" + say " $(export_path_line "$dir")" ;; esac # LAST, because it is about the NEXT command rather than about this one. diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py index 2c69f66c..bb0bd660 100644 --- a/tests/test_install_sh.py +++ b/tests/test_install_sh.py @@ -46,8 +46,11 @@ import pytest from conftest import BASH, BASH_SKIP, REPO, bash_path -from test_openreposhape_command import (COMMAND, INSTALL_NEEDS, command_env, - put_on_path, write_script) +from test_openreposhape_command import (BIN_DIR_SPELLINGS, COMMAND, + CYGPATH_STATES, INSTALL_NEEDS, + command_env, machine_path, + put_cygpath, put_on_path, run_cmd, + typed_bin_dir, write_script) INSTALLER = REPO / "install.sh" @@ -686,6 +689,156 @@ def test_a_command_that_is_a_symlink_is_refused_before_any_request( assert list(net.temporary.iterdir()) == [] +# --- a Windows-spelled `$OPENREPOSHAPE_BIN_DIR` (#667) ----------------------- + +def in_its_own_directory(tmp_path: Path, name: str) -> Path: + """`tmp_path/name`, made: one run's whole world, so that two runs of one + test cannot see each other's files.""" + directory = tmp_path / name + directory.mkdir() + return directory + + +@BIN_DIR_SPELLINGS +@pytest.mark.parametrize("state", CYGPATH_STATES) +def test_the_installer_and_the_command_answer_one_drive_directory_alike( + tmp_path, spelling, state): + """ONE `$OPENREPOSHAPE_BIN_DIR`, spelled for Windows, through install.sh + and through the command it installs (#667). + + The command respells it with `cygpath -u`, or refuses it where no + `cygpath` can (#649), and install.sh carries a copy of that `case`, + because it arrives alone and cannot share the command's. The two must + never answer one value differently, so each is given the same value, + in each spelling, with `cygpath` in each state: spelled, failing, + answering a drive spelling again, or not there. Where it spells, both + place the command at the directory as bash does and name no other; where + it does not, both refuse with exit 2, and install.sh has asked the + network nothing. + """ + net = Net(in_its_own_directory(tmp_path, "installer")) + net.bin = net.tmp_path / "drive" / "bin" + command_home = in_its_own_directory(tmp_path, "command") + command_bin = command_home / "drive" / "bin" + typed_installer = typed_bin_dir(net.bin, spelling) + typed_command = typed_bin_dir(command_bin, spelling) + put_cygpath(net.path, typed_installer, state, bash_path(net.bin)) + command_env_path = machine_path(command_home, ()) + command_env_path.pop("_log") + put_cygpath(command_home / "machine-path", typed_command, state, + bash_path(command_bin)) + + installer = net.run(env={"OPENREPOSHAPE_BIN_DIR": typed_installer}) + command = run_cmd("--install", home=command_home, cwd=command_home, + env={**command_env_path, + "OPENREPOSHAPE_BIN_DIR": typed_command}) + + if state == "spells": + assert installer.returncode == 0, installer.stdout + installer.stderr + assert command.returncode == 0, command.stdout + command.stderr + net.installed(installer) + assert (command_bin / "openRepoShape").read_bytes() \ + == COMMAND.read_bytes() + assert f"preflight: {bash_path(net.bin)}/openRepoShape --preflight" \ + in installer.stdout.splitlines() + assert "openRepoShape: installed at " \ + f"{bash_path(command_bin)}/openRepoShape" \ + in command.stdout.splitlines() + assert typed_installer not in installer.stdout + assert typed_command not in command.stdout + return + net.refused(installer) + assert command.returncode == 2, command.stdout + command.stderr + for result in (installer, command): + said = " ".join(result.stderr.split()) + assert "is spelled for Windows" in said, said + assert "no cygpath here spells it the way bash does" in said, said + assert "Set $OPENREPOSHAPE_BIN_DIR to the directory as this shell " \ + "spells it, then run this" in said, said + assert net.calls() == [], net.calls() + assert not command_bin.exists() + assert typed_installer not in installer.stderr + + +#: An old launcher that answers as Git Bash makes every launcher answer: +#: the drive spelling and the bash spelling name one directory, so it places +#: itself where `cygpath -u` says whichever it was handed. `OLD_LAUNCHER` +#: takes the variable as typed, which off Windows is a directory of its own. +OLD_LAUNCHER_IN_GIT_BASH = OLD_LAUNCHER.replace( + 'mkdir -p "$OPENREPOSHAPE_BIN_DIR" && ' + 'cp "$0" "$OPENREPOSHAPE_BIN_DIR/openRepoShape"', + 'dir="$(cygpath -u -- "$OPENREPOSHAPE_BIN_DIR")" && mkdir -p "$dir" && ' + 'cp "$0" "$dir/openRepoShape"') +assert OLD_LAUNCHER_IN_GIT_BASH != OLD_LAUNCHER + + +@pytest.mark.parametrize("preflight_exit, old, ends", [ + pytest.param(0, False, "pinned to the release tag, and this machine is " + "ready. Run the same line again to upgrade to the newest " + "release.", id="done"), + pytest.param(1, False, "but the preflight did not pass (exit 1); it " + "names what is missing above.", id="preflight-not-ready"), + pytest.param(2, False, "but the preflight did not run to the end (exit " + "2); the REFUSED line above says why.", + id="preflight-did-not-run"), + pytest.param(0, True, "and this machine is ready, but the command does " + "not stay on v9.9.9 (see above).", id="does-not-stay"), +]) +def test_every_line_names_a_windows_spelled_bin_dir_the_way_bash_does( + tmp_path, preflight_exit, old, ends): + """`D:\\tools\\bin` was echoed as typed by `install:`, `preflight:` and + each of the four `done:` lines, where the command's own lines said + `/d/tools/bin` (#667). With `cygpath` spelling it, every one of them says + the directory the way bash does, and none says it as typed. The fourth + `done:` is the one after `install:`, which an old release earns.""" + net = Net(tmp_path) + net.bin = tmp_path / "drive" / "bin" + typed = typed_bin_dir(net.bin, "backslash") + target = f"{bash_path(net.bin)}/openRepoShape" + put_cygpath(net.path, typed, "spells", bash_path(net.bin)) + net.config["preflight_exit"] = preflight_exit + if old: + launcher = net.root / "old-openRepoShape" + write_script(launcher, OLD_LAUNCHER_IN_GIT_BASH) + net.serve(RELEASE, launcher=launcher) + result = net.run(env={"OPENREPOSHAPE_BIN_DIR": typed, + "OPENREPOSHAPE_REPO": SLUG}) + assert result.returncode == (0 if preflight_exit == 0 and not old else 1), \ + result.stdout + result.stderr + lines = result.stdout.splitlines() + assert f"preflight: {target} --preflight" in lines, result.stdout + assert lines[-1] == f"done: openRepoShape {RELEASE} is installed at " \ + f"{target}{' ' if old else ', '}{ends}", result.stdout + if old: + assert any(line.startswith(f"install: {target} does not stay on " + f"{RELEASE}: ") for line in lines), \ + result.stdout + assert typed not in result.stdout + + +def test_the_symlink_check_looks_at_the_directory_as_bash_spells_it(tmp_path): + """The link check names a target it can find: with the drive spelling + respelled first, pipx's link at the directory as bash spells it is the + one that is found and refused, before any request (#563, #667). Read as + typed, `D:\\tools\\bin/openRepoShape` is no link here, and the install + would go on and copy through the one that is.""" + net = Net(tmp_path) + net.bin = tmp_path / "drive" / "bin" + typed = typed_bin_dir(net.bin, "backslash") + put_cygpath(net.path, typed, "spells", bash_path(net.bin)) + packaged = tmp_path / "pipx" / "openRepoShape" + packaged.parent.mkdir(parents=True) + packaged.write_bytes(b"#!/usr/bin/env python3\n# the package's own\n") + net.bin.mkdir(parents=True) + (net.bin / "openRepoShape").symlink_to(packaged) + result = net.run(env={"OPENREPOSHAPE_BIN_DIR": typed}) + assert result.returncode == 2, result.stdout + result.stderr + assert f"{bash_path(net.bin)}/openRepoShape is a symbolic link, so " \ + "nothing was installed" in " ".join(result.stderr.split()) + assert net.calls() == [], net.calls() + assert packaged.read_bytes() == b"#!/usr/bin/env python3\n# the package's own\n" + + #: A curl that RUNS, in either script: tab-indented, alone or as the value #: of an assignment. Usage text and refusals carry curl lines too, indented #: with spaces, and are printed, never run. diff --git a/tests/test_openreposhape_command.py b/tests/test_openreposhape_command.py index fe2d6759..d1559e8a 100644 --- a/tests/test_openreposhape_command.py +++ b/tests/test_openreposhape_command.py @@ -354,24 +354,31 @@ def test_install_says_how_to_put_it_on_path(tmp_path): WINDOWS_BIN_DIR = "D:\\tools\\bin" WINDOWS_BIN_DIR_SLASH = "D:/tools/bin" -#: The two spellings a person types, for a test to run over both. Git Bash -#: takes `D:\tools\bin` and `D:/tools/bin` alike, and `--install` has an arm -#: for each. -BIN_DIR_SPELLINGS = pytest.mark.parametrize("spelling", - ["backslash", "slash"]) +#: The spellings a person types, for a test to run over each. Git Bash takes +#: `D:\tools\bin` and `D:/tools/bin` alike, and `--install` has an arm for +#: each; a drive letter is a letter in either case (#667). +BIN_DIR_SPELLINGS = pytest.mark.parametrize( + "spelling", ["backslash", "slash", "lower-case"]) def typed_bin_dir(bin_dir: Path, spelling: str) -> str: """`$OPENREPOSHAPE_BIN_DIR` as a person types it for Windows, in - `spelling`, "backslash" or "slash". Off Windows it is `D:\\tools\\bin` - or `D:/tools/bin`, which name no directory of their own. On Windows it - is `bin_dir` itself, a real drive path under the test's directory, with - `str` for the backslash and `as_posix` for the slash: a command placed - where it should not be then lands where the test can see it, and never - on the machine's own `D:` drive.""" + `spelling`: "backslash", "slash", or "lower-case", a backslash path + whose drive letter is lower case. Off Windows it is `D:\\tools\\bin`, + `D:/tools/bin` or `d:\\tools\\bin`, which name no directory of their own. + On Windows it is `bin_dir` itself, a real drive path under the test's + directory, with `str` for the backslash and `as_posix` for the slash: a + command placed where it should not be then lands where the test can see + it, and never on the machine's own `D:` drive.""" if os.name == "nt": - return bin_dir.as_posix() if spelling == "slash" else str(bin_dir) - return WINDOWS_BIN_DIR_SLASH if spelling == "slash" else WINDOWS_BIN_DIR + if spelling == "slash": + return bin_dir.as_posix() + typed = str(bin_dir) + return typed[0].lower() + typed[1:] if spelling == "lower-case" \ + else typed + return {"slash": WINDOWS_BIN_DIR_SLASH, + "lower-case": WINDOWS_BIN_DIR.lower()}.get(spelling, + WINDOWS_BIN_DIR) def fake_cygpath(directory: Path, typed: str, answer: str | None) -> None: @@ -386,6 +393,25 @@ def fake_cygpath(directory: Path, typed: str, answer: str | None) -> None: (directory / "cygpath").chmod(0o755) +#: What `cygpath` does where a test builds a `$PATH` (#667): "spells" the +#: value the way bash does, "fails", answers a drive spelling "again", or is +#: not "there" at all. +CYGPATH_STATES = ("spells", "fails", "again", "there-is-none") + + +def put_cygpath(directory: Path, typed: str, state: str, spelled: str) -> None: + """A `cygpath` in `directory` in `state`: Git Bash's own where it + "spells" on Windows, a fake that answers `spelled` elsewhere.""" + if state == "there-is-none": + return + if state == "spells" and os.name == "nt": + directory.mkdir(exist_ok=True) + put_on_path(directory, "cygpath", shutil.which("cygpath")) + return + fake_cygpath(directory, typed, {"spells": spelled, "fails": None, + "again": WINDOWS_BIN_DIR_SLASH}[state]) + + @BIN_DIR_SPELLINGS @pytest.mark.parametrize("on_path", [False, True], ids=["not-on-path", "on-path"]) @@ -456,6 +482,183 @@ def test_a_windows_spelled_bin_dir_is_refused_where_no_cygpath_spells_it( assert [path.name for path in tmp_path.iterdir()] == ["machine-path"] +# --- the PATH note quotes the directory it prints (#667) --------------------- + +#: Windows names no file with a `"` or a `\` in it. +POSIX_NAME = pytest.mark.skipif( + os.name == "nt", reason="a Windows file name holds no \" or \\") + +#: One directory name per case, and the `export PATH` line `--install` prints +#: for it, with `{d}` for the directory's parent. The lines are written out, +#: not computed, because the launcher's own rule is what is being held: +#: today's line byte for byte where double quotes are enough (a space and an +#: apostrophe are), and single quotes, an apostrophe as `'\''`, where bash +#: would still read a `"`, `$`, backquote or `\` between double quotes, or a +#: `!` at an interactive prompt. +PATH_NOTE_NAMES = [ + pytest.param("plain", 'export PATH="{d}/plain:$PATH"', id="plain"), + pytest.param("two words", 'export PATH="{d}/two words:$PATH"', + id="a-space"), + pytest.param("it's", 'export PATH="{d}/it\'s:$PATH"', id="an-apostrophe"), + pytest.param("a$b", "export PATH='{d}/a$b':\"$PATH\"", id="a-dollar"), + pytest.param("a`b`", "export PATH='{d}/a`b`':\"$PATH\"", + id="a-backquote"), + pytest.param("a!b", "export PATH='{d}/a!b':\"$PATH\"", id="a-bang"), + pytest.param("it's $HOME", "export PATH='{d}/it'\\''s $HOME':\"$PATH\"", + id="an-apostrophe-and-a-dollar"), + pytest.param("a'b'c!", "export PATH='{d}/a'\\''b'\\''c!':\"$PATH\"", + id="two-apostrophes-and-a-bang"), + pytest.param('a"b', "export PATH='{d}/a\"b':\"$PATH\"", + marks=POSIX_NAME, id="a-double-quote"), + pytest.param("a\\b", "export PATH='{d}/a\\b':\"$PATH\"", + marks=POSIX_NAME, id="a-backslash"), + pytest.param("a\\", "export PATH='{d}/a\\':\"$PATH\"", + marks=POSIX_NAME, id="a-trailing-backslash"), + pytest.param('a"b$HOME c', "export PATH='{d}/a\"b$HOME c':\"$PATH\"", + marks=POSIX_NAME, id="the-issue-probe"), +] + + +def path_after(line: str) -> str: + """`$PATH` once bash has run `line` where it held `/sentinel`: the line + through `eval`, which reads it as a shell reads a pasted line. The line + travels in the environment and the script on stdin as bytes, which keep + a `"` and a `\\` whole on Windows.""" + ran = subprocess.run( + [BASH, "-s"], capture_output=True, check=False, + input=b'PATH=/sentinel\neval "$LINE"\nprintf %s "$PATH"\n', + env=command_env(setup_sh=None, env={"LINE": line})) + assert ran.returncode == 0, ran.stderr.decode("utf-8", "replace") + return ran.stdout.decode("utf-8") + + +@pytest.mark.parametrize("name, line", PATH_NOTE_NAMES) +def test_the_path_note_is_a_line_that_does_what_it_says(tmp_path, name, line): + """`--install` prints `export PATH=…` for a directory that is not on + `$PATH`, and a person pastes it. It put the directory between double + quotes as it was, so `a"b$HOME c` printed `export PATH="…/a"b$HOME + c:$PATH"`, which does not parse, and `$HOME` or a backquote in a name + was run or read, not kept (#667). The printed line is the one written + out for that name, and bash, handed it, puts exactly that directory + ahead of the old `$PATH`: no more, no less, nothing expanded.""" + parent = bash_path(tmp_path) + result = run_cmd("--install", home=tmp_path, cwd=tmp_path, + env={"OPENREPOSHAPE_BIN_DIR": f"{parent}/{name}"}) + assert result.returncode == 0, result.stdout + result.stderr + assert (tmp_path / name / "openRepoShape").is_file() + printed = [text for text in result.stdout.splitlines() + if text.startswith(" export PATH=")] + assert printed == [" " + line.format(d=parent)], result.stdout + assert path_after(printed[0]) == f"{parent}/{name}:/sentinel" + + +@pytest.mark.parametrize("name, line", PATH_NOTE_NAMES) +def test_export_path_line_quotes_any_directory_for_bash(name, line): + """The same rule on the function alone, so a `"` or a `\\` is held on + Windows too, where no file can be named with one and the test above + skips them: the function is lifted out of the launcher and handed the + directory in the environment, as `set -euo pipefail` runs it there.""" + directory = f"/srv/tools/{name}" + text = COMMAND.read_text(encoding="utf-8") + [function] = re.findall(r"^export_path_line\(\) \{\n.*?^\}\n", text, + re.M | re.S) + ran = subprocess.run( + [BASH, "-s"], capture_output=True, check=False, + input=("set -euo pipefail\n" + function + + 'export_path_line "$DIR"\n').encode("utf-8"), + env=command_env(setup_sh=None, env={"DIR": directory})) + assert ran.returncode == 0, ran.stderr.decode("utf-8", "replace") + assert ran.stdout.decode("utf-8") == line.format(d="/srv/tools") + assert path_after(ran.stdout.decode("utf-8")) == f"{directory}:/sentinel" + + +@POSIX_NAME +@pytest.mark.parametrize("name", ["a!b", "a$b", "plain"]) +def test_a_pasted_path_note_reaches_an_interactive_bash_as_printed( + tmp_path, name): + """A person pastes the line at a prompt, and an interactive bash reads + `!` inside double quotes as history (`event not found`), which a profile + file never does: so a directory holding one is single-quoted too. Run + through `bash -i`, the line sets `$PATH` to the directory and no + other.""" + parent = bash_path(tmp_path) + result = run_cmd("--install", home=tmp_path, cwd=tmp_path, + env={"OPENREPOSHAPE_BIN_DIR": f"{parent}/{name}"}) + assert result.returncode == 0, result.stdout + result.stderr + [line] = [text.strip() for text in result.stdout.splitlines() + if text.startswith(" export PATH=")] + reached = tmp_path / "reached" + script = f'PATH=/sentinel\n{line}\nprintf %s "$PATH" > "$OUT"\nexit\n' + subprocess.run( + [BASH, "--norc", "--noprofile", "-i"], capture_output=True, + check=False, input=script.encode("utf-8"), start_new_session=True, + env=command_env(setup_sh=None, env={ + "OUT": str(reached), "HISTFILE": os.devnull})) + assert reached.read_text(encoding="utf-8") == f"{parent}/{name}:/sentinel" + + +#: What `--install` refuses, before it places anything: a directory no line +#: about `$PATH` can name, and what its refusal says. `{d}` is `tmp_path` as +#: bash spells it. The newline's refusal never echoes the value. +UNNAMEABLE_BIN_DIRS = [ + pytest.param("{d}/a:b/bin", "holds a ':'", id="a-colon"), + pytest.param("{d}/bin:{d}/other", "holds a ':'", id="two-directories"), + pytest.param("D:tools", "holds a ':'", id="a-drive-relative-name"), + pytest.param("{d}/a\nforged/bin", + "holds a newline", id="a-newline"), + pytest.param("D:\\tools\nforged\\bin", + "holds a newline", id="a-drive-spelling-and-a-newline"), +] + + +@pytest.mark.parametrize("value, said", UNNAMEABLE_BIN_DIRS) +def test_a_bin_dir_no_path_line_can_name_is_refused_before_it_places_anything( + tmp_path, value, said): + """A `:` splits a `$PATH` and a newline breaks the note's line in two, so + no `export PATH` line could name such a directory (#667): refused with + exit 2 beside the Windows spelling's refusal, before a file is placed or + a directory made, and saying what to set instead. A newline is refused + first and the value is never echoed, so it cannot forge a line.""" + result = run_cmd("--install", home=tmp_path, cwd=tmp_path, env={ + "OPENREPOSHAPE_BIN_DIR": value.format(d=bash_path(tmp_path))}) + assert result.returncode == 2, result.stdout + result.stderr + said_in_full = " ".join(result.stderr.split()) + assert said in said_in_full, said_in_full + assert "Nothing was placed. Set $OPENREPOSHAPE_BIN_DIR to a directory " \ + "without one, then run this again." in said_in_full, said_in_full + assert "forged" not in result.stderr + assert result.stdout == "" + assert list(tmp_path.iterdir()) == [] + + +@POSIX_NAME +def test_the_default_directory_is_held_to_the_same_refusal(tmp_path): + """No variable at all, and a `$HOME` that holds a `:`: the directory is + `$HOME/.local/bin`, and it is refused the same, naming it.""" + home = tmp_path / "a:b" + result = run_cmd("--install", home=home, cwd=tmp_path) + assert result.returncode == 2, result.stdout + result.stderr + assert f"--install would place this command in '{home}/.local/bin', " \ + "which holds a ':'" in " ".join(result.stderr.split()) + assert list(tmp_path.iterdir()) == [] + + +@POSIX_NAME +def test_a_respelled_bin_dir_is_held_to_the_same_refusal(tmp_path): + """`cygpath` may answer a directory with a `:` of its own: the colon is + refused after the Windows spelling, not before it, or this one would + pass, and `D:\\tools\\bin` would be refused whatever `cygpath` said.""" + env = machine_path(tmp_path, ()) + env.pop("_log") + fake_cygpath(tmp_path / "machine-path", WINDOWS_BIN_DIR, "/d/a:b/bin") + result = run_cmd("--install", home=tmp_path, cwd=tmp_path, + env={**env, "OPENREPOSHAPE_BIN_DIR": WINDOWS_BIN_DIR}) + assert result.returncode == 2, result.stdout + result.stderr + assert "--install would place this command in '/d/a:b/bin', which " \ + "holds a ':'" in " ".join(result.stderr.split()) + assert [path.name for path in tmp_path.iterdir()] == ["machine-path"] + + def test_install_from_a_file_never_calls_gh(tmp_path): """`--install` run from a file copies THOSE bytes. A machine with no `gh` — or no network — must still be able to install the command, so a `gh` on