From 02f5c215175374588e831b00906fa470b1de816d Mon Sep 17 00:00:00 2001 From: olavostauros Date: Wed, 24 Jun 2026 11:26:45 -0300 Subject: [PATCH] =?UTF-8?q?feat(lint):=20add=20exec-stderr-silence=20rule?= =?UTF-8?q?=20=E2=80=94=20flag=20persistent=20exec=20stderr=20suppression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds text-based detection for 'exec' with stderr redirection (2>/dev/null, 2>&-, 2>!) that persists for the current shell. - Detection via bash regex: \bexec\b[^#]*2> with dangerous target - Fixtures: clean, dirty, ignored-inline, ignored-file, broad-walk, no-toml, empty - 17 BATS tests covering detection, ignore mechanisms, discovery, edge cases - Self-hosting verified: task file does not self-report - Follows or-true pattern for output format, ignore conventions, and scope --- .mise/tasks/lint/exec-stderr-silence | 128 +++++++++++++++ .../exec-stderr-silence.bats | 150 ++++++++++++++++++ .../fixtures/broad-walk/bin/tool | 4 + .../fixtures/broad-walk/mise.toml | 2 + .../fixtures/broad-walk/scripts/deploy.sh | 7 + .../fixtures/clean/.mise/tasks/probe | 12 ++ .../fixtures/clean/mise.toml | 2 + .../fixtures/dirty/.mise/tasks/probe | 33 ++++ .../fixtures/dirty/mise.toml | 2 + .../fixtures/empty/mise.toml | 2 + .../fixtures/ignored-file/.mise/tasks/probe | 10 ++ .../fixtures/ignored-file/mise.toml | 3 + .../fixtures/ignored-inline/.mise/tasks/probe | 11 ++ .../fixtures/ignored-inline/mise.toml | 2 + .../fixtures/no-toml/.mise/tasks/greet | 3 + 15 files changed, 371 insertions(+) create mode 100755 .mise/tasks/lint/exec-stderr-silence create mode 100644 test/lint/exec-stderr-silence/exec-stderr-silence.bats create mode 100644 test/lint/exec-stderr-silence/fixtures/broad-walk/bin/tool create mode 100644 test/lint/exec-stderr-silence/fixtures/broad-walk/mise.toml create mode 100644 test/lint/exec-stderr-silence/fixtures/broad-walk/scripts/deploy.sh create mode 100644 test/lint/exec-stderr-silence/fixtures/clean/.mise/tasks/probe create mode 100644 test/lint/exec-stderr-silence/fixtures/clean/mise.toml create mode 100644 test/lint/exec-stderr-silence/fixtures/dirty/.mise/tasks/probe create mode 100644 test/lint/exec-stderr-silence/fixtures/dirty/mise.toml create mode 100644 test/lint/exec-stderr-silence/fixtures/empty/mise.toml create mode 100644 test/lint/exec-stderr-silence/fixtures/ignored-file/.mise/tasks/probe create mode 100644 test/lint/exec-stderr-silence/fixtures/ignored-file/mise.toml create mode 100644 test/lint/exec-stderr-silence/fixtures/ignored-inline/.mise/tasks/probe create mode 100644 test/lint/exec-stderr-silence/fixtures/ignored-inline/mise.toml create mode 100644 test/lint/exec-stderr-silence/fixtures/no-toml/.mise/tasks/greet diff --git a/.mise/tasks/lint/exec-stderr-silence b/.mise/tasks/lint/exec-stderr-silence new file mode 100755 index 0000000..eec89d9 --- /dev/null +++ b/.mise/tasks/lint/exec-stderr-silence @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +#MISE description="Flag 'exec' with stderr redirection — suppression persists in the current shell" +#USAGE arg "…" help="Paths to codebases to check (one or more)" +#USAGE example "codebase lint:exec-stderr-silence ." header="Flag exec stderr suppression" +#USAGE example "exec 3<>\"$path\" 2>/dev/null # codebase:ignore exec-stderr-silence — intentional fd setup" header="Intentional exec-replacements need a rule-specific reason" + +set -euo pipefail + +# shellcheck source=../../../lib/shell-files.sh +source "$MISE_CONFIG_ROOT/lib/shell-files.sh" + +# Rationale: 'exec' with stderr redirection (2>/dev/null, 2>&-, 2>!) +# applies the redirect to the current shell, not just the exec'd command. +# Inside an 'if' condition, '||', or function body, this suppresses all +# subsequent stderr output — including BATS diagnostic output, error +# messages from the containing function, etc. +# +# Safe alternative: group the exec probe so the redirection is scoped: +# +# if ! { exec 3<>"$tty_path"; } 2>/dev/null; then +# echo "Error: confirmation required" >&2 +# return 2 +# fi +# +# For intentional process replacement / fd setup, add a rule-specific +# inline ignore with a reason. + +IFS=' ' read -ra TARGETS <<< "${usage_targets}" + +if [[ ${#TARGETS[@]} -eq 0 ]]; then + echo "ERROR: at least one target is required" >&2 + exit 1 +fi + +# Resolve relative paths against CALLER_PWD (see lib/shell-files.sh). +for i in "${!TARGETS[@]}"; do + TARGETS[$i]=$(resolve_target "${TARGETS[$i]}") +done + +# Pattern: line contains 'exec' followed (after skipping comments) by '2>' +# with a stderr suppression/discard target. +# We test for dangerous stderr targets directly: +# /dev/null — discard +# &- — close +# &1 — merge to stdout (also suppresses if stdout is null) +# ! — noclobber override (when used with /dev/null or null target) +EXEC_STDERR_PATTERN='\bexec\b[^#]*2>([[:space:]]*/dev/(null|zero)|[[:space:]]*&[-1!?]|[[:space:]]*!)' + +has_rule_specific_ignore() { + local line="$1" + [[ "$line" =~ codebase:ignore[[:space:]]+exec-stderr-silence[[:space:]]+ ]] +} + +scan_file() { + local file="$1" + local lineno=0 + local line + + while IFS= read -r line || [[ -n "$line" ]]; do + lineno=$((lineno + 1)) + + # Skip full-line comments. + [[ "$line" =~ ^[[:space:]]*# ]] && continue + + # Check for exec with stderr suppression pattern. + if [[ "$line" =~ $EXEC_STDERR_PATTERN ]]; then + # Rule-specific inline ignore with a reason is accepted. + if has_rule_specific_ignore "$line"; then + continue + fi + + local trimmed="${line#"${line%%[![:space:]]*}"}" + echo "$lineno:$trimmed" + fi + done < "$file" +} + +failures=0 + +for target in "${TARGETS[@]}"; do + if [[ ! -e "$target" ]]; then + echo "ERROR: target does not exist: $target" >&2 + exit 1 + fi + + name=$(basename "$target") + + # File-level ignore via mise.toml + toml="$target/mise.toml" + if [[ -f "$toml" ]] && grep -m1 -q 'codebase:ignore exec-stderr-silence' "$toml"; then + echo "SKIP $name (codebase:ignore)" + continue + fi + + # Collect shell files + files=() + while IFS= read -r f; do + [[ -n "$f" ]] && files+=("$f") + done < <(discover_shell_files "$target") + + if [[ ${#files[@]} -eq 0 ]]; then + echo "OK $name (no shell files found)" + continue + fi + + # Scan each file — collect hits + hit_count=0 + target_output="" + for file in "${files[@]}"; do + rel="${file#"$target"/}" + while IFS= read -r hit; do + [[ -z "$hit" ]] && continue + target_output+=" $rel:$hit"$'\n' + hit_count=$((hit_count + 1)) + done < <(scan_file "$file") + done + + if [[ "$hit_count" -gt 0 ]]; then + echo "FAIL $name: $hit_count exec stderr suppression(s)" + printf '%s' "$target_output" + echo " hint: exec with stderr redirection affects the current shell. Use a grouping construct ({ exec ...; } redirect stderr) or save/restore stderr explicitly." + failures=$((failures + 1)) + else + echo "OK $name (${#files[@]} file(s) clean)" + fi +done + +exit "$failures" \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/exec-stderr-silence.bats b/test/lint/exec-stderr-silence/exec-stderr-silence.bats new file mode 100644 index 0000000..11edbd9 --- /dev/null +++ b/test/lint/exec-stderr-silence/exec-stderr-silence.bats @@ -0,0 +1,150 @@ +#!/usr/bin/env bats +# Tests for lint:exec-stderr-silence rule + +load ../../test_helper + +setup() { + FIXTURES="$BATS_TEST_DIRNAME/fixtures" +} + +# Detection — basic + +@test "exec-stderr-silence: passes on a clean codebase" { + run codebase lint:exec-stderr-silence "$FIXTURES/clean" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"clean"* ]] +} + +@test "exec-stderr-silence: flags 'exec 3<>path 2>/dev/null'" { + run codebase lint:exec-stderr-silence "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty"* ]] + [[ "$output" == *"exec 3<>"* ]] + [[ "$output" == *"2>/dev/null"* ]] +} + +@test "exec-stderr-silence: flags 'exec 2>/dev/null' standalone" { + run codebase lint:exec-stderr-silence "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"exec 2>/dev/null"* ]] +} + +@test "exec-stderr-silence: flags 'exec ... 2>&-' (close stderr)" { + run codebase lint:exec-stderr-silence "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"2>&-"* ]] +} + +@test "exec-stderr-silence: flags 'exec ... 2>!' (noclobber override)" { + run codebase lint:exec-stderr-silence "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"2>!"* ]] +} + +@test "exec-stderr-silence: flags exec inside a function body" { + run codebase lint:exec-stderr-silence "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"exec 3<>\"\$tty\" 2>/dev/null"* ]] +} + +# Safe patterns — no false positives + +@test "exec-stderr-silence: does NOT flag 'exec 3<>path' without stderr redirect" { + run codebase lint:exec-stderr-silence "$FIXTURES/clean" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"clean"* ]] +} + +# Counting + +@test "exec-stderr-silence: fail output names exec suppressions in dirty fixture" { + run codebase lint:exec-stderr-silence "$FIXTURES/dirty" + [ "$status" -ne 0 ] + # The dirty fixture has these violations (excluding the annotated line 19): + # Line 5: exec 3<>"$tty_path" 2>/dev/null + # Line 10: exec 2>/dev/null cat /tmp/nope + # Line 13: exec 2>/dev/null + # Line 16: exec 3<>"$path" 2>&- + # Line 25: exec 3<>"$path" 2>!"$tty_path" + # Line 30: exec 3<>"$tty" 2>/dev/null + # = 6 violations + [[ "$output" == *"6 exec stderr suppression"* ]] +} + +# Diagnostic content + +@test "exec-stderr-silence: fail output includes the violating file path" { + run codebase lint:exec-stderr-silence "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"probe"* ]] +} + +@test "exec-stderr-silence: fail output includes the grouping hint" { + run codebase lint:exec-stderr-silence "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"grouping construct"* ]] || [[ "$output" == *"{ exec ...; }"* ]] +} + +# Ignore directives + +@test "exec-stderr-silence: inline '# codebase:ignore exec-stderr-silence — reason' skips the line" { + run codebase lint:exec-stderr-silence "$FIXTURES/ignored-inline" + [ "$status" -ne 0 ] + # The annotated lines are skipped, but the unannotated line 13 should still be flagged + [[ "$output" == *"FAIL"*"ignored-inline"* ]] + [[ "$output" != *"intentional exec fd setup"* ]] +} + +@test "exec-stderr-silence: 'codebase:ignore exec-stderr-silence' in mise.toml skips the whole target" { + run codebase lint:exec-stderr-silence "$FIXTURES/ignored-file" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIP"*"ignored-file"* ]] +} + +# Scope / discovery + +@test "exec-stderr-silence: walks the whole target — finds hits outside .mise/tasks/" { + run codebase lint:exec-stderr-silence "$FIXTURES/broad-walk" + [ "$status" -ne 0 ] + [[ "$output" == *"scripts/deploy.sh"* ]] + [[ "$output" == *"bin/tool"* ]] +} + +@test "exec-stderr-silence: works on a codebase with no mise.toml" { + run codebase lint:exec-stderr-silence "$FIXTURES/no-toml" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"no-toml"* ]] +} + +@test "exec-stderr-silence: handles empty directory (no shell files)" { + run codebase lint:exec-stderr-silence "$FIXTURES/empty" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"empty"* ]] +} + +# Codebase: ignore mechanism — inline ignore without reason is not enough + +@test "exec-stderr-silence: generic inline ignore without rule and reason does not suppress" { + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/.mise/tasks" + cat > "$tmp/.mise/tasks/probe" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +exec 3<>"$path" 2>/dev/null # codebase:ignore +EOF + + run codebase lint:exec-stderr-silence "$tmp" + [ "$status" -ne 0 ] + [[ "$output" == *"exec 3<>"* ]] + rm -rf "$tmp" +} + +# Help output + +@test "exec-stderr-silence: help output describes the rule" { + run bash -c 'cd "$REPO_DIR" && mise tasks info lint:exec-stderr-silence' + [ "$status" -eq 0 ] + [[ "$output" == *"exec"* ]] + [[ "$output" == *"persists"* ]] +} \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/broad-walk/bin/tool b/test/lint/exec-stderr-silence/fixtures/broad-walk/bin/tool new file mode 100644 index 0000000..7f87d18 --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/broad-walk/bin/tool @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Extensionless shebang file — broad walk must find it. +echo "ok" || : +exec 2>/dev/null \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/broad-walk/mise.toml b/test/lint/exec-stderr-silence/fixtures/broad-walk/mise.toml new file mode 100644 index 0000000..9dddbab --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/broad-walk/mise.toml @@ -0,0 +1,2 @@ +[tools] +bash = "latest" \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/broad-walk/scripts/deploy.sh b/test/lint/exec-stderr-silence/fixtures/broad-walk/scripts/deploy.sh new file mode 100644 index 0000000..53a1919 --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/broad-walk/scripts/deploy.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Not under .mise/tasks/ — broad walk must still find this. +set -euo pipefail +if ! exec 3<>"$remote_path" 2>/dev/null; then + echo "probe failed" >&2 + exit 1 +fi \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/clean/.mise/tasks/probe b/test/lint/exec-stderr-silence/fixtures/clean/.mise/tasks/probe new file mode 100644 index 0000000..fb35047 --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/clean/.mise/tasks/probe @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Helper — no exec redirections. + +safe_cmd() { + local arg="$1" + echo "$arg" +} + +other_cmd() { + exec 3<>"$1" + # No stderr suppression — safe +} \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/clean/mise.toml b/test/lint/exec-stderr-silence/fixtures/clean/mise.toml new file mode 100644 index 0000000..9dddbab --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/clean/mise.toml @@ -0,0 +1,2 @@ +[tools] +bash = "latest" \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/dirty/.mise/tasks/probe b/test/lint/exec-stderr-silence/fixtures/dirty/.mise/tasks/probe new file mode 100644 index 0000000..28c85ce --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/dirty/.mise/tasks/probe @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Line 5: Exec with fd redirect and stderr suppression — DANGEROUS +if ! exec 3<>"$tty_path" 2>/dev/null; then + echo "Error: confirmation required" >&2 + return 2 +fi + +# Line 10: Exec with stderr redirected to /dev/null standalone +var=$(exec 2>/dev/null cat /tmp/nope || true) + +# Line 13: Standalone exec suppressing stderr +exec 2>/dev/null + +# Line 16: Exec with close-stderr +exec 3<>"$path" 2>&- + +# Line 19: Intentional fd setup — this one should be ignored because annotated +exec 3<>/dev/tty 2>&- # codebase:ignore exec-stderr-silence — intentional fd setup + +# Line 22: This is safe — redirects stdout not stderr +exec 3<>"$path" + +# Line 25: Exec with noclobber override redirect +exec 3<>"$path" 2>!"$tty_path" + +# Line 28: Another exec with stderr to /dev/null inside function +probe_tty() { + local tty="$1" + exec 3<>"$tty" 2>/dev/null + echo "probed $tty" >&2 +} \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/dirty/mise.toml b/test/lint/exec-stderr-silence/fixtures/dirty/mise.toml new file mode 100644 index 0000000..9dddbab --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/dirty/mise.toml @@ -0,0 +1,2 @@ +[tools] +bash = "latest" \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/empty/mise.toml b/test/lint/exec-stderr-silence/fixtures/empty/mise.toml new file mode 100644 index 0000000..3a055a3 --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/empty/mise.toml @@ -0,0 +1,2 @@ +[tools] +# Empty fixture — no shell files. \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/ignored-file/.mise/tasks/probe b/test/lint/exec-stderr-silence/fixtures/ignored-file/.mise/tasks/probe new file mode 100644 index 0000000..74beb76 --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/ignored-file/.mise/tasks/probe @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Whole file is intentionally ignored via mise.toml +if ! exec 3<>"$tty_path" 2>/dev/null; then + echo "Error" >&2 + return 2 +fi + +exec 2>/dev/null \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/ignored-file/mise.toml b/test/lint/exec-stderr-silence/fixtures/ignored-file/mise.toml new file mode 100644 index 0000000..cdfc9f9 --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/ignored-file/mise.toml @@ -0,0 +1,3 @@ +# codebase:ignore exec-stderr-silence +[tools] +bash = "latest" \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/ignored-inline/.mise/tasks/probe b/test/lint/exec-stderr-silence/fixtures/ignored-inline/.mise/tasks/probe new file mode 100644 index 0000000..840a3d2 --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/ignored-inline/.mise/tasks/probe @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This usage is legitimate (intentional fd setup) and annotated. +exec 3<>"$path" 2>/dev/null # codebase:ignore exec-stderr-silence — intentional exec fd setup for subprocess + +# Also annotated +exec 2>/dev/null # codebase:ignore exec-stderr-silence — intentional: discarding stderr for a specific probe + +# But this one is NOT annotated — should still be flagged +exec 3<>"$tty" 2>/dev/null \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/ignored-inline/mise.toml b/test/lint/exec-stderr-silence/fixtures/ignored-inline/mise.toml new file mode 100644 index 0000000..9dddbab --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/ignored-inline/mise.toml @@ -0,0 +1,2 @@ +[tools] +bash = "latest" \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/no-toml/.mise/tasks/greet b/test/lint/exec-stderr-silence/fixtures/no-toml/.mise/tasks/greet new file mode 100644 index 0000000..150d440 --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/no-toml/.mise/tasks/greet @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# No mise.toml in this fixture — rule must run without one. +echo "hi" \ No newline at end of file