mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-06 12:57:16 +00:00
## Summary - query `transmissions.from_pubkey`, the column present in CoreScope's real schema, instead of the nonexistent `from_node` - preserve the existing SQLite parameter binding and stdin transport - derive the unit-test table from the ingestor's committed `CREATE TABLE` definition rather than a hand-written schema - add positive, negative, case-normalization, injection-shaped input and missing-column coverage - verify the query against the committed staging-captured E2E fixture ## Why The parameter-binding change correctly removed SQL interpolation, but its count query and test fixture both used `from_node`. The production schema defines `transmissions.from_pubkey`; therefore the live QA probe could only fail with `no such column: from_node`, while the synthetic unit fixture remained green. The ingestor stores attributed ADVERT pubkeys as lowercase hex. The query now uses: ```sql SELECT COUNT(*) FROM transmissions WHERE from_pubkey = lower(:pubkey); ``` The value remains a bound parameter. No production schema or runtime code changes. ## Verification - `bash -n qa/scripts/blacklist-test.sh` - `bash -n qa/scripts/test-blacklist-sql.sh` - `bash qa/scripts/test-blacklist-sql.sh` — 72 passed, 0 failed - schema fixture extracted directly from `cmd/ingestor/db.go` - committed E2E fixture returns the same attributed-row count through the helper query and a direct control query - legacy fixture containing only `from_node` fails non-zero with `no such column: from_pubkey` - injection-shaped, empty, whitespace, multibyte and long values remain literal bound values - `git diff --check` This PR intentionally contains only the schema correction and its regression coverage. Co-authored-by: Openclaw <openclaw@Openclaws-Mac-mini.local> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
400 lines
17 KiB
Bash
Executable File
400 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# blacklist-test.sh — verify nodeBlacklist hides a pubkey from API surface
|
|
# while retaining its packets in the DB. Implements QA plan §10.1 + §10.2.
|
|
#
|
|
# Usage:
|
|
# blacklist-test.sh BASELINE_URL TARGET_URL
|
|
#
|
|
# BASELINE_URL is currently unused for assertions but kept as a positional
|
|
# arg for parity with other qa-suite scripts (always called with two URLs).
|
|
#
|
|
# Required env (target host control + test data):
|
|
# TEST_NODE_PUBKEY — hex pubkey of a real, currently-visible node on TARGET_URL
|
|
# TARGET_SSH_HOST — e.g. runner@example
|
|
# TARGET_SSH_KEY — path to ssh private key (default: /root/.ssh/id_ed25519)
|
|
# TARGET_CONFIG_PATH — absolute path to config.json on the target
|
|
# TARGET_CONTAINER — docker container name on the target
|
|
# Optional env:
|
|
# TARGET_DB_PATH — sqlite db path on the target (for §10.2 sqlite probe)
|
|
# ADMIN_API_TOKEN — if /api/admin/transmissions exists, use it instead of ssh+sqlite
|
|
# (read from env, not argv — never appears in ps)
|
|
# CURL_TIMEOUT — per-request curl timeout, seconds (default 60)
|
|
# RESTART_WAIT_S — max wait for /api/stats after restart (default 120)
|
|
#
|
|
# Distinguishes:
|
|
# ssh-failed → cannot reach/control target
|
|
# restart-stuck → /api/stats not 200 within RESTART_WAIT_S
|
|
# hide-failed → blacklisted pubkey still surfaced via API (§10.1 fail)
|
|
# retain-failed → no transmissions.from_pubkey rows (ADVERTs) for the
|
|
# blacklisted pubkey in the DB (§10.2 fail), or the
|
|
# §10.2 probe could not run at all — no sqlite3 on the target
|
|
# able to bind a parameter. The message names what is needed;
|
|
# there is no fallback to interpolated SQL.
|
|
# teardown-failed→ post-test removal did not restore listing
|
|
#
|
|
# Exit code = number of failures (0 = pass).
|
|
# PUBLIC repo: zero PII — no real pubkeys, IPs, or hostnames as defaults.
|
|
#
|
|
# Structure: helpers live at top level and the imperative body lives in main(),
|
|
# so test-blacklist-sql.sh can source this file and exercise individual helpers
|
|
# without running the suite. Same idiom as scripts/staging/disk-monitor.sh.
|
|
|
|
set -uo pipefail
|
|
|
|
SSH_OPTS=() # populated by main() from TARGET_SSH_KEY
|
|
ssh_t() { ssh "${SSH_OPTS[@]}" "$TARGET_SSH_HOST" "$@"; }
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Teardown — MANDATORY in all exit paths.
|
|
# -----------------------------------------------------------------------------
|
|
teardown() {
|
|
local rc=$?
|
|
if [[ "$TEARDOWN_DONE" == "1" ]]; then rm -rf "$TMP"; exit "$rc"; fi
|
|
TEARDOWN_DONE=1
|
|
echo "=== teardown: removing $TEST_PUBKEY from nodeBlacklist ==="
|
|
if remove_from_blacklist && restart_target && wait_for_stats; then
|
|
if node_visible; then
|
|
echo " ✅ teardown ok — node returned to listings"
|
|
else
|
|
echo " ❌ teardown-failed: node still hidden after removal"
|
|
rc=$((rc + 1))
|
|
fi
|
|
else
|
|
echo " ❌ teardown-failed: could not restore config / restart / stats"
|
|
rc=$((rc + 1))
|
|
fi
|
|
rm -rf "$TMP"
|
|
exit "$rc"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Helpers
|
|
# -----------------------------------------------------------------------------
|
|
fetch_code() {
|
|
local url="$1" out="$2"
|
|
curl -s -m "$CURL_TIMEOUT" -o "$out" -w "%{http_code}" "$url" 2>/dev/null || echo "000"
|
|
}
|
|
|
|
wait_for_stats() {
|
|
local deadline code
|
|
echo " waiting up to ${RESTART_WAIT_S}s for $TARGET_URL/api/stats ..."
|
|
deadline=$(( $(date +%s) + RESTART_WAIT_S ))
|
|
while (( $(date +%s) < deadline )); do
|
|
code=$(fetch_code "$TARGET_URL/api/stats" "$TMP/stats.json")
|
|
if [[ "$code" == "200" ]]; then echo " stats OK"; return 0; fi
|
|
sleep 3
|
|
done
|
|
echo " ❌ restart-stuck: /api/stats never returned 200"
|
|
return 1
|
|
}
|
|
|
|
restart_target() {
|
|
echo " restarting container $TARGET_CONTAINER ..."
|
|
# TARGET_CONTAINER is validated above; still quote defensively.
|
|
if ! ssh_t "docker restart $(printf %q "$TARGET_CONTAINER")" >/dev/null; then
|
|
echo " ❌ ssh-failed: docker restart failed"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Mutate config.json on target. Values pass via env (printf %q + single-quoted
|
|
# heredoc) so $TEST_PUBKEY etc. never enter the remote shell as code.
|
|
set_blacklist_state() {
|
|
local mode="$1" # add | remove
|
|
ssh_t "CFG=$(printf %q "$TARGET_CONFIG_PATH") PK=$(printf %q "$TEST_PUBKEY") MODE=$(printf %q "$mode") bash -s" <<'REMOTE'
|
|
set -euo pipefail
|
|
TMP="$(mktemp)"
|
|
trap 'rm -f "$TMP"' EXIT
|
|
if command -v jq >/dev/null; then
|
|
if [ "$MODE" = "add" ]; then
|
|
jq --arg pk "$PK" '.nodeBlacklist = ((.nodeBlacklist // []) + [$pk] | unique)' "$CFG" > "$TMP"
|
|
else
|
|
jq --arg pk "$PK" '.nodeBlacklist = ((.nodeBlacklist // []) - [$pk])' "$CFG" > "$TMP"
|
|
fi
|
|
else
|
|
python3 - "$CFG" "$PK" "$MODE" "$TMP" <<'PY'
|
|
import json, sys
|
|
cfg, pk, mode, out = sys.argv[1:]
|
|
with open(cfg) as f: d = json.load(f)
|
|
bl = list(dict.fromkeys(d.get("nodeBlacklist") or []))
|
|
if mode == "add":
|
|
if pk not in bl: bl.append(pk)
|
|
else:
|
|
bl = [x for x in bl if x != pk]
|
|
d["nodeBlacklist"] = bl
|
|
with open(out, "w") as f: json.dump(d, f, indent=2)
|
|
PY
|
|
fi
|
|
# Preserve mode and ownership; mv across same FS is atomic.
|
|
chmod --reference="$CFG" "$TMP" 2>/dev/null || true
|
|
chown --reference="$CFG" "$TMP" 2>/dev/null || true
|
|
mv "$TMP" "$CFG"
|
|
trap - EXIT
|
|
REMOTE
|
|
local rc=$?
|
|
if (( rc != 0 )); then
|
|
echo " ❌ ssh-failed: could not edit $TARGET_CONFIG_PATH ($mode)"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
add_to_blacklist() { set_blacklist_state add; }
|
|
remove_from_blacklist() { set_blacklist_state remove; }
|
|
|
|
node_visible() {
|
|
# Returns 0 if the pubkey is currently visible via API.
|
|
local code
|
|
code=$(fetch_code "$TARGET_URL/api/nodes/$TEST_PUBKEY" "$TMP/node.json")
|
|
if [[ "$code" == "200" ]]; then return 0; fi
|
|
fetch_code "$TARGET_URL/api/nodes?limit=10000" "$TMP/nodes.json" >/dev/null
|
|
if grep -qF -- "\"$TEST_PUBKEY\"" "$TMP/nodes.json" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# §10.2 DB probe — bind the pubkey, do not interpolate it (issue #1977)
|
|
# -----------------------------------------------------------------------------
|
|
# Batch flags, all in service of "the count is parseable and errors are visible":
|
|
# -bail stop at the first SQL error instead of running on
|
|
# -init /dev/null ignore the operator's ~/.sqliterc — a stray .mode there
|
|
# would make the count unparseable
|
|
# -noheader -list stdout is exactly the number, nothing else
|
|
SQLITE_ARGS=(-batch -bail -init /dev/null -noheader -list)
|
|
# Round-trip probe token. The value is arbitrary; it only has to come back intact.
|
|
SQLITE_PROBE_TOKEN="corescope-probe-ok"
|
|
SQLITE_RUNNER="" # "container" | "host", set by resolve_sqlite_runner
|
|
RETAIN_COUNT="" # set by read_retain_count
|
|
|
|
# Hex-encode a value for embedding in SQL as a blob literal.
|
|
#
|
|
# Why hex rather than quoting: the output alphabet is [0-9a-f], so no byte the
|
|
# caller passes can terminate a string literal or add a dot-command argument.
|
|
# That holds for arbitrary input, which is the point — the SQL layer stops
|
|
# depending on main()'s hex gate in order to be safe.
|
|
#
|
|
# `od -v` is load-bearing: without it od collapses runs of identical lines to
|
|
# '*' and long repetitive values encode wrongly.
|
|
sql_hex_literal() {
|
|
printf "x'%s'" "$(printf '%s' "$1" | od -An -v -tx1 | tr -d ' \n')"
|
|
}
|
|
|
|
# SQL for the §10.2 count, fed to sqlite3 on stdin. The SELECT text is a
|
|
# constant; the pubkey arrives as a bound parameter.
|
|
#
|
|
# Note the nested cast rather than `.parameter set :pubkey '<value>'`:
|
|
# dot-command arguments are split on whitespace, so a value containing a space
|
|
# (e.g. "' OR 1=1 --") makes sqlite3 print the .parameter help to STDOUT, exit
|
|
# 0, and leave :pubkey unbound. COUNT(*) then returns 0 — which reads exactly
|
|
# like a passing security fix. -bail does not catch it either.
|
|
#
|
|
# The column is transmissions.from_pubkey (cmd/ingestor/db.go CREATE TABLE and
|
|
# the from_pubkey_v1 migration; asserted by internal/dbschema). The ingestor
|
|
# fills it only for ADVERTs, with hex.EncodeToString output — lowercase. The
|
|
# script's hex gate and the server's nodeBlacklist both accept any case, so the
|
|
# bound value is lowercased inside SQL; the parameter itself is still bound.
|
|
# There is no from_node column: querying it errors on every real database.
|
|
transmission_count_sql() {
|
|
printf '.parameter init\n'
|
|
printf '.parameter set :pubkey "cast(%s as text)"\n' "$(sql_hex_literal "$1")"
|
|
printf 'SELECT COUNT(*) FROM transmissions WHERE from_pubkey = lower(:pubkey);\n'
|
|
}
|
|
|
|
# Capability probe: bind a known value and read it back. A version number only
|
|
# implies that .parameter works; binding something and getting it back proves it
|
|
# on the binary actually in front of us, which is the operator's, not ours.
|
|
sqlite_probe_sql() {
|
|
printf '.parameter init\n'
|
|
printf '.parameter set :probe "cast(%s as text)"\n' "$(sql_hex_literal "$SQLITE_PROBE_TOKEN")"
|
|
printf 'SELECT :probe;\n'
|
|
}
|
|
|
|
# Find a sqlite3 that can bind a parameter — in the container first, then on the
|
|
# host. Sets SQLITE_RUNNER; returns 1 if neither qualifies. There is deliberately
|
|
# no interpolating fallback: that would leave the vulnerable path in place under
|
|
# a nicer name.
|
|
#
|
|
# Probe stderr is collected rather than discarded, but only printed if BOTH
|
|
# probes fail. The container miss is the known-normal case — the app image has
|
|
# no sqlite3 (pure-Go driver, no CGO; Dockerfile:15) — so surfacing it on every
|
|
# run would be noise.
|
|
resolve_sqlite_runner() {
|
|
local probe out
|
|
probe=$(sqlite_probe_sql)
|
|
SQLITE_RUNNER=""
|
|
out=$(ssh_t "docker exec -i $(printf %q "$TARGET_CONTAINER") sqlite3 ${SQLITE_ARGS[*]} :memory:" \
|
|
<<<"$probe" 2>>"$TMP/sqlite-probe.err")
|
|
if [[ "$out" == "$SQLITE_PROBE_TOKEN" ]]; then SQLITE_RUNNER="container"; return 0; fi
|
|
out=$(ssh_t "sqlite3 ${SQLITE_ARGS[*]} :memory:" <<<"$probe" 2>>"$TMP/sqlite-probe.err")
|
|
if [[ "$out" == "$SQLITE_PROBE_TOKEN" ]]; then SQLITE_RUNNER="host"; return 0; fi
|
|
return 1
|
|
}
|
|
|
|
# Run SQL from stdin against TARGET_DB_PATH via the resolved runner. Stderr is
|
|
# left alone so the caller can capture it, and the exit status is sqlite3's.
|
|
# The SQL crosses on stdin, so only the container name and db path still need
|
|
# printf %q for the remote shell. docker exec needs -i to attach stdin.
|
|
run_sqlite() {
|
|
case "$SQLITE_RUNNER" in
|
|
container) ssh_t "docker exec -i $(printf %q "$TARGET_CONTAINER") sqlite3 ${SQLITE_ARGS[*]} $(printf %q "$TARGET_DB_PATH")" ;;
|
|
host) ssh_t "sqlite3 ${SQLITE_ARGS[*]} $(printf %q "$TARGET_DB_PATH")" ;;
|
|
*) echo "run_sqlite: no runner resolved" >&2; return 127 ;;
|
|
esac
|
|
}
|
|
|
|
# Read the retained-transmission count into RETAIN_COUNT. Prints a classified
|
|
# "retain-failed" line and returns 1 on failure, so §10.2 has exactly one place
|
|
# that increments $fails.
|
|
read_retain_count() {
|
|
RETAIN_COUNT=""
|
|
local code
|
|
if [[ -n "$ADMIN_API_TOKEN" ]]; then
|
|
# Read auth header from stdin so the token never enters argv (ps-safe).
|
|
code=$(printf 'header = "Authorization: Bearer %s"\n' "$ADMIN_API_TOKEN" | \
|
|
curl -s -m "$CURL_TIMEOUT" -K - -o "$TMP/admin.json" -w "%{http_code}" \
|
|
"$TARGET_URL/api/admin/transmissions?from_node=$TEST_PUBKEY&count=1" 2>/dev/null || echo "000")
|
|
if [[ "$code" == "200" ]]; then
|
|
RETAIN_COUNT=$(jq -r '.count // ((.transmissions // []) | length)' "$TMP/admin.json" 2>/dev/null || echo "")
|
|
fi
|
|
if [[ -n "$RETAIN_COUNT" ]]; then return 0; fi
|
|
fi
|
|
|
|
if [[ -z "$TARGET_DB_PATH" ]]; then
|
|
echo " ❌ retain-failed: TARGET_DB_PATH unset and no ADMIN_API_TOKEN — cannot probe"
|
|
return 1
|
|
fi
|
|
if ! resolve_sqlite_runner; then
|
|
echo " ❌ retain-failed: no sqlite3 able to bind a parameter on the target"
|
|
echo " tried: docker exec -i $TARGET_CONTAINER sqlite3, then sqlite3 on $TARGET_SSH_HOST"
|
|
echo " need: the sqlite3 CLI reachable over ssh, supporting '.parameter set'"
|
|
cat "$TMP/sqlite-probe.err" >&2
|
|
return 1
|
|
fi
|
|
echo " sqlite3 runner: $SQLITE_RUNNER"
|
|
if ! RETAIN_COUNT=$(run_sqlite <<<"$(transmission_count_sql "$TEST_PUBKEY")" 2>"$TMP/sqlite.err"); then
|
|
echo " ❌ retain-failed: sqlite3 query failed via $SQLITE_RUNNER"
|
|
cat "$TMP/sqlite.err" >&2
|
|
RETAIN_COUNT=""
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# main
|
|
# -----------------------------------------------------------------------------
|
|
main() {
|
|
BASELINE_URL="${1:-}"
|
|
TARGET_URL="${2:-}"
|
|
if [[ -z "$BASELINE_URL" || -z "$TARGET_URL" ]]; then
|
|
echo "usage: $0 BASELINE_URL TARGET_URL (TEST_NODE_PUBKEY+TARGET_* via env)" >&2
|
|
exit 2
|
|
fi
|
|
|
|
TEST_PUBKEY="${TEST_NODE_PUBKEY:-}"
|
|
TARGET_SSH_HOST="${TARGET_SSH_HOST:-}"
|
|
TARGET_SSH_KEY="${TARGET_SSH_KEY:-/root/.ssh/id_ed25519}"
|
|
TARGET_CONFIG_PATH="${TARGET_CONFIG_PATH:-}"
|
|
TARGET_CONTAINER="${TARGET_CONTAINER:-}"
|
|
TARGET_DB_PATH="${TARGET_DB_PATH:-}"
|
|
ADMIN_API_TOKEN="${ADMIN_API_TOKEN:-}"
|
|
|
|
if [[ -z "$TEST_PUBKEY" || -z "$TARGET_SSH_HOST" || -z "$TARGET_CONFIG_PATH" || -z "$TARGET_CONTAINER" ]]; then
|
|
echo "error: TEST_NODE_PUBKEY, TARGET_SSH_HOST, TARGET_CONFIG_PATH, TARGET_CONTAINER are required" >&2
|
|
exit 2
|
|
fi
|
|
|
|
# Hard input validation — these strings are interpolated into the remote shell.
|
|
# §10.2's SQL binds TEST_PUBKEY as a parameter rather than interpolating it, so
|
|
# for the SQL layer this gate is defence in depth rather than the only guard
|
|
# (issue #1977). Keep it: redundant is not the same as wrong.
|
|
# Pubkey must be hex (MeshCore pubkeys are hex-encoded ed25519 prefixes).
|
|
if ! [[ "$TEST_PUBKEY" =~ ^[0-9a-fA-F]+$ ]]; then
|
|
echo "error: TEST_NODE_PUBKEY must be hex (got: redacted)" >&2
|
|
exit 2
|
|
fi
|
|
# Container name must match docker's allowed chars: [a-zA-Z0-9][a-zA-Z0-9_.-]*
|
|
if ! [[ "$TARGET_CONTAINER" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]]; then
|
|
echo "error: TARGET_CONTAINER has illegal chars" >&2
|
|
exit 2
|
|
fi
|
|
# Config path must be an absolute, sane path (no spaces, quotes, $, ;, etc.).
|
|
if ! [[ "$TARGET_CONFIG_PATH" =~ ^/[A-Za-z0-9_./-]+$ ]]; then
|
|
echo "error: TARGET_CONFIG_PATH must be a sane absolute path" >&2
|
|
exit 2
|
|
fi
|
|
if [[ -n "$TARGET_DB_PATH" ]] && ! [[ "$TARGET_DB_PATH" =~ ^/[A-Za-z0-9_./-]+$ ]]; then
|
|
echo "error: TARGET_DB_PATH must be a sane absolute path" >&2
|
|
exit 2
|
|
fi
|
|
|
|
CURL_TIMEOUT="${CURL_TIMEOUT:-60}"
|
|
RESTART_WAIT_S="${RESTART_WAIT_S:-120}"
|
|
|
|
SSH_OPTS=(-i "$TARGET_SSH_KEY" -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 -o BatchMode=yes)
|
|
|
|
TMP=$(mktemp -d)
|
|
fails=0
|
|
TEARDOWN_DONE=0
|
|
trap teardown EXIT INT TERM
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# §10.1 — hide
|
|
# ---------------------------------------------------------------------------
|
|
echo "=== §10.1 add $TEST_PUBKEY to nodeBlacklist ==="
|
|
if ! add_to_blacklist; then fails=$((fails+1)); exit "$fails"; fi
|
|
if ! restart_target; then fails=$((fails+1)); exit "$fails"; fi
|
|
if ! wait_for_stats; then fails=$((fails+1)); exit "$fails"; fi
|
|
|
|
detail_code=$(fetch_code "$TARGET_URL/api/nodes/$TEST_PUBKEY" "$TMP/detail.json")
|
|
list_code=$(fetch_code "$TARGET_URL/api/nodes?limit=10000" "$TMP/list.json")
|
|
in_list=0
|
|
if [[ "$list_code" == "200" ]] && grep -qF -- "\"$TEST_PUBKEY\"" "$TMP/list.json"; then
|
|
in_list=1
|
|
fi
|
|
if [[ "$detail_code" == "404" || "$in_list" == "0" ]]; then
|
|
echo " ✅ hide ok: detail=$detail_code in_list=$in_list"
|
|
else
|
|
echo " ❌ hide-failed: detail=$detail_code in_list=$in_list — pubkey still surfaced"
|
|
fails=$((fails+1))
|
|
fi
|
|
|
|
topo_code=$(fetch_code "$TARGET_URL/api/topology" "$TMP/topo.json")
|
|
if [[ "$topo_code" != "200" ]]; then
|
|
echo " ⚠️ /api/topology HTTP $topo_code — skipping topology assertion"
|
|
elif grep -qF -- "$TEST_PUBKEY" "$TMP/topo.json"; then
|
|
echo " ❌ hide-failed: /api/topology references blacklisted pubkey"
|
|
fails=$((fails+1))
|
|
else
|
|
echo " ✅ topology clean"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# §10.2 — DB retain
|
|
# ---------------------------------------------------------------------------
|
|
echo "=== §10.2 verify packets retained in DB ==="
|
|
if ! read_retain_count; then
|
|
# read_retain_count already printed the classified reason. Counting here and
|
|
# nowhere else: the old code incremented $fails for the "TARGET_DB_PATH
|
|
# unset" case and then again for the empty count it left behind.
|
|
fails=$((fails+1))
|
|
elif [[ "$RETAIN_COUNT" =~ ^[0-9]+$ ]] && (( RETAIN_COUNT > 0 )); then
|
|
echo " ✅ DB retains $RETAIN_COUNT packets from $TEST_PUBKEY"
|
|
else
|
|
echo " ❌ retain-failed: count=$RETAIN_COUNT (expected > 0)"
|
|
fails=$((fails+1))
|
|
fi
|
|
|
|
echo "=== summary: $fails failure(s) before teardown ==="
|
|
# trap handles teardown + exit
|
|
exit "$fails"
|
|
}
|
|
|
|
# Only run main when executed directly (not when sourced by tests).
|
|
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
|
|
main "$@"
|
|
fi
|