Bash Variables

Today, I will be discussing the strict and very early Bash variable settings in my Bash-based projects. You can find an example here:

https://git.coresecret.dev/msw/CISS.debian.live.builder/src/branch/master/var/bash.var.sh

I treat bash.var.sh as the canonical „strict mode plus deterministic environment“ module for the entire CISS.debian.live.builder project. I source it as early as possible so that I fix Bash’s behaviour before any real logic runs. I do not want to rely on whatever broken, noisy or even hostile environment like a CI runner, a chroot or a random root shell might hand to my scripts. By sourcing this file, I harden the current shell process itself, not some subshell that I then forget about.

The intent is simple. I want a very strict, fail fast error policy in all non interactive project scripts. Anything that goes wrong should fail hard instead of continuing with corrupted state. I also want a normalised shell environment, so that every script in the project runs with the same assumptions about globbing, locale, path handling, time zone and umask. Finally, I want that contract to be explicit and reusable, not copied in slightly different forms across dozens of files. That is why this lives in a dedicated module that I guard with a sourcing check instead of sprinkling ad hoc set -euo pipefail fragments everywhere.

Guarding and sanitising the environment

The line guard_sourcing || return "${ERR_GUARD_SRCE}" expresses my expectation that this file is sourced and only sourced. If someone tries to execute it directly, or if it is sourced multiple times when I do not want that, the guard can stop the misuse. This lets me apply the semantics in this file in a controlled way, exactly once per shell instance.

I start by unsetting BASH_ENV, CDPATH, ENV and GLOBIGNORE. Those variables are classic ways to pollute a shell environment.

BASH_ENV and ENV are hooks that cause extra code to be sourced whenever Bash or a POSIX shell starts. If a CI system, a user’s root shell or a malicious wrapper sets them, my builder might run arbitrary code before my own logic. By unsetting them at the very beginning I cut off that indirection.

CDPATH modifies how cd resolves directories and can cause cd to print a path to standard output. That might be convenient for interactive work but it is poison in scripts that rely on clean output or parse the output of other commands. Unsetting it guarantees that every cd call in this project behaves in the classic, silent way.

GLOBIGNORE changes wildcard expansion by excluding matching names from patterns. If I allowed it to leak in from the environment, patterns like * could silently ignore some files and directories. I want predictable, default globbing semantics, so I remove it here.

Strict error handling with set -o

Next I configure Bash’s error behaviour explicitly:

set -o errexit   # -e
set -o errtrace  # -E
set -o functrace # -T
set -o ignoreeof
set -o noclobber # -C
set -o nounset   # -u
set -o pipefail

errexit, nounset and pipefail are the core of my strict mode. Together they mean that any unhandled non zero exit status aborts the script, any use of an unset variable is treated as a bug and any failing command in a pipeline causes the entire pipeline to fail.

Without pipefail, only the last command in a pipeline matters. For example:

set -o errexit
false | grep foo >/dev/null
echo "still running"

Without pipefail this still reaches the echo, because grep exits with zero. With pipefail the failure of false becomes the status of the pipeline and the script terminates. In a build system I prefer to see such mistakes immediately rather than after they have propagated into bad artefacts.

errtrace and functrace extend the reach of my traps. With these options, a trap on ERR or DEBUG is inherited into functions, command substitutions and subshells. In a modular project like this, that is what turns a trap based logging or error handler into a truly global mechanism. Without them, I would get obscure situations where a failure inside $(...) never triggers my ERR trap.

noclobber protects me from accidental truncation of files through > redirections. With it enabled, this fails:

set -o noclobber
echo "data" >existing.txt

If I really want to overwrite the file, I have to write:

echo "data" >|existing.txt

That explicit override forces me to think twice before I destroy existing content. For a system that writes configuration files, checksums or images, this is a cheap safety net.

ignoreeof is mostly an interactive concern, since EOF on standard input is only relevant in a shell that reads from a terminal. In a non interactive script it has no effect. I still set it here because I also source this module in interactive sessions when I debug or test. I do not want a stray Ctrl plus D to kill such a session; I prefer explicit exit.

shopt and control of Bash’s subtleties

Then I refine Bash’s behaviour with shopt:

shopt -s failglob
shopt -s inherit_errexit
shopt -s lastpipe
shopt -u expand_aliases
shopt -u dotglob
shopt -u extglob
shopt -u nullglob

