187
$ ls -l /tmp/test/my\ dir/
total 0

I was wondering why the following ways to run the above command fail or succeed?

$ abc='ls -l "/tmp/test/my dir"'

$ $abc
ls: cannot access '"/tmp/test/my': No such file or directory
ls: cannot access 'dir"': No such file or directory

$ "$abc"
bash: ls -l "/tmp/test/my dir": No such file or directory

$ bash -c $abc
'my dir'

$ bash -c "$abc"
total 0

$ eval $abc
total 0

$ eval "$abc"
total 0
Tim
  • 101,790

6 Answers6

261

This has been discussed in a number of questions on unix.SE, I'll try to collect all issues I can come up with here. Below is

  • a description of why and how the various attempts fail,
  • a way to do it properly with a function (for a fixed command), or
  • with shell arrays (Bash/ksh/zsh) or the $@ pseudo-array (POSIX sh), both of which also allow building the command line pieces, if you e.g. only need to vary some optoins
  • and notes about using eval to do this.

Some references at the end.

For the purposes here, it doesn't matter much if it's only the command arguments or also the command name that is to be stored in a variable. They're processed similarly up to the point where the command is launched, at which point the shell just takes the first word as the name of the command to run.


Why it fails

The reason you face those problems is the fact that word splitting is quite simple and doesn't lend itself to complex cases, and the fact that quotes expanded from variables don't act as quotes, but are just ordinary characters.

(Note that the part about quotes is similar to every other programming language: e.g. char *s = "foo()"; printf("%s\n", s) does not call the function foo() in C, but just prints the string foo(). That's different in macro processors, like m4, the C preprocessor, or Make (to some extent). The shell is a programming language, not a macro processor.)

On Unix-like systems, it's the shell that processes quotes and variable expansions on the command line, turning it from a single string into the list of strings that the underlying system call passes to the launched command. The program itself doesn't see the quotes the shell processed. E.g. if given the command ls -l "foo bar", the shell turns that into the three strings ls, -l and foo bar (removing the quotes), and passes those to ls. (Even the command name is passed, though not all programs use it.)

The cases presented in the question:

The assignment here assigns the single string ls -l "/tmp/test/my dir" to abc:

$ abc='ls -l "/tmp/test/my dir"'

Below, $abc is split on whitespace, and ls gets the three arguments -l, "/tmp/test/my and dir". The quotes here are just data, so there's one at the front of the second argument and another at the back of the third. The option works, but the path gets incorrectly processed as ls sees the quotes as part of the filenames:

$ $abc
ls: cannot access '"/tmp/test/my': No such file or directory
ls: cannot access 'dir"': No such file or directory

Here, the expansion is quoted, so it's kept as a single word. The shell tries to find a program literally called ls -l "/tmp/test/my dir", spaces and quotes included.

$ "$abc"
bash: ls -l "/tmp/test/my dir": No such file or directory

And here, $abc is split, and only the first resulting word is taken as the argument to -c, so Bash just runs ls in the current directory. The other words are arguments to bash, and are used to fill $0, $1, etc.

$ bash -c $abc
'my dir'

With bash -c "$abc", and eval "$abc", there's an additional shell processing step, which does make the quotes work, but also causes all shell expansions to be processed again, so there's a risk of accidentally running e.g. a command substitution from user-provided data, unless you're very careful about quoting.


Better ways to do it

The two better ways to store a command are a) use a function instead, b) use an array variable (or the positional parameters).

Using functions:

Simply declare a function with the command inside, and run the function as if it were a command. Expansions in commands within the function are only processed when the command runs, not when it's defined, and you don't need to quote the individual commands. Though this really only helps if you have a fixed command you need to store (or more than one fixed command).

# define it
myls() {
    ls -l "/tmp/test/my dir"
}

run it

myls

It's also possible to define multiple functions and use a variable to store the name of the function you want to run in the end.

Using an array:

Arrays allow creating multi-word variables where the individual words contain white space. Here, the individual words are stored as distinct array elements, and the "${array[@]}" expansion expands each element as separate shell words:

# define the array
mycmd=(ls -l "/tmp/test/my dir")

expand the array, run the command

"${mycmd[@]}"

