diff --git a/scripts/test.sh b/scripts/test.sh index 8b9cade28..cdc6d3e1d 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -357,6 +357,12 @@ else make -f Makefile.cbm test-par ${MAKE_ARGS[@]+"${MAKE_ARGS[@]}"} fi +# Step 3a: the runner must ignore an inherited git repository environment +# (#2003: the pre-commit hook exports GIT_DIR, which overrides `git -C` and +# pointed fixture git commands at the committer's real repository). +echo "=== Step 3a: inherited git environment isolation (#2003) ===" +bash "$ROOT/tests/test_git_env_isolation_contract.sh" "$ROOT/$BUILD_DIR/test-runner" + # 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 diff --git a/tests/repro/repro_main.c b/tests/repro/repro_main.c index 7fc1cc20b..b7c478ac7 100644 --- a/tests/repro/repro_main.c +++ b/tests/repro/repro_main.c @@ -26,6 +26,7 @@ int tf_fail_count = 0; int tf_skip_count = 0; #include "test_framework.h" +#include "test_helpers.h" #include "repro_runner.h" #include "foundation/compat.h" /* cbm_setenv — #845 supervisor kill switch */ #include "daemon/bootstrap.h" /* refuse the developer's default daemon rendezvous */ @@ -139,6 +140,8 @@ int main(void) { * loudly instead of reaching it. */ cbm_daemon_bootstrap_forbid_default_runtime_for_test(true); #endif + /* #2003: fixture git commands must never reach the caller's repository. */ + th_clear_git_repo_env(); /* Unbuffered: a reproduction may crash/_exit (or a sanitizer may _exit on a * leak) before stdio flushes — keep every printed line so the summary and the diff --git a/tests/test_git_env_isolation_contract.sh b/tests/test_git_env_isolation_contract.sh new file mode 100644 index 000000000..17a2b185d --- /dev/null +++ b/tests/test_git_env_isolation_contract.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Contract (#2003): the test runner must be immune to an inherited git +# repository environment. +# +# Git exports GIT_DIR, GIT_INDEX_FILE, ... into hooks, and those variables take +# precedence over `git -C `. The pre-commit hook runs the suite, so every +# fixture git command in the runner used to act on the committer's REAL +# repository: fixture assertions failed, and branches, commits and linked +# worktrees were written into the contributor's clone. The runner now clears +# git's local-env list at startup (th_clear_git_repo_env in tests/test_helpers.h). +# +# This drives the real runner with those variables aimed at a throwaway decoy +# repository and asserts both halves: the git fixture suite passes, and the +# decoy is left exactly as it was (no refs, no linked worktrees). +# +# Usage: tests/test_git_env_isolation_contract.sh [path/to/test-runner] +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUNNER="${1:-$ROOT/build/c/test-runner}" +SUITE="git_context" + +if [ ! -x "$RUNNER" ]; then + echo "FAIL: test runner not found at '$RUNNER'; build it first (make -f Makefile.cbm build/c/test-runner)." >&2 + exit 1 +fi +if ! command -v git >/dev/null 2>&1; then + echo "SKIP: git is not on PATH, so there is no git environment to leak." + exit 0 +fi + +# This script's own git calls must address the decoy, not whatever repository +# the caller (e.g. the pre-commit hook) exported. +while IFS= read -r variable; do + unset "$variable" +done < <(git rev-parse --local-env-vars) + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/cbm-git-env-XXXXXX")" +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT + +decoy_state() { + printf 'refs:\n' + git -C "$1" for-each-ref + printf 'worktrees:\n' + git -C "$1" worktree list --porcelain +} + +# Baseline: the same suite in a clean environment. Comparing summaries (not just +# exit codes) matters: with GIT_WORK_TREE inherited, fixture commits fail and +# the suite SKIPs as "git not available" instead of failing -- a silent green. +BASE_LOG="$WORK/baseline.log" +"$RUNNER" "$SUITE" >"$BASE_LOG" 2>&1 || { + echo "FAIL: '$SUITE' is red even in a clean environment; fix that first." >&2 + grep -E 'FAIL|passed' "$BASE_LOG" >&2 || true + exit 1 +} + +summary() { grep -E '^ *[0-9]+ passed' "$1" | tail -1; } +BASE_SUMMARY="$(summary "$BASE_LOG")" +FAILED=0 + +# Two inherited shapes: GIT_DIR alone (the reported `GIT_DIR=... scripts/test.sh` +# case: fixture git commands write into the decoy) and the full set a hook +# exports (fixture commits fail, so the suite degrades to SKIPs). +check_variant() { + local name="$1" + local decoy="$WORK/decoy-$name" + shift + git init -q "$decoy" + local before + before="$(decoy_state "$decoy")" + local -a assignments=() + local var + for var in "$@"; do + assignments+=("$var=$decoy$(git_env_suffix "$var")") + done + local log="$WORK/runner-$name.log" + local rc + set +e + env "${assignments[@]}" "$RUNNER" "$SUITE" >"$log" 2>&1 + rc=$? + set -e + local env_summary after + env_summary="$(summary "$log")" + after="$(decoy_state "$decoy")" + if [ "$rc" -ne 0 ]; then + echo "FAIL[$name]: '$SUITE' exited $rc with an inherited git environment." >&2 + grep -E 'FAIL|passed' "$log" >&2 || true + FAILED=1 + fi + if [ -z "$BASE_SUMMARY" ] || [ "$BASE_SUMMARY" != "$env_summary" ]; then + echo "FAIL[$name]: '$SUITE' behaves differently with an inherited git environment." >&2 + echo " clean env: ${BASE_SUMMARY:-}" >&2 + echo " inherited env: ${env_summary:-}" >&2 + grep -E 'FAIL|SKIP' "$log" >&2 || true + FAILED=1 + fi + if [ "$before" != "$after" ]; then + echo "FAIL[$name]: the test runner modified the repository named by the inherited GIT_DIR." >&2 + echo "--- before ---" >&2 + echo "$before" >&2 + echo "--- after ---" >&2 + echo "$after" >&2 + FAILED=1 + fi +} + +# Where each exported variable points inside a repository, as a hook sees it. +git_env_suffix() { + case "$1" in + GIT_WORK_TREE) printf '' ;; + GIT_INDEX_FILE) printf '/.git/index' ;; + *) printf '/.git' ;; + esac +} + +check_variant git-dir GIT_DIR +check_variant hook-env GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR + +if [ "$FAILED" -ne 0 ]; then + exit 1 +fi +echo "PASS: test runner ignores an inherited git repository environment (#2003)." diff --git a/tests/test_helpers.h b/tests/test_helpers.h index 2559bafb5..91ea56f50 100644 --- a/tests/test_helpers.h +++ b/tests/test_helpers.h @@ -226,4 +226,37 @@ static inline void th_cleanup(const char *path) { } } +/* ── Inherited git repository environment (#2003) ─────────────── */ + +/* Git exports repository-local variables into hooks (GIT_DIR, GIT_INDEX_FILE, + * ...), and they take precedence over `git -C `. A test runner started + * from a pre-commit hook (or any shell with GIT_DIR set) would otherwise point + * every fixture's git command at the caller's real repository: branches and + * worktrees get created there and fixture assertions fail. This is the list + * `git rev-parse --local-env-vars` prints; runners clear it once at startup so + * every spawned git (and every re-exec'd child) scopes to its fixture. */ +static const char *const th_git_repo_env_vars[] = { + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CONFIG", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_COUNT", + "GIT_OBJECT_DIRECTORY", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_IMPLICIT_WORK_TREE", + "GIT_GRAFT_FILE", + "GIT_INDEX_FILE", + "GIT_NO_REPLACE_OBJECTS", + "GIT_REPLACE_REF_BASE", + "GIT_PREFIX", + "GIT_SHALLOW_FILE", + "GIT_COMMON_DIR", +}; + +static inline void th_clear_git_repo_env(void) { + for (size_t i = 0U; i < sizeof(th_git_repo_env_vars) / sizeof(th_git_repo_env_vars[0]); i++) { + (void)cbm_unsetenv(th_git_repo_env_vars[i]); + } +} + #endif /* TEST_HELPERS_H */ diff --git a/tests/test_main.c b/tests/test_main.c index 5c3dcf067..08552160a 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -1028,6 +1028,9 @@ extern void suite_dump_verify_io(void); extern void cbm_kind_in_set_free_cache(void); int main(int argc, char **argv) { + /* #2003: never let a caller's GIT_DIR/GIT_INDEX_FILE/... redirect fixture + * git commands at the caller's real repository. */ + th_clear_git_repo_env(); int memory_limit_probe_rc = tf_maybe_run_windows_memory_limit_probe(argc, argv); if (memory_limit_probe_rc >= 0) { return memory_limit_probe_rc;