← Blog
Linux & Server Operations

Test file state before Bash acts—and handle the race

Negating a Bash file test takes one character. Deciding what “missing” means—and keeping the next operation safe when the filesystem changes—takes more care. A deployment script can report that a file is absent when it …

By Emmanuel Corels

Negating a Bash file test takes one character. Deciding what “missing” means—and keeping the next operation safe when the filesystem changes—takes more care. A deployment script can report that a file is absent when it is actually a directory, a broken symbolic link, unreadable, on an unavailable mount, or simply replaced between the check and the action.

Start with the state the operation requires

In Bash, [[ ! -f "$config" ]] is true when the path does not resolve to a regular file. That is not identical to “no directory entry exists.” Current Bash documentation defines -e as exists and -f as exists and is a regular file; most file predicates follow symbolic links to their targets.

config=/etc/example/service.conf

if [[ ! -e $config ]]; then
    printf 'missing path: %s\n' "$config" >&2
    exit 1
fi

if [[ ! -f $config ]]; then
    printf 'not a regular file: %s\n' "$config" >&2
    exit 1
fi

Inside [[ ... ]], Bash does not perform word splitting or pathname expansion on an ordinary variable expansion. Quoting the path is still a useful house rule and becomes essential with the portable [ ... ] command:

if [ ! -f "$config" ]; then
    printf '%s\n' "regular file not found" >&2
fi

The spaces around [, its operands, and ] are syntax: [ is a command. An unquoted empty or whitespace-containing value can change its argument count and produce a misleading result.

Diagnose before creating or deleting anything

Capture the path exactly and inspect every parent component. Avoid printing secrets embedded in filenames or URLs to a shared log.

printf 'config=<%q>\n' "$config" >&2
namei -l -- "$config"
stat -- "$config"
readlink -- "$config"
findmnt -T "$config"

namei can reveal a non-searchable parent directory. readlink distinguishes a dangling link from an ordinary missing entry. findmnt helps catch a missing network or container mount. Run diagnostics as the same user, namespace, container, and working directory as the failing service; a root shell on the host may see a different filesystem.

Relative paths are especially fragile under systemd, cron, CI runners, and containers. Establish an absolute base rather than assuming the caller’s directory:

script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) || exit
config=$script_dir/config/service.conf

The safe fix depends on the intended object

If absence is an expected first-run state, create a parent directory with explicit ownership and mode, write a temporary file in the same directory, validate it, then rename it into place. A same-filesystem rename prevents readers from seeing a partial file.

install -d -m 0750 -- /etc/example
tmp=$(mktemp /etc/example/.service.conf.XXXXXX) || exit
trap 'rm -f -- "$tmp"' EXIT HUP INT TERM

umask 077
generate_config >"$tmp" || exit
validate_config "$tmp" || exit
chmod 0640 -- "$tmp"
chgrp example -- "$tmp"
mv -- "$tmp" "$config" || exit
trap - EXIT HUP INT TERM

Do not turn a failed prerequisite into touch "$config" unless an empty regular file is genuinely valid. That can mask a failed mount, overwrite the meaning of a dangling link, or let a service start with unsafe defaults.

Do not confuse a pre-check with a lock

The filesystem may change after a successful test. This check-then-act race matters when another process or an attacker can modify the directory. Prefer an operation whose own failure is authoritative:

if ! exec 9>>"$config"; then
    printf 'cannot open config log target\n' >&2
    exit 1
fi

For exclusive creation, use a primitive with exclusive semantics, such as mkdir for a lock directory or an application API that opens with O_CREAT|O_EXCL. For coordination among cooperating shell processes, flock can serialize work, but its guarantees depend on the filesystem and every participant honoring the lock.

Verification should exercise the negative paths

Run the script in a disposable directory as its production account. Test an ordinary file, no entry, directory, unreadable file, dangling symlink, filename containing spaces, and a path beginning with a dash. Confirm the exit status and that no unintended file was created.

bash -n deploy.sh
shellcheck deploy.sh

./deploy.sh; status=$?
printf 'status=%d\n' "$status"

Static analysis catches common quoting and conditional mistakes, but it cannot prove mount availability, permissions, or race safety. In production, log a stable error code and the resolved non-secret path; alert separately on missing mounts and permission failures rather than collapsing every state into “not found.”

Failure modes, rollback, and recovery

  • -f is false for a directory: use the predicate that matches the required type instead of creating over it.
  • A symlink exists but its target does not: repair the target or link deliberately. -e following the link can be false.
  • Permission denied resembles absence: inspect parent traversal and run identity; do not broaden permissions globally.
  • A mount is late: make service ordering and mount requirements explicit. Writing into the bare mount point can strand data under the later mount.
  • A generated replacement is invalid: leave the old file in place, retain the rejected temporary artifact in a protected incident location if needed, and fix the generator.

If a bad file has already been installed, stop or isolate its consumer, preserve metadata and a checksum, atomically restore the last known-good file, validate, then restart under the normal change procedure. Do not recursively delete a guessed path. The production trade-off is clear: strict fail-closed checks may reduce availability, while silent auto-creation may start a service with insecure or destructive defaults. Make that policy explicit per file.

Attribution and verification

This guide was prompted by Bill the Lizard’s Stack Overflow question and John Feminella’s accepted answer, used under CC BY-SA. The accepted answer’s negated test remains correct. Its current semantics and the distinctions among -e, -f, and symbolic links were independently verified against the official GNU Bash 5.3 documentation for Bash conditional expressions and conditional constructs.

Primary source: Review the official reference ↗