# 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 " ", 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//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_blockedstate=per-suite writes=nodes.protosaturates 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 "\t" 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 } # VERDICTDETAIL. 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" }