2 of 4

Shell ScriptingLesson 2 of 4

Making decisions: if, test and case

Test files, strings and numbers, combine tests, and choose between several actions.

  • if
  • test
  • [ ]
  • [[ ]]
  • case
  • &&
  • ||
  • -f -d -r -x -z -n
  • -eq -ne -gt -lt

45 min5 exercises

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

KindTestTrue when
File-e Fexists (any type)
-f F / -d Fis a regular file / a directory
-r F, -w F, -x Freadable / writable / executable by you
-s Fexists and is not empty
String-z S / -n Sempty / not empty
S1 = S2, S1 != S2equal / different
Integer-eq, -neequal / not equal
-lt, -le, -gt, -geless, 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.

  1. Warm-up

    What kind of thing is this?

    Write ~/lab/check.sh that takes one path. It prints PATH is a directory for a directory, PATH is a file, N lines for a regular file, and PATH 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 /etc says directory, ./check.sh /etc/passwd says file with a line count, ./check.sh /nope prints the error and echo $? is 1.

    Hint

    -d tests for a directory and -f for a regular file. -z "$1" is true when the argument is empty. Count lines with wc -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 $?
  2. Practice

    Compare numbers, not strings

    Write ~/lab/disk.sh that reads the percentage used on / (for example with df --output=pcent / | tail -1 | tr -dc 0-9). It prints OK below 80, WARNING from 80 to 89, and CRITICAL at 90 or above.

    Check your result

    On a nearly empty lab VM it prints OK. Temporarily testing with a threshold of 1 prints CRITICAL, 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
  3. Practice

    Several conditions at once

    Write ~/lab/safe-read.sh that takes a file name and prints its first three lines only if the file exists and is readable. If not, print cannot read FILE and exit 1. Test it on /etc/passwd, on /etc/shadow as a normal user, and on a missing file.

    Check your result

    /etc/passwd prints three lines. /etc/shadow as a normal user and a missing file both print cannot 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 $?
  4. Practice

    Dispatch with case

    Write ~/lab/svc.sh that accepts start or up (print starting), stop (print stopping), and anything else (print a usage line to standard error, exit 1). Use case, not a chain of if.

    Check your result

    ./svc.sh up and ./svc.sh start print starting. ./svc.sh restart prints 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 $?
  5. Challenge

    Use a command's result as the test

    Write ~/lab/user-exists.sh USER that prints USER exists (uid N) when the account exists and USER does not exist otherwise (exit 1), producing no other output. Use the exit status of id directly in the if, with no [ ]. Then explain what command1 && command2 || command3 does and why it is not a safe replacement for if/else.

    Check your result

    ./user-exists.sh root prints root exists (uid 0). ./user-exists.sh nobodyhere prints nobodyhere does not exist and exits 1. Neither prints an id: error message.

    Hint

    if id "$1" &>/dev/null; then. Get the uid with id -u "$1". a && b || c runs c also when b itself 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 $?