forked from mosaicstack/stack
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1806b3a57a | ||
|
|
fc80138326 | ||
|
|
8fdc8738ed | ||
|
|
8310075d33 | ||
|
|
b0fb208b89 | ||
|
|
62e3bf9e28 | ||
|
|
b7d391f9f4 | ||
|
|
1036449b57 | ||
|
|
63c5d2056f | ||
|
|
1288ce3721 | ||
|
|
92272a6400 | ||
|
|
76eef39a29 | ||
|
|
47f8689231 | ||
|
|
8d1d6e5e76 |
+241
@@ -0,0 +1,241 @@
|
||||
#!/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.
|
||||
#
|
||||
# ============================ WHAT THIS TOOL GOT WRONG ========================
|
||||
# Three defects, all found by review, all of the same shape: A TOOL WHOSE ENTIRE
|
||||
# OUTPUT IS A COVERAGE CLAIM MUST BE HARDER TO FOOL THAN THE CODE IT MEASURES.
|
||||
#
|
||||
# 1. IT REPORTED FULL COVERAGE ON A RED BASELINE. A mutant was "killed" whenever
|
||||
# the suite reported any failure at all, and the baseline was run only at the
|
||||
# END and never required to be green. So ONE pre-existing suite failure --
|
||||
# changing no guard behaviour whatsoever -- satisfied EVERY mutant: 13 killed,
|
||||
# 0 survived, a confident table generated and pasted into the README, exit 0.
|
||||
# Now: the baseline runs FIRST and must be exit-0 with zero failures, and a
|
||||
# kill requires the mutant to break a case THE BASELINE PASSED, recorded BY
|
||||
# NAME. A tally is not evidence; a named delta is.
|
||||
#
|
||||
# 2. IT MUTATED THE REVIEWED SOURCE IN PLACE. Restoration leaned on an EXIT trap.
|
||||
# A TRAP IS CLEANUP, NOT ISOLATION -- SIGKILL cannot run it. An interrupted run
|
||||
# left push-guard.sh mutated in the working tree, and the reviewer's NEXT
|
||||
# suite run silently inherited it. A verification tool that alters its subject
|
||||
# can leave the subject wrong in a way the next measurement believes.
|
||||
# Now: the subject is copied into a temp dir and only the COPY is ever
|
||||
# written to. The source is untouched BY CONSTRUCTION rather than by cleanup,
|
||||
# which is the only version of this that survives kill -9.
|
||||
#
|
||||
# 3. ITS WORK DIR WAS SHARED. Concurrent runs interfered through the suite's
|
||||
# default .work directory. Each run now gets its own.
|
||||
#
|
||||
# (Note the deliberate asymmetry with verify-clean-clone.sh, which forbids cp:
|
||||
# there the copy LAUNDERED the property under measurement, so measuring a copy
|
||||
# was the defect. Here mutation is destructive by design, so copying is what
|
||||
# PROTECTS the subject. The rule is not "never copy" -- it is "know whether the
|
||||
# copy preserves the property you are about to measure.")
|
||||
#
|
||||
# ================== THREE WAYS A MUTATION RUN LIES, AND THE GUARD FOR EACH ====
|
||||
# A. THE ANCHOR NO LONGER MATCHES. The mutant is never applied, the suite is
|
||||
# green, and the report says SURVIVED -- the same word a real coverage gap
|
||||
# gets. Guarded: ANCHOR MISSING is a loud failure.
|
||||
# B. THE ANCHOR MATCHES PROSE. This one landed on the first run: a mutant aimed
|
||||
# at a 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.
|
||||
# Guarded: anchors resolving inside usage() are refused.
|
||||
# C. THE ANCHOR IS AMBIGUOUS. Two unrelated branches here are both the line
|
||||
# `if (( status != 0 )); then`; a first-match replace would credit the kill
|
||||
# to the wrong branch. Guarded: a match count != 1 refuses rather than guesses.
|
||||
set -uo pipefail
|
||||
|
||||
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
# --dir exists so this tool can be pointed at a FIXTURE copy and tested.
|
||||
--dir) SRC_DIR="$(cd "$2" && pwd)"; shift 2 ;;
|
||||
*) printf 'usage error: unknown argument: %s\n' "$1" >&2; exit 64 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SRC_TARGET="$SRC_DIR/push-guard.sh"
|
||||
SRC_SUITE="$SRC_DIR/test-push-guard.sh"
|
||||
for f in "$SRC_TARGET" "$SRC_SUITE"; do
|
||||
[[ -r "$f" ]] || { printf 'REFUSING: cannot read %s\n' "$f" >&2; exit 1; }
|
||||
done
|
||||
|
||||
# --- ISOLATION, NOT CLEANUP --------------------------------------------------
|
||||
# Everything below writes only inside WORK. The trap is a courtesy for disk
|
||||
# space; correctness does not depend on it running.
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
install -m 755 "$SRC_TARGET" "$WORK/push-guard.sh"
|
||||
install -m 755 "$SRC_SUITE" "$WORK/test-push-guard.sh"
|
||||
TARGET="$WORK/push-guard.sh"
|
||||
SUITE="$WORK/test-push-guard.sh"
|
||||
BAK="$WORK/push-guard.sh.orig"
|
||||
cp "$TARGET" "$BAK"
|
||||
# Per-run work dir: the suite otherwise defaults to a shared .work beside itself,
|
||||
# and two concurrent runs corrupt each other's fixtures.
|
||||
export MOSAIC_TEST_WORK_DIR="$WORK/.work"
|
||||
|
||||
# --- 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
|
||||
|
||||
# passing_cases <output> -- names of cases that PASSED, one per line
|
||||
passing_cases() { printf '%s\n' "$1" | sed -n 's/^ PASS \[[^]]*\] \(.*\) (exit [0-9]*)$/\1/p'; }
|
||||
tally() { printf '%s\n' "$1" | grep -E 'needles: [0-9]+ passed' | tail -1; }
|
||||
|
||||
# --- THE BASELINE MUST BE GREEN, AND IT IS ESTABLISHED FIRST ------------------
|
||||
printf '=== baseline (must be exit 0 with zero failures) ===\n'
|
||||
BASE_OUT="$("$SUITE" 2>&1)"; BASE_RC=$?
|
||||
BASE_LINE="$(tally "$BASE_OUT")"
|
||||
BASE_FAILED="$(printf '%s\n' "$BASE_LINE" | sed -n 's/.*, \([0-9]*\) failed.*/\1/p')"
|
||||
if (( BASE_RC != 0 )) || [[ -z "$BASE_LINE" || "$BASE_FAILED" != "0" ]]; then
|
||||
printf 'REFUSING: baseline is not green -- exit %s, tally: %s\n' \
|
||||
"$BASE_RC" "${BASE_LINE:-<no tally emitted>}" >&2
|
||||
printf '\nEvery mutant would be scored KILLED by the pre-existing failure, and this\n' >&2
|
||||
printf 'tool would publish a confident coverage table that measured nothing. Fix the\n' >&2
|
||||
printf 'suite first. NO TABLE IS EMITTED.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf ' %s\n' "$BASE_LINE"
|
||||
mapfile -t BASE_PASSING < <(passing_cases "$BASE_OUT")
|
||||
printf ' %d named cases passing at baseline\n' "${#BASE_PASSING[@]}"
|
||||
if (( ${#BASE_PASSING[@]} == 0 )); then
|
||||
printf 'REFUSING: could not parse any case names -- kills could not be attributed.\n' >&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" <<'MUTPY'
|
||||
import sys
|
||||
src, dst, find, repl = sys.argv[1:5]
|
||||
open(dst, "w").write(open(src).read().replace(find, repl, 1))
|
||||
MUTPY
|
||||
local out; out="$("$SUITE" 2>&1)"
|
||||
cp "$BAK" "$TARGET"
|
||||
|
||||
local line; line="$(tally "$out")"
|
||||
if [[ -z "$line" ]]; then
|
||||
printf ' !! NO TALLY %-46s suite produced no needle count\n' "$name"
|
||||
rc_all=1; return
|
||||
fi
|
||||
# A KILL IS A NAMED DELTA, NOT A TALLY. Cases that passed at baseline and no
|
||||
# longer pass are the evidence; anything else (a case that was already
|
||||
# failing, a suite that died early) cannot be credited to this mutant.
|
||||
local now; now="$(passing_cases "$out")"
|
||||
local -a broke=()
|
||||
local c
|
||||
for c in "${BASE_PASSING[@]}"; do
|
||||
grep -qxF -- "$c" <<<"$now" || broke+=("$c")
|
||||
done
|
||||
local total="${#BASE_PASSING[@]}"
|
||||
|
||||
if (( ${#broke[@]} > 0 )); then
|
||||
printf ' KILLED L%-5s %-46s %d/%d fail\n' "$ln" "$name" "${#broke[@]}" "$total"
|
||||
printf ' by: %s\n' "${broke[0]}"
|
||||
(( ${#broke[@]} > 1 )) && printf ' +%d more\n' "$(( ${#broke[@]} - 1 ))"
|
||||
ROWS+=("| \`$name\` (L$ln) | ${#broke[@]}/$total fail | killed |")
|
||||
KILLED=$(( KILLED + 1 ))
|
||||
else
|
||||
printf ' SURVIVED L%-5s %-46s 0/%d fail <-- UNCOVERED BRANCH\n' "$ln" "$name" "$total"
|
||||
ROWS+=("| \`$name\` (L$ln) | 0/$total fail | **SURVIVED** |")
|
||||
SURVIVED=$(( SURVIVED + 1 )); rc_all=1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== push-guard mutation run ==="
|
||||
# EVERY ANCHOR BELOW IS VERBATIM SOURCE TEXT OF THE GUARD, so the single quotes
|
||||
# are load-bearing: these strings must reach `mutate` as the CHARACTERS that
|
||||
# appear in push-guard.sh. Expanding them would search for THIS shell's (unset)
|
||||
# $rel, $cmode, $EX_CONFIG and match nothing -- which the anchor guards would
|
||||
# report as ANCHOR MISSING rather than silently, but the intent is still to
|
||||
# forbid expansion. The directive is scoped to this function so it cannot mask a
|
||||
# genuine unintended-literal anywhere else in the file.
|
||||
# shellcheck disable=SC2016
|
||||
run_mutants() {
|
||||
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'
|
||||
# guard's own source text, matched verbatim. Expanding them here would search for
|
||||
# this shell's (empty) $EX_CONFIG instead of the characters in the file.
|
||||
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"')"
|
||||
}
|
||||
|
||||
run_mutants
|
||||
|
||||
printf '\nbaseline: %s\n' "$BASE_LINE"
|
||||
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_LINE" | sed 's/push-guard needles: //')"
|
||||
printf '%s\n' "${ROWS[@]}"
|
||||
exit "$rc_all"
|
||||
@@ -0,0 +1,204 @@
|
||||
# push-guard
|
||||
|
||||
Mechanical closure of one defect: **a verification that passes when the thing it verifies never happened.**
|
||||
|
||||
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 |
|
||||
|
||||
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` |
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
push-guard.sh check-staged --json-path 'data/**/*.json'
|
||||
|
||||
BEFORE=$(git rev-parse HEAD)
|
||||
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.
|
||||
|
||||
## Design decisions that measurement forced
|
||||
|
||||
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**.
|
||||
|
||||
**`--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
|
||||
|
||||
An adversarial review (author ≠ reviewer) found two genuine fail-opens that 17 self-written needles, five mutation runs and a repo-wide false-positive measurement had all missed. Both were reproduced before being fixed, and both now have needles.
|
||||
|
||||
**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.
|
||||
|
||||
**`-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.
|
||||
|
||||
Two reported findings did **not** reproduce and were not acted on: `:(glob)` does still match a bare directory pathspec, and my first rename repro failed only because the edit dropped similarity below the detection threshold — a badly built test, not an absent bug. Re-testing properly is what confirmed it.
|
||||
|
||||
## 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 decision is now required, from one of exactly two places:
|
||||
|
||||
```jsonc
|
||||
// nominate generated JSON for parse-checking
|
||||
{"json_paths": ["data/**/*.json"], "allow_invalid_json": ["fixtures/*"]}
|
||||
|
||||
// or opt out — the reason is REQUIRED and is printed on every run
|
||||
{"json_check": "none", "reason": "no generated JSON in this repo"}
|
||||
```
|
||||
|
||||
`--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.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
- `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` — 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.
|
||||
|
||||
### Mutation results — the harness was itself tested by breaking the guard
|
||||
|
||||
**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.
|
||||
|
||||
| mutation | suite result | verdict |
|
||||
| ---------------------------------------------------------------- | ------------------- | -------- |
|
||||
| _unmodified_ | 46 passed, 0 failed | baseline |
|
||||
| `json decision requirement bypassed` (L482) | 3/43 fail | killed |
|
||||
| `opt-out accepted with no written reason` (L334) | 1/43 fail | killed |
|
||||
| `committed re-read of the opt-out skipped` (L405) | 5/43 fail | killed |
|
||||
| `untracked config honoured as an opt-out` (L409) | 1/43 fail | killed |
|
||||
| `staged-but-uncommitted opt-out honoured` (L425) | 1/43 fail | killed |
|
||||
| `committed SYMLINK config honoured` (L433) | 1/43 fail | killed |
|
||||
| `unparseable committed config ignored` (L444) | 1/43 fail | killed |
|
||||
| `local-only opt-out (HEAD says ON) honoured` (L459) | 1/43 fail | killed |
|
||||
| `empty MERGE exempted` (L704) | 1/43 fail | killed |
|
||||
| `empty ROOT exempted` (L715) | 1/43 fail | killed |
|
||||
| `--since-head ancestry check removed` (L665) | 1/43 fail | killed |
|
||||
| `staged-file enumeration ignores git failure` (L173) | 1/43 fail | killed |
|
||||
| `malformed config degrades to absent instead of refusing` (L375) | 4/43 fail | killed |
|
||||
|
||||
13 mutants, 0 survived.
|
||||
|
||||
**Why the denominator is 43 while the suite reports 46.** The generator attributes kills only to cases it can parse by name, which is the `PASS [KIND] <name> (exit N)` form. Of the 46 passing assertions, 45 print `PASS` and 43 of those match that form. The three excluded lines are:
|
||||
|
||||
```
|
||||
PASS [CONTROL] guard runs without emitting any interpreter warning
|
||||
PASS [NEEDLE ] the warning detector fires on a known warning string
|
||||
ok [e9-fixture ] fixture is a merge (3 fields) with tree identical to both parents
|
||||
```
|
||||
|
||||
The two `z1` warning assertions assert on a whole _class_ of output rather than an exit code, so they carry no `(exit N)`; `e9-fixture` is a fixture precondition and prints in the `ok` form. All three still run and still gate the suite — they are excluded from _attribution_, not from _execution_.
|
||||
|
||||
An earlier revision of this paragraph named the excluded set as `w2-fixture`, `e9-fixture` and `g1-fixture`. **That was written from memory instead of from the output, and two of the three names were wrong:** `w2-fixture` belongs to `test-verify-clean-clone.sh` and `g1-fixture` to `test-mutate-push-guard.sh`, so neither runs in this suite at all and neither could contribute to its tally. A reader auditing the denominator would have gone looking for them in the wrong files. It is recorded rather than quietly corrected because a confidently-wrong provenance inside the section that exists to make a generated number auditable is the same defect as everything else on this page: **a claim that reads as measured and is not.** The set above was produced by running the suite and applying the generator's own parser to its output.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Then the review turned on the harness, and found three more
|
||||
|
||||
A second independent review ran everything from a fresh clone and reproduced three defects — **all of them in the tools written to prevent defects.** None was in `push-guard.sh`.
|
||||
|
||||
**1. The clean-clone verifier could not verify the tree that ships it.** `verify-clean-clone.sh` resolved the repository top level correctly but then passed **bare basenames** to `git ls-tree`, which is a root-relative pathspec. These files really live at `packages/mosaic/framework/tools/git/`, so in place every artifact came back `NOT TRACKED at HEAD` and the verifier exited 1 without ever running. The tool built to stop packaging false-greens was unusable against its own packaging.
|
||||
|
||||
Its suite could not see it, because **every fixture installed the artifacts at the fixture repository root.** 6/6 green proved flat-layout operation and said nothing about the deployed path. That is the third time on this tool that a control validated a _model_ instead of the _subject_: v1 of the verifier measured a `cp`'d scratch repo, and then v2's own tests measured a layout that does not exist. **A fixture is a claim about the world; an untested fixture is an unreviewed one.** The committed prefix now comes from `git rev-parse --show-prefix` and is threaded through `ls-tree`, the cloned `stat`, and the suite's working directory; `w6-nested` builds the real nested layout, `w6-prefix` asserts the verifier _reports_ that prefix (so a green `w6` cannot mean the prefix was harmlessly ignored), and `w7-nested-mode` proves the mode needle still bites down there.
|
||||
|
||||
**2. Any pre-existing suite failure satisfied every mutant.** The generator called a mutant `KILLED` whenever `failed > 0`, and never required a green baseline. Inject one always-failing case that changes no guard behaviour whatsoever and the run reports _13 killed, 0 survived_, emits the table above, and exits 0. **A tally is not evidence; a named delta is.** The generator now refuses outright unless the unmodified baseline is exit-0 with zero failures — and emits no table when it refuses — then scores each mutant by the _named cases_ that stopped passing, printing the first one (`by: <case>`) beside every kill.
|
||||
|
||||
**3. An interrupted run stranded a mutated `push-guard.sh` in the reviewed tree.** Restoration leaned on an `EXIT` trap. **A trap is cleanup, not isolation, and SIGKILL cannot run it.** It happened to the reviewer twice and contaminated the following suite run until the clone was discarded. Isolation is now by construction: the guard and suite are `install`ed into a temp dir and every mutation is applied to _that_ copy, so the reviewed file is never opened for writing at all.
|
||||
|
||||
Note the deliberate asymmetry with `verify-clean-clone.sh`, which forbids `cp` anywhere in the file. The rule is not "never copy" — it is **know whether the copy preserves the property you are about to measure.** `cp` launders mode, so the verifier must not copy; mutation is destructive by design, so the generator must.
|
||||
|
||||
`test-mutate-push-guard.sh` (8 cases) now covers all three: `g1-*` proves a red baseline is refused with no table, `g2-*` is the positive control plus an assertion that kills are attributed by name, and `g3-*` kills the generator mid-mutation and asserts the subject is byte-identical afterwards.
|
||||
|
||||
That last one was **vacuous on its first attempt.** `timeout -s KILL 3` looked convincing and proved nothing: at three seconds the generator is still running its baseline, so no mutation has been applied and the subject is trivially unchanged — for the _unfixed_ in-place generator too, which I confirmed by rebuilding it and running it. The kill is now driven from inside the run (the fixture's suite counts its own invocations and kills the generator on the second, when mutant #1 is applied), and `g3-needle-bites` puts the reconstructed pre-fix mechanism through the identical kill to prove it _does_ strand a mutated file. A control written to close a blocker was itself a member of the vacuous family.
|
||||
|
||||
Two smaller things fell out of building those cases, both worth recording because both read as the opposite of what they were:
|
||||
|
||||
- **`grep -q` under `pipefail` turns a successful match into a failed assertion.** `grep -q` exits at the first match, the producer dies of SIGPIPE, and `pipefail` reports 141. This cost a red `w6-prefix` against a verifier that was printing the right prefix all along. Capture into a variable and test the variable.
|
||||
- **`$PPID` inside `$( )` is the subshell, not the caller.** Killing it merely ends the command substitution; the parent carries on and exits 0. The fixture uses `kill -9 0` (the process group) with the generator launched under `setsid --wait`.
|
||||
|
||||
**Linting is measured at default severity, and the earlier claim was not.** "shellcheck clean on all five" was published on the strength of `shellcheck -S warning`, which exited 0 — while the default severity exited 1 with twelve `SC2016` findings. A filtered measurement reported as an unfiltered claim is the same shape as everything else on this page. Those literals genuinely must not expand, so `run_mutants()` carries one scoped, documented `SC2016` suppression; all six files are now clean at **default** severity. (A documented "this literal is intentionally unexpanded" is a different thing from a comment asserting a safety property nobody rechecks.)
|
||||
|
||||
**Not independently reproduced here:** blocker 1's original repro ran against the real PR checkout, and this session has no credential for that remote. The `w6`/`w7` fixtures replicate the layout at the exact deployed prefix instead, which is a reconstruction, not the original observation. Stated rather than glossed.
|
||||
|
||||
## Proposed framework path
|
||||
|
||||
```
|
||||
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/test-mutate-push-guard.sh # 8 needles for the generator
|
||||
framework/tools/git/verify-clean-clone.sh # proves the COMMITTED artifact runs
|
||||
framework/tools/git/test-verify-clean-clone.sh # 9 needles for the verifier
|
||||
```
|
||||
|
||||
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 six 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.
|
||||
+793
@@ -0,0 +1,793 @@
|
||||
#!/usr/bin/env bash
|
||||
# push-guard.sh - Mechanical guards against verifications that PASS when the
|
||||
# thing they verify never happened.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# ---------------
|
||||
# Three independent incidents, one shape:
|
||||
# 1. An agent's "PUSH VERIFIED" step compared local HEAD to the remote ref and
|
||||
# reported success. The commit had ABORTED, so local trivially equalled
|
||||
# remote. The guard could not distinguish "pushed" from "committed nothing".
|
||||
# 2. An agent chained a push after a FAILED commit, ran on stale state, and
|
||||
# pushed an EMPTY commit carrying an unrelated commit's message.
|
||||
# 3. An agent committed unresolved conflict markers into 70 generated files and
|
||||
# pushed invalid JSON to a default branch.
|
||||
#
|
||||
# All three are the same defect: a check whose success condition is satisfied by
|
||||
# the null case. This script asserts the POSITIVE fact (a new object exists, the
|
||||
# remote MOVED, the content parses) rather than the absence of an error.
|
||||
#
|
||||
# DESIGN RULES (do not relax these)
|
||||
# * Every check exits NON-ZERO and names what failed. There is deliberately no
|
||||
# --warn-only / --soft flag: a guard that can degrade to a warning is the
|
||||
# fail-open we are trying to remove.
|
||||
# * Checks are fail-CLOSED. `set -euo pipefail` means an unexpected git or
|
||||
# network error aborts non-zero rather than falling through to "OK".
|
||||
# * Nothing is skipped silently. A check with no applicable inputs says so on
|
||||
# stdout instead of contributing a quiet green.
|
||||
#
|
||||
# EXIT CODES
|
||||
# 0 all requested checks passed
|
||||
# 2 conflict markers present, or the index has unmerged paths
|
||||
# 3 staged JSON does not parse
|
||||
# 4 push verification failed (remote did not move, or moved elsewhere)
|
||||
# 5 nothing staged / empty commit — the null case, refused
|
||||
# 6 NO JSON DECISION — no --json-path and no usable .push-guard.json
|
||||
# 64 usage error
|
||||
#
|
||||
# Dependencies: bash 4.4+, git, python3.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly EX_OK=0
|
||||
readonly EX_CONFLICT=2
|
||||
readonly EX_JSON=3
|
||||
readonly EX_PUSH=4
|
||||
readonly EX_NULL=5
|
||||
readonly EX_CONFIG=6
|
||||
readonly EX_USAGE=64
|
||||
|
||||
# Repo-level configuration. Its ABSENCE is refused, not defaulted — see
|
||||
# resolve_json_config() for why.
|
||||
readonly CONFIG_FILE=".push-guard.json"
|
||||
|
||||
# Conflict-marker detection.
|
||||
#
|
||||
# We match `<<<<<<<`, `>>>>>>>` and the diff3 `|||||||` marker, each anchored at
|
||||
# column 0 and required to be followed by a space or end-of-line (real markers
|
||||
# are exactly seven characters plus an optional ref label).
|
||||
#
|
||||
# We deliberately do NOT match a bare `=======` line. It is the one marker that
|
||||
# collides with ordinary prose — reStructuredText section underlines and ASCII
|
||||
# rules use it constantly — and a guard that fires on documentation gets switched
|
||||
# off, which costs more than the miss. Every genuine conflict git writes contains
|
||||
# `<<<<<<<` and `>>>>>>>` as well, so requiring those loses no real detection.
|
||||
readonly MARKER_PATTERNS=(
|
||||
-e '^<<<<<<<( |$)'
|
||||
-e '^>>>>>>>( |$)'
|
||||
-e '^[|]{7}( |$)'
|
||||
)
|
||||
|
||||
log() { printf '%s\n' "$*"; }
|
||||
fail() {
|
||||
local code="$1"; shift
|
||||
printf '\n' >&2
|
||||
printf 'PUSH-GUARD FAILED: %s\n' "$1" >&2
|
||||
shift
|
||||
local line
|
||||
for line in "$@"; do printf ' %s\n' "$line" >&2; done
|
||||
printf '\n' >&2
|
||||
exit "$code"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
push-guard.sh check-staged [--json-path <pathspec>]...
|
||||
[--allow-invalid-json <pathspec>]...
|
||||
push-guard.sh push --remote <remote> --branch <branch> [--since-head <sha>]
|
||||
[-- <extra git push args>...]
|
||||
push-guard.sh --help
|
||||
|
||||
Subcommands:
|
||||
check-staged Run pre-commit content guards against the STAGED index:
|
||||
(a) no unmerged index paths, no conflict markers
|
||||
(c) staged JSON under the nominated paths parses
|
||||
Refuses to pass when nothing is staged (exit 5).
|
||||
Check (c) requires an EXPLICIT decision — either --json-path,
|
||||
or .push-guard.json. Saying nothing is refused (exit 6).
|
||||
|
||||
push Perform a push and prove it HAPPENED:
|
||||
- HEAD is a non-empty commit (differs from its parent)
|
||||
- with --since-head: HEAD advanced past that sha
|
||||
- the remote ref MOVED, and now equals local HEAD
|
||||
Capturing the remote ref BEFORE the push is what makes
|
||||
"remote == local" meaningful; without it the assertion is
|
||||
satisfied by having pushed nothing.
|
||||
|
||||
Options:
|
||||
--json-path <pathspec> Git pathspec of generated/serialized JSON to
|
||||
parse-check, e.g. 'data/**/*.json'. Repeatable.
|
||||
Opt-in on purpose: many real .json files are
|
||||
JSONC (tsconfig) or templates and do NOT parse
|
||||
strictly. Nominate the generated ones. Satisfies
|
||||
the decision requirement on its own; overrides a
|
||||
config opt-out (loudly).
|
||||
--allow-invalid-json <pathspec> Exempt a path from the JSON parse check
|
||||
(for deliberate malformed-input fixtures).
|
||||
Exemptions are printed, never silent.
|
||||
--since-head <sha> HEAD before your commit step. Asserts a new
|
||||
commit object was actually created.
|
||||
--remote <name> Remote to push to.
|
||||
--branch <name> Branch to push.
|
||||
|
||||
Repo config (.push-guard.json, at the repository root):
|
||||
{"json_paths": ["data/**/*.json"], "allow_invalid_json": ["fixtures/*"]}
|
||||
— nominate generated JSON for parse-checking.
|
||||
{"json_check": "none", "reason": "no generated JSON in this repo"}
|
||||
— opt out. The reason is REQUIRED and is printed on every run.
|
||||
|
||||
An opt-out is only accepted from this file, never from a command-line flag: a
|
||||
committed reason can be read in a diff, an absent flag cannot be reviewed at all.
|
||||
An invalid config is refused outright rather than treated as absent.
|
||||
|
||||
There is no flag to downgrade a failure to a warning. That is intentional.
|
||||
EOF
|
||||
}
|
||||
|
||||
require_repo() {
|
||||
git rev-parse --show-toplevel >/dev/null 2>&1 \
|
||||
|| fail "$EX_USAGE" "not inside a git repository"
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- check-staged
|
||||
|
||||
# staged_files_z <array-name> [pathspec...]
|
||||
#
|
||||
# THE FAIL-OPEN THIS FUNCTION EXISTS TO REMOVE:
|
||||
# mapfile -d '' -t files < <(git diff ...)
|
||||
# observes MAPFILE's exit status, never the producer's. When git diff dies -- a
|
||||
# bad pathspec, a corrupt index -- mapfile cheerfully reads zero bytes and
|
||||
# succeeds, and the caller then reports that silence as "nothing to check".
|
||||
#
|
||||
# `set -o pipefail` does NOT cover this. pipefail applies to PIPELINES; a process
|
||||
# substitution is an asynchronous child whose status is never collected. That is
|
||||
# the precise reason every `cmd | cmd` in this file is safe and both `< <(cmd)`
|
||||
# constructs were not -- a distinction worth stating, because "we set pipefail"
|
||||
# reads like whole-file coverage and is not.
|
||||
#
|
||||
# Both callers now route through here, so the category is gone rather than the
|
||||
# two known instances being patched.
|
||||
staged_files_z() {
|
||||
local -n _out="$1"; shift
|
||||
local tmp err status=0
|
||||
tmp="$(mktemp)"; err="$(mktemp)"
|
||||
if (( $# )); then
|
||||
git diff --cached --name-only --diff-filter=ACM --no-renames -z \
|
||||
-- "$@" >"$tmp" 2>"$err" || status=$?
|
||||
else
|
||||
git diff --cached --name-only --diff-filter=ACM --no-renames -z \
|
||||
>"$tmp" 2>"$err" || status=$?
|
||||
fi
|
||||
if (( status != 0 )); then
|
||||
local msg; msg="$(tr '\n' ' ' <"$err")"
|
||||
rm -f "$tmp" "$err"
|
||||
fail "$EX_CONFIG" \
|
||||
"CANNOT ENUMERATE STAGED FILES — git diff exited $status" \
|
||||
"git said: ${msg:-(no message)}" \
|
||||
"" \
|
||||
"Refusing to report a result. An enumeration that failed returns no" \
|
||||
"files, which is indistinguishable from a clean tree — reporting that" \
|
||||
"as OK would be a silent pass on an unexamined index."
|
||||
fi
|
||||
_out=()
|
||||
mapfile -d '' -t _out <"$tmp"
|
||||
rm -f "$tmp" "$err"
|
||||
}
|
||||
|
||||
check_unmerged() {
|
||||
local unmerged
|
||||
unmerged="$(git ls-files --unmerged | awk '{print $4}' | sort -u)"
|
||||
if [[ -n "$unmerged" ]]; then
|
||||
mapfile -t paths <<<"$unmerged"
|
||||
fail "$EX_CONFLICT" \
|
||||
"the index has UNMERGED paths — a merge/rebase is still in progress" \
|
||||
"${paths[@]}" \
|
||||
"" \
|
||||
"Resolve the conflict and 'git add' the result. Do not commit past this."
|
||||
fi
|
||||
log " ok index has no unmerged paths"
|
||||
}
|
||||
|
||||
check_conflict_markers() {
|
||||
local -a files=("$@")
|
||||
local hits
|
||||
# git grep --cached searches the INDEX (what will actually be committed),
|
||||
# not the working tree.
|
||||
#
|
||||
# -a (treat every blob as text), NOT -I (skip binary). -I honours git's
|
||||
# binary classification, which includes an explicit `.gitattributes` `binary`
|
||||
# attribute. A repo that marks a path binary would have its staged conflict
|
||||
# markers silently skipped while the summary still counted the file as
|
||||
# scanned — a clean pass AND a false count. Verified reproducible. The cost
|
||||
# of -a is that a genuine binary blob could theoretically emit a noisy match
|
||||
# line; a false alarm is recoverable, a silent miss is not.
|
||||
# -E is load-bearing: git grep defaults to BASIC regex, in which '(', '|'
|
||||
# and '{7}' are literal characters. Without it these patterns match nothing
|
||||
# and the check reports a clean pass over a file full of markers. That
|
||||
# failure was caught by the needle, not by review.
|
||||
#
|
||||
# Exit status is discriminated, NOT swallowed. git grep returns 1 for "no
|
||||
# match" and >=2 for a real error (bad pathspec, unreadable index). A blanket
|
||||
# '|| true' would turn a broken scan into "ok, no conflict markers" — the
|
||||
# same fail-open in the guard's own error handling.
|
||||
local status=0
|
||||
hits="$(git grep --cached -a -n -E "${MARKER_PATTERNS[@]}" -- "${files[@]}")" || status=$?
|
||||
if (( status > 1 )); then
|
||||
fail "$EX_CONFLICT" \
|
||||
"git grep FAILED (exit $status) — the conflict scan did not run" \
|
||||
"Refusing to report a clean result from a scan that did not complete."
|
||||
fi
|
||||
if [[ -n "$hits" ]]; then
|
||||
mapfile -t lines <<<"$hits"
|
||||
fail "$EX_CONFLICT" \
|
||||
"staged content contains unresolved CONFLICT MARKERS" \
|
||||
"${lines[@]}" \
|
||||
"" \
|
||||
"These are staged and would be committed verbatim."
|
||||
fi
|
||||
log " ok no conflict markers in ${#files[@]} staged file(s)"
|
||||
}
|
||||
|
||||
# JSON parse check.
|
||||
#
|
||||
# SCOPE IS OPT-IN, AND THAT IS A MEASURED DECISION, NOT LAZINESS.
|
||||
# The first draft checked every staged *.json. Run against a real repository that
|
||||
# turned out to be 17 of 119 tracked .json files failing a strict parse — all of
|
||||
# them legitimate: 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 would have fired on ~14% of this repo's JSON, and a guard
|
||||
# that cries wolf gets routed around until the bypass is habitual — which is
|
||||
# worse than no guard, because the bypass then also covers the true positives.
|
||||
#
|
||||
# So the caller nominates the generated/serialized paths this check is FOR, as
|
||||
# git pathspecs (git, not bash, expands them — '**' works correctly).
|
||||
#
|
||||
# WHAT SAYING NOTHING NOW COSTS YOU
|
||||
# ---------------------------------
|
||||
# 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 MANDATORY and must come from one of two places:
|
||||
# * turning the check ON — --json-path on the command line, or "json_paths"
|
||||
# in .push-guard.json
|
||||
# * turning the check OFF — ONLY "json_check": "none" in .push-guard.json,
|
||||
# which REQUIRES a non-empty "reason"
|
||||
# Saying nothing at all is refused (exit 6).
|
||||
#
|
||||
# The asymmetry is deliberate. Turning a check on is safe from anywhere. Turning
|
||||
# one 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 that lives in an ad-hoc command line is
|
||||
# just the old fail-open with extra steps.
|
||||
# The config parser, held as a string rather than an inline heredoc.
|
||||
#
|
||||
# A `<<'PY'` heredoc INSIDE a $( ) command substitution makes bash emit
|
||||
# warning: command substitution: 1 unterminated here-document
|
||||
# on every single run. It still executed, every needle stayed green and the
|
||||
# static linter stayed clean — the warning goes to stderr and broke no
|
||||
# assertion. It surfaced only when the guard was run against a real
|
||||
# repository. A tool built to refuse quiet output was quietly polluting
|
||||
# stderr; needle 'z1' now asserts the guard emits no warnings at all.
|
||||
#
|
||||
# `read -d ''` returns non-zero at EOF without finding a NUL, which is the
|
||||
# NORMAL path when slurping a heredoc — hence `|| true`. That is the one
|
||||
# shape where it does not mask a real error, unlike the `git grep || true`
|
||||
# this codebase already removed once.
|
||||
IFS='' read -r -d '' CONFIG_PARSER <<'PY' || true
|
||||
import json, sys
|
||||
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
with open(path) as fh:
|
||||
cfg = json.load(fh)
|
||||
except Exception as exc:
|
||||
sys.stderr.write("does not parse: %s" % exc)
|
||||
sys.exit(2)
|
||||
|
||||
if not isinstance(cfg, dict):
|
||||
sys.stderr.write("must contain a JSON object, got %s" % type(cfg).__name__)
|
||||
sys.exit(2)
|
||||
|
||||
def clean_list(name, value):
|
||||
if not isinstance(value, list):
|
||||
sys.stderr.write('"%s" must be an array' % name)
|
||||
sys.exit(2)
|
||||
for item in value:
|
||||
if not isinstance(item, str) or not item.strip():
|
||||
sys.stderr.write('"%s" must contain only non-empty strings' % name)
|
||||
sys.exit(2)
|
||||
# Tabs/newlines would be mangled by the tab-delimited handoff below.
|
||||
# Reject them explicitly rather than silently truncating a pathspec.
|
||||
if "\t" in item or "\n" in item:
|
||||
sys.stderr.write('"%s" entry contains a tab or newline: %r' % (name, item))
|
||||
sys.exit(2)
|
||||
return value
|
||||
|
||||
mode = cfg.get("json_check")
|
||||
paths = cfg.get("json_paths")
|
||||
allow = cfg.get("allow_invalid_json", [])
|
||||
|
||||
if mode is not None and mode != "none":
|
||||
sys.stderr.write('"json_check" must be "none" if present, got %r' % (mode,))
|
||||
sys.exit(2)
|
||||
|
||||
if mode == "none":
|
||||
if paths:
|
||||
sys.stderr.write('"json_check": "none" and "json_paths" are mutually exclusive')
|
||||
sys.exit(2)
|
||||
reason = cfg.get("reason")
|
||||
if not isinstance(reason, str) or not reason.strip():
|
||||
sys.stderr.write('"json_check": "none" REQUIRES a non-empty "reason"')
|
||||
sys.exit(2)
|
||||
print("MODE\tnone")
|
||||
print("REASON\t%s" % reason.strip().replace("\t", " ").replace("\n", " "))
|
||||
sys.exit(0)
|
||||
|
||||
if paths is None:
|
||||
sys.stderr.write(
|
||||
'states no decision — needs "json_paths", '
|
||||
'or "json_check": "none" with a "reason"'
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
clean_list("json_paths", paths)
|
||||
if not paths:
|
||||
sys.stderr.write('"json_paths" must not be empty')
|
||||
sys.exit(2)
|
||||
clean_list("allow_invalid_json", allow)
|
||||
|
||||
print("MODE\tcheck")
|
||||
for item in paths:
|
||||
print("JPATH\t%s" % item)
|
||||
for item in allow:
|
||||
print("ALLOW\t%s" % item)
|
||||
PY
|
||||
|
||||
resolve_json_config() {
|
||||
local cfg out status=0
|
||||
cfg="$(git rev-parse --show-toplevel)/$CONFIG_FILE"
|
||||
|
||||
if [[ ! -f "$cfg" ]]; then
|
||||
CFG_MODE="absent"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# A malformed config must REFUSE, never degrade to "treat as absent". That
|
||||
# fallback would rebuild precisely the fail-open this gate closes: a typo in
|
||||
# the config would silently disable the check it was written to enable.
|
||||
out="$(python3 -c "$CONFIG_PARSER" "$cfg" 2>&1)" || status=$?
|
||||
|
||||
if (( status != 0 )); then
|
||||
fail "$EX_CONFIG" \
|
||||
"$CONFIG_FILE is INVALID — $out" \
|
||||
"" \
|
||||
"Refusing to run. A config that does not parse is not the same as no" \
|
||||
"config: treating it as absent would silently disable the very check" \
|
||||
"this file was written to enable."
|
||||
fi
|
||||
|
||||
local key val
|
||||
while IFS=$'\t' read -r key val; do
|
||||
case "$key" in
|
||||
MODE) CFG_MODE="$val" ;;
|
||||
REASON) CFG_REASON="$val" ;;
|
||||
JPATH) CFG_PATHS+=("$val") ;;
|
||||
ALLOW) CFG_ALLOW+=("$val") ;;
|
||||
esac
|
||||
done <<<"$out"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# THE ASYMMETRY, ENFORCED RATHER THAN INTENDED.
|
||||
# ON may come from anywhere: turning a check on needs no review.
|
||||
# OFF must come from a COMMITTED object, because the entire argument for
|
||||
# requiring a written reason was that a reason is reviewable in a diff
|
||||
# while an absence is not. An opt-out honoured from an untracked
|
||||
# working-tree file is not reviewable either -- it is an ad-hoc local
|
||||
# bypass wearing a config file's clothes, and it defeats the design.
|
||||
# So the OFF decision is re-read from HEAD and the working tree gets no
|
||||
# vote in it. A committed ON flipped to OFF locally is likewise refused.
|
||||
# ------------------------------------------------------------------
|
||||
[[ "$CFG_MODE" == "none" ]] || return 0
|
||||
|
||||
local rel cmode cblob cout cstatus=0
|
||||
rel="$(git ls-files --full-name --error-unmatch -- "$cfg" 2>/dev/null)" || rel=""
|
||||
if [[ -z "$rel" ]]; then
|
||||
fail "$EX_CONFIG" \
|
||||
"OPT-OUT IS NOT REVIEWABLE — $CONFIG_FILE is not tracked in git" \
|
||||
"recorded reason was: ${CFG_REASON:-(none)}" \
|
||||
"" \
|
||||
"The opt-out requires a written reason precisely so it shows up in a" \
|
||||
"diff. An untracked file shows up in nobody's review, so honouring it" \
|
||||
"would rebuild the unreviewable bypass this design exists to prevent." \
|
||||
"" \
|
||||
"Commit $CONFIG_FILE, then run again. Turning the check ON needs no" \
|
||||
"commit; only turning it OFF does."
|
||||
fi
|
||||
|
||||
# A COMMITTED SYMLINK (mode 120000) is a mutable target: the reviewed blob
|
||||
# is a path, and what it points at can change without any diff at all.
|
||||
cmode="$(git ls-tree HEAD -- "$rel" 2>/dev/null | awk '{print $1}')"
|
||||
if [[ -z "$cmode" ]]; then
|
||||
fail "$EX_CONFIG" \
|
||||
"OPT-OUT IS NOT REVIEWABLE — $CONFIG_FILE is staged but not yet in HEAD" \
|
||||
"recorded reason was: ${CFG_REASON:-(none)}" \
|
||||
"" \
|
||||
"A staged-but-uncommitted opt-out has not been through review either." \
|
||||
"Commit it first."
|
||||
fi
|
||||
if [[ "$cmode" == "120000" ]]; then
|
||||
fail "$EX_CONFIG" \
|
||||
"OPT-OUT IS NOT REVIEWABLE — $CONFIG_FILE is a committed SYMLINK" \
|
||||
"" \
|
||||
"The reviewed object would be a path, not a decision: what it points" \
|
||||
"at can be changed later without producing any diff. Commit the" \
|
||||
"config as a regular file."
|
||||
fi
|
||||
|
||||
cblob="$(git show "HEAD:$rel" 2>/dev/null)" || cblob=""
|
||||
cout="$(printf '%s' "$cblob" | python3 -c "$CONFIG_PARSER" /dev/stdin 2>&1)" || cstatus=$?
|
||||
if (( cstatus != 0 )); then
|
||||
fail "$EX_CONFIG" \
|
||||
"COMMITTED $CONFIG_FILE is INVALID — $cout" \
|
||||
"" \
|
||||
"The working tree opts out, but the committed version does not parse," \
|
||||
"so there is no reviewable decision to honour."
|
||||
fi
|
||||
local cmode_val="" creason=""
|
||||
while IFS=$'\t' read -r key val; do
|
||||
case "$key" in
|
||||
MODE) cmode_val="$val" ;;
|
||||
REASON) creason="$val" ;;
|
||||
esac
|
||||
done <<<"$cout"
|
||||
|
||||
if [[ "$cmode_val" != "none" ]]; then
|
||||
fail "$EX_CONFIG" \
|
||||
"LOCAL-ONLY OPT-OUT — the working tree turns the JSON check OFF but" \
|
||||
"HEAD does not (committed decision: $cmode_val)" \
|
||||
"working-tree reason: ${CFG_REASON:-(none)}" \
|
||||
"" \
|
||||
"An uncommitted edit that disables a committed check is exactly the" \
|
||||
"unreviewable bypass this guard refuses. Commit the change if it is" \
|
||||
"real; revert it if it was a local convenience."
|
||||
fi
|
||||
|
||||
# Print the COMMITTED reason, never the working tree's: the reason anyone
|
||||
# can actually review is the one in the object.
|
||||
CFG_REASON="$creason"
|
||||
}
|
||||
|
||||
check_staged_json() {
|
||||
local -a checked=() skipped=()
|
||||
local f err
|
||||
local -a pathspec=()
|
||||
|
||||
resolve_json_config
|
||||
|
||||
if (( ${#JSON_PATHS[@]} == 0 )); then
|
||||
case "$CFG_MODE" in
|
||||
absent)
|
||||
fail "$EX_CONFIG" \
|
||||
"NO JSON DECISION — no --json-path, and no $CONFIG_FILE" \
|
||||
"" \
|
||||
"This guard will not run without an explicit decision about" \
|
||||
"staged-JSON validation. Create $CONFIG_FILE with EITHER:" \
|
||||
"" \
|
||||
' {"json_paths": ["data/**/*.json"]}' \
|
||||
"" \
|
||||
"or, if this repo genuinely has no generated JSON:" \
|
||||
"" \
|
||||
' {"json_check": "none", "reason": "<why>"}' \
|
||||
"" \
|
||||
"The reason is mandatory so the opt-out is recorded and" \
|
||||
"reviewable in the diff, instead of being an absence nobody sees."
|
||||
;;
|
||||
none)
|
||||
log " -- JSON check OPTED OUT in $CONFIG_FILE — recorded reason: $CFG_REASON"
|
||||
return 0
|
||||
;;
|
||||
check)
|
||||
JSON_PATHS=(${CFG_PATHS[@]+"${CFG_PATHS[@]}"})
|
||||
ALLOW_INVALID_JSON+=(${CFG_ALLOW[@]+"${CFG_ALLOW[@]}"})
|
||||
log " .. JSON paths from $CONFIG_FILE: ${JSON_PATHS[*]}"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# An explicit --json-path turns the check ON, so it satisfies the decision
|
||||
# requirement by itself. If the config opted out, the nomination WINS and
|
||||
# says so loudly — resolving a contradiction toward more checking is the
|
||||
# only safe direction, but silently ignoring a committed opt-out would
|
||||
# hide a real disagreement.
|
||||
case "$CFG_MODE" in
|
||||
none)
|
||||
log " !! $CONFIG_FILE opts out of the JSON check, but --json-path was"
|
||||
log " !! given — honouring the nomination and checking anyway."
|
||||
;;
|
||||
check)
|
||||
JSON_PATHS+=(${CFG_PATHS[@]+"${CFG_PATHS[@]}"})
|
||||
ALLOW_INVALID_JSON+=(${CFG_ALLOW[@]+"${CFG_ALLOW[@]}"})
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Normalize to :(glob) magic. WITHOUT it, git's default pathspec matching
|
||||
# makes 'data/**/*.json' require at least one intermediate directory, so it
|
||||
# matches data/sub/b.json but SILENTLY SKIPS data/a.json. The obvious
|
||||
# spelling would then deliver partial coverage with no warning — a
|
||||
# quiet-underscan, which is the same family of defect as a quiet pass.
|
||||
# Pathspecs that already carry explicit magic (leading ':') are left alone.
|
||||
for f in "${JSON_PATHS[@]}"; do
|
||||
[[ "$f" == :* ]] && pathspec+=("$f") || pathspec+=(":(glob)$f")
|
||||
done
|
||||
for f in ${ALLOW_INVALID_JSON[@]+"${ALLOW_INVALID_JSON[@]}"}; do
|
||||
[[ "$f" == :* ]] && pathspec+=("$f") || pathspec+=(":(exclude,glob)$f")
|
||||
skipped+=("$f")
|
||||
done
|
||||
|
||||
local -a files=()
|
||||
staged_files_z files "${pathspec[@]}"
|
||||
|
||||
for f in ${files[@]+"${files[@]}"}; do
|
||||
[[ "$f" == *.json ]] || continue
|
||||
|
||||
if ! err="$(git show ":$f" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
json.load(sys.stdin)
|
||||
except Exception as exc:
|
||||
sys.stderr.write(str(exc))
|
||||
sys.exit(1)
|
||||
' 2>&1)"; then
|
||||
fail "$EX_JSON" \
|
||||
"staged JSON does not parse: $f" \
|
||||
"$err" \
|
||||
"" \
|
||||
"A serialization error from a generator is a STOP, not something to commit past."
|
||||
fi
|
||||
checked+=("$f")
|
||||
done
|
||||
|
||||
for f in ${skipped[@]+"${skipped[@]}"}; do
|
||||
log " -- JSON check EXEMPTED by --allow-invalid-json: $f"
|
||||
done
|
||||
if (( ${#checked[@]} == 0 )); then
|
||||
log " -- no staged .json under ${JSON_PATHS[*]} — JSON check not applicable"
|
||||
else
|
||||
log " ok ${#checked[@]} staged .json file(s) under ${JSON_PATHS[*]} parse"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_check_staged() {
|
||||
require_repo
|
||||
log "push-guard: checking staged content"
|
||||
|
||||
check_unmerged
|
||||
|
||||
# --no-renames is load-bearing, NOT cosmetic. Git detects renames by default
|
||||
# (diff.renames=true since 2.9), so a `git mv` plus a small edit is reported
|
||||
# as ONE 'R' entry — which --diff-filter=ACM DOES NOT MATCH. The renamed file
|
||||
# becomes invisible to every content check: staged conflict markers sail
|
||||
# through and the guard prints "staged content OK". Verified reproducible at
|
||||
# 97% similarity with an ordinary co-staged file present. --no-renames
|
||||
# decomposes each rename back into D + A so the new path is always seen.
|
||||
# (Adding R to the filter also works, but --name-only -z emits two paths for
|
||||
# a rename and is ambiguous to parse — this removes the category instead.)
|
||||
#
|
||||
# SECOND INSTANCE OF THE SAME DEFECT, found by sweeping for the construct
|
||||
# rather than fixing only the one that was reported. This one failed CLOSED
|
||||
# (zero files hit "NOTHING IS STAGED"), so it was never a security hole --
|
||||
# but it reported a confident WRONG DIAGNOSIS, sending anyone debugging it
|
||||
# at their index instead of at the failing git invocation.
|
||||
local -a files=()
|
||||
staged_files_z files
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
fail "$EX_NULL" \
|
||||
"NOTHING IS STAGED" \
|
||||
"About to commit, but the index is identical to HEAD." \
|
||||
"" \
|
||||
"This is the null case: a commit here is empty, and any later" \
|
||||
"'verified' step would be asserting against content that does not exist."
|
||||
fi
|
||||
|
||||
check_conflict_markers "${files[@]}"
|
||||
check_staged_json "${files[@]}"
|
||||
|
||||
log "push-guard: staged content OK"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------ push
|
||||
|
||||
remote_sha() {
|
||||
# Empty output means the branch does not exist on the remote yet.
|
||||
# A network/auth failure makes ls-remote non-zero, which set -e turns into an
|
||||
# abort — the guard never treats an unreachable remote as "unchanged".
|
||||
git ls-remote "$1" "refs/heads/$2" | awk 'NR==1{print $1}'
|
||||
}
|
||||
|
||||
cmd_push() {
|
||||
require_repo
|
||||
local remote="" branch="" since_head=""
|
||||
local -a extra=()
|
||||
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--remote) remote="${2:-}"; shift 2 ;;
|
||||
--branch) branch="${2:-}"; shift 2 ;;
|
||||
--since-head) since_head="${2:-}"; shift 2 ;;
|
||||
--) shift; extra=("$@"); break ;;
|
||||
*) fail "$EX_USAGE" "unknown argument to push: $1" ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "$remote" && -n "$branch" ]] \
|
||||
|| fail "$EX_USAGE" "push requires --remote and --branch"
|
||||
|
||||
local head
|
||||
head="$(git rev-parse HEAD)"
|
||||
log "push-guard: verifying push of $head -> $remote/$branch"
|
||||
|
||||
# (1) Did a new commit actually get created?
|
||||
if [[ -n "$since_head" ]]; then
|
||||
git rev-parse --verify --quiet "${since_head}^{commit}" >/dev/null \
|
||||
|| fail "$EX_USAGE" \
|
||||
"--since-head is not a commit in this repository: $since_head" \
|
||||
"" \
|
||||
"Cannot verify advancement against a start point that does not exist."
|
||||
|
||||
if [[ "$head" == "$since_head" ]]; then
|
||||
fail "$EX_NULL" \
|
||||
"HEAD DID NOT ADVANCE — no commit was created" \
|
||||
"HEAD is still $head" \
|
||||
"" \
|
||||
"Your commit step failed and execution continued on stale state." \
|
||||
"Pushing now would publish something you did not just build."
|
||||
fi
|
||||
# INEQUALITY IS NOT ADVANCEMENT. "different from where I started" is
|
||||
# satisfied by checking out any unrelated commit -- including one that
|
||||
# existed long before this session -- which would let pre-existing work
|
||||
# be published as something just built. The claim being made is that new
|
||||
# commits were created ON TOP OF the recorded start point, and only
|
||||
# ancestry states that.
|
||||
if ! git merge-base --is-ancestor "$since_head" "$head"; then
|
||||
fail "$EX_NULL" \
|
||||
"HEAD IS NOT A DESCENDANT of the recorded start point" \
|
||||
"start: $since_head" \
|
||||
"head : $head" \
|
||||
"" \
|
||||
"HEAD differs from the start point but does not build on it, so" \
|
||||
"this is a checkout of other history rather than work you just" \
|
||||
"created. Being different is not the same as having advanced."
|
||||
fi
|
||||
log " ok HEAD advanced ${since_head:0:9} -> ${head:0:9} (descendant)"
|
||||
fi
|
||||
|
||||
# (2) Is that commit non-empty?
|
||||
# EXEMPTING BY PARENT COUNT WAS A FAIL-OPEN. An empty root commit sailed
|
||||
# through "emptiness check not applicable" and was then reported as a
|
||||
# CONFIRMED PUSH -- directly contradicting the non-empty requirement this
|
||||
# step exists to enforce. Root and merge commits do have well-defined
|
||||
# emptiness; the original code declined to define it, which is not the same
|
||||
# as it being undefined.
|
||||
local parents
|
||||
parents="$(git rev-list --parents -n 1 HEAD | wc -w)"
|
||||
if (( parents == 2 )); then # sha + exactly one parent
|
||||
if git diff --quiet HEAD^ HEAD; then
|
||||
fail "$EX_NULL" \
|
||||
"HEAD IS AN EMPTY COMMIT — it changes nothing against its parent" \
|
||||
"commit $head" \
|
||||
"" \
|
||||
"An empty commit carrying a real message is indistinguishable" \
|
||||
"from real work in the log. Refusing to push it."
|
||||
fi
|
||||
log " ok HEAD is a non-empty commit"
|
||||
elif (( parents > 2 )); then
|
||||
# A merge is empty when its tree matches EVERY parent: it then carries
|
||||
# no content of its own and no integration either.
|
||||
local p all_same=yes
|
||||
for p in $(git rev-list --parents -n 1 HEAD | cut -d' ' -f2-); do
|
||||
git diff --quiet "$p" HEAD || { all_same=no; break; }
|
||||
done
|
||||
if [[ "$all_same" == yes ]]; then
|
||||
fail "$EX_NULL" \
|
||||
"HEAD IS AN EMPTY MERGE — its tree is identical to every parent" \
|
||||
"commit $head" \
|
||||
"" \
|
||||
"It integrates nothing and introduces nothing. Refusing to push it."
|
||||
fi
|
||||
log " ok HEAD is a non-empty merge commit"
|
||||
else
|
||||
# A root commit has no parent, so emptiness is measured against the
|
||||
# empty tree: it is empty when it adds no paths at all.
|
||||
if [[ -z "$(git diff-tree --root -r --name-only --no-commit-id HEAD)" ]]; then
|
||||
fail "$EX_NULL" \
|
||||
"HEAD IS AN EMPTY ROOT COMMIT — it introduces no files" \
|
||||
"commit $head" \
|
||||
"" \
|
||||
"A root commit is empty when it adds nothing against the empty" \
|
||||
"tree. Refusing to push it."
|
||||
fi
|
||||
log " ok HEAD is a non-empty root commit"
|
||||
fi
|
||||
|
||||
# (3) Capture the remote BEFORE. This is the step whose absence made the
|
||||
# original "PUSH VERIFIED" a false positive.
|
||||
local before after
|
||||
before="$(remote_sha "$remote" "$branch")"
|
||||
log " .. remote before: ${before:-<branch does not exist>}"
|
||||
|
||||
git push "$remote" "HEAD:refs/heads/$branch" ${extra[@]+"${extra[@]}"}
|
||||
|
||||
after="$(remote_sha "$remote" "$branch")"
|
||||
log " .. remote after: ${after:-<absent>}"
|
||||
|
||||
# (4) The remote must now equal local HEAD...
|
||||
if [[ "$after" != "$head" ]]; then
|
||||
fail "$EX_PUSH" \
|
||||
"REMOTE DOES NOT MATCH LOCAL HEAD after push" \
|
||||
"local HEAD: $head" \
|
||||
"remote $branch: ${after:-<absent>}" \
|
||||
"" \
|
||||
"The push did not land what you are holding."
|
||||
fi
|
||||
# (5) ...AND it must have MOVED to get there. Without this, a push that
|
||||
# transferred nothing passes step (4) trivially.
|
||||
if [[ "$after" == "$before" ]]; then
|
||||
fail "$EX_PUSH" \
|
||||
"REMOTE DID NOT MOVE — nothing was actually pushed" \
|
||||
"remote was already at $after before this push ran" \
|
||||
"" \
|
||||
"'remote == local' is satisfied by having pushed nothing. That is" \
|
||||
"the false positive this guard exists to catch: the work you think" \
|
||||
"you just published was already there, or was never committed."
|
||||
fi
|
||||
|
||||
log "push-guard: PUSH CONFIRMED ${before:0:9}${before:+ }-> ${after:0:9}"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------ main
|
||||
|
||||
ALLOW_INVALID_JSON=()
|
||||
JSON_PATHS=()
|
||||
|
||||
CFG_MODE="absent"
|
||||
CFG_REASON=""
|
||||
CFG_PATHS=()
|
||||
CFG_ALLOW=()
|
||||
|
||||
main() {
|
||||
(( $# )) || { usage; exit "$EX_USAGE"; }
|
||||
local sub="$1"; shift
|
||||
case "$sub" in
|
||||
check-staged)
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--json-path) JSON_PATHS+=("${2:-}"); shift 2 ;;
|
||||
--allow-invalid-json) ALLOW_INVALID_JSON+=("${2:-}"); shift 2 ;;
|
||||
*) fail "$EX_USAGE" "unknown argument to check-staged: $1" ;;
|
||||
esac
|
||||
done
|
||||
cmd_check_staged
|
||||
;;
|
||||
push) cmd_push "$@" ;;
|
||||
-h|--help) usage ;;
|
||||
*) usage; exit "$EX_USAGE" ;;
|
||||
esac
|
||||
# Explicit: the only way to reach here is with every requested check passed.
|
||||
exit "$EX_OK"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-mutate-push-guard.sh -- needles for the mutation generator.
|
||||
#
|
||||
# The generator's entire output is a COVERAGE CLAIM, and the README publishes it.
|
||||
# That makes it the most dangerous file here: when it is wrong it does not fail,
|
||||
# it reassures. Two of its three defects were found by review rather than by any
|
||||
# test, so these are the cases that had to exist.
|
||||
#
|
||||
# g1 a RED BASELINE must be refused before any mutant runs. Previously ONE
|
||||
# pre-existing suite failure -- changing no guard behaviour at all --
|
||||
# satisfied every mutant: 13 killed, 0 survived, table emitted, exit 0.
|
||||
# g3 an INTERRUPTED run must leave the subject byte-identical. Restoration
|
||||
# used to lean on an EXIT trap, and A TRAP IS CLEANUP, NOT ISOLATION:
|
||||
# SIGKILL cannot run it, so an interrupted run stranded a mutated
|
||||
# push-guard.sh that the next suite run silently inherited.
|
||||
# g2 is the positive control. Without it every case here could be passing
|
||||
# because the generator refuses unconditionally, which is a wall, not a gate.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GEN="$HERE/mutate-push-guard.sh"
|
||||
PASS=0; FAIL=0
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
ok() { printf ' ok [%-16s] %s\n' "$1" "${2:-}"; PASS=$(( PASS + 1 )); }
|
||||
bad() { printf ' FAIL [%-16s] %s\n' "$1" "$2"; FAIL=$(( FAIL + 1 )); }
|
||||
|
||||
# fixture <name> -- a directory holding an independent copy of guard + suite
|
||||
fixture() {
|
||||
local d="$TMP/$1"; mkdir -p "$d"
|
||||
install -m 755 "$HERE/push-guard.sh" "$d/push-guard.sh"
|
||||
install -m 755 "$HERE/test-push-guard.sh" "$d/test-push-guard.sh"
|
||||
printf '%s\n' "$d"
|
||||
}
|
||||
|
||||
echo "== g1: a RED BASELINE must be refused, with NO table emitted =="
|
||||
# The injected case asserts exit 99 from `true`. It fails always, and it changes
|
||||
# NO guard behaviour -- which is the whole point: a defect anywhere in the suite
|
||||
# used to be enough to certify every branch as covered.
|
||||
g1="$(fixture red)"
|
||||
python3 - "$g1/test-push-guard.sh" <<'PY'
|
||||
import sys
|
||||
p = sys.argv[1]; s = open(p).read()
|
||||
anchor = 'mkdir -p "$WORK_DIR"\n'
|
||||
assert s.count(anchor) == 1, "injection anchor not unique -- fixture would not be the red baseline"
|
||||
s = s.replace(anchor, anchor + '\nexpect NEEDLE 99 "injected always-failing case" -- true\n', 1)
|
||||
open(p, "w").write(s)
|
||||
PY
|
||||
# Prove the fixture really IS red before asserting the generator notices, or g1
|
||||
# could pass against a green suite and test nothing.
|
||||
if "$g1/test-push-guard.sh" >/dev/null 2>&1; then
|
||||
bad g1-fixture "injected suite still passes -- fixture is not a red baseline"
|
||||
else
|
||||
ok g1-fixture "fixture suite is red, as required"
|
||||
fi
|
||||
out="$("$GEN" --dir "$g1" 2>&1)"; rc=$?
|
||||
if (( rc != 0 )) && [[ "$out" == *"REFUSING: baseline is not green"* ]]; then
|
||||
ok g1-refused "exit $rc, refused before mutating"
|
||||
else
|
||||
bad g1-refused "wanted nonzero + refusal; got rc=$rc"
|
||||
fi
|
||||
if [[ "$out" != *"README TABLE"* && "$out" != *"killed,"* ]]; then
|
||||
ok g1-no-table "no coverage table emitted from a red baseline"
|
||||
else
|
||||
bad g1-no-table "a table or kill count was published despite the red baseline"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== g3: an INTERRUPTED run must not alter the subject =="
|
||||
# THE KILL MUST LAND INSIDE A MUTATION WINDOW OR THIS CASE PROVES NOTHING.
|
||||
# First attempt used `timeout -s KILL 3`. At three seconds the generator is still
|
||||
# running its BASELINE, so no mutation has been applied yet and the subject is
|
||||
# trivially unchanged -- for the ORIGINAL in-place generator too, which I
|
||||
# confirmed by running it. The control passed for a reason unrelated to the fix:
|
||||
# a vacuous control, in the control written for blocker 3.
|
||||
#
|
||||
# So the kill is now driven from INSIDE the run. The fixture's suite counts its
|
||||
# own invocations and SIGKILLs the generator on the second one -- invocation 1 is
|
||||
# the baseline, invocation 2 happens with mutant #1 APPLIED. That is exactly the
|
||||
# window where an in-place generator strands a mutated subject.
|
||||
instrument_kill_at_second_run() {
|
||||
python3 - "$1" <<'PY'
|
||||
import sys
|
||||
p = sys.argv[1]; s = open(p).read()
|
||||
anchor = 'PASS=0\nFAIL=0\n'
|
||||
assert s.count(anchor) == 1, "instrumentation anchor not unique"
|
||||
inject = anchor + '''
|
||||
if [[ -n "${G3_COUNTER:-}" ]]; then
|
||||
n=$(( $(cat "$G3_COUNTER" 2>/dev/null || echo 0) + 1 ))
|
||||
printf '%s' "$n" > "$G3_COUNTER"
|
||||
# Invocation 2 = first mutant applied. Kill the generator where it hurts.
|
||||
# `kill -9 0` targets the whole PROCESS GROUP, not $PPID: the generator runs
|
||||
# the suite inside $( ), which forks, so $PPID is that subshell and killing
|
||||
# it merely ends the command substitution -- the generator carries on and
|
||||
# exits 0. The caller puts the generator in its OWN group via setsid, so the
|
||||
# group is exactly the generator and its children, and this harness is not
|
||||
# in it.
|
||||
(( n == 2 )) && kill -9 0
|
||||
fi
|
||||
'''
|
||||
open(p, "w").write(s.replace(anchor, inject, 1))
|
||||
PY
|
||||
}
|
||||
|
||||
# run_killed <dir> -> echoes "<rc> <before> <after>"
|
||||
run_killed() {
|
||||
local d="$1" gen="$2" before after rc
|
||||
instrument_kill_at_second_run "$d/test-push-guard.sh"
|
||||
before="$(sha256sum "$d/push-guard.sh" | cut -d' ' -f1)"
|
||||
G3_COUNTER="$d/.count" setsid --wait "$gen" --dir "$d" >/dev/null 2>&1; rc=$?
|
||||
after="$(sha256sum "$d/push-guard.sh" | cut -d' ' -f1)"
|
||||
printf '%s %s %s\n' "$rc" "$before" "$after"
|
||||
}
|
||||
|
||||
g3="$(fixture killed)"
|
||||
read -r krc before after <<<"$(run_killed "$g3" "$GEN")"
|
||||
if (( krc != 0 )); then
|
||||
ok g3-was-killed "generator died mid-mutation (exit $krc)"
|
||||
else
|
||||
bad g3-was-killed "generator exited 0 -- the kill never landed, so the check below is vacuous"
|
||||
fi
|
||||
if [[ "$before" == "$after" ]]; then
|
||||
ok g3-subject-intact "push-guard.sh byte-identical after the kill"
|
||||
else
|
||||
bad g3-subject-intact "SUBJECT MUTATED AND STRANDED: $before -> $after"
|
||||
fi
|
||||
|
||||
# PROVE THE NEEDLE BITES. Reconstruct the pre-fix mechanism -- mutate the source
|
||||
# in place, restore from an EXIT trap -- and put it through the identical kill.
|
||||
# If this does NOT strand a mutated file, g3-subject-intact is measuring nothing
|
||||
# and the isolation fix is unevidenced.
|
||||
g3o="$(fixture killed-inplace)"
|
||||
python3 - "$GEN" "$g3o/gen-inplace.sh" <<'PY'
|
||||
import sys
|
||||
s = open(sys.argv[1]).read()
|
||||
old = '''install -m 755 "$SRC_TARGET" "$WORK/push-guard.sh"
|
||||
install -m 755 "$SRC_SUITE" "$WORK/test-push-guard.sh"
|
||||
TARGET="$WORK/push-guard.sh"
|
||||
SUITE="$WORK/test-push-guard.sh"
|
||||
BAK="$WORK/push-guard.sh.orig"
|
||||
cp "$TARGET" "$BAK"'''
|
||||
new = '''TARGET="$SRC_TARGET"
|
||||
SUITE="$SRC_SUITE"
|
||||
BAK="$WORK/push-guard.sh.orig"
|
||||
cp "$TARGET" "$BAK"
|
||||
trap 'cp "$BAK" "$TARGET"; rm -rf "$WORK"' EXIT'''
|
||||
assert s.count(old) == 1, "cannot reconstruct the in-place mechanism -- bite proof would be fake"
|
||||
open(sys.argv[2], "w").write(s.replace(old, new, 1))
|
||||
PY
|
||||
chmod +x "$g3o/gen-inplace.sh"
|
||||
read -r _orc obefore oafter <<<"$(run_killed "$g3o" "$g3o/gen-inplace.sh")"
|
||||
if [[ "$obefore" != "$oafter" ]]; then
|
||||
ok g3-needle-bites "in-place generator strands a mutated subject, as it must"
|
||||
else
|
||||
bad g3-needle-bites "the OLD mechanism also left the subject intact -- g3 is vacuous"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== g2: POSITIVE CONTROL -- a clean subject must produce a full green run =="
|
||||
g2="$(fixture clean)"
|
||||
out2="$("$GEN" --dir "$g2" 2>&1)"; rc2=$?
|
||||
if (( rc2 == 0 )) && [[ "$out2" == *"0 survived"* ]]; then
|
||||
ok g2-control "$(printf '%s' "$out2" | grep -E '^[0-9]+ killed')"
|
||||
else
|
||||
bad g2-control "wanted exit 0 with 0 survived; got rc=$rc2"
|
||||
fi
|
||||
# A kill must be attributed to a NAMED case, not to a bare tally -- that is the
|
||||
# fix for blocker 2 and it needs its own assertion.
|
||||
if [[ "$out2" == *" by: "* ]]; then
|
||||
ok g2-attributed "kills name the case they broke"
|
||||
else
|
||||
bad g2-attributed "no per-mutant case attribution in the output"
|
||||
fi
|
||||
|
||||
echo
|
||||
printf '%d passed, %d failed\n' "$PASS" "$FAIL"
|
||||
(( FAIL == 0 ))
|
||||
+659
@@ -0,0 +1,659 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-push-guard.sh - Needle harness for push-guard.sh.
|
||||
#
|
||||
# Every check gets BOTH polarities:
|
||||
# NEEDLE a deliberately-broken fixture that MUST trip the guard.
|
||||
# CONTROL a clean fixture that MUST pass.
|
||||
#
|
||||
# The controls are not decoration. A guard that failed unconditionally would
|
||||
# satisfy every needle and look fully covered — which is the same class of defect
|
||||
# (an assertion satisfied by the null case) that push-guard.sh exists to prevent.
|
||||
# A needle set without controls cannot tell "working guard" from "broken guard".
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GUARD="$SCRIPT_DIR/push-guard.sh"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$SCRIPT_DIR/.work}"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
# Fresh repo + bare "remote" for each case, so no case can inherit another's state.
|
||||
new_repo() {
|
||||
local name="$1"
|
||||
local dir="$WORK_DIR/$name"
|
||||
rm -rf "$dir"
|
||||
mkdir -p "$dir"
|
||||
git init -q -b main "$dir/repo"
|
||||
git init -q --bare "$dir/remote.git"
|
||||
git -C "$dir/repo" remote add origin "$dir/remote.git"
|
||||
git -C "$dir/repo" config user.name "Needle Harness"
|
||||
git -C "$dir/repo" config user.email "[email protected]"
|
||||
printf 'seed\n' > "$dir/repo/seed.txt"
|
||||
git -C "$dir/repo" add seed.txt
|
||||
git -C "$dir/repo" commit -q -m "seed"
|
||||
printf '%s' "$dir/repo"
|
||||
}
|
||||
|
||||
# write_config <repo> <json-text>
|
||||
#
|
||||
# THIS HELPER USED TO WRITE THE CONFIG UNTRACKED, and the comment that lived here
|
||||
# justified it ("does not need to be staged"). That made every opt-out control in
|
||||
# this file assert the FORBIDDEN provenance and pass — a control that does not
|
||||
# merely test nothing, but tests the OPPOSITE of the requirement and goes green.
|
||||
# The whole design is: ON from anywhere, OFF only from a committed reviewable
|
||||
# artifact. A harness that opts out from an untracked file was proving the bypass
|
||||
# worked. It now COMMITS, so the opt-out controls exercise the supported path.
|
||||
write_config() {
|
||||
printf '%s\n' "$2" > "$1/.push-guard.json"
|
||||
git -C "$1" add .push-guard.json
|
||||
git -C "$1" commit -q -m "push-guard config"
|
||||
}
|
||||
|
||||
# write_config_untracked <repo> <json-text>
|
||||
# Deliberately NOT committed — used only where the untracked config is the thing
|
||||
# under test and the expected outcome is a REFUSAL.
|
||||
write_config_untracked() {
|
||||
printf '%s\n' "$2" > "$1/.push-guard.json"
|
||||
}
|
||||
|
||||
# expect <kind> <expected_exit> <description> [--out <substring>] -- <command...>
|
||||
#
|
||||
# --out asserts a substring of the guard's own output. It exists because exit 0
|
||||
# alone cannot distinguish "checked the files and they were fine" from "matched
|
||||
# no files and had nothing to check". Two controls in an earlier revision of this
|
||||
# harness were passing vacuously for exactly that reason — the pathspec silently
|
||||
# matched nothing. A control that cannot fail is not a control.
|
||||
expect() {
|
||||
local kind="$1" want="$2" desc="$3"; shift 3
|
||||
local need_out=""
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--out) need_out="$2"; shift 2 ;;
|
||||
--) shift; break ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
local got=0 out
|
||||
out="$("$@" 2>&1)" || got=$?
|
||||
|
||||
local why=""
|
||||
[[ "$got" == "$want" ]] || why="wanted exit $want, got $got"
|
||||
if [[ -z "$why" && -n "$need_out" && "$out" != *"$need_out"* ]]; then
|
||||
why="exit $got as expected, but output never said: $need_out"
|
||||
fi
|
||||
|
||||
if [[ -z "$why" ]]; then
|
||||
printf ' PASS [%-7s] %s (exit %s)\n' "$kind" "$desc" "$got"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
printf ' FAIL [%-7s] %s — %s\n' "$kind" "$desc" "$why"
|
||||
printf '%s\n' "$out" | sed 's/^/ | /'
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
echo "=== (a) conflict markers / unmerged index ==="
|
||||
|
||||
# NEEDLE: staged content carrying real conflict markers.
|
||||
R="$(new_repo a1)"
|
||||
cat > "$R/conflicted.txt" <<'EOF'
|
||||
intro line
|
||||
<<<<<<< HEAD
|
||||
ours
|
||||
=======
|
||||
theirs
|
||||
>>>>>>> feature/x
|
||||
tail line
|
||||
EOF
|
||||
git -C "$R" add conflicted.txt
|
||||
expect NEEDLE 2 "staged conflict markers are refused" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE: a genuine unmerged index (merge in progress).
|
||||
R="$(new_repo a2)"
|
||||
git -C "$R" checkout -q -b other
|
||||
printf 'from-other\n' > "$R/clash.txt"
|
||||
git -C "$R" add clash.txt && git -C "$R" commit -q -m other
|
||||
git -C "$R" checkout -q main
|
||||
printf 'from-main\n' > "$R/clash.txt"
|
||||
git -C "$R" add clash.txt && git -C "$R" commit -q -m main
|
||||
git -C "$R" merge other -q >/dev/null 2>&1 || true
|
||||
expect NEEDLE 2 "unmerged index paths are refused" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# CONTROL: ordinary staged content passes.
|
||||
# The config is REQUIRED as of the decision gate: this fixture has no generated
|
||||
# JSON, so it records that fact with a reason. This is the migration cost of the
|
||||
# hard cutover, paid here first — these two controls were the only pre-existing
|
||||
# cases that reached the JSON stage without nominating a path, and they are
|
||||
# exactly the "repo that never wired it up" the gate now refuses.
|
||||
R="$(new_repo a3)"
|
||||
write_config "$R" '{"json_check": "none", "reason": "fixture has no generated JSON"}'
|
||||
printf 'just some code\n' > "$R/clean.txt"
|
||||
git -C "$R" add clean.txt
|
||||
expect CONTROL 0 "clean staged content passes (1 file actually scanned)" \
|
||||
--out "no conflict markers in 1 staged file(s)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# CONTROL (false-positive guard): prose using ======= as an underline must NOT trip.
|
||||
# This is why the marker pattern deliberately ignores a bare '======='.
|
||||
R="$(new_repo a4)"
|
||||
write_config "$R" '{"json_check": "none", "reason": "fixture has no generated JSON"}'
|
||||
cat > "$R/README.rst" <<'EOF'
|
||||
Section Title
|
||||
=============
|
||||
|
||||
Body text.
|
||||
|
||||
Another Heading
|
||||
=======
|
||||
EOF
|
||||
git -C "$R" add README.rst
|
||||
expect CONTROL 0 "prose using ======= underlines does not trip the guard" \
|
||||
--out "no conflict markers in 1 staged file(s)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE (fault injection): if the scan itself ERRORS, the guard must refuse
|
||||
# rather than report a clean result. git grep exits 1 for "no match" but >=2 for
|
||||
# a real failure; a blanket '|| true' would collapse the two. We inject the
|
||||
# failure with a PATH shim rather than trying to corrupt an index.
|
||||
R="$(new_repo a5)"
|
||||
SHIM="$WORK_DIR/a5/bin"
|
||||
mkdir -p "$SHIM"
|
||||
REAL_GIT="$(command -v git)"
|
||||
cat > "$SHIM/git" <<SH
|
||||
#!/usr/bin/env bash
|
||||
if [[ "\$1" == "grep" ]]; then exit 2; fi
|
||||
exec "$REAL_GIT" "\$@"
|
||||
SH
|
||||
chmod +x "$SHIM/git"
|
||||
printf 'ordinary content\n' > "$R/thing.txt"
|
||||
git -C "$R" add thing.txt
|
||||
expect NEEDLE 2 "a conflict scan that ERRORS is refused, not reported clean" \
|
||||
--out "did not run" -- \
|
||||
bash -c "cd '$R' && export PATH=\"$SHIM:\$PATH\" && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE: RENAME + MODIFY. Git reports a `git mv` plus a small edit as a single
|
||||
# 'R' entry, which --diff-filter=ACM does not match, making the file invisible to
|
||||
# every content check. A clean file is co-staged deliberately: without it the
|
||||
# file list is empty and the nothing-staged guard fires by ACCIDENT, which would
|
||||
# make this needle pass for the wrong reason and hide the real defect.
|
||||
R="$(new_repo a6)"
|
||||
mkdir -p "$R/data"
|
||||
seq 1 200 | sed 's/^/line /' > "$R/data/canon.txt"
|
||||
printf 'original\n' > "$R/other.txt"
|
||||
git -C "$R" add -A && git -C "$R" commit -q -m seed
|
||||
git -C "$R" mv data/canon.txt data/canon2.txt
|
||||
printf '<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> b\n' >> "$R/data/canon2.txt"
|
||||
printf 'an ordinary innocent change\n' > "$R/other.txt"
|
||||
git -C "$R" add -A
|
||||
expect NEEDLE 2 "conflict markers in a RENAMED file are refused (rename+modify)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE: a path marked binary in .gitattributes. `git grep -I` honours that
|
||||
# classification and skips the blob entirely while the summary still counts the
|
||||
# file as scanned — a clean pass AND a false count. Hence -a, not -I.
|
||||
R="$(new_repo a7)"
|
||||
printf 'notes.txt binary\n' > "$R/.gitattributes"
|
||||
printf 'x\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> b\ny\n' > "$R/notes.txt"
|
||||
git -C "$R" add -A
|
||||
expect NEEDLE 2 "conflict markers in a .gitattributes-binary file are refused" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
echo "=== (c) staged JSON parses ==="
|
||||
|
||||
# NEEDLE: malformed JSON, the shape a broken generator emits.
|
||||
R="$(new_repo c1)"
|
||||
mkdir -p "$R/data"
|
||||
printf '{"a": 1,\n' > "$R/data/broken.json"
|
||||
git -C "$R" add data/broken.json
|
||||
expect NEEDLE 3 "staged unparseable JSON under --json-path is refused" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
|
||||
|
||||
# NEEDLE: conflict markers inside a generated JSON file — the 70-file incident.
|
||||
R="$(new_repo c2)"
|
||||
mkdir -p "$R/data"
|
||||
cat > "$R/data/canon.json" <<'EOF'
|
||||
{
|
||||
<<<<<<< HEAD
|
||||
"v": 1
|
||||
=======
|
||||
"v": 2
|
||||
>>>>>>> main
|
||||
}
|
||||
EOF
|
||||
git -C "$R" add data/canon.json
|
||||
expect NEEDLE 2 "conflict markers in generated JSON are refused" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# CONTROL: valid JSON passes.
|
||||
R="$(new_repo c3)"
|
||||
mkdir -p "$R/data"
|
||||
printf '{"a": 1, "b": [2, 3]}\n' > "$R/data/good.json"
|
||||
git -C "$R" add data/good.json
|
||||
expect CONTROL 0 "valid staged JSON passes (and was actually parsed)" \
|
||||
--out "1 staged .json file(s)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
|
||||
|
||||
# CONTROL: an explicit exemption works (and is reported, never silent).
|
||||
R="$(new_repo c4)"
|
||||
mkdir -p "$R/fixtures"
|
||||
printf 'not json at all' > "$R/fixtures/malformed.json"
|
||||
git -C "$R" add fixtures/malformed.json
|
||||
expect CONTROL 0 "deliberate malformed fixture can be exempted (exemption announced)" \
|
||||
--out "EXEMPTED by --allow-invalid-json" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'fixtures/*' --allow-invalid-json 'fixtures/*'"
|
||||
|
||||
# CONTROL (false-positive regression): JSONC is the common real-world case.
|
||||
# tsconfig.json legally carries comments and does NOT parse strictly. Measured on
|
||||
# a real repo: 17 of 119 tracked .json files are like this. An on-by-default
|
||||
# check would fire on all of them; scoping by --json-path is what prevents it.
|
||||
R="$(new_repo c5)"
|
||||
cat > "$R/tsconfig.json" <<'EOF'
|
||||
{
|
||||
// JSONC: comments are legal here and strict json.load() rejects them.
|
||||
"compilerOptions": { "strict": true }
|
||||
}
|
||||
EOF
|
||||
mkdir -p "$R/data"
|
||||
printf '{"generated": true}\n' > "$R/data/view.json"
|
||||
git -C "$R" add tsconfig.json data/view.json
|
||||
expect CONTROL 0 "JSONC outside --json-path does not trip the JSON check" \
|
||||
--out "1 staged .json file(s)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
|
||||
|
||||
# NEEDLE: the same tsconfig DOES fail if someone wrongly nominates it, proving
|
||||
# the check is genuinely running and the control above is not a vacuous pass.
|
||||
expect NEEDLE 3 "the same JSONC file fails when explicitly nominated" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'tsconfig.json'"
|
||||
|
||||
echo "=== (d) the JSON decision is MANDATORY (.push-guard.json) ==="
|
||||
|
||||
# NEEDLE: the fail-open this gate closes. No --json-path, no config: the previous
|
||||
# release printed "JSON check NOT REQUESTED" and exited 0, leaving the repo
|
||||
# unprotected forever with nothing ever failing.
|
||||
R="$(new_repo d1)"
|
||||
printf '{"a": 1}\n' > "$R/thing.json"
|
||||
git -C "$R" add thing.json
|
||||
expect NEEDLE 6 "no --json-path and no config is REFUSED (the closed fail-open)" \
|
||||
--out "NO JSON DECISION" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE: config nominates paths, and the check genuinely runs off it.
|
||||
R="$(new_repo d2)"
|
||||
write_config "$R" '{"json_paths": ["data/**/*.json"]}'
|
||||
mkdir -p "$R/data"
|
||||
printf '{"a": 1,\n' > "$R/data/broken.json"
|
||||
git -C "$R" add data/broken.json
|
||||
expect NEEDLE 3 "broken JSON is caught via config-supplied json_paths" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE: opt-out WITHOUT a reason. An unexplained opt-out is the absence again,
|
||||
# merely relocated into a file.
|
||||
R="$(new_repo d3)"
|
||||
write_config "$R" '{"json_check": "none"}'
|
||||
printf 'code\n' > "$R/x.txt"
|
||||
git -C "$R" add x.txt
|
||||
expect NEEDLE 6 "json_check:none without a reason is refused" \
|
||||
--out "REQUIRES a non-empty" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE: malformed config must REFUSE, never fall back to "treat as absent".
|
||||
# That fallback would mean a typo silently disables the check the file enables.
|
||||
R="$(new_repo d4)"
|
||||
write_config "$R" '{"json_paths": ["data/**/*.json",'
|
||||
printf 'code\n' > "$R/x.txt"
|
||||
git -C "$R" add x.txt
|
||||
expect NEEDLE 6 "an unparseable config is refused, not treated as absent" \
|
||||
--out "INVALID" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE: a config that parses but states NO decision. Distinct from malformed —
|
||||
# this one is valid JSON and still says nothing, which is the original defect
|
||||
# wearing a config file as a disguise.
|
||||
R="$(new_repo d5)"
|
||||
write_config "$R" '{}'
|
||||
printf 'code\n' > "$R/x.txt"
|
||||
git -C "$R" add x.txt
|
||||
expect NEEDLE 6 "a valid config that states no decision is refused" \
|
||||
--out "states no decision" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE: a bogus json_check value must not be read as an opt-out.
|
||||
R="$(new_repo d6)"
|
||||
write_config "$R" '{"json_check": "off", "reason": "typo for none"}'
|
||||
printf 'code\n' > "$R/x.txt"
|
||||
git -C "$R" add x.txt
|
||||
expect NEEDLE 6 'json_check with an unrecognised value is refused' \
|
||||
--out 'must be "none"' -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# CONTROL: config nominates paths, JSON is clean.
|
||||
# --out is REQUIRED. Exit 0 alone cannot distinguish "read the config, resolved
|
||||
# the paths, parsed the files" from "matched nothing and had nothing to do" —
|
||||
# the vacuous-control trap that already caught this harness once.
|
||||
R="$(new_repo d7)"
|
||||
write_config "$R" '{"json_paths": ["data/**/*.json"]}'
|
||||
mkdir -p "$R/data"
|
||||
printf '{"generated": true}\n' > "$R/data/view.json"
|
||||
git -C "$R" add data/view.json
|
||||
expect CONTROL 0 "config-supplied json_paths pass and are reported as parsed" \
|
||||
--out "1 staged .json file(s)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# CONTROL: a recorded opt-out passes AND the reason is echoed. Asserting on the
|
||||
# reason is the whole point — an opt-out nobody can see is what we just removed.
|
||||
R="$(new_repo d8)"
|
||||
write_config "$R" '{"json_check": "none", "reason": "no generated JSON in this repo"}'
|
||||
printf 'code\n' > "$R/x.txt"
|
||||
git -C "$R" add x.txt
|
||||
expect CONTROL 0 "a recorded opt-out passes and PRINTS its reason" \
|
||||
--out "recorded reason: no generated JSON in this repo" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# CONTROL: config-supplied exemptions still work through the config path.
|
||||
R="$(new_repo d9)"
|
||||
write_config "$R" '{"json_paths": ["fixtures/*"], "allow_invalid_json": ["fixtures/*"]}'
|
||||
mkdir -p "$R/fixtures"
|
||||
printf 'not json at all' > "$R/fixtures/malformed.json"
|
||||
git -C "$R" add fixtures/malformed.json
|
||||
expect CONTROL 0 "config-supplied allow_invalid_json exempts (and announces it)" \
|
||||
--out "EXEMPTED by --allow-invalid-json" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# CONTROL: --json-path alone still satisfies the decision, with no config at all.
|
||||
# This is what keeps every pre-existing caller working: nominating a path IS an
|
||||
# explicit decision. Only saying nothing is refused.
|
||||
R="$(new_repo d10)"
|
||||
mkdir -p "$R/data"
|
||||
printf '{"a": 1}\n' > "$R/data/good.json"
|
||||
git -C "$R" add data/good.json
|
||||
expect CONTROL 0 "--json-path alone satisfies the decision with no config present" \
|
||||
--out "1 staged .json file(s)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
|
||||
|
||||
# NEEDLE: a CLI nomination must WIN over a config opt-out, loudly. Resolving a
|
||||
# contradiction toward more checking is the only safe direction, but doing it
|
||||
# silently would hide a genuine disagreement between caller and repo.
|
||||
R="$(new_repo d11)"
|
||||
write_config "$R" '{"json_check": "none", "reason": "claims no generated JSON"}'
|
||||
mkdir -p "$R/data"
|
||||
printf '{"a": 1,\n' > "$R/data/broken.json"
|
||||
git -C "$R" add data/broken.json
|
||||
expect NEEDLE 3 "--json-path overrides a config opt-out and still catches bad JSON" \
|
||||
--out "honouring the nomination" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
|
||||
|
||||
echo "=== (z) the guard itself emits no stray output ==="
|
||||
|
||||
# CONTROL: the guard must not print interpreter warnings.
|
||||
#
|
||||
# This case exists because a real one shipped: the config parser was an inline
|
||||
# `<<'PY'` heredoc inside a $( ) command substitution, so bash printed
|
||||
# warning: command substitution: 1 unterminated here-document
|
||||
# on EVERY run. Thirty needles and a clean shellcheck all missed it — the
|
||||
# warning went to stderr, and no assertion in this harness looked at output it
|
||||
# had not already been told to expect. It was found only by running the guard
|
||||
# against a real repository.
|
||||
#
|
||||
# The lesson is narrow and worth encoding: asserting on what you EXPECT to see
|
||||
# cannot detect what you never thought to look for. This asserts on the absence
|
||||
# of a whole output class instead.
|
||||
R="$(new_repo z1)"
|
||||
write_config "$R" '{"json_paths": ["data/**/*.json"]}'
|
||||
mkdir -p "$R/data"
|
||||
printf '{"a": 1}\n' > "$R/data/view.json"
|
||||
git -C "$R" add data/view.json
|
||||
z_out="$(cd "$R" && "$GUARD" check-staged 2>&1)"
|
||||
if grep -qiE 'warning:|unterminated|command substitution' <<<"$z_out"; then
|
||||
printf ' FAIL [%-7s] %s\n' "CONTROL" "guard emits interpreter warnings"
|
||||
printf '%s\n' "$z_out" | sed 's/^/ | /'
|
||||
FAIL=$((FAIL + 1))
|
||||
else
|
||||
printf ' PASS [%-7s] %s\n' "CONTROL" "guard runs without emitting any interpreter warning"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
|
||||
# NEEDLE for the case above: prove the detector can actually fire, otherwise it
|
||||
# is one more assertion that passes because it looked at nothing.
|
||||
if grep -qiE 'warning:|unterminated|command substitution' \
|
||||
<<<"bash: warning: command substitution: 1 unterminated here-document"; then
|
||||
printf ' PASS [%-7s] %s\n' "NEEDLE" "the warning detector fires on a known warning string"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
printf ' FAIL [%-7s] %s\n' "NEEDLE" "the warning detector is dead — it cannot fire"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "=== null case: nothing staged ==="
|
||||
|
||||
# NEEDLE: the index equals HEAD. Committing here produces the empty commit.
|
||||
R="$(new_repo n1)"
|
||||
expect NEEDLE 5 "empty index is refused before a commit happens" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
echo "=== (b) push actually happened ==="
|
||||
|
||||
# CONTROL: a real push that moves the remote.
|
||||
R="$(new_repo b1)"
|
||||
printf 'work\n' > "$R/work.txt"
|
||||
git -C "$R" add work.txt && git -C "$R" commit -q -m "real work"
|
||||
expect CONTROL 0 "a push that moves the remote is confirmed" \
|
||||
--out "PUSH CONFIRMED" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main"
|
||||
|
||||
# NEEDLE: THE ORIGINAL FALSE POSITIVE. Remote already equals local HEAD, so the
|
||||
# naive 'remote == local' assertion succeeds while nothing is transferred.
|
||||
R="$(new_repo b2)"
|
||||
printf 'work\n' > "$R/work.txt"
|
||||
git -C "$R" add work.txt && git -C "$R" commit -q -m "real work"
|
||||
git -C "$R" push -q origin HEAD:refs/heads/main
|
||||
expect NEEDLE 4 "second push transferring nothing is refused (remote did not move)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main"
|
||||
|
||||
# NEEDLE: the commit step aborted, so HEAD never advanced.
|
||||
R="$(new_repo b3)"
|
||||
HEAD_BEFORE="$(git -C "$R" rev-parse HEAD)"
|
||||
expect NEEDLE 5 "push after an aborted commit is refused (HEAD did not advance)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main --since-head '$HEAD_BEFORE'"
|
||||
|
||||
# NEEDLE: an empty commit carrying a real message.
|
||||
R="$(new_repo b4)"
|
||||
git -C "$R" commit -q --allow-empty -m "feat: important-sounding message"
|
||||
expect NEEDLE 5 "pushing an empty commit is refused" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main"
|
||||
|
||||
# CONTROL: --since-head is satisfied when a real commit was made.
|
||||
R="$(new_repo b5)"
|
||||
HEAD_BEFORE="$(git -C "$R" rev-parse HEAD)"
|
||||
printf 'work\n' > "$R/work.txt"
|
||||
git -C "$R" add work.txt && git -C "$R" commit -q -m "real work"
|
||||
# --out is required here. Without it this control is VACUOUS: deleting the whole
|
||||
# --since-head validation block would still yield exit 0, because the empty-commit
|
||||
# and remote-moved checks independently succeed. It would prove nothing about the
|
||||
# code path it names.
|
||||
expect CONTROL 0 "--since-head passes when a real commit was created" \
|
||||
--out "HEAD advanced" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main --since-head '$HEAD_BEFORE'"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Needles for the five blockers rev-974 found from a clean checkout. Every one
|
||||
# of these was reproduced before it was fixed; none is a hypothesis.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo
|
||||
echo "-- blocker 2: the enumerating producer's exit status --"
|
||||
# FAULT INJECTION. mapfile < <(git diff) reported MAPFILE's status, so a git diff
|
||||
# that died returned zero files and the guard published that silence as clean.
|
||||
R="$(new_repo e1)"
|
||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
||||
git -C "$R" add -f data/bad.json
|
||||
expect NEEDLE 6 "a failed file enumeration REFUSES instead of reporting clean" \
|
||||
--out "CANNOT ENUMERATE STAGED FILES" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path ':(this-magic-does-not-exist)data/*.json'"
|
||||
# CONTROL: the same malformed file with a WORKING pathspec must still be caught,
|
||||
# so the needle above is not passing merely because everything now refuses.
|
||||
expect CONTROL 3 "a working pathspec still catches the malformed JSON" \
|
||||
--out "data/bad.json" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/*.json'"
|
||||
|
||||
echo
|
||||
echo "-- blocker 3: OFF must come from a committed, reviewable object --"
|
||||
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 "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.
|
||||
R="$(new_repo e3)"
|
||||
write_config "$R" '{"json_paths": ["data/**/*.json"]}'
|
||||
printf '%s\n' '{"json_check": "none", "reason": "local convenience"}' > "$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 "a committed ON flipped OFF in the working tree is refused" \
|
||||
--out "LOCAL-ONLY OPT-OUT" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# A committed SYMLINK is a mutable target: the reviewed blob is a path, and what
|
||||
# it points at can change with no diff at all.
|
||||
R="$(new_repo e4)"
|
||||
printf '%s\n' '{"json_check": "none", "reason": "via symlink"}' > "$R/real-config.json"
|
||||
ln -s real-config.json "$R/.push-guard.json"
|
||||
git -C "$R" add real-config.json .push-guard.json
|
||||
git -C "$R" commit -q -m "symlinked config"
|
||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
||||
git -C "$R" add -f data/bad.json
|
||||
expect NEEDLE 6 "a committed SYMLINK config is refused as a mutable target" \
|
||||
--out "committed SYMLINK" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# CONTROL: the supported path still works. Without this the whole opt-out feature
|
||||
# could be dead and every needle above would still pass — a gate that can never
|
||||
# say yes is not a gate.
|
||||
R="$(new_repo e5)"
|
||||
write_config "$R" '{"json_check": "none", "reason": "committed and reviewable"}'
|
||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
||||
git -C "$R" add -f data/bad.json
|
||||
expect CONTROL 0 "a COMMITTED opt-out is honoured and prints its committed reason" \
|
||||
--out "recorded reason: committed and reviewable" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
echo
|
||||
echo "-- blocker 4: advancement is ancestry, not inequality --"
|
||||
R="$(new_repo e6)"
|
||||
git -C "$R" checkout -qb other
|
||||
printf 'o\n' > "$R/o.txt"; git -C "$R" add o.txt; git -C "$R" commit -q -m o
|
||||
OTHER="$(git -C "$R" rev-parse HEAD)"
|
||||
git -C "$R" checkout -q main 2>/dev/null || git -C "$R" checkout -q master
|
||||
printf 'm\n' > "$R/m.txt"; git -C "$R" add m.txt; git -C "$R" commit -q -m m
|
||||
MAINH="$(git -C "$R" rev-parse HEAD)"
|
||||
git -C "$R" checkout -q "$OTHER"
|
||||
expect NEEDLE 5 "a checkout of pre-existing diverged history is not 'advancement'" \
|
||||
--out "NOT A DESCENDANT" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch other --since-head '$MAINH'"
|
||||
|
||||
echo
|
||||
echo "-- blocker 5: root and merge commits have defined emptiness --"
|
||||
R="$(new_repo e7)"
|
||||
git -C "$R" checkout -q --orphan fresh
|
||||
git -C "$R" rm -q -rf . >/dev/null 2>&1 || true
|
||||
git -C "$R" commit -q --allow-empty -m "empty root"
|
||||
expect NEEDLE 5 "an EMPTY ROOT commit is refused, not exempted" \
|
||||
--out "EMPTY ROOT COMMIT" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch fresh"
|
||||
|
||||
# CONTROL: a real root commit must still push, or the fix is just a new wall.
|
||||
R="$(new_repo e8)"
|
||||
git -C "$R" checkout -q --orphan fresh2
|
||||
git -C "$R" rm -q -rf . >/dev/null 2>&1 || true
|
||||
printf 'real\n' > "$R/real.txt"; git -C "$R" add real.txt
|
||||
git -C "$R" commit -q -m "real root"
|
||||
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,144 @@
|
||||
#!/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
|
||||
test-mutate-push-guard.sh
|
||||
test-verify-clean-clone.sh)
|
||||
|
||||
# THE REAL DEPLOYED LAYOUT. Every fixture in the first version of this file
|
||||
# installed the artifacts at the fixture ROOT, so 6/6 green proved flat-layout
|
||||
# operation and said NOTHING about the path these files actually ship at. The
|
||||
# verifier was unusable in place and the suite could not see it, because THE
|
||||
# FIXTURE ENCODED A LAYOUT THAT DOES NOT EXIST. A fixture is a claim about the
|
||||
# world; an untested fixture is an unreviewed one.
|
||||
readonly REAL_PREFIX="packages/mosaic/framework/tools/git"
|
||||
|
||||
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 <repo-root> [subdir]
|
||||
# With a subdir the artifacts are committed at repo/<subdir>/, which is how they
|
||||
# really ship. Echoes the directory the artifacts landed in -- that is what gets
|
||||
# passed as --repo, so the caller never has to reconstruct the path.
|
||||
mkrepo() {
|
||||
local d="$1" sub="${2:-}"
|
||||
local dest="$d${sub:+/$sub}"
|
||||
mkdir -p "$dest"
|
||||
local f
|
||||
for f in "${ARTIFACTS[@]}"; do
|
||||
install -m 755 "$HERE/$f" "$dest/$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"
|
||||
printf '%s\n' "$dest"
|
||||
}
|
||||
|
||||
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" >/dev/null
|
||||
run_case w1-honest 0 "the COMMITTED artifact ran and passed" "$TMP/good"
|
||||
|
||||
echo
|
||||
echo "== THE DEPLOYED LAYOUT: artifacts NESTED, not at the repository root =="
|
||||
# The blocker: ls-tree was given a bare basename, which is a ROOT-relative
|
||||
# pathspec, so in the real tree every artifact came back NOT TRACKED and the
|
||||
# verifier refused to run at all. This control fails against that version and
|
||||
# passes only when the committed PREFIX is threaded through ls-tree, the cloned
|
||||
# stat, and the suite's working directory.
|
||||
w6dir="$(mkrepo "$TMP/nested" "$REAL_PREFIX")"
|
||||
run_case w6-nested 0 "the COMMITTED artifact ran and passed" "$w6dir"
|
||||
# ...and prove the run actually happened DOWN THERE rather than at the root, or
|
||||
# a passing w6 would only mean the prefix was ignored harmlessly.
|
||||
#
|
||||
# Captured into a variable rather than piped into `grep -q` ON PURPOSE. grep -q
|
||||
# exits at the FIRST match, the verifier then dies of SIGPIPE, and under
|
||||
# `pipefail` the pipeline reports that 141 -- so A SUCCESSFUL MATCH READS AS A
|
||||
# FAILED ASSERTION. This cost me a red w6-prefix against a verifier that was
|
||||
# printing the right prefix all along. Same family as the pipefail/process-
|
||||
# substitution note already on record: the exit status being consulted is not
|
||||
# the status of the thing being asserted.
|
||||
w6out="$("$VERIFY" --repo "$w6dir" 2>&1)"
|
||||
if [[ "$w6out" == *"prefix $REAL_PREFIX/"* ]]; then
|
||||
printf ' ok [%-14s] verifier reported prefix %s/\n' "w6-prefix" "$REAL_PREFIX"; PASS=$(( PASS + 1 ))
|
||||
else
|
||||
printf ' FAIL [%-14s] verifier did not report the nested prefix -- it may have\n' "w6-prefix"
|
||||
printf ' passed by looking at the root, which is the blocker unfixed\n'; FAIL=$(( FAIL + 1 ))
|
||||
fi
|
||||
# And the mode needle must ALSO bite in the nested layout: a prefix threaded
|
||||
# into the clone but not into ls-tree would silently stop checking modes.
|
||||
w7dir="$(mkrepo "$TMP/nested-laundered" "$REAL_PREFIX")"
|
||||
git -C "$TMP/nested-laundered" update-index --chmod=-x "$REAL_PREFIX/push-guard.sh"
|
||||
git -C "$TMP/nested-laundered" commit -q -m "drop exec bit in git only"
|
||||
run_case w7-nested-mode 1 "committed 100644, needs 100755" "$w7dir"
|
||||
|
||||
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" >/dev/null
|
||||
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" >/dev/null
|
||||
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" >/dev/null
|
||||
( 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 ))
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
# verify-clean-clone.sh -- prove the COMMITTED artifact runs, from a clean clone.
|
||||
#
|
||||
# ================== 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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
test-mutate-push-guard.sh
|
||||
test-verify-clean-clone.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
|
||||
|
||||
# --- THE ARTIFACT'S COMMITTED PATH, NOT ITS BASENAME -------------------------
|
||||
# v2 asserted `git ls-tree HEAD -- push-guard.sh`, which is a ROOT-RELATIVE
|
||||
# pathspec. These files really live several directories down. Run in place and
|
||||
# every artifact came back "NOT TRACKED" -- the verifier built to stop packaging
|
||||
# false-greens could not verify the tree that ships it.
|
||||
#
|
||||
# The tests missed it because EVERY FIXTURE INSTALLED THE ARTIFACTS AT THE
|
||||
# FIXTURE ROOT. 6/6 green proved flat-layout operation and nothing about the
|
||||
# deployed path. THAT IS THE THIRD TIME ON THIS TOOL THAT A CONTROL VALIDATED A
|
||||
# MODEL INSTEAD OF THE SUBJECT: v1 measured a cp'd scratch repo, and then v2's
|
||||
# own tests measured a layout that does not exist. A fixture is a claim about
|
||||
# the world, and an untested fixture is an unreviewed one.
|
||||
#
|
||||
# --show-prefix answers "where is this directory inside its repository", so the
|
||||
# same prefix drives ls-tree, the cloned stat, and the suite's working dir.
|
||||
PREFIX="$(git -C "$REPO" rev-parse --show-prefix)"
|
||||
|
||||
printf '=== subject ===\n'
|
||||
printf ' repo %s\n rev %s (%s)\n prefix %s\n\n' \
|
||||
"$ROOT" "$REV" "$REV_SHA" "${PREFIX:-<repository root>}"
|
||||
|
||||
# --- 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" -- "${PREFIX}${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
|
||||
|
||||
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 disk mode: %s %s\n' "$(stat -c '%a' "$WORK/clone/${PREFIX}${f}")" "${PREFIX}${f}"
|
||||
done
|
||||
|
||||
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/${PREFIX}"
|
||||
if ! "./$SUITE"; then
|
||||
printf '\nCLEAN-CLONE RUN FAILED.\n'
|
||||
exit "$EX_FAIL"
|
||||
fi
|
||||
|
||||
printf '\n=== CLEAN CLONE: the COMMITTED artifact ran and passed ===\n'
|
||||
@@ -414,6 +414,22 @@ cmd_poll_once() {
|
||||
_wake_clean_stale_tmp "$STATE_DIR"
|
||||
mkdir -p "$DET_DIR"
|
||||
|
||||
local failed=0
|
||||
|
||||
# #958 preimage provenance: check the OPERATOR-SIDE preimage definition (the
|
||||
# source adapter file, the watch-list, operator-declared extras) BEFORE
|
||||
# observing any source. A changed preimage re-baselines EVERY source at once;
|
||||
# running the check first means its first-class cause line is enqueued at a
|
||||
# LOWER observed_seq than the N per-source deltas it explains, so the digest
|
||||
# shows the cause, not just the flood. An infrastructure failure of the check
|
||||
# is LOUD and marks this pass failed (G2a discipline — never read as "no
|
||||
# change"), but source observation still proceeds: provenance must not be
|
||||
# able to starve wake delivery.
|
||||
if ! "$SCRIPT_DIR/preimage.sh" check --enqueue; then
|
||||
echo "detector.sh: FAIL LOUD — preimage provenance check failed (see preimage.sh above); source observation continues but this pass exits non-zero." >&2
|
||||
failed=1
|
||||
fi
|
||||
|
||||
# Iterate the DECLARED source-coverage inventory (§4/G3): only sources listed
|
||||
# in watches[].sources[] are polled. An omitted source is not observed (and so
|
||||
# cannot make anything pass vacuously); a referenced-but-undefined source is a
|
||||
@@ -427,7 +443,7 @@ cmd_poll_once() {
|
||||
exit 2
|
||||
fi
|
||||
|
||||
local failed=0 kind id def class
|
||||
local kind id def class
|
||||
while IFS=$'\t' read -r kind id; do
|
||||
[ -n "$kind" ] || continue
|
||||
# Resolve the source definition from its top-level collection by id.
|
||||
|
||||
@@ -488,6 +488,19 @@ _dlq_alarm_offhost() {
|
||||
# A dead-letter write failure is itself alarmed (both legs) but never aborts the
|
||||
# drain — the good entries MUST still deliver (availability is the whole point of
|
||||
# #920).
|
||||
#
|
||||
# RETENTION IS LOAD-BEARING (#953) — read BEFORE adding rotation/pruning/caps:
|
||||
# this ledger is append-only history AND the SOLE evidence base for
|
||||
# store.sh quarantine-audit's conviction predicate (#946): a false
|
||||
# consumed-hashes witness row is provable ONLY while its matching entry
|
||||
# survives HERE. Any rotation, pruning, or size cap — however locally correct
|
||||
# — silently converts provable rows into unprovable ones, and the audit's
|
||||
# clean sweep reads IDENTICALLY before and after the evidence disappears (no
|
||||
# signal at either end; the audit never guesses, by design). If retention
|
||||
# limits ever become genuinely necessary, they MUST ship with (a) a loud
|
||||
# signal at prune time naming what evidence is being given up, and (b) the
|
||||
# audit's residual-class reporting updated IN THE SAME CHANGE. Until then:
|
||||
# nothing prunes this file, and that is a recorded decision, not an omission.
|
||||
_quarantine_entry() {
|
||||
local line="$1" seq="$2" dlq="$STATE_DIR/dead-letter.jsonl"
|
||||
if [ ! -f "$dlq" ] || ! grep -qxF "$line" "$dlq" 2>/dev/null; then
|
||||
|
||||
@@ -347,23 +347,6 @@
|
||||
# "one targeted call, never a search" (NOT "pins the observed
|
||||
# state") — sequenced AFTER the reseed so the edit itself is a
|
||||
# live delivery test of the fixed gate.
|
||||
# 0.6.16 #952 quarantine-audit clean-sweep message named only ONE of the
|
||||
# two unprovable residual classes ("rows without surviving
|
||||
# dead-letter evidence"), so an operator reading the OK concluded
|
||||
# NO evidence exists when evidence can exist and be UNUSABLE: a
|
||||
# surviving dead-letter row whose locator extracts an empty
|
||||
# observed_hash (live specimen: mos-dt seq 13,
|
||||
# bench/malformed-locator-test, "deliberately non-conformant")
|
||||
# can never satisfy the four-field conviction match, because
|
||||
# _record_last_consumed only writes rows with a NON-empty hash.
|
||||
# WORDING-ONLY fix (measurement defect, #951 review finding 1):
|
||||
# the clean-sweep message + the PROVABILITY BOUND comment now
|
||||
# name both classes; the conviction predicate is UNCHANGED.
|
||||
# Test T17 drives the real writer flow and plants the verbatim
|
||||
# live specimen (nested .locators.*, NO observed_hash key) —
|
||||
# never a hand-built flat dead-letter row, which would make the
|
||||
# audit's correct non-conviction look exactly like the defect
|
||||
# under hunt (the #951 review's false-defect near-miss).
|
||||
# 0.6.15 #946 the digest's embedded ack watermark covered quarantined
|
||||
# entries: quarantine is a render-time filter (0.6.14), so the
|
||||
# suggested `ack.sh consumed --upto <observed_seq>` stepped the
|
||||
@@ -391,8 +374,99 @@
|
||||
# Changed: store.sh, digest.sh, ack.sh
|
||||
# (+ test-wake-store-ack.sh T13-T16,
|
||||
# test-wake-digest-quarantine.sh Q12-Q16).
|
||||
# 0.7.0 #958 A11 preimage.sh — durable provenance for the OPERATOR-SIDE
|
||||
# preimage definition (wake-pilot finding: source-adapter.sh was
|
||||
# unversioned, so the operator-owned bytes every observed_hash is
|
||||
# computed FROM had thinner provenance than any hash they feed;
|
||||
# attribution required an agent transcript). NEW tool + two
|
||||
# pre-step integrations, ADDITIVE: (1) preimage.sh derives the
|
||||
# preimage set from the RUNTIME env (the resolved
|
||||
# WAKE_DETECTOR_SOURCE_CMD file, WAKE_WATCH_LIST, optional
|
||||
# WAKE_PREIMAGE_EXTRA paths) and, per file, records
|
||||
# {ts,path,sha256,size,mtime,prev} to an append-only ledger +
|
||||
# captures the bytes CONTENT-ADDRESSED under
|
||||
# $STATE_DIR/preimage/objects/<sha256> — prior bytes + change
|
||||
# time are answerable from durable state alone, no transcript
|
||||
# (acceptance a). A git-repo-in-operator-dir design was REJECTED:
|
||||
# its who/why claim is false under shared-author fleets, and
|
||||
# .gitignore is pattern-based where acceptance (b) demands
|
||||
# fail-closed. (2) CREDENTIAL HARD GATE (acceptance b), FOUR
|
||||
# LAYERS (#964 re-verdict: B2 cases C+D): (i) POLARITY — extras
|
||||
# (WAKE_PREIMAGE_EXTRA) are RECORD-ONLY by default (ledger row +
|
||||
# cause line, NO bytes); byte capture for an extra requires the
|
||||
# path listed in WAKE_PREIMAGE_CAPTURE (colon-separated opt-in);
|
||||
# only the core set (adapter, watch-list) is capture-eligible by
|
||||
# default — a shape list can only refuse the secrets someone
|
||||
# already enumerated, so safety may not rest on one (#964-D).
|
||||
# (ii) PATH DENY evaluated on BOTH the raw and realpath-resolved
|
||||
# candidate forms against BOTH unresolved and resolved
|
||||
# mosaic-home anchors (credentials.json,
|
||||
# tools/_lib/credentials.json, credentials/**, any basename
|
||||
# credentials.json) — a deny list written in unresolved paths
|
||||
# cannot match a path resolved before it arrived (#964-C), and a
|
||||
# symlinked opt-in entry cannot smuggle a denied target (deny
|
||||
# runs FIRST, independent of what the opt-in matched). (iii)
|
||||
# CONTENT DENY on the opt-in path only, defense-in-depth NOT the
|
||||
# safety mechanism: digest.sh's six scrub shapes + a
|
||||
# named-assignment probe (key/token/secret/passw/hmac/credential/
|
||||
# bearer = unbroken value >=16 chars); probe ERROR fails TOWARD
|
||||
# refusal (an error exit is not a negative result); false
|
||||
# positives are acceptable — a false match only withholds byte
|
||||
# capture, never tracking. (iv) size cap WAKE_PREIMAGE_MAX_BYTES
|
||||
# (default 1 MiB). Deny ALWAYS wins over the opt-in. A refused
|
||||
# file still gets its hash/size/mtime row (captured:false +
|
||||
# refused reason) so change TIME survives even when content must
|
||||
# not. (3) FIRST-CLASS CAUSE LINE
|
||||
# (acceptance c): on a change (or deletion — ABSENT is a state,
|
||||
# not an error), one class=actionable entry is enqueued via the
|
||||
# store allocator with locators {kind:preimage, path (§2.1 hard
|
||||
# locator — never quarantined), observed_hash, prev_hash,
|
||||
# preimage:true, reason:preimage-definition-changed}. Both
|
||||
# detector.sh cmd_poll_once and reconcile.sh cmd_reconcile run
|
||||
# the check as a PRE-step, so the cause line lands at a LOWER
|
||||
# observed_seq than the N per-source deltas/enumerations it
|
||||
# explains — "preimage definition changed" reads first, not N
|
||||
# UNACCOUNTED lines. (4) FAIL-LOUD discipline (D2/#955 class):
|
||||
# an unresolvable adapter, unreadable watch-list, corrupt ledger
|
||||
# (REFUSES to compare or re-baseline over corrupt history), failed
|
||||
# object/ledger write, or failed enqueue is a loud non-zero —
|
||||
# never read as "no change"; in both integrations the pass exits
|
||||
# non-zero but source observation still proceeds (no starvation).
|
||||
# An ABSENT/EMPTY ledger with objects/ NON-empty is a loud
|
||||
# non-zero REFUSAL to re-baseline (#964-B11: objects with no
|
||||
# ledger cannot be a first install — history was deleted; the
|
||||
# first-install path must not silently absorb it). First-seen on
|
||||
# a genuinely clean state dir is a SILENT baseline (detector
|
||||
# first-poll idiom).
|
||||
# The installer is UNCHANGED — Gate A auto-enumerates the new
|
||||
# file from the filesystem; the recording site is the runtime
|
||||
# tick, which is where the env-derived preimage set exists.
|
||||
# store.sh/digest.sh/beacon.sh UNCHANGED; watch-list schema
|
||||
# UNTOUCHED ([1,1]). Changed: preimage.sh (new), detector.sh,
|
||||
# reconcile.sh (+ test-wake-preimage.sh P1-P17; P13-P17 are the
|
||||
# #964 re-verdict regression needles: symlinked store, live
|
||||
# secret value, polarity, ledger deletion, allowlist-symlink).
|
||||
# 0.7.1 #952 quarantine-audit clean-sweep message named only ONE of the
|
||||
# two unprovable residual classes ("rows without surviving
|
||||
# dead-letter evidence"), so an operator reading the OK concluded
|
||||
# NO evidence exists when evidence can exist and be UNUSABLE: a
|
||||
# surviving dead-letter row whose locator extracts an empty
|
||||
# observed_hash (live specimen: mos-dt seq 13,
|
||||
# bench/malformed-locator-test, "deliberately non-conformant")
|
||||
# can never satisfy the four-field conviction match, because
|
||||
# _record_last_consumed only writes rows with a NON-empty hash.
|
||||
# WORDING-ONLY fix (measurement defect, #951 review finding 1):
|
||||
# THREE surfaces — the clean-sweep message, the PROVABILITY
|
||||
# BOUND comment, and (second commit, author-side self-catch)
|
||||
# the usage() help text — now name both classes; the
|
||||
# conviction predicate is UNCHANGED.
|
||||
# Test T17 drives the real writer flow and plants the verbatim
|
||||
# live specimen (nested .locators.*, NO observed_hash key) —
|
||||
# never a hand-built flat dead-letter row, which would make the
|
||||
# audit's correct non-conviction look exactly like the defect
|
||||
# under hunt (the #951 review's false-defect near-miss).
|
||||
component=wake
|
||||
version=0.6.16
|
||||
version=0.7.1
|
||||
|
||||
# Watch-list schema this component consumes, and the INCLUSIVE range of
|
||||
# schema_version values it supports. A wake-watch-list.json whose schema_version
|
||||
@@ -463,6 +537,19 @@ schema_max=1
|
||||
# seed) instead of a bare source error, and LINKS the unit into
|
||||
# the user systemd search path + post-install-validates it
|
||||
# resolves (#913). (W7, #913)
|
||||
# preimage.sh A11 — operator-side PREIMAGE-DEFINITION provenance: derives the
|
||||
# preimage set from the runtime env (resolved adapter file,
|
||||
# watch-list, WAKE_PREIMAGE_EXTRA), appends
|
||||
# {ts,path,sha256,size,mtime,prev} rows to an append-only
|
||||
# ledger, captures bytes content-addressed under
|
||||
# preimage/objects/<sha256>, and enqueues a FIRST-CLASS
|
||||
# "preimage-definition-changed" actionable (path = §2.1 hard
|
||||
# locator) BEFORE the deltas it explains (detector +
|
||||
# reconcile pre-step). Credential HARD GATE: capture is
|
||||
# REFUSED (hash/mtime still recorded) for credential-store
|
||||
# paths, secret-shaped content, and oversized files —
|
||||
# refusal, never redaction. Fail-loud on any infra failure;
|
||||
# a corrupt ledger refuses re-baseline. (#958)
|
||||
# Companion (framework subtree, not under tools/wake/): systemd/user/mosaic-wake.service
|
||||
# — the long-lived detector daemon unit (per-class SLO lives in
|
||||
# the daemon, NOT a systemd interval). (W7)
|
||||
|
||||
+571
@@ -0,0 +1,571 @@
|
||||
#!/usr/bin/env bash
|
||||
# preimage.sh — A11 of the wake canon (#958): durable PROVENANCE for the
|
||||
# OPERATOR-SIDE preimage definition.
|
||||
#
|
||||
# THE GAP THIS CLOSES (#958): every observed_hash in the lane store is
|
||||
# sha256(adapter stdout) — the operator's source adapter (plus the watch-list
|
||||
# and any operator-declared preimage files) IS the preimage definition for
|
||||
# every hash the pipeline ever records. Those files live OUTSIDE the versioned
|
||||
# wake component, so a byte change to them was attributable only through agent
|
||||
# transcripts: provenance thinner than any hash it feeds. A changed preimage
|
||||
# also re-baselines EVERY source at once, which previously surfaced only as N
|
||||
# per-source deltas / UNACCOUNTED lines with no first-class cause.
|
||||
#
|
||||
# WHAT THIS TOOL DOES:
|
||||
# - Derives the PREIMAGE SET generically (no operator paths baked in):
|
||||
# * the resolved file behind WAKE_DETECTOR_SOURCE_CMD (first word),
|
||||
# * the WAKE_WATCH_LIST file,
|
||||
# * operator-declared extras via WAKE_PREIMAGE_EXTRA (colon-separated —
|
||||
# e.g. sink/feed scripts). Extras are RECORD-ONLY by default (see the
|
||||
# credential hard gate below); never list key-material files.
|
||||
# - Records each file's (sha256, size, mtime, ts) as an APPEND-ONLY ledger
|
||||
# row in <state>/preimage/preimage-ledger.jsonl and stores the full bytes
|
||||
# CONTENT-ADDRESSED at <state>/preimage/objects/<sha256> — so a change is
|
||||
# attributable (prior bytes + change time) from durable state alone, with
|
||||
# no transcript required. Acceptance (a) of #958.
|
||||
# - On a CHANGE (not first-seen), `check --enqueue` enqueues ONE first-class
|
||||
# class=actionable entry per changed file via the store's single allocator
|
||||
# (#908), carrying a §2.1 hard locator (`path`), BEFORE the per-source
|
||||
# deltas it explains are observed — so the digest shows the cause line,
|
||||
# not just N re-baselined sources. Acceptance (c).
|
||||
#
|
||||
# CREDENTIAL HARD GATE — acceptance (b): the mechanism must be UNABLE to
|
||||
# capture credential material even by operator error. Layered, every layer
|
||||
# fail-closed toward NOT capturing bytes (the hash/size/mtime row is still
|
||||
# written — change-time attribution survives, content capture does not;
|
||||
# a sha256 discloses nothing about the bytes):
|
||||
# 1. POLARITY (#964 review, case D): byte capture is DENY-BY-DEFAULT for
|
||||
# operator extras. WAKE_PREIMAGE_EXTRA paths are RECORD-ONLY (rows +
|
||||
# first-class change entries, no bytes) unless explicitly listed in
|
||||
# WAKE_PREIMAGE_CAPTURE — a shape list can only refuse the secrets
|
||||
# someone already enumerated, so arbitrary operator-pointed files must
|
||||
# not default to capture. The CORE set (the resolved adapter file, the
|
||||
# watch-list) is capture-eligible: it IS the preimage definition this
|
||||
# tool exists to snapshot, and the deny layers below still apply to it.
|
||||
# 2. PATH deny: the mosaic credential store locations
|
||||
# (<mosaic-home>/credentials.json, <mosaic-home>/tools/_lib/
|
||||
# credentials.json, anything under <mosaic-home>/credentials/, and any
|
||||
# file literally named credentials.json) are refused. Evaluated on BOTH
|
||||
# the operator-supplied form AND the symlink-resolved form, against BOTH
|
||||
# the unresolved and resolved forms of the mosaic-home anchor — a deny
|
||||
# list written only in unresolved paths cannot match a path that was
|
||||
# resolved before it arrived (#964 review, case C).
|
||||
# 3. CONTENT deny: a candidate whose bytes match the well-known secret-token
|
||||
# shapes (same conservative set digest.sh redacts: PAT/Slack/AKIA/JWT/PEM)
|
||||
# OR a named-assignment secret shape (key/token/secret/passw/hmac/
|
||||
# credential/bearer = long unbroken value — catches prefix-less
|
||||
# high-entropy keys, e.g. an HMAC key in an env file) is refused —
|
||||
# refusal, not redaction: a redacted preimage would be a false witness,
|
||||
# and secret bytes must never land in the object store. Deliberately
|
||||
# conservative toward REFUSAL: a false match only withholds byte capture,
|
||||
# never tracking.
|
||||
# Plus a size cap (WAKE_PREIMAGE_MAX_BYTES, default 1 MiB) so a stray extra
|
||||
# pointing at a large binary cannot turn provenance into data hoovering.
|
||||
# Deny ALWAYS wins over the WAKE_PREIMAGE_CAPTURE opt-in.
|
||||
#
|
||||
# FAIL-LOUD DISCIPLINE (the D2/#955 class, both layers): an infrastructure
|
||||
# failure of THIS tool (unresolvable adapter, unreadable/corrupt ledger,
|
||||
# failed durable write, failed enqueue) exits NON-ZERO and is never read as
|
||||
# "no change". A corrupt ledger REFUSES to compare (and to re-baseline):
|
||||
# silently restarting history would erase the very attribution this exists
|
||||
# to provide. Likewise (#964 review, B11) an ABSENT/EMPTY ledger while
|
||||
# objects/ still holds prior captures is DELETED HISTORY, not a first
|
||||
# install — it refuses loudly instead of re-baselining, because a silent
|
||||
# restart would absorb the next real change as first-seen.
|
||||
#
|
||||
# Operator-agnostic (framework firewall): all state via XDG/env; the deny
|
||||
# list names only framework-defined credential locations, no operator hosts/
|
||||
# names/secrets. This tool never prints file CONTENT to any stream.
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=./_wake-common.sh disable=SC1091
|
||||
. "$SCRIPT_DIR/_wake-common.sh"
|
||||
|
||||
STATE_DIR="$(wake_state_dir)"
|
||||
STORE_SH="$SCRIPT_DIR/store.sh"
|
||||
PRE_DIR="$STATE_DIR/preimage"
|
||||
LEDGER="$PRE_DIR/preimage-ledger.jsonl"
|
||||
OBJECTS="$PRE_DIR/objects"
|
||||
|
||||
_need_jq() {
|
||||
command -v jq >/dev/null 2>&1 || {
|
||||
echo "preimage.sh: jq is required" >&2
|
||||
exit 3
|
||||
}
|
||||
}
|
||||
|
||||
# _hash_stdin — sha256 of stdin, first field only (portable, same fallback
|
||||
# chain as detector.sh).
|
||||
_hash_stdin() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 | awk '{print $1}'
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
openssl dgst -sha256 | awk '{print $NF}'
|
||||
else
|
||||
echo "preimage.sh: no sha256 tool (sha256sum/shasum/openssl) available" >&2
|
||||
exit 3
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: preimage.sh <command>
|
||||
|
||||
Commands:
|
||||
check [--enqueue] Compare every preimage-set file against the ledger head.
|
||||
A changed file gets a new ledger row (+ captured bytes
|
||||
when capture-eligible and not denied — see Environment);
|
||||
with --enqueue each change (not first-seen) also enqueues
|
||||
ONE first-class class=actionable store entry (hard
|
||||
locator: path). First-seen files baseline SILENTLY (row,
|
||||
no enqueue — same idiom as the detector's first-seen).
|
||||
Exit 0 whether or not changes were found; non-zero ONLY
|
||||
on infrastructure failure (never read as "no change").
|
||||
record Alias of `check` without enqueue (baseline/refresh).
|
||||
set Print the derived preimage set, one resolved path per
|
||||
line (diagnostic).
|
||||
status Print the ledger head row for every tracked path.
|
||||
|
||||
Environment:
|
||||
WAKE_DETECTOR_SOURCE_CMD Adapter command; its resolved file joins the set.
|
||||
WAKE_WATCH_LIST Watch-list path; joins the set when set.
|
||||
WAKE_PREIMAGE_EXTRA Colon-separated additional operator preimage files
|
||||
(e.g. sink/feed scripts). Extras are RECORD-ONLY
|
||||
by default: changes get a hash/size/mtime row and
|
||||
a first-class change entry, but their BYTES are
|
||||
never captured unless the path is also listed in
|
||||
WAKE_PREIMAGE_CAPTURE. A listed path that does not
|
||||
exist is tracked as ABSENT (deletion of a preimage
|
||||
file is a change, not an error).
|
||||
WAKE_PREIMAGE_CAPTURE Colon-separated allowlist of extras whose bytes
|
||||
MAY be captured. The credential deny rules ALWAYS
|
||||
win over this list. NEVER list files that can hold
|
||||
key material (env files with keys/HMACs, credential
|
||||
stores): the deny gate is a backstop, not a
|
||||
license.
|
||||
WAKE_PREIMAGE_MAX_BYTES Byte-capture cap (default 1048576). Larger files:
|
||||
hash/size/mtime recorded, bytes refused.
|
||||
WAKE_STATE_HOME/WAKE_AGENT store namespace (see store.sh).
|
||||
EOF
|
||||
}
|
||||
|
||||
# --- preimage-set derivation (generic; no operator paths baked in) ----------
|
||||
|
||||
# _realpath_or_self PATH — resolved form when resolvable, the input otherwise.
|
||||
_realpath_or_self() {
|
||||
local p="$1"
|
||||
if command -v realpath >/dev/null 2>&1; then
|
||||
realpath -- "$p" 2>/dev/null || printf '%s' "$p"
|
||||
else
|
||||
printf '%s' "$p"
|
||||
fi
|
||||
}
|
||||
|
||||
# _resolve_cmd_file CMD — resolve the FIRST WORD of an adapter command string
|
||||
# to a real file; prints `raw<TAB>resolved`. Non-zero (loud) if it cannot be
|
||||
# resolved: an adapter the detector will invoke but provenance cannot see is
|
||||
# an infrastructure failure, not a smaller set. BOTH forms are kept: the deny
|
||||
# gate must see the pre-resolution form too (#964 review, case C).
|
||||
_resolve_cmd_file() {
|
||||
local cmd="$1" word path
|
||||
# shellcheck disable=SC2086
|
||||
set -- $cmd
|
||||
word="${1:-}"
|
||||
[ -n "$word" ] || return 1
|
||||
if [ -f "$word" ]; then
|
||||
path="$word"
|
||||
else
|
||||
path="$(command -v -- "$word" 2>/dev/null)" || return 1
|
||||
[ -f "$path" ] || return 1
|
||||
fi
|
||||
# realpath so the ledger keys on the actual file, not a symlink alias.
|
||||
printf '%s\t%s' "$path" "$(_realpath_or_self "$path")"
|
||||
}
|
||||
|
||||
# _preimage_set — print the derived set, one member per line as
|
||||
# `origin<TAB>raw<TAB>resolved` (origin: core|extra). BOTH path forms travel
|
||||
# with every member so the deny gate and the capture allowlist are evaluated
|
||||
# on the SAME candidate in BOTH its forms — resolving before denying is the
|
||||
# #964 case-C ordering defect. Paths from WAKE_PREIMAGE_EXTRA are printed
|
||||
# EVEN IF ABSENT (deletion is a tracked state); the adapter and watch-list
|
||||
# must resolve (loud failure otherwise — see _resolve_cmd_file rationale).
|
||||
_preimage_set() {
|
||||
local failed=0
|
||||
if [ -n "${WAKE_DETECTOR_SOURCE_CMD:-}" ]; then
|
||||
local af
|
||||
if af="$(_resolve_cmd_file "$WAKE_DETECTOR_SOURCE_CMD")"; then
|
||||
printf 'core\t%s\n' "$af"
|
||||
else
|
||||
echo "preimage.sh: FAIL LOUD — WAKE_DETECTOR_SOURCE_CMD ('$WAKE_DETECTOR_SOURCE_CMD') does not resolve to a file; the preimage definition cannot be observed (NOT treated as 'no change')." >&2
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
if [ -n "${WAKE_WATCH_LIST:-}" ]; then
|
||||
if [ -f "$WAKE_WATCH_LIST" ]; then
|
||||
printf 'core\t%s\t%s\n' "$WAKE_WATCH_LIST" "$(_realpath_or_self "$WAKE_WATCH_LIST")"
|
||||
else
|
||||
echo "preimage.sh: FAIL LOUD — WAKE_WATCH_LIST ('$WAKE_WATCH_LIST') is not a file; the preimage definition cannot be observed." >&2
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
if [ -n "${WAKE_PREIMAGE_EXTRA:-}" ]; then
|
||||
local IFS=':' p
|
||||
for p in $WAKE_PREIMAGE_EXTRA; do
|
||||
[ -n "$p" ] || continue
|
||||
# Extras are tracked even when absent (ABSENT is a state). Resolve the
|
||||
# realpath only when the file exists; otherwise track the literal path.
|
||||
if [ -e "$p" ]; then
|
||||
printf 'extra\t%s\t%s\n' "$p" "$(_realpath_or_self "$p")"
|
||||
else
|
||||
printf 'extra\t%s\t%s\n' "$p" "$p"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
# --- credential hard gate (acceptance (b)) ----------------------------------
|
||||
|
||||
# _deny_path RAW RESOLVED — 0 (deny) iff EITHER form of the candidate names a
|
||||
# known credential-store location. Framework-defined locations only
|
||||
# (firewall): the mosaic credential store, the tools/_lib credential file the
|
||||
# framework-manifest itself carves out of tools/**, the credentials/ operator
|
||||
# subtree, and any file literally named credentials.json.
|
||||
#
|
||||
# Evaluated on BOTH the operator-supplied (raw) form and the symlink-resolved
|
||||
# form, against BOTH the unresolved and resolved forms of the mosaic-home
|
||||
# anchor: a deny list written only in unresolved paths cannot match a path
|
||||
# that was resolved before it arrived (#964 review, case C — a symlink whose
|
||||
# target was renamed slipped every path rule because the candidate had
|
||||
# already been realpath'd but the anchors never were).
|
||||
_deny_path() {
|
||||
local raw="$1" resolved="$2"
|
||||
local home="${MOSAIC_HOME:-$HOME/.config/mosaic}" rhome p a
|
||||
rhome="$(_realpath_or_self "$home")"
|
||||
for p in "$raw" "$resolved"; do
|
||||
[ -n "$p" ] || continue
|
||||
for a in "$home" "$rhome"; do
|
||||
case "$p" in
|
||||
"$a/credentials.json") return 0 ;;
|
||||
"$a/tools/_lib/credentials.json") return 0 ;;
|
||||
"$a/credentials/"*) return 0 ;;
|
||||
esac
|
||||
done
|
||||
case "$(basename -- "$p")" in
|
||||
credentials.json) return 0 ;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# _deny_content FILE — 0 (deny) iff FILE's bytes look secret-shaped.
|
||||
# Two probes: (a) the well-known vendor-token shapes digest.sh redacts
|
||||
# (PAT / Slack / AKIA / JWT / PEM — conservative: a 40-hex git sha never
|
||||
# matches); (b) a named-assignment shape (key/token/secret/passw/hmac/
|
||||
# credential/bearer = long unbroken value) that catches prefix-less
|
||||
# high-entropy keys, e.g. an HMAC key in an env file (#964 review, case D).
|
||||
#
|
||||
# (b) is defense-in-depth for the OPT-IN path only, NOT the thing that keeps
|
||||
# case D safe — polarity does that (extras are record-only unless allowlisted;
|
||||
# a shape list can only refuse the secrets someone already enumerated). It is
|
||||
# deliberately conservative toward REFUSAL: a false match (a checksum in a
|
||||
# config, a path that happens to be long and unbroken) only withholds byte
|
||||
# capture — the hash/size/mtime row and change entry still land. A variable
|
||||
# REFERENCE (token="$GITEA_TOKEN") never matches: `$` is not in the value
|
||||
# class — only literal secret values do.
|
||||
#
|
||||
# FAILS TOWARD REFUSAL: if a probe itself errors (unreadable file, grep
|
||||
# failure — rc >= 2), the answer is DENY, not capture. An error exit is not a
|
||||
# negative result.
|
||||
_deny_content() {
|
||||
local rc
|
||||
LC_ALL=C grep -Eq \
|
||||
-e 'gh[pousr]_[A-Za-z0-9]{16,}' \
|
||||
-e 'github_pat_[A-Za-z0-9_]{16,}' \
|
||||
-e 'xox[baprs]-[A-Za-z0-9-]{10,}' \
|
||||
-e 'AKIA[0-9A-Z]{16}' \
|
||||
-e 'eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}' \
|
||||
-e '-----BEGIN[A-Z ]*PRIVATE KEY-----' \
|
||||
-- "$1" 2>/dev/null
|
||||
rc=$?
|
||||
[ "$rc" -eq 1 ] || return 0
|
||||
LC_ALL=C grep -Eiq \
|
||||
-e "[A-Za-z0-9_]*(key|token|secret|passw|hmac|credential|bearer)[A-Za-z0-9_]*[[:space:]]*[=:][[:space:]]*[\"']?[A-Za-z0-9+/=_-]{16,}" \
|
||||
-- "$1" 2>/dev/null
|
||||
rc=$?
|
||||
[ "$rc" -eq 1 ] || return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# _capture_allowed ORIGIN RAW RESOLVED — 0 iff byte capture may even be
|
||||
# ATTEMPTED for this member (the deny gate still runs after, and wins).
|
||||
# POLARITY (#964 review, case D): core members (the adapter file, the
|
||||
# watch-list) are capture-eligible — they ARE the preimage definition this
|
||||
# tool exists to snapshot (acceptance (a)). Extras are RECORD-ONLY unless
|
||||
# listed in WAKE_PREIMAGE_CAPTURE.
|
||||
#
|
||||
# The allowlist is matched with the SAME both-forms discipline as the deny
|
||||
# list (entry raw/resolved vs candidate raw/resolved): two lists keyed on
|
||||
# different strings would let a symlink alias slip between them. "Deny wins
|
||||
# over opt-in" is a precedence rule, not a guarantee both lists see the same
|
||||
# path — the guarantee comes from _check_one running _deny_path on both forms
|
||||
# FIRST, independent of anything matched here.
|
||||
_capture_allowed() {
|
||||
local origin="$1" raw="$2" resolved="$3"
|
||||
[ "$origin" = "core" ] && return 0
|
||||
[ -n "${WAKE_PREIMAGE_CAPTURE:-}" ] || return 1
|
||||
local IFS=':' e er
|
||||
for e in $WAKE_PREIMAGE_CAPTURE; do
|
||||
[ -n "$e" ] || continue
|
||||
er="$e"
|
||||
[ -e "$e" ] && er="$(_realpath_or_self "$e")"
|
||||
if [ "$e" = "$raw" ] || [ "$e" = "$resolved" ] ||
|
||||
[ "$er" = "$raw" ] || [ "$er" = "$resolved" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- ledger primitives ------------------------------------------------------
|
||||
|
||||
# _ledger_head PATH — print the LAST ledger row for PATH (empty if none).
|
||||
# Non-zero (loud) if the ledger exists but is unparseable: a corrupt ledger
|
||||
# must REFUSE to compare, never silently re-baseline (that would erase the
|
||||
# attribution history this tool exists to keep).
|
||||
_ledger_head() {
|
||||
local path="$1"
|
||||
[ -f "$LEDGER" ] || return 0
|
||||
if [ -s "$LEDGER" ] && ! jq -cn 'inputs' <"$LEDGER" >/dev/null 2>&1; then
|
||||
echo "preimage.sh: FAIL LOUD — preimage ledger $LEDGER is unparseable; REFUSING to compare or re-baseline over corrupt history. Investigate/restore the ledger." >&2
|
||||
return 1
|
||||
fi
|
||||
jq -c --arg p "$path" 'select(.path == $p)' "$LEDGER" 2>/dev/null | tail -n 1
|
||||
}
|
||||
|
||||
# _ledger_append ROW_JSON — append one row atomically (read + append + rename;
|
||||
# rows are small and the writer is serialized by the preimage lock).
|
||||
_ledger_append() {
|
||||
local row="$1"
|
||||
{
|
||||
[ -f "$LEDGER" ] && cat "$LEDGER"
|
||||
printf '%s\n' "$row"
|
||||
} | _atomic_write "$LEDGER"
|
||||
}
|
||||
|
||||
# --- the check itself -------------------------------------------------------
|
||||
|
||||
# _check_one ORIGIN RAW PATH ENQUEUE — compare PATH (the resolved form; the
|
||||
# ledger keys on it) to its ledger head; on change record (+bytes only if
|
||||
# capture-eligible and not denied) and optionally enqueue. Prints nothing on
|
||||
# no-change. Returns: 0 ok (changed or not), 1 infrastructure failure.
|
||||
_check_one() {
|
||||
local origin="$1" raw="$2" path="$3" enqueue="$4"
|
||||
local cur_sha size mtime denied="" refused=""
|
||||
|
||||
if [ -f "$path" ]; then
|
||||
cur_sha="$(_hash_stdin <"$path")" || return 1
|
||||
[ -n "$cur_sha" ] || {
|
||||
echo "preimage.sh: FAIL LOUD — could not hash $path" >&2
|
||||
return 1
|
||||
}
|
||||
size="$(wc -c <"$path" | tr -d '[:space:]')"
|
||||
# Portable mtime (GNU stat -c / BSD stat -f).
|
||||
mtime="$(stat -c %Y -- "$path" 2>/dev/null || stat -f %m -- "$path" 2>/dev/null || echo 0)"
|
||||
local cap="${WAKE_PREIMAGE_MAX_BYTES:-1048576}"
|
||||
case "$cap" in '' | *[!0-9]*) cap=1048576 ;; esac
|
||||
# Gate order matters: path deny FIRST, on BOTH forms, INDEPENDENT of the
|
||||
# capture allowlist — so an allowlisted symlink whose target is denied
|
||||
# refuses no matter what string the allowlist matched (a known credential
|
||||
# store is refused without a content probe; the hash read above is
|
||||
# deliberate: a sha256 discloses nothing about the bytes). Then polarity
|
||||
# (record-only extras never reach the content probe — case D must be safe
|
||||
# WITHOUT the shape list), then size cap (never content-grep an oversized
|
||||
# file), content shape last.
|
||||
if _deny_path "$raw" "$path"; then
|
||||
denied="credential-store path (deny list)"
|
||||
elif ! _capture_allowed "$origin" "$raw" "$path"; then
|
||||
denied="extra is record-only by default (byte capture requires WAKE_PREIMAGE_CAPTURE; deny rules still win)"
|
||||
elif [ "$size" -gt "$cap" ]; then
|
||||
denied="exceeds WAKE_PREIMAGE_MAX_BYTES ($size > $cap)"
|
||||
elif _deny_content "$path"; then
|
||||
denied="secret-shaped content"
|
||||
fi
|
||||
else
|
||||
# ABSENT is a state (deletion of a preimage file is a change to record).
|
||||
cur_sha="ABSENT"
|
||||
size=0
|
||||
mtime=0
|
||||
fi
|
||||
|
||||
local head prev_sha=""
|
||||
head="$(_ledger_head "$path")" || return 1
|
||||
[ -n "$head" ] && prev_sha="$(jq -r '.sha256 // ""' <<<"$head")"
|
||||
|
||||
if [ "$cur_sha" = "$prev_sha" ]; then
|
||||
return 0 # unchanged — no row, no enqueue (idempotent)
|
||||
fi
|
||||
|
||||
# --- capture bytes (content-addressed) unless the hard gate refuses -------
|
||||
local captured=true
|
||||
if [ "$cur_sha" != "ABSENT" ]; then
|
||||
if [ -n "$denied" ]; then
|
||||
captured=false
|
||||
refused="$denied"
|
||||
echo "preimage.sh: byte capture WITHHELD for $path ($denied) — hash/size/mtime recorded, content NOT stored (#958)." >&2
|
||||
else
|
||||
mkdir -p "$OBJECTS" || return 1
|
||||
if [ ! -f "$OBJECTS/$cur_sha" ]; then
|
||||
if ! _atomic_write "$OBJECTS/$cur_sha" <"$path"; then
|
||||
echo "preimage.sh: FAIL LOUD — durable object write failed for $path ($OBJECTS/$cur_sha); NO ledger row recorded (a row whose bytes were never durably kept would be a false witness)." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
local ts row
|
||||
ts="$(date +%s)"
|
||||
row="$(jq -cn \
|
||||
--arg path "$path" \
|
||||
--arg sha "$cur_sha" \
|
||||
--arg prev "$prev_sha" \
|
||||
--argjson size "$size" \
|
||||
--argjson mtime "$mtime" \
|
||||
--argjson ts "$ts" \
|
||||
--argjson captured "$captured" \
|
||||
--arg refused "$refused" \
|
||||
'{ts:$ts, path:$path, sha256:$sha, size:$size, mtime:$mtime, captured:$captured}
|
||||
+ (if $prev != "" then {prev:$prev} else {} end)
|
||||
+ (if $refused != "" then {refused:$refused} else {} end)')" || return 1
|
||||
if ! _ledger_append "$row"; then
|
||||
echo "preimage.sh: FAIL LOUD — durable ledger append failed ($LEDGER)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -z "$head" ]; then
|
||||
# First-seen: baseline SILENTLY (row only, no enqueue) — the same idiom as
|
||||
# the detector's first-seen source baseline.
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "preimage.sh: preimage definition CHANGED — $path ($prev_sha -> $cur_sha); prior bytes: $OBJECTS/$prev_sha" >&2
|
||||
|
||||
if [ "$enqueue" = "1" ]; then
|
||||
# First-class cause line (acceptance (c)): ONE class=actionable entry per
|
||||
# changed preimage file, allocated by the store's single allocator (#908).
|
||||
# `path` is a §2.1 hard locator, so the digest renders it — never
|
||||
# quarantined. Callers run this BEFORE polling sources, so this seq is
|
||||
# LOWER than the per-source deltas the change explains.
|
||||
local locators
|
||||
locators="$(jq -cn \
|
||||
--arg path "$path" \
|
||||
--arg sha "$cur_sha" \
|
||||
--arg prev "$prev_sha" \
|
||||
'{kind:"preimage", id:$path, path:$path, observed_hash:$sha, prev_hash:$prev,
|
||||
preimage:true, reason:"preimage-definition-changed"}')"
|
||||
if ! "$STORE_SH" enqueue --class actionable --locators "$locators" --emit-ts "$(date +%s)" >/dev/null; then
|
||||
echo "preimage.sh: FAIL LOUD — store enqueue of the preimage-change entry FAILED for $path (the change IS recorded in the ledger; the first-class wake line is NOT — treat as infrastructure failure)." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_check() {
|
||||
local enqueue=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--enqueue)
|
||||
enqueue=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "preimage.sh check: unknown option '$1'" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
_need_jq
|
||||
|
||||
local set_list
|
||||
if ! set_list="$(_preimage_set)"; then
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$set_list" ]; then
|
||||
# Nothing declared (no adapter/watch-list/extras in env) — an empty set is
|
||||
# a no-op, not an error: standalone invocations outside a wake runtime
|
||||
# must not fail loud for lacking one.
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$PRE_DIR" || exit 1
|
||||
# Serialize concurrent checks (detector tick vs reconcile vs manual) — the
|
||||
# ledger append is read+rewrite, so two writers must not interleave.
|
||||
if ! _wake_lock_acquire "$PRE_DIR/preimage.lock"; then
|
||||
echo "preimage.sh: FAIL LOUD — cannot acquire preimage lock" >&2
|
||||
exit 1
|
||||
fi
|
||||
# #964 review (B11): an ABSENT/EMPTY ledger while objects/ still holds
|
||||
# prior captures is DELETED HISTORY, not a first install — the asymmetry is
|
||||
# locally detectable and is the whole detection. Re-baselining here would
|
||||
# silently absorb the next real change as first-seen (the exact erasure
|
||||
# this tool exists to prevent). ABSENT is not CORRUPT: it must not take the
|
||||
# first-install path either.
|
||||
if [ ! -s "$LEDGER" ] && [ -d "$OBJECTS" ] && [ -n "$(ls -A -- "$OBJECTS" 2>/dev/null)" ]; then
|
||||
echo "preimage.sh: FAIL LOUD — preimage ledger $LEDGER is ABSENT/EMPTY but $OBJECTS still holds prior objects: history was deleted; REFUSING to re-baseline over it (a silent restart would absorb the next real change as first-seen). Restore the ledger, or move objects/ aside explicitly after investigation." >&2
|
||||
_wake_lock_release
|
||||
exit 1
|
||||
fi
|
||||
local failed=0 origin raw resolved
|
||||
while IFS=$'\t' read -r origin raw resolved; do
|
||||
[ -n "$resolved" ] || continue
|
||||
_check_one "$origin" "$raw" "$resolved" "$enqueue" || failed=1
|
||||
done <<EOF
|
||||
$set_list
|
||||
EOF
|
||||
_wake_lock_release
|
||||
[ "$failed" -eq 0 ] || exit 1
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
_need_jq
|
||||
[ -f "$LEDGER" ] || {
|
||||
echo "preimage.sh: no ledger yet ($LEDGER)" >&2
|
||||
return 0
|
||||
}
|
||||
# Head row per path (last wins).
|
||||
jq -cs 'group_by(.path) | map(last) | .[]' "$LEDGER"
|
||||
}
|
||||
|
||||
cmd_set() {
|
||||
local s
|
||||
s="$(_preimage_set)" || exit 1
|
||||
[ -n "$s" ] && printf '%s\n' "$s" | cut -f3
|
||||
}
|
||||
|
||||
main() {
|
||||
[ $# -ge 1 ] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
local cmd="$1"
|
||||
shift
|
||||
case "$cmd" in
|
||||
check) cmd_check "$@" ;;
|
||||
record) cmd_check ;;
|
||||
set) cmd_set "$@" ;;
|
||||
status) cmd_status "$@" ;;
|
||||
-h | --help | help) usage ;;
|
||||
*)
|
||||
echo "preimage.sh: unknown command '$cmd'" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -440,6 +440,17 @@ cmd_reconcile() {
|
||||
: "$allow_enumerate"
|
||||
|
||||
local pairs kind id failed=0 unaccounted=0 enumerated=0
|
||||
|
||||
# #958 preimage provenance: same pre-step as the detector's poll tick, for
|
||||
# the path where the preimage changed while the detector was down — without
|
||||
# it, a changed adapter surfaces here only as N UNACCOUNTED enumerations
|
||||
# with no first-class cause line. Run BEFORE observing any source so the
|
||||
# cause entry's observed_seq precedes the enumerations it explains. Loud
|
||||
# infrastructure failure marks the reconcile failed but does not stop it.
|
||||
if ! "$SCRIPT_DIR/preimage.sh" check --enqueue; then
|
||||
echo "reconcile.sh: FAIL LOUD — preimage provenance check failed (see preimage.sh above); reconcile continues but exits non-zero." >&2
|
||||
failed=1
|
||||
fi
|
||||
pairs="$(printf '%s' "$json" | jq -r '[ .watches[].sources[] | "\(.kind)\t\(.id)" ] | unique | .[]')"
|
||||
if [ -z "$pairs" ]; then
|
||||
echo "reconcile.sh: watch-list declares no sources under watches[].sources[]" >&2
|
||||
|
||||
+534
@@ -0,0 +1,534 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-wake-preimage.sh — RED-FIRST invariant harness for A11 (#958): durable
|
||||
# provenance for the OPERATOR-SIDE preimage definition (preimage.sh).
|
||||
#
|
||||
# Each test asserts ONE #958 invariant and is designed to go RED if it
|
||||
# regresses:
|
||||
# P1 first-seen -> SILENT baseline (ledger rows, NO enqueue) (detector idiom)
|
||||
# P2 change -> attributable from durable state alone: new ledger row with
|
||||
# prev, PRIOR BYTES retrievable content-addressed, first-class actionable
|
||||
# enqueued with a §2.1 hard locator (path) (acceptance a+c)
|
||||
# P3 detector ordering: the preimage cause line is enqueued at a LOWER
|
||||
# observed_seq than the per-source delta it explains (acceptance c)
|
||||
# P4 credential-store PATH is refused — bytes NEVER captured, even when the
|
||||
# operator lists it by error (hash/mtime row still written) (acceptance b)
|
||||
# P5 secret-SHAPED content is refused — refusal, not redaction (acceptance b)
|
||||
# P6 deletion of a preimage file is a tracked CHANGE (ABSENT), not an error
|
||||
# P7 no-change is idempotent (no row growth, no enqueue)
|
||||
# P8 unresolvable adapter command -> FAIL LOUD, never "no change" (D2/#955 class)
|
||||
# P9 corrupt ledger -> REFUSE to compare/re-baseline (loud, no append)
|
||||
# P10 oversized file -> bytes refused, hash still recorded (no data hoovering)
|
||||
# P11 reconcile pre-step: a preimage change surfaces as a first-class line
|
||||
# BEFORE the UNACCOUNTED enumerations it explains (acceptance c)
|
||||
# P12 detector: preimage infra failure is LOUD (pass exits non-zero) but does
|
||||
# NOT starve source observation
|
||||
#
|
||||
# Regression needles from the #964 NOT-CLEAR review (decoy method: plant the
|
||||
# bytes, grep the WHOLE state dir — the decoy does not know what the code
|
||||
# believes):
|
||||
# P13 case C: symlinked/renamed credential store (mosaic-home itself behind
|
||||
# a symlink; a renamed target outside every path rule) — refused on BOTH
|
||||
# path forms / by content, bytes grep-absent (acceptance b)
|
||||
# P14 case D: prefix-less high-entropy key (detector.env class) — safe
|
||||
# WITHOUT opt-in by POLARITY alone (must hold with the shape list
|
||||
# deleted), and refused WITH opt-in by the named-assignment shape
|
||||
# P15 polarity: extras are RECORD-ONLY by default (change row + enqueue, no
|
||||
# bytes); byte capture requires explicit WAKE_PREIMAGE_CAPTURE opt-in
|
||||
# P16 case B11: ledger deleted while objects/ survives — LOUD refusal to
|
||||
# re-baseline; the v2->v3 change is NOT absorbed as first-seen
|
||||
# P17 allowlist symmetry: an allowlisted symlink whose TARGET is denied
|
||||
# refuses (deny runs first, on both forms — precedence is not path
|
||||
# agreement); a symlink to an ALLOWED target still captures (the fix
|
||||
# must not close case C by breaking every symlinked path)
|
||||
#
|
||||
# Uses FAKE/STUB sources only (no live network). Isolated per test.
|
||||
# shellcheck disable=SC2030,SC2031
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PRE="$SCRIPT_DIR/preimage.sh"
|
||||
STORE="$SCRIPT_DIR/store.sh"
|
||||
DET="$SCRIPT_DIR/detector.sh"
|
||||
RECON="$SCRIPT_DIR/reconcile.sh"
|
||||
|
||||
command -v jq >/dev/null 2>&1 || {
|
||||
echo "SKIP: jq not available" >&2
|
||||
exit 0
|
||||
}
|
||||
|
||||
TMP_ROOT="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_ROOT"' EXIT
|
||||
|
||||
FAILFILE="$TMP_ROOT/failures"
|
||||
: >"$FAILFILE"
|
||||
pass=0
|
||||
fail_msg() {
|
||||
echo " FAIL: $*" >&2
|
||||
echo "x" >>"$FAILFILE"
|
||||
}
|
||||
ok() { pass=$((pass + 1)); }
|
||||
|
||||
fresh_state() {
|
||||
local d="$TMP_ROOT/$1"
|
||||
rm -rf "$d"
|
||||
mkdir -p "$d"
|
||||
printf '%s' "$d"
|
||||
}
|
||||
depth() { "$STORE" cursors | sed -n 's/pending_depth=//p'; }
|
||||
state_dir() { printf '%s/default' "$WAKE_STATE_HOME"; }
|
||||
ledger_rows() {
|
||||
local f
|
||||
f="$(state_dir)/preimage/preimage-ledger.jsonl"
|
||||
[ -f "$f" ] && wc -l <"$f" | tr -d '[:space:]' || echo 0
|
||||
}
|
||||
|
||||
# make_stub DIR — a source adapter reading $DIR/<kind>_<id> (same shape as the
|
||||
# reconcile harness stub).
|
||||
make_stub() {
|
||||
local dir="$1"
|
||||
cat >"$dir/adapter.sh" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -u
|
||||
kind="\$1"; id="\$2"; base="$dir/\${kind}_\${id}"; rc=0
|
||||
[ -f "\$base.rc" ] && rc="\$(cat "\$base.rc")"
|
||||
[ -f "\$base" ] && cat "\$base"
|
||||
exit "\$rc"
|
||||
EOF
|
||||
chmod +x "$dir/adapter.sh"
|
||||
}
|
||||
|
||||
make_wl() { # make_wl FILE — one repo source r1
|
||||
cat >"$1" <<'EOF'
|
||||
{ "schema_version": 1,
|
||||
"repos": [ { "id": "r1" } ],
|
||||
"watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" } ] } ] }
|
||||
EOF
|
||||
}
|
||||
|
||||
echo "== P1: first-seen -> SILENT baseline (rows, no enqueue) =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p1)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
fx="$TMP_ROOT/p1fx"; mkdir -p "$fx"
|
||||
make_stub "$fx"; make_wl "$fx/wl.json"
|
||||
printf 'extra v1\n' >"$fx/extra.env"
|
||||
export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh" WAKE_WATCH_LIST="$fx/wl.json" WAKE_PREIMAGE_EXTRA="$fx/extra.env"
|
||||
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || fail_msg "P1: baseline check must exit 0 (rc=$rc) [$out]"
|
||||
[ "$(ledger_rows)" -eq 3 ] || fail_msg "P1: expected 3 baseline rows (adapter, watch-list, extra), got $(ledger_rows)"
|
||||
[ "$(depth)" = "0" ] || fail_msg "P1: first-seen must NOT enqueue (depth=$(depth))"
|
||||
) && ok
|
||||
|
||||
echo "== P2: adapter change -> prior bytes + first-class hard-locator enqueue =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p2)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
fx="$TMP_ROOT/p2fx"; mkdir -p "$fx"
|
||||
make_stub "$fx"; make_wl "$fx/wl.json"
|
||||
# Acceptance (a) names the OPERATOR ADAPTER — a core member. Core members
|
||||
# capture by default (they ARE the preimage definition); extras do not (P15).
|
||||
export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh" WAKE_WATCH_LIST="$fx/wl.json"
|
||||
unset WAKE_PREIMAGE_EXTRA WAKE_PREIMAGE_CAPTURE MOSAIC_HOME
|
||||
"$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P2: baseline failed"
|
||||
cp "$fx/adapter.sh" "$fx/adapter.v1"
|
||||
old_sha="$(sha256sum "$fx/adapter.sh" | awk '{print $1}')"
|
||||
printf '# adapter v2 CHANGED\n' >>"$fx/adapter.sh"
|
||||
new_sha="$(sha256sum "$fx/adapter.sh" | awk '{print $1}')"
|
||||
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || fail_msg "P2: change check must exit 0 (rc=$rc) [$out]"
|
||||
sd="$(state_dir)"
|
||||
row="$(jq -c --arg p "$(realpath "$fx/adapter.sh")" 'select(.path == $p)' "$sd/preimage/preimage-ledger.jsonl" | tail -n1)"
|
||||
[ "$(jq -r '.prev // ""' <<<"$row")" = "$old_sha" ] || fail_msg "P2: new row must carry prev=<old sha> [$row]"
|
||||
[ "$(jq -r '.sha256' <<<"$row")" = "$new_sha" ] || fail_msg "P2: new row sha mismatch [$row]"
|
||||
# Acceptance (a): the PRIOR BYTES are retrievable from durable state alone.
|
||||
cmp -s "$sd/preimage/objects/$old_sha" "$fx/adapter.v1" || fail_msg "P2: prior bytes missing or not byte-exact"
|
||||
cmp -s "$sd/preimage/objects/$new_sha" "$fx/adapter.sh" || fail_msg "P2: current bytes object missing or mismatched"
|
||||
# Acceptance (c): ONE first-class actionable with a §2.1 hard locator (path).
|
||||
[ "$(depth)" = "1" ] || fail_msg "P2: exactly one enqueue expected (depth=$(depth))"
|
||||
ent="$(tail -n1 "$sd/pending.jsonl")"
|
||||
[ "$(jq -r '.class' <<<"$ent")" = "actionable" ] || fail_msg "P2: entry class must be actionable [$ent]"
|
||||
[ "$(jq -r '.locators.kind' <<<"$ent")" = "preimage" ] || fail_msg "P2: locator kind must be preimage [$ent]"
|
||||
[ -n "$(jq -r '.locators.path // ""' <<<"$ent")" ] || fail_msg "P2: locator must carry path (hard locator) [$ent]"
|
||||
[ "$(jq -r '.locators.observed_hash' <<<"$ent")" = "$new_sha" ] || fail_msg "P2: locator observed_hash mismatch [$ent]"
|
||||
[ "$(jq -r '.locators.prev_hash' <<<"$ent")" = "$old_sha" ] || fail_msg "P2: locator prev_hash mismatch [$ent]"
|
||||
) && ok
|
||||
|
||||
echo "== P3: detector orders the cause line BEFORE the delta it explains =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p3)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
fx="$TMP_ROOT/p3fx"; mkdir -p "$fx"
|
||||
make_stub "$fx"; make_wl "$fx/wl.json"
|
||||
printf 'r1 state v1\n' >"$fx/repo_r1"
|
||||
export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh" WAKE_WATCH_LIST="$fx/wl.json"
|
||||
unset WAKE_PREIMAGE_EXTRA
|
||||
"$DET" poll-once >/dev/null 2>&1 || fail_msg "P3: baseline poll failed"
|
||||
[ "$(depth)" = "0" ] || fail_msg "P3: baseline poll must enqueue nothing (depth=$(depth))"
|
||||
# Change the ADAPTER (preimage) and the source state in the same window.
|
||||
printf '# comment: adapter changed\n' >>"$fx/adapter.sh"
|
||||
printf 'r1 state v2\n' >"$fx/repo_r1"
|
||||
"$DET" poll-once >/dev/null 2>&1 || fail_msg "P3: second poll failed"
|
||||
sd="$(state_dir)"
|
||||
pre_seq="$(jq -r 'select(.locators.kind == "preimage") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
||||
src_seq="$(jq -r 'select(.locators.kind == "repo") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
||||
[ -n "$pre_seq" ] || fail_msg "P3: no preimage cause entry enqueued"
|
||||
[ -n "$src_seq" ] || fail_msg "P3: no source delta entry enqueued"
|
||||
if [ -n "$pre_seq" ] && [ -n "$src_seq" ]; then
|
||||
[ "$pre_seq" -lt "$src_seq" ] || fail_msg "P3: cause line must precede the delta (preimage seq=$pre_seq, source seq=$src_seq)"
|
||||
fi
|
||||
) && ok
|
||||
|
||||
echo "== P4: credential-store PATH refused — bytes never captured (acceptance b) =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p4)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
fx="$TMP_ROOT/p4fx"; mkdir -p "$fx"
|
||||
export MOSAIC_HOME="$fx/mosaic-home"
|
||||
mkdir -p "$MOSAIC_HOME"
|
||||
secret='cred-value-P4-do-not-capture'
|
||||
printf '{"svc":{"token":"%s"}}\n' "$secret" >"$MOSAIC_HOME/credentials.json"
|
||||
# Operator error: the credential store listed as a preimage extra — AND
|
||||
# explicitly opted in to capture. Deny must win over the opt-in (#964: a
|
||||
# path both denied and allowlisted must refuse).
|
||||
export WAKE_PREIMAGE_EXTRA="$MOSAIC_HOME/credentials.json"
|
||||
export WAKE_PREIMAGE_CAPTURE="$MOSAIC_HOME/credentials.json"
|
||||
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST
|
||||
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || fail_msg "P4: refusal is not an infra failure (rc=$rc) [$out]"
|
||||
sd="$(state_dir)"
|
||||
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
||||
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P4: row must record captured=false [$row]"
|
||||
jq -r '.refused // ""' <<<"$row" | grep -qi 'path' || fail_msg "P4: refusal must name the path deny [$row]"
|
||||
# THE invariant: the secret bytes exist NOWHERE under the wake state dir.
|
||||
if grep -rq "$secret" "$sd" 2>/dev/null; then
|
||||
fail_msg "P4: credential bytes leaked into the wake state dir"
|
||||
fi
|
||||
) && ok
|
||||
|
||||
echo "== P5: secret-SHAPED content refused (refusal, not redaction) =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p5)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
fx="$TMP_ROOT/p5fx"; mkdir -p "$fx"
|
||||
tok='ghp_abcdefghijklmnop0123456789ABCDEF'
|
||||
printf 'export MY_TOKEN=%s\n' "$tok" >"$fx/leaky.env"
|
||||
# Opted in, so the CONTENT gate (not record-only polarity) is what refuses.
|
||||
export WAKE_PREIMAGE_EXTRA="$fx/leaky.env" WAKE_PREIMAGE_CAPTURE="$fx/leaky.env"
|
||||
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME
|
||||
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || fail_msg "P5: refusal is not an infra failure (rc=$rc) [$out]"
|
||||
sd="$(state_dir)"
|
||||
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
||||
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P5: row must record captured=false [$row]"
|
||||
jq -r '.refused // ""' <<<"$row" | grep -qi 'secret' || fail_msg "P5: refusal must name secret-shaped content [$row]"
|
||||
if grep -rq "$tok" "$sd" 2>/dev/null; then
|
||||
fail_msg "P5: secret-shaped bytes leaked into the wake state dir"
|
||||
fi
|
||||
) && ok
|
||||
|
||||
echo "== P6: deletion is a tracked CHANGE (ABSENT), not an error =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p6)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
fx="$TMP_ROOT/p6fx"; mkdir -p "$fx"
|
||||
printf 'to be deleted\n' >"$fx/gone.env"
|
||||
export WAKE_PREIMAGE_EXTRA="$fx/gone.env"
|
||||
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME
|
||||
"$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P6: baseline failed"
|
||||
rm -f "$fx/gone.env"
|
||||
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || fail_msg "P6: deletion check must exit 0 (rc=$rc) [$out]"
|
||||
sd="$(state_dir)"
|
||||
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
||||
[ "$(jq -r '.sha256' <<<"$row")" = "ABSENT" ] || fail_msg "P6: deletion must record sha256=ABSENT [$row]"
|
||||
[ "$(depth)" = "1" ] || fail_msg "P6: deletion is a change and must enqueue (depth=$(depth))"
|
||||
) && ok
|
||||
|
||||
echo "== P7: no-change is idempotent =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p7)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
fx="$TMP_ROOT/p7fx"; mkdir -p "$fx"
|
||||
printf 'stable\n' >"$fx/stable.env"
|
||||
export WAKE_PREIMAGE_EXTRA="$fx/stable.env"
|
||||
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME
|
||||
"$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P7: baseline failed"
|
||||
r1="$(ledger_rows)"
|
||||
"$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P7: second check failed"
|
||||
[ "$(ledger_rows)" = "$r1" ] || fail_msg "P7: no-change must not append rows ($r1 -> $(ledger_rows))"
|
||||
[ "$(depth)" = "0" ] || fail_msg "P7: no-change must not enqueue (depth=$(depth))"
|
||||
) && ok
|
||||
|
||||
echo "== P8: unresolvable adapter -> FAIL LOUD, never 'no change' (D2 class) =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p8)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
export WAKE_DETECTOR_SOURCE_CMD="$TMP_ROOT/does-not-exist-adapter"
|
||||
unset WAKE_WATCH_LIST WAKE_PREIMAGE_EXTRA MOSAIC_HOME
|
||||
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
||||
[ "$rc" -ne 0 ] || fail_msg "P8: an unobservable preimage definition must FAIL LOUD (rc=0)"
|
||||
echo "$out" | grep -qi 'does not resolve' || fail_msg "P8: the failure must name the unresolvable adapter [$out]"
|
||||
) && ok
|
||||
|
||||
echo "== P9: corrupt ledger -> REFUSE to compare/re-baseline =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p9)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
fx="$TMP_ROOT/p9fx"; mkdir -p "$fx"
|
||||
printf 'v1\n' >"$fx/f.env"
|
||||
export WAKE_PREIMAGE_EXTRA="$fx/f.env"
|
||||
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME
|
||||
"$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P9: baseline failed"
|
||||
sd="$(state_dir)"
|
||||
printf 'NOT-JSON-GARBAGE{{{\n' >>"$sd/preimage/preimage-ledger.jsonl"
|
||||
r1="$(wc -l <"$sd/preimage/preimage-ledger.jsonl")"
|
||||
printf 'v2 changed\n' >"$fx/f.env"
|
||||
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
||||
[ "$rc" -ne 0 ] || fail_msg "P9: a corrupt ledger must FAIL LOUD (rc=0)"
|
||||
echo "$out" | grep -qi 'unparseable' || fail_msg "P9: the failure must name the corrupt ledger [$out]"
|
||||
[ "$(wc -l <"$sd/preimage/preimage-ledger.jsonl")" = "$r1" ] || fail_msg "P9: nothing may be appended over corrupt history"
|
||||
) && ok
|
||||
|
||||
echo "== P10: oversized file -> bytes refused, hash still recorded =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p10)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
fx="$TMP_ROOT/p10fx"; mkdir -p "$fx"
|
||||
head -c 200 /dev/zero | tr '\0' 'A' >"$fx/big.bin"
|
||||
# Opted in, so the SIZE gate (not record-only polarity) is what refuses.
|
||||
export WAKE_PREIMAGE_EXTRA="$fx/big.bin" WAKE_PREIMAGE_MAX_BYTES=64
|
||||
export WAKE_PREIMAGE_CAPTURE="$fx/big.bin"
|
||||
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME
|
||||
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || fail_msg "P10: size refusal is not an infra failure (rc=$rc) [$out]"
|
||||
sd="$(state_dir)"
|
||||
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
||||
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P10: row must record captured=false [$row]"
|
||||
jq -r '.refused // ""' <<<"$row" | grep -qi 'MAX_BYTES' || fail_msg "P10: refusal must name the size cap [$row]"
|
||||
sha="$(jq -r '.sha256' <<<"$row")"
|
||||
[ "$sha" = "$(sha256sum "$fx/big.bin" | awk '{print $1}')" ] || fail_msg "P10: hash must still be recorded [$row]"
|
||||
[ ! -f "$sd/preimage/objects/$sha" ] || fail_msg "P10: oversized bytes must NOT be stored"
|
||||
) && ok
|
||||
|
||||
echo "== P11: reconcile surfaces the cause line before its enumerations =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p11)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
fx="$TMP_ROOT/p11fx"; mkdir -p "$fx"
|
||||
make_stub "$fx"; make_wl "$fx/wl.json"
|
||||
printf 'r1 state\n' >"$fx/repo_r1"
|
||||
export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh" WAKE_WATCH_LIST="$fx/wl.json"
|
||||
unset WAKE_PREIMAGE_EXTRA MOSAIC_HOME
|
||||
# Baseline the preimage set, then change the adapter while "the detector is
|
||||
# down" — the reconcile path must still produce the first-class cause line.
|
||||
"$PRE" check >/dev/null 2>&1 || fail_msg "P11: preimage baseline failed"
|
||||
printf '# adapter changed while detector down\n' >>"$fx/adapter.sh"
|
||||
"$RECON" reconcile >/dev/null 2>&1 # rc 1 expected (unaccounted enumerated)
|
||||
sd="$(state_dir)"
|
||||
pre_seq="$(jq -r 'select(.locators.kind == "preimage") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
||||
enum_seq="$(jq -r 'select(.locators.reconciled == true) | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
||||
[ -n "$pre_seq" ] || fail_msg "P11: reconcile must enqueue the preimage cause line"
|
||||
[ -n "$enum_seq" ] || fail_msg "P11: reconcile must still enumerate the unaccounted source"
|
||||
if [ -n "$pre_seq" ] && [ -n "$enum_seq" ]; then
|
||||
[ "$pre_seq" -lt "$enum_seq" ] || fail_msg "P11: cause line must precede the enumeration (preimage seq=$pre_seq, enum seq=$enum_seq)"
|
||||
fi
|
||||
) && ok
|
||||
|
||||
echo "== P12: detector — preimage infra failure is LOUD but does not starve observation =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p12)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
fx="$TMP_ROOT/p12fx"; mkdir -p "$fx"
|
||||
make_stub "$fx"; make_wl "$fx/wl.json"
|
||||
printf 'r1 state\n' >"$fx/repo_r1"
|
||||
export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh" WAKE_WATCH_LIST="$fx/wl.json"
|
||||
unset WAKE_PREIMAGE_EXTRA MOSAIC_HOME
|
||||
# Corrupt the ledger BEFORE the first poll: the preimage check fails loud,
|
||||
# but the poll must still observe + baseline the source.
|
||||
sd="$(state_dir)"
|
||||
mkdir -p "$sd/preimage"
|
||||
printf 'NOT-JSON-GARBAGE{{{\n' >"$sd/preimage/preimage-ledger.jsonl"
|
||||
out="$("$DET" poll-once 2>&1)"; rc=$?
|
||||
[ "$rc" -ne 0 ] || fail_msg "P12: the pass must exit non-zero on preimage infra failure"
|
||||
echo "$out" | grep -qi 'preimage' || fail_msg "P12: the failure must name the preimage check [$out]"
|
||||
n="$(find "$sd/detector" -name 'watch-*.hash' 2>/dev/null | wc -l | tr -d '[:space:]')"
|
||||
[ "$n" -ge 1 ] || fail_msg "P12: source observation must still proceed (no watch hash baselined)"
|
||||
) && ok
|
||||
|
||||
echo "== P13: symlinked/renamed credential store refused on BOTH path forms (#964 C) =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p13)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
fx="$TMP_ROOT/p13fx"; mkdir -p "$fx"
|
||||
# The mosaic home is ITSELF behind a symlink: a deny list written in
|
||||
# unresolved paths cannot match a candidate that was realpath'd before the
|
||||
# deny saw it — unless the anchors are resolved too.
|
||||
mkdir -p "$fx/real-store/credentials"
|
||||
ln -s "$fx/real-store" "$fx/mh"
|
||||
export MOSAIC_HOME="$fx/mh"
|
||||
s1='decoy-P13a-renamed-target-under-store'
|
||||
printf 'x_token=%s\n' "$s1" >"$fx/mh/credentials/brain-creds.json"
|
||||
# And a renamed target OUTSIDE every known store location, reached via a
|
||||
# benign-looking symlink: no path rule can name it — the content gate must
|
||||
# hold on the resolved file.
|
||||
s2='0123456789abcdef0123456789abcdefP13b'
|
||||
printf 'service_hmac_key=%s\n' "$s2" >"$fx/renamed-anywhere.cfg"
|
||||
ln -s "$fx/renamed-anywhere.cfg" "$fx/link-b.env"
|
||||
export WAKE_PREIMAGE_EXTRA="$fx/mh/credentials/brain-creds.json:$fx/link-b.env"
|
||||
# Explicit opt-in for BOTH: deny must win over the allowlist.
|
||||
export WAKE_PREIMAGE_CAPTURE="$fx/mh/credentials/brain-creds.json:$fx/link-b.env"
|
||||
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST
|
||||
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || fail_msg "P13: refusal is not an infra failure (rc=$rc) [$out]"
|
||||
sd="$(state_dir)"
|
||||
n="$(jq -r 'select(.captured == false) | .path' "$sd/preimage/preimage-ledger.jsonl" | wc -l | tr -d '[:space:]')"
|
||||
[ "$n" = "2" ] || fail_msg "P13: both decoys must record captured=false (got $n)"
|
||||
# THE invariant (decoy method): the planted bytes exist NOWHERE in state.
|
||||
if grep -rq "$s1" "$sd" 2>/dev/null; then
|
||||
fail_msg "P13: renamed-target store bytes leaked into the wake state dir"
|
||||
fi
|
||||
if grep -rq "$s2" "$sd" 2>/dev/null; then
|
||||
fail_msg "P13: prefix-less key bytes leaked into the wake state dir"
|
||||
fi
|
||||
) && ok
|
||||
|
||||
echo "== P14: detector.env-class key — safe WITHOUT opt-in by polarity, refused WITH opt-in by shape (#964 D) =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p14)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
fx="$TMP_ROOT/p14fx"; mkdir -p "$fx"
|
||||
hm='9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c'
|
||||
printf 'WAKE_HMAC_KEY=%s\n' "$hm" >"$fx/detector.env"
|
||||
export WAKE_PREIMAGE_EXTRA="$fx/detector.env"
|
||||
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME WAKE_PREIMAGE_CAPTURE
|
||||
# (a) NO opt-in: polarity alone keeps the bytes out. This leg must hold even
|
||||
# with the content shape list deleted — the shape is defense-in-depth for
|
||||
# the opt-in path, never the thing that makes case D read safe.
|
||||
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || fail_msg "P14a: record-only tracking must succeed (rc=$rc) [$out]"
|
||||
sd="$(state_dir)"
|
||||
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
||||
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P14a: extra must be record-only without opt-in [$row]"
|
||||
if grep -rq "$hm" "$sd" 2>/dev/null; then
|
||||
fail_msg "P14a: key bytes leaked without any opt-in"
|
||||
fi
|
||||
# (b) The operator opts the env file in (the exact error the old usage text
|
||||
# invited): the named-assignment shape must still refuse the bytes.
|
||||
export WAKE_PREIMAGE_CAPTURE="$fx/detector.env"
|
||||
printf 'WAKE_HMAC_KEY=%s\nrotated=1\n' "$hm" >"$fx/detector.env"
|
||||
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || fail_msg "P14b: refusal is not an infra failure (rc=$rc) [$out]"
|
||||
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
||||
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P14b: opted-in key material must still refuse [$row]"
|
||||
jq -r '.refused // ""' <<<"$row" | grep -qi 'secret' || fail_msg "P14b: refusal must name secret-shaped content [$row]"
|
||||
if grep -rq "$hm" "$sd" 2>/dev/null; then
|
||||
fail_msg "P14b: key bytes leaked despite refusal"
|
||||
fi
|
||||
) && ok
|
||||
|
||||
echo "== P15: extras are RECORD-ONLY by default; capture requires explicit opt-in =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p15)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
fx="$TMP_ROOT/p15fx"; mkdir -p "$fx"
|
||||
printf 'plain v1\n' >"$fx/notes.cfg"
|
||||
export WAKE_PREIMAGE_EXTRA="$fx/notes.cfg"
|
||||
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME WAKE_PREIMAGE_CAPTURE
|
||||
"$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P15: baseline failed"
|
||||
printf 'plain v2\n' >"$fx/notes.cfg"
|
||||
sha2="$(sha256sum "$fx/notes.cfg" | awk '{print $1}')"
|
||||
"$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P15: change check failed"
|
||||
sd="$(state_dir)"
|
||||
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
||||
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P15: default must be record-only [$row]"
|
||||
[ ! -f "$sd/preimage/objects/$sha2" ] || fail_msg "P15: bytes captured without opt-in"
|
||||
[ "$(depth)" = "1" ] || fail_msg "P15: record-only must still track the change (depth=$(depth))"
|
||||
# Opt in -> the next change captures.
|
||||
export WAKE_PREIMAGE_CAPTURE="$fx/notes.cfg"
|
||||
printf 'plain v3\n' >"$fx/notes.cfg"
|
||||
sha3="$(sha256sum "$fx/notes.cfg" | awk '{print $1}')"
|
||||
"$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P15: opted-in change check failed"
|
||||
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
||||
[ "$(jq -r '.captured' <<<"$row")" = "true" ] || fail_msg "P15: opt-in must capture [$row]"
|
||||
cmp -s "$sd/preimage/objects/$sha3" "$fx/notes.cfg" || fail_msg "P15: opted-in bytes missing or mismatched"
|
||||
) && ok
|
||||
|
||||
echo "== P16: deleted ledger over surviving objects/ — REFUSE to re-baseline (#964 B11) =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p16)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
fx="$TMP_ROOT/p16fx"; mkdir -p "$fx"
|
||||
make_stub "$fx"
|
||||
export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh"
|
||||
unset WAKE_WATCH_LIST WAKE_PREIMAGE_EXTRA WAKE_PREIMAGE_CAPTURE MOSAIC_HOME
|
||||
"$PRE" check >/dev/null 2>&1 || fail_msg "P16: v1 baseline failed"
|
||||
printf '# v2\n' >>"$fx/adapter.sh"
|
||||
"$PRE" check >/dev/null 2>&1 || fail_msg "P16: v2 change failed"
|
||||
sd="$(state_dir)"
|
||||
nobj="$(ls "$sd/preimage/objects" | wc -l | tr -d '[:space:]')"
|
||||
# Delete the ledger; objects/ survives. ABSENT is not CORRUPT — and it is
|
||||
# not a first install either: that asymmetry is locally detectable.
|
||||
rm -f "$sd/preimage/preimage-ledger.jsonl"
|
||||
printf '# v3\n' >>"$fx/adapter.sh"
|
||||
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
||||
[ "$rc" -ne 0 ] || fail_msg "P16: absent ledger over prior objects must FAIL LOUD (rc=0) [$out]"
|
||||
echo "$out" | grep -qi 'REFUSING to re-baseline' || fail_msg "P16: the failure must name the refusal [$out]"
|
||||
[ ! -e "$sd/preimage/preimage-ledger.jsonl" ] || fail_msg "P16: the ledger must NOT be silently recreated over deleted history"
|
||||
[ "$(ls "$sd/preimage/objects" | wc -l | tr -d '[:space:]')" = "$nobj" ] || fail_msg "P16: prior objects must remain untouched"
|
||||
[ "$(depth)" = "0" ] || fail_msg "P16: the v2->v3 change must NOT be absorbed or enqueued from an unverifiable baseline (depth=$(depth))"
|
||||
) && ok
|
||||
|
||||
echo "== P17: allowlist symmetry — symlink to a DENIED target refuses; symlink to an allowed target captures =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state p17)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
fx="$TMP_ROOT/p17fx"; mkdir -p "$fx"
|
||||
export MOSAIC_HOME="$fx/mh"
|
||||
mkdir -p "$MOSAIC_HOME"
|
||||
s='decoy-P17-cred-behind-benign-alias'
|
||||
printf '{"svc":{"token":"%s"}}\n' "$s" >"$MOSAIC_HOME/credentials.json"
|
||||
# A benign-looking alias whose TARGET is denied: the allowlist matches the
|
||||
# alias string, but deny runs FIRST and on BOTH forms — "deny wins over
|
||||
# opt-in" is a precedence rule, path agreement is what makes it hold.
|
||||
ln -s "$MOSAIC_HOME/credentials.json" "$fx/settings.json"
|
||||
# And a symlink to an ALLOWED target: the fix must not close case C by
|
||||
# breaking every symlinked path outright.
|
||||
printf 'benign payload v1\n' >"$fx/target.cfg"
|
||||
ln -s "$fx/target.cfg" "$fx/alias.cfg"
|
||||
export WAKE_PREIMAGE_EXTRA="$fx/settings.json:$fx/alias.cfg"
|
||||
export WAKE_PREIMAGE_CAPTURE="$fx/settings.json:$fx/alias.cfg"
|
||||
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST
|
||||
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || fail_msg "P17: check must exit 0 (rc=$rc) [$out]"
|
||||
sd="$(state_dir)"
|
||||
crow="$(jq -c --arg p "$(realpath "$fx/settings.json")" 'select(.path == $p)' "$sd/preimage/preimage-ledger.jsonl" | tail -n1)"
|
||||
[ "$(jq -r '.captured' <<<"$crow")" = "false" ] || fail_msg "P17: allowlisted symlink to a denied target must refuse [$crow]"
|
||||
if grep -rq "$s" "$sd" 2>/dev/null; then
|
||||
fail_msg "P17: credential bytes leaked via an allowlisted alias"
|
||||
fi
|
||||
brow="$(jq -c --arg p "$(realpath "$fx/alias.cfg")" 'select(.path == $p)' "$sd/preimage/preimage-ledger.jsonl" | tail -n1)"
|
||||
[ "$(jq -r '.captured' <<<"$brow")" = "true" ] || fail_msg "P17: symlink to an allowed target must still capture [$brow]"
|
||||
) && ok
|
||||
|
||||
echo
|
||||
total=$((pass + $(wc -l <"$FAILFILE")))
|
||||
echo "== test-wake-preimage: $pass/$total passed =="
|
||||
[ -s "$FAILFILE" ] && exit 1
|
||||
exit 0
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
Reference in New Issue
Block a user