- RC-14 removes the foreign-transaction refusal in lagoon.AfterCommit and requires TestTransactionAfterCommit to fail
740 lines
32 KiB
Bash
Executable File
740 lines
32 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Phase 11 fail-closed gate (jobs, scheduler, realtime, push and search:
|
|
# JOBS-01, CLI-04, CLI-06, RT-01, RT-02, RT-03, SRCH-01).
|
|
#
|
|
# Every stage exits non-zero on a failing command, a go test run that fails,
|
|
# skips, matches zero tests or prints "no tests to run", a named test that
|
|
# did not pass, a hygiene violation or an evidence gap. The only accepted
|
|
# skips are the two broadcast goldens that Phase 12 turns into assertions,
|
|
# and they must skip with their pending text. --self-test proves the
|
|
# detector, each hygiene rule and the removal harness fail closed.
|
|
#
|
|
# --removal is the anchor-exact mutation harness behind the RC rows of
|
|
# 11-SECURITY-REVIEW.md: it removes one protection at a time, requires its
|
|
# named test to fail on an assertion, and restores the file byte for byte.
|
|
# It edits tracked source while it runs, so it is not part of --all.
|
|
#
|
|
# Allow-list: KNOWN_APP_FAILURES names accepted fonoteka.go failures as
|
|
# "package:Test" with a reason; it is empty.
|
|
set -euo pipefail
|
|
|
|
ROOT="${PHASE11_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
|
APP="${PHASE11_APP:-$(cd "$ROOT/../fonoteka.go" && pwd)}"
|
|
PHASE_DIR="$ROOT/.planning/phases/11-jobs-realtime-and-search-infrastructure"
|
|
REVIEW="$PHASE_DIR/11-SECURITY-REVIEW.md"
|
|
VALIDATION="$PHASE_DIR/11-VALIDATION.md"
|
|
APP_PLUGINS=(./plugins/golem15/fonoteka/... ./plugins/golem15/user/...)
|
|
KNOWN_APP_FAILURES=""
|
|
# The broadcast goldens recorded from PHP but asserted only in Phase 12.
|
|
GOLDEN_SKIPS="TestBroadcastGoldens/created TestBroadcastGoldens/updated"
|
|
GOLDEN_SKIP_TEXT="pending: Phase 12"
|
|
RIVER_VERSION="v0.47.0"
|
|
|
|
usage() {
|
|
cat >&2 <<'EOF'
|
|
usage:
|
|
check-phase11.sh --self-test
|
|
check-phase11.sh --hygiene
|
|
check-phase11.sh --go
|
|
check-phase11.sh --postgres
|
|
check-phase11.sh --named
|
|
check-phase11.sh --evidence
|
|
check-phase11.sh --removal
|
|
check-phase11.sh --all
|
|
EOF
|
|
exit 2
|
|
}
|
|
|
|
# phase11_detect reads go test -json. Exit 1 fail, 2 skip, 3 zero tests or
|
|
# "no tests to run", 4 non-JSON, 5 a required test did not pass, 6 an
|
|
# allow-listed failure now passes, 7 an expected skip did not skip with its
|
|
# pending text. PHASE11_REQUIRE lists tests that must pass; PHASE11_ALLOW
|
|
# lists accepted "package:Test" failures; PHASE11_EXPECT_SKIP lists tests
|
|
# that must skip with PHASE11_SKIP_TEXT in their output.
|
|
phase11_detect() {
|
|
python3 - "$1" <<'PY'
|
|
import json, os, sys
|
|
path = sys.argv[1]
|
|
allow = set(os.environ.get("PHASE11_ALLOW", "").split())
|
|
require = set(os.environ.get("PHASE11_REQUIRE", "").split())
|
|
expect_skip = set(os.environ.get("PHASE11_EXPECT_SKIP", "").split())
|
|
skip_text = os.environ.get("PHASE11_SKIP_TEXT", "")
|
|
passed, skipped = set(), set()
|
|
output = {}
|
|
failed_tests, failed_pkgs = {}, []
|
|
build_failed = False
|
|
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
for raw in fh:
|
|
line = raw.strip()
|
|
if not line.startswith("{"):
|
|
continue
|
|
try:
|
|
ev = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
print("refuse: non-json test output", file=sys.stderr)
|
|
sys.exit(4)
|
|
action = ev.get("Action")
|
|
test = ev.get("Test") or ""
|
|
pkg = ev.get("Package") or ""
|
|
if action == "build-fail":
|
|
build_failed = True
|
|
if action == "output":
|
|
text = ev.get("Output") or ""
|
|
if "no tests to run" in text:
|
|
print(f"refuse: no tests to run in {pkg}", file=sys.stderr)
|
|
sys.exit(3)
|
|
if test:
|
|
output.setdefault(test, []).append(text)
|
|
if action == "skip" and test:
|
|
if test not in expect_skip:
|
|
print(f"refuse: skipped {pkg} {test}", file=sys.stderr)
|
|
sys.exit(2)
|
|
skipped.add(test)
|
|
if action == "fail":
|
|
if ev.get("FailedBuild"):
|
|
build_failed = True
|
|
if test:
|
|
failed_tests.setdefault(pkg, []).append(test)
|
|
else:
|
|
failed_pkgs.append(pkg)
|
|
if action == "pass" and test:
|
|
passed.add(test)
|
|
if f"{pkg}:{test}" in allow:
|
|
print(f"refuse: allow-listed failure {pkg} {test} now passes; remove it from the gate", file=sys.stderr)
|
|
sys.exit(6)
|
|
if build_failed:
|
|
print("refuse: build failed", file=sys.stderr)
|
|
sys.exit(1)
|
|
accepted = []
|
|
for pkg, tests in failed_tests.items():
|
|
for test in tests:
|
|
top = test.split("/", 1)[0]
|
|
if f"{pkg}:{top}" in allow:
|
|
accepted.append(f"{pkg} {test}")
|
|
continue
|
|
print(f"refuse: failed {pkg} {test}", file=sys.stderr)
|
|
sys.exit(1)
|
|
for pkg in failed_pkgs:
|
|
if not failed_tests.get(pkg):
|
|
print(f"refuse: failed {pkg or 'unknown package'}", file=sys.stderr)
|
|
sys.exit(1)
|
|
for item in sorted(set(accepted)):
|
|
print(f"known failure (KNOWN_APP_FAILURES): {item}", file=sys.stderr)
|
|
for name in sorted(expect_skip):
|
|
if name in passed:
|
|
print(f"refuse: expected skip {name} now passes; move it out of the pending list", file=sys.stderr)
|
|
sys.exit(7)
|
|
if name not in skipped:
|
|
print(f"refuse: expected skip {name} did not run", file=sys.stderr)
|
|
sys.exit(7)
|
|
if skip_text and not any(skip_text in o for o in output.get(name, [])):
|
|
print(f"refuse: {name} skipped without {skip_text!r}", file=sys.stderr)
|
|
sys.exit(7)
|
|
missing = sorted(name for name in require if name not in passed)
|
|
if missing:
|
|
print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr)
|
|
sys.exit(5)
|
|
if not passed:
|
|
print("refuse: zero tests", file=sys.stderr)
|
|
sys.exit(3)
|
|
PY
|
|
}
|
|
|
|
# phase11_go DIR ARGS... runs go test -json -count=1 ARGS through the
|
|
# detector. The go test exit status is trusted only when no failure was
|
|
# allow-listed.
|
|
phase11_go() {
|
|
local dir="$1"
|
|
shift
|
|
local log err
|
|
log="$(mktemp)"
|
|
err="$(mktemp)"
|
|
set +e
|
|
(cd "$dir" && go test -json -count=1 "$@") >"$log" 2>"$err"
|
|
local rc=$?
|
|
set -e
|
|
local dc=0
|
|
phase11_detect "$log" || dc=$?
|
|
if [[ "$dc" -ne 0 || ("$rc" -ne 0 && -z "${PHASE11_ALLOW:-}") ]]; then
|
|
cat "$err" >&2 || true
|
|
tail -n 40 "$log" >&2 || true
|
|
rm -f "$log" "$err"
|
|
echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2
|
|
exit 1
|
|
fi
|
|
rm -f "$log" "$err"
|
|
}
|
|
|
|
# phase11_tests DIR PKG TEST... requires every named test to run and pass.
|
|
phase11_tests() {
|
|
local dir="$1" pkg="$2"
|
|
shift 2
|
|
local names="$*"
|
|
local regex="^($(tr ' ' '|' <<<"$names"))\$"
|
|
PHASE11_REQUIRE="$names" phase11_go "$dir" "$pkg" -run "$regex"
|
|
}
|
|
|
|
expect_detect() {
|
|
local name="$1" want="$2" payload="$3"
|
|
local log dc=0
|
|
log="$(mktemp)"
|
|
printf '%s\n' "$payload" >"$log"
|
|
phase11_detect "$log" 2>/dev/null || dc=$?
|
|
rm -f "$log"
|
|
if [[ "$dc" -ne "$want" ]]; then
|
|
echo "refuse: self-test $name: detector exit $dc, want $want" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# The hygiene_11 refusal reasons; the self-test checks each plant is refused
|
|
# for its own reason only.
|
|
REASON_APPNAME="application name in a Phase 11 framework file"
|
|
REASON_CLIENT="excluded client library in the module graph"
|
|
REASON_CRON="direct cron dependency in go.mod"
|
|
REASON_RIVER="River is not pinned at $RIVER_VERSION"
|
|
REASON_README="Phase 11 module without a README or a root README row"
|
|
|
|
APPNAME_RE='pl[yý]tarium|fonoteka|albumy|kolekcj|winyl|p[lł]yt[aęy]'
|
|
# The Centrifugo and Typesense Go clients and Web Push libraries (D-12,
|
|
# D-15, D-19: all hand-rolled on net/http and stdlib crypto).
|
|
CLIENT_RE='^github\.com/centrifugal/(gocent|centrifuge-go)|^github\.com/typesense/typesense-go|webpush|web-push'
|
|
CRON_RE='cron'
|
|
PHASE11_MODULES=(conga lighthouse flare beachcomber)
|
|
|
|
# phase11_files TREE: the Phase 11 framework files the application-name
|
|
# rule reads (tracked files when TREE is a git work tree).
|
|
phase11_files() {
|
|
local tree="$1" m
|
|
if git -C "$tree" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
(cd "$tree" && git ls-files -- modules/conga modules/lighthouse modules/flare modules/beachcomber 'modules/tide/centrifugo*.go')
|
|
return
|
|
fi
|
|
(
|
|
cd "$tree"
|
|
for m in "${PHASE11_MODULES[@]}"; do
|
|
[[ -d "modules/$m" ]] && find "modules/$m" -type f
|
|
done
|
|
find modules/tide -maxdepth 1 -type f -name 'centrifugo*.go' 2>/dev/null
|
|
) | sort
|
|
}
|
|
|
|
# hygiene_11 TREE MODLIST GOMOD...: the Phase 11 hygiene rules. MODLIST is
|
|
# `go list -m all` of both repositories; GOMOD are the go.mod files whose
|
|
# direct requirements are checked. Each rule returns on its first violation.
|
|
hygiene_11() {
|
|
local tree="$1" modlist="$2"
|
|
shift 2
|
|
local hits file m
|
|
# Framework modules never name the application (CLAUDE.md).
|
|
while IFS= read -r file; do
|
|
[[ -n "$file" ]] || continue
|
|
hits="$(grep -niE "$APPNAME_RE" "$tree/$file" | head -n1 || true)"
|
|
if [[ -n "$hits" ]]; then
|
|
echo "refuse: hygiene: $REASON_APPNAME: $file: $hits" >&2
|
|
return 1
|
|
fi
|
|
done < <(phase11_files "$tree")
|
|
# No Centrifugo, Typesense or Web Push client library (T-11-SC).
|
|
hits="$(grep -iE "$CLIENT_RE" "$modlist" | head -n1 || true)"
|
|
if [[ -n "$hits" ]]; then
|
|
echo "refuse: hygiene: $REASON_CLIENT: $hits" >&2
|
|
return 1
|
|
fi
|
|
# No direct cron dependency: Daily/Every cover the cadences (11-02).
|
|
for file in "$@"; do
|
|
hits="$(awk '/^require[[:space:]]*\(/{inblock=1; next} inblock && /^\)/{inblock=0; next} (inblock || /^require[[:space:]]/) && !/\/\/[[:space:]]*indirect/' "$file" | grep -iE "$CRON_RE" | head -n1 || true)"
|
|
if [[ -n "$hits" ]]; then
|
|
echo "refuse: hygiene: $REASON_CRON: $file: $hits" >&2
|
|
return 1
|
|
fi
|
|
done
|
|
# River stays pinned at the audited version.
|
|
hits="$(grep -E '^github\.com/riverqueue/river ' "$modlist" | sort -u || true)"
|
|
if [[ -z "$hits" ]] || grep -vqE "^github\.com/riverqueue/river $RIVER_VERSION\$" <<<"$hits"; then
|
|
echo "refuse: hygiene: $REASON_RIVER: ${hits:-<absent>}" >&2
|
|
return 1
|
|
fi
|
|
# Every new module ships a README and a root modules-table row.
|
|
for m in "${PHASE11_MODULES[@]}"; do
|
|
if [[ ! -f "$tree/modules/$m/README.md" ]] || ! grep -qF "| [$m](modules/$m/README.md) |" "$tree/README.md"; then
|
|
echo "refuse: hygiene: $REASON_README: $m" >&2
|
|
return 1
|
|
fi
|
|
done
|
|
return 0
|
|
}
|
|
|
|
module_list() {
|
|
local out="$1"
|
|
(cd "$ROOT" && go list -m all) >"$out"
|
|
(cd "$APP" && go list -m all) >>"$out"
|
|
}
|
|
|
|
run_hygiene() {
|
|
local modlist
|
|
modlist="$(mktemp)"
|
|
module_list "$modlist"
|
|
local gomods=("$ROOT/go.mod" "$APP/go.mod")
|
|
local f
|
|
for f in "$APP"/plugins/*/*/go.mod; do
|
|
[[ -f "$f" ]] && gomods+=("$f")
|
|
done
|
|
if ! hygiene_11 "$ROOT" "$modlist" "${gomods[@]}"; then
|
|
rm -f "$modlist"
|
|
exit 1
|
|
fi
|
|
rm -f "$modlist"
|
|
echo "phase11 hygiene passed"
|
|
}
|
|
|
|
# removal_checks TABLE_MODE: the RC table, run by removal_harness.
|
|
# Fields: id, threat, repo (root|app|script), file, anchor, replacement,
|
|
# package, test regex. Anchors must occur exactly once.
|
|
removal_table() {
|
|
cat <<'EOF'
|
|
[
|
|
["RC-01", "T-11-01", "root", "modules/lighthouse/centrifugo/handlers.go",
|
|
"if cfg.ProxySecret == \"\" || subtle.ConstantTimeCompare([]byte(cfg.ProxySecret), []byte(provided)) != 1 {",
|
|
"if subtle.ConstantTimeCompare([]byte(cfg.ProxySecret), []byte(provided)) == 2 {", "./modules/lighthouse/centrifugo", "^TestProxy$"],
|
|
["RC-02", "T-11-02", "root", "modules/lighthouse/channel.go",
|
|
"if strings.HasPrefix(channel, presencePrefix+presencePrefix) {",
|
|
"if false {", "./modules/lighthouse", "^TestParseChannel$"],
|
|
["RC-03", "T-11-02", "root", "modules/lighthouse/channel.go",
|
|
"if len(parts) > 3 {",
|
|
"if false {", "./modules/lighthouse", "^TestParseChannel$"],
|
|
["RC-04", "T-11-02", "app", "plugins/golem15/fonoteka/classes/ws/collection_authorizer.go",
|
|
"\t\tWhere(\"golem15_fonoteka_collections.kind = ?\", \"collection\").\n",
|
|
"", "./plugins/golem15/fonoteka", "^TestWsAuthorizer$"],
|
|
["RC-05", "T-11-05", "root", "modules/lighthouse/broadcast.go",
|
|
"cb.Create().After(\"gorm:after_create\").Before(commitCallback).Register(",
|
|
"cb.Create().After(\"gorm:after_create\").Register(", "./modules/lighthouse", "^TestBroadcastTx$"],
|
|
["RC-06", "T-11-05", "app", "plugins/golem15/fonoteka/realtime.go",
|
|
"if c.Kind != \"collection\" {",
|
|
"if false {", "./plugins/golem15/fonoteka", "^TestAlbumBroadcastBinding$"],
|
|
["RC-07", "T-11-07", "app", "plugins/golem15/fonoteka/models/album_search.go",
|
|
"if a == nil || a.CollectionID == 0 {",
|
|
"if a == nil {", "./plugins/golem15/fonoteka", "^TestAlbumSearchable$"],
|
|
["RC-08", "T-11-09", "root", "modules/conga/scheduler.go",
|
|
"if !ok || entry.Command != a.Command || !slices.Equal(entry.Args, a.Args) {",
|
|
"if !ok {", "./modules/conga", "^TestScheduledEntryMismatchSkipped$"],
|
|
["RC-09", "T-11-12", "root", "modules/conga/conga.go",
|
|
"res, err := client.InsertTx(ctx, sqlTx, args, opts)",
|
|
"_ = sqlTx\n\t\tres, err := client.Insert(ctx, args, opts)", "./modules/conga", "^TestDispatchTransactional$"],
|
|
["RC-10", "T-11-19", "root", "modules/lighthouse/route.go",
|
|
"if len(s.UserAuth) == 0 {",
|
|
"if false {", "./modules/lighthouse", "^TestMountSurfaces$"],
|
|
["RC-11", "T-11-22", "root", "modules/flare/flare.go",
|
|
"if !HostAllowed(host, p.cfg.AllowedHosts) {",
|
|
"if false {", "./modules/flare", "^(TestSendAllowlist|TestSendRefusesDisallowedEndpoint)$"],
|
|
["RC-12", "T-11-28", "app", "parity/check_corpus.go",
|
|
"if strings.Contains(text, v) {\n\t\t\t\thits = append(hits, rel+\": centrifugo test value\")",
|
|
"if false && strings.Contains(text, v) {\n\t\t\t\thits = append(hits, rel+\": centrifugo test value\")", "./parity", "^TestUniqueAndSecretScan$"],
|
|
["RC-13", "T-11-SC", "script", "scripts/check-phase11.sh",
|
|
"hits=\"$(grep -iE \"$CLIENT_RE\" \"$modlist\" | head -n1 || true)\"",
|
|
"hits=\"\"", "", "--self-test"],
|
|
["RC-14", "T-11-31", "root", "modules/lagoon/transaction.go",
|
|
"if transactionalHandle(db) {",
|
|
"if false {", "./modules/lagoon", "^TestTransactionAfterCommit$"]
|
|
]
|
|
EOF
|
|
}
|
|
|
|
# removal_harness TABLE_FILE: for each row, save the file, apply the
|
|
# anchor-exact mutation, run the named test (or, for the gate script, its
|
|
# --self-test on a mutated copy) and require it to fail on an assertion,
|
|
# then restore the file and require cmp to match the saved copy.
|
|
removal_harness() {
|
|
python3 - "$1" "$ROOT" "$APP" <<'PY'
|
|
import json, os, shutil, subprocess, sys, tempfile
|
|
table = json.load(open(sys.argv[1]))
|
|
root, app = sys.argv[2], sys.argv[3]
|
|
only = set(os.environ.get("PHASE11_RC", "").split())
|
|
failures = 0
|
|
for rc, threat, repo, rel, anchor, repl, pkg, run in table:
|
|
if only and rc not in only:
|
|
continue
|
|
base = {"root": root, "app": app, "script": root}[repo]
|
|
path = os.path.join(base, rel)
|
|
original = open(path, "rb").read()
|
|
text = original.decode()
|
|
n = text.count(anchor)
|
|
if n != 1:
|
|
print(f"refuse: {rc} {threat}: anchor occurs {n} times in {rel}", file=sys.stderr)
|
|
sys.exit(1)
|
|
mutated = text.replace(anchor, repl, 1)
|
|
scratch = tempfile.mkdtemp(prefix="phase11-rc-")
|
|
saved = os.path.join(scratch, "saved")
|
|
shutil.copyfile(path, saved)
|
|
try:
|
|
if repo == "script":
|
|
copy = os.path.join(scratch, os.path.basename(rel))
|
|
open(copy, "w").write(mutated)
|
|
env = dict(os.environ, PHASE11_ROOT=root, PHASE11_APP=app)
|
|
proc = subprocess.run(["bash", copy, run], cwd=root, env=env, capture_output=True, text=True, timeout=600)
|
|
out = proc.stdout + proc.stderr
|
|
ok = proc.returncode != 0 and "refuse:" in out
|
|
evidence = next((l for l in out.splitlines() if l.startswith("refuse:")), "")
|
|
else:
|
|
with open(path, "w") as fh:
|
|
fh.write(mutated)
|
|
proc = subprocess.run(["go", "test", pkg, "-run", run, "-count=1"], cwd=base, capture_output=True, text=True, timeout=900)
|
|
out = proc.stdout + proc.stderr
|
|
build = "[build failed]" in out or "[setup failed]" in out
|
|
ok = proc.returncode != 0 and "--- FAIL" in out and not build
|
|
fails = [l.strip() for l in out.splitlines() if l.strip().startswith("--- FAIL")]
|
|
names = [l.split()[2] for l in fails if len(l.split()) > 2]
|
|
evidence = ", ".join(names[:5]) + (f" (+{len(names) - 5} more)" if len(names) > 5 else "") if names else ("build failed" if build else "no failure")
|
|
finally:
|
|
with open(path, "wb") as fh:
|
|
fh.write(original)
|
|
same = subprocess.run(["cmp", "-s", saved, path]).returncode == 0
|
|
shutil.rmtree(scratch, ignore_errors=True)
|
|
if not same:
|
|
print(f"refuse: {rc}: {rel} was not restored byte for byte", file=sys.stderr)
|
|
sys.exit(1)
|
|
status = "fails as required" if ok else "SURVIVED"
|
|
print(f"{rc} {threat} {rel}: {status}: {evidence}")
|
|
if not ok:
|
|
failures += 1
|
|
if failures:
|
|
print(f"refuse: {failures} removal check(s) survived", file=sys.stderr)
|
|
sys.exit(1)
|
|
PY
|
|
}
|
|
|
|
run_removal() {
|
|
local table
|
|
table="$(mktemp)"
|
|
removal_table >"$table"
|
|
if ! removal_harness "$table"; then
|
|
rm -f "$table"
|
|
exit 1
|
|
fi
|
|
rm -f "$table"
|
|
echo "phase11 removal passed"
|
|
}
|
|
|
|
run_self_test() {
|
|
bash -n "${BASH_SOURCE[0]}"
|
|
expect_detect pass 0 '{"Action":"pass","Package":"p","Test":"TestSuppression"}'
|
|
expect_detect fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
|
|
{"Action":"fail","Package":"p","Test":"TestProxy"}'
|
|
expect_detect skip 2 '{"Action":"skip","Package":"p","Test":"TestSyncGates"}'
|
|
expect_detect zero 3 '{"Action":"pass","Package":"git.golem15.com/golem15/summercms/modules/conga"}'
|
|
expect_detect no-tests-to-run 3 '{"Action":"pass","Package":"p","Test":"TestA"}
|
|
{"Action":"output","Package":"q","Output":"testing: warning: no tests to run\n"}'
|
|
expect_detect nonjson 4 '{"Action":"pass",'
|
|
expect_detect build 1 '{"Action":"build-fail","ImportPath":"p"}
|
|
{"Action":"pass","Package":"q","Test":"TestA"}'
|
|
expect_detect build-flag 1 '{"Action":"pass","Package":"q","Test":"TestA"}
|
|
{"Action":"fail","Package":"p","FailedBuild":"p"}'
|
|
expect_detect package 1 '{"Action":"pass","Package":"p","Test":"TestA"}
|
|
{"Action":"fail","Package":"p"}'
|
|
PHASE11_REQUIRE="TestProxy TestWsAuthorizer" expect_detect required 5 \
|
|
'{"Action":"pass","Package":"p","Test":"TestProxy"}'
|
|
PHASE11_ALLOW="p:TestKnown" expect_detect allowed 0 '{"Action":"pass","Package":"p","Test":"TestA"}
|
|
{"Action":"fail","Package":"p","Test":"TestKnown"}
|
|
{"Action":"fail","Package":"p"}'
|
|
PHASE11_ALLOW="p:TestKnown" expect_detect allowed-other 1 '{"Action":"fail","Package":"p","Test":"TestKnown"}
|
|
{"Action":"fail","Package":"p","Test":"TestOther"}'
|
|
PHASE11_ALLOW="p:TestKnown" expect_detect allowed-now-passes 6 '{"Action":"pass","Package":"p","Test":"TestKnown"}'
|
|
local golden='{"Action":"output","Package":"p","Test":"TestBroadcastGoldens/created","Output":" pending: Phase 12 asserts the album subtree\n"}
|
|
{"Action":"skip","Package":"p","Test":"TestBroadcastGoldens/created"}
|
|
{"Action":"pass","Package":"p","Test":"TestBroadcastGoldens"}'
|
|
PHASE11_EXPECT_SKIP="TestBroadcastGoldens/created" PHASE11_SKIP_TEXT="$GOLDEN_SKIP_TEXT" expect_detect expected-skip 0 "$golden"
|
|
PHASE11_EXPECT_SKIP="TestBroadcastGoldens/created" PHASE11_SKIP_TEXT="$GOLDEN_SKIP_TEXT" expect_detect expected-skip-wrong-text 7 \
|
|
'{"Action":"skip","Package":"p","Test":"TestBroadcastGoldens/created"}
|
|
{"Action":"pass","Package":"p","Test":"TestBroadcastGoldens"}'
|
|
PHASE11_EXPECT_SKIP="TestBroadcastGoldens/created" PHASE11_SKIP_TEXT="$GOLDEN_SKIP_TEXT" expect_detect expected-skip-passes 7 \
|
|
'{"Action":"pass","Package":"p","Test":"TestBroadcastGoldens/created"}'
|
|
PHASE11_EXPECT_SKIP="TestBroadcastGoldens/created" PHASE11_SKIP_TEXT="$GOLDEN_SKIP_TEXT" expect_detect expected-skip-missing 7 \
|
|
'{"Action":"pass","Package":"p","Test":"TestA"}'
|
|
PHASE11_EXPECT_SKIP="TestBroadcastGoldens/created" PHASE11_SKIP_TEXT="$GOLDEN_SKIP_TEXT" expect_detect other-skip 2 \
|
|
"$golden
|
|
{\"Action\":\"skip\",\"Package\":\"p\",\"Test\":\"TestBroadcastGoldens/deleted\"}"
|
|
local flag
|
|
for flag in --self-test --hygiene --go --postgres --named --evidence --removal --all; do
|
|
grep -q -- "^$flag)" "${BASH_SOURCE[0]}" || {
|
|
echo "refuse: missing mode $flag" >&2
|
|
exit 1
|
|
}
|
|
done
|
|
|
|
# hygiene_11 passes on scratch copies, then refuses each plant for its
|
|
# own reason and no other.
|
|
local scratch
|
|
scratch="$(mktemp -d)"
|
|
trap 'rm -rf "$scratch"' RETURN
|
|
mkdir -p "$scratch/tree/modules/tide"
|
|
local m
|
|
for m in "${PHASE11_MODULES[@]}"; do
|
|
cp -R "$ROOT/modules/$m" "$scratch/tree/modules/$m"
|
|
done
|
|
cp "$ROOT"/modules/tide/centrifugo*.go "$scratch/tree/modules/tide/"
|
|
cp "$ROOT/README.md" "$scratch/tree/README.md"
|
|
cp "$ROOT/go.mod" "$scratch/go.mod"
|
|
module_list "$scratch/modlist"
|
|
cp "$scratch/modlist" "$scratch/modlist.clean"
|
|
cp "$scratch/go.mod" "$scratch/go.mod.clean"
|
|
cp "$scratch/tree/README.md" "$scratch/README.clean"
|
|
(hygiene_11 "$scratch/tree" "$scratch/modlist" "$scratch/go.mod") >/dev/null 2>&1 || {
|
|
echo "refuse: self-test hygiene_11 rejected the clean scratch copy" >&2
|
|
exit 1
|
|
}
|
|
local plant want out reason
|
|
for plant in appname-go appname-readme client-gocent client-typesense client-webpush cron river-version river-absent readme-file readme-row; do
|
|
rm -f "$scratch/tree/modules/conga/zz_plant.go"
|
|
cp "$scratch/modlist.clean" "$scratch/modlist"
|
|
cp "$scratch/go.mod.clean" "$scratch/go.mod"
|
|
cp "$scratch/README.clean" "$scratch/tree/README.md"
|
|
cp "$ROOT/modules/lighthouse/README.md" "$scratch/tree/modules/lighthouse/README.md"
|
|
cp "$ROOT/modules/flare/README.md" "$scratch/tree/modules/flare/README.md"
|
|
case "$plant" in
|
|
appname-go)
|
|
printf 'package conga\n\n// Płytarium keeps its albums here.\n' >"$scratch/tree/modules/conga/zz_plant.go"
|
|
want="$REASON_APPNAME"
|
|
;;
|
|
appname-readme)
|
|
printf '\nThe fonoteka app uses this.\n' >>"$scratch/tree/modules/lighthouse/README.md"
|
|
want="$REASON_APPNAME"
|
|
;;
|
|
client-gocent)
|
|
printf 'github.com/centrifugal/gocent/v3 v3.3.0\n' >>"$scratch/modlist"
|
|
want="$REASON_CLIENT"
|
|
;;
|
|
client-typesense)
|
|
printf 'github.com/typesense/typesense-go/v3 v3.2.0\n' >>"$scratch/modlist"
|
|
want="$REASON_CLIENT"
|
|
;;
|
|
client-webpush)
|
|
printf 'github.com/SherClockHolmes/webpush-go v1.4.0\n' >>"$scratch/modlist"
|
|
want="$REASON_CLIENT"
|
|
;;
|
|
cron)
|
|
printf '\nrequire github.com/robfig/cron/v3 v3.0.1\n' >>"$scratch/go.mod"
|
|
want="$REASON_CRON"
|
|
;;
|
|
river-version)
|
|
sed -i 's|^github.com/riverqueue/river v0.47.0$|github.com/riverqueue/river v0.46.0|' "$scratch/modlist"
|
|
want="$REASON_RIVER"
|
|
;;
|
|
river-absent)
|
|
sed -i '/^github.com\/riverqueue\/river /d' "$scratch/modlist"
|
|
want="$REASON_RIVER"
|
|
;;
|
|
readme-file)
|
|
rm -f "$scratch/tree/modules/flare/README.md"
|
|
want="$REASON_README"
|
|
;;
|
|
readme-row)
|
|
sed -i '/^| \[beachcomber\](modules\/beachcomber\/README.md) |/d' "$scratch/tree/README.md"
|
|
want="$REASON_README"
|
|
;;
|
|
esac
|
|
if out="$( (hygiene_11 "$scratch/tree" "$scratch/modlist" "$scratch/go.mod") 2>&1)"; then
|
|
echo "refuse: self-test hygiene_11 accepted a planted $plant" >&2
|
|
exit 1
|
|
fi
|
|
if ! grep -qF "$want" <<<"$out"; then
|
|
echo "refuse: self-test hygiene_11 rejected the $plant plant without naming its rule: $out" >&2
|
|
exit 1
|
|
fi
|
|
for reason in "$REASON_APPNAME" "$REASON_CLIENT" "$REASON_CRON" "$REASON_RIVER" "$REASON_README"; do
|
|
if [[ "$reason" != "$want" ]] && grep -qF "$reason" <<<"$out"; then
|
|
echo "refuse: self-test hygiene_11 rejected the $plant plant for another rule: $out" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
done
|
|
# Look-alikes are accepted: English words near the application names,
|
|
# an indirect cron requirement and the pinned River line.
|
|
rm -f "$scratch/tree/modules/conga/zz_plant.go"
|
|
cp "$ROOT/modules/lighthouse/README.md" "$scratch/tree/modules/lighthouse/README.md"
|
|
cp "$ROOT/modules/flare/README.md" "$scratch/tree/modules/flare/README.md"
|
|
cp "$scratch/modlist.clean" "$scratch/modlist"
|
|
cp "$scratch/README.clean" "$scratch/tree/README.md"
|
|
cp "$scratch/go.mod.clean" "$scratch/go.mod"
|
|
printf 'package conga\n\n// A display tariff for the acme collection of vinyl albums and playlists.\n' >"$scratch/tree/modules/conga/zz_plant.go"
|
|
printf '\nrequire github.com/robfig/cron/v3 v3.0.1 // indirect\n' >>"$scratch/go.mod"
|
|
printf 'github.com/acme/webhooks v1.0.0\n' >>"$scratch/modlist"
|
|
(hygiene_11 "$scratch/tree" "$scratch/modlist" "$scratch/go.mod") >/dev/null 2>&1 || {
|
|
echo "refuse: self-test hygiene_11 rejected a clean look-alike" >&2
|
|
exit 1
|
|
}
|
|
|
|
# The removal harness refuses an anchor that is not unique, restores
|
|
# the file byte for byte, and reports a mutation whose test passes.
|
|
local fake="$scratch/fake"
|
|
mkdir -p "$fake/modules/acme"
|
|
printf 'module example.test/acme\n\ngo 1.27\n' >"$fake/go.mod"
|
|
printf 'package acme\n\nfunc Guard(n int) bool {\n\tif n > 3 {\n\t\treturn false\n\t}\n\treturn true\n}\n' >"$fake/modules/acme/acme.go"
|
|
printf 'package acme\n\nimport "testing"\n\nfunc TestGuard(t *testing.T) {\n\tif Guard(4) {\n\t\tt.Fatal("guard removed")\n\t}\n}\n\nfunc TestOther(t *testing.T) {}\n' >"$fake/modules/acme/acme_test.go"
|
|
cp "$fake/modules/acme/acme.go" "$scratch/acme.go.saved"
|
|
local table="$scratch/table.json"
|
|
printf '[["RC-T1","T-X","root","modules/acme/acme.go","if n > 3 {","if false {","./modules/acme","^TestGuard$"]]' >"$table"
|
|
out="$(removal_harness_in "$fake" "$table" 2>&1)" || {
|
|
echo "refuse: self-test removal harness did not catch a guarded mutation: $out" >&2
|
|
exit 1
|
|
}
|
|
grep -q "RC-T1 T-X modules/acme/acme.go: fails as required" <<<"$out" || {
|
|
echo "refuse: self-test removal harness output: $out" >&2
|
|
exit 1
|
|
}
|
|
cmp -s "$fake/modules/acme/acme.go" "$scratch/acme.go.saved" || {
|
|
echo "refuse: self-test removal harness did not restore the file" >&2
|
|
exit 1
|
|
}
|
|
printf '[["RC-T2","T-X","root","modules/acme/acme.go","if n > 3 {","if false {","./modules/acme","^TestOther$"]]' >"$table"
|
|
if out="$(removal_harness_in "$fake" "$table" 2>&1)"; then
|
|
echo "refuse: self-test removal harness accepted a mutation whose test passes: $out" >&2
|
|
exit 1
|
|
fi
|
|
grep -q "RC-T2 T-X modules/acme/acme.go: SURVIVED" <<<"$out" || {
|
|
echo "refuse: self-test removal harness refused a surviving mutation for the wrong reason: $out" >&2
|
|
exit 1
|
|
}
|
|
printf '[["RC-T3","T-X","root","modules/acme/acme.go","return","x","./modules/acme","^TestGuard$"]]' >"$table"
|
|
if out="$(removal_harness_in "$fake" "$table" 2>&1)"; then
|
|
echo "refuse: self-test removal harness accepted a non-unique anchor" >&2
|
|
exit 1
|
|
fi
|
|
grep -q "anchor occurs 2 times" <<<"$out" || {
|
|
echo "refuse: self-test removal harness refused a non-unique anchor for the wrong reason: $out" >&2
|
|
exit 1
|
|
}
|
|
cmp -s "$fake/modules/acme/acme.go" "$scratch/acme.go.saved" || {
|
|
echo "refuse: self-test removal harness left a mutation behind" >&2
|
|
exit 1
|
|
}
|
|
echo "phase11 self-test passed"
|
|
}
|
|
|
|
# removal_harness_in ROOT TABLE runs the harness against another root.
|
|
removal_harness_in() {
|
|
local root="$1" table="$2"
|
|
(
|
|
ROOT="$root"
|
|
APP="$root"
|
|
export GOWORK=off GOFLAGS=-mod=mod
|
|
removal_harness "$table"
|
|
)
|
|
}
|
|
|
|
run_go() {
|
|
(cd "$ROOT" && go vet ./...)
|
|
phase11_go "$ROOT" ./...
|
|
phase11_go "$ROOT" -race ./modules/lighthouse/... ./modules/beachcomber/... ./modules/flare
|
|
PHASE11_REQUIRE="TestListenPickupLatency" phase11_go "$ROOT" ./modules/conga -run '^TestListenPickupLatency$' -count=3
|
|
echo "phase11 go passed"
|
|
}
|
|
|
|
run_postgres() {
|
|
(cd "$APP" && go vet ./... "${APP_PLUGINS[@]}")
|
|
PHASE11_ALLOW="$KNOWN_APP_FAILURES" PHASE11_EXPECT_SKIP="$GOLDEN_SKIPS" PHASE11_SKIP_TEXT="$GOLDEN_SKIP_TEXT" \
|
|
phase11_go "$APP" ./... "${APP_PLUGINS[@]}"
|
|
echo "phase11 postgres passed"
|
|
}
|
|
|
|
# run_named runs every test named in 11-VALIDATION.md (and the plan's
|
|
# per-requirement tests) by exact name, per package.
|
|
run_named() {
|
|
phase11_tests "$ROOT" ./modules/conga TestListenPickupLatency TestDispatchTransactional \
|
|
TestOutcomeComplete TestOutcomeFailFinalAttemptOnly TestOutcomePanicBecomesError TestOutcomeSkipIsCompleteWithMetadata \
|
|
TestCancelQueuedNeverRuns TestCancelRunningCancelsCtx TestStopJobFromWorker TestManagerPHPSemantics \
|
|
TestQueueClear TestQueueWork TestScheduleNext TestScheduleEntries TestScheduleRunsCommand TestScheduleRunOnce \
|
|
TestScheduledEntryMismatchSkipped TestScheduleUniqueByPeriod TestScheduleRunForeground TestScheduleValidation \
|
|
TestScheduleOrdering TestScheduleMissingCatalog TestScheduleLogWriter TestScheduleDueAt
|
|
phase11_tests "$ROOT" ./modules/bonfire TestCall TestCallEdges
|
|
phase11_tests "$ROOT" ./modules/lagoon TestOnDatabaseAfterActivate TestQueueMigrationsUpDown TestTransactionAfterCommit
|
|
phase11_tests "$ROOT" ./modules/lighthouse TestBroadcastTx TestSuppression TestBulkEmitsOnce TestBroadcastEdges \
|
|
TestBroadcastSwallowedReadFailure TestMountSurfaces TestChannelIDMatchesPHP TestParseChannel TestRegistry
|
|
phase11_tests "$ROOT" ./modules/lighthouse/centrifugo TestTokenClaims TestTokenHandler TestClientRequests TestProxy TestHealthCommand
|
|
phase11_tests "$ROOT" ./modules/beachcomber TestSyncGates TestSyncAfterCommit TestSyncDeleteAndSoftDelete TestSyncFailuresNonFatal
|
|
phase11_tests "$ROOT" ./modules/beachcomber/typesense TestEngineWire TestSyncEngineRegistration
|
|
phase11_tests "$ROOT" ./modules/flare TestRFC8291AppendixA TestVAPIDHeader TestSendAllowlist TestSendStatuses
|
|
phase11_tests "$ROOT" ./modules/tide TestCentrifugoRecorder TestNormalizePublications TestDiffPublications
|
|
phase11_tests "$APP" ./plugins/golem15/fonoteka TestWsAuthorizer TestAlbumBroadcastBinding TestAlbumSearchable \
|
|
TestFonotekaScheduleSkipsUnregisteredPrune TestRealtimeSubscribeProxy TestAlbumBroadcastSmoke TestAlbumSearchSmoke
|
|
PHASE11_REQUIRE="TestBroadcastGoldens TestBroadcastGoldens/deleted TestBroadcastGoldens/bulk" \
|
|
PHASE11_EXPECT_SKIP="$GOLDEN_SKIPS" PHASE11_SKIP_TEXT="$GOLDEN_SKIP_TEXT" \
|
|
phase11_go "$APP" ./parity -run '^TestBroadcastGoldens$'
|
|
echo "phase11 named passed"
|
|
}
|
|
|
|
run_evidence() {
|
|
[[ -f "$REVIEW" && -f "$VALIDATION" ]] || {
|
|
echo "refuse: security review or validation file is missing" >&2
|
|
exit 1
|
|
}
|
|
python3 - "$REVIEW" "$VALIDATION" "$PHASE_DIR" <<'PY'
|
|
import glob, os, re, sys
|
|
review = open(sys.argv[1]).read()
|
|
validation = open(sys.argv[2]).read()
|
|
declared = {}
|
|
for plan in sorted(glob.glob(os.path.join(sys.argv[3], "11-0*-PLAN.md"))):
|
|
for line in open(plan):
|
|
m = re.match(r"^\| (T-11-(?:\d\d|SC)) \|", line)
|
|
if m:
|
|
cells = [c.strip().lower() for c in line.strip().strip("|").split("|")]
|
|
declared.setdefault(m.group(1), cells)
|
|
if "T-11-SC" not in declared:
|
|
print("refuse: no plan declares T-11-SC", file=sys.stderr)
|
|
sys.exit(1)
|
|
lines = review.splitlines()
|
|
removal = [l for l in lines if re.match(r"^\| RC-\d+ \| T-11-", l)]
|
|
for tid, cells in sorted(declared.items()):
|
|
rows = [l for l in lines if l.startswith("| " + tid + " |")]
|
|
if len(rows) != 1:
|
|
print(f"refuse: review has {len(rows)} threat rows for {tid}, want 1", file=sys.stderr)
|
|
sys.exit(1)
|
|
row = [c.strip().lower() for c in rows[0].strip().strip("|").split("|")]
|
|
severity, disposition = cells[3], cells[4]
|
|
if severity not in row or disposition not in row:
|
|
print(f"refuse: review row {tid} does not copy severity {severity!r} and disposition {disposition!r}", file=sys.stderr)
|
|
sys.exit(1)
|
|
if severity == "high" and disposition == "mitigate":
|
|
if not re.search(r"Test[A-Z][A-Za-z0-9]+|check-phase11\.sh", rows[0]):
|
|
print(f"refuse: high threat {tid} names no failing-when-broken test or gate stage", file=sys.stderr)
|
|
sys.exit(1)
|
|
if not any(re.match(r"^\| RC-\d+ \| " + re.escape(tid) + r" \|", l) for l in removal):
|
|
print(f"refuse: high threat {tid} has no removal check row", file=sys.stderr)
|
|
sys.exit(1)
|
|
if not re.search(r"^nyquist_compliant: true$", validation, re.M):
|
|
print("refuse: validation is not nyquist_compliant", file=sys.stderr)
|
|
sys.exit(1)
|
|
if not re.search(r"^status: validated$", validation, re.M):
|
|
print("refuse: validation status is not validated", file=sys.stderr)
|
|
sys.exit(1)
|
|
for line in validation.splitlines():
|
|
if line.startswith("|") and ("pending" in line.lower() or "| TBD |" in line):
|
|
print("refuse: validation row still pending: " + line, file=sys.stderr)
|
|
sys.exit(1)
|
|
for req in ["JOBS-01", "CLI-04", "CLI-06", "RT-01", "RT-02", "RT-03", "SRCH-01"]:
|
|
if req not in validation:
|
|
print(f"refuse: validation does not name {req}", file=sys.stderr)
|
|
sys.exit(1)
|
|
print("phase11 evidence passed")
|
|
PY
|
|
}
|
|
|
|
case "${1:-}" in
|
|
--self-test) run_self_test ;;
|
|
--hygiene) run_hygiene ;;
|
|
--go) run_go ;;
|
|
--postgres) run_postgres ;;
|
|
--named) run_named ;;
|
|
--evidence) run_evidence ;;
|
|
--removal) run_removal ;;
|
|
--all)
|
|
run_self_test
|
|
run_hygiene
|
|
run_go
|
|
run_postgres
|
|
run_named
|
|
run_evidence
|
|
echo "phase11 all passed"
|
|
;;
|
|
*) usage ;;
|
|
esac
|