diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee03b33..c3cc20c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Python uses: actions/setup-python@v5 @@ -63,8 +65,51 @@ jobs: python -m py_compile memoriaia/verify/verify-hashchain.py - name: Whitespace guard + env: + VT_WS_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before || '' }} + VT_WS_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + VT_WS_EVENT_NAME: ${{ github.event_name }} run: | - git diff --check + if ! printf '%s\n' "$VT_WS_BASE_SHA" | grep -qE '^[0-9a-f]{40}$'; then + echo "WHITESPACE FAIL: base SHA is invalid ($VT_WS_BASE_SHA)" + exit 1 + fi + if ! printf '%s\n' "$VT_WS_HEAD_SHA" | grep -qE '^[0-9a-f]{40}$'; then + echo "WHITESPACE FAIL: head SHA is invalid ($VT_WS_HEAD_SHA)" + exit 1 + fi + if ! git cat-file -e "$VT_WS_BASE_SHA^{commit}" 2>/dev/null; then + echo "WHITESPACE FAIL: base commit is unavailable ($VT_WS_BASE_SHA)" + exit 1 + fi + if ! git cat-file -e "$VT_WS_HEAD_SHA^{commit}" 2>/dev/null; then + echo "WHITESPACE FAIL: head commit is unavailable ($VT_WS_HEAD_SHA)" + exit 1 + fi + if [ "$VT_WS_EVENT_NAME" = "pull_request" ] || [ "$VT_WS_EVENT_NAME" = "pull_request_target" ]; then + WHITESPACE_RANGE="$VT_WS_BASE_SHA...$VT_WS_HEAD_SHA" + else + WHITESPACE_RANGE="$VT_WS_BASE_SHA..$VT_WS_HEAD_SHA" + fi + echo "WHITESPACE RANGE: $WHITESPACE_RANGE" + WHITESPACE_CHANGED_FILE=.vt-whitespace-changed-paths.txt + git diff --name-status --no-renames "$WHITESPACE_RANGE" > "$WHITESPACE_CHANGED_FILE" + WS_ATTR_CHANGED=0 + while IFS= read -r ws_line; do + [ -z "$ws_line" ] && continue + ws_path="${ws_line#*$'\t'}" + case "$ws_path" in + .gitattributes|*/.gitattributes) + WS_ATTR_CHANGED=1 + ;; + esac + done < "$WHITESPACE_CHANGED_FILE" + if [ "$WS_ATTR_CHANGED" -eq 1 ]; then + echo "WHITESPACE FAIL: .gitattributes changed in checked range ($WHITESPACE_RANGE)" + exit 1 + fi + rm -f "$WHITESPACE_CHANGED_FILE" + git -c core.attributesFile=/dev/null diff --check "$WHITESPACE_RANGE" - name: Run verification gate suite id: run_gates @@ -180,8 +225,8 @@ jobs: - name: G-19 CI anti-theater (run-gates execution proof required) if: always() env: - VT_G19_EXPECTED_RUN_GATES_SHA: "d1ec3239d04d0cd3e716a2a6054befb76723146396f2de25550b194c36ff234b" - VT_G19_EXPECTED_STRUCTURAL_CHECK_SHA: "4863e581cfa202621ca8a1529c0fca44faa348b4df30e1eb31e84addc1432716" + VT_G19_EXPECTED_RUN_GATES_SHA: "d61172ad3ebfa6705738d54f58a464d3aa508910cd96a37e59a23e81619ba678" + VT_G19_EXPECTED_STRUCTURAL_CHECK_SHA: "121d7484172ed7c931d0fd57101d972a9eca9f73d41058b4ca04183893acb93d" VT_G19_EXPECTED_WORKSPACE_HELPER_SHA: "e105de0bca00c509bfff6923515cccb9ab416013a20f17ff90a7159c52890a06" VT_G19_EXPECTED_VERIFY_PY_SHA: "abedd126eac0227dddad9267d8863d1b3b4921ef0bdc69301f59b9c9d708683b" VT_G19_EXPECTED_VERIFY_SH_SHA: "65694b5e0abe95f3322c45ab7c69b597d83121ec14001bdbba8e6e83f2224192" diff --git a/tests/g19-v2-structural-check.sh b/tests/g19-v2-structural-check.sh index 1c489a7..e189aae 100644 --- a/tests/g19-v2-structural-check.sh +++ b/tests/g19-v2-structural-check.sh @@ -248,6 +248,7 @@ else: GATE_STEP_NAME = "Run verification gate suite" SENTINEL_STEP_NAME = "G-19 CI anti-theater (run-gates execution proof required)" +WHITESPACE_STEP_NAME = "Whitespace guard" def find_direct_child(start, end, parent_indent, key_name): @@ -726,6 +727,19 @@ def step_env_values(step): return values +def step_with_values(step): + values = {} + for with_index in step["key_indexes"].get("with", []): + with_end = block_end(with_index, 8, SCALAR_LINES) + for index in range(with_index + 1, with_end): + if SCALAR_LINES[index]: + continue + parsed = parse_key(lines[index]) + if parsed and parsed["indent"] == 10: + values[parsed["key"]] = strip_quotes(parsed["value"]) + return values + + def writes_execution_proof_output(run_lines): code_lines = [normalize_shell_words(line.split("#", 1)[0]) for line in executable_lines(run_lines)] joined = "\n".join(code_lines) @@ -999,10 +1013,95 @@ if top_jobs_index is not None: gate_steps = [step for step in steps if step["name"] == GATE_STEP_NAME] sentinel_steps = [step for step in steps if step["name"] == SENTINEL_STEP_NAME] + whitespace_steps = [step for step in steps if step["name"] == WHITESPACE_STEP_NAME] if len(gate_steps) != 1: fail(f"expected exactly one '{GATE_STEP_NAME}' step, found {len(gate_steps)}") if len(sentinel_steps) != 1: fail(f"expected exactly one '{SENTINEL_STEP_NAME}' step, found {len(sentinel_steps)}") + if strict_workflow_proof and len(whitespace_steps) != 1: + fail(f"expected exactly one '{WHITESPACE_STEP_NAME}' step, found {len(whitespace_steps)}") + + if strict_workflow_proof: + checkout_steps = [step for step in steps if step["name"] == "Checkout"] + if len(checkout_steps) != 1: + fail(f"expected exactly one 'Checkout' step, found {len(checkout_steps)}") + else: + checkout = checkout_steps[0] + if strip_quotes(checkout["keys"].get("uses", "")) != "actions/checkout@v4": + fail("Checkout step must use actions/checkout@v4") + checkout_with = step_with_values(checkout) + if checkout_with.get("fetch-depth") != "0": + fail("Checkout step must set with.fetch-depth to 0 for range-aware whitespace verification") + + if len(whitespace_steps) == 1: + whitespace = whitespace_steps[0] + whitespace_exec = executable_lines(whitespace["run_lines"]) + whitespace_env = step_env_values(whitespace) + if "if" in whitespace["keys"]: + fail("whitespace guard must not define an if guard") + if "continue-on-error" in whitespace["keys"]: + fail("whitespace guard must not define continue-on-error") + if "shell" in whitespace["keys"] and strip_quotes(whitespace["keys"]["shell"]) != "bash": + fail("whitespace guard shell must be exactly bash when present") + if "working-directory" in whitespace["keys"]: + fail("whitespace guard must not define working-directory") + if whitespace["run_style"] == ">": + fail("folded scalar run: > found on whitespace guard") + if any(contains_neutralizer(line) for line in whitespace_exec): + fail("whitespace guard contains gate-neutralizing pattern(s)") + if len(whitespace_exec) == 1 and whitespace_exec[0] == "git diff --check": + fail("whitespace guard must not rely on bare git diff --check") + if strict_workflow_proof: + expected_whitespace_exec = [ + 'if ! printf \'%s\\n\' "$VT_WS_BASE_SHA" | grep -qE \'^[0-9a-f]{40}$\'; then', + 'echo "WHITESPACE FAIL: base SHA is invalid ($VT_WS_BASE_SHA)"', + "exit 1", + "fi", + 'if ! printf \'%s\\n\' "$VT_WS_HEAD_SHA" | grep -qE \'^[0-9a-f]{40}$\'; then', + 'echo "WHITESPACE FAIL: head SHA is invalid ($VT_WS_HEAD_SHA)"', + "exit 1", + "fi", + 'if ! git cat-file -e "$VT_WS_BASE_SHA^{commit}" 2>/dev/null; then', + 'echo "WHITESPACE FAIL: base commit is unavailable ($VT_WS_BASE_SHA)"', + "exit 1", + "fi", + 'if ! git cat-file -e "$VT_WS_HEAD_SHA^{commit}" 2>/dev/null; then', + 'echo "WHITESPACE FAIL: head commit is unavailable ($VT_WS_HEAD_SHA)"', + "exit 1", + "fi", + 'if [ "$VT_WS_EVENT_NAME" = "pull_request" ] || [ "$VT_WS_EVENT_NAME" = "pull_request_target" ]; then', + 'WHITESPACE_RANGE="$VT_WS_BASE_SHA...$VT_WS_HEAD_SHA"', + "else", + 'WHITESPACE_RANGE="$VT_WS_BASE_SHA..$VT_WS_HEAD_SHA"', + "fi", + 'echo "WHITESPACE RANGE: $WHITESPACE_RANGE"', + 'WHITESPACE_CHANGED_FILE=.vt-whitespace-changed-paths.txt', + 'git diff --name-status --no-renames "$WHITESPACE_RANGE" > "$WHITESPACE_CHANGED_FILE"', + 'WS_ATTR_CHANGED=0', + 'while IFS= read -r ws_line; do', + '[ -z "$ws_line" ] && continue', + "ws_path=\"${ws_line#*$'\\t'}\"", + 'case "$ws_path" in', + '.gitattributes|*/.gitattributes)', + 'WS_ATTR_CHANGED=1', + ';;', + 'esac', + 'done < "$WHITESPACE_CHANGED_FILE"', + 'if [ "$WS_ATTR_CHANGED" -eq 1 ]; then', + 'echo "WHITESPACE FAIL: .gitattributes changed in checked range ($WHITESPACE_RANGE)"', + "exit 1", + "fi", + 'rm -f "$WHITESPACE_CHANGED_FILE"', + 'git -c core.attributesFile=/dev/null diff --check "$WHITESPACE_RANGE"', + ] + if whitespace_exec != expected_whitespace_exec: + fail("whitespace guard must match the exact range-aware command sequence") + if whitespace_env.get("VT_WS_BASE_SHA") != "${{ github.event.pull_request.base.sha || github.event.before || '' }}": + fail("whitespace guard env must define VT_WS_BASE_SHA from GitHub event context") + if whitespace_env.get("VT_WS_HEAD_SHA") != "${{ github.event.pull_request.head.sha || github.sha }}": + fail("whitespace guard env must define VT_WS_HEAD_SHA from GitHub event context") + if whitespace_env.get("VT_WS_EVENT_NAME") != "${{ github.event_name }}": + fail("whitespace guard env must define VT_WS_EVENT_NAME from GitHub event context") if len(gate_steps) == 1: gate = gate_steps[0] diff --git a/tests/run-gates.sh b/tests/run-gates.sh index 96675c9..37f8315 100644 --- a/tests/run-gates.sh +++ b/tests/run-gates.sh @@ -858,6 +858,285 @@ if [ "$G19_FIXTURE_MISSING" -eq 0 ]; then done fi +WHITESPACE_REPO="$WORK/whitespace-range-repo" +mkdir -p "$WHITESPACE_REPO" +WHITESPACE_OUT="$ROOT/$OUT" +if ( + cd "$WHITESPACE_REPO" && + git init -q && + git config user.name "verification-tools-gates" && + git config user.email "verification-tools@example.invalid" && + git config core.autocrlf false && + printf 'alpha\n' > clean.txt && + git add clean.txt && + git commit -q -m "base" && + WS_BASE_SHA="$(git rev-parse HEAD)" && + printf 'alpha \n' > clean.txt && + git add clean.txt && + git commit -q -m "head-with-whitespace" && + WS_HEAD_SHA="$(git rev-parse HEAD)" && + git diff --check >"$WHITESPACE_OUT" 2>&1 +); then + pass "G-20 bare git diff --check misses committed whitespace on clean worktree" +else + fail "G-20 bare git diff --check misses committed whitespace on clean worktree" "$(tr '\n' ';' <"$WHITESPACE_OUT")" +fi + +if ( + cd "$WHITESPACE_REPO" && + WS_BASE_SHA="$(git rev-parse HEAD~1)" && + WS_HEAD_SHA="$(git rev-parse HEAD)" && + git -c core.attributesFile=/dev/null diff --check "$WS_BASE_SHA..$WS_HEAD_SHA" >"$WHITESPACE_OUT" 2>&1 +); then + fail "G-21 range-aware whitespace guard rejects committed whitespace" "range-aware diff unexpectedly passed" +else + pass "G-21 range-aware whitespace guard rejects committed whitespace" +fi + +if ( + cd "$WHITESPACE_REPO" && + WS_BASE_SHA="0000000000000000000000000000000000000000" && + WS_HEAD_SHA="$(git rev-parse HEAD)" && + git cat-file -e "$WS_BASE_SHA^{commit}" 2>/dev/null && + git diff --check "$WS_BASE_SHA..$WS_HEAD_SHA" >"$WHITESPACE_OUT" 2>&1 +); then + fail "G-22 whitespace guard rejects invalid base SHA" "invalid base SHA unexpectedly passed" +else + pass "G-22 whitespace guard rejects invalid base SHA" +fi + +if ( + cd "$WHITESPACE_REPO" && + git checkout -q HEAD~1 && + WS_BASE_SHA="$(git rev-parse HEAD)" && + printf 'beta\n' > clean.txt && + git add clean.txt && + git commit -q -m "head-clean" && + WS_HEAD_SHA="$(git rev-parse HEAD)" && + git -c core.attributesFile=/dev/null diff --check "$WS_BASE_SHA..$WS_HEAD_SHA" >"$WHITESPACE_OUT" 2>&1 +); then + pass "G-23 range-aware whitespace guard passes clean committed range" +else + fail "G-23 range-aware whitespace guard passes clean committed range" "$(tr '\n' ';' <"$WHITESPACE_OUT")" +fi + +WHITESPACE_PR_REPO="$WORK/whitespace-pr-merge-base-repo" +mkdir -p "$WHITESPACE_PR_REPO" +if ( + cd "$WHITESPACE_PR_REPO" && + git init -q && + git config user.name "verification-tools-gates" && + git config user.email "verification-tools@example.invalid" && + git config core.autocrlf false && + printf 'legacy \n' > inherited.txt && + git add inherited.txt && + git commit -q -m "branch-point-with-legacy-whitespace" && + git branch feature && + printf 'legacy\n' > inherited.txt && + git add inherited.txt && + git commit -q -m "main-cleans-legacy-whitespace" && + WS_BASE_SHA="$(git rev-parse HEAD)" && + printf '%s' "$WS_BASE_SHA" > .ws-base-sha && + git checkout -q feature && + printf 'feature\n' > feature.txt && + git add feature.txt && + git commit -q -m "feature-does-not-touch-legacy-whitespace" && + WS_HEAD_SHA="$(git rev-parse HEAD)" && + git -c core.attributesFile=/dev/null diff --check "$WS_BASE_SHA..$WS_HEAD_SHA" >"$WHITESPACE_OUT" 2>&1 +); then + fail "G-24 two-dot whitespace diff false-positives after base cleanup" "two-dot diff unexpectedly passed" +else + pass "G-24 two-dot whitespace diff false-positives after base cleanup" +fi + +if ( + cd "$WHITESPACE_PR_REPO" && + WS_BASE_SHA="$(cat .ws-base-sha)" && + WS_HEAD_SHA="$(git rev-parse HEAD)" && + git -c core.attributesFile=/dev/null diff --check "$WS_BASE_SHA...$WS_HEAD_SHA" >"$WHITESPACE_OUT" 2>&1 +); then + pass "G-25 triple-dot whitespace diff uses merge base for pull requests" +else + fail "G-25 triple-dot whitespace diff uses merge base for pull requests" "$(tr '\n' ';' <"$WHITESPACE_OUT")" +fi + +WHITESPACE_ATTR_REPO="$WORK/whitespace-attributes-repo" +mkdir -p "$WHITESPACE_ATTR_REPO" +if ( + cd "$WHITESPACE_ATTR_REPO" && + git init -q && + git config user.name "verification-tools-gates" && + git config user.email "verification-tools@example.invalid" && + git config core.autocrlf false && + printf 'clean\n' > tracked.txt && + git add tracked.txt && + git commit -q -m "base-clean" && + WS_BASE_SHA="$(git rev-parse HEAD)" && + printf '* -whitespace\n' > .gitattributes && + printf 'dirty \n' > tracked.txt && + git add .gitattributes tracked.txt && + git commit -q -m "head-relaxes-whitespace-attributes" && + WS_HEAD_SHA="$(git rev-parse HEAD)" && + git diff --name-only "$WS_BASE_SHA..$WS_HEAD_SHA" | grep -qE '(^|/)\.gitattributes$' +); then + pass "G-26 whitespace guard rejects checked-range .gitattributes changes" +else + fail "G-26 whitespace guard rejects checked-range .gitattributes changes" ".gitattributes change was not detected in the checked range" +fi + +WHITESPACE_PIPEFAIL_REPO="$WORK/whitespace-attributes-pipefail-repo" +mkdir -p "$WHITESPACE_PIPEFAIL_REPO" +if ( + cd "$WHITESPACE_PIPEFAIL_REPO" && + git init -q && + git config user.name "verification-tools-gates" && + git config user.email "verification-tools@example.invalid" && + git config core.autocrlf false && + printf 'clean\n' > tracked.txt && + git add tracked.txt && + git commit -q -m "base-clean" && + WS_BASE_SHA="$(git rev-parse HEAD)" && + printf '* -whitespace\n' > .gitattributes && + i=0 && + while [ "$i" -lt 512 ]; do + printf 'filler %s\n' "$i" > "$(printf 'z%03d.txt' "$i")" && + i=$((i + 1)) + done && + git add .gitattributes z*.txt && + git commit -q -m "head-many-files-with-attributes" && + WS_HEAD_SHA="$(git rev-parse HEAD)" && + set -o pipefail && + WHITESPACE_CHANGED_PATHS="$(git diff --name-status --no-renames "$WS_BASE_SHA..$WS_HEAD_SHA")" && + WS_ATTR_CHANGED=0 && + while IFS= read -r ws_line; do + [ -z "$ws_line" ] && continue + ws_path="${ws_line##* }" + case "$ws_path" in + .gitattributes|*/.gitattributes) + WS_ATTR_CHANGED=1 + ;; + esac + done < .gitattributes && + printf 'clean\n' > tracked.txt && + git add .gitattributes tracked.txt && + git commit -q -m "base-with-gitattributes" && + WS_BASE_SHA="$(git rev-parse HEAD)" && + git mv .gitattributes attrs && + printf '\tindented\n' > tracked.txt && + git add attrs tracked.txt && + git commit -q -m "rename-away-gitattributes" && + WS_HEAD_SHA="$(git rev-parse HEAD)" && + ! git diff --name-only "$WS_BASE_SHA..$WS_HEAD_SHA" | grep -qE '(^|/)\.gitattributes$' +); then + pass "G-29 name-only .gitattributes detection misses rename-away sources" +else + fail "G-29 name-only .gitattributes detection misses rename-away sources" "name-only unexpectedly detected renamed-away .gitattributes" +fi + +if ( + cd "$WHITESPACE_RENAME_REPO" && + WS_BASE_SHA="$(git rev-parse HEAD~1)" && + WS_HEAD_SHA="$(git rev-parse HEAD)" && + WHITESPACE_CHANGED_PATHS="$(git diff --name-status --no-renames "$WS_BASE_SHA..$WS_HEAD_SHA")" && + WS_ATTR_CHANGED=0 && + while IFS= read -r ws_line; do + [ -z "$ws_line" ] && continue + ws_path="${ws_line##* }" + case "$ws_path" in + .gitattributes|*/.gitattributes) + WS_ATTR_CHANGED=1 + ;; + esac + done < tracked.txt && + git add tracked.txt && + git commit -q -m "base-clean" && + WS_BASE_SHA="$(git rev-parse HEAD)" && + printf 'fixture only\n' > docs/foo.gitattributes && + git add docs/foo.gitattributes && + git commit -q -m "head-adds-suffix-only-file" && + WS_HEAD_SHA="$(git rev-parse HEAD)" && + WHITESPACE_CHANGED_PATHS="$(git diff --name-status --no-renames "$WS_BASE_SHA..$WS_HEAD_SHA")" && + WS_ATTR_CHANGED=0 && + while IFS= read -r ws_line; do + [ -z "$ws_line" ] && continue + ws_path="${ws_line##* }" + case "$ws_path" in + .gitattributes|*/.gitattributes) + WS_ATTR_CHANGED=1 + ;; + esac + done </dev/null || printf 'unknown')"