Decisions: if, case and guard clauses

Branch on exit status, match patterns with case, and fail closed.

Beginner25 min · lesson 5 of 15
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (5 files, 1 KB): bo-cond.tar.gz. Unpack it with tar -xzf bo-cond.tar.gz, which creates bo-cond/. SHA-256: 40d4c2ad4a3d61c6aa418ab66ebd647d9f25629e1b2b7e2446faf01473140897

This lesson turns exit statuses and tests into decisions. You will branch with if, elif and else, write one-line decisions with && and || without falling into their best-known trap, compare validated numbers with (( )), route a value through case patterns, and put guard clauses at the top of a script so it refuses to act on input it cannot check. The running example is a small audit of SSH server settings that exits 0, 1 or 2 so that a scheduler or a pipeline can act on the result. The examples run in ~/bash-ops/cond, with two fixture files: a hardened sshd_config and one from an old base image.

if runs a command

In many languages if evaluates a true-or-false expression. In Bash, if runs a command and looks at its exit status: 0 takes the then branch, anything else takes the else branch if there is one. fi closes the block. The command can be a test such as [[ ... ]], but it can equally be any other command. The examples use these two files:

sshd_config
# sshd_config for web01 (hardened)
Port 22
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
X11Forwarding no
sshd_config.old
# sshd_config from the old base image
Port 22
#PermitRootLogin prohibit-password
PermitRootLogin yes
UsePAM yes
X11Forwarding yes
deploy@web01:~/bash-ops/cond · Ubuntu 26.04 LTS
$ if grep -q '^PermitRootLogin no' sshd_config; then echo "root login is disabled" else echo "root login is NOT disabled" fi
root login is disabled
$ if ! grep -q '^PasswordAuthentication' sshd_config.old; then echo "sshd_config.old does not set PasswordAuthentication" fi
sshd_config.old does not set PasswordAuthentication

grep -q answered with its status only, and if branched on it. ! in front of a command inverts its status, so the second if ran its body because grep found no PasswordAuthentication line. The pattern ^PermitRootLogin no starts with ^, which anchors it to the start of the line, so a commented-out setting such as #PermitRootLogin no would not count. Testing the command directly like this also means there is no $? to save and lose.

elif (else if) adds more questions. Bash tries them from top to bottom and runs only the first branch whose command succeeds:

deploy@web01:~/bash-ops/cond · Ubuntu 26.04 LTS
$ root=prohibit-password if [[ $root == no ]]; then echo "disabled" elif [[ $root == prohibit-password ]]; then echo "keys only" elif [[ $root == prohibit* ]]; then echo "never printed: the branch above matched first" else echo "allowed" fi
keys only

Both the second and the third condition are true for prohibit-password, and only the second one ran. Put the most specific checks first.

&& and ||: one-line decisions

The Linux essentials lesson "Pipes, redirection and exit status" introduced both operators: a && b runs b only if a succeeded. a || b runs b only if a failed. They are the short form for "if this worked, do that" and "if this failed, do that instead":

deploy@web01:~/bash-ops/cond · Ubuntu 26.04 LTS
$ grep -q '^PermitRootLogin no' sshd_config && echo "root login disabled" grep -q '^PermitRootLogin no' sshd_config.old || echo "sshd_config.old allows root login"
root login disabled sshd_config.old allows root login

Chaining both on one line looks like a compact if/else, and it is not one. The next line is meant to mean "if root login is disabled, back up the config, otherwise complain":

deploy@web01:~/bash-ops/cond · Ubuntu 26.04 LTS
$ grep -q '^PermitRootLogin no' sshd_config && cp sshd_config /etc/ssh/sshd_config.bak || echo "root login is NOT disabled"
cp: cannot create regular file '/etc/ssh/sshd_config.bak': Permission denied root login is NOT disabled

Root login is disabled in sshd_config, and the line still printed root login is NOT disabled. grep succeeded, so cp ran; cp failed because deploy may not write to /etc/ssh; and || reacts to the status of the command right before it, which was now cp's. The message is false, and in an alerting script it would page someone about the wrong problem.

a && b || c is not if/else
a && b || c
c runs whenever the command before || failed
a fails
b is skipped, c runs
the case you meant
a and b succeed
c is skipped
the case you meant
a succeeds, b fails
c runs as well
the case nobody meant: c reports the wrong failure
Use && or || for a single follow-up action; use if/else when there are two alternatives.
deploy@web01:~/bash-ops/cond · Ubuntu 26.04 LTS
$ if grep -q '^PermitRootLogin no' sshd_config; then cp sshd_config /etc/ssh/sshd_config.bak else echo "root login is NOT disabled" fi echo "status: $?"
cp: cannot create regular file '/etc/ssh/sshd_config.bak': Permission denied status: 1

