Decisions: if, case and guard clauses
Branch on exit status, match patterns with case, and fail closed.
tar -xzf bo-cond.tar.gz, which creates bo-cond/. SHA-256: 40d4c2ad4a3d61c6aa418ab66ebd647d9f25629e1b2b7e2446faf01473140897This 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 for web01 (hardened)Port 22PermitRootLogin noPasswordAuthentication noKbdInteractiveAuthentication noUsePAM yesX11Forwarding no
# sshd_config from the old base imagePort 22#PermitRootLogin prohibit-passwordPermitRootLogin yesUsePAM yesX11Forwarding yes
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:
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":
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":
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.
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:
(( 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:
*[!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:
#!/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" instart | 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' >&2exit 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:
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:
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:
#!/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
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:
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:
#!/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 ]]; thenprintf 'usage: ssh-audit.sh CONFIG\n' >&2exit 2ficonfig=$1if [[ ! -f $config || ! -r $config ]]; thenprintf 'ssh-audit: not a readable file: %s\n' "$config" >&2exit 2fi# 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=0if [[ $root == no ]]; thenecho "ok root login disabled"elif [[ -z $root || $root == prohibit-password ]]; thenecho "ok root login with keys only"elseecho "FINDING PermitRootLogin $root"findings=$((findings + 1))fiif [[ $password == no ]]; thenecho "ok password login disabled"elif [[ -z $password ]]; thenecho "FINDING PasswordAuthentication not set (the default is yes)"findings=$((findings + 1))elseecho "FINDING PasswordAuthentication $password"findings=$((findings + 1))fiif (( findings > 0 )); thenexit 1fi
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.
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).
/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.
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?|| looks at the status of whatever ran just before it. After a failed reload that is the reload's status, not is-active's.grep && cp || echo line ran without complaint.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.deploy.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?case with no match succeeds. Add *) that prints a usage line to standard error and exits 2, so the typo stops the pipeline.case is case-sensitive; the lesson's API.KEY missed *.key for the same reason.*) is the conventional way to catch everything else.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?$( ) does not capture. root was empty, as the lesson's step printed.awk runs.