Shell ScriptingLesson 2 of 4
Making decisions: if, test and case
Test files, strings and numbers, combine tests, and choose between several actions.
iftest[ ][[ ]]case&&||-f -d -r -x -z -n-eq -ne -gt -lt
After this lesson you can
- Write if, elif and else blocks using test, [ ] and [[ ]].
- Choose the right test for files, strings and integers, and combine tests with && and ||.
- Dispatch on a keyword with case, and use the exit status of any command as the condition.
The idea
A script becomes useful when it can look at the world and choose. In Bash every if asks one question: did this command succeed? The command is usually test, which has a second spelling, [ ... ]. They are the same program-like builtin, so [ -f /etc/passwd ] is just test -f /etc/passwd. Leave a space after [ and before ], because they are commands and their arguments are separated by spaces.
if [ -f /etc/passwd ]; then
echo "found"
elif [ -d /etc/passwd ]; then
echo "a directory?"
else
echo "missing"
fi
The tests worth knowing
| Kind | Test | True when |
|---|---|---|
| File | -e F | exists (any type) |
-f F / -d F | is a regular file / a directory | |
-r F, -w F, -x F | readable / writable / executable by you | |
-s F | exists and is not empty | |
| String | -z S / -n S | empty / not empty |
S1 = S2, S1 != S2 | equal / different | |
| Integer | -eq, -ne | equal / not equal |
-lt, -le, -gt, -ge | less, less or equal, greater, greater or equal |
Two classic traps. First, numbers and strings use different operators: [ "$n" -gt 5 ] compares numbers, and [ "$n" \> 5 ] compares text. Second, quote your variables inside [ ]: an empty unquoted $1 disappears and [ -z $1 ] stops being a valid test.
Combine tests with && (and) and || (or). Bash also has [[ ... ]], which is safer and richer: it does not word-split variables, supports && and || inside, and allows pattern matching such as [[ $name == a* ]].
if, test and exit status together
The condition of an if can be any command. if grep -q root /etc/passwd; then runs grep and branches on its exit status (-q keeps it silent). if id alex &>/dev/null; then asks whether an account exists. This is often cleaner than capturing output and comparing it.
Try it
cat > check.sh <<'EOT'
#!/bin/bash
target="$1"
if [ -z "$target" ]; then
echo "Usage: $0 PATH" >&2
exit 2
elif [ -d "$target" ]; then
echo "$target is a directory"
elif [ -f "$target" ]; then
echo "$target is a file, $(wc -l < "$target") lines"
else
echo "$target not found" >&2
exit 1
fi
EOT
chmod +x check.sh
./check.sh /etc
./check.sh /etc/passwd
./check.sh /nope; echo "status=$?"
/etc is a directory
/etc/passwd is a file, 41 lines
/nope not found
status=1
case: one value, many branches
cat > svc.sh <<'EOT'
#!/bin/bash
case "$1" in
start|up) echo "starting" ;;
stop) echo "stopping" ;;
*) echo "Usage: $0 start|stop" >&2; exit 1 ;;
esac
EOT
chmod +x svc.sh
./svc.sh up
./svc.sh stop
./svc.sh restart; echo "status=$?"
starting
stopping
Usage: ./svc.sh start|stop
status=1
case matches the value against patterns from the top and runs the first match, ending at ;;. | means “or”, * matches anything and so goes last. Reach for it whenever you would otherwise write three elifs that all compare the same variable.
Combining tests
n=7
if [ "$n" -gt 5 ] && [ "$n" -lt 10 ]; then echo "between"; fi
[[ "abc" == a* ]] && echo "glob match"
test -r /etc/passwd && echo "readable"
between
glob match
readable
Going further
(( n > 5 )) is the arithmetic form: inside double parentheses you can use >, <, == and + the way you would in C, and the result is an exit status. [ "$a" = "$b" ] needs a single =; double == works in Bash but not in plain sh. -nt and -ot compare file ages, and -L tests for a symbolic link. When a script needs many options, getopts parses -v -f FILE properly, but the exam asks for positional arguments, so make those solid first.
Practice
0 of 5 done
Do these on your own system. They get harder. Check each result, then tick it off.
- Warm-up
What kind of thing is this?
Write
~/lab/check.shthat takes one path. It printsPATH is a directoryfor a directory,PATH is a file, N linesfor a regular file, andPATH not found(to standard error, exit status 1) otherwise. With no argument it prints a usage line and exits 2.Check your result
./check.sh /etcsays directory,./check.sh /etc/passwdsays file with a line count,./check.sh /nopeprints the error andecho $?is1.Hint
-dtests for a directory and-ffor a regular file.-z "$1"is true when the argument is empty. Count lines withwc -l < FILE.Solution
cat > ~/lab/check.sh <<'EOT' #!/bin/bash target="$1" if [ -z "$target" ]; then echo "Usage: $0 PATH" >&2 exit 2 elif [ -d "$target" ]; then echo "$target is a directory" elif [ -f "$target" ]; then echo "$target is a file, $(wc -l < "$target") lines" else echo "$target not found" >&2 exit 1 fi EOT chmod +x ~/lab/check.sh ~/lab/check.sh /etc; ~/lab/check.sh /etc/passwd; ~/lab/check.sh /nope; echo $? - Practice
Compare numbers, not strings
Write
~/lab/disk.shthat reads the percentage used on/(for example withdf --output=pcent / | tail -1 | tr -dc 0-9). It printsOKbelow 80,WARNINGfrom 80 to 89, andCRITICALat 90 or above.Check your result
On a nearly empty lab VM it prints
OK. Temporarily testing with a threshold of 1 printsCRITICAL, proving the branch works.Hint
Use
-lt,-ge, not<and>. Inside[ ]the symbols<and>are redirections.Solution
cat > ~/lab/disk.sh <<'EOT' #!/bin/bash used=$(df --output=pcent / | tail -1 | tr -dc 0-9) if [ "$used" -ge 90 ]; then echo "CRITICAL ($used%)" elif [ "$used" -ge 80 ]; then echo "WARNING ($used%)" else echo "OK ($used%)" fi EOT chmod +x ~/lab/disk.sh ~/lab/disk.sh - Practice
Several conditions at once
Write
~/lab/safe-read.shthat takes a file name and prints its first three lines only if the file exists and is readable. If not, printcannot read FILEand exit 1. Test it on/etc/passwd, on/etc/shadowas a normal user, and on a missing file.Check your result
/etc/passwdprints three lines./etc/shadowas a normal user and a missing file both printcannot read ...and exit 1.Hint
[ -f "$1" ] && [ -r "$1" ], or[[ -f $1 && -r $1 ]].head -3.Solution
cat > ~/lab/safe-read.sh <<'EOT' #!/bin/bash if [ -f "$1" ] && [ -r "$1" ]; then head -3 "$1" else echo "cannot read $1" >&2 exit 1 fi EOT chmod +x ~/lab/safe-read.sh ~/lab/safe-read.sh /etc/passwd ~/lab/safe-read.sh /etc/shadow; echo $? - Practice
Dispatch with case
Write
~/lab/svc.shthat acceptsstartorup(printstarting),stop(printstopping), and anything else (print a usage line to standard error, exit 1). Usecase, not a chain ofif.Check your result
./svc.sh upand./svc.sh startprintstarting../svc.sh restartprints usage and exits 1.Hint
case "$1" in start|up) ... ;; esac. Each branch ends with;;.*)is the catch-all and goes last.Solution
cat > ~/lab/svc.sh <<'EOT' #!/bin/bash case "$1" in start|up) echo "starting" ;; stop) echo "stopping" ;; *) echo "Usage: $0 start|stop" >&2; exit 1 ;; esac EOT chmod +x ~/lab/svc.sh ~/lab/svc.sh up; ~/lab/svc.sh stop; ~/lab/svc.sh restart; echo $? - Challenge
Use a command's result as the test
Write
~/lab/user-exists.sh USERthat printsUSER exists (uid N)when the account exists andUSER does not existotherwise (exit 1), producing no other output. Use the exit status ofiddirectly in theif, with no[ ]. Then explain whatcommand1 && command2 || command3does and why it is not a safe replacement for if/else.Check your result
./user-exists.sh rootprintsroot exists (uid 0)../user-exists.sh nobodyhereprintsnobodyhere does not existand exits 1. Neither prints anid:error message.Hint
if id "$1" &>/dev/null; then. Get the uid withid -u "$1".a && b || crunscalso whenbitself fails.Solution
cat > ~/lab/user-exists.sh <<'EOT' #!/bin/bash if id "$1" &>/dev/null; then echo "$1 exists (uid $(id -u "$1"))" else echo "$1 does not exist" exit 1 fi EOT chmod +x ~/lab/user-exists.sh ~/lab/user-exists.sh root ~/lab/user-exists.sh nobodyhere; echo $?