路径独立shebangs


20

我有一个脚本,希望能够在两台计算机上运行。这两台机器从相同的git存储库中获取脚本的副本。该脚本需要使用正确的解释器(例如zsh)运行。

不幸的是,这两个 envzsh住在本地和远程计算机不同的位置:

遥控机器

$ which env
/bin/env

$ which zsh
/some/long/path/to/the/right/zsh

本地机器

$ which env
/usr/bin/env

$which zsh
/usr/local/bin/zsh

如何设置shebang,以便像/path/to/script.sh往常一样运行脚本中的Zsh可用脚本PATH


8
您确定env同时不在/ bin和/ usr / bin中吗?尝试which -a env确认。
grawity 2014年

Answers:


22

您无法直接通过shebang解决此问题,因为shebang完全是静态的。如果该LCM不是zsh,您可以做的是在shebang中使用一些“最小公倍数”(从shell角度来看),并使用正确的shell重新执行脚本。换句话说:让您的脚本执行由外壳上的所有系统中,测试了zsh-only功能,如果测试证明虚假的,有剧本execzsh,那里的测试取得成功,你只是继续。

zsh例如,中的一项独特功能是$ZSH_VERSION变量的存在:

#!/bin/sh -

[ -z "$ZSH_VERSION" ] && exec zsh - "$0" ${1+"$@"}

# zsh-specific stuff following here
echo "$ZSH_VERSION"

在这种简单的情况下,该脚本首先由/bin/sh(所有80年代后的Unix类系统都理解#!并具有/bin/sh,或者是Bourne或POSIX,但我们的语法与两者兼容)执行。如果$ZSH_VERSION没有设置,脚本exec的本身通过zsh。如果$ZSH_VERSION设置了(表示脚本已经通过运行zsh),则仅跳过测试。Voilà。

如果zsh根本没有,这只会失败$PATH

编辑:要确保,你只有exec一个zsh在平时的地方,你可以使用类似

for sh in /bin/zsh \
          /usr/bin/zsh \
          /usr/local/bin/zsh; do
    [ -x "$sh" ] && exec "$sh" - "$0" ${1+"$@"}
done

这样可以避免您意外地exec在自己的东西上做一些$PATH不符合zsh您期望的事情。


我upvoted这个高雅,但它确实在原则上,具有安全性/兼容性问题,如果第一个zsh$PATH是不是你所期望的一个。
Ryan Reich 2014年

试图解决它。问题是,您是否始终可以确定zsh标准位置的二进制文件是否真的是zsh
Andreas Wiese 2014年

您可以动态地路径!bang行。您也可以问zsh自己与的位置zsh -c 'whence zsh'。更简单地说,你可以command -v zsh。请参阅我的答案以了解如何动态地路径#!bang
mikeserv

1
zsh从中调用二进制文件$PATH以获取二进制文件的路径zsh并不能完全解决@RyanReich指出的问题,对吗?:)
Andreas Wiese 2014年

如果您自己执行zsh,那不是,我想不是。但是,如果将结果字符串嵌入到哈希爆炸中,然后执行自己的脚本,则至少知道您得到了什么。尽管如此,它比循环测试更简单。
mikeserv

7

多年来,我一直在使用类似的方法来处理需要脚本运行的系统上Bash的各个位置。

Bash / Zsh /等

#!/bin/sh

# Determines which OS and then reruns this script with approp. shell interp.
LIN_BASH="/bin/sh";
SOL_BASH="/packages/utilities/bin/sun5/bash";

OS_TYPE=`uname -s`;

if [ $OS_TYPE = "SunOS" ]; then
  $SOL_BASH -c "`sed -n '/\#\#\# BEGIN/,$p' $0`" $0 $*;
elif [ $OS_TYPE = "Linux" ]; then
  $LIN_BASH -c "`sed -n '/\#\#\# BEGIN/,$p' $0`" $0 $*;
else
  echo "UNKNOWN OS_TYPE, $OS_TYPE";
  exit 1;
fi
exit 0;

### BEGIN

...script goes here...

上面的内容很容易适用于各种口译员。关键是该脚本最初作为Bourne shell运行。它然后递归调用自身,第二个时间,但上述评论的一切解析出来### BEGIN使用sed

佩尔

这是Perl的类似技巧:

#!/bin/sh

LIN_PERL="/usr/bin/perl";
SOL_PERL="/packages/perl/bin/perl";

OS_TYPE=`uname -s`;

if [ $OS_TYPE = "SunOS" ]; then
  eval 'exec $SOL_PERL -x -S $0 ${1+"$@"}';
