madiyaan damha
madiyaan damha

Reputation: 3077

How to manually expand a special variable (ex: ~ tilde) in bash

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.

Upvotes: 203

Views: 127034

Answers (19)

xicod
xicod

Reputation: 111

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
}

Upvotes: 3

alexis
alexis

Reputation: 430

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.

Upvotes: 0

Yordan Georgiev
Yordan Georgiev

Reputation: 5460

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')

Upvotes: 0

glibbond
glibbond

Reputation: 151

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.

Upvotes: 15

Paul M
Paul M

Reputation: 320

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

$ getent passwd mike | cut -d: -f6
/users/mike

Upvotes: 5

Charles Duffy
Charles Duffy

Reputation: 295825

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
}

Upvotes: 30

JamesIsIn
JamesIsIn

Reputation: 296

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.)

Upvotes: 2

go2null
go2null

Reputation: 2308

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.

Upvotes: 3

Karim Alibhai
Karim Alibhai

Reputation: 406

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.

Upvotes: 2

mikeserv
mikeserv

Reputation: 694

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\"}"

Upvotes: 0

Chris Johnson
Chris Johnson

Reputation: 22026

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

Upvotes: -1

Orwellophile
Orwellophile

Reputation: 13963

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

Upvotes: 2

Gino
Gino

Reputation: 1790

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"

Upvotes: 1

wkl
wkl

Reputation: 80031

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.

Upvotes: 118

Noach Magedman
Noach Magedman

Reputation: 2473

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.

Upvotes: 7

H&#229;kon H&#230;gland
H&#229;kon H&#230;gland

Reputation: 40778

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}"

Upvotes: 183

eddygeek
eddygeek

Reputation: 4510

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}

Upvotes: 9

Jay
Jay

Reputation: 14481

How about this:

path=`realpath "$1"`

Or:

path=`readlink -f "$1"`

Upvotes: 12

halloleo
halloleo

Reputation: 10464

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_".

Upvotes: 0

Related Questions