01 — Shell Architecture, Shebang Semantics & Strict-Mode Internals

How the Kernel Resolves a Shebang

bash
#!/usr/bin/env bash
# The kernel's execve() reads the first ~128 bytes (BINPRM_BUF_SIZE).
# If it finds "#!", the rest of the line is split into interpreter + optional single arg.
#   #!/usr/bin/env bash        → execve("/usr/bin/env", ["env", "bash", scriptpath])
#   #!/bin/bash                → execve("/bin/bash", ["bash", scriptpath])
#   #!/usr/bin/env bash -Eeuo pipefail → execve("/usr/bin/env", ["env", "bash -Eeuo pipefail", scriptpath])
#     ^-- "bash -Eeuo pipefail" is passed as ONE arg to env (kernel allows max 1 arg).
#          env splits it via its own argv parsing — works on Linux, NOT portable to all Unices.
#
# CRLF trap: "#!/usr/bin/env bash\r\n" → interpreter name is "bash\r" → "No such file or directory".
# Fix: sed -i 's/\r$//' script.sh  OR  configure editor to LF.

# Verify the resolved interpreter path and version at runtime:
printf 'interpreter: %s\n' "$(readlink -f "$(command -v bash)")"
printf 'bash version: %s\n' "$BASH_VERSION"           # e.g. 5.2.15(1)-release
printf 'bash major:  %d\n' "$BASH_VERSINFO[0]"        # e.g. 5 — use for feature gating
(( BASH_VERSINFO[0] >= 4 )) || { echo "Requires Bash 4+ (associative arrays, mapfile)." >&2; exit 1; }

Production Script Skeleton (Annotated)

Every script below uses this skeleton. Each line is load-bearing.

bash
#!/usr/bin/env bash
# ── Strict mode ──────────────────────────────────────────────
set -Eeuo pipefail
#  │ │ │  └─ pipefail: pipeline returns rightmost non-zero exit (default: returns last cmd only).
#  │ │ └──── u: unset variable expansion is a fatal error (not empty string).
#  │ └────── e: exit immediately on any command failure (with caveats — see below).
#  └──────── E: ERR trap inherits into functions and subshells (critical for trap ERR).
#
# ── Defensive globals ───────────────────────────────────────
shopt -s inherit_errexit 2>/dev/null || true  # Bash 4.4+: subshells inherit `set -e` (default: off!)
shopt -s nullglob        # Unmatched globs expand to nothing (not the literal pattern).
shopt -s globstar        # ** matches recursively (find **/*.py).
shopt -s extglob         # Extended pattern matching: ?(), *(), +(), @(), !().
IFS=$' \t\n'            # Explicit IFS — word-splitting on space, tab, newline only.

# ── Constants ───────────────────────────────────────────────
readonly SCRIPT_NAME=$(basename "${BASH_SOURCE[0]}")
readonly SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)  # resolves symlinks? No — see 13.
readonly LOG_FD=2       # stderr

# ── Error handling ──────────────────────────────────────────
err_report() {
    printf '[%s] %s:%d: %s failed (exit %d)\n' \
        "$(date -Iseconds)" "$SCRIPT_NAME" "${1:-0}" "${BASH_COMMAND:-?}" "${2:-0}" >&"$LOG_FD"
}
trap 'err_report "$LINENO" "$?"' ERR       # fires on any uncaught command failure (needs set -e -E).
trap 'cleanup' EXIT                          # fires on any exit path — normal, set -e abort, signal.

cleanup() {
    local exit_code=$?
    [[ -n "${tmpdir:-}" && -d "$tmpdir" ]] && rm -rf -- "$tmpdir"
    exit "$exit_code"   # preserve the original exit code (trap EXIT receives it).
}

# ── Logging ─────────────────────────────────────────────────
declare -i LOG_LEVEL=${LOG_LEVEL:-3}  # 0=trace 1=debug 2=info 3=warn 4=error 5=off
log() {
    local level=$1; shift
    local -A levels=([TRACE]=0 [DEBUG]=1 [INFO]=2 [WARN]=3 [ERROR]=4 [OFF]=5)
    (( levels[$level] >= LOG_LEVEL )) && printf '[%s] %s: %s\n' "$(date -Iseconds)" "$level" "$*" >&"$LOG_FD"
}

