Emmanuel Corels
← Blog
Linux & Server Operations

Make directory-dependent Bash jobs safe around missing paths and symlinks

Turn a familiar directory check into a reliable operational pattern: quote paths, distinguish links, create atomically, validate ownership, and test failure paths.

By Emmanuel Corels

A deployment job writes reports beneath a configurable directory. The obvious check works in a demo, then production supplies a path with spaces, a dangling symbolic link, a read-only mount, or two workers start together. The useful question is not merely “does this directory exist?” It is “what property do I need, and can I perform the required action safely?”

Use the directory test when observation is the goal

report_dir=/srv/reports/daily

if [[ -d "$report_dir" ]]; then
    printf 'directory is available: %s\n' "$report_dir"
else
    printf 'not an accessible directory: %s\n' "$report_dir" >&2
fi

In Bash, [[ -d path ]] succeeds when the path exists and resolves to a directory. Quote the expansion even inside [[ ]]; it documents that the value is one path and remains safe if the code is later moved into a command. Do not parse ls output or test command output text.

Know what -d says—and what it does not

TestQuestion answered
[[ -d "$p" ]]Does the resolved path identify a directory?
[[ -e "$p" ]]Does a resolved filesystem object exist?
[[ -L "$p" ]]Is this directory entry a symbolic link?
[[ -r "$p" ]]Would this process currently pass a readability check?
[[ -w "$p" ]]Would this process currently pass a writability check?
[[ -x "$p" ]]Can this process search/traverse it?

These are observations at one instant. Access-control lists, mount state, parent permissions, quotas and a later rename can still make the next operation fail. The operation’s exit status remains authoritative.

Distinguish a directory link from a real directory entry

if [[ -L "$report_dir" ]]; then
    printf 'refusing symbolic link: %s\n' "$report_dir" >&2
    exit 1
elif [[ ! -d "$report_dir" ]]; then
    printf 'expected a directory: %s\n' "$report_dir" >&2
    exit 1
fi

-d follows a symbolic link. That is often desirable for stable release links such as /opt/app/current, but dangerous when policy requires a directory owned directly at that pathname. A dangling link makes -L true while -d and -e are false. State the intended policy before choosing tests.

Prefer the action when the goal is creation

if ! mkdir -p -- "$report_dir"; then
    printf 'cannot prepare report directory: %s\n' "$report_dir" >&2
    exit 1
fi

GNU mkdir -p creates missing parents and does not complain when the final path is already a directory. It also avoids the classic check-then-create race in which another process changes the path between [[ ! -d ]] and mkdir. The -- ends option processing if a relative path begins with a dash.

Do not hide a conflicting file

target=/var/lib/example/cache
if ! mkdir -p -- "$target"; then
    if [[ -e "$target" || -L "$target" ]]; then
        printf 'path exists but cannot serve as directory: %s\n' "$target" >&2
    else
        printf 'directory creation failed: %s\n' "$target" >&2
    fi
    exit 1
fi

If a regular file occupies the target, mkdir -p fails. Preserve that failure. Never “fix” it by recursively deleting the path in an unattended job; the path may be valuable data or evidence of a configuration error.

Validate configuration before touching the filesystem

