Files
Martin Vogel 1f674c805e harden: remove capability that should never have shipped
Microsoft's ML flagged the rc.1 release binaries. The decisive evidence is that
the SAME sha256 went from 0/62 clean to Microsoft-detected in about an hour with
no byte change, so the verdict lives partly in scanner-side state and no code
change can promise a clean result. What code CAN do is stop shipping things that
have no business in a release artifact, which is worth doing on its own merits
and incidentally widens the classifier margin. Every claim below is verified
against a built binary by the new gate, not by reading source.

Executable stack (the worst of the findings). vendored/nomic/code_vectors_blob.S
is the only assembly in the build and carried no .note.GNU-stack. An unannotated
object makes ld assume the worst for the whole link, so EVERY Linux release we
have ever shipped had GNU_STACK RWE. Adds the note (cause) plus ELF-only
-Wl,-z,noexecstack (outcome); the gate fails the release if it returns.

Test seams are now opt-in, never opt-out. TEST_SEAMS=1 defines
CBM_ENABLE_TEST_SEAMS; without it the crash-orphan probe -- which forks a child
that ignores SIGTERM and loops forever, then writes its pid to a caller-supplied
path -- and the lease-ownership marker compile to trivial stubs, so call sites are
untouched and the binary holds no fork, no signal handler and no env-var string.
Opt-IN is the point: forgetting the flag yields a clean binary rather than a leaky
one. scripts/test.sh requests it in the leg that consumes it, and
tests/test_worker_watchdog.sh now asserts the capability up front instead of
dying later with an opaque "Killed: 9".

The daemon's background version check is gone. It spawned curl against
api.github.com/repos/.../releases/latest on the first eligible session of every
run to say "a newer version exists" -- a release URL and an outbound request in
every shipped binary, for something the install scripts already report. The
INJECTABLE SEAM survives: update_ops is still honoured, the fakes in
tests/test_daemon_application.c still cover notice/ownership/cancellation/replay,
and with no provider application_update_subscribe_locked returns early so no
generation ever starts. "No network request by default" is now structural.

Dead capability out of release builds. The tar.gz/zip extraction block
(gzip_decompress through cbm_extract_binary_from_zip, plus its cli.h
declarations) moves under CBM_CLI_ENABLE_TEST_API -- verified self-contained, zero
uses of any helper outside it, only callers the excluded updater and
tests/test_cli.c. Downloading an archive, decompressing it, picking an executable
out of it and marking it executable is the canonical dropper composite; it is now
absent rather than merely unreachable. SQLite is built with
-DSQLITE_OMIT_LOAD_EXTENSION (no caller of load_extension anywhere in src/ or
internal/), removing that API surface and part of the dlopen/dlsym surface.

Temp files and environment scanning (S2/S3). Predictable paths in mcp.c,
artifact.c and diagnostics.c are created privately and exclusively and written
through the returned descriptor; pass_envscan.c no longer descends symlinked
directories out of the project root, and its fixed 512-byte path buffers no
longer truncate into pointer arithmetic that could land outside the buffer.

Build-time entropy. mimalloc's version banner baked __DATE__/__TIME__ into every
binary, so two builds of identical source seconds apart could never share a hash
and no release could inherit a false-positive determination made about its
predecessor. Local patch removes it (marked to survive refreshes), -Wdate-time
makes any future use a build error, and -Wl,--no-insert-timestamp stops the PE
header carrying the link clock.

scripts/ci/check-binary-composition.sh is the proof that each removal stays
removed, wired into package-release.sh after strip so the local artifact-flow
smoke enforces exactly what CI does. It asserts absences plus a CANARY string, so
handing it a compressed, stubbed or empty file fails instead of passing
vacuously, and a missing tool is a hard error -- a skipped assertion must never
look like a satisfied one.

Two build-system traps found by that gate, both of which had silently defeated a
fix: the product binary is compiled in one shot from sources, so a flag flip did
not rebuild it (now tracked by a .build-config stamp that also removes the
binary, making it independent of mtime granularity); and prod_sqlite3.o /
prod_mimalloc.o depended on a single named source, so SQLITE_OMIT_LOAD_EXTENSION
and the mimalloc patch BOTH compiled to nothing on the first incremental build.
Source review would have called them done.