The command is written inside the parentheses exactly as it would be written when running the command. The processing the shell does is the same in both cases, just in one it only saves the resulting list of strings, instead of using it to run a program.

The syntax for expanding the array later is slightly horrible, though, and the quotes around it are important.

Arrays also allow you to build the command line piece-by-piece. For example:

mycmd=(ls)               # initial command
if [ "$want_detail" = 1 ]; then
    mycmd+=(-l)          # optional flag, append to array
fi
mycmd+=("$targetdir")    # the filename

"${mycmd[@]}"

or keep parts of the command line constant and use the array fill just a part of it, like options or filenames:

options=(-x -v)
files=(file1 "file name with whitespace")
target=/somedir

somecommand "${options[@]}" "${files[@]}" "$target"

(somecommand being a generic placeholder name here, not any real command.)

The downside of arrays is that they're not a standard feature, so plain POSIX shells (like dash, the default /bin/sh in Debian/Ubuntu) don't support them (but see below). Bash, ksh and zsh do, however, so it's likely your system has some shell that supports arrays.

Using "$@"

In shells with no support for named arrays, one can still use the positional parameters (the pseudo-array "$@") to hold the arguments of a command.

The following should be portable script bits that do the equivalent of the code bits in the previous section. The array is replaced with "$@", the list of positional parameters. Setting "$@" is done with set, and the double quotes around "$@" are important (these cause the elements of the list to be individually quoted).

First, simply storing a command with arguments in "$@" and running it:

set -- ls -l "/tmp/test/my dir"
"$@"

Conditionally setting parts of the command line options for a command:

set -- ls
if [ "$want_detail" = 1 ]; then
    set -- "$@" -l
fi
set -- "$@" "$targetdir"

"$@"

Only using "$@" for options and operands:

set -- -x -v
set -- "$@" file1 "file name with whitespace"
set -- "$@" /somedir

somecommand "$@"

Of course, "$@" is usually filled with the arguments to the script itself, so you'll have to save them somewhere before re-purposing "$@".

To conditionally pass a single argument, you can also use the alternate value expansion ${var:+word} with some careful quoting. Here, we include -f and the filename only if the filename is nonempty:

file="foo bar"
somecommand ${file:+-f "$file"}

Using eval (be careful here!)

eval takes a string and runs it as a command, just like if it was entered on the shell command line. This includes all quote and expansion processing, which is both useful and dangerous.

In the simple case, it allows doing just what we want:

cmd='ls -l "/tmp/test/my dir"'
eval "$cmd"

With eval, the quotes are processed, so ls eventually sees just the two arguments -l and /tmp/test/my dir, like we want. eval is also smart enough to concatenate any arguments it gets, so eval $cmd could also work in some cases, but e.g. all runs of whitespace would be changed to single spaces. It's still better to quote the variable there as that will ensure it gets unmodified to eval.

However, it's dangerous to include user input in the command string to eval. For example, this seems to work:

read -r filename
cmd="ls -ld '$filename'"
eval "$cmd";

But if the user gives input that contains single quotes, they can break out of the quoting and run arbitrary commands! E.g. with the input '$(whatever)'.txt, your script happily runs the command substitution. That it could have been rm -rf (or worse) instead.

The issue there is that the value of $filename was embedded in the command line that eval runs. It was expanded before eval, which saw e.g. the command ls -l ''$(whatever)'.txt'. You would need to pre-process the input to be safe.

If we do it the other way, keeping the filename in the variable, and letting the eval command expand it, it's safer again:

read -r filename
cmd='ls -ld "$filename"'
eval "$cmd";

Note the outer quotes are now single quotes, so expansions within do not happen. Hence, eval sees the command ls -l "$filename" and expands the filename safely itself.

But that's not much different from just storing the command in a function or an array. With functions or arrays, there is no such problem since the words are kept separate for the whole time, and there's no quote or other processing for the contents of filename.

read -r filename
cmd=(ls -ld -- "$filename")
"${cmd[@]}"

Pretty much the only reason to use eval is one where the varying part involves shell syntax elements that can't be brought in via variables (pipelines, redirections, etc.). However, you'll then need to quote/escape everything else on the command line that needs protection from the additional parsing step (see link below). In any case, it's best to avoid embedding input from the user in the eval command!


References

