Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 15s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 26s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m16s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m39s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m37s
A force-push H1 -> H2 -> H1 spanning `pr-changed-files.sh`'s paging leaves its final
`.head.sha` comparison equal while the middle pages came from H2, so a mixed file list
could produce a docs-only exemption `success` no single head ever justified. The base
alias had been fenced since #706 by a monotonic `change_target_branch` count; the head
axis had nothing, and three contracts asserted otherwise.
`count_retargets` becomes `count_pr_mutations`: one timeline walk, two tallies, one shared
trust flag, a separate fence arm and diagnostic per axis. The advisory hook re-reads
`.head.sha` at the same hoist and off the same response as the base re-read. All three
overclaiming contracts are corrected, plus four paraphrases the first sweep missed.
Measured, not assumed: Gitea 1.27.1 still serves no `files` on `compare/{base}...{head}`;
every push is a `pull_push` event and its count cannot alias; PR #761 really went
`8798a1d -> 830a407 -> 8798a1d`; and Gitea creates the push comment BEFORE emitting the
synchronize notification, so a run cannot abstain on its own trigger.
Two pre-existing fail-opens in the shared walk were found by review and fixed: an empty
ARRAY first page was trusted on any page while the `null` arm required `page > 1`, and no
row was validated before `.type` was selected on.
NOT closed, and documented rather than overclaimed: the walk's `null` terminator is
defeatable, because Gitea pages before it filters (#870). The fence closes the ABA on a
timeline with no truncating block, not the ABA outright.
fixes #803
fixes #664
Refs #870
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
313 lines
20 KiB
Bash
Executable File
313 lines
20 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Exhaustively enumerate a PR's changed file paths, or fail closed.
|
|
#
|
|
# ersatztv#649. This is the ONE implementation of the security-critical half of the merge gate.
|
|
# It exists because the same logic was written twice — once in `.claude/hooks/pretooluse-merge-consent.sh`
|
|
# (advisory: a failure produces a human prompt) and once in `.gitea/workflows/review-verdict.yml`
|
|
# (ENFORCED: it writes the branch-protection-required `review-verdict/h10` status). The advisory copy
|
|
# accumulated four rounds of hardening (ersatztv#643) that the enforced copy never received, leaving the
|
|
# copy with real authority strictly weaker than the copy without. Two copies of a security predicate
|
|
# drift; one cannot.
|
|
#
|
|
# SCOPE — mechanism, not policy. This script answers exactly one question: "what is the complete set of
|
|
# paths this PR touches, with no head or base movement observable from in here, or can we not tell?"
|
|
# ("at one head" until 2026-08-28 — the same overclaim as the CONTRACT below, in the paraphrase that
|
|
# survived the first sweep for it.) It deliberately does NOT classify the PR.
|
|
# The two callers' allow-lists differ ON PURPOSE and must stay separate:
|
|
# * the hook's docs-only pattern also lets .claude/ .gitea/ .husky/ through, which is safe there only
|
|
# because it falls through to a HUMAN PROMPT;
|
|
# * the workflow's is narrower, because there a match posts a green status with nobody in the loop.
|
|
# Sharing the enumeration fixes the drift; sharing the classification would erase an intended difference.
|
|
#
|
|
# CONTRACT
|
|
# Usage: pr-changed-files.sh <owner> <repo> <pr> <expected-head-sha> <expected-base-ref>
|
|
# stdout: newline-delimited paths, BOTH sides of every rename, no blank lines. May be empty.
|
|
# exit 0 the enumeration is COMPLETE, and both <expected-head-sha> and <expected-base-ref> were
|
|
# observed unchanged at BOTH ends of it. stdout is authoritative.
|
|
#
|
|
# READ THAT AS THE ONE-WAY GUARANTEE IT IS (ersatztv#803/#664). This line said "bound to
|
|
# <expected-head-sha>" until 2026-08-28, which claims more than the code can do. Every
|
|
# binding below compares a value against ITSELF, so it detects movement that is still in
|
|
# effect at the end and is blind to an ALIAS: `H1 -> H2 -> H1` across the paging round
|
|
# trips, or `main -> S -> main`, restores the expected value while the middle pages were
|
|
# enumerated against the other one. Exit 0 means "no movement was OBSERVABLE from here",
|
|
# not "this list belongs to one head".
|
|
#
|
|
# A caller needing the stronger property must fence it with a MONOTONIC key, because a
|
|
# count cannot alias where a value can. The ENFORCED caller
|
|
# (`.gitea/workflows/review-verdict.yml`) does exactly that on both axes — it counts
|
|
# `change_target_branch` and `pull_push` timeline events before and after, and writes
|
|
# nothing if either moved (`ci.verdict-write-retarget-fence`). The advisory hook does not,
|
|
# and does not need to: its failure mode is a human prompt, not a green status.
|
|
# exit 1 the enumeration could NOT be completed or verified. stdout is meaningless — the caller
|
|
# MUST fail closed (withhold any exemption). A diagnostic goes to stderr.
|
|
# exit 2 usage error.
|
|
# Callers must treat any non-zero exit as "no exemption". Never read stdout without checking the status.
|
|
#
|
|
# WHY THE BASE REF IS AN ARGUMENT, AND WHY IT IS NOT OPTIONAL (ersatztv#698 route 1).
|
|
# `/pulls/{n}/files` computes the diff against the PR's **live** base, which is mutable. Retargeting a
|
|
# PR changes the enumerated file set without moving the head sha, so head-binding alone does not bind
|
|
# the ANSWER — only the commit it is nominally about. Reproduced live on this instance: a PR opened
|
|
# into `main` and retargeted mid-run to a scratch base enumerated as docs-only and was granted
|
|
# `review-verdict/h10=success`, while its diff against `main` carried a C# file (probe PR #703).
|
|
#
|
|
# REQUIRED rather than optional on purpose. An optional binding on a shared security primitive is an
|
|
# opt-out, and the caller that forgets it is precisely the caller that needed it — silently. Five
|
|
# arguments or exit 2.
|
|
#
|
|
# This NARROWS the window, it does not erase it. The base is re-read after the paging round trips
|
|
# alongside the head, so a retarget that is still in effect at that point fails closed; a retarget
|
|
# that opens and closes strictly between the files call and the re-read is not observable from here.
|
|
# Pinning the diff to two shas would close it, and Gitea cannot serve that: `compare/{base}...
|
|
# {head}` returns `total_commits`/`commits` and NO `files` (measured on 1.25.4, re-confirmed on
|
|
# 1.27.1 2026-08-28 ersatztv#747). Separately, and NOT re-probed since 1.25.4: a `--depth=1` fetch of
|
|
# the two shas has no merge base, so a three-dot diff is impossible while a two-dot one over-reports
|
|
# every commit `main` gained since the branch point (measured, #698). Independently re-probed again on
|
|
# 1.27.1, 2026-08-28 (ersatztv#803): still `total_commits`/`commits` only, over a 12-commit range.
|
|
#
|
|
# The remainder is covered one level up instead, and since ersatztv#803 that cover is explicit on BOTH
|
|
# axes rather than the base alone: the enforced caller fences its WRITE on the monotonic count of
|
|
# `change_target_branch` AND `pull_push` timeline events (`ci.verdict-write-retarget-fence`) — which
|
|
# is the thing an ALIAS cannot defeat — in addition to reclassifying on `edited` rather than trusting
|
|
# a machine-written success.
|
|
#
|
|
# AUTH/TRANSPORT is caller-supplied via env, because the two callers authenticate differently:
|
|
# ETV_GITEA_TOKEN | GITEA_TOKEN -> `Authorization: token`
|
|
# ETV_GITEA_BASICAUTH -> curl -u user:pass
|
|
# ETV_GITEA_URL | GITEA_BASE_URL -> API base; defaults to the homelab Gitea. A value ending in
|
|
# /api/v1 is used as-is, otherwise /api/v1 is appended.
|
|
#
|
|
# jq COMPATIBILITY (ersatztv#648). This runs on the CI runner, which ships **jq 1.6**, while it is
|
|
# authored on Macs shipping 1.8.x. It is therefore written to the 1.6-compatible subset:
|
|
# * never rely on `jq -e`'s exit status over EMPTY input — 1.6 exits 0 where >=1.7 exits 4, which is
|
|
# precisely the fail-open that ersatztv#647 found live in the enforced gate. Emptiness is always
|
|
# checked explicitly in shell FIRST.
|
|
# * never use `contains()` for substring tests — on 1.6 `contains("<NUL>")` is true for every string.
|
|
# * never distinguish a parse error from "no output" by exit code — 1.6 returns 4 for both.
|
|
# See docs/ci-cd.md -> "The jq contract".
|
|
|
|
set -euo pipefail
|
|
|
|
if [ "$#" -ne 5 ]; then
|
|
echo "usage: pr-changed-files.sh <owner> <repo> <pr> <expected-head-sha> <expected-base-ref>" >&2
|
|
exit 2
|
|
fi
|
|
|
|
owner=$1
|
|
repo=$2
|
|
pr=$3
|
|
expected_sha=$4
|
|
expected_base=$5
|
|
|
|
if [ -z "$owner" ] || [ -z "$repo" ] || [ -z "$pr" ] || [ -z "$expected_sha" ] || [ -z "$expected_base" ]; then
|
|
echo "pr-changed-files: empty owner/repo/pr/sha/base argument" >&2
|
|
exit 2
|
|
fi
|
|
|
|
base_url="${ETV_GITEA_URL:-${GITEA_BASE_URL:-http://192.168.1.95:3000}}"
|
|
case "$base_url" in
|
|
*/api/v1) : ;;
|
|
*/) base_url="${base_url}api/v1" ;;
|
|
*) base_url="${base_url}/api/v1" ;;
|
|
esac
|
|
|
|
# Empty output on ANY failure, so every caller path treats a transport error the same way. The
|
|
# emptiness is then rejected explicitly below — never inferred from a jq exit code.
|
|
gq() {
|
|
local path="$1"
|
|
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
|
|
curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$path" 2>/dev/null || true
|
|
elif [ -n "${GITEA_TOKEN:-}" ]; then
|
|
curl -sf -H "Authorization: token $GITEA_TOKEN" "$base_url/$path" 2>/dev/null || true
|
|
elif [ -n "${ETV_GITEA_BASICAUTH:-}" ]; then
|
|
curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$path" 2>/dev/null || true
|
|
else
|
|
printf ''
|
|
fi
|
|
}
|
|
|
|
if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then
|
|
echo "pr-changed-files: no Gitea credentials in env — cannot enumerate, failing closed" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Bind the BASE before the first page is requested (ersatztv#698 route 1). Checking only afterwards
|
|
# would leave the common case — a PR retargeted before the enumeration even starts — indistinguishable
|
|
# from an honest one, because every page would agree with every other page while all of them described
|
|
# a diff against the wrong base. Both ends are checked; neither alone is sufficient.
|
|
prjson_before=$(gq "repos/$owner/$repo/pulls/$pr")
|
|
if [ -z "${prjson_before//[[:space:]]/}" ]; then
|
|
echo "pr-changed-files: could not read PR #$pr to bind the base ref before enumerating — failing closed" >&2
|
|
exit 1
|
|
fi
|
|
base_before=$(printf '%s' "$prjson_before" | jq -r '.base.ref // ""' 2>/dev/null || true)
|
|
if [ -z "$base_before" ] || [ "$base_before" != "$expected_base" ]; then
|
|
echo "pr-changed-files: PR #$pr targets '${base_before:-<unreadable>}', not the expected '$expected_base' — the diff would be computed against a different base, failing closed" >&2
|
|
exit 1
|
|
fi
|
|
# Also capture the base's TIP at this same read (ersatztv#707). This costs no extra round trip —
|
|
# `prjson_before` is already fetched above for the `.base.ref` check. It answers a DIFFERENT
|
|
# question than that check does, and the two are not interchangeable:
|
|
# * `.base.ref` (above) answers "did this PR RETARGET to a different branch" — comparing branch
|
|
# NAMES is deliberate there (ersatztv#698 route 1 / ersatztv#632), because comparing tip shas
|
|
# for that purpose would self-deadlock: `main` advancing on every unrelated merge would fail
|
|
# every open enumeration even though the PR still targets the same branch it always did.
|
|
# * `.base.sha` (here) answers "did `$expected_base` ADVANCE while THIS enumeration was running."
|
|
# `/pulls/{n}/files` diffs against the base's LIVE tip and is offset-paged over several round
|
|
# trips; if `main` gains a commit mid-enumeration, Gitea recomputes each subsequent page against
|
|
# the new tip independently, so rows can drop out of the result entirely (a file `main` no longer
|
|
# differs on) while later rows shift into offset ranges already consumed on the old tip. The
|
|
# result reads as a complete, ordinary list — `.base.ref` never changed, `.head.sha` never
|
|
# changed, page count and termination all look normal — while silently omitting a page's worth of
|
|
# changed paths, including possibly the only code file in the diff. This is a narrower, additional
|
|
# check layered on top of the ref check, not a replacement for it.
|
|
base_sha_before=$(printf '%s' "$prjson_before" | jq -r '.base.sha // ""' 2>/dev/null || true)
|
|
if [ -z "$base_sha_before" ]; then
|
|
echo "pr-changed-files: could not read PR #$pr's base tip sha before enumerating — failing closed" >&2
|
|
exit 1
|
|
fi
|
|
|
|
PAGE_SIZE=50
|
|
MAX_PAGES=40 # 2000 files; beyond this we refuse rather than guess
|
|
|
|
files=""
|
|
page=1
|
|
complete=no
|
|
|
|
while [ "$page" -le "$MAX_PAGES" ]; do
|
|
raw=$(gq "repos/$owner/$repo/pulls/$pr/files?limit=${PAGE_SIZE}&page=${page}")
|
|
|
|
# An EMPTY body is rejected in SHELL, before jq sees it. `jq -e` over empty input exits 4 on
|
|
# jq >= 1.7 but 0 on jq 1.6, and the runner ships 1.6 — leaving this to jq's exit status is the
|
|
# exact fail-open ersatztv#647 found in the enforced copy. A transport failure must never
|
|
# masquerade as a legitimate short final page.
|
|
if [ -z "${raw//[[:space:]]/}" ]; then
|
|
echo "pr-changed-files: empty/unreadable response for page ${page}" >&2
|
|
complete=no; break
|
|
fi
|
|
|
|
# VALIDATE EVERY FIELD THE EXTRACTION BELOW CONSUMES, on EVERY row.
|
|
#
|
|
# * Top-level type alone is not enough: `[{}]` is a well-formed array whose rows carry no
|
|
# `filename`, so it contributes no paths, looks like a short page, and would complete the
|
|
# enumeration from a PARTIAL list — the same failure one level down. It also rejects arrays of
|
|
# scalars, which would otherwise make the extraction fail under `set -e`.
|
|
# * CR/LF in a path is rejected outright. `chunk` flattens paths into newline-delimited text, so a
|
|
# filename containing a newline splits into TWO lines matched against the allow-list separately:
|
|
# "safe.md\ndocs/Program.cs" yields `safe.md` and `docs/Program.cs`, both of which pass, while the
|
|
# real single path ends in `.cs`. Git permits newlines in filenames, so this is reachable and was
|
|
# reproduced against the hook.
|
|
# * `previous_filename` is validated on EVERY row, not only `renamed` ones, because `chunk` emits it
|
|
# for every row regardless of `.status`. Validating it only where it is semantically "supposed to"
|
|
# appear left a hole one predicate wide: a `status: "modified"` row carrying a newline in
|
|
# `previous_filename` was reproducibly exempted. The validation domain must match the CONSUMPTION
|
|
# domain.
|
|
# * `..` is rejected because the callers' allow-lists anchor `^docs/`, so `docs/../ErsatzTV/Program.cs`
|
|
# matches one. Git will not produce such a path; this guard's job is to fail closed on unexpected
|
|
# 2xx shapes rather than assume a well-behaved peer.
|
|
# * `.status` is checked against a CLOSED set. Be precise about what this does and does not do:
|
|
# the extraction below emits `(.previous_filename // empty)` UNCONDITIONALLY, so a present
|
|
# `previous_filename` is never dropped on account of `.status`. What the closed set actually buys
|
|
# is rejecting rows whose vocabulary we do not recognise — where a source path may be absent, or
|
|
# carried in some other field we are not reading. Without it, `"Renamed"` with a capital R, or an
|
|
# absent status, silently takes the `else true` branch of the clause below and skips the
|
|
# "renamed rows MUST carry previous_filename" requirement entirely. (An earlier version of this
|
|
# comment claimed the source path would be "dropped", which is not the mechanism; a maintainer
|
|
# who tested that claim would find it false and might conclude the check is redundant.)
|
|
# `modified` is accepted alongside `changed` deliberately. Live Gitea emits `changed`; a re-derivation
|
|
# on 1.27.1 (2026-08-28, ersatztv#747) over the file rows of the 200 most recently updated PRs saw
|
|
# `changed`, `added`, `renamed` and `deleted` — all four already in the list below. `copied` and
|
|
# `modified` did not appear in that corpus, which does not show they are never emitted. `modified`
|
|
# is retained because a closed allow-list built from the wrong vocabulary is a worse failure than
|
|
# the hole it closes — it would gate every genuine docs-only PR on any version that spells it
|
|
# differently. The property is
|
|
# "reject values we do not recognise", not "enumerate one version exactly".
|
|
if ! printf '%s' "$raw" \
|
|
| jq -e 'def ok: type == "string" and length > 0
|
|
and (test("[\\r\\n]") | not)
|
|
and (split("/") | index("..") | not);
|
|
type == "array" and all(.[];
|
|
(.filename | ok)
|
|
and (.previous_filename == null or (.previous_filename | ok))
|
|
and ((.status // "") as $s | ($s | type) == "string"
|
|
and (["added","deleted","changed","modified","renamed","copied"] | index($s)) != null)
|
|
and (if .status == "renamed"
|
|
then (.previous_filename | type == "string" and length > 0)
|
|
else true end))' \
|
|
>/dev/null 2>&1; then
|
|
echo "pr-changed-files: page ${page} failed row validation" >&2
|
|
complete=no; break
|
|
fi
|
|
|
|
# BOTH sides of a rename: Gitea reports a `git mv` as ONE row whose `filename` is the DESTINATION,
|
|
# with the source only in `previous_filename`. Reading `filename` alone lets a PR move a protected
|
|
# file INTO docs/ and pass as docs-only (verified live: `.gitea/workflows/renovate.yml` ->
|
|
# `docs/innocuous-note.md` showed no protected path). One renamed row is therefore ONE row but TWO
|
|
# paths, which is why the two counts below are computed differently.
|
|
n=$(printf '%s' "$raw" | jq -r 'length')
|
|
chunk=$(printf '%s' "$raw" | jq -r '.[] | (.filename // empty), (.previous_filename // empty)')
|
|
[ -n "$chunk" ] && files=$(printf '%s\n%s' "$files" "$chunk")
|
|
|
|
# Terminate ONLY on an explicitly validated EMPTY page — never on a merely SHORT one.
|
|
# "Fewer than 50 rows means last page" assumes the server's page size is the 50 we asked for, but
|
|
# Gitea caps `limit` at the server-wide MAX_RESPONSE_ITEMS (default 50, configurable) and is free to
|
|
# return fewer. A 30-row page followed by a page of code would complete the enumeration over a
|
|
# PARTIAL list — the same fail-open, reached without any transport error. Costs one extra request;
|
|
# the MAX_PAGES cap still fails closed.
|
|
if [ "$n" -eq 0 ]; then complete=yes; break; fi
|
|
page=$((page + 1))
|
|
done
|
|
|
|
if [ "$complete" != yes ]; then
|
|
echo "pr-changed-files: enumeration incomplete (stopped at page ${page}) — failing closed" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Re-read the head and refuse if it MOVED. Paging is several round-trips; a force-push between them
|
|
# means page 1 came from head A and page 2 from head B, so the assembled list belongs to no single
|
|
# commit — B's code page can be skipped entirely while B's docs page reads as a clean short tail.
|
|
#
|
|
# ONE-WAY ONLY. This said "Bind the enumeration to ONE head" until 2026-08-28, which is the overclaim
|
|
# ersatztv#664 was filed against and ersatztv#803 carried: `H1 -> H2 -> H1` across the paging window
|
|
# passes the comparison below, because the value re-read is the value expected, while pages 1 and 2
|
|
# came from different trees. Nothing checkable HERE closes that — the check would have to compare
|
|
# against something the API does not offer (see the `compare/` probe above) — so it lives at the
|
|
# caller as a monotonic event count instead (`ci.verdict-write-retarget-fence`).
|
|
prjson=$(gq "repos/$owner/$repo/pulls/$pr")
|
|
if [ -z "${prjson//[[:space:]]/}" ]; then
|
|
echo "pr-changed-files: could not re-read PR head to bind the enumeration — failing closed" >&2
|
|
exit 1
|
|
fi
|
|
sha_after=$(printf '%s' "$prjson" | jq -r '.head.sha // ""' 2>/dev/null || true)
|
|
if [ -z "$sha_after" ] || [ "$sha_after" != "$expected_sha" ]; then
|
|
echo "pr-changed-files: head moved during enumeration (${expected_sha:0:7} -> ${sha_after:0:7}) — failing closed" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# The same round-trip window applies to the BASE, and the head check cannot see it: retargeting moves
|
|
# the diff without moving the head sha (ersatztv#698 route 1). Comparing `.base.ref` — the branch NAME,
|
|
# never its tip — is deliberate and matches `post-review-verdict.sh` (ersatztv#632): a base that merely
|
|
# ADVANCES is ordinary churn, while comparing tips would fail every enumeration on every unrelated
|
|
# merge to `main`.
|
|
base_after=$(printf '%s' "$prjson" | jq -r '.base.ref // ""' 2>/dev/null || true)
|
|
if [ -z "$base_after" ] || [ "$base_after" != "$expected_base" ]; then
|
|
echo "pr-changed-files: base moved during enumeration ('$expected_base' -> '${base_after:-<unreadable>}') — the enumerated diff is against a base this PR no longer targets, failing closed" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Same window, the tip-advance question this time (ersatztv#707; see the comment at
|
|
# `base_sha_before` above for why this is a DIFFERENT check from `.base.ref`, not a duplicate of
|
|
# it). `prjson` is already fetched above to re-read the head sha, so this is the same read, not a
|
|
# new round trip. `$expected_base`'s branch name can be unchanged across the whole enumeration
|
|
# while its TIP moved partway through — the exact #707 window: no retarget, no head movement,
|
|
# nothing the ref check or the head-sha check can see, yet later pages were diffed against a base
|
|
# earlier pages never saw.
|
|
base_sha_after=$(printf '%s' "$prjson" | jq -r '.base.sha // ""' 2>/dev/null || true)
|
|
if [ -z "$base_sha_after" ] || [ "$base_sha_after" != "$base_sha_before" ]; then
|
|
echo "pr-changed-files: base '$expected_base' advanced during enumeration (${base_sha_before:0:7} -> ${base_sha_after:0:7}) — later pages may have been diffed against a base earlier pages were not, failing closed" >&2
|
|
exit 1
|
|
fi
|
|
|
|
printf '%s\n' "$files" | grep -v '^$' || true
|
|
exit 0
|