mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-16 00:10:11 -04:00
* test-state: match the sandbox HOME in-shell so a survivor hit cannot report as a miss * Trim the survivor-scan comment to two lines * test-state: read the environ with a NUL-delimited read loop, not mapfile -d * test-state-check: report the survivor's actual HOME and the wrapper's stderr * test-state-check: re-run the scan when it reports a miss, to separate a race from a mismatch * test-state-check: let the survivor fixture finish exec before the suite returns * test-state-check: fail the survivor fixture instead of staging a pid it never saw exec
227 lines
9.4 KiB
Bash
227 lines
9.4 KiB
Bash
# shellcheck shell=bash
|
|
#
|
|
# Shared helpers for the native test harness's shared-state check. Sourced by
|
|
# bin/pio-test-isolate.sh (which enforces it per suite) and bin/test-state-check.sh (which proves
|
|
# the checker itself still works). Nothing here executes on its own.
|
|
#
|
|
# Linux-only, like the rest of the native harness: this uses GNU coreutils behaviour (`find -printf`,
|
|
# md5sum) rather than carrying a per-host fallback. bin/run-tests.sh states and enforces that.
|
|
#
|
|
# The check answers one question: did this suite change any file it did not declare? It deliberately
|
|
# does NOT compare file *contents* against a baseline. Content baselines over protobuf bytes are
|
|
# snapshot tests - add a field to NodeInfoLite and every recorded hash in the repo churns, which is
|
|
# how snapshot suites turn into an --update-all ritual and then into noise. Hashes are used only to
|
|
# answer the boolean "did this change?"; what gets declared and reviewed is the set of paths.
|
|
|
|
STATE_MANIFEST_DEFAULT="test/state-manifest.tsv"
|
|
|
|
# Files the native binaries persist under $HOME. Listed for documentation and for the
|
|
# --write-manifest hint; the scan itself is unfiltered, so a suite writing somewhere unexpected is
|
|
# still caught.
|
|
# shellcheck disable=SC2034 # referenced by callers and by the docs
|
|
STATE_KNOWN_FILES="nodes.proto config.proto channels.proto module.proto device.proto warm.dat transmit_history.dat"
|
|
|
|
# Guard the guard: refuse to run a suite against a sandbox that is not empty. If isolation ever
|
|
# leaks, the after-diff measures against the wrong baseline and the whole check reports CLEAN while
|
|
# meaning nothing - so before-empty is as load-bearing as after-diff. Returns non-zero and explains
|
|
# itself rather than carrying on.
|
|
state_assert_empty() {
|
|
local dir="$1"
|
|
if [[ -n $(find "$dir" -mindepth 1 -print -quit 2>/dev/null) ]]; then
|
|
echo "test-state: sandbox $dir is not empty before the suite ran - isolation is broken" >&2
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Fingerprint every file under $1 as "<relative-path> <md5>", sorted. Empty output for an empty or
|
|
# missing tree. Output is fed to comm/diff, so the sort order has to be stable across calls.
|
|
#
|
|
# GNU `find -printf` and md5sum(1), deliberately: this harness is Linux-only and bin/run-tests.sh
|
|
# refuses to start anywhere else, so there is no host here that needs a BSD fallback.
|
|
state_fingerprint() {
|
|
local root="$1"
|
|
[[ -d $root ]] || return 0
|
|
(
|
|
cd "$root" || return 0
|
|
find . -type f -printf '%P\n' 2>/dev/null | LC_ALL=C sort | while IFS= read -r rel; do
|
|
printf '%s %s\n' "$rel" "$(md5sum -- "$rel" 2>/dev/null | cut -d' ' -f1)"
|
|
done
|
|
)
|
|
}
|
|
|
|
# Processes still running inside the suite's sandbox $HOME. Prints one PID per line.
|
|
#
|
|
# A suite that ends on a bare UNITY_END() does not stop: setup() returns, the runtime keeps calling
|
|
# loop(), and PlatformIO - which reports a suite from its Unity output, not from process exit -
|
|
# moves on with the binary still resident. Nothing else notices, and the damage is quiet: the
|
|
# sandbox gets deleted under a live process, so the after-fingerprint below describes what the suite
|
|
# had written when we stopped looking rather than what it left behind, and .gcda plus LeakSanitizer
|
|
# both flush from atexit handlers that never run.
|
|
#
|
|
# Matching on the environment rather than on a remembered PID is deliberate: the sandbox HOME is
|
|
# mktemp-unique per suite, so this identifies survivors whatever their parentage - a fork, a
|
|
# grandchild, a process already reparented to init - none of which a $! comparison would catch.
|
|
# Scoped to this user's processes: /proc/<pid>/environ is unreadable for anyone else's anyway, and
|
|
# the narrower sweep costs ~270ms against ~460ms for all of /proc.
|
|
state_find_survivors() {
|
|
local home="$1" pid entry
|
|
[[ -n $home ]] || return 0
|
|
for pid in $(ps -u "$(id -u)" -o pid= 2>/dev/null); do
|
|
[[ $pid == "$$" ]] && continue
|
|
# In-shell, not `tr | grep -qxF`: -q closes the pipe on the match, tr takes SIGPIPE, and
|
|
# under `set -o pipefail` the hit reports as a miss. Grouped so a vanished pid is silent.
|
|
{
|
|
while IFS= read -r -d '' entry; do
|
|
if [[ $entry == "HOME=$home" ]]; then
|
|
printf '%s\n' "$pid"
|
|
break
|
|
fi
|
|
done <"/proc/$pid/environ"
|
|
} 2>/dev/null
|
|
done
|
|
}
|
|
|
|
# Paths present in the "after" fingerprint ($2) that are absent or different in "before" ($1).
|
|
# Prints one relative path per line.
|
|
state_changed_paths() {
|
|
local before="$1" after="$2"
|
|
LC_ALL=C comm -13 <(LC_ALL=C sort "$before") <(LC_ALL=C sort "$after") | awk '{print $1}' | LC_ALL=C sort -u
|
|
}
|
|
|
|
# Read a suite's flag string out of the manifest. Empty when the suite has no entry, which is the
|
|
# default and means "isolated": fresh state in, contents discarded out.
|
|
#
|
|
# Manifest format - TSV, three columns, the same shape as an allowlist entry: the thing, what it is
|
|
# allowed to do, and why. The reason column is mandatory and is what a reviewer reads.
|
|
#
|
|
# test_nodedb_blocked<TAB>state=per-suite writes=nodes.proto<TAB>saturates the DB to test the cap
|
|
state_manifest_flags() {
|
|
local suite="$1" manifest="${2:-$STATE_MANIFEST_DEFAULT}"
|
|
[[ -f $manifest ]] || return 0
|
|
awk -F'\t' -v s="$suite" '!/^[[:space:]]*#/ && $1 == s { print $2; exit }' "$manifest"
|
|
}
|
|
|
|
# Pull one flag's value out of a flag string: state_flag_value "writes" "state=per-suite writes=a,b"
|
|
state_flag_value() {
|
|
local key="$1" flags="$2" f
|
|
for f in $flags; do
|
|
[[ $f == "$key="* ]] && {
|
|
printf '%s' "${f#"$key="}"
|
|
return 0
|
|
}
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# Does a changed path match a declared write? A declaration matches either the full path relative to
|
|
# the scratch HOME or just the basename, because the useful name for these is the basename
|
|
# (`nodes.proto`) and nobody should have to write .portduino/default/prefs/ in front of it.
|
|
state_path_declared() {
|
|
local path="$1" declared="$2" entry
|
|
IFS=',' read -ra _entries <<<"$declared"
|
|
for entry in "${_entries[@]}"; do
|
|
[[ -z $entry ]] && continue
|
|
[[ $path == "$entry" || ${path##*/} == "$entry" ]] && return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Classify a suite's leftovers. Prints "<verdict>\t<detail>" where verdict is one of:
|
|
#
|
|
# CLEAN nothing changed, or everything that changed was declared
|
|
# DIRTY at least one undeclared path changed - the finding this whole check exists for
|
|
# MISSING every changed path was declared, but a declared path did NOT change
|
|
#
|
|
# MISSING is reported separately rather than folded into DIRTY because it catches the opposite bug:
|
|
# persistence that silently stopped happening. That is a real class here - the TAK config bug
|
|
# upstream was a has_ flag never being set, so the save wrote nothing and no test noticed. It starts
|
|
# as a warning because some declared writes are legitimately conditional.
|
|
state_classify() {
|
|
local changed="$1" declared="$2"
|
|
local undeclared=() missing=() path entry found
|
|
|
|
while IFS= read -r path; do
|
|
[[ -z $path ]] && continue
|
|
if ! state_path_declared "$path" "$declared"; then
|
|
undeclared+=("$path")
|
|
fi
|
|
done <<<"$changed"
|
|
|
|
# Ask state_path_declared() in the other direction rather than matching by hand: one rule for
|
|
# "does this path match this declaration", so the two directions cannot drift apart. Matching
|
|
# an entry as a regex would also let a metacharacter in a manifest name (`.`, `+`) match a file
|
|
# that is not the declared one.
|
|
IFS=',' read -ra _declared <<<"$declared"
|
|
for entry in "${_declared[@]}"; do
|
|
[[ -z $entry ]] && continue
|
|
found=1
|
|
while IFS= read -r path; do
|
|
[[ -z $path ]] && continue
|
|
if state_path_declared "$path" "$entry"; then
|
|
found=0
|
|
break
|
|
fi
|
|
done <<<"$changed"
|
|
((found)) && missing+=("$entry")
|
|
done
|
|
|
|
if ((${#undeclared[@]} > 0)); then
|
|
printf 'DIRTY\tundeclared: %s\n' "${undeclared[*]}"
|
|
elif ((${#missing[@]} > 0)); then
|
|
printf 'MISSING\tdeclared but unwritten: %s\n' "${missing[*]}"
|
|
else
|
|
printf 'CLEAN\t\n'
|
|
fi
|
|
}
|
|
|
|
# --- Error-line budget -------------------------------------------------------------------------
|
|
#
|
|
# A second orthogonal axis, like CLEAN/DIRTY above: a suite can pass while emitting six figures of
|
|
# LOG_ERROR, which buries a real failure and trains everyone to skim. The budget is declared in the
|
|
# same manifest, as an `errors=` flag, and it is a RANGE rather than a ceiling - for a fuzz suite the
|
|
# floor is the load-bearing half. test_fuzz_decode logging ~100k rejections is it working; the same
|
|
# suite logging none means it stopped feeding malformed input, and every case would still pass.
|
|
#
|
|
# Undeclared suites get ERROR_BUDGET_DEFAULT. Declared forms: "N" (max), "MIN..MAX", "MIN.." (floor
|
|
# only). Everything is inclusive.
|
|
ERROR_BUDGET_DEFAULT=100
|
|
|
|
# Count LOG_ERROR lines in a suite's captured output.
|
|
state_count_errors() {
|
|
local log="$1"
|
|
[[ -f $log ]] || {
|
|
printf '0'
|
|
return 0
|
|
}
|
|
# `|| true`, not `|| printf 0`: grep -c already prints 0 before exiting 1 on no match, so a
|
|
# fallback that prints appends a second line and the caller gets "0\n0" to do arithmetic on.
|
|
grep -cE '^ERROR +\|' "$log" 2>/dev/null || true
|
|
}
|
|
|
|
# VERDICT<TAB>DETAIL. WITHIN / OVER / UNDER, mirroring state_classify()'s shape.
|
|
state_classify_errors() {
|
|
local count="$1" declared="$2" min=0 max="$ERROR_BUDGET_DEFAULT"
|
|
|
|
if [[ -n $declared ]]; then
|
|
if [[ $declared == *".."* ]]; then
|
|
min="${declared%%..*}"
|
|
max="${declared##*..}"
|
|
[[ -z $max ]] && max=""
|
|
else
|
|
max="$declared"
|
|
fi
|
|
fi
|
|
|
|
if [[ -n $max ]] && ((count > max)); then
|
|
printf 'OVER\t%d error line(s), budget %s' "$count" "${declared:-$ERROR_BUDGET_DEFAULT}"
|
|
return 0
|
|
fi
|
|
if ((count < min)); then
|
|
printf 'UNDER\t%d error line(s), expected at least %d - is it still exercising the path?' \
|
|
"$count" "$min"
|
|
return 0
|
|
fi
|
|
printf 'WITHIN\t%d' "$count"
|
|
}
|