In a remote CentOS with Bash 5.0.17(1) where I am the only user via SSH I have executed read web_application_root with:


or with:


or with:


or with:


Aiming to get an output with an expanded (environment) variable such as MY_USER_HOME_DIRECTORY/www.

While ls -la $HOME/www works fine, ls -la $web_application_root fails with all examples; an error example is:

ls: cannot access '$HOME/www': No such file or directory

I understand that read treats all the above $HOME variants as a string (due to the single quote marks in the error) and hence doesn't expand it.

How to expand variables inside read?

  • What shell are you using? Note that you are reading code from the user. As such, it would need to undergo an extra step of evaluation before the actual value (the pathname) is had. Such an evaluation could be done with eval, but if you're using the zsh shell, then there are better (safer) ways of doing that.
    – Kusalananda
    Feb 22 '21 at 10:13
  • eval usage on user input is probably no good idea, because it could lead to the user executing commands you have not intended.
    – Jaleks
    Feb 22 '21 at 10:16
  • @Kusalananda thanks, I have updated to describe the shell. Feb 22 '21 at 10:17
  • 1
    And if the user enters $(reboot) or ${HOME+$(reboot)}, should ls -la $web_application_root reboot? Feb 22 '21 at 10:19
  • @StéphaneChazelas I don't have enough knowledge to fully understand your comment but I can tell you that the session I work with is pretty straightforward; it's an SSH session to pretty much run a script and exit. Feb 22 '21 at 10:27

Variables are not expanded when passed to read. If you want to expand the $VARs or ${VAR}s where VAR denotes the name of an existing environment variable (limited to those whose name starts with an ASCII letter or underscore and followed by ASCII alnums or underscores) and leave all the other word expansions ($non_exported_shell_variable, $1, $#, ${HOME+x}, $((1 + 1)), $(cmd)...) untouched, you could use envsubst (from GNU gettext):

IFS= read -r web_application_root || exit
web_application_root=$(printf %s "$web_application_root" | envsubst)
ls -la -- "$web_application_root"

You could make it a shell function that takes a variable name as argument and does both the reading and environment variable expansion with:

read_one_line_and_expand_envvars() {
  IFS= read -r "$1"
  command eval "$1"'=$(printf %s "${'"$1"'}" | envsubst)' && return "$ret"

To be used for instance as:

printf >&2 'Please enter the root dir (${ENVVAR} expanded): '
read_one_line_and_expand_envvars web_application_root || exit
printf >&2 'The expanded version of your input is "%s"\n' "$web_application_root"

To limit that substitution to a limited set of environment variables, you'd pass the list as a $VAR1$VAR2... literal argument to envsubst:

  printf %s "$web_application_root" |
    envsubst '$HOME$MYENVVAR'

(here tells envsubst to only substitute $HOME, ${HOME}, $MYENVVAR and ${MYENVVAR} in its input, leaving all other $VARs untouched).

If you want to allow all forms of word expansions¹ (but note that then that makes it a command injection vulnerability), you could do:

web_application_root=$(eval "cat << __EOF__

Or again, as a function that takes the variable name as argument:

read_one_line_and_perform_shell_word_expansions() {
  IFS= read -r "$1"
  command eval '
    case "${'"$1"'}" in
      (EOF) ;;
        '"$1"'=$(command eval "cat << EOF
    esac' && return "$ret"
printf >&2 'Please enter the root dir ($var/$((...))/$(cmd) allowed): '
read_one_line_and_perform_shell_word_expansions web_application_root || exit
printf >&2 'The expanded version of your input is "%s"\n' "$web_application_root"

The same function with detailed inline documentation:

read_one_line_and_perform_shell_word_expansions() {
  # first argument of our function is the variable name or REPLY
  # if not specified.

  # read one line from stdin with read's unwanted default post-processing
  # (which is otherwise dependant on the current value of $IFS) disabled.
  IFS= read -r "$varname"

  # record read's exit status. If it's non zero, a full line could not be
  # read. We may still want to perform the expansions in whatever much
  # was read, and pass that exit status to the caller so they decide what
  # to do with it.

  # We prefix the "eval" special builtin with "command" to make it lose
  # its "special" status (namely here, exit the script about failure,
  # something bash only does when in standard mode).
  command eval '
    # the approach we take to expand word expansions would be defeated
    # if the user entered "EOF" which is the delimiter we chose for our
    # here-document, so we need to handle it as a special case:
    case "${'"$varname"'}" in
      (EOF) ;;
        # the idea here is to have the shell evaluate the
        # myvar=$(command eval "cat << EOF
        # ${myvar}
        # EOF")
        # shell code when $1 is myvar, so that the
        # cat << EOF
        # contents of $myvar with $(cmd), $ENV and all
        # EOF
        # shell code be evaluated, and those $(cmd), $ENV expansions
        # performed in the process
        '"$varname"'=$(command eval "cat << EOF
    esac' &&
      # unless eval itself failed, return read's exit status to the caller:
      return "$ret"

But your problems sounds more like an XY problem. Getting input via read is cumbersome and impractical. It's much better to get input via arguments, and then you can leave it to the caller's shell to do the expansions as they intend it.

Instead of

#! /bin/sh -
IFS= read -r var
ls -l -- "$var"

(and remember that calling read without IFS= and without -r is almost never what you want).

Make it:

#! /bin/sh -
ls -l -- "$var"

And then the caller can do your-script ~/dir or your-script "$HOME/dir" or your-script '$$$weird***/dir' or even your-script $'/dir\nwith\nnewline\ncharacters' as they see fit.

¹ word expansion in this context refers to parameter expansion, arithmetic expansion and command substitution. That doesn't include filename generation (aka globbing or pathname expansion), tilde expansion nor brace expansion (itself not a standard sh feature). Using a here-document here makes sure ' and "s are left untouched, but note that there still is backslash processing.

  • Thanks for the answer ! About the last two code examples --- in that scenario I must use read. Feb 22 '21 at 10:47
  • @variableexpander It's such an unusual thing to want to do, so it would be interesting to see why you think you need to use read to read something that needs further shell evaluation.
    – Kusalananda
    Feb 22 '21 at 10:58
  • @Kusalananda I just want to give any one who uses the script I write (on its own environment) the option to define a certain web application root; for example, not all users would have it under ${HOME} as in my case. Feb 22 '21 at 11:00
  • 1
    @variableexpander Stéphane's last piece of code seems rather on point then. Take the pathname on the command line. This is commonly done by most other utilities that require the user to supply some form of pathname.
    – Kusalananda
    Feb 22 '21 at 11:10
  • 1
    @variableexpander, I think the point they're trying to make is that most utilities don't prompt the user for values. Instead, they take the values from command line arguments, or environment variables, and just complain if a required value is not set. That is, you'd call them as whatever -d /my/webapp/root, or put something like export WEBAPPROOT=/somedir in your shell startup files beforehand. Not using read does not mean predetermining the value, and relying on read might not be a good idea wrt. user expectations either.
    – ilkkachu
    Feb 22 '21 at 11:36

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.