ci/woodpecker/pr/ci Pipeline is pending approval
Answers the review on #1228. Each item below was measured against the pre-change code, and where the review's stated consequence did not reproduce, that is recorded rather than repeated. BLOCKER -- `mapfile` is a Bash 4 builtin and macOS ships Bash 3.2, which this installer supports (node_platform names Darwin). newest_matching_file was therefore unavailable on macOS, and an empty answer is exactly what sends the uninstaller down its delete-the-destination branch. The lookup no longer renders candidates as text at all: the glob output is compared in-shell by mtime, via a stat helper that probes for GNU -c vs BSD -f once. That removes the Bash 4 dependency, the `ls | head` SIGPIPE failure, and the newline-splitting bug together, because all three came from turning filenames into lines. The function now distinguishes three outcomes instead of two: found, nothing matched, and could-not-tell. Callers act destructively on the answer, so the third case had to stop being indistinguishable from the second. The uninstaller leaves the file in place on an unanswerable lookup, and the manifest builder refuses to record a null backup it cannot vouch for. HIGH -- writing ~/.profile does not reach the shells that matter. A bash login shell reads the first of .bash_profile / .bash_login / .profile that exists and never looks at the rest, so on a host with either of the first two the entry was a silent no-op; a non-interactive remote zsh reads .zshenv and neither .zprofile nor .zshrc, which is what the previous version wrote; and a systemd --user unit reads no shell file at all, which is how a Mosaic agent seat starts. All four are now covered, with .bash_profile and .bash_login appended to only when they already exist -- creating one would itself start shadowing .profile. The systemd case is an environment.d drop-in. MEDIUM -- the checksum lookup interpolated the filename into a grep pattern. A Node tarball name is mostly dots, and a dot matches any character, so a manifest line for a different-but-regex-equivalent name was accepted as this file's checksum. Confirmed against the old function: it accepted the decoy. Filenames are now compared exactly, every line is read so a duplicate entry is refused rather than silently resolved, and the digest must look like a SHA-256. MEDIUM -- the PATH line is executed by every future shell that reads the file, and the directory was interpolated unescaped. A path containing shell syntax is now refused with a message instead of written. MEDIUM -- the idempotence check was an unanchored substring match, so a commented-out example of the same export made the installer skip the real entry. Reproduced against the old function, and now anchored with grep -Fqx. MEDIUM -- MOSAIC_NODE_DIST accepted any scheme. https:// and file:// only. The narrower point in the review stands and is not fixed by this: when the dist is overridden, the tarball and the checksum that vouches for it come from the same place, so the gate is integrity and not authenticity. HIGH, with a correction -- MOSAIC_NODE_VERSION is now validated before it becomes a path, but the review's specific consequence does not reproduce. `rm -rf` on a path ending in `..` is refused by rm itself, and a traversal version mangles the download URL so the run dies at curl long before the removal. Both were measured. The check is defence in depth and a clearer error, not a demonstrated hole being closed. Also removed a second `| head -1` in node_resolve_version, the same SIGPIPE shape as the one this PR already fixed, and the index result is validated before it becomes a path. Tests. The review was right that several existing cases passed on the unpatched code. The version-selection case now lists a higher major first and an older release of the right major after the right answer, so "first entry" and "last match" both fail it. The PATH case starts a real login shell and asks it to resolve node, rather than grepping for text the installer just wrote. The checksum-failure case asserts nothing survives, including the staging directory. New cases cover the empty manifest, the regex-equivalent decoy, the duplicate entry, the invalid version, the non-https dist, the shell-syntax path, the commented-out profile line, the .bash_profile shadow, and the environment.d drop-in. Each new case was run against the pre-change installer: the decoy, the commented-out line, the .bash_profile shadow and environment.d all go red there, which is the evidence that they test something. Bash 3.2 cannot be executed here, so the portability guard is a lint over install.sh for Bash 4 syntax. It is a weaker instrument than a run and is not claimed otherwise -- but every Bash 4 construct that has broken macOS in this file was added by someone who was not running it there either. test:installer passes.
138 lines
5.4 KiB
Bash
Executable File
138 lines
5.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Tests for newest_matching_file() in tools/install.sh.
|
|
#
|
|
# The function answers one question -- "which is the most recent backup / tarball
|
|
# here?" -- and its callers act destructively on the answer. Three ways of getting it
|
|
# wrong have already been found, and each has a case below:
|
|
#
|
|
# * `ls -1t | head -1` returns 141 under `set -o pipefail` once the listing fills a
|
|
# pipe buffer (~1600 names), because head closes the pipe and ls takes SIGPIPE.
|
|
# Callers assign it at top level under `set -e`, so a 141 aborts the run.
|
|
# * `mapfile` is a Bash 4 builtin. macOS ships Bash 3.2 and the installer supports
|
|
# Darwin, so the whole lookup was unavailable there -- and an empty answer is what
|
|
# sends the uninstaller down its delete-the-destination branch.
|
|
# * Any line-based parse of `ls` splits a filename containing a newline into two
|
|
# wrong answers.
|
|
#
|
|
# The large-population and newline cases are the point: with two or three ordinary
|
|
# names every version of this function passes, which is why the first two went
|
|
# unnoticed.
|
|
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-newest-match-test-XXXXXX")"
|
|
trap 'rm -rf "$TMP"' EXIT
|
|
|
|
# Load the function under test and the mtime helper it depends on, with the same
|
|
# shell options install.sh runs under.
|
|
eval "$(sed -n '/^_MTIME_STYLE=/,/^}/p' "$ROOT/tools/install.sh")"
|
|
eval "$(sed -n '/^newest_matching_file()/,/^}/p' "$ROOT/tools/install.sh")"
|
|
|
|
POPULATED="$TMP/many"
|
|
mkdir -p "$POPULATED"
|
|
# Enough names to overflow a 64 KiB pipe buffer several times over.
|
|
for i in $(seq 1 5000); do
|
|
: > "$POPULATED/mosaicstack-mosaic-0.0.${i}.tgz"
|
|
done
|
|
sleep 1
|
|
: > "$POPULATED/mosaicstack-mosaic-9.9.9.tgz"
|
|
|
|
echo "[test] the newest match is returned from a directory large enough to fill a pipe"
|
|
GOT="$(newest_matching_file "$POPULATED" 'mosaicstack-mosaic-*.tgz')"
|
|
[[ "$(basename "$GOT")" == "mosaicstack-mosaic-9.9.9.tgz" ]] || {
|
|
echo "expected the newest tarball, got '${GOT}'" >&2
|
|
exit 1
|
|
}
|
|
|
|
echo "[test] a large population does not make the lookup fail"
|
|
set +e
|
|
newest_matching_file "$POPULATED" 'mosaicstack-mosaic-*.tgz' >/dev/null
|
|
RC=$?
|
|
set -e
|
|
[[ "$RC" -eq 0 ]] || { echo "expected rc=0, got ${RC} (141 means the SIGPIPE regression is back)" >&2; exit 1; }
|
|
|
|
echo "[test] a small population still works"
|
|
SMALL="$TMP/few"
|
|
mkdir -p "$SMALL"
|
|
: > "$SMALL/mosaicstack-gateway-0.0.1.tgz"
|
|
sleep 1
|
|
: > "$SMALL/mosaicstack-gateway-0.0.2.tgz"
|
|
GOT="$(newest_matching_file "$SMALL" 'mosaicstack-gateway-*.tgz')"
|
|
[[ "$(basename "$GOT")" == "mosaicstack-gateway-0.0.2.tgz" ]] || {
|
|
echo "expected the newer gateway tarball, got '${GOT}'" >&2
|
|
exit 1
|
|
}
|
|
|
|
echo "[test] a name containing a space is returned whole"
|
|
SPACED="$TMP/spaced"
|
|
mkdir -p "$SPACED"
|
|
: > "$SPACED/agents.md.mosaic-bak-one two"
|
|
GOT="$(newest_matching_file "$SPACED" 'agents.md.mosaic-bak-*')"
|
|
[[ "$GOT" == "$SPACED/agents.md.mosaic-bak-one two" ]] || {
|
|
echo "expected the spaced name intact, got '${GOT}'" >&2
|
|
exit 1
|
|
}
|
|
|
|
echo "[test] a name containing a newline is returned whole, not split"
|
|
# The old `ls -1t` parse reported this file as two separate shorter names, neither of
|
|
# which exists -- so the caller saw a backup path that could not be restored.
|
|
NEWLINE="$TMP/newline"
|
|
mkdir -p "$NEWLINE"
|
|
WEIRD="$NEWLINE/agents.md.mosaic-bak-$(printf 'a\nb')"
|
|
: > "$WEIRD"
|
|
GOT="$(newest_matching_file "$NEWLINE" 'agents.md.mosaic-bak-*')"
|
|
[[ "$GOT" == "$WEIRD" ]] || {
|
|
echo "expected the newline-containing name intact, got '${GOT}'" >&2
|
|
exit 1
|
|
}
|
|
[[ -f "$GOT" ]] || { echo "the returned path does not name a real file" >&2; exit 1; }
|
|
|
|
echo "[test] no match is an empty answer, not an error"
|
|
EMPTY="$TMP/none"
|
|
mkdir -p "$EMPTY"
|
|
set +e
|
|
GOT="$(newest_matching_file "$EMPTY" 'nothing-*.tgz')"
|
|
RC=$?
|
|
set -e
|
|
[[ "$RC" -eq 0 && -z "$GOT" ]] || { echo "expected empty output and rc=0, got '${GOT}' rc=${RC}" >&2; exit 1; }
|
|
|
|
echo "[test] a directory that does not exist is an empty answer, not an error"
|
|
set +e
|
|
GOT="$(newest_matching_file "$TMP/absent" 'nothing-*.tgz')"
|
|
RC=$?
|
|
set -e
|
|
[[ "$RC" -eq 0 && -z "$GOT" ]] || { echo "expected empty output and rc=0, got '${GOT}' rc=${RC}" >&2; exit 1; }
|
|
|
|
echo "[test] an unanswerable lookup fails loudly instead of reporting no match"
|
|
# This is the distinction the uninstaller depends on. "No backup exists" is licence to
|
|
# delete the destination; "I could not tell" must never reach that branch.
|
|
_MTIME_STYLE=none
|
|
set +e
|
|
GOT="$(newest_matching_file "$SMALL" 'mosaicstack-gateway-*.tgz')"
|
|
RC=$?
|
|
set -e
|
|
_MTIME_STYLE=""
|
|
[[ "$RC" -ne 0 ]] || {
|
|
echo "expected a non-zero rc when no mtime source is usable, got rc=0 output '${GOT}'" >&2
|
|
exit 1
|
|
}
|
|
|
|
echo "[test] the installer uses no Bash 4 syntax"
|
|
# A lint, not an execution test: this host has no Bash 3.2 to run under. It is still
|
|
# the thing that stops the regression, because every Bash 4 construct that has broken
|
|
# macOS here was introduced by someone who never ran the script there either.
|
|
# Comments are stripped first -- the ones above name these constructs on purpose.
|
|
BASH4_HITS="$(
|
|
sed 's/#.*$//' "$ROOT/tools/install.sh" \
|
|
| grep -nE '(^|[^[:alnum:]_])(mapfile|readarray)([^[:alnum:]_]|$)|declare[[:space:]]+-[a-zA-Z]*A|local[[:space:]]+-[a-zA-Z]*A|\$\{[A-Za-z_][A-Za-z0-9_]*(\^\^|,,)' \
|
|
|| true
|
|
)"
|
|
[[ -z "$BASH4_HITS" ]] || {
|
|
echo "tools/install.sh uses Bash 4+ syntax, which macOS's Bash 3.2 cannot run:" >&2
|
|
echo "$BASH4_HITS" >&2
|
|
exit 1
|
|
}
|
|
|
|
echo "[test] newest_matching_file tests passed"
|