190

I have a variable in my bash script whose value is something like this:

~/a/b/c

Note that it is unexpanded tilde. When I do ls -lt on this variable (call it $VAR), I get no such directory. I want to let bash interpret/expand this variable without executing it. In other words, I want bash to run eval but not run the evaluated command. Is this possible in bash?

How did I manage to pass this into my script without expansion? I passed the argument in surrounding it with double quotes.

Try this command to see what I mean:

ls -lt "~"

This is exactly the situation I am in. I want the tilde to be expanded. In other words, what should I replace magic with to make these two commands identical:

ls -lt ~/abc/def/ghi

and

ls -lt $(magic "~/abc/def/ghi")

Note that ~/abc/def/ghi may or may not exist.

madiyaan damha
  • 2,917
  • 5
  • 21
  • 15
  • 6
    You might find [Tilde expansion in quotes](http://stackoverflow.com/questions/15858766/tilde-expansion-in-quotes/15859646#15859646) helpful too. It mostly, but not entirely, avoids using `eval`. – Jonathan Leffler Dec 28 '14 at 09:21
  • 6
    How did your variable get assigned with an unexpanded tilde? Maybe all that is required is assign that variable with the tilde outside quotes. `foo=~/"$filepath"` or `foo="$HOME/$filepath"` – Chad Skeeters Jan 06 '17 at 23:45
  • `dir="$(readlink -f "$dir")"` – Jack Wasey Feb 13 '20 at 11:51

19 Answers19

165

If the variable var is input by the user, eval should not be used to expand the tilde using

eval var=$var  # Do not use this!

The reason is: the user could by accident (or by purpose) type for example var="$(rm -rf $HOME/)" with possible disastrous consequences.

A better (and safer) way is to use Bash parameter expansion:

var="${var/#\~/$HOME}"
Håkon Hægland
  • 39,012
  • 21
  • 81
  • 174
  • 12
    How could you change ~userName/ instead of just ~/ ? – aspergillusOryzae Dec 15 '14 at 22:43
  • 1
    @aspergillusOryzae Good question. Here is a workaround: http://stackoverflow.com/a/2069835/2173773 – Håkon Hægland Dec 16 '14 at 07:09
  • 5
    What is the purpose of `#` in `"${var/#\~/$HOME}"` ? – Jahid Jun 02 '15 at 16:46
  • 7
    @Jahid It is explained in the [manual](http://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html) . It forces the tilde to only match at the beginning of `$var`. – Håkon Hægland Jun 02 '15 at 17:27
  • 1
    Thanks. (1) Why do we need `\~` i.e. escaping `~`? (2) Your reply assumes that `~` is the first character in `$var`. How can we ignore the leading white spaces in `$var`? – Tim May 05 '18 at 01:16
  • 1
    @Tim Thanks for the comment. Yes you are right, we do not need to escape a tilde unless it is the first character of an unquoted string or it is following a `:` in an unquoted string. More information in [the docs](https://www.gnu.org/software/bash/manual/bash.html#Tilde-Expansion). To remove leading white space, see [How to trim whitespace from a Bash variable?](https://stackoverflow.com/q/369758/2173773) – Håkon Hægland May 05 '18 at 03:59
  • 5
    Please edit into your answer the explanation that "${var/#...}" is a special bash syntax to only match at the beginning. With URL. I've used bash for decades but didn't know that one. Also that the possible problem with eval() is malicious code injection. – smci Oct 10 '18 at 01:08
111

Due to the nature of StackOverflow, I can't just make this answer unaccepted, but in the intervening 5 years since I posted this there have been far better answers than my admittedly rudimentary and pretty bad answer (I was young, don't kill me).

The other solutions in this thread are safer and better solutions. Preferably, I'd go with either of these two:


Original answer for historic purposes (but please don't use this)

If I'm not mistaken, "~" will not be expanded by a bash script in that manner because it is treated as a literal string "~". You can force expansion via eval like this.

#!/bin/bash

homedir=~
eval homedir=$homedir
echo $homedir # prints home path

Alternatively, just use ${HOME} if you want the user's home directory.

Community
  • 1
  • 1
wkl
  • 77,184
  • 16
  • 165
  • 176
  • 3
    Do you have a fix for when the variable has a space in it? – Hugo Jul 08 '11 at 13:01
  • @Hugo - do you mean like `VAR="Something With Spaces"`? – wkl Jul 08 '11 at 15:14
  • The reason ~ is not expanded is explained here: http://fvue.nl/wiki/Bash:_Why_use_eval_with_variable_expansion%3F – frankc Sep 23 '11 at 18:55
  • 41
    I found `${HOME}` most attractive. Is there any reason not to make this your primary recommendation? In any case, thanks! – sage Sep 05 '13 at 15:21
  • 2
    @sage No, I'd prefer `${HOME}` as well - but my answer was explaining how to do it with `~` like the question was asking. – wkl Sep 05 '13 at 16:22
  • 1
    +1 -- I was needing to expand ~$some_other_user and eval works fine when $HOME will not work because I don't need the current user home. – olivecoder Sep 10 '13 at 11:30
  • @birryree Yes exactly like that. To be more specific a path with a space: `'~/some very nice dir/that has spaces'` – RedX Nov 04 '13 at 09:29
  • 16
    Using `eval` is a horrible suggestion, it's really bad that it gets so many upvotes. You will run into all sorts of problems when the variable's value contains shell meta characters. – user2719058 Aug 31 '14 at 19:47
  • @user2719058 Agree. Use Bash parameter expansion instead. – Håkon Hægland Dec 15 '14 at 13:56
  • @olivecoder, there are other ways to do this (given in other answers!) that don't wipe your machine when you try to `expandPath '/tmp/$(rm -rf /)'`. Security implications are a thing that exist. – Charles Duffy Aug 21 '15 at 14:34
  • @CharlesDuffy Due to the nature of SO, I can't delete my answer nor make it unaccepted, so best I can do is just link future readers to the better/safer solutions in this thread. – wkl Aug 21 '15 at 15:01
  • @CharlesDuffy: Wow! That was five years ago... OK, I'm not sure why I've been especially referred but let's go: The context, at first: I was looking, at that time, for a way to find the home directory for an user passed as parameter in a newly created vagrant machine. – olivecoder Aug 26 '15 at 14:54
  • @olivecoder, ...and that's a perfectly reasonable use case, and it certainly makes sense that `$HOME` won't work for you there, but there are more options than choosing between (1) using `eval` dangerously or (2) substituting only `$HOME`. See for instance the recent answer by Orwellophile, making safe use of `eval`. Gino's most recent edits are also safe, as long as the username itself is trusted. Or there's the answer I've had since March -- long and unwieldy, but no use of `eval` (and associated security risk) at all. – Charles Duffy Aug 26 '15 at 15:01
  • 1
    I could not finish my comment at that time and, then, I wasnt allowed to edit it later. So I'm grateful (thanks again @birryree) for this solution as it helped in my specific context at that time. Thanks Charles for making me aware. – olivecoder Aug 26 '15 at 15:42
  • 1
    Thanks for honourably putting the disclaimer at the top. Definitely doesn't deserve a down for that! ;-) – Noldorin Feb 14 '19 at 17:33
  • @olivecoder The shortest way without `eval` is probably `getent passwd "$user" | awk -F : '{print $6}'` (as indicated in @CharlesDuffy’s solution). Using `getent` might not be necessary, you can also use only `awk -F : -v user="$user" '$1 == user {print $6}' /etc/passwd`. Note that `$user` would refer to the user of whom you want to know the home directory, while `$USER` would refer to the current user. – PointedEars Aug 25 '19 at 13:28
  • Looked at answers to your question and they inspired this. ```var="$(echo "$var" | sed "s#^~#${HOME}#")"``` – 5p0ng3b0b May 05 '23 at 12:18
29

Plagarizing myself from a prior answer, to do this robustly without the security risks associated with eval:

expandPath() {
  local path
  local -a pathElements resultPathElements
  IFS=':' read -r -a pathElements <<<"$1"
  : "${pathElements[@]}"
  for path in "${pathElements[@]}"; do
    : "$path"
    case $path in
      "~+"/*)
        path=$PWD/${path#"~+/"}
        ;;
      "~-"/*)
        path=$OLDPWD/${path#"~-/"}
        ;;
      "~"/*)
        path=$HOME/${path#"~/"}
        ;;
      "~"*)
        username=${path%%/*}
        username=${username#"~"}
        IFS=: read -r _ _ _ _ _ homedir _ < <(getent passwd "$username")
        if [[ $path = */* ]]; then
          path=${homedir}/${path#*/}
        else
          path=$homedir
        fi
        ;;
    esac
    resultPathElements+=( "$path" )
  done
  local result
  printf -v result '%s:' "${resultPathElements[@]}"
  printf '%s\n' "${result%:}"
}

...used as...

path=$(expandPath '~/hello')

Alternately, a simpler approach that uses eval carefully:

expandPath() {
  case $1 in
    ~[+-]*)
      local content content_q
      printf -v content_q '%q' "${1:2}"
      eval "content=${1:0:2}${content_q}"
      printf '%s\n' "$content"
      ;;
    ~*)
      local content content_q
      printf -v content_q '%q' "${1:1}"
      eval "content=~${content_q}"
      printf '%s\n' "$content"
      ;;
    *)
      printf '%s\n' "$1"
      ;;
  esac
}
Charles Duffy
  • 280,126
  • 43
  • 390
  • 441
  • 5
    Looking at your code it looks like you're using a cannon to kill a mosquito. There's *got* to be a much simpler way.. – Gino Aug 21 '15 at 13:49
  • 4
    @Gino, there's surely a simpler way; the question is whether there's a simpler way that's also secure. – Charles Duffy Aug 21 '15 at 13:56
  • 2
    @Gino, ...I _do_ suppose that one can use `printf %q` to escape everything but the tilde, and _then_ use `eval` without risk. – Charles Duffy Aug 21 '15 at 13:57
  • 1
    @Gino, ...and so implemented. – Charles Duffy Aug 21 '15 at 14:03
  • 1
    I just posted my solution to this question. It uses a regular expression to expand the tilde and should be fairly safe. – Gino Aug 21 '15 at 14:11
  • 4
    Safe, yes, but very much incomplete. My code isn't complex for the fun of it -- it's complex because the actual operations done by tilde expansion are complex. – Charles Duffy Aug 21 '15 at 14:14
  • @Gino - you don't need the regexp. there's a command for that. `pathchk`. – mikeserv Dec 13 '15 at 04:14
  • @mikeserv, I'm not sure pathchk is applicable here. The portable set on any given system is typically much larger than the valid set (meaning you're often ruling out paths that the local filesystem will allow), and the valid set isn't guaranteed to be `eval`-safe. – Charles Duffy Jan 28 '16 at 17:46
  • @CharlesDuffy - maybe that's true - but can you provide some examples either way? – mikeserv Jan 28 '16 at 17:49
  • 1
    @mikeserv, sure. `touch '/tmp/$(hello)'` works -- it's valid -- on ext2 derivatives, but it contains characters that aren't part of the POSIX-defined portable set, and `pathchk` will declare it invalid. – Charles Duffy Jan 28 '16 at 17:51
  • @CharlesDuffy - yeah, it will with `-pP`, but that's the point. I'm not looking for valid pathnames - but valid usernames. – mikeserv Jan 28 '16 at 17:52
  • @mikeserv, the thing is, if instead of using `pathchk` to filter you use `printf %q` to escape pre-`eval` -- as the answer by Orwellophile does -- then you get the best of both worlds: You can work with all locally-valid names even if they're outside the POSIX spec. Why restrict yourself, then? – Charles Duffy Jan 28 '16 at 17:54
  • ...though yes, if the POSIX spec for usernames allows all characters in the portable set for filenames to be used, then `pathchk` *is* a good fit. – Charles Duffy Jan 28 '16 at 17:55
  • 1
    @CharlesDuffy - but then you also lock yourself into the use of an awful shell like `bash`. more simple just to use the tools as they are spec'd for use, as i see it. some people dont think bash is such an awful shell - i think said people are a little deluded, but then maybe im a little deluded. i like to do things quickly and according to spec so they they might work regardless of one's delusions. – mikeserv Jan 28 '16 at 17:57
  • I agree that bash is awful, but ksh93 is *not* awful, and it provides %q as well. :) – Charles Duffy Jan 28 '16 at 17:58
  • @CharlesDuffy - ^^^^!!!! - by the way - did you see my update at the is ksh dead? question? – mikeserv Jan 28 '16 at 17:58
  • Well, ksh93 not awful for being a POSIX-y shell. I'd argue that to be a truly non-awful programming language one needs to be a LISP. :) – Charles Duffy Jan 28 '16 at 18:01
  • @CharlesDuffy - we can at least agree about `ksh`. i think we can both laugh about lisp, though. anyway, have a look at the update [here](http://unix.stackexchange.com/a/256968/52934) - if you havent already. – mikeserv Jan 28 '16 at 18:04
  • What is the purpose of the special `~-` and `~+` cases? I've not seen these before. – Tom Hale Mar 11 '17 at 14:35
  • They're covered in `man bash` -- `~+` expands to the current directory (same as `$PWD`), and `~-` expands to `$OLDPWD`, the same directory that `cd -` would move to. – Charles Duffy Mar 11 '17 at 15:40
  • You've still missed a couple of cases (in the first program): `~`, `~+` or `~-` alone (i.e. no directory separator). The easiest fix may be to write `case "$path/"`, and drop `/` from the `${path#}` substitution. I think that could simplify the `~name` case, too. – Toby Speight Aug 01 '17 at 08:37
  • @CharlesDuffy In your `IFS=: read _ _ _ _ _ homedir _ < <(getent passwd "$username")` line, how come you don't use `read -r` to prevent mangling of backslashes? – leetbacoon Aug 01 '19 at 16:30
  • @leetbacoon, ...in writing the code, I was under the impression that when a value contained a literal colon, a backslash would be used to escape it, making the behavior of `read` without `-r` exactly what we wanted. Reading the spec, however, I see that that impression was mistaken -- colons are simply disallowed from the contents -- so `-r` is appropriate. – Charles Duffy Aug 01 '19 at 16:46
  • To catch *all* cases of tilde expansion, I agree that your code is probably required; but for the *simple* case of only finding out the home directory of a user, I think that `getent passwd "$user" | awk -F : '{print $6}'` or `awk -F : -v user="$user" '$1 == user {print $6}' /etc/passwd`, where `$user` refers to the username, suffices. – PointedEars Aug 25 '19 at 13:35
  • Certainly; one shouldn't ever do this if one doesn't *really* need to provide shell-like tilde expansion. However, if one *is* going to present to users an interface that looks like a shell (or claims to honor all paths a shell would accept), it had damned well better act like a shell; anything else leads to confusion -- so my general advice is to avoid presenting such interfaces in the first place; accepting only paths that can actually passed to kernel syscalls (`open()`, `chdir()`, etc) is much less trouble, and in line with what other programs, written in C, expect. – Charles Duffy Aug 25 '19 at 15:31
14

How about this:

path=`realpath "$1"`

Or:

path=`readlink -f "$1"`
Thor
  • 45,082
  • 11
  • 119
  • 130
Jay
  • 13,803
  • 4
  • 42
  • 69
  • looks nice, but realpath does not exist on my mac. And you would have to write path=$(realpath "$1") – Hugo Jul 08 '11 at 11:09
  • Hi @Hugo. You can compile your own `realpath` command in C. For instance, you can generate an executable `realpath.exe` using [tag:bash] and [tag:gcc] from this command line: `gcc -o realpath.exe -x c - <<< $'#include \n int main(int c,char**v){char p[9999]; realpath(v[1],p); puts(p);}'`. Cheers – oHo Oct 24 '13 at 09:21
  • @Quuxplusone not true, at least on linux: `realpath ~` -> `/home/myhome` – blueFast Oct 05 '18 at 11:00
  • 1
    iv'e used it with brew on mac – nhed Jan 06 '20 at 01:37
  • 4
    @dangonfast This won't work if you set the tilde into quotes, the result is `/~`. – Murphy Feb 23 '20 at 14:09
  • Neither of these commands can be used to expand the tilde on my system. – Murphy Feb 23 '20 at 14:11
  • @Murphy You might submit a question with details. The system you use and the error you're getting. We can't help you otherwise. – Jay Feb 23 '20 at 18:26
  • Thanks, but the substitution works very well for my purpose. Just wanted to point out to subsequent readers that this isn't an alternative that can be used everywhere. You may want to add the environment you tested this with. – Murphy Feb 23 '20 at 20:45
  • Several years late but for others on macOS, you can use [homebrew](https://brew.sh/) and run `brew install coreutils`. This installs `realpath` among other things as seen on the [GNU coreutils page](https://www.gnu.org/software/coreutils/). – John Pancoast Apr 22 '22 at 00:47
10

A safe way to use eval is "$(printf "~/%q" "$dangerous_path")". Note that is bash specific.

#!/bin/bash

relativepath=a/b/c
eval homedir="$(printf "~/%q" "$relativepath")"
echo $homedir # prints home path

See this question for details

Also, note that under zsh this would be as as simple as echo ${~dangerous_path}

Community
  • 1
  • 1
eddygeek
  • 4,236
  • 3
  • 25
  • 32
10

Here is a ridiculous solution:

$ echo "echo $var" | bash

An explanation of what this command does:

  1. create a new instance of bash, by... calling bash;
  2. take the string "echo $var" and substitute $var with the value of the variable (thus after the substitution the string will contain the tilde);
  3. take the string produced by step 2 and send it to the instance of bash created in step one, which we do here by calling echo and piping its output with the | character.

Basically the current bash instance we're running takes our place as the user of another bash instance and types in the command "echo ~..." for us.

glibbond
  • 101
  • 1
  • 4
  • While your answer may solve the question, [including an explanation](https://meta.stackexchange.com/q/114762) of how and why this solves the problem would really help to improve the quality of your post, and probably result in more up-votes. Remember that you are answering the question for readers in the future, not just the person asking now. You can edit your answer to add explanations and give an indication of what limitations and assumptions apply. - [From Review](https://stackoverflow.com/review/late-answers/28312827) – Adam Marshall Feb 13 '21 at 05:04
  • Thanks, this helps to resolve a path like `~$USERNAME` before passing it to `realpath` command e.g. realpath -qe \`echo "echo ~$USERNAME/.." | bash\` – Winand Jul 25 '22 at 09:27
7

Expanding (no pun intended) on birryree's and halloleo's answers: The general approach is to use eval, but it comes with some important caveats, namely spaces and output redirection (>) in the variable. The following seems to work for me:

mypath="$1"

if [ -e "`eval echo ${mypath//>}`" ]; then
    echo "FOUND $mypath"
else
    echo "$mypath NOT FOUND"
fi

Try it with each of the following arguments:

'~'
'~/existing_file'
'~/existing file with spaces'
'~/nonexistant_file'
'~/nonexistant file with spaces'
'~/string containing > redirection'
'~/string containing > redirection > again and >> again'

Explanation

  • The ${mypath//>} strips out > characters which could clobber a file during the eval.
  • The eval echo ... is what does the actual tilde expansion
  • The double-quotes around the -e argument are for support of filenames with spaces.

Perhaps there's a more elegant solution, but this is what I was able to come up with.

Noach Magedman
  • 2,313
  • 1
  • 23
  • 18
4

why not delve straight into getting the user's home directory with getent?

$ getent passwd mike | cut -d: -f6
/users/mike
Paul M
  • 290
  • 2
  • 12
3

For anyone's reference, a function to mimic python's os.path.expanduser() behavior (no eval usage):

# _expand_homedir_tilde ~/.vim
/root/.vim
# _expand_homedir_tilde ~myuser/.vim
/home/myuser/.vim
# _expand_homedir_tilde ~nonexistent/.vim
~nonexistent/.vim
# _expand_homedir_tilde /full/path
/full/path

And the function:

function _expand_homedir_tilde {
    (
    set -e
    set -u
    p="$1"
    if [[ "$p" =~ ^~ ]]; then
        u=`echo "$p" | sed 's|^~\([a-z0-9_-]*\)/.*|\1|'`
        if [ -z "$u" ]; then
            u=`whoami`
        fi

        h=$(set -o pipefail; getent passwd "$u" | cut -d: -f6) || exit 1
        p=`echo "$p" | sed "s|^~[a-z0-9_-]*/|${h}/|"`
    fi
    echo $p
    ) || echo $1
}
xicod
  • 91
  • 1
  • 2
2

I believe this is what you're looking for

magic() { # returns unexpanded tilde express on invalid user
    local _safe_path; printf -v _safe_path "%q" "$1"
    eval "ln -sf ${_safe_path#\\} /tmp/realpath.$$"
    readlink /tmp/realpath.$$
    rm -f /tmp/realpath.$$
}

Example usage:

$ magic ~nobody/would/look/here
/var/empty/would/look/here

$ magic ~invalid/this/will/not/expand
~invalid/this/will/not/expand
Orwellophile
  • 13,235
  • 3
  • 69
  • 45
  • I'm surprised that `printf %q` _doesn't_ escape leading tildes -- it's almost tempting to file this as a bug, as it's a situation in which it fails at its stated purpose. However, in the interim, a good call! – Charles Duffy Aug 21 '15 at 14:05
  • 1
    Actually -- this bug is fixed at some point between 3.2.57 and 4.3.18, so this code no longer works. – Charles Duffy Aug 21 '15 at 14:07
  • 1
    Good point, I've adjusted to code to remove the leading \ if it exists, so all fixed and worked :) I was testing without quoting the arguments, so it was expanding before calling the function. – Orwellophile Aug 25 '15 at 12:42
2

Here is the POSIX function equivalent of Håkon Hægland's Bash answer

expand_tilde() {
    tilde_less="${1#\~/}"
    [ "$1" != "$tilde_less" ] && tilde_less="$HOME/$tilde_less"
    printf '%s' "$tilde_less"
}

2017-12-10 edit: add '%s' per @CharlesDuffy in the comments.

go2null
  • 2,080
  • 1
  • 21
  • 17
  • 1
    `printf '%s\n' "$tilde_less"`, perhaps? Otherwise it'll misbehave if the filename being expanded contain backslashes, `%s`, or other syntax meaningful to `printf`. Other than that, though, this is a great answer -- correct (when bash/ksh extensions don't need to be covered), obviously safe (no mucking with `eval`) and terse. – Charles Duffy Dec 08 '17 at 21:53
1

Here's my solution:

#!/bin/bash


expandTilde()
{
    local tilde_re='^(~[A-Za-z0-9_.-]*)(.*)'
    local path="$*"
    local pathSuffix=

    if [[ $path =~ $tilde_re ]]
    then
        # only use eval on the ~username portion !
        path=$(eval echo ${BASH_REMATCH[1]})
        pathSuffix=${BASH_REMATCH[2]}
    fi

    echo "${path}${pathSuffix}"
}



result=$(expandTilde "$1")

echo "Result = $result"
Gino
  • 1,593
  • 17
  • 22
  • 1
    Also, relying on `echo` means that `expandTilde -n` isn't going to behave as expected, and behavior with filenames containing backslashes is undefined by POSIX. See http://pubs.opengroup.org/onlinepubs/009604599/utilities/echo.html – Charles Duffy Aug 21 '15 at 14:14
  • Good catch. I normally use a one-user machine so I didn't think to handle that case. But I think the function could easily be enhanced to handle this other case by grepping through the /etc/passwd file for the otheruser. I'll leave it as an exercise for someone else :). – Gino Aug 21 '15 at 14:15
  • I've already done that exercise (and handled the OLDPWD case and others) in an answer you deemed too complex. :) – Charles Duffy Aug 21 '15 at 14:16
  • actually, i just found a fairly simple one-line solution that should handle the otheruser case: path=$(eval echo $orgPath) – Gino Aug 21 '15 at 14:24
  • Dude, using "plagiarizing' in reference to a 1 line statement is a bit of an overstatement, don't you think ? In any case, I would assume that the shell script writer has full control over the invocation of the expandTilde() function with an understanding of it's limitations. Just because a statement is dangerous doesn't mean that one should avoid using it. Example: 'rm' is dangerous, since you can wipe out the filesystem with it, but that doesn't mean that you shouldn't use it. Rather, one has to be familiar with the limits of the tools one uses.. – Gino Aug 21 '15 at 15:04
  • 1
    FYI: I just updated my solution so that it can now handle ~username correctly. And, it should be fairly safe as well. Even if you put in a '/tmp/$(rm -rf /*)' as an argument, it should handle it gracefully. – Gino Aug 21 '15 at 17:44
  • I think at this point, there's enough there that someone else can take it and improve it further. Thanks to Charles Duffy for pointing out the ~username and '/tmp/${rm -rf /)' cases.. – Gino Aug 21 '15 at 17:53
  • go ahead and make your changes. I suggest you do it in a separate post and just reference my post since you're editing my post. – Gino Aug 21 '15 at 17:56
  • i'm actually not that clear on the exact changes that need to be made, which is why i suggested making a separate post. If you show me the diffs I'll be happy to incorporate them into my post. Is it just that one line change to path=$(eval "echo ..) ? – Gino Aug 21 '15 at 18:01
  • Hmm.. I just tried using path=$(eval "echo '${BASH_REMATCH[1]}'") in a script and it causes it to no longer work for even the basic case '~'. – Gino Aug 21 '15 at 18:04
  • Oh. Right. Yup, that won't work if it escapes the tilde. Since that's only relevant if IFS is set to a non-default value, it might actually be easier just to make sure that can never happen. `path=$(unset IFS; eval "echo ${BASH_REMATCH[1]}")` – Charles Duffy Aug 21 '15 at 18:05
  • (specifically, the bug I was worried about here was IFS containing a character that existed inside the username, causing the expansion to be split into multiple words on either side of that latter; if IFS is unset, the default is tabs, newlines, and spaces, and your regex doesn't allow any of those). – Charles Duffy Aug 21 '15 at 18:08
  • Charles, If you can make it more robust, I suggest you make a separate post and just reference mine, if you're using it as a starting point. – Gino Aug 21 '15 at 18:11
  • I'm happy with my existing answer, in terms of having something adequately robust; and this one's in reasonably good shape too: the non-default-IFS bug is a pretty minor one, and it'll just cause incorrect results, not security breaches. I've tried to clean up parts of my comment history that are no longer relevant as-amended. – Charles Duffy Aug 21 '15 at 18:12
1

Simplest: replace 'magic' with 'eval echo'.

$ eval echo "~"
/whatever/the/f/the/home/directory/is

Problem: You're going to run into issues with other variables because eval is evil. For instance:

$ # home is /Users/Hacker$(s)
$ s="echo SCARY COMMAND"
$ eval echo $(eval echo "~")
/Users/HackerSCARY COMMAND

Note that the issue of the injection doesn't happen on the first expansion. So if you were to simply replace magic with eval echo, you should be okay. But if you do echo $(eval echo ~), that would be susceptible to injection.

Similarly, if you do eval echo ~ instead of eval echo "~", that would count as twice expanded and therefore injection would be possible right away.

Karim Alibhai
  • 346
  • 3
  • 4
  • 1
    Contrary to what you said, **this code is unsafe**. For example, test `s='echo; EVIL_COMMAND'`. (It will fail because `EVIL_COMMAND` doesn’t exist on your computer. But if that command had been `rm -r ~` for example, it would have deleted your home directory.) – Konrad Rudolph Apr 09 '19 at 15:16
0

You might find this easier to do in python.

(1) From the unix command line:

python -c 'import os; import sys; print os.path.expanduser(sys.argv[1])' ~/fred

Results in:

/Users/someone/fred

(2) Within a bash script as a one-off - save this as test.sh:

#!/usr/bin/env bash

thepath=$(python -c 'import os; import sys; print os.path.expanduser(sys.argv[1])' $1)

echo $thepath

Running bash ./test.sh results in:

/Users/someone/fred

(3) As a utility - save this as expanduser somewhere on your path, with execute permissions:

#!/usr/bin/env python

import sys
import os

print os.path.expanduser(sys.argv[1])

This could then be used on the command line:

expanduser ~/fred

Or in a script:

#!/usr/bin/env bash

thepath=$(expanduser $1)

echo $thepath
Chris Johnson
  • 20,650
  • 6
  • 81
  • 80
  • Or how about passing only '~' to Python, returning "/home/fred"? – Tom Russell Nov 08 '15 at 08:01
  • 2
    Needs moar quotes. `echo $thepath` is buggy; needs to be `echo "$thepath"` to fix the less-uncommon cases (names with tabs or runs of spaces being converted to single spaces; names with globs having them expanded), or `printf '%s\n' "$thepath"` to fix the uncommon ones too (ie. a file named `-n`, or a file with backslash literals on an XSI-compliant system). Similarly, `thepath=$(expanduser "$1")` – Charles Duffy Dec 13 '15 at 06:12
  • ...to understand what I meant about backslash literals, see http://pubs.opengroup.org/onlinepubs/009604599/utilities/echo.html -- POSIX allows `echo` to behave in a completely implementation-defined manner if any argument contains backslashes; the optional XSI extensions to POSIX mandate default (no `-e` or `-E` needed) expansion behaviors for such names. – Charles Duffy Dec 13 '15 at 06:13
0

Just use eval correctly: with validation.

case $1${1%%/*} in
([!~]*|"$1"?*[!-+_.[:alnum:]]*|"") ! :;;
(*/*)  set "${1%%/*}" "${1#*/}"       ;;
(*)    set "$1" 
esac&& eval "printf '%s\n' $1${2+/\"\$2\"}"
mikeserv
  • 694
  • 7
  • 9
  • This is probably safe -- I haven't found a case it fails for. That said, if we're going to speak to using eval "correctly", I'd argue that Orwellophile's answer follows the better practice: I trust the shell's `printf %q` to escape things safely more than I trust hand-written validation code to have no bugs. – Charles Duffy Dec 13 '15 at 06:25
  • @Charles Duffy - that's silly. the shell might not have a %q - and `printf` is a `$PATH`'d command. – mikeserv Dec 13 '15 at 06:29
  • 2
    Isn't this question tagged `bash`? If so, `printf` is a builtin, and `%q` is guaranteed to be present. – Charles Duffy Dec 13 '15 at 06:30
  • @Charles Duffy - what version? – mikeserv Dec 13 '15 at 06:31
  • 2.02, I believe -- in there since bash first got a clone of ksh's printf builtin. – Charles Duffy Dec 13 '15 at 06:33
  • 1
    @Charles Duffy - that's... pretty early. but i still think its weird that you'd trust a %q arg more than you would code right before your eyes, ive used `bash` enough before to know *not* to trust it. try: `x=$(printf \\1); [ -n "$x" ] || echo but its not null!` – mikeserv Dec 13 '15 at 06:35
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/97747/discussion-between-charles-duffy-and-mikeserv). – Charles Duffy Dec 13 '15 at 06:35
0

I have done this with variable parameter substitution after reading in the path using read -e (among others). So the user can tab-complete the path, and if the user enters a ~ path it gets sorted.

read -rep "Enter a path:  " -i "${testpath}" testpath 
testpath="${testpath/#~/${HOME}}" 
ls -al "${testpath}" 

The added benefit is that if there is no tilde nothing happens to the variable, and if there is a tilde but not in the first position it is also ignored.

(I include the -i for read since I use this in a loop so the user can fix the path if there is a problem.)

JamesIsIn
  • 181
  • 1
  • 6
0

for some reason when the string is already quoted only perl saves the day

  #val="${val/#\~/$HOME}" # for some reason does not work !!
  val=$(echo $val|perl -ne 's|~|'$HOME'|g;print')
Yordan Georgiev
  • 5,114
  • 1
  • 56
  • 53
0

I think that

thepath=( ~/abc/def/ghi )

is easier than all the other solutions... or I am missing something? It works even if the path does not really exists.

alexis
  • 410
  • 4
  • 18
0

Just to extend birryree's answer for paths with spaces: You cannot use the eval command as is because it seperates evaluation by spaces. One solution is to replace spaces temporarily for the eval command:

mypath="~/a/b/c/Something With Spaces"
expandedpath=${mypath// /_spc_}    # replace spaces 
eval expandedpath=${expandedpath}  # put spaces back
expandedpath=${expandedpath//_spc_/ }
echo "$expandedpath"    # prints e.g. /Users/fred/a/b/c/Something With Spaces"
ls -lt "$expandedpath"  # outputs dir content

This example relies of course on the assumption that mypath never contains the char sequence "_spc_".

Community
  • 1
  • 1
halloleo
  • 9,216
  • 13
  • 64
  • 122
  • 1
    Doesn't work with tabs, or newlines, or anything else in IFS... and doesn't provide security around metacharacters like paths containing `$(rm -rf .)` – Charles Duffy Apr 13 '15 at 20:19