Linux and Bash for Developers · Files, Disks and Permissions

An Unquoted Variable Deleted the Wrong Files

See how word splitting and empty variables turn rm commands dangerous, and write cleanup scripts that can't delete the wrong thing.

Create a free account to save this page, take notes and track your progress. Sign in

Scenario

A cleanup script removes old build artifacts: rm -rf $BUILD_DIR/*. One day the CI job runs it with BUILD_DIR unset, and on another machine a directory name contains a space. Both times the script deletes things it was never meant to touch.

What you see in production

  • Files outside the intended directory are gone.
  • Errors such as rm: cannot remove 'reports': No such file or directory for a directory called old reports.
  • The script "worked fine" on the author's machine.

The naive code

#!/bin/bash
target=$BUILD_DIR
rm -rf $target/*

Reproduce it

In a sandbox: a name with a space is split into two arguments, and an empty variable turns $target/* into /*, the whole filesystem (the example only counts the paths, it doesn't delete them):

Word splitting and empty variables
mkdir -p sandbox/"old reports" sandbox/old sandbox/reports sandbox/keep
touch sandbox/"old reports"/a.txt sandbox/old/important.txt sandbox/reports/q1.txt sandbox/keep/data.txt
cd sandbox
target="old reports"
rm -rf $target              # becomes: rm -rf old reports
ls
echo "--- 'old' and 'reports' were deleted, 'old reports' survived"
cd ..
root="$PWD/sandbox"
BUILD_DIR=""
cd "$root"
target=$BUILD_DIR
paths=( $target/* )
echo "rm -rf \$target/* expands to ${#paths[@]} paths at the filesystem root: ${paths[*]:0:3} ..."

Output

keep
old reports
--- 'old' and 'reports' were deleted, 'old reports' survived
rm -rf $target/* expands to 26 paths at the filesystem root: /bin /bin.usr-is-merged /boot ...

Run this on your computer — see “Get Started” for setup.

Root cause

Unquoted expansions go through word splitting (on spaces, tabs, newlines) and globbing. An empty or unset variable expands to nothing, so $target/* silently becomes /*. Nothing stops rm -rf from acting on the result.

The fix

Quote every expansion, fail on unset variables, refuse empty values with ${var:?}, and check the target before deleting:

A cleanup that refuses to run unsafely
cat > clean.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
target="${1:?usage: clean.sh <build-dir>}"
[[ -d $target ]] || { echo "not a directory: $target" >&2; exit 1; }
case "$(realpath "$target")" in
  "$HOME"/builds/*) ;;
  *) echo "refusing to clean outside ~/builds: $target" >&2; exit 1 ;;
esac
find "$target" -mindepth 1 -maxdepth 1 -mtime +7 -print -exec rm -rf -- {} +
echo "cleaned $target"
EOF
chmod +x clean.sh
mkdir -p "$HOME/builds/web app"
touch -d '2026-01-01' "$HOME/builds/web app/old.tar"
touch "$HOME/builds/web app/new.tar"
./clean.sh 2>&1 || true
./clean.sh / 2>&1 || true
./clean.sh "$HOME/builds/web app" | sed "s|$HOME|~|"
ls "$HOME/builds/web app"

Output

./clean.sh: line 3: 1: usage: clean.sh <build-dir>
refusing to clean outside ~/builds: /
~/builds/web app/old.tar
cleaned ~/builds/web app
new.tar

Run this on your computer — see “Get Started” for setup.

  • "$var" everywhere; "$@" for argument lists; -- before file arguments.
  • set -u and ${var:?message} stop on missing values.
  • Allow-list the paths a destructive script may touch.
  • Prefer find ... -delete/-exec with explicit conditions over globs.

Trade-offs

  • Strict checks make scripts longer, but destructive scripts deserve it.
  • realpath resolves symlinks; decide whether symlinked targets should be allowed.
  • ShellCheck catches most of these issues automatically; run it in CI.

Detect it in production

  • Lint scripts with ShellCheck in CI (SC2086 "double quote to prevent globbing and word splitting").
  • Run destructive scripts with a --dry-run mode first and log what would be deleted.
  • Restrict CI and service users so a bad script can only damage what it owns.

Key points

  • Unquoted variables split on spaces and expand globs.
  • Empty variables silently change paths; use set -u and ${var:?}.
  • Destructive scripts should validate and allow-list their targets.

Further reading: bash(1): word splitting · ShellCheck SC2086 · Bash tutorial: quoting

Practice questions

What happens to rm -rf $dir when dir="my files"?

Answer

Word splitting turns it into two arguments, rm -rf my files, deleting 'my' and 'files' instead of 'my files'.

What does ${BUILD_DIR:?} do?

Answer

It stops the command with an error if BUILD_DIR is unset or empty, instead of expanding to an empty string.

How can CI catch these bugs?

Answer

Run ShellCheck on every script; it warns about unquoted expansions and risky rm patterns.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?