Deliberately NOT changed. Three seams stay in release artifacts because
scripts/smoke-test.sh runs against the real artifact and needs them:
CBM_TEST_CRASH_ON and CBM_TEST_HANG_ON inject the faults that prove supervisor
recovery, and CBM_TEST_WINDOWS_USER_PATH_RUN_ID is what keeps the PATH smoke from
writing the tester's actual PATH. The gate treats those as an allowlist, so a
NOVEL seam still fails. The true no-UI standard build is deferred rather than
rushed: src/ui/* is in PROD_SRCS and four files outside src/ui reference UI
symbols, including the daemon that serves the UI, so that assertion reports
instead of failing until the split lands -- a gate everyone knows is red teaches
people to ignore gates.

No grammar is removed. ObjectScript accounts for essentially all binary growth
since the last provably-clean release (+21.4MB rodata, +1.1MB text from two
four-line shims), which made it the obvious ablation candidate, but a dry run
performed twelve real Defender endpoint scans across standard/UI and amd64/arm64
with ObjectScript, the daemon and the expanded hooks all present and every scan
was clean. Nothing there is a deterministic trigger, so cutting a
community-contributed language would spend a real feature on unproven margin.
Lean is not a candidate either: at 99.6MB of source it is by far the largest
grammar, but it shipped in v0.9.0 which scanned 20/20 clean, so removing it would
produce a novel unscanned profile instead of restoring a known-good one.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-29 23:50:53 +02:00

286 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# test.sh — THE canonical test leg. Every venue (local ladder, PR CI, dry run,
# release) runs tests through this file; iteration happens through its flags,
# never through a second entry point.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
usage() {
cat <<'EOF'
Usage: scripts/test.sh [--suites LIST] [--arch ARCH] [VAR=VAL ...]
The canonical test entry: identical in local CI, PR CI, dry run and release.
DEFAULT (no --suites) is exactly what CI runs: static contract checks
(Step 0a-0j), a CLEAN sanitizer build, every suite via the parallel harness,
then the prod-binary regression guards (Steps 4-6).
Modes:
(default) The venue leg. Clean build (scripts/clean.sh) + all suites +
all contract steps. This is the shape every gate runs.
--suites LIST ITERATION mode: comma- or space-separated suite names, e.g.
--suites daemon,daemon_ipc. Rebuilds the test-runner
INCREMENTALLY (make dependency tracking, no clean) and runs
only those suites — seconds, not minutes. Skips the contract
steps and prod-binary guards; the full default run remains
the merge gate. Suite names: build/c/test-runner --list-suites.
--tsan ThreadSanitizer leg (data-race gate): builds and runs the
widened TSan runner via make test-tsan — the same leg CI's
tsan jobs and the compose test-tsan service run.
Options:
--arch ARCH Force target arch (arm64 | x86_64), e.g. under Rosetta.
-h, --help This text.
Make passthrough (VAR=VAL, forwarded verbatim):
CC= CXX= Compiler override, e.g. CC=gcc-14 CXX=g++-14.
BUILD_DIR= Build in an isolated directory (containers/sanitizer variants).
SANITIZE= Override sanitizer flags. Platform defaults when unset:
unix/CLANG64 use the Makefile's ASan+UBSan test flags;
CLANGARM64 (Windows on ARM, no ASan runtime) gets CI's
trap-UBSan set (-fsanitize=undefined -fsanitize-trap=undefined
-fstack-protector-strong -fno-omit-frame-pointer) applied HERE
so local and CI build identical test binaries. Pass SANITIZE=
(empty) for a plain build when debugging a trap.
Environment:
CBM_TEST_SEQUENTIAL=1 Single-process runner instead of the parallel harness.
CBM_RUN_HANG_TEST=1 Opt-in C++ index-hang regression (#410, needs prod).
CBM_NO_CCACHE=1 Disable the content-verified compiler cache.
CBM_TEST_SHARD/_LEG Set by CI's sharded legs; leave unset locally.
Examples:
scripts/test.sh # the full venue leg (what CI runs)
scripts/test.sh --suites daemon_ipc # one suite, incremental, seconds
scripts/test.sh --suites "arena hash_table" CC=clang CXX=clang++
scripts/test.sh SANITIZE= --suites daemon_ipc # plain build for trap debugging
EOF
}
# Parse --help / --suites / --tsan / --arch before sourcing env.sh.
# STRICT: an unknown flag or a stray word is an immediate usage error, never
# silently swallowed — agents must know exactly what a run will do.
SUITES=""
TSAN=0
prev_arg=""
for arg in "$@"; do
case "$arg" in
-h|--help) usage; exit 0 ;;
--tsan) :;;
--suites) :;; # next arg is the value, handled below
--suites=*) SUITES="${arg#--suites=}" ;;
--arch) :;; # next arg is the value, handled below
--arch=*) :;; # handled below
-*)
echo "test.sh: unknown option '$arg'. Please consult --help." >&2
exit 2
;;
arm64|x86_64)
if [[ "${prev_arg:-}" != "--arch" && "${prev_arg:-}" != "--suites" ]]; then
echo "test.sh: unexpected argument '$arg' (did you mean --arch $arg?). Please consult --help." >&2
exit 2
fi
;;
*=*) :;; # VAR=VAL make passthrough, validated below
*)
if [[ "${prev_arg:-}" != "--suites" ]]; then
echo "test.sh: unexpected argument '$arg'. Please consult --help." >&2
exit 2
fi
;;
esac
prev_arg="$arg"
done
for arg in "$@"; do
case "$arg" in
--tsan) TSAN=1 ;;
arm64|x86_64)
if [[ "${prev_arg2:-}" == "--arch" ]]; then
export CBM_ARCH="$arg"
fi
;;
*)
if [[ "${prev_arg2:-}" == "--suites" ]]; then
SUITES="$arg"
fi
;;
esac
prev_arg2="$arg"
done
# Normalize comma separation to the runner's space-separated argv form.
SUITES="${SUITES//,/ }"
case "${prev_arg:-}" in
--suites|--arch)
echo "test.sh: '$prev_arg' needs a value. Please consult --help." >&2
exit 2
;;
esac
if [ "$TSAN" -eq 1 ] && [ -n "$SUITES" ]; then
echo "test.sh: --tsan and --suites are separate modes (the TSan leg has its own suite set). Please consult --help." >&2
exit 2
fi
prev_arg=""
# Also support --arch=value
for arg in "$@"; do
case "$arg" in
--arch=*) export CBM_ARCH="${arg#--arch=}" ;;
esac
done
# shellcheck source=env.sh
source "$ROOT/scripts/env.sh"
# shellcheck source=path-safety.sh
source "$ROOT/scripts/path-safety.sh"
# Forward CC/CXX and collect make-passthrough args. BUILD_DIR is honored for
# the explicit target path below so containerized legs can build in their own
# directory instead of clobbering the host's native build/c artifacts.
# MAKE_ARGS is an ARRAY so a VAR=VAL whose value contains spaces (the
# windows-11-arm leg passes SANITIZE with four flags) survives as ONE make
# argument. The old string accumulation re-split it at every expansion and
# make swallowed the second flag's leading -f as its makefile option.
MAKE_ARGS=()
BUILD_DIR="build/c"
SANITIZE_GIVEN=0
prev_arg=""
for arg in "$@"; do
case "$arg" in
CC=*|CXX=*) export "${arg}" ;;
--arch|--arch=*) ;; # already handled
arm64|x86_64) ;; # already handled
--tsan) ;; # already handled
--suites|--suites=*) ;; # already handled (value skipped via prev_arg below)
BUILD_DIR=*) BUILD_DIR="${arg#BUILD_DIR=}"; MAKE_ARGS+=("$arg") ;;
SANITIZE=*) SANITIZE_GIVEN=1; MAKE_ARGS+=("$arg") ;;
*=*)
if [[ "${prev_arg:-}" != "--suites" ]]; then
MAKE_ARGS+=("$arg") # forward any VAR=VAL to make
fi
;;
esac
prev_arg="$arg"
done
# Platform default absorbed FROM CI (previously inline in _test.yml, so the
# local arm64 leg silently built without it — that divergence is why the SQLite
# page-cache misalignment was fatal only on the windows-11-arm runner): native
# ARM64 Windows has no ASan runtime, so its sanitizer gate is UBSan in trap
# mode + stack protector. Applied here, once, for every venue; an explicit
# SANITIZE=... (or SANITIZE=) argument overrides.
if [ "$SANITIZE_GIVEN" -eq 0 ] && [ "${MSYSTEM:-}" = "CLANGARM64" ]; then
MAKE_ARGS+=("SANITIZE=-fsanitize=undefined -fsanitize-trap=undefined -fstack-protector-strong -fno-omit-frame-pointer")
fi
print_env "test.sh"
# ── TSan mode (--tsan): the data-race gate ──
# One entry for every venue: CI's tsan jobs and the compose test-tsan service
# both run this instead of carrying their own make invocation.
if [ "$TSAN" -eq 1 ]; then
echo "=== test.sh: TSan leg (make test-tsan) ==="
make -j"$NPROC" -f Makefile.cbm "$BUILD_DIR/test-runner-tsan" ${MAKE_ARGS[@]+"${MAKE_ARGS[@]}"}
make -f Makefile.cbm test-tsan ${MAKE_ARGS[@]+"${MAKE_ARGS[@]}"}
exit "$?"
fi
# ── Iteration mode (--suites): incremental rebuild + subset run ──
# The documented fast path: no clean, no contract steps, no prod-binary
# guards — those all still gate every merge through the default full run.
if [ -n "$SUITES" ]; then
echo "=== test.sh: ITERATION mode — suites: $SUITES (incremental build) ==="
make -j"$NPROC" -f Makefile.cbm "$BUILD_DIR/test-runner" ${MAKE_ARGS[@]+"${MAKE_ARGS[@]}"}
# shellcheck disable=SC2086 # suite list is deliberately word-split
"$BUILD_DIR/test-runner" $SUITES
exit "$?"
fi
# Step 0: fast build/security harness regressions run before the compiler-heavy
# suite. The Windows package surface is static here; native launcher behavior is
# exercised by scripts/test-windows.ps1.
echo "=== Step 0a: build directory safety contract ==="
bash "$ROOT/tests/test_build_dir_safety.sh"
echo "=== Step 0b: Windows VM worktree sync contract ==="
bash "$ROOT/tests/test_vm_worktree_manifest.sh"
echo "=== Step 0c: UI development proxy security contract ==="
bash "$ROOT/tests/test_ui_dev_proxy_security.sh"
echo "=== Step 0d: daemon soak recovery contract ==="
bash "$ROOT/tests/test_soak_daemon_recovery_contract.sh"
echo "=== Step 0e: Windows launcher bundle contract ==="
bash "$ROOT/tests/test_windows_bundle_contract.sh"
echo "=== Step 0f: tree-sitter runtime Makefile dependencies ==="
bash "$ROOT/tests/test_makefile_ts_runtime_dependencies.sh"
echo "=== Step 0g: security fuzz harness self-test ==="
bash "$ROOT/tests/test_security_fuzz_harness.sh"
echo "=== Step 0h: smoke release-fixture contract ==="
bash "$ROOT/tests/test_smoke_fixture_contract.sh"
echo "=== Step 0i: parallel suite scheduler contract ==="
bash "$ROOT/tests/test_parallel_harness_contract.sh"
echo "=== Step 0j: venue parity contract (one harness, every venue) ==="
bash "$ROOT/tests/test_venue_parity_contract.sh"
# Verify compiler supports target arch
verify_compiler "$CC"
# Step 1: Clean (scoped to this leg's build directory)
BUILD_DIR="$BUILD_DIR" scripts/clean.sh
# Step 2 + 3: Build, then run every suite as parallel processes (identical
# gate quality — see the ZERO-LOSS CONTRACT in scripts/run-tests-parallel.sh:
# the suite set is enumerated from the runner itself and union-guarded, and
# pass/fail/skip totals aggregate to the same numbers as the sequential run).
# CBM_TEST_SEQUENTIAL=1 restores the single-process runner.
make -j"$NPROC" -f Makefile.cbm "$BUILD_DIR/test-runner" ${MAKE_ARGS[@]+"${MAKE_ARGS[@]}"}
if [ "${CBM_TEST_SEQUENTIAL:-0}" = "1" ]; then
make -f Makefile.cbm test ${MAKE_ARGS[@]+"${MAKE_ARGS[@]}"}
else
make -f Makefile.cbm test-par ${MAKE_ARGS[@]+"${MAKE_ARGS[@]}"}
fi
# Step 4: C++ large-TU index-hang regression guard (#410). Runs the PROD binary
# in a subprocess with a wall-clock timeout — a hang must fail, not block the run.
# Opt-in via CBM_RUN_HANG_TEST=1 (it needs the prod binary, which the ASan unit
# run above does not build). Skipped by default so the fast unit run stays fast.
if [ "${CBM_RUN_HANG_TEST:-0}" = "1" ]; then
echo "=== Step 4: C++ index-hang regression (#410) ==="
bash "$ROOT/tests/test_cpp_index_hang.sh"
fi
# Step 5: Parent-death watchdog regression (#406/#407). Builds the prod stdio
# binary and verifies it self-exits when its launching parent is killed.
#
# TEST_SEAMS=1: the worker-mode leg below needs the crash-orphan probe, which is
# compiled out of ordinary builds (it forks a SIGTERM-ignoring child — see
# src/main.c). Requesting it HERE, in the leg that consumes it, is what keeps
# release artifacts free of it; scripts/ci/check-binary-composition.sh proves
# they stay that way.
echo "=== Step 5: parent-death watchdog regression (#406/#407) ==="
make -j"$NPROC" -f Makefile.cbm cbm TEST_SEAMS=1 ${MAKE_ARGS[@]+"${MAKE_ARGS[@]}"}
WATCHDOG_BINARY="$ROOT/$BUILD_DIR/codebase-memory-mcp"
CBM_TEST_BINARY="$WATCHDOG_BINARY" bash "$ROOT/tests/test_parent_watchdog.sh"
# Step 5b: worker-mode parent-death watchdog (#845). A supervised index worker
# (`cli --index-worker …`) whose supervisor dies must self-exit instead of
# indexing on as an orphan. Reuses the prod binary built in Step 5.
echo "=== Step 5b: worker-mode watchdog regression (#845) ==="
CBM_TEST_BINARY="$WATCHDOG_BINARY" bash "$ROOT/tests/test_worker_watchdog.sh"
# Step 6: security-strings URL allow-list regression. The MSYS2 CLANG64 toolchain
# bakes its package-tracker URL into the static Windows .exe; the binary string
# 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"
echo "=== All tests passed ==="