failglob enforces hard failure on incorrect globbing. If a pattern matches nothing, Bash raises an error instead of leaving the pattern unchanged. That means that a loop such as

for iso in *.iso; do
  process_iso "$iso"
done

does not silently do the wrong thing when there is no .iso present. It fails and forces me to handle the empty case explicitly if I care about it.

inherit_errexit is essential together with set -o errexit. Without it, command substitutions inside $(...) behave as if errexit was disabled. With it, a failure inside $(...) bubbles up and can terminate the outer script. If I write

set -o errexit
shopt -s inherit_errexit
output="$(some_command_that_fails)"
echo "this will never run"

then a failing some_command_that_fails kills the script as expected. Without inherit_errexit it would quietly return an empty or partial output and move on, which is exactly the kind of temporal landmine I do not accept in a security sensitive tooling chain.

lastpipe changes how Bash executes the last element of a pipeline when job control is disabled, which is the normal situation in non interactive scripts. With lastpipe the last command in a pipeline can run in the current shell instead of a subshell. That matters whenever I write constructs like

shopt -s lastpipe
printf '%s\n' "a b" | while read -r x y; do
  last="$y"
done
echo "$last"

Without lastpipe the while loop would run in a subshell and last would not survive beyond the end of the loop. With lastpipe the variable is set in the current shell and available afterwards.

The four shopt -u lines serve two purposes. In a non interactive shell, expand_aliases is off by default and dotglob, extglob and nullglob are usually off as well. I still unset them explicitly to document the behaviour I rely on and to neutralise any earlier configuration that might have toggled them. If I source this module in an interactive session where the user’s .bashrc turned some of these features on, they are turned off again and I get exactly the same semantics as in CI.

Deterministic baseline for environment variables

Finally I establish a deterministic baseline for environment variables:

declare -gx IFS=$' \t\n'
declare -gx LANG=C.UTF-8
declare -gx LC_ALL=C.UTF-8
declare -gx PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
declare -gx TZ=UTC
umask 0022

IFS controls word splitting, read behaviour and various other details. I want the classic three character sequence, space, tab and newline. This removes surprises from exotic IFS values a caller might have set and it ensures that my parsing code behaves in the predictable, documented way.

With LANG and LC_ALL set to C.UTF-8, I force a stable locale. That affects collation order, decimal separators, error message language and even character classes. I prefer C.UTF-8 because it is simple and deterministic but still fully Unicode capable. I do not want a sort or tr to behave differently on a German system versus a Portuguese one.

I reset PATH to a minimal, root style search path where only standard system directories are present. That prevents user level installations under ~/bin or similar from influencing which tar, gpg or ls my scripts run. It is both a reproducibility measure and a security measure against path injection.

By setting TZ=UTC I make sure that all timestamps, logs and generated files are based on UTC time. That removes daylight saving complications and makes it much easier to reason about time across machines and time zones. For a build system that might compare timestamped files or log entries, this is important.

umask 0022 gives me a standard file creation mask. Files are readable by everyone and writable only by the owner. More importantly, the creation mask is no longer dependent on whoever starts the build. That makes downstream permissions predictable.

Options that are effectively documentation in non interactive shells

Some of the options I set are, strictly speaking, already defaults in non interactive shells. expand_aliases is off anyway, dotglob, extglob and nullglob are usually off, job control is disabled, ignoreeof is not relevant. I still set or unset them explicitly for two reasons.

First, I want this module to be a complete, self contained specification of the shell semantics my project relies on. Even if an option currently matches the Bash default, I do not want to rely on that behaviour being stable across distributions or future versions. Writing it explicitly removes that uncertainty.

Second, I know that I will frequently source this module in interactive shells to debug and develop. In an interactive shell the defaults differ. expand_aliases is typically enabled, and many users fiddle with dotglob, extglob, nullglob and other options. By forcing all these flags to the state I want, I turn an interactive session into a faithful replica of my non interactive environment. That way, if something works in an interactive shell that has sourced bash.var.sh, I can trust it to behave identically inside the build pipeline.

In short, I use bash.var.sh as an aggressively strict, self documenting baseline for every Bash process inside CISS.debian.live.builder. By sourcing it early I avoid a „pre strict“ phase where bugs can hide, inherited environment state can leak in or pipelines can silently swallow errors. Some of the options I configure are redundant with non interactive defaults, but I keep them to make the behaviour explicit, reproducible and identical between CI, chroots and my own interactive debugging sessions.

Categories: IT, IT-Security, Linux