路径独立的 shebang

路径独立的 shebang

我有一个脚本,我希望能够在两台机器上运行。这两台机器从同一个 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

答案1

你不能直接通过 shebang 解决这个问题,因为 shebang 是纯粹静态的。您可以做的是在 shebang 中添加一些“最不常见的乘数”(从 shell 角度来看),并使用正确的 shell 重新执行您的脚本(如果该 LCM 不是 zsh)。换句话说:让您的脚本由在所有系统上找到的 shell 执行,测试zsh仅 - 功能,如果测试结果为 false,则让脚本exec带有zsh,测试将成功,您只需继续。

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/shBourne 或 POSIX 但我们的语法与两者兼容)执行。如果$ZSH_VERSION不是设置,脚本exec本身通过zsh。如果$ZSH_VERSION设置了(或者脚本已经运行过zsh),则简单地跳过测试。瞧。

zsh仅当根本不存在时,此操作才会失败$PATH

编辑:为了确保您只execzsh平常的地方,您可以使用类似的东西

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

这可以避免你意外地exec得到一些$PATH不是zsh你所期望的东西。

答案2

多年来,我一直使用类似的方法来处理需要运行脚本的系统上的 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

答案3

注意:@jw013 进行以下操作不支持的反对意见见以下评论:

否决是因为自修改代码通常被认为是不好的做法。在过去的小型汇编程序时代,这是减少条件分支和提高性能的聪明方法,但现在安全风险超过了优点。如果运行脚本的用户没有脚本的写入权限,您的方法将不起作用。

我回答了他的安全反对意见,指出任何特殊权限是只需要一次安装更新采取行动,以便安装更新自安装脚本 - 我个人认为它非常安全。我还向他指出了一个man sh指通过相似的手段实现相似的目标。当时我并没有费心去指出无论什么安全缺陷或其他方面通常不建议的做法这些可能会或可能不会出现在我的答案中,但它们更有可能植根于问题本身,而不是我的答案:

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

@jw013 不满意,继续反对,进一步推进他的尚未支持论证至少有几个错误的陈述:

您使用单个文件,而不是两个文件。这[man sh参考] 包中有一个文件修改另一个文件。您有一个正在修改自身的文件。这两种情况有明显的区别。接受输入并产生输出的文件就可以了。可执行文件在运行时会自行更改通常是一个坏主意。你提到的例子并没有做到这一点。

首先:

唯一的可执行文件任意代码可执行文件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 脚本的。这是不可能用于执行 shell 脚本文件涉及少于两个文件。自己的编译器可能有一个例外zsh,但我对此没有什么经验,并且这里没有以任何方式表示。

shell 脚本的 hashbang必须指出其意图口译员或因无关而被丢弃。

贝壳的令牌识别/执行行为是由标准定义的

shell 有两种解析和解释其输入的基本模式:要么其当前输入正在定义 a <<here_document,要么它正在定义 a { ( command |&&|| list ) ; } &- 换句话说,shell 要么解释 a代币作为命令的分隔符,一旦读入命令,它就应该执行;或者作为创建文件并将其映射到另一个命令的文件描述符的指令。就是这样。

当解释命令来执行 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 内置命令、子 shell、当前环境等等,但是,对于我在这里的目的来说,这已经足够了。

说到内置的命令列表,afunction() { declaration ; }只是分配 a 的一种方式复合命令到一个简单的命令。shell 不得$expansions对声明语句本身执行任何操作(包括<<redirections>),而是必须将定义存储为单个文字字符串,并在调用时将其作为内置的特殊 shell 执行。

因此,在可执行 shell 脚本中声明的 shell 函数以其文字字符串形式存储在解释 shell 的内存中 - 未展开以包含附加的此处文档作为输入 - 并在每次作为内置 shell 调用时独立于其源文件执行 -只要 shell 的当前环境持续存在。

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

重定向运算符<<<<-都允许重定向 shell 输入文件中包含的行,称为此处文档,到命令的输入。

此处文档应被视为单个单词,从下一个单词开始\newline,一直持续到有一行仅包含分隔符和 a ,中间\newline没有s。[:blank:]然后下一个此处文档开始,如果有的话。格式如下:

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

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

这使得一些文件

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

{    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. 我首先将catshell 创建的任何文件的内容FILE放入./file,使其可执行,然后执行它。

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

  3. sh将字符串映射到内存中,其中包含复合命令开始于_fn()并结束于SCRIPT

  4. _fn调用时,sh必须首先解释然后映射到文件中定义的描述符<<SCRIPT...SCRIPT _fn作为特殊的内置实用程序调用,因为SCRIPTis_fn<input.

  5. printf和输出的字符串command被写到_fns标准输出 >&1- 重定向到当前 shell 的ARGV0- 或$0.

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

  7. 完成已读入的电流复合命令, sh execs 可执行文件和新重写的$0参数。

从调用时间./file到其包含的指令指定应exec再次调用它,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. 您必须确保 shell 读取的命令会在与执行脚本的命令列表相同的命令列表中覆盖您的脚本。

看:

{   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中 - 但如果你没有正确计划,你可以截断你的文件流,这就是我上面发生的事情。

答案4

这是一种使用自修改脚本来修复其 shebang 的方法。该代码应添加到您的实际脚本之前。

#!/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 标准的路径,后跟一系列可能的 zsh 位置,以避免选择“虚假”zsh。

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

相关内容