如何在bash中手动扩展特殊变量(例如:~ tilde)

195

我在我的bash脚本中有一个变量,其值类似于:

~/a/b/c
请注意,这是未展开的波浪号。当我在这个变量上运行ls -lt(假设它叫做$VAR)时,找不到该目录。我希望让bash解释/展开这个变量而不执行它。换句话说,我想让bash运行eval但不运行评估的命令。在bash中是否可以实现这一点?
我是如何在没有展开的情况下将其传递到我的脚本中的?我用双引号将参数括起来传递。
尝试运行以下命令以了解我的意思:
ls -lt "~"

我正处于这种情况下。我希望波浪号得到扩展。换句话说,我应该用什么替换magic,使得这两个命令相同:

ls -lt ~/abc/def/ghi

并且

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

请注意,~/abc/def/ghi 可能存在,也可能不存在。


6
您可能会发现Tilde expansion in quotes也很有用。它主要避免使用eval,但并非完全如此。 - Jonathan Leffler
6
你的变量是如何被分配为未展开的波浪号的?也许只需要在引号外面使用波浪号分配该变量。foo=~/"$filepath"foo="$HOME/$filepath" - Chad Skeeters
dir="$(readlink -f "$dir")" - Jack Wasey
19个回答

169

如果变量var是由用户输入的,则不应使用eval来扩展波浪号。

eval var=$var  # Do not use this!

原因是:用户可能会意或不小心输入像var="$(rm -rf $HOME/)"这样的代码,可能导致灾难性后果。

更好(而且更安全)的方法是使用Bash参数扩展:

var="${var/#\~/$HOME}"

12
如何将“/”更改为“userName/”? - aspergillusOryzae
1
@aspergillusOryzae 好问题。这里有一个解决方法:https://dev59.com/9kvSa4cB1Zd3GeqPdlZo#2069835 - Håkon Hægland
5
#"${var/#\~/$HOME}" 中的作用是替换 var 变量中以 ~ 开头的部分为 $HOME,其中 # 表示从开头开始匹配最短字符串并替换。 - Jahid
7
手册中有解释。它强制波浪符只匹配在$var的开头。 - Håkon Hægland
5
请将"${var/#...}"解释为特殊的bash语法,仅匹配开头,并将其编辑到您的答案中。附带URL。我已经使用bash数十年了,但不知道这个。另外,eval()可能存在恶意代码注入的问题。 - smci
显示剩余2条评论

112
由于StackOverflow的性质,我不能取消这个答案的采纳状态。但是在我发布这个答案的5年间,已经有比我的初步和相当糟糕的答案更好的解决方案了(我当时很年轻,请不要杀我)。
这个线程中的其他解决方案都是更安全和更好的解决方案。最好选择以下两种之一:
- Charle's Duffy's solution - Håkon Hægland's solution

历史目的的原始答案(但请不要使用此答案)

如果我没记错的话,"~"在bash脚本中不会以那种方式扩展,因为它被视为一个文字字符串"~"。您可以通过eval强制扩展,像这样。

#!/bin/bash

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

如果您想要用户的主目录,只需使用${HOME}


3
当变量名中含有空格时,你有解决方法吗? - Hugo
41
我认为${HOME}最具吸引力。有没有任何理由不将其作为您的首选建议?无论如何,谢谢! - sage
2
@sage 不,我也更喜欢${HOME} - 但我的回答是在解释如何像问题所问的那样使用~ - wkl
16
使用 eval 是一个可怕的建议,它得到了很多赞,这真的很糟糕。当变量的值包含 shell 元字符时,你会遇到各种问题。 - user2719058
1
我当时无法完成我的评论,后来也无法编辑它。所以我很感激(再次感谢@birryree)这个解决方案,因为它在那个特定的情境下帮了我很大的忙。感谢Charles让我意识到这一点。 - olivecoder
显示剩余12条评论

29

借鉴了我之前的一篇答案(链接),为了能够在不使用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%:}"
}

...被用作...

path=$(expandPath '~/hello')

相反,一个使用 eval 谨慎的简单方法:

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
}

5
看起来你的代码像是用加农炮打蚊子,一定有更简单的方法可以实现。 - Gino
4
@Gino,肯定有更简单的方法;问题在于是否有一种既简单又安全的方法。 - Charles Duffy
2
@Gino,我认为人们可以使用printf %q来转义除了波浪号之外的所有内容,然后在没有风险的情况下使用eval - Charles Duffy
4
安全,没错,但很不完整。我的代码并非为了好玩而复杂——它之所以复杂,是因为波浪号扩展实际上执行的操作是复杂的。 - Charles Duffy
1
@CharlesDuffy - 但是这样你也会被锁定在使用像bash这样糟糕的shell中。按照规范使用工具会更简单,我认为。有些人并不认为bash是一个那么糟糕的shell - 我认为这些人有点儿错觉,但也许我自己也有点儿错觉。我喜欢快速地按照规范完成任务,这样它们可能会在不同的人的想法之下正常工作。 - mikeserv
显示剩余20条评论