die() { log ERROR "$*"; exit 1; }

# ── Main ─────────────────────────────────────────────────────
main() {
    log INFO "Starting $SCRIPT_NAME"
    tmpdir=$(mktemp -d)  # mktemp -d: creates 0700-permission temp dir — race-free, no symlink attack.
    log DEBUG "tmpdir=$tmpdir"
    # ... script logic ...
    log INFO "Done"
}

main "$@"

Anti-Pattern: The Naive Shebang

bash
# ❌ NAIVE — hardcodes path, breaks on NixOS, Homebrew, conda, alpine
#!/bin/bash

# ❌ NAIVE — passes flags as single arg to env, non-portable
#!/usr/bin/env bash -euo pipefail

# ✅ CORRECT — portable, strict mode set in-body (not shebang)
#!/usr/bin/env bash
set -euo pipefail

set -e Doesn't Catch Everything

bash
set -e

# ── Commands whose exit status is "tested" don't trigger -e ──
false && echo "never"          # OK — false is tested by &&
if false; then echo "never"; fi # OK — false is tested by if
false || true                   # OK — false is tested by ||
! false                         # OK — negated

# ── Pipeline: only the LAST command's status matters (without pipefail) ──
false | true                     # exit 0 — false is masked!
# pipefail fixes this: set -o pipefail  → false | true exits 1.

# ── Functions: a function returning non-zero triggers -e ONLY if called as a simple command ──
maybe_fail() { return 1; }
maybe_fail                       # ✗ script exits (simple command)
maybe_fail || true               # OK — tested by ||
if maybe_fail; then echo "ok"; fi # OK — tested by if

# ── Subshell vs command substitution ──
(false)                           # ✗ subshell failure propagates with -e
x=$(false)                       # ✗ command substitution failure propagates with -e (Bash 4.4+)
x=$(false) || true               # OK — tested

# ── `grep` returns 1 when no match — silently kills the script ──
grep "pattern" file.txt          # ✗ if no match → exit 1 → script dies
grep "pattern" file.txt || true  # OK — explicit "no match is fine"
grep -q "pattern" file.txt && echo "found"  # OK — tested by &&

set -u and the Default Expansion Trap

bash
set -u

echo "$UNDEFINED_VAR"             # ✗ bash: UNDEFINED_VAR: unbound variable — fatal
echo "${UNDEFINED_VAR:-}"         # OK — :- provides empty default, doesn't error
echo "${UNDEFINED_VAR:-default}"  # OK — provides "default"
echo "${UNDEFINED_VAR-default}"   # OK — but ONLY if unset; empty string still errors
echo "${UNDEFINED_VAR:?missing}"  # ✗ prints "missing" to stderr, exits — even with :-

# ── Arrays are tricky with set -u ──
declare -a arr=()
echo "${arr[@]}"                  # OK — empty array, no error (Bash 4.4+)
# Bash < 4.4: this errors ("unbound variable") — use ${arr[@]:-}

declare -A map=()
echo "${map[key]}"                # ✗ if key doesn't exist → unbound (some versions)
echo "${map[key]:-}"              # OK — safe access pattern

Edge Cases: echo vs printf Portability

bash
# ── echo is non-portable for escape sequences ──
echo -e "line1\nline2"    # Bash: works. dash/POSIX sh: prints "-e line1\nline2" literally.
echo -n "no newline"      # Bash: works. Some systems: -n printed literally.

# ── printf is the portable choice ──
printf '%s\n' "line1" "line2"     # always: two lines with newlines
printf 'exit: %d\n' 42            # C-style format — consistent everywhere
printf '%-20s %5d\n' "label" 42   # left-align string (20 cols), right-align int (5 cols)
printf '%x\n' 255                 # ff (hex)
printf '%b\n' 'tab\there'         # interpret backslash escapes (like echo -e, but portable)
printf '%q\n' "string with spaces" # shell-quoted: string\ with\ spaces (safe for re-eval)

