fix(git): push-guard — resolve re-review blockers; mutation table now generated
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Authored by installer-7; committed by mos-claude (no credential for this remote). BLOCKER 1 — verify-clean-clone.sh had B1's own defect: it copied WORKING-TREE files into a scratch repo and asserted the SCRATCH index, so a stale local exec bit was laundered in and it reported success while the committed artifact was still 100644. v2 reads mode from 'git ls-tree' of the SOURCE COMMIT and runs from 'git clone --no-local --no-hardlinks' of that commit; there is no 'cp' in the file. Proven by A/B against one laundered repo: v1 EXIT=0, v2 EXIT=1 (both observed, not asserted). New test-verify-clean-clone.sh 6/6, incl. a fixture that first proves it really is git=100644 disk=755 before testing it. BLOCKER 2 — empty-merge needle + non-empty-merge control, both asserting on OUTPUT; the fixture first proves it IS a merge with a tree identical to both parents. The 'non-blocking' README item was not documentation: regenerating its mutation table (now generated by mutate-push-guard.sh, not hand-numbered) found TWO genuinely surviving mutants against a 46/46 green suite — staged-but-uncommitted opt-out honoured, and unparseable committed config ignored. Both still exit 6 under mutation because control falls to a SIBLING refusal, so an exit-code-only assertion would have been satisfied by the wrong branch. It also found a live instance of the substring-anchor defect already in the suite: three branches print 'OPT-OUT IS NOT REVIEWABLE', so deleting one let the needle RE-POINT to a sibling instead of failing. Re-anchored. Suite 44 -> 46. Verifier 6/6. 13 mutants, 0 survived. Also: README reformatted to satisfy 'pnpm format:check' (prettier), which was failing CI at both prior heads — a gate neither author nor reviewer had exercised. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01YKj59Qadrb2WBLaePvkM7H
This commit is contained in:
co-authored by
Claude Opus 5
parent
b0fb208b89
commit
8310075d33
+143
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
# mutate-push-guard.sh -- regenerate the README's mutation table from MEASUREMENT.
|
||||
#
|
||||
# WHY THIS EXISTS. The README carried a hand-written mutation table quoting
|
||||
# "32/32" style results. Those numbers were true when typed and went stale in
|
||||
# silence as the suite grew. A README is what a reader trusts when the tool
|
||||
# misbehaves, so a confidently-wrong one is worse than none. Every number the
|
||||
# README prints about mutation now comes out of this script.
|
||||
#
|
||||
# TWO WAYS A MUTATION RUN LIES, AND THE GUARD FOR EACH:
|
||||
#
|
||||
# 1. THE ANCHOR NO LONGER MATCHES. The mutant is never applied, the suite is
|
||||
# green, and the report says SURVIVED -- which is the same word a real
|
||||
# coverage gap gets. Guarded by grep-before-mutate (ANCHOR MISSING).
|
||||
#
|
||||
# 2. THE ANCHOR MATCHES PROSE. This one bit me on the first run: my
|
||||
# "revert refuse-on-missing-config" mutant landed inside the usage()
|
||||
# heredoc, edited a help string, changed no behaviour, and duly reported
|
||||
# SURVIVED. I nearly recorded a documentation edit as an uncovered branch.
|
||||
# A MUTATION THAT CANNOT CHANGE BEHAVIOUR IS NOT A SURVIVING MUTANT, IT IS
|
||||
# A NON-MEASUREMENT -- and a non-measurement reported as a result is the
|
||||
# same defect as the vacuous test it is supposed to be hunting.
|
||||
# Guarded by prose_range(): anchors inside usage() are a loud refusal.
|
||||
#
|
||||
# 3. THE ANCHOR IS AMBIGUOUS. replace(...,1) silently picks the first match,
|
||||
# which may not be the branch named in the label. Guarded by an exact
|
||||
# occurrence count.
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET="$HERE/push-guard.sh"
|
||||
SUITE="$HERE/test-push-guard.sh"
|
||||
BAK="$(mktemp)"; cp "$TARGET" "$BAK"
|
||||
trap 'cp "$BAK" "$TARGET"; rm -f "$BAK"' EXIT
|
||||
|
||||
# --- where the prose lives: usage() { ... EOF ---------------------------------
|
||||
PROSE_LO="$(grep -n '^usage() {' "$BAK" | head -1 | cut -d: -f1)"
|
||||
PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")"
|
||||
if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then
|
||||
echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'prose (usage heredoc) is lines %s-%s -- anchors there are refused, not scored\n\n' \
|
||||
"$PROSE_LO" "$PROSE_HI"
|
||||
|
||||
rc_all=0
|
||||
KILLED=0; SURVIVED=0
|
||||
declare -a ROWS=()
|
||||
|
||||
mutate() {
|
||||
local name="$1" find="$2" repl="$3"
|
||||
# Count and locate in python so MULTI-LINE anchors work. They are required:
|
||||
# two unrelated branches in this file are both the single line
|
||||
# `if (( status != 0 )); then`, and a one-line anchor cannot say which one a
|
||||
# result belongs to. Guessing would attribute a kill to the wrong branch.
|
||||
local loc; loc="$(python3 - "$BAK" "$find" <<'LOCPY'
|
||||
import sys
|
||||
s = open(sys.argv[1]).read(); find = sys.argv[2]
|
||||
n = s.count(find)
|
||||
print(n, (s[:s.index(find)].count("\n") + 1) if n else 0)
|
||||
LOCPY
|
||||
)"
|
||||
local n="${loc%% *}" ln="${loc##* }"
|
||||
if (( n == 0 )); then
|
||||
printf ' !! ANCHOR MISSING %-46s NOT APPLIED -- result would be meaningless\n' "$name"
|
||||
rc_all=1; return
|
||||
fi
|
||||
if (( n != 1 )); then
|
||||
printf ' !! ANCHOR AMBIGUOUS %-46s %d matches -- refusing to guess which branch\n' "$name" "$n"
|
||||
rc_all=1; return
|
||||
fi
|
||||
if (( ln >= PROSE_LO && ln <= PROSE_HI )); then
|
||||
printf ' !! ANCHOR IS PROSE %-46s line %d is inside usage() -- not a branch\n' "$name" "$ln"
|
||||
rc_all=1; return
|
||||
fi
|
||||
|
||||
python3 - "$BAK" "$TARGET" "$find" "$repl" <<'PY'
|
||||
import sys
|
||||
src, dst, find, repl = sys.argv[1:5]
|
||||
open(dst, "w").write(open(src).read().replace(find, repl, 1))
|
||||
PY
|
||||
local out line failed passed total
|
||||
out="$("$SUITE" 2>&1)"
|
||||
line="$(printf '%s\n' "$out" | grep -E 'needles: [0-9]+ passed' | tail -1)"
|
||||
failed="$(printf '%s\n' "$line" | sed -n 's/.*, \([0-9]*\) failed.*/\1/p')"
|
||||
passed="$(printf '%s\n' "$line" | sed -n 's/.*: \([0-9]*\) passed.*/\1/p')"
|
||||
if [[ -z "$failed" || -z "$passed" ]]; then
|
||||
printf ' !! NO TALLY %-46s suite produced no needle count\n' "$name"
|
||||
rc_all=1; cp "$BAK" "$TARGET"; return
|
||||
fi
|
||||
total=$(( passed + failed ))
|
||||
if (( failed > 0 )); then
|
||||
printf ' KILLED L%-5s %-46s %s/%s fail\n' "$ln" "$name" "$failed" "$total"
|
||||
ROWS+=("| \`$name\` (L$ln) | $failed/$total fail | killed |")
|
||||
KILLED=$(( KILLED + 1 ))
|
||||
else
|
||||
printf ' SURVIVED L%-5s %-46s 0/%s fail <-- UNCOVERED BRANCH\n' "$ln" "$name" "$total"
|
||||
ROWS+=("| \`$name\` (L$ln) | 0/$total fail | **SURVIVED** |")
|
||||
SURVIVED=$(( SURVIVED + 1 )); rc_all=1
|
||||
fi
|
||||
cp "$BAK" "$TARGET"
|
||||
}
|
||||
|
||||
echo "=== push-guard mutation run ==="
|
||||
mutate "json decision requirement bypassed" \
|
||||
' if (( ${#JSON_PATHS[@]} == 0 )); then' ' if false; then'
|
||||
mutate "opt-out accepted with no written reason" \
|
||||
'if not isinstance(reason, str) or not reason.strip():' 'if False:'
|
||||
mutate "committed re-read of the opt-out skipped" \
|
||||
' [[ "$CFG_MODE" == "none" ]] || return 0' ' return 0'
|
||||
mutate "untracked config honoured as an opt-out" \
|
||||
' if [[ -z "$rel" ]]; then' ' if false; then'
|
||||
mutate "staged-but-uncommitted opt-out honoured" \
|
||||
' if [[ -z "$cmode" ]]; then' ' if false; then'
|
||||
mutate "committed SYMLINK config honoured" \
|
||||
' if [[ "$cmode" == "120000" ]]; then' ' if false; then'
|
||||
mutate "unparseable committed config ignored" \
|
||||
' if (( cstatus != 0 )); then' ' if false; then'
|
||||
mutate "local-only opt-out (HEAD says ON) honoured" \
|
||||
' if [[ "$cmode_val" != "none" ]]; then' ' if false; then'
|
||||
mutate "empty MERGE exempted" \
|
||||
' if [[ "$all_same" == yes ]]; then' ' if false; then'
|
||||
mutate "empty ROOT exempted" \
|
||||
'if [[ -z "$(git diff-tree --root -r --name-only --no-commit-id HEAD)" ]]; then' \
|
||||
'if false; then'
|
||||
mutate "--since-head ancestry check removed" \
|
||||
'if ! git merge-base --is-ancestor "$since_head" "$head"; then' 'if false; then'
|
||||
mutate "staged-file enumeration ignores git failure" \
|
||||
"$(printf 'if (( status != 0 )); then\n local msg')" \
|
||||
"$(printf 'if false; then\n local msg')"
|
||||
mutate "malformed config degrades to absent instead of refusing" \
|
||||
"$(printf 'if (( status != 0 )); then\n fail "$EX_CONFIG"')" \
|
||||
"$(printf 'if false; then\n fail "$EX_CONFIG"')"
|
||||
|
||||
BASE="$("$SUITE" 2>&1 | grep -E 'needles: [0-9]+ passed' | tail -1)"
|
||||
printf '\nunmodified: %s\n' "$BASE"
|
||||
printf '%d killed, %d survived\n' "$KILLED" "$SURVIVED"
|
||||
|
||||
printf '\n--- README TABLE (paste verbatim) ---\n'
|
||||
printf '| mutation | suite result | verdict |\n|---|---|---|\n'
|
||||
printf '| *unmodified* | %s | baseline |\n' \
|
||||
"$(printf '%s' "$BASE" | sed 's/push-guard needles: //')"
|
||||
printf '%s\n' "${ROWS[@]}"
|
||||
exit "$rc_all"
|
||||
@@ -4,27 +4,27 @@ Mechanical closure of one defect: **a verification that passes when the thing it
|
||||
|
||||
Three incidents in one evening, three agents, no coordination, same shape:
|
||||
|
||||
| # | Incident | Why the check passed |
|
||||
|---|---|---|
|
||||
| 1 | `PUSH VERIFIED` reported after nothing was pushed | Commit had aborted, so local HEAD trivially equalled the remote ref |
|
||||
| 2 | An **empty commit** carrying another commit's message was pushed | A push was chained after a *failed* commit and ran on stale state |
|
||||
| 3 | Conflict markers + invalid JSON committed into 70 generated files | Nothing asserted the generated output still parsed |
|
||||
| # | Incident | Why the check passed |
|
||||
| --- | ----------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| 1 | `PUSH VERIFIED` reported after nothing was pushed | Commit had aborted, so local HEAD trivially equalled the remote ref |
|
||||
| 2 | An **empty commit** carrying another commit's message was pushed | A push was chained after a _failed_ commit and ran on stale state |
|
||||
| 3 | Conflict markers + invalid JSON committed into 70 generated files | Nothing asserted the generated output still parsed |
|
||||
|
||||
Each is an assertion satisfied by the null case. `push-guard.sh` asserts the **positive** fact instead: a new object exists, the remote *moved*, the content *parses*.
|
||||
Each is an assertion satisfied by the null case. `push-guard.sh` asserts the **positive** fact instead: a new object exists, the remote _moved_, the content _parses_.
|
||||
|
||||
## Checks
|
||||
|
||||
| Sub-command | Asserts | Exit on failure |
|
||||
|---|---|---|
|
||||
| `check-staged` | index has no unmerged paths | `2` |
|
||||
| | no conflict markers in staged content | `2` |
|
||||
| | the conflict scan actually *completed* | `2` |
|
||||
| | staged JSON under the nominated paths parses | `3` |
|
||||
| | **something is actually staged** | `5` |
|
||||
| | **an explicit JSON decision exists** | `6` |
|
||||
| `push` | HEAD advanced past `--since-head` | `5` |
|
||||
| | HEAD is a non-empty commit | `5` |
|
||||
| | remote **moved**, and now equals local HEAD | `4` |
|
||||
| Sub-command | Asserts | Exit on failure |
|
||||
| -------------- | -------------------------------------------- | --------------- |
|
||||
| `check-staged` | index has no unmerged paths | `2` |
|
||||
| | no conflict markers in staged content | `2` |
|
||||
| | the conflict scan actually _completed_ | `2` |
|
||||
| | staged JSON under the nominated paths parses | `3` |
|
||||
| | **something is actually staged** | `5` |
|
||||
| | **an explicit JSON decision exists** | `6` |
|
||||
| `push` | HEAD advanced past `--since-head` | `5` |
|
||||
| | HEAD is a non-empty commit | `5` |
|
||||
| | remote **moved**, and now equals local HEAD | `4` |
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -36,7 +36,7 @@ git commit -m "..."
|
||||
push-guard.sh push --remote origin --branch main --since-head "$BEFORE"
|
||||
```
|
||||
|
||||
`--since-head` is what distinguishes "committed nothing" from "committed something", and capturing the remote ref *before* pushing is what makes `remote == local` mean anything. Both were missing from the guard that produced incident 1.
|
||||
`--since-head` is what distinguishes "committed nothing" from "committed something", and capturing the remote ref _before_ pushing is what makes `remote == local` mean anything. Both were missing from the guard that produced incident 1.
|
||||
|
||||
## Design decisions that measurement forced
|
||||
|
||||
@@ -44,9 +44,9 @@ These are the interesting part; each one was wrong in the first draft.
|
||||
|
||||
**A bare `=======` is deliberately NOT treated as a conflict marker.** It collides with reStructuredText underlines and ASCII rules. Every genuine conflict git writes also contains `<<<<<<<` and `>>>>>>>`, so requiring those loses no real detection. Measured against a real repository: **zero** false positives across the entire tracked tree.
|
||||
|
||||
**The JSON check is opt-in by path, not on-by-default.** The first draft checked every staged `*.json`, on the reasoning that broader is strictly stronger. Measured against the same repository: **17 of 119** tracked `.json` files fail a strict parse, *all legitimately* — every `tsconfig*.json` is JSONC (comments are legal there) and the CA templates are Go templates that merely carry a `.json` suffix. An on-by-default check fires on ~14% of the repo's JSON, and a guard that cries wolf gets routed around until the bypass is habitual — at which point the bypass covers the true positives too. Broader was **weaker**.
|
||||
**The JSON check is opt-in by path, not on-by-default.** The first draft checked every staged `*.json`, on the reasoning that broader is strictly stronger. Measured against the same repository: **17 of 119** tracked `.json` files fail a strict parse, _all legitimately_ — every `tsconfig*.json` is JSONC (comments are legal there) and the CA templates are Go templates that merely carry a `.json` suffix. An on-by-default check fires on ~14% of the repo's JSON, and a guard that cries wolf gets routed around until the bypass is habitual — at which point the bypass covers the true positives too. Broader was **weaker**.
|
||||
|
||||
**`--json-path` values are normalized to `:(glob)` magic.** Git's default pathspec matching makes `data/**/*.json` require at least one intermediate directory: it matches `data/sub/b.json` and *silently skips* `data/a.json`. The obvious spelling would have delivered partial coverage with no warning.
|
||||
**`--json-path` values are normalized to `:(glob)` magic.** Git's default pathspec matching makes `data/**/*.json` require at least one intermediate directory: it matches `data/sub/b.json` and _silently skips_ `data/a.json`. The obvious spelling would have delivered partial coverage with no warning.
|
||||
|
||||
## Found by independent review, after the needles were already green
|
||||
|
||||
@@ -54,9 +54,9 @@ An adversarial review (author ≠ reviewer) found two genuine fail-opens that 17
|
||||
|
||||
**Renamed files were invisible to every content check.** Git detects renames by default (`diff.renames=true` since 2.9), so `git mv` plus a small edit is reported as a single `R` entry — which `--diff-filter=ACM` does not match. Reproduced at 97% similarity: the guard printed `staged content OK` and exited 0 with conflict markers in the staged blob. Fixed with `--no-renames`.
|
||||
|
||||
There is a trap inside this one worth recording. With *only* the renamed file staged, the guard exits 5 — the nothing-staged check fires because the file list came back empty. That looks like a catch and is pure accident; co-stage one ordinary file and the fail-open is total. The needle deliberately co-stages a clean file so it cannot pass for the wrong reason.
|
||||
There is a trap inside this one worth recording. With _only_ the renamed file staged, the guard exits 5 — the nothing-staged check fires because the file list came back empty. That looks like a catch and is pure accident; co-stage one ordinary file and the fail-open is total. The needle deliberately co-stages a clean file so it cannot pass for the wrong reason.
|
||||
|
||||
**`-I` skipped files marked binary in `.gitattributes`,** while the summary still counted them as scanned — a clean pass *and* a false count. Fixed with `-a`.
|
||||
**`-I` skipped files marked binary in `.gitattributes`,** while the summary still counted them as scanned — a clean pass _and_ a false count. Fixed with `-a`.
|
||||
|
||||
The review also correctly identified one **vacuous control**: the positive `--since-head` case asserted only exit 0, which the push produces anyway, so deleting the entire `--since-head` block left it passing. It now asserts on output.
|
||||
|
||||
@@ -64,7 +64,7 @@ Two reported findings did **not** reproduce and were not acted on: `:(glob)` doe
|
||||
|
||||
## The JSON decision is mandatory (`.push-guard.json`)
|
||||
|
||||
The first release announced `JSON check NOT REQUESTED` and continued. That was *visible* rather than silent, which is better — but it is still an **absence**, and an absence is not reviewable. Nobody reads a line that has printed correctly ten thousand times. A repo that needed the check and never wired it up stayed unprotected forever, and nothing ever failed.
|
||||
The first release announced `JSON check NOT REQUESTED` and continued. That was _visible_ rather than silent, which is better — but it is still an **absence**, and an absence is not reviewable. Nobody reads a line that has printed correctly ten thousand times. A repo that needed the check and never wired it up stayed unprotected forever, and nothing ever failed.
|
||||
|
||||
The decision is now required, from one of exactly two places:
|
||||
|
||||
@@ -78,61 +78,87 @@ The decision is now required, from one of exactly two places:
|
||||
|
||||
`--json-path` on the command line also satisfies it. Saying nothing is refused (exit `6`).
|
||||
|
||||
**The asymmetry is deliberate.** Turning the check *on* is safe from anywhere, so a CLI flag suffices. Turning it *off* is confined to a committed file, because that is the only form a human can review: you can read a reason in a diff, and you cannot review the fact that nobody typed a flag. An opt-out living in an ad-hoc command line is the old fail-open with extra steps.
|
||||
**The asymmetry is deliberate.** Turning the check _on_ is safe from anywhere, so a CLI flag suffices. Turning it _off_ is confined to a committed file, because that is the only form a human can review: you can read a reason in a diff, and you cannot review the fact that nobody typed a flag. An opt-out living in an ad-hoc command line is the old fail-open with extra steps.
|
||||
|
||||
A **malformed** config is refused outright rather than treated as absent — that fallback would mean a typo silently disables the check the file was written to enable. A config that parses but *states nothing* (`{}`) is refused too: valid JSON that says nothing is the original defect wearing a config file as a disguise.
|
||||
A **malformed** config is refused outright rather than treated as absent — that fallback would mean a typo silently disables the check the file was written to enable. A config that parses but _states nothing_ (`{}`) is refused too: valid JSON that says nothing is the original defect wearing a config file as a disguise.
|
||||
|
||||
**Migration is a hard cutover, on purpose.** The tempting path is "warn for one release, then enforce" — but that warning phase *is* the degrade-to-a-warning this tool forbids, and it leaves the fail-open open for exactly as long as the warning is ignored, which is indefinitely. Consumers add a config or the guard refuses. It breaks loudly, once. Two pre-existing cases in this repo's own harness were the first to pay that cost, which is the correct place to feel it.
|
||||
**Migration is a hard cutover, on purpose.** The tempting path is "warn for one release, then enforce" — but that warning phase _is_ the degrade-to-a-warning this tool forbids, and it leaves the fail-open open for exactly as long as the warning is ignored, which is indefinitely. Consumers add a config or the guard refuses. It breaks loudly, once. Two pre-existing cases in this repo's own harness were the first to pay that cost, which is the correct place to feel it.
|
||||
|
||||
## Known limitations — stated, not hidden
|
||||
|
||||
- `check-staged` inspects the index. Content added to the working tree *after* it runs is not covered — it belongs in a `pre-commit` hook, where the window is smallest.
|
||||
- `check-staged` inspects the index. Content added to the working tree _after_ it runs is not covered — it belongs in a `pre-commit` hook, where the window is smallest.
|
||||
- `push` hardcodes `HEAD:refs/heads/$branch`, so it cannot verify a commit built via plumbing on a different base. A `--sha` option would close this; it is deliberately not added without a needle.
|
||||
|
||||
## Test harness
|
||||
|
||||
`test-push-guard.sh` — 32 cases, each check in **both polarities**:
|
||||
`test-push-guard.sh` — 46 cases, each check in **both polarities**:
|
||||
|
||||
- **NEEDLE** — a deliberately broken fixture that must trip the guard.
|
||||
- **CONTROL** — a clean fixture that must pass.
|
||||
|
||||
Controls are not decoration. A guard that failed unconditionally would satisfy every needle and look fully covered. Several controls additionally assert on the guard's *output* (`--out`), because exit 0 cannot distinguish "checked the files and they were fine" from "matched no files and had nothing to check" — two controls in an earlier revision were passing vacuously for exactly that reason.
|
||||
Controls are not decoration. A guard that failed unconditionally would satisfy every needle and look fully covered. Several controls additionally assert on the guard's _output_ (`--out`), because exit 0 cannot distinguish "checked the files and they were fine" from "matched no files and had nothing to check" — two controls in an earlier revision were passing vacuously for exactly that reason.
|
||||
|
||||
### Mutation results — the harness was itself tested by breaking the guard
|
||||
|
||||
| Mutant | Result |
|
||||
|---|---|
|
||||
| fail-open (every failure downgraded to exit 0) | 9/16 fail — exactly the needles |
|
||||
| always-fail | 16/16 fail — controls catch it |
|
||||
| revert `:(glob)` normalization | 3/16 fail — caught **only** by the `--out` assertions |
|
||||
| drop the remote-did-not-move assertion | 1/16 fail — the incident-1 needle |
|
||||
| restore blanket `\|\| true` on the scan | 1/17 fail — the fault-injection needle |
|
||||
| revert `--no-renames` | 1/19 fail — the rename needle |
|
||||
| revert `-a` to `-I` | 1/19 fail — the binary-attribute needle |
|
||||
| delete the `--since-head` block | 2/19 fail — incl. the control that *was* vacuous |
|
||||
| revert refuse-on-missing-config | 1/32 fail — the closed fail-open |
|
||||
| malformed config degrades to "absent" | 4/32 fail — **all four by `--out` alone**, exit code was correct every time |
|
||||
| drop the mandatory `reason` | 1/32 fail — the unexplained-opt-out needle |
|
||||
| make the guard emit a stray warning | 1/32 fail — the stray-output control |
|
||||
| unmodified | 32/32 pass |
|
||||
**Everything in this section is regenerated by `./mutate-push-guard.sh`, not typed.** The previous version of this table was hand-maintained: it was true when written, went stale as the suite grew, and ended up asserting `unmodified | 32/32 pass` against a 46-case suite. A README is what a reader consults when the tool misbehaves, so a confidently-wrong one is worse than none. Re-run the script and paste; do not edit the numbers.
|
||||
|
||||
A guard nobody has watched fail is not a guard. The same applies to the harness: mutant C would have passed silently without the output assertions, so the assertions are load-bearing rather than ornamental.
|
||||
| mutation | suite result | verdict |
|
||||
| ---------------------------------------------------------------- | ------------------- | -------- |
|
||||
| _unmodified_ | 46 passed, 0 failed | baseline |
|
||||
| `json decision requirement bypassed` (L482) | 3/46 fail | killed |
|
||||
| `opt-out accepted with no written reason` (L334) | 1/46 fail | killed |
|
||||
| `committed re-read of the opt-out skipped` (L405) | 5/46 fail | killed |
|
||||
| `untracked config honoured as an opt-out` (L409) | 1/46 fail | killed |
|
||||
| `staged-but-uncommitted opt-out honoured` (L425) | 1/46 fail | killed |
|
||||
| `committed SYMLINK config honoured` (L433) | 1/46 fail | killed |
|
||||
| `unparseable committed config ignored` (L444) | 1/46 fail | killed |
|
||||
| `local-only opt-out (HEAD says ON) honoured` (L459) | 1/46 fail | killed |
|
||||
| `empty MERGE exempted` (L704) | 1/46 fail | killed |
|
||||
| `empty ROOT exempted` (L715) | 1/46 fail | killed |
|
||||
| `--since-head ancestry check removed` (L665) | 1/46 fail | killed |
|
||||
| `staged-file enumeration ignores git failure` (L173) | 1/46 fail | killed |
|
||||
| `malformed config degrades to absent instead of refusing` (L375) | 4/46 fail | killed |
|
||||
|
||||
13 mutants, 0 survived.
|
||||
|
||||
Earlier mutants, run at the suite size of the day and kept as history rather than as a live claim: fail-open (9/16), always-fail (16/16 — controls catch it), revert `:(glob)` normalization (3/16, caught **only** by the `--out` assertions), drop the remote-did-not-move assertion (1/16), restore blanket `|| true` on the scan (1/17), revert `--no-renames` (1/19), revert `-a` to `-I` (1/19), delete the `--since-head` block (2/19, incl. the control that _was_ vacuous), stray-warning emission (1/32).
|
||||
|
||||
A guard nobody has watched fail is not a guard. The same applies to the harness: the `:(glob)` mutant would have passed silently without the output assertions, so the assertions are load-bearing rather than ornamental.
|
||||
|
||||
### Two branches were uncovered, and writing this table is what found them
|
||||
|
||||
Building the generator turned up two mutants that survived a **46-case suite reporting 46/46 green**: `staged-but-uncommitted opt-out honoured` and `unparseable committed config ignored`. Neither branch had any case at all. Both are now needled, which is why they read _killed_ above.
|
||||
|
||||
Both survivors share a shape worth naming: the mutant still exits `6`, because control falls through to a _sibling_ refusal that rejects for a different reason. **An exit-code-only assertion would have been satisfied by the wrong branch.** The new cases anchor on the distinguishing clause instead.
|
||||
|
||||
The same audit found a live instance of that defect already in the suite. Three separate branches print the headline `OPT-OUT IS NOT REVIEWABLE` — untracked, staged-not-committed, and symlink — and the untracked needle was anchored on the shared headline. Delete the untracked branch and control reaches a sibling printing those same words, so **the needle would not fail; it would re-point.** A substring anchor does not fail when its subject is removed. It is now anchored on `is not tracked in git`.
|
||||
|
||||
### The generator refuses three ways a mutation run can lie
|
||||
|
||||
1. **The anchor no longer matches.** The mutant is never applied, the suite is green, and a naive report says _survived_ — the same word a real coverage gap gets. `ANCHOR MISSING` is a loud failure instead.
|
||||
2. **The anchor matches prose.** This one landed on the first run: a mutant aimed at the refuse-on-missing-config branch matched inside the `usage()` heredoc, edited a help string, changed no behaviour, and duly reported _survived_. A documentation edit was one step from being recorded as an uncovered branch. **A mutation that cannot change behaviour is not a surviving mutant, it is a non-measurement** — and a non-measurement reported as a result is the same defect as the vacuous test the harness exists to hunt. Anchors resolving inside `usage()` are now refused, and the heredoc's bounds are located at runtime rather than hardcoded.
|
||||
3. **The anchor is ambiguous.** Two unrelated branches here are both the single line `if (( status != 0 )); then`; a first-match replace would silently attribute the kill to whichever came first. Multi-line anchors are supported and a match count `!= 1` refuses rather than guesses.
|
||||
|
||||
**A blanket `|| true` on the scan was a fail-open in the guard's own error handling.** `git grep` exits `1` for "no match" but `>=2` for a real failure. `|| true` collapsed the two, so a malformed pathspec or unreadable index would have been reported as "ok, no conflict markers" — a clean pass from a scan that never ran. The status is now discriminated, and a PATH-shim fault-injection needle proves the refusal.
|
||||
|
||||
**A `<<'PY'` heredoc inside `$( )` made bash print a warning on every run — and 30 needles plus a clean linter all missed it.** The config parser started life as an inline heredoc inside a command substitution, so every invocation emitted `warning: command substitution: 1 unterminated here-document` to stderr. It executed correctly, every needle stayed green, and shellcheck reported nothing. The harness missed it because `expect` asserts *substrings it was told to look for* — and nobody tells you to look for output you did not know existed. It surfaced only when the guard was run against a real repository.
|
||||
**A `<<'PY'` heredoc inside `$( )` made bash print a warning on every run — and 30 needles plus a clean linter all missed it.** The config parser started life as an inline heredoc inside a command substitution, so every invocation emitted `warning: command substitution: 1 unterminated here-document` to stderr. It executed correctly, every needle stayed green, and shellcheck reported nothing. The harness missed it because `expect` asserts _substrings it was told to look for_ — and nobody tells you to look for output you did not know existed. It surfaced only when the guard was run against a real repository.
|
||||
|
||||
The fix moves the parser to a top-level constant. The lesson is encoded as case `z1`, which asserts the **absence of a whole output class** rather than the presence of an expected string, and is paired with a needle proving the detector can fire. A tool built to refuse quiet failures was quietly polluting stderr for its entire existence.
|
||||
|
||||
**The `-E` flag on `git grep` is load-bearing and was caught by a needle, not by review.** `git grep` defaults to *basic* regex, in which `(`, `|` and `{7}` are literal characters. Without `-E` the patterns match nothing and the check reports a clean pass over a file full of conflict markers — the exact defect this tool exists to prevent, shipped inside the tool itself.
|
||||
**The `-E` flag on `git grep` is load-bearing and was caught by a needle, not by review.** `git grep` defaults to _basic_ regex, in which `(`, `|` and `{7}` are literal characters. Without `-E` the patterns match nothing and the check reports a clean pass over a file full of conflict markers — the exact defect this tool exists to prevent, shipped inside the tool itself.
|
||||
|
||||
## Proposed framework path
|
||||
|
||||
```
|
||||
framework/tools/git/push-guard.sh
|
||||
framework/tools/git/test-push-guard.sh
|
||||
framework/tools/git/push-guard.sh # the guard
|
||||
framework/tools/git/test-push-guard.sh # 46 needles and controls
|
||||
framework/tools/git/mutate-push-guard.sh # regenerates the mutation table above
|
||||
framework/tools/git/verify-clean-clone.sh # proves the COMMITTED artifact runs
|
||||
framework/tools/git/test-verify-clean-clone.sh # 6 needles for the verifier itself
|
||||
```
|
||||
|
||||
Matches the existing `tools/git/test-*.sh` convention. Dependencies: bash 4.4+, git, python3 — `python3` is already an accepted dependency of `ci-queue-wait.sh`.
|
||||
|
||||
**All five must be committed mode `100755`.** They were once delivered `100644`, so a clone exited `126 Permission denied` for everyone who was not the author; `verify-clean-clone.sh` exists to make that unshippable and asserts the mode from `git ls-tree` of the source commit, never from the filesystem.
|
||||
|
||||
Operator-agnostic: no hostnames, credentials, remotes, or operator-specific paths. Clean under the framework-PR firewall.
|
||||
|
||||
@@ -508,8 +508,44 @@ R="$(new_repo e2)"
|
||||
write_config_untracked "$R" '{"json_check": "none", "reason": "local unreviewed bypass"}'
|
||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
||||
git -C "$R" add -f data/bad.json
|
||||
# The anchor is the DISTINGUISHING clause, not the shared headline. Three
|
||||
# separate branches print "OPT-OUT IS NOT REVIEWABLE" — untracked, staged-not-
|
||||
# committed, and symlink. Anchoring on the headline means this needle stays green
|
||||
# if the untracked branch is deleted and control falls through to a SIBLING that
|
||||
# prints the same words: a substring anchor does not fail when its subject is
|
||||
# removed, IT RE-POINTS. Anchor on the clause only this branch can produce.
|
||||
expect NEEDLE 6 "an UNTRACKED opt-out is refused as unreviewable" \
|
||||
--out "OPT-OUT IS NOT REVIEWABLE" -- \
|
||||
--out "is not tracked in git" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# STAGED BUT NOT COMMITTED. Found by mutation, not by review: `if [[ -z "$cmode" ]]`
|
||||
# survived `if false` against a 44/44 green suite, because no case ever built this
|
||||
# state. Note the mutant still exits 6 — control falls to the LOCAL-ONLY branch
|
||||
# below and refuses for a different reason. An exit-code-only assertion here would
|
||||
# be satisfied by the wrong branch, which is why --out carries the distinguishing
|
||||
# clause. `git add` puts the file in the index, so ls-files matches while HEAD
|
||||
# does not: staged review is not review.
|
||||
R="$(new_repo e2b)"
|
||||
write_config_untracked "$R" '{"json_check": "none", "reason": "staged, never committed"}'
|
||||
git -C "$R" add .push-guard.json
|
||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
||||
git -C "$R" add -f data/bad.json
|
||||
expect NEEDLE 6 "a STAGED-but-uncommitted opt-out is refused" \
|
||||
--out "staged but not yet in HEAD" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# COMMITTED CONFIG DOES NOT PARSE while the working tree opts out cleanly. Also a
|
||||
# mutation survivor. The working-tree config is valid, so the early config check
|
||||
# passes and we reach the re-read; the committed object is what fails. Without
|
||||
# this branch an opt-out could be honoured on the strength of a HEAD blob nobody
|
||||
# can actually read a decision out of.
|
||||
R="$(new_repo e2c)"
|
||||
write_config "$R" '{ this is not json'
|
||||
printf '%s\n' '{"json_check": "none", "reason": "valid here, broken in HEAD"}' > "$R/.push-guard.json"
|
||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
||||
git -C "$R" add -f data/bad.json
|
||||
expect NEEDLE 6 "an UNPARSEABLE committed config cannot authorise an opt-out" \
|
||||
--out "is INVALID" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# A committed ON silently flipped OFF in the working tree is the same bypass.
|
||||
@@ -580,6 +616,44 @@ expect CONTROL 0 "a NON-empty root commit still pushes" \
|
||||
--out "non-empty root commit" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch fresh2"
|
||||
|
||||
# THE MERGE BRANCH HAD NO NEEDLE AT ALL. It was defined, hand-verified, and
|
||||
# shipped -- and deleting the refusal left the suite at 41/41 green. Defining
|
||||
# semantics is not testing them, and an untested branch is indistinguishable
|
||||
# from an absent one to everyone downstream.
|
||||
# EMPTY MERGE: two branches that each change nothing, merged. The merge tree is
|
||||
# then identical to EVERY parent -- it integrates nothing and introduces nothing.
|
||||
R="$(new_repo e9)"
|
||||
git -C "$R" checkout -q -b mx
|
||||
git -C "$R" commit -q --allow-empty -m "x: no tree change"
|
||||
git -C "$R" checkout -q -b my HEAD~1 2>/dev/null || git -C "$R" checkout -q -b my
|
||||
git -C "$R" commit -q --allow-empty -m "y: no tree change"
|
||||
git -C "$R" checkout -q mx
|
||||
git -C "$R" merge -q --no-ff --no-edit my
|
||||
# PROVE THE FIXTURE IS ACTUALLY AN EMPTY MERGE before asserting on it, or the
|
||||
# needle passes for the wrong reason on a repo that never made a merge at all.
|
||||
np="$(git -C "$R" rev-list --parents -n 1 HEAD | wc -w)"
|
||||
if (( np > 2 )) && git -C "$R" diff --quiet HEAD^1 HEAD && git -C "$R" diff --quiet HEAD^2 HEAD; then
|
||||
printf ' ok [%-14s] fixture is a merge (%d fields) with tree identical to both parents\n' "e9-fixture" "$np"; PASS=$(( PASS + 1 ))
|
||||
else
|
||||
printf ' FAIL [%-14s] fixture is not an empty merge -- needle would be vacuous\n' "e9-fixture"; FAIL=$(( FAIL + 1 ))
|
||||
fi
|
||||
expect NEEDLE 5 "an EMPTY MERGE is refused, not exempted" \
|
||||
--out "EMPTY MERGE" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch mx"
|
||||
|
||||
# CONTROL: a merge that really integrates must still push. Both sides add a
|
||||
# distinct file, so the merge tree differs from BOTH parents.
|
||||
R="$(new_repo e10)"
|
||||
git -C "$R" checkout -q -b nx
|
||||
printf 'from x\n' > "$R/x.txt"; git -C "$R" add x.txt; git -C "$R" commit -q -m "x adds a file"
|
||||
git -C "$R" checkout -q -b ny HEAD~1
|
||||
printf 'from y\n' > "$R/y.txt"; git -C "$R" add y.txt; git -C "$R" commit -q -m "y adds a file"
|
||||
git -C "$R" checkout -q nx
|
||||
git -C "$R" merge -q --no-ff --no-edit ny
|
||||
expect CONTROL 0 "a NON-empty merge commit still pushes" \
|
||||
--out "non-empty merge commit" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch nx"
|
||||
|
||||
echo
|
||||
printf 'push-guard needles: %d passed, %d failed\n' "$PASS" "$FAIL"
|
||||
(( FAIL == 0 )) || exit 1
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-verify-clean-clone.sh -- needles for the verifier itself.
|
||||
#
|
||||
# v1 of the verifier passed while the real repository recorded 100644, because it
|
||||
# asserted a SCRATCH repo built by cp. There was no needle that could have caught
|
||||
# that: every case ran against a tree whose modes came off my filesystem.
|
||||
# So the load-bearing needle here is w2 -- SOURCE GIT MODE 100644, DISK MODE 755.
|
||||
# That is the exact contaminated state, and v1 exits 0 on it while v2 must refuse.
|
||||
# A verifier without this needle is the same shape as the defect it verifies.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERIFY="$HERE/verify-clean-clone.sh"
|
||||
ARTIFACTS=(push-guard.sh test-push-guard.sh verify-clean-clone.sh mutate-push-guard.sh)
|
||||
|
||||
PASS=0; FAIL=0
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# Build a source repo whose COMMITTED modes are 100755 and whose disk modes are
|
||||
# executable -- the honest state.
|
||||
mkrepo() {
|
||||
local d="$1"
|
||||
mkdir -p "$d"
|
||||
local f
|
||||
for f in "${ARTIFACTS[@]}"; do
|
||||
install -m 755 "$HERE/$f" "$d/$f"
|
||||
done
|
||||
git -C "$d" init -q .
|
||||
git -C "$d" config user.email "[email protected]"
|
||||
git -C "$d" config user.name "Verifier Needles"
|
||||
git -C "$d" add -A
|
||||
git -C "$d" commit -q -m "artifacts"
|
||||
}
|
||||
|
||||
run_case() {
|
||||
local name="$1" want_rc="$2" want_txt="$3" repo="$4"
|
||||
local out rc
|
||||
out="$("$VERIFY" --repo "$repo" 2>&1)"; rc=$?
|
||||
if (( rc == want_rc )) && [[ "$out" == *"$want_txt"* ]]; then
|
||||
printf ' ok [%-14s]\n' "$name"; PASS=$(( PASS + 1 ))
|
||||
else
|
||||
printf ' FAIL [%-14s] wanted rc=%d containing %q; got rc=%d\n%s\n' \
|
||||
"$name" "$want_rc" "$want_txt" "$rc" "$out"; FAIL=$(( FAIL + 1 ))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "== POSITIVE CONTROL: an honestly-committed artifact must PASS =="
|
||||
# Without this, every case below could be passing because the verifier always
|
||||
# refuses -- a wall, not a gate.
|
||||
mkrepo "$TMP/good"
|
||||
run_case w1-honest 0 "the COMMITTED artifact ran and passed" "$TMP/good"
|
||||
|
||||
echo
|
||||
echo "== THE B1 NEEDLE: source git mode 100644, DISK MODE STILL 755 =="
|
||||
# This is the reviewer's exact reproduction. v1 of the verifier exits 0 here.
|
||||
mkrepo "$TMP/laundered"
|
||||
git -C "$TMP/laundered" update-index --chmod=-x push-guard.sh test-push-guard.sh
|
||||
git -C "$TMP/laundered" commit -q -m "drop exec bit in git only"
|
||||
# PROVE THE FIXTURE IS THE CONTAMINATED STATE, not merely a broken repo: git must
|
||||
# say 100644 while the filesystem still says executable. If the disk bit were
|
||||
# gone too, the needle would be testing something easier than the real defect.
|
||||
src_mode="$(git -C "$TMP/laundered" ls-tree HEAD -- push-guard.sh | awk '{print $1}')"
|
||||
disk_mode="$(stat -c '%a' "$TMP/laundered/push-guard.sh")"
|
||||
if [[ "$src_mode" == "100644" && "$disk_mode" == "755" ]]; then
|
||||
printf ' ok [%-14s] fixture is git=%s disk=%s\n' "w2-fixture" "$src_mode" "$disk_mode"; PASS=$(( PASS + 1 ))
|
||||
else
|
||||
printf ' FAIL [%-14s] fixture wrong: git=%s disk=%s -- needle would not test the defect\n' \
|
||||
"w2-fixture" "$src_mode" "$disk_mode"; FAIL=$(( FAIL + 1 ))
|
||||
fi
|
||||
run_case w2-laundered 1 "committed 100644, needs 100755" "$TMP/laundered"
|
||||
|
||||
echo
|
||||
echo "== the artifact must be COMMITTED, or there is no mode to verify =="
|
||||
mkrepo "$TMP/untracked"
|
||||
git -C "$TMP/untracked" rm -q --cached push-guard.sh
|
||||
git -C "$TMP/untracked" commit -q -m "untrack the guard"
|
||||
run_case w3-untracked 1 "NOT TRACKED" "$TMP/untracked"
|
||||
|
||||
echo
|
||||
echo "== a committed SYMLINK is a path, not the reviewed code =="
|
||||
mkrepo "$TMP/symlink"
|
||||
( cd "$TMP/symlink" && rm -f push-guard.sh && ln -s /dev/null push-guard.sh \
|
||||
&& git add push-guard.sh && git commit -q -m "symlink the guard" )
|
||||
run_case w4-symlink 1 "SYMLINK" "$TMP/symlink"
|
||||
|
||||
echo
|
||||
echo "== not a repository at all: REFUSE, never pass =="
|
||||
mkdir -p "$TMP/bare"
|
||||
for f in "${ARTIFACTS[@]}"; do install -m 755 "$HERE/$f" "$TMP/bare/$f"; done
|
||||
run_case w5-norepo 1 "not inside a git repository" "$TMP/bare"
|
||||
|
||||
echo
|
||||
printf '%d passed, %d failed\n' "$PASS" "$FAIL"
|
||||
(( FAIL == 0 ))
|
||||
@@ -1,75 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
# verify-clean-clone.sh -- run the suite from a FRESH CLONE, never the working copy.
|
||||
# verify-clean-clone.sh -- prove the COMMITTED artifact runs, from a clean clone.
|
||||
#
|
||||
# BLOCKER 1 EXISTED BECAUSE EVERY RUN HAPPENED WHERE THE EXEC BIT WAS ALREADY SET.
|
||||
# Two people ran 32/32 on files whose local mode had been set by hand at creation
|
||||
# time; the COMMITTED mode was 100644, so the delivered artifact returned 126
|
||||
# Permission denied and aborted before printing a summary. The artifact had never
|
||||
# run for anyone.
|
||||
# ================== THIS FILE IS THE SECOND VERSION. THE FIRST HAD B1. ========
|
||||
# B1 was: the delivered scripts were committed mode 100644, so the artifact
|
||||
# returned 126 Permission denied for anyone who cloned it. Two of us ran 32/32
|
||||
# because our local working copies had the exec bit set by hand at creation.
|
||||
#
|
||||
# "Run it somewhere real" was not enough. The missing word is CLEAN: run it from a
|
||||
# checkout that carries only what the repository actually stores. Local state you
|
||||
# did not commit is invisible to you precisely because it is yours.
|
||||
# I wrote v1 of this file to prevent exactly that. V1 COPIED THE WORKING-TREE
|
||||
# FILES INTO A SCRATCH REPOSITORY AND ASSERTED THE SCRATCH REPOSITORY'S INDEX.
|
||||
# cp preserves the local exec bit, so `git add` in the scratch repo recorded
|
||||
# 100755 NO MATTER WHAT THE REAL REPOSITORY STORED. Reviewer reproduction:
|
||||
# git update-index --chmod=-x push-guard.sh test-push-guard.sh
|
||||
# ./verify-clean-clone.sh -> EXIT 0, "CLEAN CLONE: suite ran and passed"
|
||||
# while the real index read 100644 -- the precise contaminated state B1 was.
|
||||
#
|
||||
# This script commits the artifacts into a scratch repo, clones that repo, and
|
||||
# runs the suite from the clone -- so the only modes in play are the ones git
|
||||
# recorded. It also asserts the mode positively, so a future commit that drops the
|
||||
# bit fails here instead of at the next reviewer.
|
||||
# THE CONTROL FOR THE DEFECT INHERITED THE DEFECT, BECAUSE IT MEASURED A COPY
|
||||
# INSTEAD OF THE SUBJECT. cp LAUNDERS MODE.
|
||||
#
|
||||
# Both of v1's mechanisms were right -- assert the INDEX not the disk, execute
|
||||
# DIRECTLY not via `bash script`. They were applied to the wrong repository.
|
||||
# So v2 changes what is measured, not how:
|
||||
# * mode is read from the SOURCE repository's COMMITTED TREE (git ls-tree),
|
||||
# which no local chmod can influence;
|
||||
# * the tree under test is produced by CLONING THAT COMMIT, so every mode
|
||||
# comes out of the object store rather than off my filesystem.
|
||||
# There is no cp anywhere in this file, and that is deliberate.
|
||||
#
|
||||
# It also REFUSES rather than passes when the artifacts are untracked: an
|
||||
# artifact that is not committed has no mode to verify, and a verifier that
|
||||
# silently succeeds on nothing is the vacuous-absence failure all over again.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly EX_FAIL=1
|
||||
readonly EX_USAGE=64
|
||||
|
||||
REPO=""
|
||||
REV="HEAD"
|
||||
|
||||
die() { printf 'usage error: %s\n' "$1" >&2; exit "$EX_USAGE"; }
|
||||
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--repo) REPO="${2:-}"; shift 2 ;;
|
||||
--rev) REV="${2:-}"; shift 2 ;;
|
||||
*) die "unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
[[ -n "$REPO" ]] || REPO="$HERE"
|
||||
|
||||
ARTIFACTS=(push-guard.sh test-push-guard.sh verify-clean-clone.sh mutate-push-guard.sh)
|
||||
SUITE="test-push-guard.sh"
|
||||
|
||||
# --- the subject must be a real repository, or there is nothing to verify -----
|
||||
if ! ROOT="$(git -C "$REPO" rev-parse --show-toplevel 2>/dev/null)"; then
|
||||
printf 'REFUSING: %s is not inside a git repository.\n' "$REPO" >&2
|
||||
printf 'This verifier checks the COMMITTED mode of the artifact. An uncommitted\n' >&2
|
||||
printf 'artifact has no committed mode, and passing here would prove nothing.\n' >&2
|
||||
exit "$EX_FAIL"
|
||||
fi
|
||||
if ! REV_SHA="$(git -C "$ROOT" rev-parse --verify --quiet "${REV}^{commit}")"; then
|
||||
printf 'REFUSING: %s does not resolve to a commit in %s\n' "$REV" "$ROOT" >&2
|
||||
exit "$EX_FAIL"
|
||||
fi
|
||||
|
||||
printf '=== subject ===\n'
|
||||
printf ' repo %s\n rev %s (%s)\n\n' "$ROOT" "$REV" "$REV_SHA"
|
||||
|
||||
# --- 1. MODE, FROM THE COMMITTED TREE OF THE SUBJECT -------------------------
|
||||
# git ls-tree reports what the COMMIT records. Nothing on my filesystem can
|
||||
# move this number -- which is the whole point, and what v1 got wrong.
|
||||
printf '=== committed modes (git ls-tree %s) ===\n' "$REV"
|
||||
rc=0
|
||||
for f in "${ARTIFACTS[@]}"; do
|
||||
entry="$(git -C "$ROOT" ls-tree "$REV_SHA" -- "$f")"
|
||||
if [[ -z "$entry" ]]; then
|
||||
printf ' FAIL %s is NOT TRACKED at %s -- nothing committed to verify\n' "$f" "$REV"
|
||||
rc=1; continue
|
||||
fi
|
||||
mode="${entry%% *}"; rest="${entry#* }"; type="${rest%% *}"
|
||||
if [[ "$type" != blob ]]; then
|
||||
printf ' FAIL %s is a %s at %s, not a regular file\n' "$f" "$type" "$REV"
|
||||
rc=1; continue
|
||||
fi
|
||||
case "$mode" in
|
||||
100755) printf ' ok %s committed %s\n' "$f" "$mode" ;;
|
||||
120000) printf ' FAIL %s is a SYMLINK (%s) -- the reviewed blob is a path, not the code\n' "$f" "$mode"; rc=1 ;;
|
||||
*) printf ' FAIL %s committed %s, needs 100755\n' "$f" "$mode"
|
||||
printf ' fix with: git update-index --chmod=+x %s && commit\n' "$f"
|
||||
rc=1 ;;
|
||||
esac
|
||||
done
|
||||
if (( rc != 0 )); then
|
||||
printf '\nREFUSING TO CONTINUE: the COMMITTED modes are wrong, whatever the disk says.\n'
|
||||
printf 'A clone of this commit would exit 126 for the next person.\n'
|
||||
exit "$EX_FAIL"
|
||||
fi
|
||||
|
||||
# --- 2. RUN FROM A CLONE OF THAT COMMIT --------------------------------------
|
||||
# Cloning materialises every file from the object store, so the modes on disk
|
||||
# are the COMMITTED modes by construction. No cp, no local state, nothing this
|
||||
# machine can contribute.
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
ARTIFACTS=(push-guard.sh test-push-guard.sh)
|
||||
|
||||
printf '=== packaging from %s ===\n' "$HERE"
|
||||
mkdir -p "$WORK/src"
|
||||
for f in "${ARTIFACTS[@]}"; do
|
||||
cp "$HERE/$f" "$WORK/src/$f"
|
||||
done
|
||||
[[ -f "$HERE/README.md" ]] && cp "$HERE/README.md" "$WORK/src/README.md"
|
||||
|
||||
cd "$WORK/src"
|
||||
git init -q .
|
||||
git config user.email "[email protected]"
|
||||
git config user.name "Clean Clone"
|
||||
git add -A
|
||||
|
||||
# THE ASSERTION THAT WOULD HAVE CAUGHT BLOCKER 1.
|
||||
# Check the mode in the INDEX -- what git will actually store -- not the mode on
|
||||
# disk, which is what misled us. These differ exactly when it matters.
|
||||
rc=0
|
||||
for f in "${ARTIFACTS[@]}"; do
|
||||
mode="$(git ls-files -s -- "$f" | awk '{print $1}')"
|
||||
if [[ "$mode" != "100755" ]]; then
|
||||
printf ' FAIL %s is mode %s in the index (needs 100755)\n' "$f" "$mode"
|
||||
printf ' fix with: git update-index --chmod=+x %s\n' "$f"
|
||||
rc=1
|
||||
else
|
||||
printf ' ok %s is mode %s in the index\n' "$f" "$mode"
|
||||
fi
|
||||
done
|
||||
(( rc == 0 )) || { printf '\nREFUSING TO CONTINUE: the committed modes are wrong.\n'; exit 1; }
|
||||
|
||||
git commit -q -m "artifacts under test"
|
||||
|
||||
printf '\n=== cloning (no local mode bits survive this) ===\n'
|
||||
cd "$WORK"
|
||||
git clone -q src clone
|
||||
cd clone
|
||||
printf '\n=== cloning %s (no local mode bits survive this) ===\n' "$REV_SHA"
|
||||
git clone -q --no-local --no-hardlinks "$ROOT" "$WORK/clone"
|
||||
git -C "$WORK/clone" -c advice.detachedHead=false checkout -q "$REV_SHA"
|
||||
|
||||
for f in "${ARTIFACTS[@]}"; do
|
||||
printf ' clone mode: %s %s\n' "$(stat -c '%a' "$f")" "$f"
|
||||
printf ' clone disk mode: %s %s\n' "$(stat -c '%a' "$WORK/clone/$f")" "$f"
|
||||
done
|
||||
|
||||
printf '\n=== executing DIRECTLY (./script), not via `bash script` ===\n'
|
||||
# Running `bash script` masks a missing exec bit completely -- it is how the
|
||||
# original 32/32 was obtained. Direct execution is the thing under test.
|
||||
if ! ./test-push-guard.sh; then
|
||||
printf '\n=== executing DIRECTLY (./%s), not via "bash %s" ===\n' "$SUITE" "$SUITE"
|
||||
# Invoking the interpreter explicitly masks a missing exec bit completely -- it
|
||||
# is how the original green was obtained. Direct execution is the thing under
|
||||
# test, so the mode has to be real for this line to succeed.
|
||||
cd "$WORK/clone"
|
||||
if ! "./$SUITE"; then
|
||||
printf '\nCLEAN-CLONE RUN FAILED.\n'
|
||||
exit 1
|
||||
exit "$EX_FAIL"
|
||||
fi
|
||||
|
||||
printf '\n=== CLEAN CLONE: suite ran and passed from a fresh checkout ===\n'
|
||||
printf '\n=== CLEAN CLONE: the COMMITTED artifact ran and passed ===\n'
|
||||
|
||||
Reference in New Issue
Block a user