a73x

test/bans.sh

Ref:   Size: 11.7 KiB   History

#!/bin/sh
# bans.sh — the folder rules' own pin. `make check` runs it after
# `zig build check`.
#
# build.zig's `checkSourceBan` reads the production lines of every file
# under src/ for bytes no import graph can catch. Nothing asserted that it
# still bites: a needle list edited down to nothing, a folder list that
# stopped naming a folder, or a `test` skip widened to swallow production
# lines would all leave a green tree saying the rules hold. This plants ONE
# representative needle per rule in a scratch file under one banned folder
# and requires the refusal by name, then plants one INSIDE a test block and
# requires no refusal at all — the skip is as load-bearing as the ban, since
# a test that drives a VT with escape bytes must stay legal.
#
# The scratch file is removed by an EXIT trap, so a failing assertion never
# leaves a planted needle behind to refuse every later build in the tree.
#
# The last check in this file is not build.zig's at all: it reads the SHELL
# scripts for one shape no compiler sees, for the same reason as the rest.
# A rule nothing asserts is a rule that has already stopped biting.
set -u

ZIG="${1:-deps/zig/zig}"
ROOT=$(cd "$(dirname "$0")/.." && pwd)
cd "$ROOT" || exit 1
[ -x "$ZIG" ] || { echo "bans FAIL: no zig at $ZIG (pass it as \$1)"; exit 1; }

PROBE=""
cleanup() { [ -z "$PROBE" ] || rm -f "$PROBE"; }
trap cleanup EXIT INT TERM

FAILED=0

# plant FOLDER BODY — a fresh .zig file under FOLDER holding BODY. The
# basename is this run's pid and carries no prefix any other build check
# claims: `server_test_` would trip checkSiblingTestsReached first and the
# rule under test would never be reached.
plant() {
    PROBE="$1/zz_bans_probe_$$.zig"
    printf '%s\n' "$2" > "$PROBE"
}

unplant() {
    [ -z "$PROBE" ] || rm -f "$PROBE"
    PROBE=""
}

# must_break RULE FOLDER BODY — the planted needle has to stop the build
# with that rule's own fatal.
#
# `zig build check` is the gate's own spelling and is what runs here.
# `checkSourceBans` is called from `build(b)`, so the refusal happens while
# the build graph is still being constructed: no step runs, no test is
# compiled, and the whole call costs about as long as reading src/.
must_break() {
    _rule="$1"
    plant "$2" "$3"
    _out=$("$ZIG" build check 2>&1)
    _rc=$?
    unplant
    if [ "$_rc" -eq 0 ]; then
        echo "bans FAIL: rule $_rule: a needle planted in $2 built cleanly (rc 0)."
        echo "           The rule is not reading that folder's production lines."
        FAILED=1
        return
    fi
    case "$_out" in
        *"folder rule $_rule broken"*)
            echo "bans ok: rule $_rule refuses its needle in $2" ;;
        *)
            echo "bans FAIL: rule $_rule: the build failed, but not with"
            echo "           'folder rule $_rule broken'. What it said:"
            echo "$_out" | tail -20
            FAILED=1 ;;
    esac
}

# must_skip FOLDER BODY — the same needles inside a `test` block are legal,
# and the build must not so much as mention a rule.
#
# `zig build --help` rather than `zig build check`: this case has to reach
# the END of `build(b)` to prove nothing fatalled, and `check` would then
# go on to run the whole unit suite to answer a question the configure pass
# has already answered. `--help` constructs the identical graph — same
# `build(b)`, same `checkSourceBans` call — and runs no step.
must_skip() {
    plant "$1" "$2"
    _out=$("$ZIG" build --help 2>&1)
    _rc=$?
    unplant
    if [ "$_rc" -ne 0 ] || [ "${_out#*folder rule}" != "$_out" ]; then
        echo "bans FAIL: a needle inside a test block was refused (rc $_rc)."
        echo "           The test skip is what lets a test drive a VT or spawn"
        echo "           a shell; without it those tests cannot be written."
        echo "$_out" | tail -20
        FAILED=1
    else
        echo "bans ok: a needle inside a test block is skipped"
    fi
}