elif [ $OS_TYPE = "Linux" ]; then
  eval 'exec $LIN_PERL -x -S $0 ${1+"$@"}';
else
  echo "$OS_TYPE: UNSUPORRTED OS/PLATFORM";
  exit 0;
fi
exit 0;

#!perl

...perl script goes here...

当给定要运行的文件时,此方法将利用Perl的功能来解析该文件,从而跳过该行之前的所有行#! perl


那里存在许多问题:缺少引号,使用$*代替"$@",无用地使用eval,未报告退出状态(您没有使用exec第一个),缺少-/ --,错误消息不在stderr上,错误状态的退出状态为0 ,对于LIN_BASH使用/ bin / sh,无用的分号(化妆品),对于非env变量则使用所有大写字母。uname -s就像uname(uname是Unix名称)。您忘了提到跳过是由的-x选项触发的perl
斯特凡Chazelas

4

注意:@ jw013在以下注释中提出了以下不受支持的反对意见:

不好的理由是因为自修改代码通常被认为是不好的做法。在过去的小型汇编程序中,这是减少条件分支和提高性能的一种聪明方法,但是如今,安全风险胜过了优点。如果运行脚本的用户对该脚本没有写权限,则您的方法将行不通。

我通过指出回答了他的安全反对任何特殊权限只需要一次,每次安装/更新行动,以安装/更新自安装的,我会亲自打电话很安全-脚本。我还指出他man sh提到了通过类似手段实现类似目标的情况。当时,我没有费心指出,无论安全缺陷或其他通常不建议的做法可能会或可能不会出现在我的答案中,它们更可能植根于问题本身而不是我的答案:

如何设置shebang,以便将脚本作为/path/to/script.sh运行始终使用PATH中可用的Zsh?

不满意,@ jw013继续反对,至少提出了两个错误的陈述,以进一步推论其尚不被支持的论点:

您使用一个文件,而不是两个文件。的[ man sh引用] 封装具有一个文件修改另一个文件。您有一个修改自身的文件。这两种情况之间有明显的区别。接受输入并产生输出的文件就可以了。可执行文件在运行时会自行更改通常是一个坏主意。您所指向的示例没有这样做。

首先:

THE ONLY EXECUTABLE代码将任何EXECUTABLE shell脚本的#!本身