With if, the error that appears is the real one, and nothing claims root login is enabled. The status of an if statement is the status of the last command in the branch that ran, here cp's 1, so a caller still learns that something failed. (When no branch runs, the status is 0.)

Numbers: (( )) for values you have checked

(( )) evaluates an arithmetic expression and is the most readable way to compare numbers in Bash: (( findings > 0 )) instead of [[ $findings -gt 0 ]]. Inside it, variables need no $. It is Bash-only; under sh it does something else entirely, as the first lesson's table showed. Its status follows one rule: 0 (true) when the expression's value is not zero, 1 (false) when it is zero:

deploy@web01:~/bash-ops/cond · Ubuntu 26.04 LTS
$ findings=2 if (( findings > 0 )); then echo "findings: $findings"; fi (( 0 )); echo "(( 0 )): $?" (( 5 - 5 )); echo "(( 5 - 5 )): $?" n=0 (( n++ )); echo "(( n++ )) from 0: $?, n is now $n"
findings: 2 (( 0 )): 1 (( 5 - 5 )): 1 (( n++ )) from 0: 1, n is now 1

(( 0 )) and (( 5 - 5 )) are "false". So is (( n++ )) when n starts at 0, because n++ hands back the old value, 0, and only then adds one. That is harmless here, but in the lesson "Strict mode, honestly" a failing status stops the script, which is why this course counts with n=$((n + 1)).

(( )) evaluates whatever it is given, so the rule from "Exit status and tests" applies in full: only a value you have validated may go in. case, from the next section, can do that check in any POSIX shell:

deploy@web01:~/bash-ops/cond · Ubuntu 26.04 LTS
$ limit='a[$(echo injected >&2)]' case "$limit" in '' | *[!0-9]*) echo "not a whole number: $limit" ;; *) if (( 3 > limit )); then echo "under the limit"; fi ;; esac
not a whole number: a[$(echo injected >&2)]

*[!0-9]* matches any value containing at least one character that is not a digit, and '' matches the empty value. Either one rejects the value, so the (( )) branch never saw the payload, and nothing printed injected.

case: one value, many patterns

When one value decides between several actions, a stack of elif [[ $action == ... ]] lines is hard to read. case takes the value once and compares it with a list of patterns. This stub prints what it would do to a service; a real one would call systemctl:

svc.sh
#!/usr/bin/env bash
# Usage: svc.sh start|stop|restart|status
# A stub for this lesson: it prints what it would do to app.service.
# Exit status: 0 done, 2 missing or unknown action.
case "$1" in
start | up)
echo "would start app.service"
;;
stop | down)
echo "would stop app.service"
;;
restart)
echo "would restart app.service"
;;
status)
echo "would show the status of app.service"
;;
*)
printf 'usage: svc.sh start|stop|restart|status\n' >&2
exit 2
;;
esac

Each pattern ends with ), and | separates alternatives, so start | up accepts either word. ;; ends a branch. esac (case backwards) closes the statement. The last pattern, *, matches anything, so it catches every value no earlier pattern took:

deploy@web01:~/bash-ops/cond · Ubuntu 26.04 LTS
$ chmod +x svc.sh ./svc.sh start ./svc.sh down ./svc.sh stpo echo "status: $?" ./svc.sh echo "status: $?"
would start app.service would stop app.service usage: svc.sh start|stop|restart|status status: 2 usage: svc.sh start|stop|restart|status status: 2

The typo stpo and the missing argument both landed in the * branch, which printed the usage line on standard error and exited 2, this course's status for "called wrongly". A caller such as a deploy pipeline sees the failure and stops. Here is the same idea without a default branch:

deploy@web01:~/bash-ops/cond · Ubuntu 26.04 LTS
$ action=stpo case "$action" in start) echo "would start app.service" ;; stop) echo "would stop app.service" ;; esac echo "status: $?"
status: 0

No pattern matched, so nothing ran, and a case in which nothing matched returns 0. The typo reports success, and whatever comes next in the pipeline runs on the assumption that the service was stopped. That is failing open. A default branch that refuses and exits non-zero is failing closed, and every case that chooses an action needs one.

