Linux Basic — Defensive File Handling

4 minute read

Defensive File Handling

A defensive script answers three questions before it touches a file:

  1. Does it exist?
  2. Do I have permission?
  3. 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 command
  • set -u — abort on use of an unset variable (catches typos)
  • set -o pipefail — pipeline status = first non-zero exit
  • IFS=$'\n\t' — split only on newlines/tabs (not spaces) for for/read loops

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

Updated: