“Bash”
I would hazard a guess that most people who are writing a quick Bash script end up writing their scripts something like this:
service_name=$1
sudo rm -rf /etc/$service_name
In this example you have some clean up that needs to happen on the configuration files for some service. Assuming that the user correctly gives the service name then this is fine. However this is incredibly dangerous and if done incorrectly (or if someone is feeling malicious) can do some really bad things. Let’s look at what happens if someone just makes a small typo.
If someone ran our sample script (we’ll call it clean.sh
) like so
./clean.sh "cloud /"
what this would end up doing is running the
rm
command as sudo rm -rf /etc/cloud /
. This would correctly
delete the /etc/cloud
directory but then just delete everything
from the root of the filesystem. That’s not what we wanted!
By default, when you pass a bare variable using something like
$var_name
in Bash, it’ll insert that string exactly as is. If
there were any spaces those would be included as well. That’s why
in our example the rm
command is trying to delete /
. It leaves
the space in the arguments and rm
interprets those as two different
arguments.
The safer way to write this (ignoring actual sanity checks on the input for a moment) would be like so:
service_name="$1"
sudo rm -rf "/etc/$service_name"
In this case, Bash would have looked for a directory that had a space at the end of it which probably wouldn’t match to anything and the command would fail saying that no such directory exists. This is what we want.
Let’s look at another way this could go wrong.
./install.sh --prefix $prefix
If for some reason prefix
isn’t set (maybe its given by the user or
as output from some other command that failed) then this command is
functionally the same as just calling ./install.sh --prefix
with
nothing at the end. Hopefully that doesn’t do anything bad for you.
It can get even worse when you have additional input.
./install.sh --prefix $prefix all
Now it’s going to install stuff to the all
directory local to
wherever it’s being run from. Not what you want.
If you had instead used quotes it would see that prefix
isn’t set.
./install.sh --prefix "${prefix}" all
# Equivalent to:
./install.sh --prefix '' all
Bonus points if your command allows for the
=
symbol../install.sh --prefix="${prefix}" all
Recommendation⌗
Given this example, my recommendation is to always quote your Bash variables by default (make sure to use double quotes cause they do interpolation whereas single quotes do not). This is going to prevent foot guns and silly mistakes from happening.
Exceptions⌗
The only times that I can think of where not using quotes is helpful is when you want the variable to potentially be expanded.
Expanding arguments⌗
function main() {
local name="$1"; shift
echo "Hello, ${name}!"
}
main $@
With this, all arguments passed to the script (the @
variable is
magically set by Bash to include all arguments) as is directly to
main
. I personally like this pattern so that you can have a clear
entrypoint for your script.
You could also use this as a way to build up a set of arguments dynamically to be passed somewhere else.
args="${TARGET:-all}"
if ! [ -z "${PARALLEL}" ]; then
args="-j4 ${args}"
fi
make ${args}
In this case it’ll use 4 processes if the PARALLEL
variable is set
and could expand to make -j4 all
.
Honestly though, I don’t do this pattern a ton.
Loops⌗
The other case where this can be helpful is when you need to loop over the contents of a variable.
config_files="$(ls /etc)"
for f in ${config_files}; do
echo "'${f}'"
done
This will execute the loop once for each file/directory in /etc
. If
you had quoted ${config_files}
in the loop it would only execute one
time.