Linux & Bash · Part II · Module 33 · Debugging Failed Jobs and Broken Flows
Quoting and Path Defects in Real Automation
Most flow bugs are one of seven quoting defects, and the dangerous ones do not produce an error — they produce a plausible command with the wrong arguments. This chapter measures each defect's exact effect on the argv a tool receives, shows an empty variable and a space together yielding the correct argument count with every value wrong, and builds a detector whose coverage is measured rather than asserted.
Advanced27 min readLinuxPart IIDebuggingQuotingPaths
Part II · Module 33 · Chapter 33.5 · Quoting and Path Defects
Chapter 33.3 met this twice — one line building two arguments in zsh and four in bash. This chapter is the full catalogue, and the reason it is Critical is in Section 2: the worst case produces the right number of arguments.
Every measurement below prints the argv the shell actually built, because that is the only thing a tool ever sees.
1. The Seven
=== 1. an expansion containing a space ===
vvp $p (unquoted) argc=2 [1]=/a [2]=dir/sim.vvp
vvp "$p" (quoted) argc=1 [1]=/a dir/sim.vvp
=== 2. an EMPTY variable ===
cmd $e x (unquoted) argc=1 [1]=x
cmd "$e" x (quoted) argc=2 [1]= [2]=x
=== 3. "$@" vs $@ vs "$*" in a wrapper ===
forward "$@" argc=2 [1]=+TESTNAME=smoke test [2]=+SEED=1
forward $@ argc=3 [1]=+TESTNAME=smoke [2]=test [3]=+SEED=1
forward "$*" argc=1 [1]=+TESTNAME=smoke test +SEED=1
=== 5. command substitution eats trailing newlines, and splits ===
args from $(cat list.txt) argc=3 [1]=a [2]=b [3]=c
args from "$(cat list.txt)" argc=1 [1]=a b
c
=== 7. a glob inside quotes does not expand ===
ls *.vvp (unquoted) argc=5 [1]=logs/draw.vvp [2]=logs/draw2.vvp …
ls "*.vvp" (quoted) argc=1 [1]=logs/*.vvpAnd two that produce messages rather than argv:
=== 4. a filename that begins with a dash ===
grep hello qt/-weird.log -> grep: hello: No such file or directory
grep hello -- -weird.log -> hello
grep hello ./-weird.log -> hello
=== 6. [ $x = y ] with x empty ===
unquoted: bin/probe_quote.sh: line 38: [: =: unary operator expected
quoted : no match (and no error)| # | the defect | what the tool gets | the fix |
|---|---|---|---|
| 1 | a space inside an expansion | two arguments where one was meant | quote it |
| 2 | an empty expansion | the argument disappears | quote it |
| 3 | $@ or $* instead of "$@" | re-split, or glued into one | "$@" |
| 4 | a filename starting with - | an option, and a confusing error | -- or ./ |
| 5 | unquoted command substitution | split on whitespace, newlines eaten | read line by line |
| 6 | an unquoted operand in [ ] | a syntax error from the test | quote both operands |
| 7 | a glob inside quotes | a literal * handed to the tool | leave it unquoted, deliberately |
Defect 4 is worth a second look, because the error message is actively misleading. grep hello -weird.log reports grep: hello: No such file or directory — it blames hello. What happened is that grep parsed -weird.log as options, one of which (-e) takes an argument, so hello became a filename. Chapter 14.1 covers --; the reason it belongs in a debugging chapter is that the message points away from the problem.
2. The Case That Passes the Argument Count Check
A wrapper taking positional arguments, as almost all of them do:
#!/bin/bash
set -u
test=${1:-MISSING}; seed=${2:-MISSING}; out=${3:-MISSING}Called with an empty seed variable, unquoted:
-- unquoted: posflow.sh $T $S $O --
received argc=2 [1]=smoke [2]=runs/x
test=smoke seed=runs/x out=MISSINGseed=runs/x. The empty variable did not become an empty argument — it vanished, and the output directory slid left into the seed's position. The tool will be handed a seed of runs/x and write its results to whatever MISSING resolves to.
Quoted, the same call is diagnosable:
-- quoted: posflow.sh "$T" "$S" "$O" --
received argc=3 [1]=smoke [2]= [3]=runs/x
test=smoke seed=MISSING out=runs/xAn empty seed is still wrong, but it is visibly wrong, and ${2:-MISSING} catches it.
And now both defects at once, which is the realistic case, because an area with a space in its name and a variable that is sometimes empty are both ordinary:
=== both at once, which is the realistic case ===
-- unquoted --
received argc=3 [1]=smoke [2]=runs/my [3]=runs
test=smoke seed=runs/my out=runs
-- quoted --
received argc=3 [1]=smoke [2]= [3]=runs/my runs3. The One Rule
Every defect in Section 1 except the last is the same mistake: leaving an expansion unquoted when the thing being expanded is data rather than a list.
vvp "$IMAGE" "+TESTNAME=$test" "+SEED=$seed" > "$out/run.log"Quote every expansion. Then, deliberately and visibly, unquote the ones that are meant to produce several words — and there are only two of those in practice:
A glob you want expanded. rm -f "$d"/*.vcd — note where the quotes stop. The directory is data and is quoted; the * is a list and is not.
A variable that genuinely holds a list, which is almost always a mistake waiting to happen. Chapter 31.2 argued for keeping lists in files rather than in variables, and defect 5 above is why: the moment a list lives in a string, re-splitting it is the only way to use it, and the splitting rules are not the quoting rules.
The replacement for both is to read a line at a time:
while IFS= read -r f; do
process "$f"
done < filelist.txtIFS= so spaces survive, -r so backslashes are literal — and Chapter 33.2 added the third piece, || [ -n "$f" ], for a file whose last line has no newline.
4. A Detector, and What It Does Not Catch
shellcheck would do this properly, and it is not installed here:
$ command -v shellcheck || echo "shellcheck: NOT INSTALLED on this machine"
shellcheck: NOT INSTALLED on this machineSo the subset that matters most, by hand:
#!/bin/bash
# quotelint FILE... -- flag expansions used outside double quotes.
# shellcheck is not installed on this machine, so this covers the one subset
# that accounts for most flow bugs: $VAR as a command argument, unquoted.
# It walks each line character by character tracking quote state, because a
# regex cannot tell "$x" from $x reliably once both appear on one line.
set -u
[ $# -gt 0 ] || { echo "quotelint: give me one or more files" >&2; exit 2; }
for f in "$@"; do [ -r "$f" ] || { echo "quotelint: cannot read '$f'" >&2; exit 2; }; done
awk '
function flag(file, ln, name, text) {
printf " %s:%d: unquoted $%s -> %s\n", file, ln, name, text
hits++
}
{
line = $0
sub(/^[ \t]+/, "", line)
if (line ~ /^#/) next
sq = 0; dq = 0; n = length($0)
for (i = 1; i <= n; i++) {
c = substr($0, i, 1)
if (c == "\\") { i++; continue }
if (c == "'"'"'" && !dq) { sq = !sq; continue }
if (c == "\"" && !sq) { dq = !dq; continue }
if (c == "#" && !sq && !dq) break # a comment ends the code
if (c == "$" && !sq && !dq) {
rest = substr($0, i + 1)
if (rest ~ /^\{?[A-Za-z_][A-Za-z_0-9]*\}?/ || rest ~ /^[@*]/) {
match(rest, /^\{?[A-Za-z_@*][A-Za-z_0-9]*\}?/)
nm = substr(rest, 1, RLENGTH)
gsub(/[{}]/, "", nm)
# an assignment target like X=$Y is safe in bash: no splitting happens
pre = substr($0, 1, i - 1)
if (pre ~ /[A-Za-z_][A-Za-z_0-9]*=[^ \t]*$/) continue
if (nm ~ /^(\?|#|!)$/) continue
flag(FILENAME, FNR, nm, substr(line, 1, 56))
}
}
}
}
END { exit (hits > 0 ? 1 : 0) }
' "$@"On a deliberately defective wrapper:
$ quotelint bin/bad.sh
bin/bad.sh:5: unquoted $OUT -> mkdir -p $OUT
bin/bad.sh:6: unquoted $IMAGE -> vvp $IMAGE $OPTS > $OUT/run.log
bin/bad.sh:6: unquoted $OPTS -> vvp $IMAGE $OPTS > $OUT/run.log
bin/bad.sh:6: unquoted $OUT -> vvp $IMAGE $OPTS > $OUT/run.log
rc=1
$ quotelint bin/good.sh
rc=0What it gets right is as important as what it reports. In bad.sh it did not flag:
IMAGE=$PROJ_ROOT/logs/sim.vvp # an assignment -- no splitting happens here
OPTS="+TESTNAME=$1 +SEED=$2" # inside double quotes
cp "$OUT/run.log" "$ARCHIVE/keep.log" # quotedThe assignment case is the one that matters, because X=$Y genuinely needs no quotes — bash does not word-split the right-hand side of an assignment — and a linter that flagged it would produce noise that gets it switched off. Tracking quote state character by character is what makes that distinction possible; a regex cannot tell "$x" from $x once both appear on one line.
5. Paths, Specifically
Three path defects are worth separating from quoting, because quoting does not fix them.
A relative path crossing a cd. The mechanism this track has met seven times. Resolve once, absolutely, at the top:
root=$(cd "$(dirname "$0")/.." && pwd)Note that this line itself contains an unquoted $( ) — deliberately, because cd takes one argument and "$(dirname "$0")" would be the quoted form. Writing it quoted is better:
root=$(cd "$(dirname "$0")/.." && pwd) # $0 IS quoted, insideA path assembled with a stray or missing separator. "$root/$sub" where $sub already begins with / gives //, which works, and where $root is empty gives /sub, which is Chapter 33.4's measured hazard — an absolute path into the filesystem root.
And a path that is a symlink to somewhere else. pwd returns the logical path by default; pwd -P resolves symlinks. Two runs recording different-looking roots for the same area is a confusing afternoon, and the fix is to pick one and use it everywhere — Chapter 33.4's identity check is what makes the choice not matter.
Practice
- Print the argv for an expansion containing a space, quoted and unquoted.
- Do the same for an empty variable and note the argument count.
- Compare
"$@",$@and"$*"in a wrapper that forwards its arguments. - Create a file whose name starts with
-and grep it; read the error message carefully. - Fix it two ways —
--and./. - Expand an unquoted command substitution over a file with a space and a newline.
- Write
[ $x = y ]withxempty and read the error. - Quote a glob and see what the tool receives.
- Build a positional wrapper and call it with an empty variable, unquoted.
- Find which argument ended up in which slot.
- Add a space to the output directory too, and count the arguments.
- Add an
[ $# -eq 3 ]guard and confirm it passes. - Add per-argument shape validation and confirm it refuses.
- State the one rule, and the two cases where you unquote on purpose.
- Argue why quote-everything beats quote-what-needs-it.
- Find a quoted glob in your own scripts.
- Check whether
shellcheckis installed on the machine your flow runs on. - Write a linter that tracks quote state rather than using a regex.
- Confirm it does not flag assignments, and say why that matters.
- Count its coverage against a file of known defects.
- Name its gap precisely so a reviewer knows what is left.
- Run one of your quoting probes in the wrong shell and see what it tells you.
- Assemble a path from an empty root and see where it points.
- Compare
pwdandpwd -Pin a directory reached through a symlink.
Check Your Understanding
- What does an unquoted expansion containing a space produce?
- What does an unquoted empty expansion produce, and why is that worse?
- Give the argument counts for
"$@",$@and"$*". - What error does
grep hello -weird.logprint, and what actually happened? - Give the two fixes for a leading dash.
- What does an unquoted command substitution do to
a b\nc? And a quoted one? - What does
[ $x = y ]do whenxis empty? - What does a quoted glob hand the tool?
- In the positional wrapper, what did
seedbecome when the seed variable was empty? - With both defects at once, how many arguments arrived?
- Why does an
[ $# -eq 3 ]guard pass? - What earlier measurement has the same shape?
- What does catch it?
- State the one rule.
- What are the only two cases for unquoting on purpose?
- Why is quote-everything the right default?
- What does quoting an unnecessary expansion cost?
- Which defect is the exception, and how must the rule be phrased?
- Why is the trace the only direct evidence?
- Is
shellcheckavailable here? - Why must a linter not flag
X=$Y? - Why can a regex not do this job?
- What is the linter's measured coverage, and what does it miss?
- Why is an unmeasured linter a liability?
- What did my own probe get wrong, and how?
- Name the three path defects quoting does not fix.
- What does an empty root produce when a path is assembled from it?
6. Remember
- Seven defects, and the dangerous ones produce no error — just a plausible command with the wrong arguments.
- An unquoted empty expansion does not become an empty argument; it vanishes, and everything after it shifts left.
- An empty variable plus a space gave
argc=3— the correct count — with every value wrong.[ $# -eq 3 ]passes. - Counting arguments is not validating them. Check each one's shape.
"$@"is 2,$@is 3,"$*"is 1.grep hello -weird.logblameshello. Use--or./.- An unquoted command substitution splits on whitespace and eats trailing newlines. Read a line at a time with
IFS= read -r. [ $x = y ]withxempty is a syntax error, not a false.- Quote data, unquote lists on purpose. Quoting what did not need it costs nothing; the asymmetry is total.
shellcheckis not installed here, so the linter has to track quote state character by character — and must not flagX=$Y.- Its measured coverage is 2 of 5, missing every
$( )and backtick. Publish that number: an unmeasured linter reads as an all-clear. - My own quoting probe was first run in zsh, which does not split — the wrong shell for a measurement about splitting.
- Quoting does not fix paths: resolve relatives before any
cd, watch an empty root giving/sub, and pickpwdorpwd -Pand keep to it.
7. What Is Next
Section 3 ended on the one piece of direct evidence about what a tool was asked to do. Chapter 33.6 is that evidence: recovering the actual sequence of commands a long flow executed, from the shell itself, rather than inferring it from the output the flow chose to print.
Where this fits
Part of the Linux & Bash curriculum.
