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.
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 directoryfor a directory calledold 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):
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:
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 -uand${var:?message}stop on missing values.- Allow-list the paths a destructive script may touch.
- Prefer
find ... -delete/-execwith explicit conditions over globs.
Trade-offs
- Strict checks make scripts longer, but destructive scripts deserve it.
realpathresolves 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-runmode 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.