case patterns are glob patterns, the same wildcards the shell uses for file names: * matches any run of characters, ? exactly one, and [0-9] one character from a set. That makes case a clean way to sort file names:

classify.sh
#!/usr/bin/env bash
# Usage: classify.sh FILENAME
# Decides, from the name alone, how a cleanup job may treat a file.
case "$1" in
*.key | *.pem | id_rsa* | id_ed25519*)
echo "$1: secret, never copy or upload"
;;
*.log | *.log.[0-9]*)
echo "$1: log, safe to rotate"
;;
*.conf | *.cfg)
echo "$1: config, review before changing"
;;
*)
echo "$1: unknown, leave it alone"
;;
esac
deploy@web01:~/bash-ops/cond · Ubuntu 26.04 LTS
$ chmod +x classify.sh ./classify.sh server.key ./classify.sh app.log.1 ./classify.sh nginx.conf ./classify.sh app.log.gz ./classify.sh API.KEY
server.key: secret, never copy or upload app.log.1: log, safe to rotate nginx.conf: config, review before changing app.log.gz: unknown, leave it alone API.KEY: unknown, leave it alone

A pattern must match the whole value, not part of it: app.log.gz ends in .gz, and *.log.[0-9]* needs a digit right after .log., so neither log pattern matched. Matching is case-sensitive, so API.KEY missed the secret pattern too. Both fell through to the default, and for a cleanup job "leave it alone" is the safe default: an unknown file is kept, never deleted or uploaded. The first matching pattern wins, so put the most dangerous class (secrets) first. Quote a variable used as a pattern if you mean its literal text, as with == in [[ ]].

Guard clauses: check first, act after

A guard clause is a check at the top of a script (or function) that exits early when a precondition fails: the wrong number of arguments, a file that cannot be read, a value that is not valid. After the guards, the rest of the script can assume its inputs are sane. The audit script below needs one, because of what awk does with a file that is not there:

