防御性Bash 脚本(Linux):安全基础、参数解析、生产模式、ShellCheck 合规。用于编写 bash 脚本、shell 脚本。
Produce bash scripts that pass shellcheck --enable=all and shfmt -d with zero warnings.
Target: GNU Bash 4.4+ on Linux. No macOS/BSD workarounds, no Windows paths, no POSIX-only restrictions.
bash
#!/usr/bin/env bash
set -Eeuo pipefail
shopt -s inherit_errexit
SCRIPT_DIR=$(dirname -- "${BASH_SOURCE[0]}") || exit "$?"
SCRIPT_DIR=$(cd -- "${SCRIPT_DIR}" && pwd -P) || exit "$?"
readonly SCRIPT_DIR
trap 'printf "Error at %s:%d\n" "${BASH_SOURCE[0]}" "$LINENO" >&2' ERR
trap 'rm -rf -- "${_tmpdir:-}"' EXIT
-E propagates ERR traps into functionsinherit_errexit propagates errexit into $() command substitutionsSCRIPT_DIR, never the caller's cwd or git rev-parse --show-toplevel. A shared linter invoked from another project's git hook, a cron job, or a wrapper runs with someone else's cwd, so a caller-relative rules path resolves to a file that does not exist: the rule set loads empty, zero violations are found, exit 0. It is a silent no-op, not an error, and running it from inside its own repo passes for the wrong reason. Exercise it once from a scratch directory that is not the script's own tree_tmpdir=$(mktemp -d)main function. Guard its invocation so direct execution runs it and sourcing only defines functions for testing.10# before applying the sign; 10#-08 is invalid.local and readonly declarations from command substitutions.sh, GNU utility modes behave like syscall modes, or a configured fallback path is usable.Read the relevant reference before implementing the matching behavior:
${path%/*} not dirname, ${path##*/} not basename, ${var//old/new} not sed(( )) over expr; [[ =~ ]] over echo | grepval=$(cmd) once, reuse $valxargs -0 -P "$(nproc)" for parallel workdeclare -A map for lookups instead of repeated grep${var@Q} shell-quoted (Bash 4.4+); ${var@U} uppercase and ${var@L} lowercase (Bash 5.1+)declare -n ref=varname nameref for indirect accesswait -n wait for any background job$EPOCHSECONDS, $EPOCHREALTIME: timestamps without forking date (Bash 5.0+)sed -i (no '' suffix), grep -P (PCRE support), readlink -f (canonical path)timeout 30s cmd to prevent automation hangssed -i replaces a symlink with a regular file: it writes a temp file and renames it over the path, so the two copies diverge later with no error. Use sed -i --follow-symlinks or edit the target by name, and check git status --short for T (typechange) after scripted editsRun shellcheck --enable=all script.sh. Key rules:
cd dir || exit${BASH_REMATCH[n]}, not positional parametersPre-commit: shellcheck *.sh && shfmt -i 2 -ci -d *.sh
Run shellcheck --enable=all and shfmt -d with zero warnings before declaring done. Test edge cases: empty input, missing files, spaces in paths.
If shellcheck or shfmt is not installed ( command -v shellcheck fails), the check was skipped, not passed: report "static analysis not run: shellcheck unavailable" and fall back to bash -n for syntax only. A skipped linter is the same silent no-op as the empty rules file above.