skip to content

A script builds options in a string, `OPTS="--output 'my report.csv'"`, then runs `mytool $OPTS`. The tool receives four arguments instead of two, and one of them starts with a quote character. Why, and how should the option list be built instead?

level: middleimportance: should knowfreq 50%

answer

  1. a string is not an argument list
  2. quotes inside a value are data
  3. splitting runs once, quoting does not
  4. one element per argument, expand quoted

basics

~20 s

Word splitting happens after the variable expands, and the quotes inside the string are then just literal characters — bash never re-parses them. Store each argument as its own array element and run mytool "${opts[@]}".

solid answer

~40 s

Quotes are removed by the shell when it parses the *source line*, not after a variable expands. By the time `$OPTS` is substituted, its value is plain text; bash then applies word splitting on `IFS` and pathname expansion, but it does not run the quoting rules a second time. So `--output 'my report.csv'` splits into `--output`, `'my`, `report.csv'` — four words in total once the surrounding text is counted, with the quote characters preserved as literal data. The fix is to hold the arguments in an indexed array, one argument per element, and expand it quoted: `opts=(--output "my report.csv")` then `mytool "${opts[@]}"`. Conditional flags become `opts+=(--verbose)`, and an empty array simply contributes nothing. `eval` would also "work" and is the wrong answer: it re-parses attacker-influenced text.

code

bash · 11 lines
bash
outfile="my report.csv"
verbose=yes

# wrong: one string, re-split on IFS, quotes are literal
OPTS="--output '$outfile'"
printf '<%s>\n' $OPTS

# right: one array element per argument
opts=(--output "$outfile")
[[ $verbose == yes ]] && opts+=(--verbose)
printf '<%s>\n' "${opts[@]}"

go deeper

for a junior

Recognise the shape of the bug: an unquoted variable used where several arguments are expected. Know the safe pattern by heart — put each argument in an array element and run the command with "${arr[@]}".

for a middle

Explain the ordering that causes it: expansion happens, then word splitting on IFS, and quote removal never runs over expanded text, so embedded quotes stay as literal characters.

for a senior

Show the production judgment: this passes every test with well-behaved values and breaks on the first path with a space, so make the array pattern the default for any command line assembled conditionally, and log it with printf %q.

for a principal

Frame it as a class of defect rather than one bug. Ban eval-based command assembly outright, enforce SC2086 in CI, and treat any string-built command line reaching an external process as an injection risk to be designed out.

## What the shell actually does with $OPTS Bash parses a command line in a fixed order. Quote characters are recognised during that parse, and quote removal is the *last* step — it happens after expansions, and it only removes quotes that were present in the original source text. When a variable's *value* contains quote characters, they were never parsed as syntax; they are ordinary bytes. So for `mytool $OPTS`: 1. `$OPTS` expands to the literal text `--output 'my report.csv'`. 2. Because the expansion is unquoted, the result goes through word splitting on `IFS` (space, tab, newline by default), giving the words `--output`, `'my`, `report.csv'`. 3. Each word is a candidate for pathname expansion. 4. Quote removal does **not** run over the expanded text. The tool therefore sees an option value of `'my` and a stray argument `report.csv'`, complete with the apostrophes. Nothing you can do with more quoting inside the string will fix this, because the problem is that the quotes are data, not syntax. ## The correct structure: one array element per argument ```bash opts=(--output "my report.csv") mytool "${opts[@]}" ``` Here the quotes appear in the *source*, so they are real syntax: `my report.csv` becomes a single element. Expanding `"${opts[@]}"` produces exactly one word per element with contents untouched, so `mytool` receives two arguments. There is no re-splitting stage to defeat. Building the list conditionally is what makes arrays worth the trouble: ```bash opts=(--output "$outfile") [[ $verbose == yes ]] && opts+=(--verbose) [[ -n $config ]] && opts+=(--config "$config") mytool "${opts[@]}" ``` Each `+=` appends whole arguments. If every condition is false, `opts` may end up empty, and a quoted `[@]` expansion of an empty array contributes zero words — the command simply runs without extra flags, with no empty-string argument sneaking in. ## Why the string approach seems to work It works for as long as no argument contains a space, a tab, a newline, or a globbing character. `OPTS="--verbose --retry 3"` splits perfectly. That is precisely what makes the bug dangerous: it passes every test with tidy values and fails the first time a user supplies a path with a space, or a message with a `*` in it that matches a file in the current directory. Failures show up as a wrong value silently used rather than as an error. ## Why not eval ```bash eval mytool $OPTS # do not do this ``` `eval` re-parses the expanded text as a new command line, so the quotes become syntax again and the example "works". It also re-parses everything else: a semicolon, a backtick, a `$(...)` or a `>` inside that value becomes live shell syntax. If any part of the string comes from a filename, an environment variable, a CI variable or user input, that is command injection. Arrays give you the parsing you wanted without the parsing you did not. ## Passing an argument list onward The positional parameters are the built-in version of the same idea, which is why a wrapper forwards its arguments as `"$@"` and never as `$*`. Inside a function you can capture them into an array with `args=("$@")`, add to it, and pass `"${args[@]}"` to the real command. A related habit: build the command *itself* into the array when it varies. ```bash cmd=(ssh -o BatchMode=yes "$host" -- "$remote_cmd") printf 'running: %q ' "${cmd[@]}"; printf '\n' "${cmd[@]}" ``` `printf %q` prints each element with shell-safe quoting, so the log line shows exactly where the argument boundaries are — invaluable when debugging why a remote command was mangled. ## What to say in review Any `$SOMETHING` sitting unquoted in a command position is a bug or a comment away from one; ShellCheck flags it as SC2086. When the author's reply is "but it needs to split into several arguments", the answer is not to remove the warning, it is to make the list a real list.

  • Someone argues that `eval mytool $OPTS` fixes the problem. What is your objection?
    It re-parses the whole string as shell source, so quotes work again — and so do semicolons, backticks, `$(...)`, redirections and globs. Any part of that value coming from user input, a filename or an environment variable becomes executable syntax. An array gives the argument boundaries you wanted with no second parse, so eval buys nothing and costs an injection surface.
  • How would you log the exact command line before running it, so argument boundaries are visible?
    Print the array with a quoting-aware format: `printf 'running: %q ' "${cmd[@]}"; printf '\n'`. The %q conversion escapes each element so the log shows exactly where one argument ends and the next begins, which plain echo cannot — it merges everything into one space-separated string.
  • What if the array of options ends up empty?
    A quoted "${opts[@]}" expansion of an empty array produces zero words, so the command runs with no extra arguments — no empty-string argument is inserted. In bash before 4.4 the same expansion under `set -u` errored as unbound, which is why older scripts carry the `${opts[@]+"${opts[@]}"}` workaround.

A quoted string of options is one envelope with several addresses written on it; an array is several envelopes, each addressed once and delivered intact.

saying these in an interview costs you the question

  • Thinking bash re-processes quotes after a variable expands
  • Adding more quotes inside the string to fix it
  • Reaching for eval to make the quoting work
  • Assuming it is fine because it works in testing
  • Believing arrays are only for lists of files

context