Skip to content
Merged
1 change: 1 addition & 0 deletions bash/aliases
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ function update_kubectl {
echo "update_kubectl: SHA-256 verification failed" >&2
return 1
fi
mkdir -p "$HOME/.local/bin"
install -m 0755 "$tmpdir/kubectl" "$HOME/.local/bin/kubectl"
}

Expand Down
25 changes: 21 additions & 4 deletions bash/changelog
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,29 @@ if [ -n "$FROM_DATE" ]; then
fi

if [ -n "$FROM_DATE" ] || [ -n "$TO_DATE" ]; then
if [ -z "$to_commit" ]; then
echo "No change history in the requested range"
# SUR-2327: distinguish "flag not passed" from "flag passed but date
# matched no commit". The latter must NOT silently fall through to a
# default endpoint — that would expand the range to the opposite of
# what the user asked for (e.g. -t 1900-01-01 dumping full history).
if [ -n "$TO_DATE" ] && [ -z "$to_commit" ]; then
echo "No commits before $TO_DATE"
exit 0
else
::fromto "$from_commit" "$to_commit"
fi
if [ -n "$FROM_DATE" ] && [ -z "$from_commit" ]; then
echo "No commits on or after $FROM_DATE"
exit 0
fi
# SUR-2327: default each missing endpoint so -f and -t can be used
# independently. Pre-fix, -f alone hit the "No change history" branch
# (to_commit empty) and -t alone tripped ::fromto's `${1:?}` because
# from_commit was empty.
if [ -z "$to_commit" ]; then
to_commit=HEAD
fi
if [ -z "$from_commit" ]; then
from_commit=$(git::cmd rev-list --max-parents=0 HEAD | tail -n 1)
fi
::fromto "$from_commit" "$to_commit"
else
::full
fi
8 changes: 7 additions & 1 deletion bash/commands.sh
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@ function commands::use {
@doc Find the specified command on the PATH if available or error
@arg _1_ the base command name to find
local cmd=${1:?}
local var=_${cmd}
# Cache-key sanitisation: bash variable names cannot contain hyphens or
# dots, so commands like ssh-keygen or python3.12 must be remapped to
# _ssh_keygen / _python3_12 for the declare -g call. The lookup itself
# below keeps the original $cmd. Distinct names that collide after
# sanitisation (foo-bar vs foo_bar) would share a cache slot, but that
# is vanishingly unlikely on a real PATH.
local var=_${cmd//[^A-Za-z0-9_]/_}
if [ -z "${!var}" ]; then
local cmd_path
cmd_path=$(command -v "$cmd") || commands::err_not_found "$cmd"
Expand Down
34 changes: 26 additions & 8 deletions bash/log.sh
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,28 @@ function log::level() {
}

# Initialise the LOG_DISABLE_* flags from LOG_LEVEL at source time so
# scripts that don't pass -v still get deterministic gating. Skip if a
# caller has already pre-set any of the disable flags directly (so
# explicit pre-set values survive sourcing).
if [ -z "${LOG_DISABLE_INFO+set}" ] && [ -z "${LOG_DISABLE_DEBUG+set}" ] &&
[ -z "${LOG_DISABLE_WARNING+set}" ] && [ -z "${LOG_DISABLE_TRACE+set}" ]; then
log::level "$LOG_LEVEL"
fi
# scripts that don't pass -v still get deterministic gating. Each flag is
# initialised independently: SUR-2347 — pre-fix, if a caller pre-set any
# single flag (e.g. LOG_DISABLE_INFO=false) the entire block was skipped,
# leaving the other three unset (empty), and `[ "$LOG_DISABLE_TRACE" =
# "false" ]` evaluated false, silently disabling output. Snapshot the
# caller's explicit pre-sets, let log::level recompute defaults from
# LOG_LEVEL, then restore the snapshots so explicit values win.
__log_preset_trace=${LOG_DISABLE_TRACE+x}
__log_preset_debug=${LOG_DISABLE_DEBUG+x}
__log_preset_info=${LOG_DISABLE_INFO+x}
__log_preset_warning=${LOG_DISABLE_WARNING+x}
__log_value_trace=${LOG_DISABLE_TRACE-}
__log_value_debug=${LOG_DISABLE_DEBUG-}
__log_value_info=${LOG_DISABLE_INFO-}
__log_value_warning=${LOG_DISABLE_WARNING-}
log::level "$LOG_LEVEL"
[ "$__log_preset_trace" = x ] && LOG_DISABLE_TRACE=$__log_value_trace
[ "$__log_preset_debug" = x ] && LOG_DISABLE_DEBUG=$__log_value_debug
[ "$__log_preset_info" = x ] && LOG_DISABLE_INFO=$__log_value_info
[ "$__log_preset_warning" = x ] && LOG_DISABLE_WARNING=$__log_value_warning
unset __log_preset_trace __log_preset_debug __log_preset_info __log_preset_warning
unset __log_value_trace __log_value_debug __log_value_info __log_value_warning

function log::level_increase() {
@doc Increase the LOG_LEVEL
Expand Down Expand Up @@ -212,10 +227,13 @@ function log::_format() {
local date
date="$(date "$LOG_DATE_FORMAT")"
local formatted_log="$LOG_FORMAT"
formatted_log="${formatted_log/'%MESSAGE'/$log}"
# SUR-2331: substitute %MESSAGE last so any literal %LEVEL/%PID/%DATE
# text inside the user-supplied message does not get re-interpreted by
# the subsequent substitutions.
formatted_log="${formatted_log/'%LEVEL'/$level}"
formatted_log="${formatted_log/'%PID'/$pid}"
formatted_log="${formatted_log/'%DATE'/$date}"
formatted_log="${formatted_log/'%MESSAGE'/$log}"
printf '%s\n' "$formatted_log"
}
# Deprecated public name retained as a parens-only shim so the bare identifier
Expand Down
2 changes: 1 addition & 1 deletion bash/pretty-labels
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ options::add -o t -d "resource type to examine" -a -m -e RESOURCE_TYPE
options::parse "$@"
shift $((OPTIND - 1))

for line in $(k8s::ctl get "$RESOURCE_TYPE" --show-labels "$@" | awk '{print $1":"$NF}'); do
for line in $(k8s::ctl get "$RESOURCE_TYPE" --show-labels --no-headers "$@" | awk '{print $1":"$NF}'); do
resource_name=$(echo "$line" | awk -F: '{print $1}')
labels=$(echo "$line" | awk -F: '{print $NF}')
col1="$resource_name"
Expand Down
41 changes: 39 additions & 2 deletions bash/secret.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,50 @@ declare -g -A SECRETS
declare -g -A SECRETS_FILES
declare -g -a SECRET_TMPFILES
declare -g SECRET_TRAP_INSTALLED=${SECRET_TRAP_INSTALLED:-false}
declare -g SECRET_PREV_EXIT_TRAP=""
declare -g SECRET_PREV_INT_TRAP=""
declare -g SECRET_PREV_TERM_TRAP=""

function secret::_run_chained_trap {
@doc Internal - run secret::clear and afterwards run the caller \
pre-existing trap captured at install time for the given signal.
@arg _1_ signal name EXIT INT or TERM
local sig=${1:?}
secret::clear
local prev=
case "$sig" in
EXIT) prev=$SECRET_PREV_EXIT_TRAP ;;
INT) prev=$SECRET_PREV_INT_TRAP ;;
TERM) prev=$SECRET_PREV_TERM_TRAP ;;
*) return ;;
esac
if [ -n "$prev" ]; then
# trap -p SIG returns the literal text:
# trap -- <single-quoted-CMD> SIG
# Strip the wrapper, then unquote the CMD via eval-set-positional-args
# so embedded single quotes (escaped by bash as the standard
# close/escape/reopen sequence) round-trip cleanly.
local cmd_quoted=${prev#trap -- }
cmd_quoted=${cmd_quoted% "$sig"}
eval "set -- $cmd_quoted"
eval "$1"
fi
}