ilkkachu
  • 138,973
  • 2
    you can get around the eval quoting thing by doing cmd="ls -l $(printf "%q" "$filename")". not pretty, but if the user is dead set on using an eval, it helps. It's also very useful for sending the command though similar things, such as ssh foohost "ls -l $(printf "%q" "$filename")", or in the sprit of this question: ssh foohost "$cmd". – phemmer May 20 '18 at 19:39
  • 1
    Not directly related, but have you hard-coded the directory? In that case, you might want to look at alias. Something like:

    $ alias abc='ls -l "/tmp/test/my dir"'

    – Hopping Bunny May 23 '18 at 02:25
  • 1
    "... useful but dangerous..." Forsooth. :) – paul garrett Jul 22 '22 at 21:40
  • No, it is wrong to say that "The reason you face those problems is word splitting". That is simply: Not true. – QuartzCristal Sep 07 '22 at 06:23
  • @QuartzCristal, well, yeah, it's not word splitting per se as the simplest cases can work with the help of word splitting. But the fact that word splitting is simple and doesn't work in the complex cases even if people might expect it to help them is part of the matter. Edited. – ilkkachu Sep 07 '22 at 14:59
  • @ilkkachu, I can't find references to the command transmutate you used in some examples. Could you please point me to some documentation? – Fjor Oct 23 '22 at 06:14
  • @Fjor, you wouldn't, since there isn't such a command. That was supposed to be a generic placeholder for the command name, since it's not really relevant which command it is. Perhaps the choice of name used was a bit too cheesy and confusing, so sorry about that. (edited to call it somecommand instead and clarify the matter a bit.) – ilkkachu Oct 23 '22 at 19:15
  • Thanks for the clarification, @ilkkachu, I thought it was some new command unknown to me (or from some shell I don't grok), related to array manipulation. – Fjor Oct 25 '22 at 02:25
  • how to up vote 2 times? )) used function approach – abc Jan 06 '23 at 14:22
  • 1
    Fantastic answer @ilkkachu, I would love to use the arrays in my case, however I am not able to make it work during expansion when the string has to escape, for example - suppose we have the following code: cmd=("find" "." "\( -name \"*.txt.gz\" -o -name \"*.sh\" \)"); echo "${cmd[*]}"; "${cmd[@]}". This fails as it is expanded with double quotes with the error find: ‘\\( -name "*.txt.gz" -o -name "*.sh" \\)’: No such file or directory - do you know how we are able to fix this? eval works without issues, i.e. eval "${cmd[*]}" instead of "${cmd[@]}". – jtimz Feb 07 '23 at 16:22
  • @jtimz, the correct syntax for storing the command name and args in an array, is to take the syntax you'd use to run the command, exactly, and slap the parentheses and assignment around it. In that case, it's probably cmd=(find . \( -name "*.txt.gz" -o -name "*.sh" \) ). The way you showed it there, that "\( -name ... \)" is a single quoted string, one that has hard double-quotes it in, and that doesn't match any of the find expressions, so it's taken as a filename. (I'm not sure why the output has doubled backslashes.) – ilkkachu Feb 07 '23 at 18:09
  • somehow the quotation marks in the array are not repected. i used eval , which works! – Summer-Sky Jul 23 '23 at 11:30
  • @Summer-Sky, it'd help if you could show the actual code you tried. Maybe post it in a new question. – ilkkachu Jul 23 '23 at 17:55
  • i tired it with echo. – Summer-Sky Jul 24 '23 at 11:24
  • @Summer-Sky, well, if you refuse to show your code, no-one can help you find out why it doesn't do what it should and you won't learn how to do it properly. Have a nice day. – ilkkachu Jul 24 '23 at 12:03
  • it was a fidelity issue: here an example tmptest=(mkdir "a; echo c"); echo "${tmptest[@]}" ; "${tmptest[@]}" after that use the echoed command (ctrl+c ctrl+v) and spot the difference ... try again with escaped string and eval .. the point is to have a command that you can print out and that will be identical to what was executed – Summer-Sky Jul 25 '23 at 09:37
  • and yea i am well aware it isn't the OPs request. I usually use SO only as a notepad for future-me, as in this case. I will know in the future to use the eval option ¯\(ツ)/¯, – Summer-Sky Jul 25 '23 at 09:43
  • @Summer-Sky, yes, of course. The point of storing the command as an array is to AVOID a round-trip via a string, and instead create it in the format it actually gets used as in the end: as an array of multiple separate strings. Also echo by itself is not really good at printing things unambiguously, it loses the difference between separate arguments and a single argument with a space in it. – ilkkachu Jul 25 '23 at 11:50
  • 1
    If you have an array and want to print it with proper quoting, you could try echo "${tmptest[@]@Q}", which should at least do something like that. Here, it prints 'mkdir' 'a; echo c', where quoting the mkdir is of course unnecessary, but whatever. (As far as I understand, the output of @Q should be usable as input to Bash, but I can't think of all the details right now, so I'm not exactly sure if there's still some gotcha. And yes, it's Bash only, zsh has better ways for the same.) – ilkkachu Jul 25 '23 at 11:52
20

The safest way to run a (non-trivial) command is eval. Then you can write the command as you would do on the command line and it is executed exactly as if you had just entered it. But you have to quote everything.

Simple case:

abc='ls -l "/tmp/test/my dir"'
eval "$abc"

not so simple case:

# command: awk '! a[$0]++ { print "foo: " $0; }' inputfile
abc='awk '\''! a[$0]++ { print "foo: " $0; }'\'' inputfile'
eval "$abc"
Hauke Laging
  • 90,279
  • 3
    It's worth noting the security issue using eval poses. See the "Using eval (be careful here!)" section in this answer: https://unix.stackexchange.com/a/444949/151000. – nishanthshanmugham Jun 27 '21 at 14:43
1

The second quote sign break the command.

When I run:

abc="ls -l '/home/wattana/Desktop'"
$abc

It gave me an error.

But when I run

abc="ls -l /home/wattana/Desktop"
$abc

There is no error at all

There is no way to fix this at the time(for me) but you can avoid the error by not having space in directory name.

This answer said the eval command can be used to fix this but it doesn't work for me :(

Saad
  • 101
  • 5
    Yeah, that works as long as there's no need for e.g. filenames with embedded spaces (or ones containing glob characters). – ilkkachu May 20 '18 at 13:22
-1

If it needs an array to execute, make it into an array!

IFS=' ' read -r -a command_arr <<< "${command}"
"${command_arr[@]}"

the first line converts the string into an array. The second line executes the command.

This does not appear to work with chained commands, e.g. using && or ;.

ingyhere
  • 161
  • This completely missed the point of why an array is needed. – muru Aug 27 '23 at 00:43
  • It’s intended to add a convenient syntax option, not a comprehensive answer, which is covered above. Not all answers need to fit into a box. – ingyhere Aug 28 '23 at 02:44
  • 1
    What's convenient about it? Done the way in this answer, there's really not much difference between this and just doing set -f; $command; set +f. It won't handle spaces in arguments well, which is one of the reasons why arrays are actually meant to be used. – muru Aug 28 '23 at 02:56
  • Speaking of boxes, this definitely looks like something someone would do to check a box named "Use arrays to execute commands stored in variables" – muru Aug 28 '23 at 03:02
-1

Although @ilkkachu referenced bash's word splitting, I think it would be good to explicitly point out the importance of the IFS shell variable. For example in bash:

OLD_IFS="$IFS"
IFS=$'\x1a'
my_command=$'ls\x1a-l\x1a-a\x1a/tmp/test/my dir'
$my_command
IFS="$OLD_IFS"

would run the command stored in my_command as expected. \x1a is CTRL-Z from ASCII and a good delimiter choice. This works as long as the command the be executed does not contain any CTRL+Z character, which is arguably more likely than with whitespace. I also said bash, since ANSI-C style quoting $'...' is not POSIX as of now.

This technique works either when you have a hard coded command or when you are constructing a command. Just don't forget to reset IFS to its previous value.

-2

Another trick to run any (trivial/non-trivial) command stored in abc variable is:

$ history -s $abc

and press UpArrow or Ctrl-p to bring it in the command line. Unlike any other method this way you can edit it before execution if needed.

This command will append the variable's content as a new entry to the Bash history and you can recall it by UpArrow.

In combinaison with another command to replay the last listed history command you can replay it without pressing a key.

 $ fc -e : -1
fabrice
  • 3
  • 3
bloody
  • 117