From bc5c465ceec8c20bf386268b6ca04d807f17f1d1 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Mon, 12 Oct 2026 13:58:34 +0100 Subject: [PATCH 1/6] fix(install): put ant on PATH from install.sh, as install.ps1 does (#194) After a successful install.sh run, `ant --version` failed with command not found on a fresh shell: the install dir was not on PATH (macOS's default /usr/local/bin is root-owned without Homebrew, and the ~/.local/bin workaround is not on macOS's default PATH), and the script only printed the export line. install.ps1 already adds its dir to the Windows user PATH. install.sh now: - defaults to ~/.local/bin on macOS as well as Linux, so it never needs sudo; - when INSTALL_DIR is not already on PATH, appends one guarded line to ~/.profile, the user's existing bash startup files (creating ~/.bashrc when bash is the login shell), ~/.zshenv when zsh is installed, and fish's conf.d when fish is (the rustup set), listing each file it changed. Re-runs add nothing; unwritable files are reported, not fatal; - honours --no-modify-path / ANT_NO_MODIFY_PATH=1 to keep the old print-the-line behaviour (install.ps1 honours the env var too); - notes when another ant is already on PATH, e.g. an earlier macOS install in /usr/local/bin that path_helper would put first. install-test.yml gains a fresh-shell check on Linux and macOS (stock /bin/bash 3.2, zsh login and non-login). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/install-test.yml | 33 ++++++ CHANGELOG.md | 9 ++ README.md | 10 +- install.ps1 | 17 ++- install.sh | 164 ++++++++++++++++++++++++++--- 5 files changed, 212 insertions(+), 21 deletions(-) diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml index 8734022d..3969b215 100644 --- a/.github/workflows/install-test.yml +++ b/.github/workflows/install-test.yml @@ -19,6 +19,7 @@ jobs: env: ANT_VERSION: ${{ inputs.version }} INSTALL_DIR: ${{ runner.temp }}/bin + ANT_NO_MODIFY_PATH: "1" run: bash install.sh - name: verify binary @@ -37,6 +38,21 @@ jobs: echo "Bootstrap config installed at $config_file" cat "$config_file" + # A default install must leave `ant` on PATH in new shells with no manual edit (#194). env -i + # starts each shell from a PATH without ~/.local/bin, as on a fresh machine. + - name: verify a default install is on PATH in new shells + env: + ANT_VERSION: ${{ inputs.version }} + run: | + fresh() { env -i HOME="$HOME" SHELL=/bin/bash PATH=/usr/bin:/bin "$@"; } + fresh ANT_VERSION="$ANT_VERSION" bash install.sh + for shell in "bash -lc" "bash -ic"; do + found="$(fresh $shell 'command -v ant' 2> /dev/null)" + echo "$shell: ${found:-}" + [ "$found" = "$HOME/.local/bin/ant" ] || exit 1 + done + fresh bash -lc 'ant --version' + test-install-script-macos: name: test install.sh (macOS) runs-on: macos-latest @@ -47,6 +63,7 @@ jobs: env: ANT_VERSION: ${{ inputs.version }} INSTALL_DIR: ${{ runner.temp }}/bin + ANT_NO_MODIFY_PATH: "1" run: bash install.sh - name: verify binary @@ -65,6 +82,22 @@ jobs: echo "Bootstrap config installed at $config_file" cat "$config_file" + # As on Linux (#194), from a fresh-machine PATH. /bin/bash is the 3.2 that `curl | bash` runs + # on a stock Mac, and zsh is the default login shell; `zsh -lc` also runs /etc/zprofile's + # path_helper, which reorders PATH. + - name: verify a default install is on PATH in new shells + env: + ANT_VERSION: ${{ inputs.version }} + run: | + fresh() { env -i HOME="$HOME" SHELL=/bin/zsh PATH=/usr/bin:/bin "$@"; } + fresh ANT_VERSION="$ANT_VERSION" /bin/bash install.sh + for shell in "zsh -c" "zsh -lc" "zsh -ic" "/bin/bash -lc"; do + found="$(fresh $shell 'command -v ant' 2> /dev/null)" + echo "$shell: ${found:-}" + [ "$found" = "$HOME/.local/bin/ant" ] || exit 1 + done + fresh zsh -lc 'ant --version' + test-install-script-windows: name: test install.ps1 (Windows) runs-on: windows-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index a876f85d..90d765c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 it (`npm update -g @withautonomi/ant`). Self-replacing an npm-managed binary left npm's metadata describing a file that was no longer there, so the next `npm update` silently rolled the user back. +- `install.sh` puts `ant` on `PATH`, as `install.ps1` already did on Windows, so `ant --version` + works in a new terminal straight after installing (#194). When `INSTALL_DIR` is not already on + `PATH` it adds a guarded line to `~/.profile`, to the user's existing bash startup files, to + `~/.zshenv` when zsh is installed and to fish's `conf.d` when fish is, and lists each file it + changed. `--no-modify-path` or `ANT_NO_MODIFY_PATH=1` (also honoured by `install.ps1`) restores + the old print-the-line behaviour. The macOS default install directory moves from + `/usr/local/bin`, which is root-owned without Homebrew so the install failed without `sudo`, to + `~/.local/bin`, matching Linux. If another `ant` is already on `PATH` — such as an earlier + macOS install in `/usr/local/bin` — the installer says so, since it may shadow the new one. ## [0.3.6] - 2026-09-01 diff --git a/README.md b/README.md index 2ade88cd..603e8604 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,15 @@ Both installers take the same environment variables: |----------|-------------| | `ANT_CHANNEL` | Release channel: `stable` (default) or `beta`. See [Beta Programme](#beta-programme). | | `ANT_VERSION` | Install one specific version, e.g. `0.3.3`. Overrides `ANT_CHANNEL`. | -| `INSTALL_DIR` | Where to put the binary. Defaults to `~/.local/bin` on Linux, `/usr/local/bin` on macOS, `%LOCALAPPDATA%\ant\bin` on Windows. | +| `INSTALL_DIR` | Where to put the binary. Defaults to `~/.local/bin` on Linux and macOS, `%LOCALAPPDATA%\ant\bin` on Windows. | +| `ANT_NO_MODIFY_PATH` | Set to `1` to stop the installer adding `INSTALL_DIR` to your `PATH`. | + +If `INSTALL_DIR` is not already on your `PATH`, the installer adds it, so `ant` works in every new +terminal. `install.ps1` adds it to your Windows user `PATH`. `install.sh` adds one guarded line to +`~/.profile`, to whichever of `~/.bash_profile`, `~/.bash_login` and `~/.bashrc` you already have, +to `~/.zshenv` when zsh is installed and to `conf.d/ant.fish` when fish is, and lists each file it +changed. To leave your startup files alone, pass `--no-modify-path` (`curl ... | bash -s -- +--no-modify-path`) or set `ANT_NO_MODIFY_PATH=1`. ### npm diff --git a/install.ps1 b/install.ps1 index d837fe3a..06b30ac6 100644 --- a/install.ps1 +++ b/install.ps1 @@ -7,6 +7,8 @@ # ANT_CHANNEL - release channel to install from: "stable" (default) or "beta". # ANT_VERSION - install a specific version (e.g. "0.1.1"). Overrides ANT_CHANNEL. # INSTALL_DIR - override install directory (default: %LOCALAPPDATA%\ant\bin). +# ANT_NO_MODIFY_PATH - set to 1 to leave your user PATH alone; the installer prints the +# directory to add instead. # # Examples: # irm | iex # newest stable @@ -199,12 +201,19 @@ try { # Add to user PATH if not already there $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") + $ModifyPath = (-not $env:ANT_NO_MODIFY_PATH) -or ($env:ANT_NO_MODIFY_PATH -eq "0") if ($UserPath -notlike "*$InstallDir*") { Say "" - Say "Adding $InstallDir to your user PATH..." - [Environment]::SetEnvironmentVariable("Path", "$InstallDir;$UserPath", "User") - $env:Path = "$InstallDir;$env:Path" - Say "Restart your terminal for the PATH change to take effect." + if ($ModifyPath) { + Say "Adding $InstallDir to your user PATH..." + [Environment]::SetEnvironmentVariable("Path", "$InstallDir;$UserPath", "User") + $env:Path = "$InstallDir;$env:Path" + Say "Restart your terminal for the PATH change to take effect." + } else { + Say "WARNING: $InstallDir is not in your user PATH." + Say "Add it for this session with:" + Say " `$env:Path = `"$InstallDir;`$env:Path`"" + } } } finally { Remove-Item -Path $TempDir -Recurse -Force -ErrorAction SilentlyContinue diff --git a/install.sh b/install.sh index 8da2f202..cee01eef 100755 --- a/install.sh +++ b/install.sh @@ -5,14 +5,23 @@ # curl -fsSL https://raw.githubusercontent.com/WithAutonomi/ant-client/main/install.sh | bash # # Environment variables: -# ANT_CHANNEL — release channel to install from: "stable" (default) or "beta". -# ANT_VERSION — install a specific version (e.g. "0.1.1"). Overrides ANT_CHANNEL. -# INSTALL_DIR — override install directory (default: ~/.local/bin on Linux, /usr/local/bin on macOS). +# ANT_CHANNEL — release channel to install from: "stable" (default) or "beta". +# ANT_VERSION — install a specific version (e.g. "0.1.1"). Overrides ANT_CHANNEL. +# INSTALL_DIR — override install directory (default: ~/.local/bin). +# ANT_NO_MODIFY_PATH — set to 1 to leave your shell startup files alone (same as --no-modify-path). +# +# Options: +# --no-modify-path — do not add INSTALL_DIR to PATH; print the line to add instead. +# +# When INSTALL_DIR is not already on PATH, the installer adds it to your shell startup files +# (~/.profile, your existing bash files, ~/.zshenv, fish's conf.d) so that `ant` works in every new +# terminal, and prints each file it changed. install.ps1 does the same with the Windows user PATH. # # Examples: -# curl -fsSL | bash # newest stable -# curl -fsSL | ANT_CHANNEL=beta bash # newest beta-eligible -# curl -fsSL | ANT_VERSION=0.3.3 bash # a specific version +# curl -fsSL | bash # newest stable +# curl -fsSL | ANT_CHANNEL=beta bash # newest beta-eligible +# curl -fsSL | ANT_VERSION=0.3.3 bash # a specific version +# curl -fsSL | bash -s -- --no-modify-path # leave PATH to me set -euo pipefail @@ -66,16 +75,113 @@ config_dir() { esac } +# User-writable on both platforms, so the install never needs sudo. macOS used to default to +# /usr/local/bin, which is root-owned on a machine without Homebrew. default_install_dir() { - local os - os="$(uname -s)" - case "$os" in - Linux) echo "$HOME/.local/bin" ;; - Darwin) echo "/usr/local/bin" ;; - *) err "unsupported OS: $os" ;; + echo "$HOME/.local/bin" +} + +# $1 with a leading $HOME spelled as a literal `$HOME`, for lines written into startup files. +home_relative() { + case "$1" in + "$HOME"/*) printf '%s\n' "\$HOME${1#"$HOME"}" ;; + *) printf '%s\n' "$1" ;; esac } +# Shell startup files that should put INSTALL_DIR on PATH, one per line. Mirrors rustup: +# ~/.profile — every POSIX login shell; always written. +# ~/.bash_profile, +# ~/.bash_login — bash login shells skip ~/.profile when either exists; written if present. +# ~/.bashrc — interactive non-login bash (a new Linux terminal); written if present, or +# created when bash is the login shell. +# ~/.zshenv — read by every zsh, interactive or not; written whenever zsh is installed, +# which includes every Mac. +# fish is handled separately, as its syntax differs. +posix_startup_files() { + local shell_name="${SHELL##*/}" rc + + printf '%s\n' "$HOME/.profile" + for rc in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.bashrc"; do + if [ -f "$rc" ]; then + printf '%s\n' "$rc" + elif [ "$rc" = "$HOME/.bashrc" ] && [ "$shell_name" = "bash" ]; then + printf '%s\n' "$rc" + fi + done + if [ "$shell_name" = "zsh" ] || command -v zsh > /dev/null 2>&1; then + printf '%s\n' "${ZDOTDIR:-$HOME}/.zshenv" + fi +} + +# Append line $2 to startup file $1, under a comment saying where it came from. +# Returns 0 when the file was changed, 1 when the line was already there, 2 when the write failed. +append_startup_line() { + local rc="$1" line="$2" gap="" + + if [ -f "$rc" ] && grep -qxF "$line" "$rc"; then + return 1 + fi + if [ -s "$rc" ]; then + gap=$'\n' + fi + { + mkdir -p "$(dirname "$rc")" \ + && printf '%s# Added by the ant installer (https://github.com/%s)\n%s\n' "$gap" "$REPO" "$line" >> "$rc" + } 2> /dev/null || return 2 +} + +# Put INSTALL_DIR on PATH for future shells, and tell the user what changed. +add_install_dir_to_path() { + local dir posix_line fish_line rc i status + local entries=() changed=() present=() failed=() + + dir="$(home_relative "$INSTALL_DIR")" + # Guarded, so a shell that reads several of these files (Ubuntu's ~/.profile sources ~/.bashrc) + # still ends up with one entry. + # shellcheck disable=SC2016 # $PATH is meant to reach the startup file unexpanded. + posix_line="$(printf 'case ":${PATH}:" in *":%s:"*) ;; *) export PATH="%s:$PATH" ;; esac' "$dir" "$dir")" + # shellcheck disable=SC2016 + fish_line="$(printf 'contains "%s" $PATH; or set -gx PATH "%s" $PATH' "$dir" "$dir")" + + # Pairs of (startup file, line to add to it). + while IFS= read -r rc; do + entries+=("$rc" "$posix_line") + done < <(posix_startup_files) + if command -v fish > /dev/null 2>&1; then + entries+=("${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/ant.fish" "$fish_line") + fi + + for ((i = 0; i < ${#entries[@]}; i += 2)); do + rc="${entries[i]}" + status=0 + append_startup_line "$rc" "${entries[i + 1]}" || status=$? + case "$status" in + 0) changed+=("$rc") ;; + 1) present+=("$rc") ;; + *) failed+=("$rc") ;; + esac + done + + say "" + if [ "${#changed[@]}" -gt 0 ]; then + say "Added ${INSTALL_DIR} to your PATH in:" + for rc in "${changed[@]}"; do say " $rc"; done + elif [ "${#present[@]}" -gt 0 ]; then + say "Your shell startup files already add ${INSTALL_DIR} to your PATH." + fi + if [ "${#failed[@]}" -gt 0 ]; then + say "Could not write to:" + for rc in "${failed[@]}"; do say " $rc"; done + fi + if [ "${#changed[@]}" -eq 0 ] && [ "${#present[@]}" -eq 0 ]; then + say "Add it to your PATH with:" + else + say "Open a new terminal to use ant, or run this to use it in the current one:" + fi + say " export PATH=\"${INSTALL_DIR}:\$PATH\"" +} + # Whether a version is eligible for a release channel. # # Deliberately mirrors `version_matches_channel` in ant-core/src/channel.rs, which in turn mirrors @@ -184,6 +290,18 @@ resolve_version() { need curl need tar +MODIFY_PATH=1 +case "${ANT_NO_MODIFY_PATH:-}" in + ""|0) ;; + *) MODIFY_PATH=0 ;; +esac +for arg in "$@"; do + case "$arg" in + --no-modify-path) MODIFY_PATH=0 ;; + *) err "unknown option '$arg' (the only option is --no-modify-path)" ;; + esac +done + TARGET="$(detect_target)" CHANNEL="${ANT_CHANNEL:-stable}" VERSION="${ANT_VERSION:-$(resolve_version "$CHANNEL")}" @@ -209,6 +327,8 @@ tar xzf "${TMPDIR}/${ARCHIVE}" -C "$TMPDIR" # Install binary mkdir -p "$INSTALL_DIR" +# Absolute, so the PATH entry written below does not depend on where the installer was run from. +INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd)" cp "${TMPDIR}/${BINARY_NAME}-${VERSION}-${TARGET}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" chmod +x "${INSTALL_DIR}/${BINARY_NAME}" say "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}" @@ -227,12 +347,24 @@ fi case ":$PATH:" in *":${INSTALL_DIR}:"*) ;; *) - say "" - say "WARNING: ${INSTALL_DIR} is not in your PATH." - say "Add it with:" - say " export PATH=\"${INSTALL_DIR}:\$PATH\"" + if [ "$MODIFY_PATH" = 1 ]; then + add_install_dir_to_path + else + say "" + say "WARNING: ${INSTALL_DIR} is not in your PATH." + say "Add it with:" + say " export PATH=\"${INSTALL_DIR}:\$PATH\"" + fi ;; esac +# An older install elsewhere (macOS used to default to /usr/local/bin) can shadow this one. +OTHER_ANT="$(command -v "$BINARY_NAME" 2> /dev/null || true)" +if [ -n "$OTHER_ANT" ] && [ "$OTHER_ANT" != "${INSTALL_DIR}/${BINARY_NAME}" ]; then + say "" + say "NOTE: another ${BINARY_NAME} is on your PATH at ${OTHER_ANT}, and may run instead of this one." + say "Remove whichever copy you no longer use." +fi + say "" say "Done! Run 'ant --help' to get started." From e4aaa874fc02502b67141e8e8efc91c55fcaa246 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Mon, 12 Oct 2026 14:09:47 +0100 Subject: [PATCH 2/6] fix(install): name Apache Ant in the shadowing note; check PATH in macOS login shells The macOS install-test run showed zsh login shells resolving `ant` to the runner image's Homebrew Apache Ant: /etc/zprofile's path_helper and `brew shellenv` run after ~/.zshenv and put /opt/homebrew/bin ahead of ~/.local/bin. Forcing ~/.local/bin to the front would change precedence for every tool in it, so the installer keeps appending and instead says, when another `ant` is on PATH, that it may be an unrelated program and how to get past it. The macOS check asserts ~/.local/bin is on PATH in login shells, and that `zsh -c` resolves this ant. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/install-test.yml | 19 ++++++++++++------- CHANGELOG.md | 5 +++-- install.sh | 9 ++++++--- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml index 3969b215..dc44e836 100644 --- a/.github/workflows/install-test.yml +++ b/.github/workflows/install-test.yml @@ -83,20 +83,25 @@ jobs: cat "$config_file" # As on Linux (#194), from a fresh-machine PATH. /bin/bash is the 3.2 that `curl | bash` runs - # on a stock Mac, and zsh is the default login shell; `zsh -lc` also runs /etc/zprofile's - # path_helper, which reorders PATH. + # on a stock Mac, and zsh is the default login shell. zsh login shells run /etc/zprofile's + # path_helper and Homebrew's shellenv after ~/.zshenv, which put ~/.local/bin behind + # /opt/homebrew/bin, where this runner image has Apache Ant (also `ant`). So login shells are + # checked for ~/.local/bin on PATH rather than for which `ant` wins. - name: verify a default install is on PATH in new shells env: ANT_VERSION: ${{ inputs.version }} run: | fresh() { env -i HOME="$HOME" SHELL=/bin/zsh PATH=/usr/bin:/bin "$@"; } fresh ANT_VERSION="$ANT_VERSION" /bin/bash install.sh - for shell in "zsh -c" "zsh -lc" "zsh -ic" "/bin/bash -lc"; do - found="$(fresh $shell 'command -v ant' 2> /dev/null)" - echo "$shell: ${found:-}" - [ "$found" = "$HOME/.local/bin/ant" ] || exit 1 + found="$(fresh zsh -c 'command -v ant')" + echo "zsh -c: ${found:-}" + [ "$found" = "$HOME/.local/bin/ant" ] + for shell in "zsh -lc" "zsh -ic" "/bin/bash -lc"; do + p="$(fresh $shell 'echo $PATH' 2> /dev/null)" + echo "$shell: $p" + case ":$p:" in *":$HOME/.local/bin:"*) ;; *) exit 1 ;; esac done - fresh zsh -lc 'ant --version' + fresh zsh -c 'ant --version' test-install-script-windows: name: test install.ps1 (Windows) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90d765c7..92e7222d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,8 +65,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 changed. `--no-modify-path` or `ANT_NO_MODIFY_PATH=1` (also honoured by `install.ps1`) restores the old print-the-line behaviour. The macOS default install directory moves from `/usr/local/bin`, which is root-owned without Homebrew so the install failed without `sudo`, to - `~/.local/bin`, matching Linux. If another `ant` is already on `PATH` — such as an earlier - macOS install in `/usr/local/bin` — the installer says so, since it may shadow the new one. + `~/.local/bin`, matching Linux. If another `ant` is already on `PATH` — an earlier macOS + install in `/usr/local/bin`, or an unrelated program such as Apache Ant — the installer says + so, since it may shadow the new one. ## [0.3.6] - 2026-09-01 diff --git a/install.sh b/install.sh index cee01eef..a1517541 100755 --- a/install.sh +++ b/install.sh @@ -358,12 +358,15 @@ case ":$PATH:" in ;; esac -# An older install elsewhere (macOS used to default to /usr/local/bin) can shadow this one. +# Another `ant` can shadow this one: an older install (macOS used to default to /usr/local/bin), or +# an unrelated program such as Apache Ant. macOS login shells put /usr/local/bin and Homebrew's bin +# ahead of ~/.local/bin, so either wins there. OTHER_ANT="$(command -v "$BINARY_NAME" 2> /dev/null || true)" if [ -n "$OTHER_ANT" ] && [ "$OTHER_ANT" != "${INSTALL_DIR}/${BINARY_NAME}" ]; then say "" - say "NOTE: another ${BINARY_NAME} is on your PATH at ${OTHER_ANT}, and may run instead of this one." - say "Remove whichever copy you no longer use." + say "NOTE: ${OTHER_ANT} is also on your PATH and may run instead of this ant." + say "If it is an older install of this ant, remove it. If it is another program (Apache Ant is" + say "also called ant), put ${INSTALL_DIR} first on your PATH or run ${INSTALL_DIR}/${BINARY_NAME}." fi say "" From 32119651804ee9b6de56e3d049acdaec70e75b0c Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Tue, 27 Oct 2026 13:22:54 +0000 Subject: [PATCH 3/6] fix(install): escape INSTALL_DIR in startup lines; clear CDPATH for a relative one The PATH lines written to the shell startup files formatted INSTALL_DIR straight into shell code, so a directory name containing $(...), backticks or quotes ran as code whenever a new shell started. Everything after the literal $HOME prefix is now escaped for double quotes: \ " $ and ` for POSIX shells, and \ " and $ for fish. The escaping uses sed, because bash's ${var//...} replacement quoting differs between 3.2 and 5.2. The `export PATH=...` line printed for the current shell is escaped the same way. A directory name with a newline leaves the startup files alone and says so. Making a relative INSTALL_DIR absolute ran a plain `cd`, which with CDPATH set prints the directory too, so INSTALL_DIR held two lines and the copy failed. It now clears CDPATH, and `cd` and `mkdir` take `--`. install-test gains regression steps: - Linux and macOS: an install directory named with $(...), backticks, quotes and a backslash must resolve literally in each new shell, including dash, zsh and fish on Linux, and from the printed line, without running anything. - Linux: a relative INSTALL_DIR with CDPATH=. must install to the right absolute path. The shell lookups in these steps report a failure instead of exiting silently. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/install-test.yml | 90 +++++++++++++++++++++++++++++- CHANGELOG.md | 4 +- install.sh | 43 ++++++++++---- 3 files changed, 121 insertions(+), 16 deletions(-) diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml index dc44e836..10372134 100644 --- a/.github/workflows/install-test.yml +++ b/.github/workflows/install-test.yml @@ -47,12 +47,62 @@ jobs: fresh() { env -i HOME="$HOME" SHELL=/bin/bash PATH=/usr/bin:/bin "$@"; } fresh ANT_VERSION="$ANT_VERSION" bash install.sh for shell in "bash -lc" "bash -ic"; do - found="$(fresh $shell 'command -v ant' 2> /dev/null)" + # shellcheck disable=SC2086 # $shell is a shell and its flag, split on purpose. + found="$(fresh $shell 'command -v ant' 2> /dev/null || true)" echo "$shell: ${found:-}" [ "$found" = "$HOME/.local/bin/ant" ] || exit 1 done fresh bash -lc 'ant --version' + # INSTALL_DIR goes into the startup files as shell code, so a name with shell syntax in it + # must read back as that literal directory and run nothing. It gets its own HOME, and zsh and + # fish are installed so their lines are checked too. sh is dash. + - name: verify an install directory with shell syntax in its name is written literally + env: + ANT_VERSION: ${{ inputs.version }} + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq zsh fish > /dev/null + home="$RUNNER_TEMP/odd-home" + mkdir -p "$home" + fresh() { env -i HOME="$home" SHELL=/bin/bash PATH=/usr/bin:/bin "$@"; } + # shellcheck disable=SC2016 # The syntax is meant to reach the installer unexpanded. + dir="$home"'/odd $(touch $HOME/ran-subst) `touch $HOME/ran-backtick` "quoted" back\slash' + out="$(fresh ANT_VERSION="$ANT_VERSION" INSTALL_DIR="$dir" bash install.sh)" + echo "$out" + for shell in "bash -lc" "bash -ic" "sh -lc" "zsh -c" "fish -c"; do + # shellcheck disable=SC2086 # $shell is a shell and its flag, split on purpose. + found="$(fresh $shell 'command -v ant' 2> /dev/null || true)" + echo "$shell: ${found:-}" + [ "$found" = "$dir/ant" ] || exit 1 + done + # The line the installer prints for the current shell must be as safe to paste. + line="$(echo "$out" | grep -m1 '^ export PATH=')" + found="$(env -i HOME="$home" PATH=/usr/bin:/bin sh -c "$line; command -v ant" || true)" + echo "printed line: ${found:-}" + [ "$found" = "$dir/ant" ] || exit 1 + if ls "$home"/ran-* 2> /dev/null; then + echo "ERROR: a shell ran code from the install directory's name" + exit 1 + fi + + # A relative INSTALL_DIR is made absolute with `cd`, which also prints the directory when it + # is found through CDPATH. + - name: verify a relative install directory with CDPATH set + env: + ANT_VERSION: ${{ inputs.version }} + run: | + work="$RUNNER_TEMP/relative" + mkdir -p "$work" + cp install.sh "$work/" + cd "$work" + out="$(CDPATH=. INSTALL_DIR=bin ANT_NO_MODIFY_PATH=1 bash install.sh)" + echo "$out" + if ! echo "$out" | grep -qxF "Installed ant to $work/bin/ant"; then + echo "ERROR: expected ant to be installed to $work/bin/ant" + exit 1 + fi + "$work/bin/ant" --version + test-install-script-macos: name: test install.sh (macOS) runs-on: macos-latest @@ -93,16 +143,50 @@ jobs: run: | fresh() { env -i HOME="$HOME" SHELL=/bin/zsh PATH=/usr/bin:/bin "$@"; } fresh ANT_VERSION="$ANT_VERSION" /bin/bash install.sh - found="$(fresh zsh -c 'command -v ant')" + found="$(fresh zsh -c 'command -v ant' || true)" echo "zsh -c: ${found:-}" [ "$found" = "$HOME/.local/bin/ant" ] for shell in "zsh -lc" "zsh -ic" "/bin/bash -lc"; do - p="$(fresh $shell 'echo $PATH' 2> /dev/null)" + # shellcheck disable=SC2016,SC2086 # $PATH is for the new shell; $shell is split on purpose. + p="$(fresh $shell 'echo $PATH' 2> /dev/null || true)" echo "$shell: $p" case ":$p:" in *":$HOME/.local/bin:"*) ;; *) exit 1 ;; esac done fresh zsh -c 'ant --version' + # As on Linux, a name with shell syntax in it must read back as that literal directory and run + # nothing, here with the stock /bin/bash running the installer and macOS's own sed. Login + # shells are checked for the directory on PATH, as above. + - name: verify an install directory with shell syntax in its name is written literally + env: + ANT_VERSION: ${{ inputs.version }} + run: | + home="$RUNNER_TEMP/odd-home" + mkdir -p "$home" + fresh() { env -i HOME="$home" SHELL=/bin/zsh PATH=/usr/bin:/bin "$@"; } + # shellcheck disable=SC2016 # The syntax is meant to reach the installer unexpanded. + dir="$home"'/odd $(touch $HOME/ran-subst) `touch $HOME/ran-backtick` "quoted" back\slash' + out="$(fresh ANT_VERSION="$ANT_VERSION" INSTALL_DIR="$dir" /bin/bash install.sh)" + echo "$out" + found="$(fresh zsh -c 'command -v ant' || true)" + echo "zsh -c: ${found:-}" + [ "$found" = "$dir/ant" ] || exit 1 + for shell in "zsh -lc" "zsh -ic" "/bin/bash -lc"; do + # shellcheck disable=SC2016,SC2086 # $PATH is for the new shell; $shell is split on purpose. + p="$(fresh $shell 'printf "%s\n" "$PATH"' 2> /dev/null || true)" + echo "$shell: $p" + case ":$p:" in *":$dir:"*) ;; *) exit 1 ;; esac + done + # The line the installer prints for the current shell must be as safe to paste. + line="$(echo "$out" | grep -m1 '^ export PATH=')" + found="$(env -i HOME="$home" PATH=/usr/bin:/bin /bin/sh -c "$line; command -v ant" || true)" + echo "printed line: ${found:-}" + [ "$found" = "$dir/ant" ] || exit 1 + if ls "$home"/ran-* 2> /dev/null; then + echo "ERROR: a shell ran code from the install directory's name" + exit 1 + fi + test-install-script-windows: name: test install.ps1 (Windows) runs-on: windows-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 92e7222d..18dc96db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,7 +62,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 works in a new terminal straight after installing (#194). When `INSTALL_DIR` is not already on `PATH` it adds a guarded line to `~/.profile`, to the user's existing bash startup files, to `~/.zshenv` when zsh is installed and to fish's `conf.d` when fish is, and lists each file it - changed. `--no-modify-path` or `ANT_NO_MODIFY_PATH=1` (also honoured by `install.ps1`) restores + changed. The directory is escaped in those lines and in the `export PATH=...` line it prints, so + a name containing quotes, `$`, backticks or backslashes is added literally and never run. + `--no-modify-path` or `ANT_NO_MODIFY_PATH=1` (also honoured by `install.ps1`) restores the old print-the-line behaviour. The macOS default install directory moves from `/usr/local/bin`, which is root-owned without Homebrew so the install failed without `sudo`, to `~/.local/bin`, matching Linux. If another `ant` is already on `PATH` — an earlier macOS diff --git a/install.sh b/install.sh index a1517541..da6a29e3 100755 --- a/install.sh +++ b/install.sh @@ -81,11 +81,18 @@ default_install_dir() { echo "$HOME/.local/bin" } -# $1 with a leading $HOME spelled as a literal `$HOME`, for lines written into startup files. -home_relative() { +# $1 escaped to read back literally inside double quotes: \ " $ and ` for POSIX shells, and +# \ " and $ for fish, where ` is not special. sed rather than ${1//...}, whose replacement quoting +# differs between bash 3.2 and 5.2. +sh_quoted() { printf '%s\n' "$1" | sed 's/[\\"$`]/\\&/g'; } +fish_quoted() { printf '%s\n' "$1" | sed 's/[\\"$]/\\&/g'; } + +# $1 for use inside double quotes in a startup file, escaped by $2 (sh_quoted or fish_quoted), with +# a leading $HOME spelled as a literal `$HOME`, so the line keeps working if the home directory moves. +startup_path() { case "$1" in - "$HOME"/*) printf '%s\n' "\$HOME${1#"$HOME"}" ;; - *) printf '%s\n' "$1" ;; + "$HOME"/*) printf '%s\n' "\$HOME$("$2" "${1#"$HOME"}")" ;; + *) "$2" "$1" ;; esac } @@ -133,16 +140,27 @@ append_startup_line() { # Put INSTALL_DIR on PATH for future shells, and tell the user what changed. add_install_dir_to_path() { - local dir posix_line fish_line rc i status + local posix_dir fish_dir posix_line fish_line rc i status local entries=() changed=() present=() failed=() - dir="$(home_relative "$INSTALL_DIR")" + # Each startup line has to fit on one line, and is checked for line by line. + case "$INSTALL_DIR" in + *$'\n'*) + say "" + say "WARNING: ${INSTALL_DIR} contains a newline, so it was not added to your shell startup files." + say "Add it to your PATH yourself." + return + ;; + esac + + posix_dir="$(startup_path "$INSTALL_DIR" sh_quoted)" + fish_dir="$(startup_path "$INSTALL_DIR" fish_quoted)" # Guarded, so a shell that reads several of these files (Ubuntu's ~/.profile sources ~/.bashrc) # still ends up with one entry. # shellcheck disable=SC2016 # $PATH is meant to reach the startup file unexpanded. - posix_line="$(printf 'case ":${PATH}:" in *":%s:"*) ;; *) export PATH="%s:$PATH" ;; esac' "$dir" "$dir")" + posix_line="$(printf 'case ":${PATH}:" in *":%s:"*) ;; *) export PATH="%s:$PATH" ;; esac' "$posix_dir" "$posix_dir")" # shellcheck disable=SC2016 - fish_line="$(printf 'contains "%s" $PATH; or set -gx PATH "%s" $PATH' "$dir" "$dir")" + fish_line="$(printf 'contains "%s" $PATH; or set -gx PATH "%s" $PATH' "$fish_dir" "$fish_dir")" # Pairs of (startup file, line to add to it). while IFS= read -r rc; do @@ -179,7 +197,7 @@ add_install_dir_to_path() { else say "Open a new terminal to use ant, or run this to use it in the current one:" fi - say " export PATH=\"${INSTALL_DIR}:\$PATH\"" + say " export PATH=\"$(sh_quoted "$INSTALL_DIR"):\$PATH\"" } # Whether a version is eligible for a release channel. @@ -326,9 +344,10 @@ say "Extracting..." tar xzf "${TMPDIR}/${ARCHIVE}" -C "$TMPDIR" # Install binary -mkdir -p "$INSTALL_DIR" +mkdir -p -- "$INSTALL_DIR" # Absolute, so the PATH entry written below does not depend on where the installer was run from. -INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd)" +# CDPATH cleared, or `cd` into a relative directory found through it prints that directory too. +INSTALL_DIR="$(CDPATH='' cd -- "$INSTALL_DIR" && pwd)" cp "${TMPDIR}/${BINARY_NAME}-${VERSION}-${TARGET}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" chmod +x "${INSTALL_DIR}/${BINARY_NAME}" say "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}" @@ -353,7 +372,7 @@ case ":$PATH:" in say "" say "WARNING: ${INSTALL_DIR} is not in your PATH." say "Add it with:" - say " export PATH=\"${INSTALL_DIR}:\$PATH\"" + say " export PATH=\"$(sh_quoted "$INSTALL_DIR"):\$PATH\"" fi ;; esac From c8feea50d2b3c8cc629cf916bcde7e511dd2dfe9 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Tue, 27 Oct 2026 13:39:31 +0000 Subject: [PATCH 4/6] fix(install): refuse to put a directory with shell syntax in its name on PATH Escaping INSTALL_DIR in the startup lines (3211965) isn't enough on macOS. Login shells there run `eval $(path_helper -s)` from /etc/zprofile and /etc/profile, and path_helper prints every PATH entry inside double quotes without escaping. So a correctly written entry containing $(...) still runs, or is mangled, in every new login shell. The macOS install-test run showed it: zsh -lc had `.../odd quoted back\slash` on PATH, with the substitutions run and the quotes stripped. A directory whose name contains $, `, ", \ or a newline is now left out of every startup file. The installer prints a warning, plus an escaped `export PATH=` line for the current shell only. Every remaining character is literal inside the double quotes of the POSIX and fish lines, so the per-shell escaping is gone. The printed lines stay escaped. install-test now checks, on Linux and macOS: - such a name is refused, no startup file is written, the printed line resolves, and nothing runs, including through a macOS login shell; - a name with spaces, ', ( ), &, # and * is still added and resolves in every shell, through path_helper on macOS. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/install-test.yml | 93 ++++++++++++++++++++++-------- CHANGELOG.md | 6 +- install.sh | 58 +++++++++++-------- 3 files changed, 107 insertions(+), 50 deletions(-) diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml index 10372134..6454dada 100644 --- a/.github/workflows/install-test.yml +++ b/.github/workflows/install-test.yml @@ -54,10 +54,11 @@ jobs: done fresh bash -lc 'ant --version' - # INSTALL_DIR goes into the startup files as shell code, so a name with shell syntax in it - # must read back as that literal directory and run nothing. It gets its own HOME, and zsh and - # fish are installed so their lines are checked too. sh is dash. - - name: verify an install directory with shell syntax in its name is written literally + # Shells can run part of a PATH entry containing $, `, " or \ as code (macOS login shells + # re-read PATH through path_helper unescaped), so the installer must leave such a directory + # out of every startup file, and the line it prints instead must be safe to paste. zsh and + # fish are installed so their startup files are checked too. sh is dash. + - name: verify an install directory with shell syntax in its name is refused env: ANT_VERSION: ${{ inputs.version }} run: | @@ -69,22 +70,43 @@ jobs: dir="$home"'/odd $(touch $HOME/ran-subst) `touch $HOME/ran-backtick` "quoted" back\slash' out="$(fresh ANT_VERSION="$ANT_VERSION" INSTALL_DIR="$dir" bash install.sh)" echo "$out" - for shell in "bash -lc" "bash -ic" "sh -lc" "zsh -c" "fish -c"; do - # shellcheck disable=SC2086 # $shell is a shell and its flag, split on purpose. - found="$(fresh $shell 'command -v ant' 2> /dev/null || true)" - echo "$shell: ${found:-}" - [ "$found" = "$dir/ant" ] || exit 1 + if ! echo "$out" | grep -q "was not added to your shell startup files"; then + echo "ERROR: expected the installer to refuse this directory" + exit 1 + fi + for f in .profile .bashrc .zshenv .config/fish/conf.d/ant.fish; do + if [ -e "$home/$f" ]; then + echo "ERROR: $f was written" + exit 1 + fi done - # The line the installer prints for the current shell must be as safe to paste. line="$(echo "$out" | grep -m1 '^ export PATH=')" found="$(env -i HOME="$home" PATH=/usr/bin:/bin sh -c "$line; command -v ant" || true)" echo "printed line: ${found:-}" [ "$found" = "$dir/ant" ] || exit 1 if ls "$home"/ran-* 2> /dev/null; then - echo "ERROR: a shell ran code from the install directory's name" + echo "ERROR: code from the install directory's name ran" exit 1 fi + # Every other character is safe inside the double quotes of the lines written, so a name with + # spaces, quotes and other punctuation must still be added and resolve in each new shell. + - name: verify an install directory with other punctuation in its name is added + env: + ANT_VERSION: ${{ inputs.version }} + run: | + home="$RUNNER_TEMP/punct-home" + mkdir -p "$home" + fresh() { env -i HOME="$home" SHELL=/bin/bash PATH=/usr/bin:/bin "$@"; } + dir="$home/my apps (x86) & 'quoted' #1 *" + fresh ANT_VERSION="$ANT_VERSION" INSTALL_DIR="$dir" bash install.sh + for shell in "bash -lc" "bash -ic" "sh -lc" "zsh -c" "fish -c"; do + # shellcheck disable=SC2086 # $shell is a shell and its flag, split on purpose. + found="$(fresh $shell 'command -v ant' 2> /dev/null || true)" + echo "$shell: ${found:-}" + [ "$found" = "$dir/ant" ] || exit 1 + done + # A relative INSTALL_DIR is made absolute with `cd`, which also prints the directory when it # is found through CDPATH. - name: verify a relative install directory with CDPATH set @@ -154,10 +176,10 @@ jobs: done fresh zsh -c 'ant --version' - # As on Linux, a name with shell syntax in it must read back as that literal directory and run - # nothing, here with the stock /bin/bash running the installer and macOS's own sed. Login - # shells are checked for the directory on PATH, as above. - - name: verify an install directory with shell syntax in its name is written literally + # As on Linux, a name with shell syntax in it is refused and runs nothing, here with the stock + # /bin/bash running the installer and macOS's own sed. Login shells run path_helper, which is + # why such a name can't be put on PATH here at all. + - name: verify an install directory with shell syntax in its name is refused env: ANT_VERSION: ${{ inputs.version }} run: | @@ -168,6 +190,38 @@ jobs: dir="$home"'/odd $(touch $HOME/ran-subst) `touch $HOME/ran-backtick` "quoted" back\slash' out="$(fresh ANT_VERSION="$ANT_VERSION" INSTALL_DIR="$dir" /bin/bash install.sh)" echo "$out" + if ! echo "$out" | grep -q "was not added to your shell startup files"; then + echo "ERROR: expected the installer to refuse this directory" + exit 1 + fi + for f in .profile .bash_profile .zshenv; do + if [ -e "$home/$f" ]; then + echo "ERROR: $f was written" + exit 1 + fi + done + fresh zsh -lc true + fresh /bin/bash -lc true + line="$(echo "$out" | grep -m1 '^ export PATH=')" + found="$(env -i HOME="$home" PATH=/usr/bin:/bin /bin/sh -c "$line; command -v ant" || true)" + echo "printed line: ${found:-}" + [ "$found" = "$dir/ant" ] || exit 1 + if ls "$home"/ran-* 2> /dev/null; then + echo "ERROR: code from the install directory's name ran" + exit 1 + fi + + # A name with spaces, quotes and other punctuation must still be added, and come through + # path_helper intact in login shells. + - name: verify an install directory with other punctuation in its name is added + env: + ANT_VERSION: ${{ inputs.version }} + run: | + home="$RUNNER_TEMP/punct-home" + mkdir -p "$home" + fresh() { env -i HOME="$home" SHELL=/bin/zsh PATH=/usr/bin:/bin "$@"; } + dir="$home/my apps (x86) & 'quoted' #1 *" + fresh ANT_VERSION="$ANT_VERSION" INSTALL_DIR="$dir" /bin/bash install.sh found="$(fresh zsh -c 'command -v ant' || true)" echo "zsh -c: ${found:-}" [ "$found" = "$dir/ant" ] || exit 1 @@ -177,15 +231,6 @@ jobs: echo "$shell: $p" case ":$p:" in *":$dir:"*) ;; *) exit 1 ;; esac done - # The line the installer prints for the current shell must be as safe to paste. - line="$(echo "$out" | grep -m1 '^ export PATH=')" - found="$(env -i HOME="$home" PATH=/usr/bin:/bin /bin/sh -c "$line; command -v ant" || true)" - echo "printed line: ${found:-}" - [ "$found" = "$dir/ant" ] || exit 1 - if ls "$home"/ran-* 2> /dev/null; then - echo "ERROR: a shell ran code from the install directory's name" - exit 1 - fi test-install-script-windows: name: test install.ps1 (Windows) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18dc96db..edb15897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,8 +62,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 works in a new terminal straight after installing (#194). When `INSTALL_DIR` is not already on `PATH` it adds a guarded line to `~/.profile`, to the user's existing bash startup files, to `~/.zshenv` when zsh is installed and to fish's `conf.d` when fish is, and lists each file it - changed. The directory is escaped in those lines and in the `export PATH=...` line it prints, so - a name containing quotes, `$`, backticks or backslashes is added literally and never run. + changed. A directory whose name contains `$`, a backtick, `"`, `\` or a newline is not added, + because shells can run part of such a PATH entry as code (macOS login shells re-read `PATH` + through `path_helper` without escaping it). The installer says so and prints an escaped line + for the current shell instead. `--no-modify-path` or `ANT_NO_MODIFY_PATH=1` (also honoured by `install.ps1`) restores the old print-the-line behaviour. The macOS default install directory moves from `/usr/local/bin`, which is root-owned without Homebrew so the install failed without `sudo`, to diff --git a/install.sh b/install.sh index da6a29e3..444149cf 100755 --- a/install.sh +++ b/install.sh @@ -81,18 +81,27 @@ default_install_dir() { echo "$HOME/.local/bin" } -# $1 escaped to read back literally inside double quotes: \ " $ and ` for POSIX shells, and -# \ " and $ for fish, where ` is not special. sed rather than ${1//...}, whose replacement quoting -# differs between bash 3.2 and 5.2. +# $1 escaped to read back literally inside double quotes in a POSIX shell, for the `export PATH=` +# lines printed for the user to paste. sed rather than ${1//...}, whose replacement quoting differs +# between bash 3.2 and 5.2. sh_quoted() { printf '%s\n' "$1" | sed 's/[\\"$`]/\\&/g'; } -fish_quoted() { printf '%s\n' "$1" | sed 's/[\\"$]/\\&/g'; } -# $1 for use inside double quotes in a startup file, escaped by $2 (sh_quoted or fish_quoted), with -# a leading $HOME spelled as a literal `$HOME`, so the line keeps working if the home directory moves. -startup_path() { +# Whether $1 is unsafe to put on PATH from a startup file: shells can end up running part of a name +# containing $, `, " or \ as code, however the line is quoted. macOS login shells, for one, re-read +# PATH through `eval $(path_helper -s)`, and path_helper does not escape it. A newline would split +# the line, which is checked for line by line. +unsafe_on_path() { case "$1" in - "$HOME"/*) printf '%s\n' "\$HOME$("$2" "${1#"$HOME"}")" ;; - *) "$2" "$1" ;; + *'$'* | *'`'* | *'"'* | *\\* | *$'\n'*) return 0 ;; + *) return 1 ;; + esac +} + +# $1 with a leading $HOME spelled as a literal `$HOME`, for lines written into startup files. +home_relative() { + case "$1" in + "$HOME"/*) printf '%s\n' "\$HOME${1#"$HOME"}" ;; + *) printf '%s\n' "$1" ;; esac } @@ -140,27 +149,28 @@ append_startup_line() { # Put INSTALL_DIR on PATH for future shells, and tell the user what changed. add_install_dir_to_path() { - local posix_dir fish_dir posix_line fish_line rc i status + local dir posix_line fish_line rc i status local entries=() changed=() present=() failed=() - # Each startup line has to fit on one line, and is checked for line by line. - case "$INSTALL_DIR" in - *$'\n'*) - say "" - say "WARNING: ${INSTALL_DIR} contains a newline, so it was not added to your shell startup files." - say "Add it to your PATH yourself." - return - ;; - esac + if unsafe_on_path "$INSTALL_DIR"; then + say "" + say "WARNING: ${INSTALL_DIR} was not added to your shell startup files." + # shellcheck disable=SC2016 # The characters are listed, not expanded. + say 'Its name contains $, `, ", \ or a newline, which some shells would run as code: macOS login' + say "shells, for one, re-read PATH without escaping it. Install to a directory without them, or" + say "to use ant in the current shell only, run:" + say " export PATH=\"$(sh_quoted "$INSTALL_DIR"):\$PATH\"" + return + fi - posix_dir="$(startup_path "$INSTALL_DIR" sh_quoted)" - fish_dir="$(startup_path "$INSTALL_DIR" fish_quoted)" + dir="$(home_relative "$INSTALL_DIR")" # Guarded, so a shell that reads several of these files (Ubuntu's ~/.profile sources ~/.bashrc) - # still ends up with one entry. + # still ends up with one entry. unsafe_on_path has ruled out everything that is special inside + # double quotes, in POSIX shells and in fish. # shellcheck disable=SC2016 # $PATH is meant to reach the startup file unexpanded. - posix_line="$(printf 'case ":${PATH}:" in *":%s:"*) ;; *) export PATH="%s:$PATH" ;; esac' "$posix_dir" "$posix_dir")" + posix_line="$(printf 'case ":${PATH}:" in *":%s:"*) ;; *) export PATH="%s:$PATH" ;; esac' "$dir" "$dir")" # shellcheck disable=SC2016 - fish_line="$(printf 'contains "%s" $PATH; or set -gx PATH "%s" $PATH' "$fish_dir" "$fish_dir")" + fish_line="$(printf 'contains "%s" $PATH; or set -gx PATH "%s" $PATH' "$dir" "$dir")" # Pairs of (startup file, line to add to it). while IFS= read -r rc; do From e1648ce25f0e37d81969d0874bb5343bdc5cc510 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Tue, 27 Oct 2026 15:29:04 +0000 Subject: [PATCH 5/6] fix(install): refuse a colon in INSTALL_DIR for PATH, reject a newline up front PATH is colon-separated, so a directory with `:` in its name can't be one of its entries. A trailing `:` added an empty entry, which searches the current directory, and one in the middle added a second, different directory; in fish the line could re-add itself on every start. Such a directory is now left off PATH, with or without --no-modify-path. The installer says why and suggests running ant by its full path, and prints no `export PATH=` line. An INSTALL_DIR containing a newline is now rejected before anything is downloaded or created. Making the path absolute with `$(... pwd)` stripped a trailing newline, so ant could be copied into the directory without it and the install still reported success. install-test's Linux job gains a step covering a colon in the middle, a trailing colon and a colon with ANT_NO_MODIFY_PATH=1. Each must be refused, with no startup file written (fish's included) and no export line printed. The step also checks that an INSTALL_DIR ending in a newline, whose stripped directory exists, fails and installs nothing. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/install-test.yml | 40 ++++++++++++++++++++++++++++++ CHANGELOG.md | 9 ++++--- install.sh | 39 ++++++++++++++++++++++++----- 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml index 6454dada..4f316f18 100644 --- a/.github/workflows/install-test.yml +++ b/.github/workflows/install-test.yml @@ -107,6 +107,46 @@ jobs: [ "$found" = "$dir/ant" ] || exit 1 done + # PATH is colon-separated, so a name with `:` can't be a PATH entry at all: no startup file, + # fish's included, and no `export PATH=` line, with or without --no-modify-path. A newline is + # rejected before anything is installed; `pwd` would strip a trailing one, and the install + # would land in the directory without it. + - name: verify an install directory with a colon or a newline in its name is refused + env: + ANT_VERSION: ${{ inputs.version }} + run: | + home="$RUNNER_TEMP/colon-home" + mkdir -p "$home" + fresh() { env -i HOME="$home" SHELL=/bin/bash PATH=/usr/bin:/bin "$@"; } + for case in "$home/with:colon|" "$home/trailing:|" "$home/no-modify:path|1"; do + dir="${case%|*}" + out="$(fresh ANT_VERSION="$ANT_VERSION" INSTALL_DIR="$dir" ANT_NO_MODIFY_PATH="${case##*|}" bash install.sh)" + echo "$out" + if ! echo "$out" | grep -q "can't be added to your PATH"; then + echo "ERROR: expected the installer to refuse $dir" + exit 1 + fi + if echo "$out" | grep -q '^ export PATH='; then + echo "ERROR: the installer printed an export line for $dir" + exit 1 + fi + done + for f in .profile .bashrc .zshenv .config/fish/conf.d/ant.fish; do + if [ -e "$home/$f" ]; then + echo "ERROR: $f was written" + exit 1 + fi + done + mkdir -p "$home/nl" + if fresh ANT_VERSION="$ANT_VERSION" INSTALL_DIR="$home/nl"$'\n' bash install.sh; then + echo "ERROR: an INSTALL_DIR ending in a newline was accepted" + exit 1 + fi + if [ -e "$home/nl/ant" ] || [ -e "$home/nl"$'\n' ]; then + echo "ERROR: something was installed for an INSTALL_DIR ending in a newline" + exit 1 + fi + # A relative INSTALL_DIR is made absolute with `cd`, which also prints the directory when it # is found through CDPATH. - name: verify a relative install directory with CDPATH set diff --git a/CHANGELOG.md b/CHANGELOG.md index edb15897..76f5510a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,10 +62,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 works in a new terminal straight after installing (#194). When `INSTALL_DIR` is not already on `PATH` it adds a guarded line to `~/.profile`, to the user's existing bash startup files, to `~/.zshenv` when zsh is installed and to fish's `conf.d` when fish is, and lists each file it - changed. A directory whose name contains `$`, a backtick, `"`, `\` or a newline is not added, - because shells can run part of such a PATH entry as code (macOS login shells re-read `PATH` - through `path_helper` without escaping it). The installer says so and prints an escaped line - for the current shell instead. + changed. A directory whose name contains `$`, a backtick, `"` or `\` is not added, because + shells can run part of such a PATH entry as code (macOS login shells re-read `PATH` through + `path_helper` without escaping it). The installer says so and prints an escaped line for the + current shell instead. A name with `:`, which separates `PATH` entries, can't be added at all, + and an `INSTALL_DIR` containing a newline is rejected before anything is installed. `--no-modify-path` or `ANT_NO_MODIFY_PATH=1` (also honoured by `install.ps1`) restores the old print-the-line behaviour. The macOS default install directory moves from `/usr/local/bin`, which is root-owned without Homebrew so the install failed without `sudo`, to diff --git a/install.sh b/install.sh index 444149cf..7e679491 100755 --- a/install.sh +++ b/install.sh @@ -88,15 +88,31 @@ sh_quoted() { printf '%s\n' "$1" | sed 's/[\\"$`]/\\&/g'; } # Whether $1 is unsafe to put on PATH from a startup file: shells can end up running part of a name # containing $, `, " or \ as code, however the line is quoted. macOS login shells, for one, re-read -# PATH through `eval $(path_helper -s)`, and path_helper does not escape it. A newline would split -# the line, which is checked for line by line. +# PATH through `eval $(path_helper -s)`, and path_helper does not escape it. (A newline is rejected +# before anything is installed.) unsafe_on_path() { case "$1" in - *'$'* | *'`'* | *'"'* | *\\* | *$'\n'*) return 0 ;; + *'$'* | *'`'* | *'"'* | *\\*) return 0 ;; *) return 1 ;; esac } +# PATH is colon-separated, so a directory with `:` in its name can't be one of its entries: a +# trailing `:` adds an empty entry, which searches the current directory, and one in the middle +# adds a second, different directory. +has_colon() { + case "$1" in + *:*) return 0 ;; + *) return 1 ;; + esac +} + +say_colon_refusal() { + say "" + say "WARNING: ${INSTALL_DIR} can't be added to your PATH: ':' in its name separates PATH entries." + say "Install to a directory without it, or run ${INSTALL_DIR}/${BINARY_NAME} by its full path." +} + # $1 with a leading $HOME spelled as a literal `$HOME`, for lines written into startup files. home_relative() { case "$1" in @@ -152,13 +168,17 @@ add_install_dir_to_path() { local dir posix_line fish_line rc i status local entries=() changed=() present=() failed=() + if has_colon "$INSTALL_DIR"; then + say_colon_refusal + return + fi if unsafe_on_path "$INSTALL_DIR"; then say "" say "WARNING: ${INSTALL_DIR} was not added to your shell startup files." # shellcheck disable=SC2016 # The characters are listed, not expanded. - say 'Its name contains $, `, ", \ or a newline, which some shells would run as code: macOS login' - say "shells, for one, re-read PATH without escaping it. Install to a directory without them, or" - say "to use ant in the current shell only, run:" + say 'Its name contains $, `, " or \, which some shells would run as code: macOS login shells,' + say "for one, re-read PATH without escaping it. Install to a directory without them, or to use" + say "ant in the current shell only, run:" say " export PATH=\"$(sh_quoted "$INSTALL_DIR"):\$PATH\"" return fi @@ -334,6 +354,11 @@ TARGET="$(detect_target)" CHANNEL="${ANT_CHANNEL:-stable}" VERSION="${ANT_VERSION:-$(resolve_version "$CHANNEL")}" INSTALL_DIR="${INSTALL_DIR:-$(default_install_dir)}" +# Checked before anything is downloaded or created: making the path absolute below strips a +# trailing newline, which would install somewhere else, and no PATH line can hold one anyway. +case "$INSTALL_DIR" in + *$'\n'*) err "INSTALL_DIR must not contain a newline" ;; +esac if [ -n "${ANT_VERSION:-}" ]; then say "Installing ant ${VERSION} for ${TARGET}..." @@ -378,6 +403,8 @@ case ":$PATH:" in *) if [ "$MODIFY_PATH" = 1 ]; then add_install_dir_to_path + elif has_colon "$INSTALL_DIR"; then + say_colon_refusal else say "" say "WARNING: ${INSTALL_DIR} is not in your PATH." From 65a1f8727a4ec4ee307c88347993b2c9fe6349a3 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Wed, 28 Oct 2026 08:51:56 +0000 Subject: [PATCH 6/6] fix(install): reject a newline in the effective install path, not just INSTALL_DIR The newline check looked only at INSTALL_DIR as given. A relative one was resolved later against the current directory, whose own name could hold the newline: - With INSTALL_DIR=. run from a directory whose name ends in a newline, `$(cd ... && pwd)` stripped it, and ant went into the sibling without it, overwriting any ant there. - A newline in the middle of the current directory's path reached the PATH lines. The check now runs on the effective path before anything is downloaded or created: INSTALL_DIR itself if absolute, otherwise "$PWD/$INSTALL_DIR". $PWD keeps trailing newlines, where `$(pwd)` doesn't. Making the path absolute is also lossless now: `pwd && printf x`, then exactly "\nx" is cut. Even an unforeseen route can't change the destination silently. install-test's colon/newline step gains two cases. INSTALL_DIR=. is run from a directory ending in a newline with its sibling present, and INSTALL_DIR=bin from one with a newline in the middle. Each must fail and install nothing in either place. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/install-test.yml | 20 ++++++++++++++++++-- CHANGELOG.md | 3 ++- install.sh | 15 +++++++++++---- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml index 4f316f18..8cb888fc 100644 --- a/.github/workflows/install-test.yml +++ b/.github/workflows/install-test.yml @@ -109,12 +109,14 @@ jobs: # PATH is colon-separated, so a name with `:` can't be a PATH entry at all: no startup file, # fish's included, and no `export PATH=` line, with or without --no-modify-path. A newline is - # rejected before anything is installed; `pwd` would strip a trailing one, and the install - # would land in the directory without it. + # rejected before anything is installed, whether it is in INSTALL_DIR or, for a relative one, + # in the current directory; `pwd` would strip a trailing one, and the install would land in + # the directory without it. - name: verify an install directory with a colon or a newline in its name is refused env: ANT_VERSION: ${{ inputs.version }} run: | + installer="$PWD/install.sh" home="$RUNNER_TEMP/colon-home" mkdir -p "$home" fresh() { env -i HOME="$home" SHELL=/bin/bash PATH=/usr/bin:/bin "$@"; } @@ -146,6 +148,20 @@ jobs: echo "ERROR: something was installed for an INSTALL_DIR ending in a newline" exit 1 fi + # A relative INSTALL_DIR from a current directory whose name ends in a newline, with the + # directory without it present, and from one with a newline in the middle. + mkdir -p "$home/work" "$home/work"$'\n' "$home/a"$'\n'"b" + for cwd_dir in "$home/work"$'\n'"|." "$home/a"$'\n'"b|bin"; do + cwd="${cwd_dir%|*}" + if (cd "$cwd" && fresh ANT_VERSION="$ANT_VERSION" INSTALL_DIR="${cwd_dir##*|}" bash "$installer"); then + echo "ERROR: a relative INSTALL_DIR under a directory with a newline in its name was accepted" + exit 1 + fi + done + if [ -e "$home/work/ant" ] || [ -e "$home/work"$'\n'"/ant" ] || [ -e "$home/a"$'\n'"b/bin" ]; then + echo "ERROR: something was installed for a current directory with a newline in its name" + exit 1 + fi # A relative INSTALL_DIR is made absolute with `cd`, which also prints the directory when it # is found through CDPATH. diff --git a/CHANGELOG.md b/CHANGELOG.md index 76f5510a..f621224a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,7 +66,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 shells can run part of such a PATH entry as code (macOS login shells re-read `PATH` through `path_helper` without escaping it). The installer says so and prints an escaped line for the current shell instead. A name with `:`, which separates `PATH` entries, can't be added at all, - and an `INSTALL_DIR` containing a newline is rejected before anything is installed. + and an install directory containing a newline (in `INSTALL_DIR`, or in the current directory + for a relative one) is rejected before anything is installed. `--no-modify-path` or `ANT_NO_MODIFY_PATH=1` (also honoured by `install.ps1`) restores the old print-the-line behaviour. The macOS default install directory moves from `/usr/local/bin`, which is root-owned without Homebrew so the install failed without `sudo`, to diff --git a/install.sh b/install.sh index 7e679491..50ba66ea 100755 --- a/install.sh +++ b/install.sh @@ -354,10 +354,15 @@ TARGET="$(detect_target)" CHANNEL="${ANT_CHANNEL:-stable}" VERSION="${ANT_VERSION:-$(resolve_version "$CHANNEL")}" INSTALL_DIR="${INSTALL_DIR:-$(default_install_dir)}" -# Checked before anything is downloaded or created: making the path absolute below strips a -# trailing newline, which would install somewhere else, and no PATH line can hold one anyway. +# Checked before anything is downloaded or created, on the path as it will be used: a relative +# INSTALL_DIR is resolved against the current directory, whose own name could hold the newline. +# No PATH line can hold one. ($PWD, unlike `$(pwd)`, keeps a trailing newline.) case "$INSTALL_DIR" in - *$'\n'*) err "INSTALL_DIR must not contain a newline" ;; + /*) effective_install_dir="$INSTALL_DIR" ;; + *) effective_install_dir="$PWD/$INSTALL_DIR" ;; +esac +case "$effective_install_dir" in + *$'\n'*) err "the install directory must not contain a newline (in INSTALL_DIR, or in the current directory for a relative one)" ;; esac if [ -n "${ANT_VERSION:-}" ]; then @@ -382,7 +387,9 @@ tar xzf "${TMPDIR}/${ARCHIVE}" -C "$TMPDIR" mkdir -p -- "$INSTALL_DIR" # Absolute, so the PATH entry written below does not depend on where the installer was run from. # CDPATH cleared, or `cd` into a relative directory found through it prints that directory too. -INSTALL_DIR="$(CDPATH='' cd -- "$INSTALL_DIR" && pwd)" +# The `x` stops `$(...)` stripping newlines from the end of the path; only pwd's own is cut off. +INSTALL_DIR="$(CDPATH='' cd -- "$INSTALL_DIR" && pwd && printf x)" +INSTALL_DIR="${INSTALL_DIR%$'\n'x}" cp "${TMPDIR}/${BINARY_NAME}-${VERSION}-${TARGET}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" chmod +x "${INSTALL_DIR}/${BINARY_NAME}" say "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}"