function secret::_install_cleanup_trap {
@doc Internal - install the EXIT/INT/TERM trap that calls secret::clear \
exactly once per shell. Must be invoked from the parent shell scope so \
the trap is in the right place. The registration helpers call this.
the trap is in the right place. The registration helpers call this. \
SUR-2324: captures any pre-existing caller trap on EXIT/INT/TERM so the \
chained handler runs the caller pre-existing trap after secret::clear \
instead of silently overwriting it.
if [ "$SECRET_TRAP_INSTALLED" != "true" ]; then
trap 'secret::clear' EXIT INT TERM
SECRET_PREV_EXIT_TRAP=$(trap -p EXIT)
SECRET_PREV_INT_TRAP=$(trap -p INT)
SECRET_PREV_TERM_TRAP=$(trap -p TERM)
trap 'secret::_run_chained_trap EXIT' EXIT
trap 'secret::_run_chained_trap INT' INT
trap 'secret::_run_chained_trap TERM' TERM
SECRET_TRAP_INSTALLED=true
fi
}
Expand Down
50 changes: 50 additions & 0 deletions tests/aliases.bats
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,56 @@ setup() {
[[ "$output" =~ "install -m 0755" ]]
}

# SUR-2330: update_kubectl must create ~/.local/bin if missing (peers
# update_eksctl/update_helm already do). Without this, install(1) hard-fails
# with "No such file or directory" on a fresh machine.
@test "update_kubectl creates ~/.local/bin before installing (SUR-2330)" {
run awk '/^function update_kubectl/,/^}$/' "$ALIASES"
[ "$status" -eq 0 ]
# Single quotes are intentional: the source contains the literal `$HOME`,
# not its expansion.
# shellcheck disable=SC2016
[[ "$output" == *'mkdir -p "$HOME/.local/bin"'* ]]
}

