shell `trap` and proper quoting
What’s wrong with
#!/bin/bash
TMPDIR=$(mktemp -d)
trap 'rm -r $TMPDIR' EXIT
...
Let’s ask shellcheck:
No issues detected!
Actually there are multiple issues:
TMPDIR
Please do not assign to TMPDIR
as that variable in an input parameter to mktemp
itself:
When you read man:mktemp your will find this for option -p
:
if DIR is not specified, use $TMPDIR if set, else /tmp.
The variable is used for example by pam_tmpdir to setup per user temporary directories to improve security on multi-user systems.
By using TMPDIR
inside your script to store the path of your specific temporary directory, you risk chanhing the behavior of other called child-processes also using mktemp
.
Other equivalent implementations thereof) also use TEMP
and TMP
, so better do not use these as well.
So lets use tmp
:
tmp=$(mktemp -d)
trap 'rm -r $tmp' EXIT
IFS
By default man:mktemp will only create safe file names, e.g. none containing blanks and characters of $IFS
.
Remember that $IFS
is used by the shell to split every argument — which is not quoted — into multiple arguments.
By default it is set to space, tab and newline.
But you can redefine or extend it, after which hell breaks loose:
$ bash -c 'IFS="$IFS/."; . my-trap-script"
rm: cannot remove '': No such file or directory
rm: cannot remove 'tmp': No such file or directory
rm: cannot remove 'user': No such file or directory
rm: cannot remove '1000': No such file or directory
rm: cannot remove 'tmp': No such file or directory
rm: cannot remove 'XyJlR6AHpn': No such file or directory
Luckily $IFS
is re-set for each shell to its default value, but do keep that in mind when you fiddle with $IFS
.
My advise is to do that only in functions and to use local IFS
there to have the change confined to only inside the function.
quoting
To prevent $IFS
-splitting you have to quote arguments.
So let’s try with this:
tmp=$(mktemp -d)
trap 'rm -r "$tmp"' EXIT
You may wonder, why I didn’t quote tmp=$(…)
as the spitting occurs after command substitution?
For that you have to read man:bash very carefully.
In section parameters you have this:
All values undergo tilde expansion, parameter and variable expansion, command substitution, arithmetic expansion, and quote removal.
Compare that to section expansion:
There are seven kinds of expansion performed: brace expansion, tilde expansion, parameter and variable expansion, command substitution, arithmetic expansion, word splitting, and pathname expansion.
The important difference here is, that parameter assignment expects a single argument and this word splitting does not occurs there. So no quoting is needed for parameter assignments, but you can do it for consistency — it does not hurt.
late vs. early evaluation
While shellcheck
is happy, there is a lingering problem:
The trap
is executed only later on when the shell exits.
$tmp
might get changed (by accident) or be used for something else.
In that case the rm
will delete whatever file $tmp
points too.
That is because the outer quotes are single quotes while the inner quotes are double quotes:
single quotes prevent evaluation of the command when the trap
statement is executed.
Later on when the trap is executed, the command is evaluated a second time.
That is when the double quotes prevent $tmp
from being split on $IFS
.
So lets look at the following variant:
tmp=$(mktemp -d)
trap "rm -r $tmp" EXIT
tmp+="/subdir"
Double quotes are now use when the trap is setup:
$tmp
gets inserted here as it is currently defined.
If $tmp
is changed later on, we still delete file temporary file we just created.
But shellcheck
is unhappy now:
trap "rm -r $tmp" EXIT
^-- SC2064 (warning): Use single quotes, otherwise this expands now rather than when signalled.
Personally I think SC2064 is a bad advise here as we want to evaluate “$tmp” now and not later.
I want to delete the file $tmp
is pointing to right now, not where it might point to in the future.
I’m not alone with that opinion and issue 1945 calls SC2064 questionable.
But there is a bigger problem again: But what will happen, when the trap fires?
late quoting
Remember that $tmp
might contain $IFS
characters!
For example I can set TMPDIR=/tmp/I like blanks
.
The trap command will be rm -f /tmp/I like blanks
.
It will fail as there is no file /tmp/I
, ./like
and ./blanks
— hopefully.
So how do we fix that? I give you two variants:
- Nestes double quoting using backslash-escaping:
tmp=$(mktemp -d) trap "rm -r \"$tmp\"" EXIT
- Single quotes inside double quotes:
tmp=$(mktemp -d) trap "rm -r '$tmp'" EXIT
Which one is correct?
The answer is very disappointing: None!
Variant 1 will fail for TMPDIR=/tmp/\"
and variante will fail for TMPDIR=/tmp/\'
.
$tmp
will then be a path containing a double quote in variant 1 and a single quote in variant 2.
Because of the early evaluation $tmp
is inserted as-is during the first evaluation when trap
is setup.
On the 2nd evaluation when the trap is executed, you will have an odd number of quotes!
correct quoting
So we need a mechanism to quote $tmp
correctly, so it survives two rounds of evaluation.
Luckily bash
has such a feature:
tmp=$(mktemp -d)
# shellcheck disable=SC2064
trap "rm -r ${tmp@Q}" EXIT
@Q
is a operator, which is documented like this in man:bash:
The expansion is a string that is the value of parameter quoted in a format that can be reused as input.
That is exactly what we want:
- the outer quotes prevent
$tmp
from being split when thetrap
is setup. - the
@Q
adds the necessary escaping to also prevent$tmp
from being split when the trap executes.
closing words
Be warned that the operator @Q
is a bash
ism:
This is not supported by ash
, dash
, or busybox sh
:
There you have to quote "
and '
manually.
I leave that to you.
I will simply accept bash
and use @Q
as that is much more readable and — most importantly — correct.