diff --git a/scripts/test.sh b/scripts/test.sh index 96cd13903..3829a6688 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -318,5 +318,6 @@ CBM_TEST_BINARY="$WATCHDOG_BINARY" bash "$ROOT/tests/test_worker_error_response. # audit must allow-list it (Windows-only — Linux smoke never saw it). echo "=== Step 6: security-strings allow-list regression ===" bash "$ROOT/tests/test_security_strings_allowlist.sh" +bash "$ROOT/tests/test_destructive_ordering_contract.sh" echo "=== All tests passed ===" diff --git a/tests/test_destructive_ordering_contract.sh b/tests/test_destructive_ordering_contract.sh new file mode 100644 index 000000000..84ebace63 --- /dev/null +++ b/tests/test_destructive_ordering_contract.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Contract: irreversible work must never run before a step that can still abort. +# +# This exists because it destroyed a user's data. On 0.9.0 -> 0.10.2, +# `update -y` auto-confirmed deleting OTHER projects' indexes, removed two .db +# files (~59 MB), and THEN aborted on an interactive prompt it could not answer +# under a non-interactive shell (#1558). The run failed, and the data was +# already gone. The prompt that aborted has since been removed, but that fixed +# one instance rather than the rule. +# +# The rule: in both activation flows, the index deletion must come AFTER the +# agent-configuration step, so any failure that can still stop the run happens +# while the indexes are intact. Both flows already return early when agent +# configuration fails, so ordering is the whole guarantee. +# +# This is a source-order check rather than a behavioural one, deliberately: the +# property is "the destructive call sits late in this function", and reordering +# it is exactly the regression to catch. A behavioural test would need to drive +# a full activation to failure, which is far more machinery for a weaker signal. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CLI="$ROOT/src/cli/cli.c" + +[ -f "$CLI" ] || { echo "FAIL: cannot find $CLI" >&2; exit 1; } + +python3 - "$CLI" <<'PY' +import re, sys + +src = open(sys.argv[1], encoding="utf-8").read() +failures = [] + +# The two activation entry points that may delete indexes. Each is checked in +# isolation so a call in one cannot satisfy the contract for the other. +FLOWS = [ + ("cli_install_activate", "install"), + ("cli_update_activate_binary", "update"), +] + +def body_of(name): + """Source text of a static function, from its signature to the next + top-level closing brace.""" + m = re.search(r"^static int " + re.escape(name) + r"\(.*?\{", src, re.S | re.M) + if not m: + return None + start = m.end() + depth = 1 + i = start + while i < len(src) and depth: + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + i += 1 + return src[start:i] + +for func, label in FLOWS: + body = body_of(func) + if body is None: + # Renamed or restructured: fail loudly rather than pass vacuously. + failures.append(f"{label}: could not find {func}() — update this contract " + f"rather than deleting it") + continue + + delete_at = body.find("cbm_remove_indexes") + if delete_at < 0: + # No deletion in this flow at all is fine — nothing to order. + continue + + # Key on the configuration CALL, not a local variable name: the two flows + # spell the result differently (one stores agent_config_rc, the other tests + # the call inline), and the call is the thing that must precede deletion. + config_at = body.find("cbm_install_agent_configs") + if config_at < 0: + failures.append(f"{label}: deletes indexes but no agent-configuration step " + f"was found before it; the ordering guarantee cannot hold") + continue + + if delete_at < config_at: + failures.append( + f"{label}: cbm_remove_indexes() runs BEFORE the agent-configuration\n" + f" step. A failure after that point destroys data the user was\n" + f" told would be rebuilt, on a run that then aborts (#1558).") + +if failures: + for f in failures: + print("FAIL: " + f, file=sys.stderr) + print(f"destructive-ordering contract FAILED with {len(failures)} violation(s)", + file=sys.stderr) + sys.exit(1) +print("PASS: index deletion runs after every step that can still abort the run") +PY