14
这样怎么样?
path=`realpath "$1"`

或者:

path=`readlink -f "$1"`

看起来不错,但我的Mac上不存在realpath。你需要编写path = $(realpath“$ 1”) - Hugo
嗨@Hugo。您可以使用C编译自己的realpath命令。例如,您可以使用[tag:bash]和[tag:gcc]从此命令行生成可执行文件realpath.exegcc -o realpath.exe -x c - <<< $'#include <stdlib.h> \n int main(int c,char**v){char p[9999]; realpath(v[1],p); puts(p);}'。干杯 - oHo
1
我已经在Mac上使用了Brew。 - nhed
5
如果您将波浪号放在引号中,它就行不通了,结果会是“<workingdir>/~”。 - Murphy
虽然有些晚了,但对于macOS上的其他用户,您可以使用homebrew并运行brew install coreutils。这将安装realpath以及其他一些在GNU coreutils页面上看到的东西。 - John Pancoast
显示剩余4条评论

10

这里有一个荒谬的解决方案:

$ echo "echo $var" | bash

这条命令的作用:

  1. 通过调用 bash,创建一个新的 bash 实例;
  2. 获取字符串 "echo $var" 并将其中的 $var 替换为变量的值 (替换后的字符串将包含波浪线);
  3. 将步骤 2 产生的字符串发送到步骤一中创建的 bash 实例中去,我们在此通过调用 echo 并使用管道符 | 来进行输出。

基本上,我们正在运行的当前 bash 实例代替我们作为另一个 bash 实例的用户,并为我们键入命令 "echo ~..."


虽然你的回答可能解决了问题,但包括解释如何以及为什么能够解决问题,真的可以提高你的帖子质量,并且很可能会得到更多的点赞。请记住,你正在为未来的读者回答问题,而不仅仅是现在提问的人。你可以编辑你的回答添加解释,并给出适用的限制和假设。- 来自审核 - Adam Marshall
谢谢,这有助于在将路径传递给realpath命令之前解析类似~$USERNAME的路径,例如:realpath -qe `echo "echo ~$USERNAME/.." | bash`。 - Winand

10

使用eval的安全方法是"$(printf "~/%q" "$dangerous_path")"。请注意,这仅适用于bash。

#!/bin/bash

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

请参考这个问题以获取详细信息。
此外,请注意,在zsh下,这只需要简单地使用echo ${~dangerous_path}

在zsh(mac os x)上,echo ${~root}没有输出。 - Orwellophile
export test="~root/a b"; echo ${~test} - Gyscos

7

扩展(无恶意)birryree和halloleo的答案:一般的方法是使用eval,但它存在一些重要的警告,即变量中的空格和输出重定向(>)。以下对我有效:

mypath="$1"

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

请尝试使用以下每个参数:

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

解释

  • ${mypath//>} 去除了可能在eval期间破坏文件的>字符。
  • eval echo ... 是实际执行波浪线展开的部分。
  • -e 参数周围的双引号是为了支持带有空格的文件名。

也许有更优雅的解决方案,但这是我能想到的。


3
你可以考虑查看名称包含 $(rm -rf .) 的文件的行为。 - Charles Duffy
1
这个在实际包含 > 字符的路径上会出问题,不是吗? - Radon Rosborough

4
为什么不直接使用getent获取用户的主目录?
$ getent passwd mike | cut -d: -f6
/users/mike

3

以下是模仿Python的os.path.expanduser()函数行为(不使用eval)的功能,供大家参考:

# _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

而这个函数:

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
}

2
我相信这就是你想要的。
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.$$
}

使用示例:

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

$ magic ~invalid/this/will/not/expand
~invalid/this/will/not/expand

我很惊讶 printf %q 没有转义前导波浪符 -- 这几乎可以将其视为一个错误,因为它在未能达到其声明目的的情况下失败了。然而,在此期间,这是个好选择! - Charles Duffy
1
实际上,这个 bug 在 3.2.57 和 4.3.18 之间的某个时间点被修复了,所以这段代码不再起作用。 - Charles Duffy
1
好的,我已经调整了代码以删除前导\(如果存在),所以一切都解决了 :) 我进行测试时没有引用参数,所以在调用函数之前就已经扩展了。 - Orwellophile

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接