Linux Basic — Defensive File Handling
Defensive File Handling
A defensive script answers three questions before it touches a file:
- Does it exist?
- Do I have permission?
- Will the operation do what I expect?
Get those right and you eliminate 80% of the bugs that hit production scripts.
The Defensive Read Pattern
read_config() {
local file=$1
[[ -n "$file" ]] || { echo "read_config: no file given" >&2; return 2; }
[[ -e "$file" ]] || { echo "read_config: $file does not exist" >&2; return 2; }
[[ -f "$file" ]] || { echo "read_config: $file is not a file" >&2; return 2; }
[[ -r "$file" ]] || { echo "read_config: $file not readable" >&2; return 2; }
[[ -s "$file" ]] || { echo "read_config: $file is empty" >&2; return 2; }
cat -- "$file"
}
Each line answers one specific failure mode with a specific message — much easier to debug than cat: file: No such file or directory halfway through your script.
You can compress this when needed:
[[ -r "$file" && -s "$file" ]] || { echo "config missing or empty" >&2; exit 1; }
The Defensive Write Pattern
Before you write to a file, check the parent directory:
write_output() {
local file=$1
local dir
dir=$(dirname -- "$file")
[[ -d "$dir" ]] || { echo "write_output: $dir does not exist" >&2; return 2; }
[[ -w "$dir" ]] || { echo "write_output: $dir not writable" >&2; return 2; }
# If the file exists, ensure we can overwrite it
if [[ -e "$file" ]]; then
[[ -w "$file" ]] || { echo "write_output: $file not writable" >&2; return 2; }
fi
printf '%s\n' "$@" > "$file"
}
For a brand-new file, the directory must be writable — file permissions don’t exist yet.
Always Quote Variables
file="My Documents/notes.txt"
# ❌ Unquoted — Bash word-splits into TWO args: "My" and "Documents/notes.txt"
cat $file
# cat: My: No such file or directory
# cat: Documents/notes.txt: No such file or directory
# ✅ Quoted — one arg, exactly the filename
cat "$file"
Rule of thumb: every variable that holds a filename, path, or anything that could contain spaces is quoted.
Same applies to arrays:
files=("a b.txt" "c d.txt")
# ❌ Splits each element on spaces
for f in ${files[@]}; do echo "$f"; done
# ✅ Treats each element as one arg
for f in "${files[@]}"; do echo "$f"; done
Use -- to Terminate Options
What if a filename starts with -? Most tools will interpret it as a flag:
> "-rf" # create a file literally named -rf
# ❌ Disaster
rm -rf . # OK
rm "-rf" # interpreted as "rm -rf" with NO arg → no-op
# but in some tools, like ls, it would be:
ls -rf # ls flags
# ✅ Use -- to say "no more options after this point"
rm -- "-rf"
ls -- "-rf"
-- is honoured by all GNU coreutils. Always pass -- before user-supplied filenames in production scripts:
cat -- "$file"
mv -- "$src" "$dst"
rm -- "$file"
Distinguish “Missing” from “Permission Denied”
Three different problems, three different fixes:
check() {
local f=$1
if [[ ! -e "$f" ]]; then echo "missing"
elif [[ -d "$f" ]]; then echo "is a directory"
elif [[ ! -r "$f" ]]; then echo "permission denied"
else echo "ok"
fi
}
check /etc/passwd # ok
check /etc/shadow # permission denied (unless root)
check /etc # is a directory
check /not/here # missing
A useful logging pattern in real scripts:
classify_error() {
local f=$1
[[ ! -e "$f" ]] && { log error "missing $f"; return; }
[[ ! -r "$f" ]] && { log error "perm denied: $f"; return; }
[[ -d "$f" ]] && { log error "expected file, got dir: $f"; return; }
log info "ok: $f"
}
Strict Mode at the Top
Set these at the top of every script and your defenses get a huge boost for free:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
set -e— abort on the first failing commandset -u— abort on use of an unset variable (catches typos)set -o pipefail— pipeline status = first non-zero exitIFS=$'\n\t'— split only on newlines/tabs (not spaces) forfor/readloops
Covered fully in Handling Errors.
Safe Deletion Pattern
The single most dangerous primitive in shell:
# ❌ The classic disaster
rm -rf "$BASE/$SUBDIR/" # if either var is empty: rm -rf /
# ✅ Sanity check both vars and the path
[[ -n "$BASE" ]] || { echo "BASE not set" >&2; exit 1; }
[[ -n "$SUBDIR" ]] || { echo "SUBDIR not set" >&2; exit 1; }
target="$BASE/$SUBDIR"
[[ -d "$target" ]] || { echo "$target not a dir" >&2; exit 1; }
[[ "$target" =~ ^/(home|tmp|var/cache)/ ]] \
|| { echo "$target outside allowed roots" >&2; exit 1; }
rm -rf -- "$target"
A regex whitelist of allowed parent paths is a powerful belt-and-braces guard against typos.
Failing Fast vs Failing Late
Compare these two styles for the same job:
# ❌ Fail late — all the noise from each step
backup_file file1
backup_file file2
backup_file file3
upload_backup
If backup_file file2 fails silently and upload_backup runs anyway, you may upload a partial archive without realising. Add set -e and set -o pipefail and have each function return 1 on error — failures stop the script immediately.
# ✅ Fail fast — first error halts the chain
set -euo pipefail
backup_file file1
backup_file file2
backup_file file3
upload_backup
Common Mistakes
| ❌ Wrong | ✅ Right | Issue |
|---|---|---|
rm $file |
rm -- "$file" |
unquoted + no -- = disaster on weird names |
[ -f $file ] |
[[ -f $file ]] |
unquoted in POSIX [ ] splits |
cat $@ |
cat -- "$@" |
doesn’t handle filenames with spaces or leading - |
rm -rf "$dir/foo" (no guard) |
[[ -n "$dir" ]] first |
unset $dir → rm -rf /foo |
| Assume success then react | set -euo pipefail at top |
silent failures cascade |
Next → chmod & chown in Scripts