# ── echo behavior depends on xpg_echo and shopt ──
shopt -s xpg_echo   # echo now interprets \n, \t (like echo -e always)
echo "line1\nline2"  # prints two lines (xpg_echo on)
shopt -u xpg_echo   # echo now prints \n literally

Edge Cases: CRLF Line Endings

bash
# Symptom: bash: ./script.sh: /usr/bin/env: bad interpreter: No such file or directory
# Cause: shebang is "#!/usr/bin/env bash\r" — \r is part of the interpreter name.

# Detect:
file script.sh  # "ASCII text, with CRLF line terminators"

# Fix:
sed -i 's/\r$//' script.sh
# Or: dos2unix script.sh
# Or in Vim: :set fileformat=unix

# Prevent in Git:
# .gitattributes:
# *.sh text eol=lf

💡 Tips & Tricks

bash
# ── Feature-gate by Bash version ──
if (( BASH_VERSINFO[0] >= 4 )); then
    declare -A config       # associative arrays (Bash 4+)
else
    # Fallback: parallel indexed arrays or a config file
    :
fi

# ── Gate by feature, not version (more robust) ──
if ! declare -A _test_map 2>/dev/null; then
    die "Associative arrays not available — need Bash 4+."
fi

# ── Detect if script is being sourced ──
# When sourced, BASH_SOURCE[0] != BASH_SOURCE[1] (caller). When executed, they match.
(return 0 2>/dev/null) && SOURCED=1 || SOURCED=0
if (( SOURCED )); then
    # Don't run main() when sourced — act as a library.
    :
else
    main "$@"
fi

# ── Atomic script self-exec: re-exec with bash if run by sh ──
if [ -z "${BASH_VERSION:-}" ]; then
    exec bash "$0" "$@"  # re-exec under bash if invoked by sh/dash
fi

⚠️ Edge Cases & Gotchas

bash
# ── Shebang max length is 128 bytes (BINPRM_BUF_SIZE - 2) ──
# A path longer than 126 chars silently fails with "No such file or directory".

# ── Shebang with spaces: kernel splits on first whitespace ──
#!/usr/bin/env bash  # "bash" is the single arg to env
# But: #!/usr/bin/env /opt/bash  → fails (env doesn't resolve absolute paths without PATH lookup)

# ── `set -e` is disabled inside command substitution in Bash < 4.4 ──
# Bash 4.3: x=$(false); echo "still here"  → prints "still here" (no exit!)
# Bash 4.4+: x=$(false); echo "still here" → exits (inherit_errexit helps too)

# ── `set -e` in a subshell with `|` ──
set -e
(echo "subshell"; false) | cat   # subshell exits 1, but pipefail needed to catch it
# Without pipefail: cat exits 0 → pipeline exits 0 → no failure detected.

# ── `trap ERR` doesn't fire on `exit` ──
# trap ERR fires on command failures. trap EXIT fires on any exit (including exit, set -e, signals).
# For cleanup, use trap EXIT. For logging the failing command, use trap ERR.
# Combine: trap 'err_handler' ERR; trap 'cleanup' EXIT

# ── `BASH_SOURCE[0]` vs `$0` ──
# $0: the name the script was invoked as (may be a symlink, or "bash" if sourced)
# BASH_SOURCE[0]: the file path of the current script (works when sourced)
# Use BASH_SOURCE[0] for SCRIPT_DIR — $0 is unreliable.

🧠 Quick Quiz

What exit code does this produce, and why?

bash
set -euo pipefail
false | true
echo "reached"
Answer
  • set -e alone: pipeline exits with true's status (0). Script continues, prints "reached". false is silently masked.
  • set -o pipefail: pipeline exits with false's status (1). set -e catches it, script dies before "reached".

The lesson: set -e without pipefail is a false sense of safety — the first command in a pipeline can fail silently. Always use the full set -euo pipefail.