4

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:

$HOME/www

or with:

${HOME}/www

or with:

"${HOME}"/www

or with:

"${HOME}/www"

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. – variableexpander Feb 22 '21 at 10:17
  • 1
    And if the user enters $(reboot) or ${HOME+$(reboot)}, should ls -la $web_application_root reboot? – Stéphane Chazelas 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. – variableexpander Feb 22 '21 at 10:27
  • @Jaleks I am the only user in the entire environment (it's a private hosting environment I SSH to). Please consider to write an answer with eval based on that fact (then, I assume there is no worry from other users running commands on the same system). – variableexpander Feb 22 '21 at 10:57
  • The OP comments (SE cookie lost): “After reading Stéphane Chazelas's answer or what I could mentally read (let along get) from it, I say that I insist to working with read because I sorely need its interactivity but I can't use the envsubst utility because my website hosting plan doesn't include it (neither the new one I prepare to move to) so I seek a shell-only solution which would allow people lacking envsubst (or any other non-shell-builtin utility) to achieve the original goal in the question. All parts about envsubst are better to be hidden in some dropdown window, I think.” – Stephen Kitt Mar 18 '21 at 19:02
  • (The above was submitted as an edit but is more appropriate as a comment.) – Stephen Kitt Mar 18 '21 at 19:02
  • @Kusalananda: What is the safer way to do this in zsh? – nishanthshanmugham Nov 29 '22 at 11:38
  • 1
    @nishanthshanmugham ${(e)web_application_root} would expand the variable web_application_root to $HOME/www and then apply a second re-evaluation to the result, expanding any further variable. – Kusalananda Nov 29 '22 at 12:43

1 Answers1

8

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"
  ret="$?"
  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:

web_application_root=$(
  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__
$web_application_root
__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"
  ret=$?
  command eval '
    case "${'"$1"'}" in
      (EOF) ;;
      (*)
        '"$1"'=$(command eval "cat << EOF
${'"$1"'}
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.
  varname=${1-REPLY}

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.

ret=$?

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 ${'"$varname"'} 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 -
var=${1?}
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.

terdon
  • 242,166
  • Thanks for the answer ! About the last two code examples --- in that scenario I must use read. – variableexpander 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. – variableexpander 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
  • @Kusalananda did you mean to #! /bin/sh - var=${1?} ls -l -- "$var"? I don't know how this could help me. – variableexpander Feb 22 '21 at 11:16
  • @Stéphane I believe my problem is not an XY problem because I want to prompt myself for the variable value and not strictly predetermine it; the reason is because I might run the same script on different systems where the web application root might be on various paths. Furthermore, I also want to use variables which are based on existing variables such as domain_dir=$web_application_root/$domain and read is the only way I know to define such variables via prompting. – variableexpander Feb 22 '21 at 11:24
  • 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
  • @ilkkachu all the ways you mentioned aren't ways to prompt the user or I miss something (sorry if I do); in my special case I do indeed need to prompt the user for such details. – variableexpander Feb 22 '21 at 11:45
  • you can leave it to the caller's shell to do the expansions as *they* intend it the script I try to develop is only for Bash (different version could interpret differently, I know). – variableexpander Feb 22 '21 at 14:32
  • @variableexpander, see the example invocations of your-script I gave where the user can use whatever expansion or quoting feature of their shell they want to pass the directory name as the first argument to the script. – Stéphane Chazelas Feb 22 '21 at 14:35
  • Okay, I copied the function read_one_line_and_perform_shell_word_expansions() to clipboard, pasted it on console and executed it. I then copied to clipboard only read_one_line_and_perform_shell_word_expansions web_application_root || exit and finally I could use read to hold a variable expansion as a variable value. – variableexpander Feb 22 '21 at 15:39
  • I admit I don't totally understand the code of the function and might likely ask a separate question about it because I really want to understand it. – variableexpander Feb 22 '21 at 15:40
  • @variableexpander, the edit for a commented version. – Stéphane Chazelas Feb 22 '21 at 16:05