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
$tmpfrom being split when thetrapis setup. - the
@Qadds the necessary escaping to also prevent$tmpfrom being split when the trap executes.
closing words
Be warned that the operator @Q is a bashism:
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.