Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions .mise/tasks/lint/exec-stderr-silence
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
#MISE description="Flag 'exec' with stderr redirection — suppression persists in the current shell"
#USAGE arg "<targets>…" 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"
150 changes: 150 additions & 0 deletions test/lint/exec-stderr-silence/exec-stderr-silence.bats
Original file line number Diff line number Diff line change
@@ -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"* ]]
}
4 changes: 4 additions & 0 deletions test/lint/exec-stderr-silence/fixtures/broad-walk/bin/tool
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Extensionless shebang file — broad walk must find it.
echo "ok" || :
exec 2>/dev/null
2 changes: 2 additions & 0 deletions test/lint/exec-stderr-silence/fixtures/broad-walk/mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
bash = "latest"
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions test/lint/exec-stderr-silence/fixtures/clean/.mise/tasks/probe
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions test/lint/exec-stderr-silence/fixtures/clean/mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
bash = "latest"
33 changes: 33 additions & 0 deletions test/lint/exec-stderr-silence/fixtures/dirty/.mise/tasks/probe
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions test/lint/exec-stderr-silence/fixtures/dirty/mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
bash = "latest"
2 changes: 2 additions & 0 deletions test/lint/exec-stderr-silence/fixtures/empty/mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
# Empty fixture — no shell files.
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions test/lint/exec-stderr-silence/fixtures/ignored-file/mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# codebase:ignore exec-stderr-silence
[tools]
bash = "latest"
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
bash = "latest"
Loading