Indirect variable assignment in bash

Seems that the recommended way of doing indirect variable setting in bash is to use eval:

var=x; val=foo
eval $var=$val
echo $x  # --> foo

The problem is the usual one with eval:

var=x; val=1$'\n'pwd
eval $var=$val  # bad output here

(and since it is recommended in many places, I wonder just how many scripts are vulnerable because of this...)

In any case, the obvious solution of using (escaped) quotes doesn't really work:

var=x; val=1\"$'\n'pwd\"
eval $var=\"$val\"  # fail with the above

The thing is that bash has indirect variable reference baked in (with ${!foo}), but I don't see any such way to do indirect assignment -- is there any sane way to do this?

For the record, I did find a solution, but this is not something that I'd consider "sane"...:

eval "$var='"${val//\'/\'\"\'\"\'}"'"

Solution 1:

A slightly better way, avoiding the possible security implications of using eval, is

declare "$var=$val"

Note that declare is a synonym for typeset in bash. The typeset command is more widely supported (ksh and zsh also use it):

typeset "$var=$val"

In modern versions of bash, one should use a nameref.

declare -n var=x
x=$val

It's safer than eval, but still not perfect.

Solution 2:

Bash has an extension to printf that saves its result into a variable:

printf -v "${VARNAME}" '%s' "${VALUE}"

This prevents all possible escaping issues.

If you use an invalid identifier for $VARNAME, the command will fail and return status code 2:

$ printf -v ';;;' '%s' foobar; echo $?
bash: printf: `;;;': not a valid identifier
2

Solution 3:

eval "$var=\$val"

The argument to eval should always be a single string enclosed in either single or double quotes. All code that deviates from this pattern has some unintended behavior in edge cases, such as file names with special characters.

When the argument to eval is expanded by the shell, the $var is replaced with the variable name, and the \$ is replaced with a simple dollar. The string that is evaluated therefore becomes:

varname=$value

This is exactly what you want.

Generally, all expressions of the form $varname should be enclosed in double quotes, to prevent accidental expansion of filename patterns like *.c.

There are only two places where the quotes may be omitted since they are defined to not expand pathnames and split fields: variable assignments and case. POSIX 2018 says:

Each variable assignment shall be expanded for tilde expansion, parameter expansion, command substitution, arithmetic expansion, and quote removal prior to assigning the value.

This list of expansions is missing the parameter expansion and the field splitting. Sure, that's hard to see from reading this sentence alone, but that's the official definition.

Since this is a variable assignment, the quotes are not needed here. They don't hurt, though, so you could also write the original code as:

eval "$var=\"the value is \$val\""

Note that the second dollar is escaped using a backslash, to prevent it from being expanded in the first run. What happens is:

eval "$var=\"the value is \$val\""

The argument to the command eval is sent through parameter expansion and unescaping, resulting in:

varname="the value is $val"

This string is then evaluated as a variable assignment, which assigns the following value to the variable varname:

the value is value

Solution 4:

The main point is that the recommended way to do this is:

eval "$var=\$val"

with the RHS done indirectly too. Since eval is used in the same environment, it will have $val bound, so deferring it works, and since now it's just a variable. Since the $val variable has a known name, there are no issues with quoting, and it could have even been written as:

eval $var=\$val

But since it's better to always add quotes, the former is better, or even this:

eval "$var=\"\$val\""

A better alternative in bash that was mentioned for the whole thing that avoids eval completely (and is not as subtle as declare etc):

printf -v "$var" "%s" "$val"

Though this is not a direct answer what I originally asked...