Skip to content

Newsletter · Issue #052

The Shell Emptied the File Before It Failed

After this issue, the reader can tell when a shell command that failed with command not found has already destroyed the file it was writing to, and can write file-producing commands so a failure cannot.

Published
Format
Field Note
Reader job
Challenge
Length
2 min read
Written by
Victor Solano

Four files went to zero bytes at 10:43:59 this morning. The shell printed command not found: cat, once per loop iteration. No error mentioned a file. I found it by listing the directory afterwards.

Two zsh behaviours have to line up, and neither is a bug. zsh binds the variable name path to PATH, so assigning to it replaces your PATH and every binary stops resolving. And zsh opens a redirect target before it resolves the command name, so the file is truncated before the shell discovers there is nothing to run.

cdpath, fpath, manpath and fignore are bound the same way. My loop used path as its variable for a path field, which is what that field is called in the files it was writing.
# 1. `path` is bound to PATH. This is documented zsh, not a bug.
zsh -c 'path="/p"; echo $PATH'
# /p

# 2. The redirect opens the file before the command is resolved.
zsh -c 'echo IMPORTANT > f; path=/nowhere; cat > f <<EOF
new
EOF'
# zsh: command not found: cat
stat -f %z f
# 0
Incident
A loop wrote four coordination files. It parsed each one's path into a variable called path, which emptied PATH. cat stopped resolving, and every cat > file still truncated its target first. Four files gone, one missing-binary message.
Decision
I did not rebuild them from memory. I quoted only what I could show from a read taken a minute before the loss, recorded that the rest is gone, and pointed at the git commits that were the real record anyway.
Portable lesson
A shell that reports a missing binary has often already destroyed the target. Check the size before you retype the command.
  1. Step 01

    Do not name a zsh variable path

    Use lease_path, target, dest. That is the entire root cause, and it is one word.

  2. Step 02

    Write through a temp file

    Build the content into "$tmp", then mv "$tmp" "$target". A failed command cannot touch the live file, because it was never opened.

  3. Step 03

    Sweep for empties

    for f in *; do [ -s "$f" ] || echo "EMPTY: $f"; done. This is how I found the damage.

The part worth keeping

I had been reading command not found as a typo: annoying, retype it, move on. When the command has a redirect attached it is not that. The message describes what did not happen and says nothing about what did.

The error names the command. It does not name the damage.