1

I know there are rules for quoting parameters, but no matter what I try, I can't seem to figure out how to make this work.

I have a function that takes a single filename as a parameter, then uses that filename to build a command line and runs the command line. I am able to make it work when there are no spaces in any of the files, but when a filename with a space is introduced, it breaks. Everything I try to make it work with spaces either doesn't work or causes it to not work on the files without spaces too.

Here is an example script that works with no spaces:

#!/bin/bash
list_files() {
    file="${1}"
    cmd="cat ${file}"
    echo "Running command: '${cmd}'"
    ${cmd}
}

TARGET_DIR="${1}"

while IFS= read -d $'\0' -r file; do
    list_files "${file}"
done < <(find ${TARET_DIR} -type f -print 0)

I just created a directory structure as follows:

./files/
|-- one                # ==> Contains the text "files/one"
|-- two                # ==> Contains the text "files/two"
|-- three and four     # ==> Contains the text "files/three and four"
|-- five               # ==> Contains the text "files/five"

When I run the above script on the directory above, I get the following output:

bash-3.2$ ./test.bash ./files
Running Command: 'cat ./files/one'
files/one
Running Command: 'cat ./files/two'
files/two
Running Command: 'cat ./files/three and four'
cat: ./files/three: No such file or directory
cat: and: No such file or directory
cat: four: No such file or directory
Running Command: 'cat ./files/five'
files/five

What am I doing wrong and how can I make a script like this work no matter what the file names are?

NOTE: For this example it would be simple enough to not store the command in a variable first and just print it then call it by typing the command itself twice, but for my real script, the command is much more complex and I would like to only have to maintain it in one place if it should need to change in the future.

Kris
  • 13

1 Answers1

2

Instead of:

cmd="cat ${file}"
printf '%s\n' "Running command: '${cmd}'"
${cmd}

Either pass a fixed command to eval for it to be interpreted as shell code.

cmd='cat -- "$file"'
printf '%s\n' "Running command: '${cmd}'"
eval "$cmd"

That will output Running command: cat -- "$file" which is probably not what you want.

Or (bash specific) use printf %q to quote $file properly so its expansion can be passed to eval:

printf -v cmd 'cat -- %q' "$file"
printf '%s\n' "Running command: $cmd"
eval "$cmd"

Or, since it's a simple command here, you can store the arguments of that simple command in an array:

cmd=(cat -- "$file")
printf 'Running command: '
printf ' %q' "${cmd[@]}"
printf '\n'
"${cmd[@]}"

If you know of one particular character that $file is guaranteed not to contain like : (note that : is a perfectly valid character in a file name on Unix though), you could do:

cmd="cat:--:$file"
IFS=:  # split on :
set -f # disable glob
# use the split+glob operator by leaving the variable unquoted:
printf 'Running command: '
printf ' %q' $cmd
printf '\n'
$cmd

Now, in this specific case, you can also do:

(PS4='Running command: '
set -x; cat -- "$file")

(beware the message goes to stderr).

Or even:

PS4='Running command: ' find "$TARGET_DIR" -type f -exec sh -xc '
  cat "$1"' sh {} \;

Or to avoid running that many sh and cat commands, use ksh93 where cat is a builtin mapped to /opt/ast/bin/cat and use the {} + syntax to run as few shells as possible:

PS4='Running command: ' find "$TARGET_DIR" -type f -exec ksh93 -c '
  PATH=/opt/ast/bin; set -x; for file do cat "$file"; done' ksh93 {} +

To display the content of files with headers and running as few commands as possible, on GNU systems, you can also use head:

$ find "$TARGET_DIR" -type f -exec head -vc-0 {} +
==> ./file 1 <==
1
2

==> ./file 2 <==
test

With zsh:

zmodload zsh/mapfile
for file (**/*(D.)) printf '==> %q <==\n%s\n' $file $mapfile[$file]