deploy@web01:~/bash-ops/cond · Ubuntu 26.04 LTS
$ root=$(awk 'tolower($1) == "permitrootlogin" { print $2; exit }' sshd_config.new) echo "status: $?, root=[$root]"
awk: fatal: cannot open file `sshd_config.new' for reading: No such file or directory status: 2, root=[]

awk complained and exited 2, but the command substitution simply produced an empty string, and nothing stopped. In this audit an empty PermitRootLogin means "not set", and not set means the default, which is safe. Without a guard, the script would report ok about a file it never read. Here is the whole script:

ssh-audit.sh
#!/usr/bin/env bash
# Usage: ssh-audit.sh CONFIG
# Reports risky settings in an sshd_config file.
# Exit status: 0 no findings, 1 findings, 2 wrong arguments.
# Guard clauses: refuse to judge a file we cannot read.
if [[ $# -ne 1 ]]; then
printf 'usage: ssh-audit.sh CONFIG\n' >&2
exit 2
fi
config=$1
if [[ ! -f $config || ! -r $config ]]; then
printf 'ssh-audit: not a readable file: %s\n' "$config" >&2
exit 2
fi
# sshd keywords are case-insensitive, and the first value in the file wins.
root=$(awk 'tolower($1) == "permitrootlogin" { print $2; exit }' "$config")
password=$(awk 'tolower($1) == "passwordauthentication" { print $2; exit }' "$config")
findings=0
if [[ $root == no ]]; then
echo "ok root login disabled"
elif [[ -z $root || $root == prohibit-password ]]; then
echo "ok root login with keys only"
else
echo "FINDING PermitRootLogin $root"
findings=$((findings + 1))
fi
if [[ $password == no ]]; then
echo "ok password login disabled"
elif [[ -z $password ]]; then
echo "FINDING PasswordAuthentication not set (the default is yes)"
findings=$((findings + 1))
else
echo "FINDING PasswordAuthentication $password"
findings=$((findings + 1))
fi
if (( findings > 0 )); then
exit 1
fi

The two guards come first: exactly one argument, and a regular, readable file (|| inside [[ ]] means "or"). Each failure prints to standard error and exits 2. The two awk programs print the second field of the first line whose first field, lowercased, is the keyword, and exit stops at that first line. That mirrors sshd, which treats keywords case-insensitively and uses the first value it reads for these two settings (checked with sshd -T on the lab machine). The decisions follow the defaults in sshd_config(5): PermitRootLogin defaults to prohibit-password, which is acceptable, while PasswordAuthentication defaults to yes, which is a finding. findings counts problems, and the final (( )) turns the count into the exit status.

deploy@web01:~/bash-ops/cond · Ubuntu 26.04 LTS
$ chmod +x ssh-audit.sh ./ssh-audit.sh sshd_config echo "status: $?"
ok root login disabled ok password login disabled status: 0
$ ./ssh-audit.sh sshd_config.old echo "status: $?"
FINDING PermitRootLogin yes FINDING PasswordAuthentication not set (the default is yes) status: 1
$ ./ssh-audit.sh echo "status: $?" ./ssh-audit.sh sshd_config.new echo "status: $?" ./ssh-audit.sh /etc/ssh echo "status: $?"
usage: ssh-audit.sh CONFIG status: 2 ssh-audit: not a readable file: sshd_config.new status: 2 ssh-audit: not a readable file: /etc/ssh status: 2

The hardened file passes with status 0. The old one gives two findings and status 1, one of them for a setting the file never mentions. The three misuses (no argument, a missing file, a directory) are refused with status 2 before any check runs, so a caller can tell "the server has problems" (1) apart from "the audit could not run" (2).

This audit reads one file
On Ubuntu 26.04, /etc/ssh/sshd_config starts with Include /etc/ssh/sshd_config.d/*.conf, and Match blocks can change settings per user or address. The settings sshd actually uses come from sudo sshd -T, which prints every effective value, defaults included. Use the script's structure (guards, explicit defaults, exit statuses) and feed it sshd -T output on real servers.

Try this

Rewrite the PermitRootLogin decision in ssh-audit.sh as a case: no is ok; '', prohibit-password and without-password (the old name) are ok with keys only; yes is a finding; and a * default reports FINDING PermitRootLogin has an unexpected value: VALUE and counts it. Then create sshd_config.case with the two lines PermitRootLogin Yes and PasswordAuthentication no, and check:

1. ./ssh-audit.sh sshd_config: two ok lines, status 0.

2. ./ssh-audit.sh sshd_config.old: the same two findings as before, status 1.

3. ./ssh-audit.sh sshd_config.case: FINDING PermitRootLogin has an unexpected value: Yes and status 1. sshd accepts Yes as yes (the lab confirmed it with sshd -T), and your yes) branch did not match the capital letter. The default branch caught it and reported it, which is exactly the job of a default that fails closed.

Takeaway

Branch on commands with if, use && and || only for a single follow-up action, and give every case a default that refuses. Guard the top of a script so it exits 2 before it can judge input it could not read.

Quick check
01A deploy step runs systemctl is-active --quiet nginx && systemctl reload nginx || systemctl start nginx. nginx is running, but the reload fails because of a broken config file. What happens?
Incorrect — || looks at the status of whatever ran just before it. After a failed reload that is the reload's status, not is-active's.
Incorrect — They combine freely and are evaluated left to right; the lesson's grep && cp || echo line ran without complaint.
Incorrect — A list reports the status of the last command that ran. The start attempt ran last, so its status is what the caller sees.
Correct — a && b || c runs c when b fails, so a failed reload triggers a start of a service that is already running, and the real error is buried. Write it as if/else.
02deploy.sh routes its first argument with case "$1" in staging) ... ;; prod) ... ;; esac and has no other branches. Someone runs ./deploy.sh Prod from a release pipeline. What happens?
Correct — No pattern matched, and a case with no match succeeds. Add *) that prints a usage line to standard error and exits 2, so the typo stops the pipeline.
Incorrect — Glob matching in case is case-sensitive; the lesson's API.KEY missed *.key for the same reason.
Incorrect — Bash reports nothing when no pattern matches. That silence is the reason a default branch has to fail on purpose.
Incorrect — There is no fallback. Only a pattern that matches runs its branch, and *) is the conventional way to catch everything else.
03An audit script does root=$(awk '...' "$config") and treats an empty root as "not set, the default is safe". What happens when $config names a file that was deleted, if the script has no guard clause?
Incorrect — Nothing stops a script on a failed command unless it asks for that (the strict-mode lesson). The lesson's missing-file step carried on to the next line.
Incorrect — The error goes to standard error, which $( ) does not capture. root was empty, as the lesson's step printed.
Correct — The substitution yields an empty string and the script judges a file it never read as fine. A guard that checks the file first and exits 2 prevents it.
Incorrect — Bash does not look at the files a script names before running it. The failure only happens when awk runs.

Related