Bash and Linux

Test Files and Conditions

easyBash scripts and automation Must-do

Problem statement

Check a list of paths before using them and print what each one is: a missing path, a folder, an empty file, a file with content, and whether it can be run. Then see the number-comparison trap inside [[ ]]. Scripts that check before they act fail with clear messages instead of strange errors halfway through.

The script creates these paths in a temporary folder:

paths

TEXT
app.conf a file with content
empty.txt an empty file
logs/ a folder
deploy.sh a file with content, made executable
missing.txt does not exist
  1. For each path, print what it is, and add (executable) when it can be run.
  2. Compare the numbers 9 and 10 with > inside [[ ]], with -gt, and with (( )).
  3. Use case to describe each file by its extension.

Expected output:

TEXT
== what is each path? ==
app.conf file with content
empty.txt empty file
logs folder
deploy.sh file with content (executable)
missing.txt missing
== comparing 9 and 10 ==
[[ 9 > 10 ]] true (text comparison, wrong)
[[ 9 -gt 10 ]] false (number comparison)
(( 9 > 10 )) false (number comparison)
== case on the name ==
app.conf: config file
deploy.sh: shell script
logs/: folder
notes.md: something else

Hints

Hint 1: [[ -e x ]] is true if x exists, -d if it is a folder, -f if it is a regular file, -s if it is a file that is not empty, -x if it can be run. Test the most specific things in a sensible order with if, elif and else.

Approach

Optimal: [[ ]] tests with if and case

Covers: exit codes and if, [[ ]], file tests -e -f -d -s -x, string tests == and -z, number tests -eq -lt -gt, (( )), && and ||, case, [ ] vs [[ ]].

Strict mode, used on every page in this section. The second line, set -euo pipefail, makes bash stop on mistakes instead of carrying on:

Option Means
-e exit as soon as a command fails (with some exceptions, see Handle Command Failures)
-u treat an unset variable as an error, instead of silently using empty text
-o pipefail a pipeline fails if any command in it fails, not just the last one

Put it right after the shebang line #!/usr/bin/env bash in every script you write.

A test is a command that answers yes or no. [[ -f app.conf ]] runs, and exits with 0 for "true" or 1 for "false". if does not look at text; it runs the command and checks that exit code:

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart TB T(["[[ -f app.conf ]]"]):::purple --> C{{"exit code?"}}:::yellow C --> Y["0: then branch"]:::green C --> N["1: else branch"]:::red classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px

That is why any command can go after if: if grep -q error app.log; then ... works the same way. It is also why tests inside if do not trigger set -e.

File tests.

Test True when
-e path it exists (any kind)
-f path it is a regular file
-d path it is a folder
-s path it is a file with at least one byte
-x path you can run it (or enter it, for a folder)
-L path it is a symlink
! test the opposite

Order matters: test "missing" first, then "folder", then "empty or not", so each path lands in the right branch.

Strings and numbers are different.

Compare Strings Numbers
equal [[ $a == $b ]] [[ $a -eq $b ]] or (( a == b ))
less / greater < > (alphabetical) -lt -gt or (( a < b ))
empty [[ -z $a ]]

The trap: [[ 9 > 10 ]] is true, because as text 9 comes after 1. Always use -gt or (( )) for numbers.

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart LR subgraph S["[[ 9 > 10 ]]"] direction TB S1["compares text
9 after 1"]:::red --> S2["true"]:::red end subgraph N["(( 9 > 10 ))"] direction TB N1["compares numbers"]:::green --> N2["false"]:::green end S ~~~ N classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px style S fill:transparent,stroke:#dc2626,stroke-width:2px style N fill:transparent,stroke:#059669,stroke-width:2px

Short forms. [[ -d logs ]] && echo "has logs" runs the second command only if the test passed; [[ -f app.conf ]] || exit 1 runs it only if the test failed. They are handy one-liners, but if is clearer for anything longer.

case matches patterns. case $f in *.conf) ... ;; *.sh) ... ;; *) ... ;; esac tries each pattern in order and runs the first match. It is cleaner than a chain of elif [[ $f == *.conf ]].