# One needle per rule, each in a folder that rule names, and each chosen so
# no EARLIER rule matches the same line — the checker fatals on the first
# hit, so a body that tripped rule 4 would say nothing about rule 7.
must_break 4 src/client 'const probe = "isatty";'
must_break 5 src/engine 'const probe = "/bin/sh";'
must_break 6 src/server 'const probe = std.posix.fork();'
must_break 7 src/server 'const probe = std.os.linux.O.RDONLY;'

# An exemption permits one occurrence, never the rest of the file.
must_skip src/client '// folder rule 4 exemption: fixture checks a permitted occurrence.
const permitted = "isatty";'
must_break 4 src/client '// folder rule 4 exemption: fixture checks a permitted occurrence.
const permitted = "isatty";
const forbidden = "tcgetattr";'
must_break 4 src/client '// folder rule 4 exemption:
const forbidden = "isatty";'

# A container-level `test` opens at column 0 and its `}` closes there,
# which is the line arithmetic checkSourceBan relies on and `zig fmt
# --check` already guarantees.
must_skip src/server 'test "bans probe" {
    const probe = std.os.linux.O.RDONLY;
    _ = probe;
}'

# ---- the shell rule: no backtick inside an UNQUOTED heredoc -------------
#
# In an unquoted heredoc the shell runs a backquoted word as a command
# substitution wherever it stands, comments included. Most ptyclient scripts
# in this suite are QUOTED heredocs and safe; the ones that interpolate a
# fingerprint or a port have to leave the delimiter bare, and those are the
# ones this rule is for. This repo quotes a key or a verb in backticks
# everywhere else,
# so writing one into such a comment is the natural thing to do and it is a
# trap. There IS an expect program on macOS: the shell starts it, it reads
# the rest of the heredoc as its own script, and the group hangs until its
# budget runs out with nothing on stdout. That cost four gate runs on the
# macOS port before anyone spelled the rule (2026-09-04), and one of the
# four was the comment written to explain it.
#
# The same four directories shellGate compiles, so a script added tomorrow
# is covered without anybody remembering to add it. A heredoc whose
# delimiter is QUOTED is left alone, which is what makes this a rule about
# the opener rather than a ban on a byte.
heredoc_backticks() {
    awk '
    # The delimiter this line opens a heredoc with, or "" for a line that
    # opens none. A three-angle form is a here-string and has no body. A
    # QUOTED delimiter performs no substitution in its body, so there is
    # nothing there to catch; the quotes are spelled \047 and \042 so this
    # whole program fits inside one shell-quoted string.
    function delim_of(line,   p, rest, c) {
        if (index(line, "<<<") > 0) return ""
        p = index(line, "<<")
        if (p == 0) return ""
        rest = substr(line, p + 2)
        sub(/^-/, "", rest)
        sub(/^[ \t]+/, "", rest)
        c = substr(rest, 1, 1)
        if (c == "\047" || c == "\042") return ""
        if (match(rest, /^[A-Za-z_][A-Za-z0-9_]*/)) return substr(rest, RSTART, RLENGTH)
        return ""
    }
    FNR == 1 { inbody = 0 }
    # Matched ANYWHERE on the line and not just at its end: the site this
    # rule was written for spells its opener with a trailing space after
    # the delimiter, and a first sweep that anchored on end-of-line walked
    # straight past it.
    !inbody {
        d = delim_of($0)
        if (d != "") { inbody = 1; delim = d; opened = FNR }
        next
    }
    {
        line = $0
        gsub(/^[ \t]+|[ \t]+$/, "", line)
        if (line == delim) { inbody = 0; next }
        if (index($0, "\140") > 0)
            printf "%s:%d (heredoc opened at %d): %s\n", FILENAME, FNR, opened, $0
    }
' "$@"
}

