diff --git a/install.sh b/install.sh index fa0fbd57..09f3170f 100755 --- a/install.sh +++ b/install.sh @@ -42,9 +42,10 @@ # 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, a -# command already there as a symbolic link (pipx's, #563), or a +# command already there as a symbolic link (pipx's, #563), a # `$OPENREPOSHAPE_BIN_DIR` spelled for Windows where no `cygpath` can -# spell it the way bash does (#667). +# spell it the way bash does (#667), or a directory holding a control +# character or a `:`, which no line about `$PATH` can name (#760). # 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 @@ -365,6 +366,29 @@ main() { 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 DIRECTORY HOLDING A CONTROL CHARACTER IS REFUSED FIRST (#760), before + # any line below names it: a newline breaks a line in two, a carriage + # return rewrites it on the terminal and an escape is the terminal's to + # act on, and the `install:`, `preflight:` and `done:` lines would print + # the directory as typed. The command refuses the same value, but only + # from the release that added the check, and only after this file had + # fetched it. The `case` is `install_commands`'s in `openRepoShape`, + # copied for the reason the drive-letter one below is, and the refusal + # says nothing of the value. `[[:cntrl:]]` and not `[![:print:]]`, which + # `checked_repo` uses: a directory with a letter the locale does not + # print (`café` under LC_ALL=C) is no fault of the directory's. The check + # runs under `LC_ALL=C` and names U+0080 to U+009F's two bytes (`\302`, + # then `\200` to `\237`), so a C1 control is refused in every locale, + # whatever else the value holds; U+2028 and U+2029 are refused in none. + # Left to the locale, `[[:cntrl:]]` matches a C1 control only in a value + # that is valid UTF-8: one byte that is not (a lone 0xE9) makes bash + # match byte by byte, and then U+009B's two bytes are not controls, as + # under `C` (measured with bash 5.2.21). The locale is `C` in a subshell, + # so that the match is byte by byte whatever a libc says of a byte in a + # UTF-8 locale, and the locale of every line below is as it was. + if (LC_ALL=C; case "$bin_dir" in *[[:cntrl:]]* | *$'\302'[$'\200'-$'\237']*) exit 0 ;; *) exit 1 ;; esac); then + die "the directory to install into holds a control character, such as a newline, a carriage return, a tab or an escape, and no line about \$PATH can print such a name as it is: a terminal acts on it. Nothing was installed. Set \$OPENREPOSHAPE_BIN_DIR to a directory without one, then run this line again." + fi # 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:`, @@ -375,7 +399,7 @@ main() { # 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. + # does not echo the value. case "$bin_dir" in [A-Za-z]:/* | [A-Za-z]:\\*) spelled="" @@ -390,6 +414,19 @@ main() { bin_dir="$spelled" ;; esac + # A `:` SPLITS A `$PATH` WHATEVER THE QUOTING (#760), so no line about + # `$PATH` can name such a directory; the command refuses it from the + # release that added the check (#667), and this file learnt of it only + # after the fetch. Checked after the Windows spelling, as the command + # checks it: a drive spelling holds a `:` by definition, is respelled + # without one, and was refused above where it could not be. The + # control-character arm is above, so the directory is named here, as the + # command names it, and no value that can forge a line gets this far. + case "$bin_dir" in + *:*) + die "install.sh would place openRepoShape in '$bin_dir', which holds a ':', and a ':' is what splits a \$PATH into its directories, so no line about \$PATH can name it. Nothing was installed. Set \$OPENREPOSHAPE_BIN_DIR to a directory without one, then run this line again." + ;; + 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 diff --git a/openRepoShape b/openRepoShape index 232ca66b..b5cd1dd7 100755 --- a/openRepoShape +++ b/openRepoShape @@ -411,9 +411,10 @@ record_ref() { # 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. +# double quotes. A `:` splits a `$PATH` whatever the quoting, and a control +# character (a newline breaks the printed line in two, an escape is the +# terminal's to act on) is no name a line can print as it is, so +# `install_commands` refuses both before this is asked. export_path_line() { local dir="$1" backslash='\' quote="'" rest quoted="" case "$dir" in @@ -444,19 +445,32 @@ export_path_line() { 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 + # NO LINE ABOUT `$PATH` CAN NAME A DIRECTORY HOLDING A CONTROL CHARACTER + # OR A `:` (#667, #760): the colon is what splits a `$PATH`; a newline + # breaks the line the note prints in two; a carriage return rewrites the + # line on the terminal (`/tmp/x^MFORGED is not on $PATH`) and an escape + # is the terminal's to act on, in the note and in every refusal that + # names the directory. Refused before anything is placed, as the Windows + # spelling below is. The control characters go first, and their refusal + # says nothing of the value, so that no refusal after it echoes one that + # can forge a line of output. It is `[[:cntrl:]]` and not + # `[![:print:]]`, which is what this file holds the repository and the + # ref to: a name with a letter the locale does not print, as `café` + # under LC_ALL=C, is no fault of the directory's. The check runs under + # `LC_ALL=C` and names U+0080 to U+009F's two bytes (`\302`, then `\200` + # to `\237`), so a C1 control is refused in every locale, whatever else + # the value holds; U+2028 and U+2029 are refused in none. Left to the + # locale, `[[:cntrl:]]` matches a C1 control only in a value that is + # valid UTF-8: one byte that is not (a lone 0xE9) makes bash match byte + # by byte, and then U+009B's two bytes are not controls, as under `C` + # (measured with bash 5.2.21). The locale is `C` in a subshell, so that + # the match is byte by byte whatever a libc says of a byte in a UTF-8 + # locale, and the locale of every line below is as it was. The colon is + # checked after the Windows spelling, which holds one by definition, is + # respelled without one, and refuses what it cannot respell. + if (LC_ALL=C; case "$dir" in *[[:cntrl:]]* | *$'\302'[$'\200'-$'\237']*) exit 0 ;; *) exit 1 ;; esac); then + die "--install would place this command in a directory whose name holds a control character, such as a newline, a carriage return, a tab or an escape, and no line about \$PATH can print such a name as it is: a terminal acts on it. Nothing was placed. Set \$OPENREPOSHAPE_BIN_DIR to a directory without one, then run this again." + fi # 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 diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py index bb0bd660..19604b80 100644 --- a/tests/test_install_sh.py +++ b/tests/test_install_sh.py @@ -46,8 +46,15 @@ import pytest from conftest import BASH, BASH_SKIP, REPO, bash_path -from test_openreposhape_command import (BIN_DIR_SPELLINGS, COMMAND, - CYGPATH_STATES, INSTALL_NEEDS, +from test_openreposhape_command import (BIN_DIR_SPELLINGS, C1_BIN_DIRS, + COMMAND, COMMAND_REFUSAL_ENDS, + CYGPATH_STATES, HOLDS_A_COLON, + HOLDS_A_CONTROL_CHARACTER, + INSTALL_NEEDS, LOCALES, + NO_FILE_NAMED_SO, + NO_NON_ASCII_INTO_GIT_BASH, + NOT_CONTROLS, UNNAMEABLE_BIN_DIRS, + assert_a_bin_dir_is_refused, command_env, machine_path, put_cygpath, put_on_path, run_cmd, typed_bin_dir, write_script) @@ -839,6 +846,129 @@ def test_the_symlink_check_looks_at_the_directory_as_bash_spells_it(tmp_path): assert packaged.read_bytes() == b"#!/usr/bin/env python3\n# the package's own\n" +# --- a directory no line about `$PATH` can name (#760) ----------------------- + +#: How install.sh's refusals of one end: the command's remedy, for this file. +INSTALLER_REFUSAL_ENDS = ( + "Nothing was installed. Set $OPENREPOSHAPE_BIN_DIR to a directory " + "without one, then run this line again.") + + +@pytest.mark.parametrize("value, said", UNNAMEABLE_BIN_DIRS) +def test_the_installer_refuses_what_the_command_refuses_before_any_request( + tmp_path, value, said): + """ONE `$OPENREPOSHAPE_BIN_DIR`, a `:` or a control character, through + install.sh and through the command it installs (#760). + + The command refuses it with exit 2, but install.sh learnt of it only + after its fetch, said only "the openRepoShape's --install + refused (exit 2)", and a release older than the refusal placed the + command under it and printed it as typed. So install.sh carries a copy + of both `case` arms, and the two must never answer one value + differently: each is refused with exit 2 and the remedy, nothing is + placed or made, and install.sh has asked the network nothing. A colon's + refusal names the directory; a control character's never does.""" + net = Net(in_its_own_directory(tmp_path, "installer")) + command_home = in_its_own_directory(tmp_path, "command") + seen = {path.name for path in net.tmp_path.iterdir()} + typed_installer = value.format(d=bash_path(net.tmp_path)) + typed_command = value.format(d=bash_path(command_home)) + + installer = net.run(env={"OPENREPOSHAPE_BIN_DIR": typed_installer}) + command = run_cmd("--install", home=command_home, cwd=command_home, + env={"OPENREPOSHAPE_BIN_DIR": typed_command}) + + assert net.calls() == [], net.calls() + assert_a_bin_dir_is_refused(installer, typed_installer, said, + INSTALLER_REFUSAL_ENDS) + assert_a_bin_dir_is_refused(command, typed_command, said, + COMMAND_REFUSAL_ENDS) + net.refused(installer) + assert {path.name for path in net.tmp_path.iterdir()} - seen \ + <= {"gh-config"} + assert list(command_home.iterdir()) == [] + + +@NO_FILE_NAMED_SO +def test_the_installer_holds_the_default_directory_to_the_colon_refusal( + tmp_path): + """No variable at all, and a `$HOME` that holds a `:`: the directory is + `$HOME/.local/bin`, and install.sh refuses it as the command does, + naming it (#667, #760).""" + net = Net(tmp_path) + home = tmp_path / "a:b" + result = net.run(env={"HOME": str(home)}) + assert net.calls() == [], net.calls() + assert_a_bin_dir_is_refused(result, f"{home}/.local/bin", HOLDS_A_COLON, + INSTALLER_REFUSAL_ENDS) + net.refused(result) + + +@NO_FILE_NAMED_SO +def test_a_control_character_is_refused_before_the_symlink_check_names_it( + tmp_path): + """The symlink refusal (#563) names `$target`, so a directory holding a + carriage return that has pipx's link in it would have install.sh print + the value as typed (#760): the control character is refused first, and + the link stays as it was.""" + net = Net(tmp_path) + bin_dir = tmp_path / "a\rforged" / "bin" + bin_dir.mkdir(parents=True) + link = bin_dir / "openRepoShape" + link.symlink_to(tmp_path / "nowhere") + result = net.run(env={"OPENREPOSHAPE_BIN_DIR": str(bin_dir)}) + assert net.calls() == [], net.calls() + assert_a_bin_dir_is_refused(result, str(bin_dir), + HOLDS_A_CONTROL_CHARACTER, + INSTALLER_REFUSAL_ENDS) + assert link.is_symlink() + + +@NO_NON_ASCII_INTO_GIT_BASH +@pytest.mark.parametrize("locale", LOCALES) +@pytest.mark.parametrize("name", NOT_CONTROLS) +def test_the_installer_places_a_directory_with_an_unprinted_letter( + tmp_path, locale, name): + """The refusal is `[[:cntrl:]]` and not `[![:print:]]`, which + `checked_repo` uses (#760): under `LC_ALL=C` the two bytes of an e with + an acute accent are not printable, so the second would refuse a + directory called `caf` and that letter for a fault it has not got. It + is installed into, in either locale, and named as typed, and so is a + no-break space, whose first byte is the C1 controls' (`0xC2`) and + whose second is the one past them, and so are U+2028 and U+2029 (see + `NOT_CONTROLS`). The line is looked for among the lines split at a + newline and nothing else: `splitlines` splits at those two as well.""" + net = Net(tmp_path) + net.bin = tmp_path / name + result = net.run(env={"OPENREPOSHAPE_BIN_DIR": bash_path(net.bin), + "LC_ALL": locale}) + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result) + assert f"preflight: {bash_path(net.bin)}/openRepoShape --preflight" \ + in result.stdout.split("\n"), result.stdout + + +@NO_NON_ASCII_INTO_GIT_BASH +@pytest.mark.parametrize("locale", LOCALES) +@pytest.mark.parametrize("value", C1_BIN_DIRS) +def test_the_installer_refuses_a_c1_control_in_every_locale_before_any_request( + tmp_path, value, locale): + """A C1 control (U+0080 to U+009F) in the directory, under `C` and + under `C.UTF-8`, alone, after a byte that is not UTF-8, and after a + colon (#760): exit 2 with the control-character refusal, no request, + and nothing of the value in either stream. Under a UTF-8 locale a byte + that is not UTF-8 anywhere in the value makes bash match byte by byte, + and `[[:cntrl:]]` then matches neither byte of U+009B, as it matches + neither under `C`.""" + net = Net(tmp_path) + typed = value.format(d=bash_path(tmp_path)) + result = net.run(env={"OPENREPOSHAPE_BIN_DIR": typed, "LC_ALL": locale}) + assert net.calls() == [], net.calls() + assert_a_bin_dir_is_refused(result, typed, HOLDS_A_CONTROL_CHARACTER, + INSTALLER_REFUSAL_ENDS) + net.refused(result) + + #: 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 d1559e8a..49b6247d 100644 --- a/tests/test_openreposhape_command.py +++ b/tests/test_openreposhape_command.py @@ -597,40 +597,195 @@ def test_a_pasted_path_note_reaches_an_interactive_bash_as_printed( assert reached.read_text(encoding="utf-8") == f"{parent}/{name}:/sentinel" +#: Control characters, typed as their numbers: no escape sequence is written +#: into this file, because a tool that writes it may write the raw character +#: (U+009B is the C1 "control sequence introducer" a terminal runs). +ESCAPE = chr(0x1B) +DELETE = chr(0x7F) +C1_CSI = chr(0x9B) +#: A lone 0xE9 byte, which no UTF-8 text holds, as Python hands one to a +#: child's environment (the byte comes back through `surrogateescape`). +NOT_UTF8 = chr(0xDCE9) + +HOLDS_A_COLON = "holds a ':'" +HOLDS_A_CONTROL_CHARACTER = "holds a control character" + +#: How `--install`'s two refusals end, and how install.sh's do (#760): the +#: remedy is the same sentence in both, less the verb. +COMMAND_REFUSAL_ENDS = ( + "Nothing was placed. Set $OPENREPOSHAPE_BIN_DIR to a directory without " + "one, then run this again.") + #: 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. +#: bash spells it. A control character is refused FIRST and its refusal +#: never echoes the value, so every control value ends in `forged`, which +#: shows in no output when the refusal is right; a colon's refusal names the +#: directory, which holds no control character by then (#667, #760). 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.param("{d}/a:b/bin", HOLDS_A_COLON, id="a-colon"), + pytest.param("{d}/bin:{d}/other", HOLDS_A_COLON, id="two-directories"), + pytest.param("D:tools", HOLDS_A_COLON, id="a-drive-relative-name"), + pytest.param("{d}/a\nforged/bin", HOLDS_A_CONTROL_CHARACTER, + id="a-newline"), + pytest.param("{d}/a\rforged/bin", HOLDS_A_CONTROL_CHARACTER, + id="a-carriage-return"), + pytest.param("{d}/a" + ESCAPE + "[31mforged/bin", + HOLDS_A_CONTROL_CHARACTER, id="an-escape"), + pytest.param("{d}/a\tforged/bin", HOLDS_A_CONTROL_CHARACTER, + id="a-tab"), + pytest.param("{d}/a" + DELETE + "forged/bin", HOLDS_A_CONTROL_CHARACTER, + id="a-delete"), + pytest.param("D:\\tools\nforged\\bin", HOLDS_A_CONTROL_CHARACTER, + id="a-drive-spelling-and-a-newline"), + pytest.param("D:\\tools" + ESCAPE + "forged\\bin", + HOLDS_A_CONTROL_CHARACTER, + id="a-drive-spelling-and-an-escape"), + pytest.param("{d}/a:b" + ESCAPE + "forged/bin", + HOLDS_A_CONTROL_CHARACTER, id="a-colon-and-an-escape"), ] +#: Windows names no file with a control character in it, or with a `:` +#: beyond the drive's. +NO_FILE_NAMED_SO = pytest.mark.skipif( + os.name == "nt", reason="a Windows file name holds no control character") -@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))}) +#: The two locales a value is tried under: `C` reads one byte at a time, and +#: `C.UTF-8` reads UTF-8 text where it is valid (#760). +LOCALES = ["C", "C.UTF-8"] + +NO_NON_ASCII_INTO_GIT_BASH = pytest.mark.skipif( + os.name == "nt", reason="a non-ASCII value through the environment into " + "Git Bash is not run here") + + +def assert_a_bin_dir_is_refused(result, typed: str, said: str, + ending: str) -> None: + """`result` is a refusal, exit 2, of `typed`, a directory no line about + `$PATH` can name (#667, #760), saying `said` and ending as `ending` + does. A colon's refusal names the directory; nothing else does, and no + control character, and no `forged` tail, reaches either stream.""" 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 ending in said_in_full, said_in_full + if said == HOLDS_A_COLON: + assert f"'{typed}', which {said}" in said_in_full, said_in_full + for stream in (result.stdout, result.stderr): + assert "forged" not in stream, stream + assert ESCAPE not in stream, stream + assert DELETE not in stream, stream + assert C1_CSI not in stream, stream assert result.stdout == "" + + +@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`, a newline breaks the note's line in two, a + carriage return rewrites it on the terminal and an escape is the + terminal's to act on, so no `export PATH` line could name such a + directory (#667, #760): 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. Every control character is refused first, + ahead of the Windows spelling and the colon, and the value is never + echoed, so it cannot forge a line or reach the terminal.""" + typed = value.format(d=bash_path(tmp_path)) + result = run_cmd("--install", home=tmp_path, cwd=tmp_path, + env={"OPENREPOSHAPE_BIN_DIR": typed}) + assert_a_bin_dir_is_refused(result, typed, said, COMMAND_REFUSAL_ENDS) + assert list(tmp_path.iterdir()) == [] + + +#: Names no arm refuses, in either locale (#760): an e with an acute accent; +#: U+00A0, a no-break space, whose bytes `0xC2 0xA0` come right after the +#: C1 range's last (`0xC2 0x9F`); and U+2028 and U+2029, which `[[:cntrl:]]` +#: alone matches under a UTF-8 locale and not under `C`. The arm runs under +#: `C`, so it answers alike in both, and neither is a control a terminal +#: acts on (a line that holds one prints as it is). +NOT_CONTROLS = [ + pytest.param("caf" + chr(0xE9), id="an-e-acute"), + pytest.param("a" + chr(0xA0) + "b", id="a-no-break-space"), + pytest.param("a" + chr(0x2028) + "b", id="a-line-separator"), + pytest.param("a" + chr(0x2029) + "b", id="a-paragraph-separator"), +] + + +@NO_NON_ASCII_INTO_GIT_BASH +@pytest.mark.parametrize("locale", LOCALES) +@pytest.mark.parametrize("name", NOT_CONTROLS) +def test_a_bin_dir_with_an_unprinted_letter_is_placed( + tmp_path, locale, name): + """The refusal is `[[:cntrl:]]` and not `[![:print:]]` (#760): under + `LC_ALL=C` the two bytes of an e with an acute accent are not + printable, so the second would refuse a directory called `caf` and + that letter for a fault it has not got. It is placed, in either + locale, and so is a no-break space, whose first byte is the C1 + controls' (`0xC2`) and whose second is the one past them, and so are + U+2028 and U+2029, which only a UTF-8 locale's `[[:cntrl:]]` matches.""" + result = run_cmd("--install", home=tmp_path, cwd=tmp_path, env={ + "OPENREPOSHAPE_BIN_DIR": f"{bash_path(tmp_path)}/{name}", + "LC_ALL": locale}) + assert result.returncode == 0, result.stdout + result.stderr + assert (tmp_path / name / "openRepoShape").read_bytes() \ + == COMMAND.read_bytes() + + +#: A C1 control (U+0080 to U+009F) is two bytes in UTF-8, `0xC2` and a byte +#: from `0x80` to `0x9F`, and a terminal that reads the two as one acts on +#: it (#760). Each is refused in every locale, and whatever else the value +#: holds: under a UTF-8 locale `[[:cntrl:]]` matches a C1 control only in a +#: value that is valid UTF-8, and a lone `0xE9` byte before it makes bash +#: match byte by byte. The first and the last of the range are named, so an +#: arm that stops one short at either end is caught, and a colon beside the +#: control shows the control arm comes first. +C1_BIN_DIRS = [ + pytest.param("{d}/a" + C1_CSI + "forged/bin", id="a-c1-csi"), + pytest.param("{d}/a" + chr(0x80) + "forged/bin", id="the-first-c1-control"), + pytest.param("{d}/a" + chr(0x9F) + "forged/bin", id="the-last-c1-control"), + pytest.param("{d}/caf" + NOT_UTF8 + C1_CSI + "31mforged/bin", + id="a-byte-that-is-not-utf-8-then-a-c1-csi"), + pytest.param("{d}/a:caf" + NOT_UTF8 + C1_CSI + "31mforged/bin", + id="a-colon-then-a-byte-that-is-not-utf-8-and-a-c1-csi"), +] + + +@NO_NON_ASCII_INTO_GIT_BASH +@pytest.mark.parametrize("locale", LOCALES) +@pytest.mark.parametrize("value", C1_BIN_DIRS) +def test_a_c1_control_is_refused_in_every_locale_whatever_else_the_value_holds( + tmp_path, value, locale): + """Refused as an escape is, exit 2, nothing placed, and never echoed: + under `C`, where `[[:cntrl:]]` reads the two bytes one at a time and + matches neither, and under `C.UTF-8`, where a byte that is not UTF-8 + anywhere in the value makes bash match byte by byte (#760).""" + typed = value.format(d=bash_path(tmp_path)) + result = run_cmd("--install", home=tmp_path, cwd=tmp_path, + env={"OPENREPOSHAPE_BIN_DIR": typed, "LC_ALL": locale}) + assert_a_bin_dir_is_refused(result, typed, HOLDS_A_CONTROL_CHARACTER, + COMMAND_REFUSAL_ENDS) assert list(tmp_path.iterdir()) == [] +@NO_FILE_NAMED_SO +def test_a_control_character_is_refused_before_a_link_in_it_names_it( + tmp_path): + """The symlink refusal (#563) names `$dir/openRepoShape`, so a directory + holding a carriage return that already has pipx's link in it would + have the refusal print the value as typed (#760): the control character + is refused first, and the link stays as it was.""" + bin_dir = tmp_path / "a\rforged" / "bin" + bin_dir.mkdir(parents=True) + link = bin_dir / "openRepoShape" + link.symlink_to(tmp_path / "nowhere") + result = run_cmd("--install", home=tmp_path, cwd=tmp_path, + env={"OPENREPOSHAPE_BIN_DIR": str(bin_dir)}) + assert_a_bin_dir_is_refused(result, str(bin_dir), + HOLDS_A_CONTROL_CHARACTER, + COMMAND_REFUSAL_ENDS) + assert link.is_symlink() + + @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