Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
=== 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/*.vvp

And two that produce messages rather than argv:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
=== 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 defectwhat the tool getsthe fix
1a space inside an expansiontwo arguments where one was meantquote it
2an empty expansionthe argument disappearsquote it
3$@ or $* instead of "$@"re-split, or glued into one"$@"
4a filename starting with -an option, and a confusing error-- or ./
5unquoted command substitutionsplit on whitespace, newlines eatenread line by line
6an unquoted operand in [ ]a syntax error from the testquote both operands
7a glob inside quotesa literal * handed to the toolleave 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/bin/bash
set -u
test=${1:-MISSING}; seed=${2:-MISSING}; out=${3:-MISSING}

Called with an empty seed variable, unquoted:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  -- unquoted: posflow.sh $T $S $O --
  received argc=2 [1]=smoke [2]=runs/x
  test=smoke seed=runs/x out=MISSING

seed=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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  -- quoted:   posflow.sh "$T" "$S" "$O" --
  received argc=3 [1]=smoke [2]= [3]=runs/x
  test=smoke seed=MISSING out=runs/x

An 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
=== 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 runs
Four unquoted outcomes of one three-argument call. An empty seed gives two arguments and the output directory lands in the seed slot. A space in the output directory gives four arguments and the directory is truncated. Both together give three arguments, the correct count, with every value wrong. Quoting gives three arguments with every value right.Empty seedargc=2, out is MISSINGSpace in the pathargc=4, path truncatedBoth at onceargc=3 — the right countQuote every oneargc=3Empty stays emptyso a default can catch itValidate each shapea count is not a check12
Figure 1 — the same three-argument call, made four ways. An empty variable removes an argument and shifts the rest left; a space adds one. Together they cancel in the count and compound in the content, so the call arrives with the correct number of arguments and every value in the wrong slot.

3. 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
while IFS= read -r f; do
  process "$f"
done < filelist.txt

IFS= 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ command -v shellcheck || echo "shellcheck: NOT INSTALLED on this machine"
shellcheck: NOT INSTALLED on this machine

So the subset that matters most, by hand:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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=0

What it gets right is as important as what it reports. In bad.sh it did not flag:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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"  # quoted

The 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
root=$(cd "$(dirname "$0")/.." && pwd)        # $0 IS quoted, inside

A 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

  1. Print the argv for an expansion containing a space, quoted and unquoted.
  2. Do the same for an empty variable and note the argument count.
  3. Compare "$@", $@ and "$*" in a wrapper that forwards its arguments.
  4. Create a file whose name starts with - and grep it; read the error message carefully.
  5. Fix it two ways — -- and ./.
  6. Expand an unquoted command substitution over a file with a space and a newline.
  7. Write [ $x = y ] with x empty and read the error.
  8. Quote a glob and see what the tool receives.
  9. Build a positional wrapper and call it with an empty variable, unquoted.
  10. Find which argument ended up in which slot.
  11. Add a space to the output directory too, and count the arguments.
  12. Add an [ $# -eq 3 ] guard and confirm it passes.
  13. Add per-argument shape validation and confirm it refuses.
  14. State the one rule, and the two cases where you unquote on purpose.
  15. Argue why quote-everything beats quote-what-needs-it.
  16. Find a quoted glob in your own scripts.
  17. Check whether shellcheck is installed on the machine your flow runs on.
  18. Write a linter that tracks quote state rather than using a regex.
  19. Confirm it does not flag assignments, and say why that matters.
  20. Count its coverage against a file of known defects.
  21. Name its gap precisely so a reviewer knows what is left.
  22. Run one of your quoting probes in the wrong shell and see what it tells you.
  23. Assemble a path from an empty root and see where it points.
  24. Compare pwd and pwd -P in a directory reached through a symlink.

Check Your Understanding

  1. What does an unquoted expansion containing a space produce?
  2. What does an unquoted empty expansion produce, and why is that worse?
  3. Give the argument counts for "$@", $@ and "$*".
  4. What error does grep hello -weird.log print, and what actually happened?
  5. Give the two fixes for a leading dash.
  6. What does an unquoted command substitution do to a b\nc? And a quoted one?
  7. What does [ $x = y ] do when x is empty?
  8. What does a quoted glob hand the tool?
  9. In the positional wrapper, what did seed become when the seed variable was empty?
  10. With both defects at once, how many arguments arrived?
  11. Why does an [ $# -eq 3 ] guard pass?
  12. What earlier measurement has the same shape?
  13. What does catch it?
  14. State the one rule.
  15. What are the only two cases for unquoting on purpose?
  16. Why is quote-everything the right default?
  17. What does quoting an unnecessary expansion cost?
  18. Which defect is the exception, and how must the rule be phrased?
  19. Why is the trace the only direct evidence?
  20. Is shellcheck available here?
  21. Why must a linter not flag X=$Y?
  22. Why can a regex not do this job?
  23. What is the linter's measured coverage, and what does it miss?
  24. Why is an unmeasured linter a liability?
  25. What did my own probe get wrong, and how?
  26. Name the three path defects quoting does not fix.
  27. 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.log blames hello. 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 ] with x empty 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.
  • shellcheck is not installed here, so the linter has to track quote state character by character — and must not flag X=$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 pick pwd or pwd -P and 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.