diff --git a/agents/darkwing/work/queue-211/mutants.sh b/agents/darkwing/work/queue-211/mutants.sh index a1ed1ebfd..f832bf663 100755 --- a/agents/darkwing/work/queue-211/mutants.sh +++ b/agents/darkwing/work/queue-211/mutants.sh @@ -75,16 +75,16 @@ run differing-hook-replaced $I '[ "$FORCE" = 1 ] || die 2 "refused: a different' run replace-in-place $I 'mv -f -- "$tmp" "$HOOK" ||' 'cat -- "$tmp" > "$HOOK" && rm -f -- "$tmp" ||' run symlink-followed $I 'if [ -L "$HOOK" ]; then die 2' 'if false; then die 2' run ancestor-error-passes $H ' *) refuse "$rref: the ancestor check failed' ' *) : "$rref: the ancestor check failed' -run snapshot-matched $S $' if [[ "$c" =~ $RE_SNAPSHOT ]]; then sourced "${c:${#BASH_REMATCH[0]}}"; return; fi\n' '' +run snapshot-matched $S $' if [[ " $n " =~ $RE_SNAPSHOT ]]; then sourced "$1"; return; fi\n' '' run ancestors-matched $S ' [ -z "${SKIP[$pid]:-}" ] || continue' ' :' -run no-node-rule $S 'if [[ "$c" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi' ':' -run no-compose-rule $S 'if [[ "$c" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi' ':' +run no-node-rule $S 'if [[ "$1" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi' ':' +run no-compose-rule $S 'if [[ "$1" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi' ':' # R3, the GPT review (F1 to F5 and the object-name length check). run unreadable-read-clear $S ' gone "$PROC" "$pid" && continue' ' continue' run vanished-is-error $S $' gone "$PROC" "$pid" && continue\n' '' run read-errors-ignored $S '[ "$errors" = 0 ] || {' 'true || {' run self-read-unchecked $S ' || { echo "slot-busy: error: cannot read its own process" >&2; exit 2; }' ' || p=""' -run snapshot-substring $S '[[ "$c" =~ $RE_SNAPSHOT ]]' '[[ "$c" == *"/shell-snapshots/snapshot-"* ]]' +run snapshot-substring $S '[[ " $n " =~ $RE_SNAPSHOT ]]' '[[ " $n " == *"/shell-snapshots/snapshot-"* ]]' run tag-replace-passes $H 'if [ "${rref#refs/tags/}" != "$rref" ] && [ "$lsha" != "$rsha" ]; then' 'if false; then' run hooks-dir-symlink-followed $I 'own_hooks() {' 'own_hooks() { return 0;' run short-sha-accepted $H ' if ! idlen "$lsha" || ! idlen "$rsha"; then' ' if false; then' @@ -92,10 +92,38 @@ run short-sha-accepted $H ' if ! idlen "$lsha" || ! idlen "$rsha"; then' ' run ancestor-read-unchecked $S $' gone /proc "$p" && break\n echo "slot-busy: error: cannot read the status of ancestor $p" >&2; exit 2' ' break' run exited-ancestor-error $S ' gone /proc "$p" && break' ' :' run denied-lookup-vanished $S ' gone "$PROC" "$pid" && continue' ' [ -e "$d" ] || continue' -run denied-listing-gone $S 'names=$(ls -f -- "$1" 2>/dev/null) || return 1' 'names=$(ls -f -- "$1" 2>/dev/null) || return 0' +run denied-listing-gone $S 'names=$(ls -f -1 --quoting-style=literal -- "$1" 2>/dev/null) || return 1' 'names=$(ls -f -1 --quoting-style=literal -- "$1" 2>/dev/null) || return 0' run dir-check-skips $S $' pid=${d##*/}\n' $' [ -d "$d" ] || continue\n pid=${d##*/}\n' -run sourced-test-hidden $S 'then sourced "${c:${#BASH_REMATCH[0]}}"; return; fi' 'then return 1; fi' -run sourced-first-only $S ' rest=${rest#*"$m"}' ' break' -run dot-source-missed $S '(source|\\.)[[:space:]]+' '(source)[[:space:]]+' +run sourced-test-hidden $S 'then sourced "$1"; return; fi' 'then return 1; fi' +run sourced-first-only $S '{ if (!depth && !bq) span = 0 }' '{ if (!depth && !bq) { if (span) return 1 } }' +run dot-source-missed $S 'w == "source" || w == "."' 'w == "source"' run installer-fixed-tmp $I ' tmp=$(mktemp "$HOOKS/.pre-push.install-git-hooks.XXXXXX") || die 1 "cannot create a temporary file in $HOOKS"' ' tmp="$HOOKS/.pre-push.install-git-hooks.$$"' +# R5 (the GPT review of R4: N1 and N2). Sites R5 moved are edited above. +run ls-env-quoting $S '-1 --quoting-style=literal --' '-1 --' +run locale-kept $S $'export LC_ALL=C\n' '' +run quotes-kept $S 'gsub(/["\047\\]/, "", t); ' '' +run operators-kept $S 'gsub(/[[:space:];&|(){}<>`]+/, " ", t)' 'gsub(/[[:space:]]+/, " ", t)' +run snapshot-single-space $S '<>`]+/, " ", t)' '<>`]/, " ", t)' +run flat-skipped $S ' if [[ "$n" == *$SPECIAL* || "$n" == *" "* ]]; then' ' if false; then' +run flat-double-blank-missed $S ' || "$n" == *" "* ]]; then' ' ]]; then' +run special-tab-missed $S '<>`\t\n\r\v\f]'"'" '<>`\n\r\v\f]'"'" +run flat-failure-passes $S '[[ "$n" == F* ]] || return 2' '[[ "$n" == F* ]] || true' +run flat-sentinel-unchecked $S 'n=$(flat "$1") && [[ "$n" == F* ]] || return 2' 'n=$(flat "$1") || return 2' +run raw-rules-dropped $S 'rules " $n " || rules " $1 "' 'rules " $n "' +run flat-rules-dropped $S 'rules " $n " || rules " $1 "' 'rules " $1 "' +run source-arg-first-word $S 'span = span || w == "source" || w == "."' 'span = w == "source" || w == "."' +run no-requote $S 'if (q && w ~ AGAIN) {' 'if (0) {' +run quoted-span-reset $S 'if (open) span = 1' 'span = open' +run span-ends-in-parens $S '{ if (!depth && !bq) span = 0 }' '{ span = 0 }' +run backtick-ignored $S 'bq = !bq' 'bq = bq' +run sq-kept $S 'if (c == SQ) {' 'if (0) {' +run dq-kept $S '} else if (c == DQ) {' '} else if (0) {' +run dollar-quote-glued $S '} else if (c == "$" && index(SQ DQ, substr(s, i + 1, 1))) {' '} else if (0) {' +run backslash-kept $S '} else if (c == BS) {' '} else if (0) {' +run snapshot-path-checked $S 'if (snap == "" && w ~ SNAP) snap = w' 'if (0) snap = w' +run snapshot-repeat-checked $S 'else if (w != snap) {' 'else if (1) {' +run depth-unbounded $S 'if (lvl > maxlvl) return 2' 'if (0) return 2' +run awk-failure-none $S "') || return 2" "') || return 1" +run lexer-garbage-none $S '*) return 2 ;; esac' '*) return 1 ;; esac' +run classify-error-passes $S '[ "$rc" != 1 ] || continue' '[ "$rc" = 0 ] || continue' exit "$bad" diff --git a/scripts/slot-busy.sh b/scripts/slot-busy.sh index b3f478f4a..4d81d0f5f 100755 --- a/scripts/slot-busy.sh +++ b/scripts/slot-busy.sh @@ -7,26 +7,33 @@ # # It prints one BUSY line per matching process, or one CLEAR line. Exit codes: # 0 CLEAR, 1 BUSY, 2 its own error, which includes a process or an ancestor -# it can't read that isn't confirmed gone (it still prints any BUSY lines, -# never CLEAR). Gone means a listing of the proc root that succeeds and lacks -# the pid. Chain it on the exit code, never on a filter of its output: +# it can't read that isn't confirmed gone, and a wrapper's text it can't +# classify (it still prints any BUSY lines, never CLEAR). Gone means a +# literal listing of the proc root that succeeds and lacks the pid. Chain it +# on the exit code, never on a filter of its output: # bash scripts/slot-busy.sh > LOG && gate (set -o pipefail if piped) # never `bash scripts/slot-busy.sh | tail -1 && gate`, which runs while BUSY. # # A process matches when its command line holds `node ... --test`, a # `*gate*.sh`, `mutant`, a `test-*.sh` or `docker compose`/`docker-compose`. -# It skips itself and its ancestors (a gate script may run the check), and an -# agent harness's shell-snapshot wrapper (`bash -c 'source .../shell-snapshots/ -# snapshot-*.sh ...'`), whose command line is the agent's command text; that -# shell's real children are still matched, and so is any other command line -# that only names a shell-snapshots path. A wrapper whose text after the -# snapshot source also sources (`source` or `.`) a gate, test or mutant -# script runs it in its own shell, so that wrapper is BUSY. It reads only -# /proc//cmdline, the cwd link, its own and its ancestors' -# /proc//status and, after a failed read, the proc root's listing, never -# a file under a seat's directory. +# The rules read the command line as it is and in a flat form: quotes and +# backslashes removed, and each run of blanks and the characters ; & | ( ) { } +# < > ` made one space. It skips itself and its ancestors (a gate script may +# run the check), and an agent harness's shell-snapshot wrapper (`bash -c +# 'source .../shell-snapshots/snapshot-*.sh ...'`, matched in the flat form), +# whose command line is the agent's command text; that shell's real children +# are still matched, and so is any other command line that only names a +# shell-snapshots path. A wrapper that sources (`source` or `.`) a gate, test +# or mutant script runs it in its own shell, so that wrapper is BUSY. Every +# word from the `source` or `.` to the next ; & | or newline counts, and +# quoted text is read again as shell text. It reads only /proc//cmdline, +# the cwd link, its own and its ancestors' /proc//status and, after a +# failed read, the proc root's listing, never a file under a seat's directory. # --proc-root names a stand-in for /proc in the selftests. set -uo pipefail +# Every tool reads bytes the same way whatever the caller's locale: under a +# UTF-8 locale a byte that isn't UTF-8 would end a [^ /]* run early. +export LC_ALL=C PROC=/proc while [ $# -gt 0 ]; do @@ -40,10 +47,12 @@ done # gone ROOT PID: true only when ROOT lists and PID isn't in the listing. A # failed or denied listing is false: a lookup that fails can't tell an exited -# process from one it may not see. `ls -f` reads the names only, no stat. +# process from one it may not see. `ls -f` reads the names only, no stat. The +# flags fix the format: one name per line, never quoted, whatever +# QUOTING_STYLE holds (a quoted name would read as a missing pid). gone() { local names - names=$(ls -f -- "$1" 2>/dev/null) || return 1 + names=$(ls -f -1 --quoting-style=literal -- "$1" 2>/dev/null) || return 1 [[ $'\n'"$names"$'\n' != *$'\n'"$2"$'\n'* ]] } @@ -67,34 +76,119 @@ RE_NODE='[ /]node[^ ]* (.* )?--test ' RE_GATE='[ /_-]gate[^ /]*\.sh ' RE_TEST='[ /]test-[^ /]*\.sh ' RE_COMPOSE='[ /]docker( compose|-compose) ' -# The wrapper's shape: a shell, its options with -c, then `source` (or `.`) -# of a shell-snapshots/snapshot-*.sh file as the first command. +# The wrapper's shape, in the flat form: a shell, its options with -c, then +# `source` (or `.`) of a shell-snapshots/snapshot-*.sh file as the first +# command. RE_SNAPSHOT='^ (/[^ ]*/)?(ba|z)?sh( -[a-zA-Z]+)* -[a-zA-Z]*c[a-zA-Z]*( -[a-zA-Z]+)* (source|\.) [^ ]*/shell-snapshots/snapshot-[^ /]*\.sh ' -# A sourced file in the wrapper's text: `source` or `.` after the start, a -# blank, a shell operator or a quote, then blanks, an optional `--`, an -# optional opening quote and the path, up to a blank, operator or quote. -RE_SOURCED="(^|[[:space:];&|(){}\`'\"])(source|\\.)[[:space:]]+(--[[:space:]]+)?['\"]?([^[:space:];&|<>(){}\`'\"]+)" -sourced() { # $1: the wrapper's text after the snapshot source. Prints the rule. - local rest=$1 m f - while [[ "$rest" =~ $RE_SOURCED ]]; do - # Saved first: the rule tests below reset BASH_REMATCH. - m=${BASH_REMATCH[0]} f=" ${BASH_REMATCH[4]} " - if [[ "$f" =~ $RE_GATE ]]; then echo "sourced gate"; return 0; fi - if [[ "$f" == *mutant* ]]; then echo "sourced mutant"; return 0; fi - if [[ "$f" =~ $RE_TEST ]]; then echo "sourced test script"; return 0; fi - rest=${rest#*"$m"} - done + +# sourced TEXT: a wrapper's whole command line. Prints the rule when a +# `source` or `.` in it names a gate, test or mutant script; returns 1 when +# none does, 2 when the text can't be classified. +# +# The awk walk reads the text in words the way the shell splits them: a +# blank, newline or one of ; & | ( ) < > ` ends a word; '...' and "..." are +# parts of a word with the quotes removed (a backslash inside "..." keeps the +# next character in the word); a backslash keeps the next character; $'...' +# and $"..." are read as quotes. A quote that never closes runs to the end of +# the text. Quoted text that holds a blank, an operator, a quote or a +# backslash is walked again as shell text (eval, bash -c and the harness's +# '\'' forms), and a span left open at its end stays open after the word. +# A span starts after a `source` or `.` word and ends at ; & | or a newline +# outside parentheses and backticks; every word in it is checked, so +# redirections, $(...) and `...` parts and later arguments count. The first +# shell-snapshots/snapshot-*.sh word in a span is the harness's own snapshot, +# skipped whatever its name; a later word equal to it is skipped too. Text +# quoted more than 32 levels deep, a failed awk or output that is neither a +# rule nor "none" returns 2. +sourced() { + local out + out=$(printf '%s' "$1" | awk -v maxlvl=32 ' + BEGIN { + RS = "\001"; SQ = "\047"; DQ = "\""; BS = "\\" + SEP = " \t\n;&|()<>`"; STOP = SEP SQ DQ BS "$" + AGAIN = "[[:space:];&|()<>`" SQ DQ "\\\\]" + GATE = "(^|[/_-])gate[^/]*\\.sh$"; TEST = "(^|/)test-[^/]*\\.sh$" + SNAP = "/shell-snapshots/snapshot-[^/]*\\.sh$" + } + { text = (NR > 1) ? text RS $0 : $0 } + function walk(s, span, lvl, n, i, j, c, w, q, had, r, depth, bq) { + if (lvl > maxlvl) return 2 + n = length(s); i = 1 + for (;;) { + w = ""; q = had = 0 + while ((c = substr(s, i, 1)) != "" && !index(SEP, c)) { + had = 1 + if (c == SQ) { + for (j = i + 1; j <= n && substr(s, j, 1) != SQ; j++) ; + w = w substr(s, i + 1, j - i - 1); i = j + 1; q = 1 + } else if (c == DQ) { + for (j = i + 1; j <= n && (c = substr(s, j, 1)) != DQ; j++) if (c == BS) j++ + w = w substr(s, i + 1, j - i - 1); i = j + 1; q = 1 + } else if (c == "$" && index(SQ DQ, substr(s, i + 1, 1))) { + i++ + } else if (c == BS) { + c = substr(s, i + 1, 1); if (c != "\n") w = w c; i += 2; q = 1 + } else { + for (j = i + 1; j <= n && !index(STOP, substr(s, j, 1)); j++) ; + w = w substr(s, i, j - i); i = j + } + } + if (had) { + if (q && w ~ AGAIN) { + r = walk(w, span, lvl + 1) + if (r != 1) return r + if (open) span = 1 + } + if (span) { + if (snap == "" && w ~ SNAP) snap = w + else if (w != snap) { + if (w ~ GATE) { rule = "sourced gate"; return 0 } + if (index(w, "mutant")) { rule = "sourced mutant"; return 0 } + if (w ~ TEST) { rule = "sourced test script"; return 0 } + } + } + span = span || w == "source" || w == "." + } + if (c == "") { open = span; return 1 } + i++ + if (c == ";" || c == "&" || c == "|" || c == "\n") { if (!depth && !bq) span = 0 } + else if (c == "(") depth++ + else if (c == ")") { if (depth) depth-- } + else if (c == "`") bq = !bq + } + } + END { r = walk(text, 0, 1); print (r == 0 ? rule : r == 1 ? "none" : "deep") } + ') || return 2 + case $out in none) return 1 ;; "sourced "*) echo "$out"; return 0 ;; *) return 2 ;; esac +} +rules() { # $1: a command line with a blank at each end. Prints the rule. + if [[ "$1" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi + if [[ "$1" =~ $RE_GATE ]]; then echo "gate"; return 0; fi + if [[ "$1" == *mutant* ]]; then echo "mutant"; return 0; fi + if [[ "$1" =~ $RE_TEST ]]; then echo "test script"; return 0; fi + if [[ "$1" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi return 1 } -match() { # $1: the command line, NULs as spaces. Prints the rule that matched. - local c=" $1 " - if [[ "$c" =~ $RE_SNAPSHOT ]]; then sourced "${c:${#BASH_REMATCH[0]}}"; return; fi - if [[ "$c" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi - if [[ "$c" =~ $RE_GATE ]]; then echo "gate"; return 0; fi - if [[ "$c" == *mutant* ]]; then echo "mutant"; return 0; fi - if [[ "$c" =~ $RE_TEST ]]; then echo "test script"; return 0; fi - if [[ "$c" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi - return 1 +# The characters the flat form changes, other than a single space. A command +# line with none of them and no two blanks in a row is its own flat form. +SPECIAL=$'[\"\'\\\\;&|(){}<>`\t\n\r\v\f]' +# flat TEXT: prints F, then TEXT in the flat form. awk, not bash: a bash +# ${s//x/y} costs time per match, and a 2 MB line full of quotes took minutes. +flat() { + printf '%s' "$1" | awk ' + BEGIN { RS = "\001" } + { t = (NR > 1) ? t RS $0 : $0 } + END { gsub(/["\047\\]/, "", t); gsub(/[[:space:];&|(){}<>`]+/, " ", t); printf "F%s", t } + ' +} +match() { # $1: the command line, NULs as spaces. Prints the rule; 1 none, 2 unclassified. + local n=$1 + if [[ "$n" == *$SPECIAL* || "$n" == *" "* ]]; then + n=$(flat "$1") && [[ "$n" == F* ]] || return 2 + n=${n#F} + fi + if [[ " $n " =~ $RE_SNAPSHOT ]]; then sourced "$1"; return; fi + rules " $n " || rules " $1 " } seen=0 @@ -119,13 +213,19 @@ for d in "$PROC"/[0-9]*; do fi cmd=${cmd% } [ -n "$cmd" ] || continue - rule=$(match "$cmd") || continue + rule=$(match "$cmd"); rc=$? + [ "$rc" != 1 ] || continue + if [ "$rc" != 0 ]; then + echo "slot-busy: error: cannot classify the command line of $d" >&2 + errors=$((errors + 1)) + continue + fi cwd=$(readlink "$d/cwd" 2>/dev/null) || cwd="?" printf 'BUSY %s %s cwd=%s %s\n' "$pid" "$rule" "$cwd" "${cmd:0:200}" busy=1 done [ "$seen" -ge 1 ] || { echo "slot-busy: error: no processes under $PROC" >&2; exit 2; } -[ "$errors" = 0 ] || { echo "slot-busy: error: $errors process(es) could not be read, so the host isn't known to be idle" >&2; exit 2; } +[ "$errors" = 0 ] || { echo "slot-busy: error: $errors process(es) could not be read or classified, so the host isn't known to be idle" >&2; exit 2; } if [ "$busy" = 1 ]; then exit 1; fi echo "CLEAR" exit 0 diff --git a/scripts/test-git-guard.sh b/scripts/test-git-guard.sh index 4d7301793..f15e5abdb 100755 --- a/scripts/test-git-guard.sh +++ b/scripts/test-git-guard.sh @@ -273,7 +273,7 @@ rule() { # $1: expected rule, rest: argv local want=$1; shift rm -rf "${P:?}/4194420"; fakeproc 4194420 /seat/b "$@" out=$(busyrun); rc=$? - check "busy on: $* ($want)" "$([ "$rc" = 1 ] && has "$out" "BUSY 4194420 $want "; echo $?)" + check "busy on: ${*//$'\n'/\\n} ($want)" "$([ "$rc" = 1 ] && has "$out" "BUSY 4194420 $want "; echo $?)" } rule "node --test" /usr/bin/node --experimental-strip-types --test a.test.ts rule "gate" bash /mnt/scratch/sage-gate.sh 253 @@ -297,6 +297,58 @@ rule "sourced test script" /bin/bash -c "$SNAP && source \"scripts/test-x.sh\" a rule "sourced test script" /bin/bash -c "$SNAP && source .venv/bin/activate && . scripts/test-x.sh" rule "sourced gate" /usr/bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-2-ab.sh 2>/dev/null || true && eval '. ./gate.sh 253' && pwd -P >| /home/u/cwd" rule "sourced mutant" /bin/bash -c "$SNAP && cd /seat && eval 'source agents/x/work/queue-1/mutants.sh M3'" +# R5 (N2): every word up to the next operator counts, not only the first; and +# quoted, escaped and nested forms read the way the shell reads them. +rule "sourced test script" /bin/bash -c "$SNAP && source 2>/dev/null /seat/scripts/test-x.sh" +rule "sourced test script" /bin/bash -c "$SNAP && source \$(cd scripts && pwd)/test-x.sh" +rule "sourced test script" /bin/bash -c "$SNAP && source \`cd scripts; pwd\`/test-x.sh" +rule "sourced test script" /bin/bash -c "$SNAP && source \"/seat/a;b (c)/scripts/test-x.sh\"" +rule "sourced test script" /bin/bash -c "$SNAP && eval 'source \"/seat/a;b/scripts/test-x.sh\"'" +rule "sourced test script" /bin/bash -c "$SNAP && eval \"source \\\"/seat/dir with spaces/scripts/test-x.sh\\\"\"" +rule "sourced test script" /usr/bin/bash -c "$SNAP 2>/dev/null || true && eval 'source '\\''scripts/test-x.sh'\\''' && pwd -P >| /home/u/cwd" +rule "sourced test script" /bin/bash -c "$SNAP && eval \$'source scripts/test-x.sh'" +rule "sourced test script" /bin/bash -c "$SNAP && cat <&1); rc=$? +check "under LC_ALL=C.UTF-8, a test script name holding the byte 0xff: exit 1" \ + "$([ "$rc" = 1 ] && has "$out" "BUSY 4194420 test script "; echo $?)" +# Quoting that nests deeper than the walk reads is an error, never CLEAR. Each +# level escapes backslashes and double quotes and wraps the text in quotes. +nest() { # $1: levels. Sets w. + w='source scripts/test-x.sh' + for _ in $(seq "$1"); do w=$(printf '%s' "$w" | sed 's/[\\"]/\\&/g'); w="\"$w\""; done +} +nest 16 +rm -rf "${P:?}/4194420"; fakeproc 4194420 /seat/b /bin/bash -c "$SNAP && eval $w" +out=$(busyrun); rc=$? +check "a sourced test under 16 escaped quoting levels (${#w} bytes): exit 1, sourced test script" \ + "$([ "$rc" = 1 ] && has "$out" "BUSY 4194420 sourced test script "; echo $?)" +nest 17 +rm -rf "${P:?}/4194420"; fakeproc 4194420 /seat/b /bin/bash -c "$SNAP && eval $w" +out=$(busyrun); rc=$? +check "quoting 17 escaped levels deep (${#w} bytes): exit 2, cannot classify, no CLEAR" \ + "$([ "$rc" = 2 ] && has "$out" "cannot classify the command line of $P/4194420" && ! has "$out" CLEAR; echo $?)" rm -rf "${P:?}/4194420" echo "== slot-busy, read errors ==" @@ -362,9 +414,9 @@ fi exec $(command -v awk) "\$@" EOF chmod +x "$SANDBOX/awkn/awk" -awkn() { # $1: fail, empty or exited. Runs the check on the idle fake /proc. - echo "$1" > "$SANDBOX/awkn/mode"; rm -f "$SANDBOX/awkn/n" - out=$(PATH="$SANDBOX/awkn:$PATH" bash "$BUSY" --proc-root "$P" 2>&1); rc=$? +awkn() { # $1: fail, empty or exited; rest: NAME=VALUE for the check's environment. + echo "$1" > "$SANDBOX/awkn/mode"; rm -f "$SANDBOX/awkn/n"; shift + out=$(env "$@" PATH="$SANDBOX/awkn:$PATH" bash "$BUSY" --proc-root "$P" 2>&1); rc=$? calls=$(cat "$SANDBOX/awkn/n" 2>/dev/null || echo 0) } awkn fail @@ -377,6 +429,48 @@ awkn exited check "an ancestor that has exited (its pid gone from /proc) ends the walk: exit 0 and CLEAR" \ "$([ "$calls" -ge 3 ] && [ "$rc" = 0 ] && [ "$out" = CLEAR ]; echo $?)" +# R5 (N1): the gone check reads the proc root's listing whatever the caller's +# QUOTING_STYLE is. A quoted name would read as a vanished pid, and both +# failed reads above would turn into CLEAR. +for qs in shell-always c escape shell-escape-always locale; do + out=$(QUOTING_STYLE=$qs PATH="$SANDBOX/failtr:$PATH" bash "$BUSY" --proc-root "$P" 2>&1); rc=$? + check "QUOTING_STYLE=$qs: a failing cmdline read with the pid still listed: exit 2, no CLEAR" \ + "$([ "$rc" = 2 ] && has "$out" "cannot read $P/" && ! has "$out" CLEAR; echo $?)" + awkn fail QUOTING_STYLE=$qs + check "QUOTING_STYLE=$qs: a failing ancestor status read with the pid still listed: exit 2, no CLEAR" \ + "$([ "$calls" -ge 2 ] && [ "$rc" = 2 ] && has "$out" "cannot read the status of ancestor" && ! has "$out" CLEAR; echo $?)" +done + +# The flat form and the walk over a wrapper's text are awk programs. If +# either awk fails, or prints nothing, the command line can't be classified: +# exit 2, never CLEAR. This awk fails or goes silent for one program only +# (the walk has `function walk`, the flat form `gsub`). +mkdir -p "$SANDBOX/lexawk" +cat > "$SANDBOX/lexawk/awk" < /dev/null; exit 1 ;; walk-empty) cat > /dev/null; exit 0 ;; esac ;; + *gsub*) case \$m in flat-fail) cat > /dev/null; exit 1 ;; flat-empty) cat > /dev/null; exit 0 ;; esac ;; +esac +exec $(command -v awk) "\$@" +EOF +chmod +x "$SANDBOX/lexawk/awk" +lexawk() { # $1: mode, $2: label, rest: argv of a process that is BUSY + local mode=$1 label=$2; shift 2 + fakeproc 4194460 /seat/w "$@" + echo "$mode" > "$SANDBOX/lexawk/mode" + out=$(PATH="$SANDBOX/lexawk:$PATH" bash "$BUSY" --proc-root "$P" 2>&1); rc=$? + check "$label: exit 2, cannot classify, no CLEAR" \ + "$([ "$rc" = 2 ] && has "$out" "cannot classify the command line of $P/4194460" && ! has "$out" CLEAR; echo $?)" + rm -rf "${P:?}/4194460" +} +lexawk walk-fail "the wrapper walk's awk exits 1" /bin/bash -c "$SNAP && source /seat/scripts/test-x.sh" +lexawk walk-empty "the wrapper walk's awk prints nothing" /bin/bash -c "$SNAP && source /seat/scripts/test-x.sh" +# Only the flat form names this one (`.sh;` ends no rule's match as it is). +lexawk flat-fail "the flat form's awk exits 1" /bin/bash -c "bash scripts/test-x.sh;echo done" +lexawk flat-empty "the flat form's awk prints nothing" /bin/bash -c "bash scripts/test-x.sh;echo done" + # A lookup that is denied isn't a process that vanished. This tr makes the # proc root unreadable once the pid directory is listed, then fails the read; # the pid directory and its node --test cmdline stay where they are. @@ -497,6 +591,55 @@ check "the wrapper itself has no BUSY line" "$(! printf '%s\n' "$out" | grep -q for _ in $(seq 50); do kill -0 "$WRAP" 2>/dev/null || break; sleep 0.1; done if ! kill -0 "$WRAP" 2>/dev/null; then wait "$WRAP" 2>/dev/null; KIDS=""; fi +# R5 (N2): the 13 shapes from the R4 review's Check 2, each a live wrapper +# that runs the sourced file in its own shell, with no child. S is the +# snapshot, SF a snapshot that holds `false` (so `||` runs the test), T the +# test. The sourced file blocks as test-x.sh does above. +S="$SANDBOX/shell-snapshots/snapshot-x.sh" +SF="$SANDBOX/shell-snapshots/snapshot-f.sh" +T="$SANDBOX/scripts/test-x.sh" +echo false > "$SF" +mkdir -p "$SANDBOX/wrap/scripts" "$SANDBOX/dir with spaces/scripts" +for f in wrap/scripts/test-x.sh wrap/gate.sh wrap/mutants.sh "dir with spaces/scripts/test-x.sh"; do + cp "$T" "$SANDBOX/$f" +done +live() { # $1: shape number, $2: expected rule or CLEAR, $3: label, $4: the wrapper's text + local n=$1 want=$2 label=$3 lp + rm -f "$SANDBOX/wrap/ready" + (cd "$SANDBOX/wrap" && exec bash -c "$4") & + WRAP=$!; KIDS="$KIDS $WRAP" + for _ in $(seq 50); do [ -e "$SANDBOX/wrap/ready" ] && break; sleep 0.1; done + kids=$(cat "/proc/$WRAP/task/$WRAP/children") && kids="none$kids" + lp="$SANDBOX/lproc-s$n"; mkdir -p "$lp"; ln -s "/proc/$WRAP" "$lp/$WRAP" + out=$(bash "$BUSY" --proc-root "$lp" 2>&1); rc=$? + if [ "$want" = CLEAR ]; then + check "live shape $n, $label, no child: exit 0 and CLEAR" \ + "$([ -e "$SANDBOX/wrap/ready" ] && [ "$kids" = none ] && [ "$rc" = 0 ] && [ "$out" = CLEAR ]; echo $?)" + else + check "live shape $n, $label, no child: exit 1, $want" \ + "$([ -e "$SANDBOX/wrap/ready" ] && [ "$kids" = none ] && [ "$rc" = 1 ] && ! has "$out" CLEAR \ + && printf '%s\n' "$out" | grep -q "^BUSY $WRAP $want cwd=$SANDBOX/wrap "; echo $?)" + fi + kill "$WRAP" 2>/dev/null; wait "$WRAP" 2>/dev/null; KIDS="" +} +live 1 "sourced test script" 'source S && source T' "source $S && source $T" +live 2 "sourced test script" "source S && .'scripts/test-x.sh'" "source $S && ."$'\t\t'"'scripts/test-x.sh'" +live 3 "sourced test script" 'source S ; source T ; :' "source $S ; source $T ; :" +live 4 "sourced test script" 'source SF || source T' "source $SF || source $T" +live 5 "sourced gate" 'source S && . "./gate.sh"' "source $S && . \"./gate.sh\"" +live 6 "sourced mutant" 'source S && source ./mutants.sh' "source $S && source ./mutants.sh" +live 7 "sourced test script" 'source S && source ".../dir with spaces/scripts/test-x.sh"' \ + "source $S && source \"$SANDBOX/dir with spaces/scripts/test-x.sh\"" +live 8 "sourced test script" 'source S && "source" T' "source $S && \"source\" $T" +live 9 "sourced test script" 'source S;source T;:' "source $S;source $T;:" +live 10 "sourced test script" 'source S;. T;:' "source $S;. $T;:" +live 11 "sourced test script" 'source "S" && source "T"' "source \"$S\" && source \"$T\"" +live 12 "sourced test script" 'sourceS && source "T"' "source"$'\t'"$S && source \"$T\"" +live 13 "sourced test script" 'sourceS && source "T"' "source $S && source \"$T\"" +# A plain wrapper that blocks the same way but sources no test stays CLEAR. +live 14 CLEAR 'a plain wrapper, the same blocking read inline' \ + "source $S && exec 3<>\"$SANDBOX/wrap/fifo\" && : > \"$SANDBOX/wrap/ready\" && read -t 60 -u 3 _" + echo "---" echo "git-guard selftests: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ]