diff --git a/CHANGELOG.md b/CHANGELOG.md index 22e35c1..f124707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -https://github.com/beplus/be/compare/v0.8.0...main +https://github.com/beplus/be/compare/v0.9.0...main + +## [v0.9.0] (10/02/2026) + +Verify every download against the `SHA256SUMS` the beplus CLI release publishes beside its +tarballs, and extract only a match. A missing `SHA256SUMS`, a tarball it does not list or a +mismatch aborts with the URL and extracts nothing; until now a download's integrity rested on TLS +alone. Verifying uses `sha256sum`, or `shasum` where there is none. Both line forms the releases +use are read: ` ` from 2.x on, and v1.0.5's `: `. + +Detect the installed version by comparing `$BE_PREFIX/bin/beplus` with the downloads it was copied +from. With a 2.x CLI detection always came up empty, so `be prune` deleted the installed version +along with the rest. + +Stop `be uninstall` at end of input, deleting nothing, as if answered no. It used to ask again for +ever. + +Always download the `.tar.gz`. From major 4 on, `be` asked for a `.tar.xz`, which the beplus CLI +release does not publish; `--use-xz`, `--no-use-xz` and `BE_USE_XZ` are gone with it. ## [v0.8.0] (10/02/2026) diff --git a/bin/be b/bin/be index 925bd78..ed296ad 100755 --- a/bin/be +++ b/bin/be @@ -64,7 +64,7 @@ function n_grep() { # Setup and state # -VERSION="v0.8.0" +VERSION="v0.9.0" BE_PREFIX="${BE_PREFIX-/usr/local}" BE_PREFIX=${BE_PREFIX%/} @@ -83,18 +83,6 @@ BE_DOWNLOAD_MIRROR=${BE_DOWNLOAD_MIRROR:-https://github.com/beplus/cli/releases/ BE_DOWNLOAD_MIRROR=${BE_DOWNLOAD_MIRROR%/} readonly BE_DOWNLOAD_MIRROR -# Using xz instead of gzip is enabled by default, if xz compatibility checks pass. -# User may set BE_USE_XZ to 0 to disable, or set to anything else to enable. -# May also be overridden by command line flags. - -# Normalise external values to true/false -if [[ "${BE_USE_XZ}" = "0" ]]; then - BE_USE_XZ="false" -elif [[ -n "${BE_USE_XZ+defined}" ]]; then - BE_USE_XZ="true" -fi -# Not setting to readonly. Overriden by CLI flags, and update_xz_settings_for_version. - BE_MAX_REMOTE_MATCHES=${BE_MAX_REMOTE_MATCHES:-20} g_mirror_url=${BE_MIRROR} @@ -188,22 +176,6 @@ display_major_version() { echo "${version}" } -# -# Synopsis: update_xz_settings_for_version numeric-version -# Globals modified: -# - BE_USE_XZ -# - -function update_xz_settings_for_version() { - # tarballs in xz format were available in later version of iojs, but KISS and only use xz from v4. - if [[ "${BE_USE_XZ}" = "true" ]]; then - local major_version="$(display_major_version "$1")" - if [[ "${major_version}" -lt 4 ]]; then - BE_USE_XZ="false" - fi - fi -} - # # Synopsis: update_arch_settings_for_version numeric-version # Globals modified: @@ -327,7 +299,6 @@ Options: -a, --arch Override system architecture --all ls-remote displays all matches instead of last 20 --insecure Turn off certificate checking for https requests (may be needed from behind a proxy server) - --use-xz/--no-use-xz Override automatic detection of xz support and enable/disable use of xz compressed beplus CLI downloads. Aliases: @@ -383,28 +354,23 @@ display_be_version() { # # Synopsis: set_active_beplus -# Checks cached downloads for a binary matching the active beplus CLI. +# Checks cached downloads for the one the installed beplus CLI was copied from: activate copies +# /beplus//beplus to $BE_PREFIX/bin/beplus, so the two files are identical. # Globals modified: # - g_active_beplus # function set_active_beplus() { g_active_beplus= - local beplus_path="$(command -v beplus)" - if [[ -x "${beplus_path}" ]]; then - local installed_version=$(beplus --version) - installed_version=${installed_version#v} - for dir in "${CACHE_DIR}"/*/ ; do - local folder_name="${dir%/}" - folder_name="${folder_name##*/}" - if diff &> /dev/null \ - "${CACHE_DIR}/${folder_name}/${installed_version}/bin/beplus" \ - "${beplus_path}" ; then - g_active_beplus="${folder_name}/${installed_version}" - break - fi - done - fi + local cached + for cached in "${CACHE_DIR}"/*/*/beplus; do + # cmp stops at the first difference; diff may read both binaries (~110 MB each) whole. + if cmp -s "${cached}" "${BE_PREFIX}/bin/beplus"; then + cached="${cached#"${CACHE_DIR}/"}" + g_active_beplus="${cached%/beplus}" + break + fi + done } # @@ -638,18 +604,10 @@ install() { get_latest_resolved_version "$1" || return 2 version="${g_target_beplus}" [[ -n "${version}" ]] || abort "no version found for '$1'" - update_xz_settings_for_version "${version}" update_arch_settings_for_version "${version}" local dir="${CACHE_DIR}/${g_mirror_folder_name}/${version}" - # Note: decompression flags ignored with default Darwin tar which autodetects. - if test "$BE_USE_XZ" = "true"; then - local tarflag="-Jx" - else - local tarflag="-zx" - fi - if test -d "$dir"; then if [[ ! -e "$dir/be.lock" ]] ; then if [[ "$DOWNLOAD" == "false" ]] ; then @@ -664,6 +622,15 @@ install() { local url="$(tarball_url "$version")" is_ok "${url}" || abort "download preflight failed for '$version' (${url})" + # The release publishes SHA256SUMS beside its tarballs. Read this tarball's line before + # downloading it, and extract only a download that matches it. 2.x lists ` `, as + # sha256sum writes it; v1.0.5 lists `: `. + local sums_url="${url%/*}/SHA256SUMS" + local sums + sums="$(do_get_index "${sums_url}")" || abort "failed to download SHA256SUMS for $version (${sums_url})" + local checksum="$(echo "${sums}" | awk -v name="${url##*/}" '$2 == name { print $1; exit } $1 == name ":" { print $2; exit }')" + [[ -n "${checksum}" ]] || abort "no checksum for ${url##*/} in ${sums_url}" + log mkdir "$dir" mkdir -p "$dir" || abort "sudo required (or change ownership, or define BE_PREFIX)" touch "$dir/be.lock" @@ -671,15 +638,21 @@ install() { cd "${dir}" || abort "Failed to cd to ${dir}" log fetch "$url" - do_get "${url}" | tar "$tarflag" --no-same-owner -f - # --strip-components=1 - pipe_results=( "${PIPESTATUS[@]}" ) - if [[ "${pipe_results[0]}" -ne 0 ]]; then + local archive="${dir}/${url##*/}" + if ! do_get "${url}" > "${archive}"; then + rm -f "${archive}" abort "failed to download archive for $version" fi - if [[ "${pipe_results[1]}" -ne 0 ]]; then - abort "failed to extract archive for $version" - fi [ "$GET_SHOWS_PROGRESS" = "true" ] && erase_line + local download_checksum="$(display_sha256 "${archive}")" + if [[ "${download_checksum}" != "${checksum}" ]]; then + rm -f "${archive}" + abort "checksum mismatch for ${url}: ${sums_url} lists ${checksum}, the download is ${download_checksum}" + fi + tar -zx --no-same-owner -f "${archive}" # --strip-components=1 + local tar_status=$? + rm -f "${archive}" + [[ "${tar_status}" -eq 0 ]] || abort "failed to extract archive for $version" rm -f "$dir/be.lock" disable_pax_mprotect bin/beplus @@ -713,6 +686,23 @@ function do_get() { fi } +# +# Synopsis: display_sha256 file +# sha256sum where it exists, otherwise shasum, which macOS has. Both hash stdin rather than a +# named file: given a name with a backslash in it, they escape it and put a backslash before the +# hash. +# + +function display_sha256() { + local line + if command -v sha256sum &> /dev/null; then + line="$(sha256sum < "$1")" + else + line="$(shasum -a 256 < "$1")" + fi + echo "${line%% *}" +} + # # Synopsis: do_get_index [option...] url # Call curl or wget with combination of global and passed options, @@ -836,30 +826,6 @@ function is_ok() { fi } -# -# Synopsis: can_use_xz -# Test system to see if xz decompression is supported by tar. -# - -function can_use_xz() { - # Be conservative and only enable if xz is likely to work. Unfortunately we can't directly query tar itself. - # For research, see https://github.com/shadowspawn/nvh/issues/8 - local uname_s="$(uname -s)" - if [[ "${uname_s}" = "Linux" ]] && command -v xz &> /dev/null ; then - # tar on linux is likely to support xz if it is available as a command - return 0 - elif [[ "${uname_s}" = "Darwin" ]]; then - local macos_version="$(sw_vers -productVersion)" - local macos_major_version="$(echo "${macos_version}" | cut -d '.' -f 1)" - local macos_minor_version="$(echo "${macos_version}" | cut -d '.' -f 2)" - if [[ "${macos_major_version}" -gt 10 || "${macos_minor_version}" -gt 8 ]]; then - # tar on recent Darwin has xz support built-in - return 0 - fi - fi - return 2 # not supported -} - # # Synopsis: display_tarball_platform # @@ -926,10 +892,8 @@ function tarball_url() { # Artifact version (filename MUST NOT contain +suffix) local artifact_version="${version%%+*}" - local ext="gz" - [ "$BE_USE_XZ" = "true" ] && ext="xz" - - echo "${g_mirror_url}/v${url_version}/beplus-cli-v${artifact_version}-$(display_tarball_platform).tar.${ext}" + # The beplus CLI release publishes .tar.gz only. + echo "${g_mirror_url}/v${url_version}/beplus-cli-v${artifact_version}-$(display_tarball_platform).tar.gz" } # @@ -1217,7 +1181,8 @@ function delete_with_echo() { uninstall_installed() { while true; do - read -r -p "Do you wish to delete beplus CLI from ${BE_PREFIX}? " yn + # End of input answers no. + read -r -p "Do you wish to delete beplus CLI from ${BE_PREFIX}? " yn || yn="n" case $yn in [Yy]* ) break ;; [Nn]* ) exit ;; @@ -1432,8 +1397,6 @@ while [[ $# -ne 0 ]]; do -q|--quiet) set_quiet ;; -d|--download) DOWNLOAD="true" ;; --insecure) set_insecure ;; - --use-xz) BE_USE_XZ="true" ;; - --no-use-xz) BE_USE_XZ="false" ;; --latest) display_remote_versions latest; exit ;; --stable) display_remote_versions latest; exit ;; -a|--arch) shift; set_arch "$1";; # set arch and continue @@ -1452,11 +1415,6 @@ while [[ $# -ne 0 ]]; do shift done -if [[ -z "${BE_USE_XZ+defined}" ]]; then - BE_USE_XZ="true" # Default to using xz - can_use_xz || BE_USE_XZ="false" -fi - set -- "${unprocessed_args[@]}" if test $# -eq 0; then diff --git a/package-lock.json b/package-lock.json index e245e6e..c2194d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@beplus/be", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@beplus/be", - "version": "0.8.0", + "version": "0.9.0", "license": "MIT", "os": [ "!win32" diff --git a/package.json b/package.json index 2831648..691b8b0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@beplus/be", "description": "Interactively Manage beplus CLI Versions", - "version": "0.8.0", + "version": "0.9.0", "author": "Igor Lamos ", "homepage": "https://github.com/beplus/be", "bugs": "https://github.com/beplus/be", diff --git a/test/bin/run-all-tests b/test/bin/run-all-tests index 5dad75d..83ae2b5 100755 --- a/test/bin/run-all-tests +++ b/test/bin/run-all-tests @@ -1,7 +1,7 @@ #!/usr/bin/env bash BIN_DIRECTORY="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" -# We want to cover curl and wget especially, gz and xz and variety of OS a bonus. +# We want to cover curl and wget especially, and variety of OS a bonus. services=( ubuntu-wget ) # amazonlinux-curl archlinux-curl ubuntu-curl cd "$(dirname "${BIN_DIRECTORY}")" || exit 2 diff --git a/test/tests/checksums.bats b/test/tests/checksums.bats new file mode 100644 index 0000000..da12953 --- /dev/null +++ b/test/tests/checksums.bats @@ -0,0 +1,90 @@ +#!/usr/bin/env bats +# +# be extracts a download only when it matches its line in the SHA256SUMS the beplus CLI release +# publishes beside its tarballs. These install from a local mirror laid out like the release. + +load shared-functions +load '../../node_modules/bats-support/load' +load '../../node_modules/bats-assert/load' + + +function setup() { + unset_n_env + setup_tmp_prefix + setup_tmp_mirror 2.7.0 + RELEASE="${TMP_MIRROR_DIR}/v2.7.0" + TARBALL="beplus-cli-v2.7.0-$(tarball_platform).tar.gz" + CACHED="${BE_PREFIX}/be/versions/beplus/2.7.0" +} + + +function teardown() { + rm -rf "${TMP_PREFIX_DIR}" "${TMP_MIRROR_DIR}" +} + + +@test "a download that matches SHA256SUMS is installed, and its tarball removed" { + be 2.7.0 + output="$(beplus --version | cli_version)" + assert_equal "${output}" "2.7.0" + [ ! -e "${CACHED}/${TARBALL}" ] + [ ! -e "${CACHED}/be.lock" ] +} + + +@test "a download that matches a v1-style SHA256SUMS line is installed" { + # v1.0.5 published `: ` lines, without a trailing newline. + printf '%s: %s' "${TARBALL}" "$(cut -d ' ' -f 1 "${RELEASE}/SHA256SUMS")" > "${RELEASE}/SHA256SUMS" + + be 2.7.0 + output="$(beplus --version | cli_version)" + assert_equal "${output}" "2.7.0" +} + + +@test "a download under a path with a backslash in it is installed" { + # Given a file name with a backslash, GNU sha256sum and shasum escape it and put a backslash + # in front of the hash. + export BE_CACHE_PREFIX="${TMP_PREFIX_DIR}/back\\slash" + + be 2.7.0 + output="$(beplus --version | cli_version)" + assert_equal "${output}" "2.7.0" +} + + +@test "a download that does not match SHA256SUMS is not extracted" { + echo "0000000000000000000000000000000000000000000000000000000000000000 ${TARBALL}" > "${RELEASE}/SHA256SUMS" + + run be 2.7.0 + assert_failure + assert_output --partial "checksum mismatch for ${BE_MIRROR}/v2.7.0/${TARBALL}" + [ ! -e "${CACHED}/beplus" ] + [ ! -e "${CACHED}/${TARBALL}" ] + # Like a half-finished download, so the next install downloads it again. + [ -e "${CACHED}/be.lock" ] + [ ! -e "${BE_PREFIX}/bin/beplus" ] +} + + +@test "a download SHA256SUMS has no line for is not extracted" { + # The right checksum, but listed under another platform's tarball only. + echo "$(cut -d ' ' -f 1 "${RELEASE}/SHA256SUMS") beplus-cli-v2.7.0-aix-ppc64.tar.gz" > "${RELEASE}/SHA256SUMS" + + run be 2.7.0 + assert_failure + assert_output --partial "no checksum for ${TARBALL} in ${BE_MIRROR}/v2.7.0/SHA256SUMS" + [ ! -e "${CACHED}/beplus" ] + [ ! -e "${BE_PREFIX}/bin/beplus" ] +} + + +@test "a release without SHA256SUMS is not extracted" { + rm "${RELEASE}/SHA256SUMS" + + run be 2.7.0 + assert_failure + assert_output --partial "failed to download SHA256SUMS for 2.7.0 (${BE_MIRROR}/v2.7.0/SHA256SUMS)" + [ ! -e "${CACHED}/beplus" ] + [ ! -e "${BE_PREFIX}/bin/beplus" ] +} diff --git a/test/tests/prune.bats b/test/tests/prune.bats new file mode 100644 index 0000000..f1d2086 --- /dev/null +++ b/test/tests/prune.bats @@ -0,0 +1,38 @@ +#!/usr/bin/env bats +# +# `be prune` removes every downloaded version except the installed one, so it has to recognise +# the installed one: activate copies /beplus//beplus to $BE_PREFIX/bin/beplus. + +load shared-functions +load '../../node_modules/bats-support/load' +load '../../node_modules/bats-assert/load' + + +function setup() { + unset_n_env + setup_tmp_prefix + # KISS and make the downloads by hand rather than do actual installs: a stub beplus that + # prints its version banner, as 2.x does. + local version + for version in 2.5.0 2.6.0 2.7.0; do + mkdir -p "${BE_PREFIX}/be/versions/beplus/${version}" + printf '#!/bin/sh\necho "beplus CLI v%s"\n' "${version}" > "${BE_PREFIX}/be/versions/beplus/${version}/beplus" + chmod +x "${BE_PREFIX}/be/versions/beplus/${version}/beplus" + done +} + + +function teardown() { + rm -rf "${TMP_PREFIX_DIR}" +} + + +@test "be prune keeps the installed version" { + be 2.6.0 + + be prune + output="$(be ls)" + assert_equal "${output}" "beplus/2.6.0" + output="$(beplus --version | cli_version)" + assert_equal "${output}" "2.6.0" +} diff --git a/test/tests/shared-functions.bash b/test/tests/shared-functions.bash index 82ff9e1..2a18844 100644 --- a/test/tests/shared-functions.bash +++ b/test/tests/shared-functions.bash @@ -34,6 +34,57 @@ function setup_tmp_prefix() { export PATH="${BE_PREFIX}/bin:${PATH}" } + +# Create a local mirror laid out as the beplus CLI release publishes one, and point be at it: +# v/ holds this machine's tarball, with a stub beplus that prints the version banner, +# and SHA256SUMS beside it. The mirror is a file:// URL, which be reads with curl only (wget +# fetches http, https and ftp), so without curl the test is skipped. +# Globals: +# TMP_MIRROR_DIR +# BE_MIRROR + +function setup_tmp_mirror() { + command -v curl > /dev/null || skip "the local mirror is a file:// URL, which needs curl" + local version="$1" + TMP_MIRROR_DIR="$(mktemp -d)" + [ -d "${TMP_MIRROR_DIR}" ] || exit 2 + export TMP_MIRROR_DIR + + local release="${TMP_MIRROR_DIR}/v${version}" + local tarball="beplus-cli-v${version}-$(tarball_platform).tar.gz" + mkdir -p "${release}" "${TMP_MIRROR_DIR}/stub" + printf '#!/bin/sh\necho "beplus CLI v%s"\n' "${version}" > "${TMP_MIRROR_DIR}/stub/beplus" + chmod +x "${TMP_MIRROR_DIR}/stub/beplus" + tar -czf "${release}/${tarball}" -C "${TMP_MIRROR_DIR}/stub" beplus + if command -v sha256sum &> /dev/null; then + (cd "${release}" && sha256sum "${tarball}" > SHA256SUMS) + else + (cd "${release}" && shasum -a 256 "${tarball}" > SHA256SUMS) + fi + + export BE_MIRROR="file://${TMP_MIRROR_DIR}" +} + +# +# @todo Duplicate +# Synopsis: tarball_platform +# The platform in a release tarball's name, as be picks it for this machine. +# + +function tarball_platform() { + local os arch + case "$(uname -s)" in + Linux) os="linux" ;; + Darwin) os="macos" ;; + esac + case "$(uname -m)" in + x86_64) arch="x64" ;; + aarch64 | armv8l) arch="arm64" ;; + *) arch="$(uname -m)" ;; + esac + echo "${os}-${arch}" +} + # # Synopsis: beplus --version | cli_version # The version a beplus binary reports, without the leading v. 2.x prints a banner whose first diff --git a/test/tests/tarball-format.bats b/test/tests/tarball-format.bats new file mode 100644 index 0000000..13033b5 --- /dev/null +++ b/test/tests/tarball-format.bats @@ -0,0 +1,27 @@ +#!/usr/bin/env bats +# +# The beplus CLI release publishes .tar.gz only, whatever the major version. be used to ask for +# .tar.xz from major 4 on, a rule inherited from Node's downloads, and would have found nothing. + +load shared-functions +load '../../node_modules/bats-support/load' +load '../../node_modules/bats-assert/load' + + +function setup() { + unset_n_env + setup_tmp_prefix + setup_tmp_mirror 4.0.0 +} + + +function teardown() { + rm -rf "${TMP_PREFIX_DIR}" "${TMP_MIRROR_DIR}" +} + + +@test "be 4.0.0 downloads the .tar.gz" { + be 4.0.0 + output="$(beplus --version | cli_version)" + assert_equal "${output}" "4.0.0" +} diff --git a/test/tests/uninstall.bats b/test/tests/uninstall.bats index 96266d0..31d4067 100644 --- a/test/tests/uninstall.bats +++ b/test/tests/uninstall.bats @@ -36,3 +36,15 @@ function teardown() { output="$(find "${BE_PREFIX}" -not -type d)" assert_equal "$output" "" } + + +@test "be uninstall at end of input deletes nothing" { + mkdir -p "${BE_PREFIX}/bin" + touch "${BE_PREFIX}/bin/beplus" + + # It used to ask again for ever; head ends that loop rather than hang the suite. + run bash -c 'set -o pipefail; be uninstall < /dev/null | head -n 1' + assert_success + assert_output "" + [ -f "${BE_PREFIX}/bin/beplus" ] +}