#!/usr/bin/env bash # Phase 12.1 fail-closed gate (admin bulk and record actions, preview screen, # row state, permission editor, form seams, and the user plugin's admin # screens built on them). # # Every stage exits non-zero on a failing command, a go test run that fails, # skips, matches zero tests or does not build, a named security test that is # missing, renamed or skipped, OpenAPI or dist drift, a docs checker problem, # a consuming-application name in a framework file, a coverage number below # the floor, and an evidence gap. --self-test proves each detector fails # closed on planted input. # # Framework commands run in this repository. The plugin's tests run inside # the application workspace named by PHASE121_APP (default: the sibling # checkout next to this repository); --app runs every module of that # workspace. Output about the application workspace has the application's # name masked; set PHASE121_VERBOSE=1 to see it as it is while debugging. # # --removal edits tracked source while it runs (and restores it byte for # byte), so it is not part of --all. # Run with FORCE_COLOR unset: bonfire's colour tests read it. set -euo pipefail ROOT="${PHASE121_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" APP="${PHASE121_APP:-$ROOT/../fonoteka.go}" # The user plugin inside the application workspace. PLUGIN="./plugins/golem15/user" APP_NAMES='fonoteka|p[lł]ytarium' PHASE_DIR="${PHASE121_PHASE_DIR:-$ROOT/.planning/phases/12.1-user-plugin-admin-screens}" REVIEW="$PHASE_DIR/12.1-SECURITY-REVIEW.md" VALIDATION="$PHASE_DIR/12.1-VALIDATION.md" COVERAGE_FLOOR=80 # The framework tag the phase's contracts were released under. RELEASE_TAG=v0.1.3 # The named tests of the security stage, by prefix. Each prefix must match # at least one top-level test that passes; any skip refuses. SECURITY_CABANA=(TestPhase121Threats TestBulkAction TestRecordAction TestRowState TestForbidden TestSoftDeletedRecord TestPreview TestPasswordField TestVirtualFields TestFormRules TestPermissionEditor TestRelationLock TestWritableForeignKey TestInvisibleColumn TestFilterOptionsController TestPhase121BootErrors) SECURITY_PLUGIN=(TestPhase121Threats TestAdminPrivilegedGroups TestAdminPrivilegedMember TestAdminUserGroupsField TestAdminUserActions TestAdminUserForceDelete TestAdminUserPassword TestAdminUserInvite TestAdminAvatarSharedWithAPI TestAdminGroups TestAdminOrganisations TestAdminOrganisationMembers TestLastSeen TestAdminUsersEdge TestAdminUsersControllerGuards TestAdminUsersGroupFilterValue TestAdminRegistration TestAdminNeedsSecret) SECURITY_PLUGIN_CLASSES=(TestMergedPermissions TestPermissionSetScan TestIsPrivilegedMember TestPrivilegedGroupCodes TestAdminActions) # Framework files this phase added or changed; the hygiene stage refuses a # consuming-application name in them, in the root README, in every module # README, in docs and in the SPA sources and tests. PHASE_FILES=( modules/cabana/testdata/roster modules/cabana/actions.go modules/cabana/crud.go modules/cabana/field_permission.go modules/cabana/relation_field.go modules/cabana/form_schema.go modules/cabana/list_schema.go modules/cabana/tx_context.go modules/cabana/example_actions_test.go modules/cabana/example_rowstate_test.go modules/cabana/example_form_seams_test.go modules/cabana/phase121_fixture_test.go modules/cabana/phase121_actions_test.go modules/cabana/phase121_form_test.go modules/cabana/phase121_threats_test.go modules/cabana/phase121_bulk_test.go modules/cabana/phase121_record_test.go modules/cabana/phase121_rowstate_test.go modules/cabana/phase121_forbidden_test.go modules/cabana/phase121_preview_test.go modules/cabana/phase121_fields_test.go modules/cabana/phase121_permission_test.go modules/cabana/phase121_relation_lock_test.go modules/cabana/phase121_list_test.go modules/cabana/phase121_schema_boot_test.go modules/pact/capabilities.go modules/pact/capabilities_test.go admin/tests ) HYGIENE_DOCS=(README.md docs admin/src) # Dependency manifests: the phase adds and changes no module or package. MANIFESTS=(go.mod go.sum admin/package.json admin/package-lock.json) # High or critical mitigated threats without a removal row. Each needs an # "| NR-nn | | |" row in the review instead; any other # high or critical mitigated threat needs an "| RC-nn | |" row. NO_REMOVAL="T-12.1-17 T-12.1-SC" STAGES=(self-test go security removal coverage spa openapi dist docs hygiene app evidence all) usage() { cat >&2 <<'EOF' usage: check-phase12.1.sh --self-test check-phase12.1.sh --go check-phase12.1.sh --security check-phase12.1.sh --removal check-phase12.1.sh --coverage check-phase12.1.sh --spa check-phase12.1.sh --openapi check-phase12.1.sh --dist check-phase12.1.sh --docs check-phase12.1.sh --hygiene check-phase12.1.sh --app check-phase12.1.sh --evidence check-phase12.1.sh --all (every stage except --removal) environment: PHASE121_APP the application workspace (default: the sibling checkout) PHASE121_VERBOSE 1 shows application output without masking its name PHASE121_RC removal rows to run, space separated (default: all) EOF exit 2 } # mask hides the application's name in output about its workspace. mask() { if [[ "${PHASE121_VERBOSE:-}" == "1" ]]; then cat else sed -E "s/($APP_NAMES)(\.go)?//gI" fi } # where DIR names a directory in output: the application workspace is never # printed by its path. where() { case "$1" in "$APP") echo "the application workspace" ;; "$APP"/*) echo "the application workspace (${1#"$APP"/})" ;; *) echo "${1#"$ROOT"/}" ;; esac } # detect reads go test -json. Exit 1 fail or build failure, 2 skip, 3 zero # tests or "no tests to run", 4 non-JSON, 5 a required prefix has no passing # top-level test. REQUIRE_PREFIXES lists the prefixes. ALLOW_EMPTY=1 accepts # packages without tests (a whole-module run) but still refuses a run in # which no test passed at all. detect() { python3 - "$1" <<'PY' import json, os, sys path = sys.argv[1] prefixes = os.environ.get("REQUIRE_PREFIXES", "").split() allow_empty = os.environ.get("ALLOW_EMPTY", "") == "1" passed = set() failed = [] 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 ev.get("ImportPath") or "" if action == "build-fail" or (action == "fail" and ev.get("FailedBuild")): print(f"refuse: build failed {pkg}", file=sys.stderr) sys.exit(1) if action == "output" and "no tests to run" in (ev.get("Output") or "") and not allow_empty: print(f"refuse: no tests to run in {pkg}", file=sys.stderr) sys.exit(3) if action == "skip" and test: print(f"refuse: skipped {pkg} {test}", file=sys.stderr) sys.exit(2) if action == "fail": failed.append(f"{pkg} {test}".strip()) if action == "pass" and test: passed.add(test) if failed: print("refuse: failed " + ", ".join(failed), file=sys.stderr) sys.exit(1) if not passed: print("refuse: zero tests", file=sys.stderr) sys.exit(3) top = {name for name in passed if "/" not in name} missing = [p for p in prefixes if not any(name.startswith(p) for name in top)] if missing: print("refuse: missing named test: no passing test for " + ", ".join(missing), file=sys.stderr) sys.exit(5) PY } # go_json DIR [go test args...] runs go test -json -count=1 through detect. go_json() { local dir="$1" shift local log err out rc=0 dc=0 log="$(mktemp)" err="$(mktemp)" out="$(mktemp)" (cd "$dir" && go test -json -count=1 "$@") >"$log" 2>"$err" || rc=$? detect "$log" 2>"$out" || dc=$? if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then { cat "$err" || true grep -v '^{' "$log" | tail -n 20 || true python3 - "$log" <<'PY' || true import json, sys for raw in open(sys.argv[1], encoding="utf-8", errors="replace"): try: ev = json.loads(raw) except ValueError: continue text = ev.get("Output") or "" if ev.get("Action") == "output" and ("--- FAIL" in text or "_test.go:" in text or "panic:" in text): sys.stdout.write(text) PY cat "$out" || true echo "refuse: go test $* in $(where "$dir") (test=$rc detect=$dc)" } 2>&1 | mask | tail -n 80 >&2 rm -f "$log" "$err" "$out" return 1 fi rm -f "$log" "$err" "$out" } # named DIR PKG PREFIX... runs the tests matching the prefixes verbosely and # requires a passing top-level test for each one. named() { local dir="$1" pkg="$2" shift 2 local regex regex="^($( IFS='|' echo "$*" ))" REQUIRE_PREFIXES="$*" go_json "$dir" "$pkg" -v -run "$regex" } expect_detect() { local name="$1" want="$2" payload="$3" log dc=0 log="$(mktemp)" printf '%s\n' "$payload" >"$log" 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 return 1 fi } need_app() { [[ -d "$APP" && -f "$APP/go.work" ]] || { echo "refuse: the application workspace was not found (set PHASE121_APP)" >&2 return 1 } } # workspace_modules prints the module directories of the application's # go.work, relative to the workspace, one per line. workspace_modules() { python3 - "$1/go.work" <<'PY' import re, sys text = open(sys.argv[1]).read() dirs = [] block = re.search(r"^use\s*\((.*?)^\)", text, re.S | re.M) if block: dirs += [line.split("//")[0].strip() for line in block.group(1).splitlines()] dirs += re.findall(r"^use\s+([^\s(]+)\s*$", text, re.M) dirs = [d.strip('"') for d in dirs if d] if not dirs: print("refuse: go.work names no module", file=sys.stderr) sys.exit(1) print("\n".join(dirs)) PY } run_go() { (cd "$ROOT" && go vet ./...) ALLOW_EMPTY=1 go_json "$ROOT" ./... echo "phase12.1 go passed" } run_security() { need_app named "$ROOT" ./modules/cabana "${SECURITY_CABANA[@]}" named "$APP" "$PLUGIN" "${SECURITY_PLUGIN[@]}" named "$APP" "$PLUGIN/classes" "${SECURITY_PLUGIN_CLASSES[@]}" echo "phase12.1 security passed" } # coverage_report FLOOR PROFILE... prints one line per package of the merged # profiles (a block counts as covered when any profile covered it) and # refuses any package below FLOOR percent. coverage_report() { python3 - "$@" <<'PY' import collections, sys floor = float(sys.argv[1]) blocks = {} for path in sys.argv[2:]: for line in open(path): if line.startswith("mode:") or not line.strip(): continue loc, n, c = line.rsplit(" ", 2) n, c = int(n), int(c) prev = blocks.get(loc, (n, 0)) blocks[loc] = (n, max(prev[1], c)) total, covered = collections.Counter(), collections.Counter() for loc, (n, c) in blocks.items(): pkg = loc.split(":")[0].rsplit("/", 1)[0] total[pkg] += n if c: covered[pkg] += n if not total: print("refuse: coverage profile is empty", file=sys.stderr) sys.exit(1) low = [] for pkg in sorted(total): pct = 100.0 * covered[pkg] / total[pkg] print(f"coverage {pkg} {pct:.1f}% ({covered[pkg]}/{total[pkg]} statements)") if pct < floor: low.append(f"{pkg} {pct:.1f}%") if low: print(f"refuse: below the {floor:.0f}% coverage floor: " + ", ".join(low), file=sys.stderr) sys.exit(1) PY } # cover_profile DIR OUT ARGS... writes a coverage profile of go test ARGS. cover_profile() { local dir="$1" out="$2" shift 2 local log log="$(mktemp)" if ! (cd "$dir" && go test -count=1 -coverprofile="$out" "$@") >"$log" 2>&1; then grep -E '^(--- FAIL|FAIL|panic:|\s+\S+_test\.go:[0-9]+)' "$log" | mask | tail -n 40 >&2 rm -f "$log" echo "refuse: go test -coverprofile in $(where "$dir")" >&2 return 1 fi rm -f "$log" } run_coverage() { need_app local dir dir="$(mktemp -d)" trap 'rm -rf "$dir"' RETURN # Framework packages: each package's own tests. cover_profile "$ROOT" "$dir/pact.out" ./modules/pact cover_profile "$ROOT" "$dir/cabana.out" ./modules/cabana coverage_report "$COVERAGE_FLOOR" "$dir/pact.out" "$dir/cabana.out" # The plugin's packages: every test of the plugin that exercises them. cover_profile "$APP" "$dir/plugin.out" "$PLUGIN/..." \ "-coverpkg=$PLUGIN,$PLUGIN/classes,$PLUGIN/controllers,$PLUGIN/models,$PLUGIN/updates" coverage_report "$COVERAGE_FLOOR" "$dir/plugin.out" | mask echo "phase12.1 coverage passed" } run_spa() { npm --prefix "$ROOT/admin" run typecheck local log rc=0 log="$(mktemp)" npm --prefix "$ROOT/admin" test >"$log" 2>&1 || rc=$? if [[ "$rc" -ne 0 ]] || grep -qE 'No test files found|Unhandled (Errors|Rejection)|FAIL ' "$log"; then tail -n 60 "$log" >&2 rm -f "$log" echo "refuse: admin Vitest run failed (exit $rc)" >&2 return 1 fi grep -E 'Test Files|Tests ' "$log" || true rm -f "$log" # The five UI backstops of the UI-SPEC are named test cases. local needle for needle in \ 'backstop: focus returns to the bulk menu trigger' \ 'backstop: mapWinterUrl maps preview/:id' \ 'backstop: a row state outside the fixed set' \ 'backstop: radio mode emits 1 and -1' \ 'backstop: a locked option cannot be chosen'; do grep -rqF -- "$needle" "$ROOT/admin/tests" || { echo "refuse: missing named test: no vitest case titled \"$needle\"" >&2 return 1 } done if grep -rnE "\b(it|describe|test)\.(skip|todo|only)\(" "$ROOT/admin/tests" >&2; then echo "refuse: a skipped, todo or exclusive vitest case" >&2 return 1 fi echo "phase12.1 spa passed" } run_openapi() { "$ROOT/scripts/check-admin-openapi.sh" --check named "$ROOT" ./modules/cabana TestPhase10OpenAPIConformance TestPhase09ContractInventory TestPhase09PermissionMatrix echo "phase12.1 openapi passed" } run_dist() { "$ROOT/scripts/check-admin-dist.sh" echo "phase12.1 dist passed" } run_docs() { go_json "$ROOT" ./cmd/summer -run '^TestDocsTree$' local out out="$(cd "$ROOT" && go run ./cmd/summer docs:build --check 2>&1)" || { echo "$out" >&2 echo "refuse: docs:build --check failed" >&2 return 1 } go_json "$ROOT" ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' echo "phase12.1 docs passed" } # hygiene_scan ROOT PATH... prints every line that names a consuming # application; the planning tree and this script are never scanned. hygiene_scan() { local root="$1" shift (cd "$root" && grep -rniIE "$APP_NAMES" "$@" 2>/dev/null || true) } run_hygiene() { local bad=0 hits path for path in "${PHASE_FILES[@]}"; do [[ -e "$ROOT/$path" ]] || { echo "refuse: hygiene: $path is listed and does not exist" >&2 bad=1 } done local readmes=() mapfile -t readmes < <(cd "$ROOT" && ls modules/*/README.md) [[ "${#readmes[@]}" -gt 0 ]] || { echo "refuse: hygiene: no module README was found" >&2 bad=1 } hits="$(hygiene_scan "$ROOT" "${HYGIENE_DOCS[@]}" "${readmes[@]}" "${PHASE_FILES[@]}")" if [[ -n "$hits" ]]; then echo "refuse: hygiene: consuming-application names in the framework:" >&2 cut -d: -f1,2 <<<"$hits" | sort -u | head -n 20 >&2 bad=1 fi # The acme.roster fixture lives only in _test.go files and testdata. hits="$(cd "$ROOT" && grep -rlnE 'acme\.roster|rosterPlugin' --include='*.go' modules cmd 2>/dev/null | grep -vE '_test\.go$' || true)" if [[ -n "$hits" ]]; then echo "refuse: hygiene: the test fixture plugin is referenced by production code: $hits" >&2 bad=1 fi hits="$(cd "$ROOT" && grep -rnE 'v-html|innerHTML|outerHTML|insertAdjacentHTML' admin/src 2>/dev/null || true)" if [[ -n "$hits" ]]; then echo "refuse: hygiene: raw-HTML sink in admin/src: $hits" >&2 bad=1 fi hits="$(cd "$ROOT" && gofmt -l modules/cabana modules/pact 2>/dev/null || true)" if [[ -n "$hits" ]]; then echo "refuse: hygiene: gofmt: $hits" >&2 bad=1 fi # T-12.1-SC: no module and no package was added or changed since the tag. if git -C "$ROOT" rev-parse -q --verify "refs/tags/$RELEASE_TAG" >/dev/null; then hits="$(git -C "$ROOT" diff --name-only "$RELEASE_TAG" HEAD -- "${MANIFESTS[@]}")" if [[ -n "$hits" || -n "$(git -C "$ROOT" status --porcelain -- "${MANIFESTS[@]}")" ]]; then echo "refuse: hygiene: a dependency manifest changed since $RELEASE_TAG: $hits" >&2 bad=1 fi else echo "refuse: hygiene: the tag $RELEASE_TAG does not exist in this checkout" >&2 bad=1 fi [[ "$bad" -eq 0 ]] || return 1 echo "phase12.1 hygiene passed" } run_app() { need_app local dir modules modules="$(workspace_modules "$APP")" (cd "$APP" && go build ./...) 2>&1 | mask >&2 while IFS= read -r dir; do [[ -d "$APP/$dir" && -f "$APP/$dir/go.mod" ]] || { echo "refuse: workspace module $(where "$APP/$dir") has no go.mod" >&2 return 1 } if ! (cd "$APP/$dir" && go vet ./...) >/dev/null 2>"$APP_ERR"; then mask <"$APP_ERR" | tail -n 40 >&2 echo "refuse: go vet in $(where "$APP/$dir")" >&2 return 1 fi ALLOW_EMPTY=1 go_json "$APP/$dir" ./... echo "app module $(where "$APP/$dir" | mask): vet and tests passed" done <<<"$modules" # The schema and the user API contract are named: they must have run. REQUIRE_PREFIXES="TestSchemaMatchesPHPSnapshot TestUserAPINuxtFlows TestParityCorpus" \ go_json "$APP" ./parity -v -run '^(TestSchemaMatchesPHPSnapshot|TestUserAPINuxtFlows|TestParityCorpus)$' local pointer head if [[ -n "$(git -C "$APP" status --porcelain)" ]]; then git -C "$APP" status --short | mask >&2 echo "refuse: the application workspace has uncommitted changes" >&2 return 1 fi if [[ -n "$(git -C "$APP/$PLUGIN" status --porcelain)" ]]; then git -C "$APP/$PLUGIN" status --short >&2 echo "refuse: the user plugin checkout has uncommitted changes" >&2 return 1 fi pointer="$(git -C "$APP" rev-parse "HEAD:${PLUGIN#./}")" head="$(git -C "$APP/$PLUGIN" rev-parse HEAD)" if [[ "$pointer" != "$head" ]]; then echo "refuse: the application records plugin commit $pointer, the plugin checkout is at $head" >&2 return 1 fi echo "phase12.1 app passed" } # removal_table: one row per protection. Fields: id, threat, repo # (root|plugin), file, anchor, replacement, test dir (root|app), package, # test regex. An anchor must occur exactly once in its file. removal_table() { cat <<'EOF' [ ["RC-01", "T-12.1-01", "root", "modules/cabana/crud.go", "if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil {\n\t\t\tif next := ext.ListExtendQuery(ctx, q); next != nil {\n\t\t\t\tq = next\n\t\t\t}\n\t\t}\n\t}\n\tcol := primaryColumn(proto)\n\tvals := make([]any, len(ids))", "if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil && false {\n\t\t\tif next := ext.ListExtendQuery(ctx, q); next != nil {\n\t\t\t\tq = next\n\t\t\t}\n\t\t}\n\t}\n\tcol := primaryColumn(proto)\n\tvals := make([]any, len(ids))", "root", "./modules/cabana", "^TestPhase121Threats$/^T-12.1-01$"], ["RC-02", "T-12.1-01", "root", "modules/cabana/crud.go", "\t\tif len(rows) != len(ids) {\n\t\t\treturn partialSelection{}\n\t\t}\n\t\tout, err := action.Run(ctx, pact.AdminBulkActionInput{Records: rows})", "\t\tout, err := action.Run(ctx, pact.AdminBulkActionInput{Records: rows})", "root", "./modules/cabana", "^TestPhase121Threats$/^T-12.1-01$"], ["RC-03", "T-12.1-02", "root", "modules/cabana/actions.go", "if Allows(principal, permissions) {\n\t\treturn true\n\t}\n\tvar adminID uint", "if true || Allows(principal, permissions) {\n\t\treturn true\n\t}\n\tvar adminID uint", "root", "./modules/cabana", "^TestPhase121Threats$/^T-12.1-02$"], ["RC-04", "T-12.1-03", "root", "modules/cabana/http.go", "requireAjax(s.bulkAction)", "s.bulkAction", "root", "./modules/cabana", "^TestPhase121Threats$/^T-12.1-03$"], ["RC-05", "T-12.1-03", "root", "modules/cabana/http.go", "requireAjax(s.recordAction)", "s.recordAction", "root", "./modules/cabana", "^TestPhase121Threats$/^T-12.1-03$"], ["RC-06", "T-12.1-04", "root", "modules/cabana/crud.go", "if err := loadRecord(ctx, tx, cc, target, pk); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif action.Applies != nil {", "if err := loadRecord(ctx, tx, nil, target, pk); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif action.Applies != nil {", "root", "./modules/cabana", "^TestPhase121Threats$/^T-12.1-04$"], ["RC-07", "T-12.1-04", "root", "modules/cabana/crud.go", "\t\t\tif !applies {\n\t\t\t\treturn actionConflict{}\n\t\t\t}\n\t\t}\n\t\tout, err := action.Run(ctx, pact.AdminRecordActionInput{", "\t\t\tif false && !applies {\n\t\t\t\treturn actionConflict{}\n\t\t\t}\n\t\t}\n\t\tout, err := action.Run(ctx, pact.AdminRecordActionInput{", "root", "./modules/cabana", "^TestPhase121Threats$/^T-12.1-04$"], ["RC-08", "T-12.1-09", "root", "modules/cabana/crud.go", "\t\tif !cc.virtual[field.Name] || !contextAllows(cc, field.Name, op) {\n\t\t\tcontinue\n\t\t}", "\t\tif !cc.virtual[field.Name] {\n\t\t\tcontinue\n\t\t}", "root", "./modules/cabana", "^(TestPhase121Threats$/^T-12.1-09|TestVirtualFields)"], ["RC-09", "T-12.1-09", "root", "modules/cabana/crud.go", "|| protectedFillKey(field.Name) || cc.virtual[field.Name] {", "|| protectedFillKey(field.Name) {", "root", "./modules/cabana", "^TestPhase121Threats$/^T-12.1-09$"], ["RC-10", "T-12.1-10", "root", "modules/cabana/crud.go", "|| protectedFillKey(field.Name) || cc.virtual[field.Name] {", "|| protectedFillKey(field.Name) {", "root", "./modules/cabana", "^TestPhase121Threats$/^T-12.1-10$"], ["RC-11", "T-12.1-11", "root", "modules/cabana/relation_field.go", "out.ReadOnly = protectedFillKey(contract.ForeignKey) && !contract.WritableForeignKey", "out.ReadOnly = false", "root", "./modules/cabana", "^TestPhase121Threats$/^T-12.1-11$"], ["RC-12", "T-12.1-12", "root", "modules/cabana/crud.go", "if err := checkRelationLocks(ctx, tx, cc, target, relations); err != nil {", "if err := checkRelationLocks(ctx, tx, cc, target, nil); err != nil {", "root", "./modules/cabana", "^TestPhase121Threats$/^T-12.1-12$"], ["RC-13", "T-12.1-13", "root", "modules/cabana/field_permission.go", "\t\t\tif _, known := offered[code]; !known {\n\t\t\t\treturn &ValidationError{", "\t\t\tif _, known := offered[code]; false && !known {\n\t\t\t\treturn &ValidationError{", "root", "./modules/cabana", "^TestPhase121Threats$/^T-12.1-13$"], ["RC-14", "T-12.1-13", "root", "modules/cabana/field_permission.go", "if option.Locked && stored[option.Code] != submitted[option.Code] {", "if false && option.Locked && stored[option.Code] != submitted[option.Code] {", "root", "./modules/cabana", "^TestPhase121Threats$/^T-12.1-13$"], ["RC-15", "T-12.1-18", "plugin", "controllers/users_admin_controller.go", "func (usersAdminController) RequiredPermissions() []string {\n\treturn []string{PermissionAccessUsers}", "func (usersAdminController) RequiredPermissions() []string {\n\treturn []string{}", "app", "./plugins/golem15/user", "^TestPhase121Threats$/^T-12.1-18$"], ["RC-16", "T-12.1-19", "plugin", "models/user/columns.yaml", " email:\n label: golem15.user::lang.user.email\n searchable: true\n", " email:\n label: golem15.user::lang.user.email\n searchable: true\n password:\n label: golem15.user::lang.user.email\n", "app", "./plugins/golem15/user", "^TestPhase121Threats$/^T-12.1-19$"], ["RC-17", "T-12.1-20", "plugin", "models/user/fields.yaml", " email:\n label: golem15.user::lang.user.email\n type: text\n span: full\n tab: golem15.user::lang.user.account\n", " email:\n label: golem15.user::lang.user.email\n type: text\n span: full\n tab: golem15.user::lang.user.account\n organisation_role:\n label: golem15.user::lang.user.email\n type: text\n tab: golem15.user::lang.user.account\n", "app", "./plugins/golem15/user", "^TestPhase121Threats$/^T-12.1-20$"], ["RC-18", "T-12.1-24", "plugin", "models/user.go", "LastSeen *time.Time `gorm:\"column:last_seen\" json:\"-\"`", "LastSeen *time.Time `gorm:\"column:last_seen\" json:\"last_seen\"`", "app", "./plugins/golem15/user", "^TestPhase121Threats$/^T-12.1-24$"], ["RC-27", "T-12.1-24", "plugin", "models/user.go", "Permissions PermissionSet `gorm:\"column:permissions\" json:\"-\"`", "Permissions PermissionSet `gorm:\"column:permissions\" json:\"permissions\"`", "app", "./plugins/golem15/user", "^TestPhase121Threats$/^T-12.1-24$"], ["RC-19", "T-12.1-28", "plugin", "controllers/users_admin_controller.go", "return cabana.RelationLock{IDs: ids, Message: usersLang + \"privileged_group_forbidden\"}, nil", "return cabana.RelationLock{IDs: ids[:0], Message: usersLang + \"privileged_group_forbidden\"}, nil", "app", "./plugins/golem15/user", "^TestPhase121Threats$/^T-12.1-28$"], ["RC-20", "T-12.1-29", "plugin", "controllers/usergroups_admin_controller.go", "principal, _ := bouncer.User(ctx)\n\tif cabana.Allows(principal, []string{classes.PermissionManagePrivilegedGroups}) {\n\t\treturn nil\n\t}\n\tconst message = groupsLang", "principal, _ := bouncer.User(ctx)\n\tif true || cabana.Allows(principal, []string{classes.PermissionManagePrivilegedGroups}) {\n\t\treturn nil\n\t}\n\tconst message = groupsLang", "app", "./plugins/golem15/user", "^TestPhase121Threats$/^T-12.1-29$"], ["RC-21", "T-12.1-30", "plugin", "classes/admin_actions.go", "\t\tuser.IsActivated = true\n\t\tuser.ActivatedAt = &now", "\t\tfresh(ctx, db).Exec(\"DELETE FROM users_groups WHERE user_id = ?\", user.ID)\n\t\tuser.IsActivated = true\n\t\tuser.ActivatedAt = &now", "app", "./plugins/golem15/user", "^TestPhase121Threats$/^T-12.1-30$"], ["RC-22", "T-12.1-34", "plugin", "models/user.go", "joinReferences:user_group_id\" json:\"-\"`", "joinReferences:user_group_id\" json:\"groups\"`", "app", "./plugins/golem15/user", "^TestPhase121Threats$/^T-12.1-34$"], ["RC-23", "T-12.1-38", "plugin", "controllers/users_admin_controller.go", "\tif err := c.guardCredentials(ctx, user); err != nil {\n\t\treturn err\n\t}\n", "", "app", "./plugins/golem15/user", "^TestPhase121Threats$/^T-12.1-38$"], ["RC-24", "T-12.1-39", "plugin", "controllers/users_admin_controller.go", "return c.guardPrivilegedMember(ctx, user.ID, usersLang+\"privileged_member_delete_forbidden\", nil)", "return nil", "app", "./plugins/golem15/user", "^TestPhase121Threats$/^T-12.1-39$"], ["RC-25", "T-12.1-38", "plugin", "classes/admin_actions.go", "return db.Session(&gorm.Session{NewDB: true, Context: ctx}).Clauses().Session(&gorm.Session{NewDB: true})", "return db.Session(&gorm.Session{NewDB: true}).WithContext(ctx)", "app", "./plugins/golem15/user/classes", "^TestIsPrivilegedMember$"], ["RC-26", "D-23", "plugin", "models/user.go", "\tid, ok := groupFilterID(value)\n\tif !ok {\n\t\treturn db.Where(\"1 = 0\")\n\t}\n\treturn db.Where(\"users.id IN (SELECT user_id FROM users_groups WHERE user_group_id = ?)\", id)", "\t_, _ = groupFilterID(value)\n\treturn db.Where(\"users.id IN (SELECT user_id FROM users_groups WHERE user_group_id = ?)\", value)", "app", "./plugins/golem15/user", "^TestAdminUsersGroupFilterValue$"] ] EOF } # removal_harness TABLE_FILE: for each row, refuse a file with uncommitted # changes, save it, apply the anchor-exact mutation, run the named test and # require it to fail on an assertion (a build failure does not count), then # restore the file and require cmp to match. removal_harness() { python3 - "$1" "$ROOT" "$APP" "$APP/$PLUGIN" <<'PY' import json, os, shutil, subprocess, sys, tempfile table = json.load(open(sys.argv[1])) bases = {"root": sys.argv[2], "app": sys.argv[3], "plugin": sys.argv[4]} only = set(os.environ.get("PHASE121_RC", "").split()) failures = 0 ran = 0 for rc, threat, repo, rel, anchor, repl, test_repo, pkg, run in table: if only and rc not in only: continue ran += 1 path = os.path.join(bases[repo], rel) where = os.path.dirname(path) tracked = subprocess.run(["git", "-C", where, "rev-parse", "--is-inside-work-tree"], capture_output=True, text=True).returncode == 0 if tracked: dirty = subprocess.run(["git", "-C", where, "status", "--porcelain", "--", os.path.basename(path)], capture_output=True, text=True).stdout.strip() if dirty: print(f"refuse: {rc}: {rel} is dirty; commit or restore it first", file=sys.stderr) sys.exit(1) 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="phase121-rc-") saved = os.path.join(scratch, "saved") shutil.copyfile(path, saved) try: with open(path, "w") as fh: fh.write(mutated) proc = subprocess.run(["go", "test", pkg, "-run", run, "-count=1"], cwd=bases[test_repo], capture_output=True, text=True, timeout=1200) 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}; restored") sys.stdout.flush() if not ok: failures += 1 if ran == 0: print("refuse: no removal row was run", file=sys.stderr) sys.exit(1) if failures: print(f"refuse: {failures} removal check(s) survived", file=sys.stderr) sys.exit(1) PY } run_removal() { need_app local table rc=0 table="$(mktemp)" removal_table >"$table" removal_harness "$table" || rc=$? rm -f "$table" [[ "$rc" -eq 0 ]] || return 1 # Nothing the harness touched may be left modified. local dirty dirty="$(git -C "$ROOT" status --porcelain -- modules; git -C "$APP/$PLUGIN" status --porcelain)" if [[ -n "$dirty" ]]; then echo "refuse: the removal stage left modified files: $dirty" >&2 return 1 fi echo "phase12.1 removal passed" } # removal_harness_in ROOT TABLE runs the harness against another root. removal_harness_in() { local root="$1" table="$2" ( ROOT="$root" APP="$root" PLUGIN="." export GOWORK=off GOFLAGS=-mod=mod removal_harness "$table" ) } # removal_ids prints the "RC-nn threat" pairs of the removal table. removal_ids() { removal_table | python3 -c 'import json,sys for row in json.load(sys.stdin): print(row[0], row[1])' } # evidence_check PHASE_DIR REVIEW VALIDATION NO_REMOVAL RC_PAIRS: every # threat the plans declare has exactly one review row copying its severity # and disposition; a mitigated threat names a test, a vitest file or a gate # stage; a high or critical mitigated threat has a removal row (or, for the # ids in NO_REMOVAL only, an NR row with a reason); every row of the removal # table is in the review; the review reports zero open threats; the # validation file is validated, Nyquist-compliant, keyed by real task ids # and has no pending or TBD row. evidence_check() { python3 - "$@" <<'PY' import glob, os, re, sys phase_dir, review_path, validation_path = sys.argv[1], sys.argv[2], sys.argv[3] no_removal = set(sys.argv[4].split()) rc_pairs = [line.split() for line in sys.argv[5].splitlines() if line.strip()] for p in (review_path, validation_path): if not os.path.isfile(p): print(f"refuse: {os.path.basename(p)} is missing", file=sys.stderr) sys.exit(1) review = open(review_path).read() validation = open(validation_path).read() declared = {} for plan in sorted(glob.glob(os.path.join(phase_dir, "*-0*-PLAN.md"))): for line in open(plan): m = re.match(r"^\| (T-12\.1-(?:\d\d|SC)) \|", line) if m: cells = [c.strip().lower() for c in line.strip().strip("|").split("|")] declared.setdefault(m.group(1), cells) if not declared: print("refuse: no plan declares a T-12.1 threat", file=sys.stderr) sys.exit(1) lines = review.splitlines() names_test = re.compile(r"Test[A-Z][A-Za-z0-9]+|[A-Za-z]+\.test\.ts|check-phase12\.1\.sh --[a-z-]+") 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 disposition == "mitigate" and not names_test.search(rows[0]): print(f"refuse: mitigated threat {tid} names no test or gate stage", file=sys.stderr) sys.exit(1) if severity in ("high", "critical") and disposition == "mitigate": removal = any(re.match(r"^\| RC-\d+ \| " + re.escape(tid) + r" \|", l) for l in lines) waived = [l for l in lines if re.match(r"^\| NR-\d+ \| " + re.escape(tid) + r" \|", l)] reason = waived and len([c for c in waived[0].strip().strip("|").split("|") if c.strip()]) >= 3 if not removal and not (tid in no_removal and reason): print(f"refuse: {severity} threat {tid} has no removal check row", file=sys.stderr) sys.exit(1) for rc, threat in rc_pairs: if not any(re.match(r"^\| " + re.escape(rc) + r" \| " + re.escape(threat) + r" \|", l) for l in lines): print(f"refuse: review has no row for removal check {rc} {threat}", file=sys.stderr) sys.exit(1) if not re.search(r"^threats_open: 0$", review, re.M): print("refuse: the review does not report threats_open: 0", file=sys.stderr) sys.exit(1) if "T-12-18" not in review: print("refuse: the review does not cite T-12-18", 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) status_word = re.compile(r"(?&2 return 1 } grep -q -- "check-phase12.1.sh --$stage" "${BASH_SOURCE[0]}" || { echo "refuse: usage does not list --$stage" >&2 return 1 } done # The mask hides the application's name unless asked not to. local masked masked="$(printf 'ok \tgit.example.test/x/fonoteka.go/parity\n' | PHASE121_VERBOSE='' mask)" if grep -qiE "$APP_NAMES" <<<"$masked"; then echo "refuse: self-test mask left the application's name: $masked" >&2 return 1 fi local scratch out scratch="$(mktemp -d)" trap 'rm -rf "$scratch"' RETURN # Coverage: 80% passes an 80% floor, a 50% package refuses, profiles # merge, an empty profile refuses. printf 'mode: set\nexample.test/acme/a.go:1.1,2.2 8 1\nexample.test/acme/a.go:3.1,4.2 2 0\n' >"$scratch/p1" printf 'mode: set\nexample.test/low/b.go:1.1,2.2 5 1\nexample.test/low/b.go:3.1,4.2 5 0\n' >"$scratch/p2" printf 'mode: set\nexample.test/low/b.go:3.1,4.2 5 1\n' >"$scratch/p3" printf 'mode: set\n' >"$scratch/empty" out="$(coverage_report 80 "$scratch/p1" 2>&1)" || { echo "refuse: self-test coverage_report refused 80% at an 80% floor: $out" >&2 return 1 } if out="$(coverage_report 80 "$scratch/p1" "$scratch/p2" 2>&1)"; then echo "refuse: self-test coverage_report accepted a 50% package" >&2 return 1 fi grep -q "below the 80% coverage floor: example.test/low 50.0%" <<<"$out" || { echo "refuse: self-test coverage_report refused for the wrong reason: $out" >&2 return 1 } coverage_report 80 "$scratch/p1" "$scratch/p2" "$scratch/p3" >/dev/null 2>&1 || { echo "refuse: self-test coverage_report did not merge profiles" >&2 return 1 } if coverage_report 80 "$scratch/empty" 2>/dev/null; then echo "refuse: self-test coverage_report accepted an empty profile" >&2 return 1 fi # Hygiene: a planted application name is found in a file and in a tree, # in either spelling and any case; a clean tree gives nothing. mkdir -p "$scratch/hyg/docs" "$scratch/hyg/modules/acme" printf 'A neutral page about the acme plugin.\n' >"$scratch/hyg/docs/page.md" printf 'package acme\n' >"$scratch/hyg/modules/acme/acme.go" if [[ -n "$(hygiene_scan "$scratch/hyg" docs modules)" ]]; then echo "refuse: self-test hygiene_scan found a name in a clean tree" >&2 return 1 fi local plant for plant in 'the Fonoteka app' 'Płytarium' 'plytarium' 'FONOTEKA.GO'; do printf '// used by %s\n' "$plant" >"$scratch/hyg/modules/acme/acme.go" if [[ -z "$(hygiene_scan "$scratch/hyg" docs modules)" ]]; then echo "refuse: self-test hygiene_scan missed a planted application name" >&2 return 1 fi done # Evidence: a complete record passes; a missing threat row, a wrong # disposition, a high threat without a removal row, a waiver for a # threat that may not be waived, a removal row missing from the review, # an open threat, a pending validation row and a draft validation file # each refuse. mkdir -p "$scratch/phase" printf '| T-12.1-90 | Spoofing | x | high | mitigate | y |\n| T-12.1-91 | Tampering | x | low | accept | y |\n| T-12.1-92 | Tampering | x | high | mitigate | y |\n' >"$scratch/phase/12.1-01-PLAN.md" cat >"$scratch/review.md" <<'EOR' threats_open: 0 T-12-18 is revisited. | T-12.1-90 | Spoofing | x | high | mitigate | y | TestAlpha | pass | none | | T-12.1-91 | Tampering | x | low | accept | y | none (accepted) | accepted | none | | T-12.1-92 | Tampering | x | high | mitigate | y | check-phase12.1.sh --hygiene | pass | none | | RC-90 | T-12.1-90 | f | a | b | fails | | NR-01 | T-12.1-92 | a process control with no code to remove | EOR cat >"$scratch/validation.md" <<'EOV' status: validated nyquist_compliant: true | 12.1-01-T1 | D-09 | `go test -run '^TestAlpha$'` | ✅ green | EOV local pairs='RC-90 T-12.1-90' out="$(evidence_check "$scratch/phase" "$scratch/review.md" "$scratch/validation.md" "T-12.1-92" "$pairs" 2>&1)" || { echo "refuse: self-test evidence_check rejected a complete record: $out" >&2 return 1 } local case waive for case in missing-row disposition removal waiver table open cite pending draft tasks; do cp "$scratch/review.md" "$scratch/review.case" cp "$scratch/validation.md" "$scratch/validation.case" waive="T-12.1-92" case "$case" in missing-row) sed -i '/^| T-12.1-91 /d' "$scratch/review.case" ;; disposition) sed -i 's/| low | accept |/| low | mitigate |/' "$scratch/review.case" ;; removal) sed -i '/^| RC-90 /d' "$scratch/review.case" ;; waiver) waive="" ;; table) pairs='RC-90 T-12.1-90 RC-91 T-12.1-92' ;; open) sed -i 's/^threats_open: 0$/threats_open: 1/' "$scratch/review.case" ;; cite) sed -i '/T-12-18/d' "$scratch/review.case" ;; pending) printf '| 12.1-02-T1 | D-10 | x | ⬜ pending |\n' >>"$scratch/validation.case" ;; draft) sed -i 's/^status: validated$/status: draft/' "$scratch/validation.case" ;; tasks) sed -i 's/^| 12.1-01-T1 /| TBD-task /' "$scratch/validation.case" ;; esac if evidence_check "$scratch/phase" "$scratch/review.case" "$scratch/validation.case" "$waive" "$pairs" >/dev/null 2>&1; then echo "refuse: self-test evidence_check accepted the $case plant" >&2 return 1 fi pairs='RC-90 T-12.1-90' done # The removal harness reports a guarded mutation as failing, refuses a # mutation whose test still passes, an anchor that is not unique and a # dirty tracked file, and restores the file byte for byte. 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 {","root","./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 return 1 } grep -q "RC-T1 T-X modules/acme/acme.go: fails as required" <<<"$out" || { echo "refuse: self-test removal harness output: $out" >&2 return 1 } cmp -s "$fake/modules/acme/acme.go" "$scratch/acme.go.saved" || { echo "refuse: self-test removal harness did not restore the file" >&2 return 1 } printf '[["RC-T2","T-X","root","modules/acme/acme.go","if n > 3 {","if false {","root","./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 return 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 return 1 } cmp -s "$fake/modules/acme/acme.go" "$scratch/acme.go.saved" || { echo "refuse: self-test removal harness did not restore the file after a surviving mutation" >&2 return 1 } printf '[["RC-T5","T-X","root","modules/acme/acme.go","if n > 3 {","if n > 3 {{","root","./modules/acme","^TestGuard$"]]' >"$table" if out="$(removal_harness_in "$fake" "$table" 2>&1)"; then echo "refuse: self-test removal harness counted a build failure as a failing test: $out" >&2 return 1 fi printf '[["RC-T3","T-X","root","modules/acme/acme.go","return","x","root","./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 return 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 return 1 } (cd "$fake" && git init -q && git add -A && git -c user.email=gate@example.test -c user.name=gate commit -qm init) >/dev/null printf '// local edit\n' >>"$fake/modules/acme/acme.go" printf '[["RC-T4","T-X","root","modules/acme/acme.go","if n > 3 {","if false {","root","./modules/acme","^TestGuard$"]]' >"$table" if out="$(removal_harness_in "$fake" "$table" 2>&1)"; then echo "refuse: self-test removal harness mutated a dirty file" >&2 return 1 fi grep -q "is dirty" <<<"$out" || { echo "refuse: self-test removal harness refused a dirty file for the wrong reason: $out" >&2 return 1 } # The workspace reader finds every module of a go.work and refuses an # empty one. mkdir -p "$scratch/ws" printf 'go 1.27.0\n\nuse (\n\t.\n\t./plugins/acme/one\n\t./plugins/acme/two // comment\n)\n' >"$scratch/ws/go.work" out="$(workspace_modules "$scratch/ws" | tr '\n' ' ')" [[ "$out" == ". ./plugins/acme/one ./plugins/acme/two " ]] || { echo "refuse: self-test workspace_modules read: $out" >&2 return 1 } printf 'go 1.27.0\n' >"$scratch/ws/go.work" if workspace_modules "$scratch/ws" >/dev/null 2>&1; then echo "refuse: self-test workspace_modules accepted a go.work without modules" >&2 return 1 fi echo "phase12.1 self-test passed" } run_all() { local stage for stage in self-test go security coverage spa openapi dist docs hygiene app evidence; do # Each stage runs in its own process, so errexit stays in force # inside it (bash ignores set -e in a function called from an if). local started=$SECONDS if bash "${BASH_SOURCE[0]}" "--$stage"; then echo "PASS $stage ($((SECONDS - started)) s)" else echo "FAIL $stage ($((SECONDS - started)) s)" exit 1 fi done echo "phase12.1 all stages passed (removal is run separately)" } APP_ERR="$(mktemp)" trap 'rm -f "$APP_ERR"' EXIT case "${1:-}" in --self-test) run_self_test ;; --go) run_go ;; --security) run_security ;; --removal) run_removal ;; --coverage) run_coverage ;; --spa) run_spa ;; --openapi) run_openapi ;; --dist) run_dist ;; --docs) run_docs ;; --hygiene) run_hygiene ;; --app) run_app ;; --evidence) run_evidence ;; --all) run_all ;; *) usage ;; esac