Walking through the code. The # Setup: lines only create the sample paths, so skip past them.

  1. describe tests each path in order, and adds (executable) for files that pass -x.
  2. The three number comparisons show the trap and both correct forms.
  3. The case gives a description per extension, and */ matches the folder.

Edge cases. Running as root, -r and -w are almost always true, so do not use them to check permissions for other users. -x on a folder means "can enter", so test -d first. A broken symlink fails -e, because the target is missing, but passes -L.

#!/usr/bin/env bash
set -euo pipefail

# Setup: create the sample paths in a fresh temporary folder
cd "$(mktemp -d)"
echo "port=8080" > app.conf
: > empty.txt
mkdir logs
printf '#!/bin/sh\necho deploying\n' > deploy.sh
chmod +x deploy.sh

describe() {
  local p=$1 kind
  if [[ ! -e $p ]]; then kind="missing"
  elif [[ -d $p ]]; then kind="folder"
  elif [[ -s $p ]]; then kind="file with content"
  else kind="empty file"
  fi
  if [[ -f $p && -x $p ]]; then kind="$kind (executable)"; fi
  printf '%-12s %s\n' "$p" "$kind"
}
echo "== what is each path? =="
for p in app.conf empty.txt logs deploy.sh missing.txt; do describe "$p"; done

echo "== comparing 9 and 10 =="
if [[ 9 > 10 ]]; then echo '[[ 9 > 10 ]]    true  (text comparison, wrong)'; fi
if [[ 9 -gt 10 ]]; then echo "[[ 9 -gt 10 ]] true"; else echo "[[ 9 -gt 10 ]] false (number comparison)"; fi
if (( 9 > 10 )); then echo "(( 9 > 10 ))   true"; else echo "(( 9 > 10 ))   false (number comparison)"; fi

echo "== case on the name =="
for f in app.conf deploy.sh logs/ notes.md; do
  case $f in
    *.conf) echo "$f: config file" ;;
    *.sh)   echo "$f: shell script" ;;
    */)     echo "$f: folder" ;;
    *)      echo "$f: something else" ;;
  esac
done
RecapThe whole problem in a few lines, for the night before
  • Spot it: "check before you act", "does this file exist"
  • Idea: if [[ -f x ]]; then ...; elif [[ -d x ]]; ..., -s for not empty, -gt for numbers
  • Cost: one quick system call per test
  • Trap: comparing numbers with > inside [[ ]], which compares text

Interview follow-ups

  • Also say whether each file is owned by the user running the script.

    Add a test with -O, which is true when the file is owned by the current user: [[ -O $p ]] && kind="$kind, yours". -G does the same for your group. To print the actual owner, use stat -c '%U' "$p" (GNU). Combined with -x, this tells you quickly whether a deploy script will run and whether you are allowed to change it.

Frequently asked questions

[ is an old command (also called test) that follows normal word splitting, so an unquoted empty variable vanishes and [ $x == y ] becomes [ == y ], an error. [[ ]] is bash syntax: it does not split variables, supports && and || inside, and adds pattern matching (== *.log) and regex (=~). In bash scripts, use [[ ]]. Use [ ] with every variable quoted only when the script must run in plain sh.

With [ ], an empty unquoted $name disappears completely, so bash runs [ == admin ] and reports "unary operator expected". Quoting fixes it, [ "$name" == admin ], because an empty quoted string is still an argument. [[ $name == admin ]] does not have this problem at all. That is one more reason to prefer [[ ]], and to quote anyway out of habit.

command -v jq > /dev/null exits 0 when jq can be run, so if ! command -v jq > /dev/null; then echo "jq is required" >&2; exit 1; fi stops early with a clear message. Avoid which, which differs between systems and does not know about shell functions or aliases. Checking all tools at the top of a script is much friendlier than failing with "command not found" in the middle.