@test "update_kubectl succeeds when ~/.local/bin does not pre-exist (SUR-2330)" {
# Stub curl so the network/verify path resolves against a local fake
# "kubectl" payload, then run update_kubectl with HOME pointing at a
# tmpdir that lacks .local/bin and assert the binary is installed.
STUB_BIN=$(mktemp -d)
FAKE_KUBECTL="$STUB_BIN/kubectl-payload"
printf '#!/usr/bin/env bash\nexit 0\n' >"$FAKE_KUBECTL"
FAKE_HASH=$(sha256sum "$FAKE_KUBECTL" | awk '{print $1}')

cat >"$STUB_BIN/curl" <<EOF
#!/usr/bin/env bash
out=
while [ \$# -gt 0 ]; do
case "\$1" in
-o) out=\$2; shift 2 ;;
-fsSL|-fsS|-s|-S|-L|-f) shift ;;
*) url=\$1; shift ;;
esac
done
case "\$url" in
*kubectl.sha256) printf '%s' "$FAKE_HASH" >"\$out" ;;
*kubectl) cp "$FAKE_KUBECTL" "\$out" ;;
*) exit 1 ;;
esac
EOF
chmod +x "$STUB_BIN/curl"

# Confirm the isolated HOME does not already contain .local/bin.
[ ! -d "$HOME/.local/bin" ]

run env "PATH=$STUB_BIN:$PATH" KUBECTL_VERSION=v1.32.0 \
bash -c "source '$ALIASES'; update_kubectl"
[ "$status" -eq 0 ]
[ -x "$HOME/.local/bin/kubectl" ]

rm -rf "$STUB_BIN"
}

# ---------- get_latest_btp_branch (SUR-1928) ----------------------------------

