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.
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
| Test | Question 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
-dto 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 ↗