(尽管甚至#!没有正式指定

{   cat >|./file 
    chmod +x ./file 
    ./file
} <<-\FILE
    #!/usr/bin/sh
    {   ${l=lsof -p} $$
        echo "$l \$$" | sh
    } | grep \
        "COMMAND\|^..*sh\| [0-9]*[wru] "
#END
FILE

##OUTPUT

COMMAND  PID     USER   FD   TYPE DEVICE SIZE/OFF     NODE NAME
file    8900 mikeserv  txt    REG   0,33   774976  2148676 /usr/bin/bash
file    8900 mikeserv  mem    REG   0,30           2148676 /usr/bin/bash (path dev=0,33)
file    8900 mikeserv    0r   REG   0,35      108 15496912 /tmp/zshUTTARQ (deleted)
file    8900 mikeserv    1u   CHR  136,2      0t0        5 /dev/pts/2
file    8900 mikeserv    2u   CHR  136,2      0t0        5 /dev/pts/2
file    8900 mikeserv  255r   REG   0,33      108  2134129 /home/mikeserv/file
COMMAND  PID     USER   FD   TYPE DEVICE SIZE/OFF     NODE NAME
sh      8906 mikeserv  txt    REG   0,33   774976  2148676 /usr/bin/bash
sh      8906 mikeserv  mem    REG   0,30           2148676 /usr/bin/bash (path dev=0,33)
sh      8906 mikeserv    0r  FIFO    0,8      0t0 15500515 pipe
sh      8906 mikeserv    1w  FIFO    0,8      0t0 15500514 pipe
sh      8906 mikeserv    2u   CHR  136,2      0t0        5 /dev/pts/2

{    sed -i \
         '1c#!/home/mikeserv/file' ./file 
     ./file 
     sh -c './file ; echo'
     grep '#!' ./file
}

##OUTPUT
zsh: too many levels of symbolic links: ./file
sh: ./file: /home/mikeserv/file: bad interpreter: Too many levels of symbolic links

#!/home/mikeserv/file

Shell脚本只是一个文本文件-为了使其完全起作用,必须由另一个可执行文件读取该脚本,然后由另一个可执行文件解释其指令,最后由另一个可执行文件执行对脚本的解释。外壳脚本。这是不可能的shell脚本文件的执行,以涉及少于两个文件。zsh自己的编译器中可能存在例外,但是由于这一点,我的经验很少,在这里没有任何代表。

Shell脚本的哈希值必须指向其预期的解释器,否则将被丢弃。

标准定义了外壳的令牌识别/执行行为

Shell具有解析和解释其输入的两种基本模式:其当前输入定义a <<here_document或定义a- { ( command |&&|| list ) ; } &换句话说,Shell要么将令牌解释为命令的定界符,则应在读取命令后执行或作为创建文件并将其映射到另一个命令的文件描述符的说明。而已。

在解释要执行的命令时,shell会在一组保留字上定界标记当Shell遇到打开令牌时,它必须继续读取命令列表,直到该列表由关闭令牌(例如,换行符)(在适用时)或关闭令牌(例如,})用于({执行之前)定界。

Shell区分简单命令复合命令。化合物的命令是一组必须在执行之前被读取的命令,而所述壳不执行$expansion任何其组成的简单的指令,直到它单独地执行每一个。

因此,在下面的示例中,;semicolon 保留字界定了单个简单命令,而非转义\newline字符界定了两个复合命令之间的界限

{   cat >|./file
    chmod +x ./file
    ./file
} <<-\FILE
        #!/usr/bin/sh
        echo "simple command ${sc=1}" ;\
                : > $0 ;\
                echo "simple command $((sc+2))" ;\
                sh -c "./file && echo hooray"
        sh -c "./file && echo hooray"
#END
FILE

##OUTPUT

simple command 1
simple command 3
hooray

那是准则的简化。当您考虑使用shell-builtins,子shell,当前环境等时,它会变得更加复杂,但是就我在这里的目的而言,这已经足够了。

和发言的内置插件命令列表,一个function() { declaration ; }仅仅是分配的一种手段复合命令到一个简单的命令。Shell不能$expansions对声明语句本身执行任何操作(包括)<<redirections>,而必须将定义存储为单个文字字符串,并在调用时作为特殊的内置Shell执行。

因此,在可执行Shell脚本中声明的Shell函数将以其原义字符串形式存储在解释Shell的内存中(未扩展为包含附加的此处文档作为输入),并且在每次称为Shell build-时都独立于其源文件执行。只要shell的当前环境持续存在就可以。

一个<<HERE-DOCUMENT是内联文件

重定向操作符<<<<-两者都允许将外壳程序输入文件(称为here-document)中包含的行重定向到命令的输入。

这里,文件将被视为后的下一个开头一个字\newline,并继续,直到有一个只包含一个行分隔符\newline,没有[:blank:]在s之间。然后,下一个此处文档开始(如果有)。格式如下:

[n]<<word
    here-document 
delimiter

...其中可选n代表文件描述符号。如果省略该数字,则本文参考标准输入(文件描述符0)。

for shell in dash zsh bash sh ; do sudo $shell -c '
        {   readlink /proc/self/fd/3
            cat <&3
        } 3<<-FILE
            $0

        FILE
' ; done

#OUTPUT

pipe:[16582351]
dash

/tmp/zshqs0lKX (deleted)
zsh

/tmp/sh-thd-955082504 (deleted)
bash

/tmp/sh-thd-955082612 (deleted)
sh

你看?对于上面的每个shell,shell都会创建一个文件并将其映射到文件描述符。在zsh, (ba)shshell中/tmp,在中创建一个常规文件,转储输出,将其映射到描述符,然后删除该/tmp文件,这样就剩下了描述符的内核副本。dash避免了所有这些废话,只需将其输出处理放到|pipe针对重定向<<目标的匿名文件中即可。

这使得dash

cmd <<HEREDOC
    $(cmd)
HEREDOC

在功能上等同于bash

cmd <(cmd)

dash的实现至少是POSIXly可移植的。

这使得几个 FILES

所以在下面的答案中,当我这样做时:

{    cat >|./file
     chmod +x ./file
     ./file
} <<\FILE
#!/usr/bin/sh
_fn() { printf '#!' ; command -v zsh ; cat 
} <<SCRIPT >$0
    [SCRIPT BODY]
SCRIPT    

_fn ; exec $0
FILE

发生以下情况:

  1. 首先cat,我将为shell创建的任何文件的内容FILE放入其中./file,使其可执行,然后执行它。

  2. 内核使用分配给的文件描述符解释#!和调用。/usr/bin/sh<read ./file

  3. sh将字符串映射到内存中,该内存由开始于并结束于的复合命令组成。_fn()SCRIPT

  4. _fn被调用时,sh必须先译作然后映射到一个描述符中定义的文件<<SCRIPT...SCRIPT 之前,调用_fn内置的实用工具特别,因为SCRIPT_fn<input.

  5. 输出通过串printfcommand写出到_fn标准出来 >&1 -这被重定向到当前shell的ARGV0-或$0

  6. cat在截断的当前shell的参数或之上连接其<&0 标准输入文件描述符- 。SCRIPT>ARGV0$0

  7. 完成其已经读入的当前复合命令,即可sh exec执行文件和新重写的$0参数。

./file调用时间开始,直到其包含的指令指定应exec再次执行d,sh并在执行它们时一次在单个复合命令中读取它,而./file它本身什么也不做,只是高兴地接受其新内容。实际上正在工作的文件是/usr/bin/sh, /usr/bin/cat, /tmp/sh-something-or-another.

谢谢,之后

因此,当@ jw013指定:

接受输入并产生输出的文件就可以了...

...尽管对这个答案有错误的批评,但他实际上是在不知不觉地宽恕了这里使用的唯一方法,该方法基本上可以解决:

cat <new_file >old_file

回答

这里的所有答案都是好的,但是没有一个是完全正确的。每个人似乎都宣称您无法动态地永久地选择您的路径#!bang。这是建立独立于路径的shebang的演示:

演示

{   cat >|./file
    chmod +x ./file
    ./file
} <<\FILE 
#!/usr/bin/sh
_rewrite_me() { printf '#!' ; command -v zsh
        ${out+cat} ; ${out+:} . /dev/fd/0 >&2
} <<\SCRIPT >|${out-/dev/null}
        printf "
        \$0    :\t$0
        lines :\t$((c=$(wc -l <$0)))
        !bang :\t$(sed 1q "$0")
        shell :\t"$(printf `ps -o args= -p $$`)\\n\\n
        sed -n "1,2{=;p};$((c-1)),\${=;p}" "$0" |
                sed -e 'N;s/\n/ >\t/' -e 4a\\...
SCRIPT
_rewrite_me ; out=$0 _rewrite_me ; exec $0
FILE

输出值

        $0    : ./file
        lines : 13
        !bang : #!/usr/bin/sh
        shell : /usr/bin/sh

1 >     #!/usr/bin/sh
2 >     _rewrite_me() { printf '#!' ; command -v zsh
...
12 >    SCRIPT
13 >    _rewrite_me ; out=$0 _rewrite_me ; exec $0

        $0    : /home/mikeserv/file
        lines : 8
        !bang : #!/usr/bin/zsh
        shell : /usr/bin/zsh

1 >     #!/usr/bin/zsh
2 >             printf "
...
7 >             sed -n "1,2{=;p};$((c-1)),\${=;p}" "$0" |
8 >                     sed -e 'N;s/\n/ >\t/' -e 4a\\...

你看?我们只是使脚本覆盖自身。而且git同步后只会发生一次。从这一点开始,在#!bang行中有了正确的路径。

现在几乎所有这些都只是绒毛。要安全地执行此操作,您需要:

  1. 在顶部定义并在底部调用的函数进行编写。这样,我们将所需的所有内容存储在内存中,并确保在开始写入文件之前先读取整个文件。

  2. 确定路径应采用的某种方式。command -v对此非常好。

  3. Heredocs确实有帮助,因为它们是实际文件。他们将同时存储您的脚本。您也可以使用字符串,但是...

  4. 您必须确保外壳程序在与执行脚本的命令相同的命令列表中读入覆盖脚本的命令。

看:

{   cat >|./file
    chmod +x ./file
    ./file
} <<\FILE 
#!/usr/bin/sh
_rewrite_me() { printf '#!' ; command -v zsh
        ${out+cat} ; ${out+:} . /dev/fd/0 >&2
} <<\SCRIPT >|${out-/dev/null}
        printf "
        \$0    :\t$0
        lines :\t$((c=$(wc -l <$0)))
        !bang :\t$(sed 1q "$0")
        shell :\t"$(printf `ps -o args= -p $$`)\\n\\n
        sed -n "1,2{=;p};$((c-1)),\${=;p}" "$0" |
                sed -e 'N;s/\n/ >\t/' -e 4a\\...
SCRIPT
_rewrite_me ; out=$0 _rewrite_me
exec $0
FILE

注意,我只将exec命令向下移动了一行。现在:

#OUTPUT
        $0    : ./file
        lines : 14
        !bang : #!/usr/bin/sh
        shell : /usr/bin/sh

1 >     #!/usr/bin/sh
2 >     _rewrite_me() { printf '#!' ; command -v zsh
...
13 >    _rewrite_me ; out=$0 _rewrite_me
14 >    exec $0

我没有得到输出的后半部分,因为该脚本无法在下一条命令中读取。不过,因为缺少的唯一命令是最后一个命令:

cat ./file

#!/usr/bin/zsh
        printf "
        \$0    :\t$0
        lines :\t$((c=$(wc -l <$0)))
        !bang :\t$(sed 1q "$0")
        shell :\t"$(printf `ps -o args= -p $$`)\\n\\n
        sed -n "1,2{=;p};$((c-1)),\${=;p}" "$0" |
                sed -e 'N;s/\n/ >\t/' -e 4a\\...

该脚本应有的状态通过了-主要是因为它全部在Heredoc中-但是如果您不正确地计划它,则可以截断您的文件流,这就是我上面遇到的情况。


不好的理由是因为自修改代码通常被认为是不好的做法。在过去的小型汇编程序中,这是减少条件分支和提高性能的一种聪明方法,但是如今,安全风险胜过了优点。如果运行脚本的用户对该脚本没有写权限,则您的方法将行不通。
jw013 2014年

@ jw013显然,如果尝试安装或更新脚本的人没有安装或更新脚本的权限,那么我安装或更新可执行脚本的方法将行不通实际上,这就是使此答案比此处的其他答案更好的原因-它可以在安装过程中根据需要提供准确的#!bang行,并且在首次调用时仅需要任何特殊权限即可这样做再说一次,我不会简单地说出自我修改代码是一种不好的做法 -请参阅以获取矛盾的意见。man command
mikeserv 2014年

请查看man command矛盾的观点 -未找到观点。您能指导我到您正在谈论的特定部分吗?
jw013 2014年

@ jw013-我的错误是在man sh-搜索“命令-v”。我知道那是man在前几天我正在看的一页中。
mikeserv

我假设command -v您正在从中谈论的示例man sh。那是看起来正常的安装程序脚本,而不是自我修改的脚本。甚至独立的安装程序也仅包含预修改输入,并将其修改输出到其他地方。他们不会按照您的建议重写自己。
jw013 2014年

1

这是一种具有自我修改脚本的方法,该脚本可以解决该问题。该代码应放在您的实际脚本之前。

#!/bin/sh
# unpatched

PATH=`PATH=/bin:/usr/bin:$PATH getconf PATH`
if [ "`awk 'NR==2 {print $2;exit;}' $0`" = unpatched ]; then
  [ -z "`PATH=\`getconf PATH\`:/usr/local/bin:/some/long/path/to/the/right:$PATH command -v zsh`" ] && { echo "zsh not found"; exit 1; }
  cp -- "$0" "$0.org" || exit 1
  mv -- "$0" "$0.old" || exit 1
  (
    echo "#!`PATH=\`getconf PATH\`:$PATH command -v zsh`" 
    sed -n '/^##/,$p' $0.old
  ) > $0 || exit
  chmod +x $0
  rm $0.old
  sync
  exit
fi
## Original script starts here

一些评论:

  • 它应由有权在脚本所在目录中创建和删除文件的人运行一次。

  • 它仅使用传统的bourne shell语法,因为尽管人们普遍认为/bin/sh,甚至不能保证它是POSIX shell,甚至不能兼容POSIX操作系统。

  • 它将PATH设置为POSIX兼容的PATH,然后将其列出可能的zsh位置,以避免选择“伪” zsh。

  • 如果由于某种原因,不建议使用自修改脚本,则分发两个脚本而不是一个脚本将是微不足道的,第一个是您要修补的脚本,第二个是我建议稍作修改以处理前一个脚本的脚本。


/bin/sh点是一个很好的-但在这种情况下,你需要一个预改性#!呢?而且不awk只是因为很可能是zsh是什么?
mikeserv 2014年

@mikeserv答案已更新以调用POSIX awk。预先修改的shebang可以防止脚本被非bourne兼容的shell解释,如果它是您的登录shell。
jlliagre 2014年

说得通。我对其进行了投票,因为它可以正常工作,并且坚持使用这本书,并且表明您对可能的外壳环境/文件处理(尤其是您使用的备份文件)有很好的理解,而这正是GNU sed -i所做的。我个人认为,$PATH在另一个答案的注释中指出的问题,您可以安全地解决这一问题,就像我可以在此处几行指出的那样,可以通过简单明确地定义依赖项和/或严格而明确的测试来更好地解决该问题-例如,现在getconf可能是假,但机会却接近零,同他们比zshawk.
mikeserv

@mikeserv,对脚本进行了修改,以减少调用伪造的getconf的风险。
jlliagre 2014年

$(getconf PATH)不是伯恩。cp $0 $0.old是zsh语法。伯恩相当于是cp "$0" "$0.old"虽然你想要cp -- "$0" "$0.old"
斯特凡Chazelas
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.