@test "get_latest_btp_branch declares loop vars local (SUR-1928)" {
Expand Down
56 changes: 56 additions & 0 deletions tests/changelog.bats
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,62 @@ teardown() {
[ "$trailing" -eq 1 ]
}

# SUR-2327: -f and -t are documented as independent. Pre-fix, -f alone
# printed "No change history in the requested range" and -t alone hit
# ::fromto's `${1:?}` because from_commit was empty.
@test "changelog -f <past-date> alone produces a non-empty range up to HEAD (SUR-2327)" {
run bash -c "cd '$FIXTURE_DIR' && '$CHANGELOG' -f 1970-01-01"
[ "$status" -eq 0 ]
[[ "$output" == *"feat: post-tag change"* ]]
[[ "$output" != *"No change history in the requested range"* ]]
}

@test "changelog -t <future-date> alone produces a non-empty range from repo root (SUR-2327)" {
# 2050-01-01 is the future-but-parseable upper bound for git's approxidate
# parser. Pre-2026 we used 2999-01-01, but git rev-list's --before/--after
# silently misparse 2999 and return wrong sets, masking the SUR-2327
# follow-up bug ("flag passed but no match" must NOT silently expand).
run bash -c "cd '$FIXTURE_DIR' && '$CHANGELOG' -t 2050-01-01"
[ "$status" -eq 0 ]
[[ "$output" == *"feat: post-tag change"* ]]
[[ "$output" != *"No change history in the requested range"* ]]
}

@test "changelog -f and -t together produce a bounded range (SUR-2327)" {
run bash -c "cd '$FIXTURE_DIR' && '$CHANGELOG' -f 1970-01-01 -t 2050-01-01"
[ "$status" -eq 0 ]
[[ "$output" == *"feat: post-tag change"* ]]
[[ "$output" != *"No change history in the requested range"* ]]
}

# SUR-2327 follow-up: the SUR-2327 default-endpoint fix must not silently
# expand the range when the user passes -t with a date older than every
# commit. Pre-fix this branch defaulted to_commit=HEAD and dumped the
# full history — the opposite of what the user asked for.
@test "changelog -t <pre-history-date> reports no commits, not full history (SUR-2327)" {
run bash -c "cd '$FIXTURE_DIR' && '$CHANGELOG' -t 1900-01-01"
[ "$status" -eq 0 ]
[[ "$output" != *"feat: initial"* ]]
[[ "$output" != *"feat: second"* ]]
[[ "$output" != *"feat: third"* ]]
[[ "$output" != *"feat: post-tag change"* ]]
[[ "$output" == *"No commits before 1900-01-01"* ]]
}

# SUR-2327 follow-up: same disambiguation for -f with a date after HEAD.
# Pre-fix this branch defaulted from_commit to the repo root and dumped
# the full history. 2050-01-01 chosen instead of 2999-01-01 because git
# rev-list's date parser silently misparses 2999 and would mask the bug.
@test "changelog -f <post-history-date> reports no commits, not full history (SUR-2327)" {
run bash -c "cd '$FIXTURE_DIR' && '$CHANGELOG' -f 2050-01-01"
[ "$status" -eq 0 ]
[[ "$output" != *"feat: initial"* ]]
[[ "$output" != *"feat: second"* ]]
[[ "$output" != *"feat: third"* ]]
[[ "$output" != *"feat: post-tag change"* ]]
[[ "$output" == *"No commits on or after 2050-01-01"* ]]
}

# SUR-1939: count::fromto invocations to guarantee we don't run twice
# per tag iteration. We instrument by sourcing `bash/changelog`'s
# ::full + ::fromto definitions in a sub-shell that wraps ::fromto in
Expand Down
73 changes: 73 additions & 0 deletions tests/commands.bats
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,76 @@ setup() {
"
[ "$status" -ne 0 ]
}

# SUR-2342: bash variable names reject hyphens and dots, so the cache-key
# variable name must be sanitised. Pre-fix, `commands::use ssh-keygen`
# crashed with `declare: '_ssh-keygen=...': not a valid identifier`.
@test "commands::use resolves a hyphenated command name (SUR-2342)" {
STUB_BIN=$(mktemp -d)
cat >"$STUB_BIN/ssh-keygen" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
chmod +x "$STUB_BIN/ssh-keygen"
out=$(mktemp)
run env "PATH=$STUB_BIN:$PATH" bash -c "
source '$REPO_ROOT/bash/includer.sh'
@include commands
commands::use ssh-keygen >'$out'
"
rm -rf "$STUB_BIN"
[ "$status" -eq 0 ]
resolved=$(cat "$out")
rm -f "$out"
[[ "$resolved" == */ssh-keygen ]]
}

@test "commands::use resolves a dotted command name (SUR-2342)" {
STUB_BIN=$(mktemp -d)
cat >"$STUB_BIN/python3.12" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
chmod +x "$STUB_BIN/python3.12"
out=$(mktemp)
run env "PATH=$STUB_BIN:$PATH" bash -c "
source '$REPO_ROOT/bash/includer.sh'
@include commands
commands::use python3.12 >'$out'
"
rm -rf "$STUB_BIN"
[ "$status" -eq 0 ]
resolved=$(cat "$out")
rm -f "$out"
[[ "$resolved" == */python3.12 ]]
}

@test "commands::use caches under sanitised key and survives binary removal (SUR-2342)" {
# First call resolves and caches under _ssh_keygen. After the stub is
# deleted, a second call must still return the cached path rather than
# re-resolving. $() is a subshell so we redirect to a tempfile to keep
# the declare -g cache write in the same shell.
STUB_BIN=$(mktemp -d)
cat >"$STUB_BIN/ssh-keygen" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
chmod +x "$STUB_BIN/ssh-keygen"
first="$BATS_TEST_TMPDIR/first"
second="$BATS_TEST_TMPDIR/second"
rm -f "$first" "$second"
run env "PATH=$STUB_BIN:$PATH" bash -c "
source '$REPO_ROOT/bash/includer.sh'
@include commands
commands::use ssh-keygen >'$first'
rm -f '$STUB_BIN/ssh-keygen'
commands::use ssh-keygen >'$second'
[ \"\$_ssh_keygen\" = \"\$(cat '$first')\" ] || { echo 'cache key mismatch' >&2; exit 1; }
"
rm -rf "$STUB_BIN"
[ "$status" -eq 0 ]
p1=$(cat "$first")
p2=$(cat "$second")
[ "$p1" = "$p2" ]
[[ "$p1" == */ssh-keygen ]]
}
Loading
Loading