# The positive: the check has to SEE the shape, in both spellings of the
# opener, or it is a line in a file that never fires. Planted rather than
# quoted out of the tree, because the tree is the negative below and a
# check that only ever reads a clean tree proves nothing.
SHPROBE="$ROOT/test/zz_bans_heredoc_$$.sh"
# printf and not a heredoc, because this script would otherwise have to
# contain the very shape it refuses — and the trailing space after the
# first opener is half of what is being pinned.
#
# The FIXTURE's opener is BUILT rather than written. The sweep's opener
# detector spares a quoted delimiter, and it sees a quote right after the
# two angle brackets in the awk program at the top of `heredoc_backticks`,
# so those literals do not fool it — but a bare opener written out here
# would make it read this script's own fixture as a heredoc and report the
# three backticks planted below. The only ways out of that are an exception
# for this file or a rule that stops covering it. Neither is worth a pair of
# characters.
_a='<'
_hd="$_a$_a"
printf '%s\n' \
    "cat ${_hd}EOF " \
    '# prose quoting a `verb` the way this repo quotes one' \
    'EOF' \
    "cat ${_hd}-INNER" \
    '# and a `second` under the dash form of the opener' \
    'INNER' \
    "cat ${_hd}'QUOTED'" \
    '# a `third` here is inert, because the delimiter is quoted' \
    'QUOTED' > "$SHPROBE"
_hits=$(heredoc_backticks "$SHPROBE" | wc -l | tr -d ' ')
rm -f "$SHPROBE"
if [ "$_hits" -eq 2 ]; then
    echo "bans ok: the heredoc rule catches both openers and spares a quoted one"
else
    echo "bans FAIL: the heredoc rule found $_hits of the 2 planted backticks."
    echo "           An unquoted opener must be caught, including the spelling"
    echo "           with a trailing space after the delimiter, and a quoted"
    echo "           one must be spared."
    FAILED=1
fi

# The negative: the tree as it stands.
_sh=""
for _d in test tools deps deps/quic; do
    for _f in "$ROOT/$_d"/*.sh; do
        [ -f "$_f" ] && _sh="$_sh $_f"
    done
done
# shellcheck disable=SC2086 # the list is words, and split is the point
_left=$(heredoc_backticks $_sh)
if [ -z "$_left" ]; then
    echo "bans ok: no backtick inside an unquoted heredoc"
else
    echo "bans FAIL: a backtick inside an unquoted heredoc:"
    printf '%s\n' "$_left" | sed 's/^/           /'
    echo "           The shell runs it as a command substitution. Say the word"
    echo "           in plain prose instead; there is no way to quote it here."
    FAILED=1
fi

# The module split, which no folder rule can express: a folder rule reads
# bytes inside a file, and this is about which module a file belongs to.
# `checkGrantsUsed` refuses a grant nobody spends, but nothing refuses a
# grant somebody adds — a client row that reached for the engine again
# would build green and put ghostty-vt back in every client binary.
split_fail() {
    echo "bans FAIL: $1"
    FAILED=1
}

# The term module is the wasm root and the client's whole view of the wire,
# so it spells no emulator. engine.zig is the one file allowed the
# dependency, and term.zig must not re-export it: a re-export would hand
# every term importer the engine back without touching the table.
if grep -l '"ghostty-vt"' src/engine/term.zig src/engine/protocol.zig \
    src/engine/replica.zig src/engine/grid.zig >/dev/null 2>&1; then
    split_fail "a term child imports ghostty-vt"
fi
if grep -q 'engine.zig' src/engine/term.zig; then
    split_fail "term.zig re-exports the engine"
fi

# No client row links the engine in production: the client parses no VT and
# authors screens through an engine only under test. The table writes one
# row per line, so a row's `.imports` list is on the line that names it, and
# `.test_imports` cannot match because the character before `imports` there
# is an underscore rather than a dot.
for row in client wall webhub term mux; do
    if grep -E "\.name = \"$row\"" build.zig | grep -qE '\.imports = &\.[{][^}]*"engine"'; then
        split_fail "module row $row imports engine outside its tests"
    fi
done
[ "$FAILED" -eq 0 ] && echo "bans ok: the engine is the daemon's alone"

[ "$FAILED" -eq 0 ] || { echo "bans: FAILED"; exit 1; }
echo "bans: every folder rule bit, the test skip held, and no heredoc runs its own comments"