Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -334,7 +336,7 @@
}

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
Expand Down Expand Up @@ -362,14 +364,40 @@
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

Check failure on line 379 in install.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a default case (*) to handle unexpected values.

See more on https://sonarcloud.io/project/issues?id=opensoft_openRepoShape&issues=AaFFsWXB2nuAkh0oF9se&open=AaFFsWXB2nuAkh0oF9se&pullRequest=726
[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

Check failure on line 385 in install.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a default case (*) to handle unexpected values.

See more on https://sonarcloud.io/project/issues?id=opensoft_openRepoShape&issues=AaFFsWXB2nuAkh0oF9sf&open=AaFFsWXB2nuAkh0oF9sf&pullRequest=726
"" | [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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass the respelled directory to the downloaded launcher

When a Windows-spelled directory is successfully converted, bin_dir changes only as a local shell variable; OPENREPOSHAPE_BIN_DIR in the environment remains unchanged when the downloaded launcher runs. Consequently, installing a named release from before the launcher's own cygpath fix still receives and reports the raw D:\... spelling, prints its broken PATH instruction, and on shells where the two spellings are not aliases may install somewhere other than the normalized $target that this script subsequently executes. Pass the normalized value in the environment of the downloaded launcher so the installer's documented compatibility with older named releases also gets this fix.

Useful? React with 👍 / 👎.

;;
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
# directory, and the command's `--install` copies with `cp`, which writes
# 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:
Expand Down
58 changes: 57 additions & 1 deletion openRepoShape
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
157 changes: 155 additions & 2 deletions tests/test_install_sh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading