- scripts/check-phase11.sh: --self-test, --hygiene, --go, --postgres, --named, --evidence, --all (prints 'phase11 all passed') and --removal - the go test -json detector refuses failures, skips, zero tests and 'no tests to run'; only the two Phase 12 broadcast goldens may skip, and only with their pending text - hygiene refuses application names in the Phase 11 framework files, the Centrifugo/Typesense/Web Push client libraries, a direct cron requirement, River other than v0.47.0 and a module without README or root row; each rule returns on its first violation and the self-test proves each refuses its own plant and accepts look-alikes - --removal: anchor-exact mutations for the high threats, each required to fail its named test on an assertion and restored byte for byte (cmp)
737 lines
32 KiB
Bash
Executable File
737 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"]
|
|
]
|
|
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
|