case "$report_dir" in
  /srv/reports/*) ;;
  *)
    printf 'report path is outside /srv/reports: %s\n' "$report_dir" >&2
    exit 64
    ;;
esac

[[ "$report_dir" != *$'\n'* ]] || exit 64

A directory test is not path authorization. If configuration controls where a privileged job writes, enforce an allowed root, reject empty values and resolve whether symlinks are permitted. Avoid interpolating untrusted paths into sudo, remote shells, find -exec sh -c, or log formats.

Create private directories with an explicit mode

old_umask=$(umask)
umask 077
mkdir -p -- "$report_dir"
umask "$old_umask"

The process umask influences new directory permissions. It does not retrofit permissions on an existing directory, and GNU mkdir -p -m applies the requested mode to the final newly created directory rather than every existing parent. For security-sensitive state, verify ownership and mode after creation.

Use install -d when deployment owns the metadata

install -d   -o reportd   -g reportd   -m 0750   -- /var/lib/reportd /var/log/reportd

install -d is convenient in packaging or root-run deployment because owner, group and mode are explicit. Do not run chown -R over a broad existing tree merely to establish one leaf directory; that can change application data and cross mount boundaries.

Test usability by performing a bounded write

probe=$(mktemp --tmpdir="$report_dir" .write-test.XXXXXX) || {
    printf 'directory is not safely writable: %s\n' "$report_dir" >&2
    exit 1
}
trap 'rm -f -- "$probe"' EXIT HUP INT TERM
printf '%s\n' "probe $$" > "$probe"
rm -f -- "$probe"
trap - EXIT HUP INT TERM

[[ -w ]] is only a permission test. A real write can still fail because a filesystem is full, read-only, disconnected, subject to quota, or blocked by a mandatory access-control policy. A uniquely named, promptly removed probe tests more of the actual contract without overwriting a known filename.

Create temporary workspaces without predicting names

work_dir=$(mktemp -d "${TMPDIR:-/tmp}/report-job.XXXXXXXX") || exit 1
cleanup() {
    case "$work_dir" in
      "${TMPDIR:-/tmp}"/report-job.*) rm -rf -- "$work_dir" ;;
      *) printf 'refusing unexpected cleanup path\n' >&2 ;;
    esac
}
trap cleanup EXIT HUP INT TERM

mktemp -d creates the directory exclusively, avoiding predictable-name races. Cleanup still deserves a boundary check. Never construct a cleanup target from an unset variable, and never recursively remove a broad configurable path.

Publish completed output with a rename

tmp_file=$(mktemp --tmpdir="$report_dir" .report.XXXXXX) || exit 1
trap 'rm -f -- "$tmp_file"' EXIT

generate_report > "$tmp_file"
chmod 0640 "$tmp_file"
mv -f -- "$tmp_file" "$report_dir/latest.txt"
trap - EXIT

Creating the directory does not make a multi-step file update atomic. Write within the destination filesystem, validate the temporary file, then rename it into place. Readers see either the old file or the complete new file, rather than a partially written report.

Diagnose the path component that fails

namei -l -- "$report_dir"
findmnt -T "$report_dir"
df -h -- "$report_dir"
df -i -- "$report_dir"
getfacl -p -- "$report_dir"

namei -l exposes permissions and ownership for every path component. findmnt identifies the backing mount; capacity and inode exhaustion are separate failure modes. Capture only authorized metadata because usernames and directory names may be sensitive.

Make errors survive strict mode

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

prepare_directory() {
    local path=$1
    mkdir -p -- "$path" ||
        { printf 'mkdir failed for %q\n' "$path" >&2; return 1; }
}

prepare_directory "${REPORT_DIR:?REPORT_DIR is required}"

set -e has contextual exceptions and is not an error-handling design by itself. Test critical commands explicitly, return failures, and quote diagnostic paths with %q when control characters could make logs misleading.

Exercise the failure matrix before deployment

root=$(mktemp -d)
trap 'rm -rf -- "$root"' EXIT

mkdir "$root/existing"
touch "$root/file"
ln -s "$root/existing" "$root/link-to-directory"
ln -s "$root/missing" "$root/dangling-link"

for path in "$root/existing" "$root/file"             "$root/link-to-directory" "$root/dangling-link"; do
    if [[ -d "$path" ]]; then state=directory; else state=not-directory; fi
    printf '%-40s %s\n' "$path" "$state"
done

Add tests for spaces, leading dashes, permission denial, read-only mounts, full filesystems, concurrent creators and unexpected symlinks. Run privileged and unprivileged cases separately; root can make an unrealistic permission test appear successful.

Use -d to observe a directory, but use the intended filesystem operation to prove the work can proceed. The gap between those two statements is where operational failures—and symlink mistakes—live.

Sources: the solved Stack Overflow question (question and accepted answer by Grundlefleck, CC BY-SA), the GNU Bash manual’s conditional expressions, and GNU Coreutils documentation for mkdir, mktemp, and install.

Primary source: Review the official reference ↗