本文是用来记录我发现的一些有用的小技巧,这是关于POSIX(以及其他不符规范)的shell脚本技巧的paper。我始终坚持认为Bourne系并不是一个好的编程语言,这点与Perl大致相同,个人对sh语言不太苟同,此处不再多说。因此,你在本文不会看见我花太多词语去描述特定的Bash、ksh之类主流的shell语言。

POSIX shell技巧分享篇-RadeBit瑞安全

打印变量的值

printf %s\n "$var"

如果没有换行的话,\n其实是可以省略的,但是引号必不可少。下面这样就不行:

echo "$var"

大家记住,不用要像上面这样使用echo喔。根据POSIX的规定,echo在参数中如果含有“”,或者其第一个参数是“-n”时,其需要有指定的行为。Unix标准为了实现XSI-conformant,采用了一个很不好的方法(“”是c样式的转义),而其他流行shell语言的如Bash的参数解析方案,并没采用“-n”作为特殊选项(即使是在POSIX兼容模式),这点表现的十分突兀,请看下面:

POSIX shell技巧分享篇-RadeBit瑞安全

上表的内容意味着echo "$var",能在你没有特别指定var变量里的内容时把你搞死,比如里面是一个非负整数。即使你是一个GNU/Linux-centric里,整天呼吁Bash大法好,完全不关心可移植性的人,有朝一日当你遇到“-n”或者“-e”甚至是“-neEenenEene”时,你的脚本也会遇到麻烦。

但是如果你真的钟情于echo,想要在你的脚本中使用它,下面这个函数能够让echo以合理方式运行(这点很像Bash的echo命令,但是随着约定的规则,最后一个参数不会被当成一个选项,所以echo "$var"即使在var里面的内容看起来像个选项时也很安全。):

echo () (
fmt=%s end=\n IFS=" "
 
while [ $# -gt 1 ] ; do
case "$1" in
[!-]*|-*[!ne]*) break ;;
*ne*|*en*) fmt=%b end= ;;
*n*) end= ;;
*e*) fmt=%;;
esac
shift
done
 
printf "$fmt$end" "$*"
)

放弃最开始那段脚本,或许能解决掉echo命令带来的那些烦恼。但是如果你认为echo拥有选项时非常可笑的话,可以试试在这里用“$*”来代替“$@”。

echo () { printf %s\n "$*" ; }

以前想过打印一个简单的变量,会变得如此艰难是么?那你现在该明白,我为什么说Bourne系的语言不应该用于正式编程了吧。

逐行读取输入

IFS= read -r var

上面这条命令读取了一行的输入,由换行符、文件结束符以及错误条件而终止,从stdin里取得数据然后把结果存储在var里。如果读取到了新的一行,退出状态会是0(成功)。如果读取错误或者遇到了文件结束符,则不会是0(失败)。某些功能要求较高的健壮型脚本,或许会要求区分清楚这些情况。

据我对于POSIX理解所得,var里面的内容应该会由读取的数据进行填充,即使出现了错误或出现了文件终止符。但我并不确定所有的实现方法会如此实现,以及这是否严格符合标准。这里,鄙人非常欢迎高手的指教。

然而,有个常见的陷阱,那就是可能有人会试图从管道中读取命令:

foo | IFS= read var

POSIX允许任何管道中的命令在子shell(subshell)运行,而在主shell里运行的命令可能在实现上会千差万别,特别是Bash和ksh。下面的东西可能会帮助你克服这个问题:

IFS= read var << EOF$(foo)EOF 
逐字节读取输入
read dummy oct << EOF
$(dd bs=1 count=1|od -b)
EOF

这个命令会让变量oct里,按八进制(一个字节)进行输入。注意,dd是仅有的标准命令,它可以安全准确读取地、逐字节读取输入的内容,保证没有字节溢出和丢失。除了不可以移植外,head -c 1可以用C 的stdio函数进行缓存实现。

因为读取命令处理的是文本文件,一些转义格式的转换(比如这里的八进制)是非常有必要的。事实上,它不能处理全部字节,特别是没法将一个空字节存储在shell变量里。而其他非ASCII字节的问题,可能取决于你实现的方法和语言环境。你可以修改代码逐一读取字节,但记得注意如空字节等异常的出现。

将八进制转为二进制数据,可以通过下一个sh技巧来完成。

向stdout写数字型字节

writebytes () { printf %`printf \\%03o "$@"` ; }
writebytes 65 66 67 10

该函数允许八、十、十六进制的值,八进制和十六进制的值必须分别带上0或者0x的前缀。如果你想要将你的参数被当作八进制,比如在用前面的小技巧读二进制数据流并处理值时,可以试试下面这个:

writeoct () { printf %`printf \\%s "$@"` ; }

注意如果你的八进制的值大于三位,它会崩掉,所以不要在前面加0。下面的版本的实现,要慢得多,但至少能够避免这个问题:

writeoct2 () { printf %b $(printf \%03o $(printf 0%s  "$@")) ; }

使用xargs搭配find

GNU死粉们一般喜欢用-print0或者-0选项来进行find和xargs,分别获取强效的结果。没了GNU扩展,find的输出是单行的,这意味着如果在换行符后存在部分路径名字符,我们是无法恢复实际的路径名的。

如果你不介意你的脚本会受到换行符后包含的路径名字符的影响,至少要确保这不会引起提升权限的漏洞,接下来看看下面这个:

find ... | sed 's/./\&/g' | xargs command

这里的sed命令是强制性的,与大多数人的看法(也是我曾经的看法)不同,xargs不会接受换行符限定的列表,而只会接受shell编码的列表,比如输入被空格所分割,那么所有空格必须被编码。上面的命令只是简单用地编码了所有带有反斜杠的字符来满足这个需求,以此来保护文件名里的空格不会丢失。

在find命令使用+

当然,我们还有更聪明的办法来使用find给文件传递命令,带上-exec,用“+”替换“;”:

find path -exec command '{}' +

这里的find首先将尽可能多的文件名,把“{}”替换掉,每一个都作为自己的参数。这样写的话,不会有上面出现的换行符问题。然而遗憾的是,尽管它在POSIX里存在了许久,而最常用的GNU里,find很长一段时间里并不支持“+”,所以它在实际使用中并不是很方便。合理的解决办法是编写一个针对“+”的支持,在其中用“;”代替“+”,这可以用来修补不支持这类find的系统。

Find是一个在任何符合POSIX标准的系统中,都必须能成功运行的命令,但是你会发现在“;”的参数丢失后,会失去对“+”的支持:

find /dev/null -exec true '{}' +

这利用了“/dev/null”是仅有的,由POSIX规定存在的三个对路径之一。

find -print0易用版

find path -exec printf %s\0 '{}' +

然而直到最近版本还是缺少“;”的支持,如果需要的话还是用“;”代替“+”好些。

注意,这个法子不一定有用,因为输出的不是文本文件,只有GUN的xargs应该能对它进行解析。

强撸用find –print

尽管find有着诸多问题,它仍然可以有着对输出的强力解析。每个搜索到的绝对路径,用“/.”前缀替换了“/”,而类似的搜索“/./”替换成“././”,这用来甄别是否产生了换行符作分隔符,或者将嵌入了路径名。

从命令替换得到无懈可击的输出

下面的代码并不安全:

var=$(dirname "$f")

由于在大多数命令输出时的末尾,它们都会加上换行符。Bourne系命令可以替换,但不仅仅替换一条输出中的换行符。在上面这条命令中,如果f包含了后面目录名的的换行符,里面的换行符会被替换掉,从而产生一个与预计不同的目录名称。虽然这种目录名里写换行符的情况非常扯淡,但这种地方是很可能被黑客利用从而进行攻击的。

这个问题的解决办法很简单,在最后一个换行符后加上一个安全字符,然后用shell的参数替换机制来去掉那个安全字符。

var=$(command ; echo x) ; var=${var%?}

在目录名命令的案例里,去掉目录名添加的单换行符,如:

var=$(dirname "$f" ; echo x) ; var=${var%??}

当然还有个更简单的办法来获得路径的目录部分,如果你不关心这些特别的案例:

var=${f%/*}

当然这对root目录的文件会无效,在其他情况下,为这种特殊情况写一个专门的shell函数是个比较好的办法。但请你注意,这样的函数必须将结果存储到一个变量里。如果直接将他们从stdout里打印出来,常见做法是写shell函数来处理字符串,我们会遇到如“$(…)”之类的情况,然后进行换行符替换后再回归处理。

从shell函数里返回字符串

我们可以看出,在上面命令替换的问题里,stdout并不是一个让shell函数返回字符串给调用口的好途径,除非不涉及到换行符。当然,这样的做法不适用于那些旨在处理任意字符串的函数。那么,我们能干些什么呢?

比如:

func () {
body here
eval "$1=${foo}"
}

当然${foo}可以替换成任何形式的替代品,关键点在于eval那行,以及转义的使用。当主命令解析器创建了eval的参数时,“$1”被扩展了。但是“${foo}”在这里没有扩展,因为“$”被编码了。然而,当eval执行参数时它会扩展。如果你不太清楚为什么这点重要,可以思考下下面会带来什么坏处:

foo='hello ; rm -rf /'
dest=bar
eval "$dest=$foo"

但下面的版本就是安全的:

foo='hello ; rm -rf /'
dest=bar
eval "$dest=$foo"

注意在原案例中,“$1”被用于调用口将目标变量名当作函数参数进行传递。如果你的函数需要使用shift命令,比如将剩下的参数写作“$@”,这样将“$1”的值保存在函数起始的一个临时变量里可能会起作用。

Shell编码的任意字符串

有时候有必要把字符串弄成shell编码的形式,比如需要扩展进一个用eval执行的命令,或者写入一个生成的脚本等等。这儿有许多方法,但是有不少会由于字符串包含换行符而失败。下面是一个可以用的版本:

quote () { printf %s\n "$1" | sed "s/'/'\\''/g;1s/^/'/;$s/$/'/" ; }

这个函数仅仅替换每个实例种的«’»(单引号),变成«’’’»,然后把单引号放在字符串的起始和末尾。因为只有一个单引号字符具有特殊意义,这样是很安全的。正确处理换行符后,末尾的单引号双打为一个安全的符号,避免换行符带来的命令替换攻击,比如:

quoted=$(quote "$var")

用上数组

与增强版的Bourne系shell(比如Bash)不同,POSIX的shell没有数组类型。然而,牺牲一点效率后,你可以得到类数组的东西。下面的做法可以让你得到一个数组(只有一个),位置参数“$1”、“$2”等等,你可以通过这个数组交互内容。替换“$@”数组里的内容很简单:

set -- foo bar baz boo

或者更实用些:

set -- *

但是我们并不清楚“$@”里当前的内容,所以你可以在替换后将其取回,还有就是如何用编程方式生成这些所谓的“数组”。试试这个基于前面技巧的编码函数:

save () {
for i do printf %s\n "$i" | sed "s/'/'\\''/g;1s/^/'/;$s/$/' \\/" ; done
echo " "
}

用法可以像这样:

myarray=$(save "$@")
set -- foo bar baz boo
eval "set -- $myarray"

在这里,编码为eval命令的使用预备了数组,恢复位置参数。其他如myarray=$(save *)的形式也是可能的,以及编程生成的数组变量的值。

我们可以从find命令的输出里,生成一个数组变量。要么使用一个用-exec选项巧妙构造的命令,或者忽略路径名中存在换行符的可能性,并为find的xargs结果使用sed命令:

findarray () {
find "$@" -exec sh -"for i do printf %s\\n "$i" \
| sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/"
done" dummy '{}' +
}

比如下面的脚本:

old=$(save "$@")
eval "set -- $(findarray path)"
for i do command "$i" ; done
eval "set -- $old"

注意,这里采用了经常出现的错误形式,如“for i in `find …` ; do …”。

字符串是否匹配文件名(glob)的形式

fnmatch () { case "$2" in $1) return 0 ;; *) return 1 ;; esac ; }

现在你可以这样做:

if fnmatch 'a??*' "$var" ; then ... ; fi

突然感觉特别需要Bash中的“[[”命令。

统计字符出现的次数

tr -dc 'a' | wc -c

这里统计了字符a出现的次数,删除了其他字符。然而,在输入里存在非字符二进制数据时,tr –dc是不太管用的,POSIX在这点上与其他实现机制不同。我们可以这样:

tr a\n \na | wc -l

wc –l命令会读取到所有换行符的出现次数,所以用“a”去替换所以的换行符,最后用tr去统计“a”出现的次数即可。

覆盖locale categories

下面这样不会生效的:

LC_COLLATE=C ls

这是因为LC_ALL会在环境里出现,覆盖掉任何特定category变量。没有设置的LC_ALL还会有错误的行为,这样它可能会改变所有的category。比如:

eval export `locale` ; unset LC_ALL

这个命令根据他们收到的隐式变量,无论是来自语言、分类变量本身以及LC_ALL,显式地设置了所有特定的category变量,你的脚本或许会像上面那个脚本命令一样,覆盖个别category:

记住,可以用来设置locale变量的只有C脚本(或者POSIX)。

像在【a-z】范围的glob模式和正则表达式,是基于ascll代码点排序,而不是自然排序或者非兼容ascll字符集(比如EBCDIC)排序。这也适用于对tr命令字符范围(LC_COLLATE)。
基于ascll代码点排序的(LC_COLLATE)
“I”或者“i”的条件映射是合理的(LC_CTYPE)。
日期由Unix传统方式打印(LC_TIME)。

如果存在一些你不能假定的东西,或者C的locale会比现有的locale产生更坏影响的:

在易用的字符集(ascll)之外的二进制数据,不一定是字符,因为他们可能是非字符字节,将其视作ISO Latin-1,视作某种抽象的没有属性的字符集,或者甚至视作UTF-8字符,这影响到他们是否能在glob和正则表达式进行匹配(LC_CTYPE)。
如果LC_CTYPE改变,其他单独category的数据取决于字符编码,比如LC_TIME 月份名、LC_MESSAGES 字符串、LC_COLLATE 排序的元素等等,它们有着未定义的行为(LC_CTYPE)。
如果LC_COLLATE设为C且在正则范围内出现非accll字符的话,目前尚不清楚POSIX怎样,但GNU的C库里的正则表达式引擎会历史性崩溃。

因此,用C替换个别类别如 LC_COLLATE或LC_TIME,来获取可预测的输出是安全的,但是替换LC_CTYPE是不安全的,除非你替换了LC_ALL。替换LC_CTYPE可能在是在特定场合来抑制奇怪而危险的条件映射,但是最坏的情况下完全可以阻止所有针对包含非ascll字符的文件名的访问。现在,应该没有一个特别好的解决方案。

去掉所有exports

unexport_all () {
eval set -- `export -p`
for i do case "$i" in
*=*) unset ${i%%=*} ; eval "${i%%=*}=${i#*=}" ;;
esac ; done
}

使用glob匹配所有“点文件”

.[!.]* ..?*

上面两种glob匹配,第一张会在所有文件中以“.”开始匹配,紧随其后的是一个除了“.”以外任何字符。第二个匹配的是两个“.”及另外一个非“.”的字符。在这两者之间,他们匹配了以“..”和“.”开头的所有文件名,有着自己的特殊含义。

记住,如果一个glob不匹配任何文件名,它作为一个单独存在并未从命令里消失,你可能需要通过测试,比如匹配或者忽略隐藏错误。

检测目录是否为空

is_empty () (
cd "$1"
set -- .[!.]* ; test -"$1" && return 1
set -- ..?* ; test -"$1" && return 1
set -- * ; test -"$1" && return 1
return 0 )

这段代码使用了三段glob,匹配了除“.”或“..”的情况,还处理了逐字匹配glob字符串的文字名。

如果你不在乎权限保护,下面有一个更简单的实现方法:

is_empty () { rmdir "$1" && mkdir "$1" ; }

如果其他用户对该目录有写权限,或者其他进程可以对其进行变更的话,这两种办法都有条件竞争,因此像后者这样有着适当限制umask的方法,实际上更为可取,它的结果拥有正确的原属性。

is_empty_2 () ( umask 077 ; rmdir "$1" && mkdir "$1" )

查询某特定用户的home目录

这样是不行的:

foo=~$user

试试这样:

eval "foo=~$user"

保证用户变量里的内容是安全的,否则会有糟糕的事情发生,写成一个函数是一个不错的选择:

her_homedir () { eval "$1=~$2" ; }
her_homedir foo alice

这样变量foo会包含波浪扩展~alice。

不用find进行递归目录处理

因为find在强撸模式是困难甚至是不可能的,为啥不写递归脚本来代替呢。不幸的是,我还没做出来不需要在每级目录树嵌套一个子shell的法子,但这里有一个使用子shell的点:

myfind () (
cd --- "$1"
[ $# -lt 3 ] || [ "$PWD" = "$3" ] || exit 1
for i in ..?* .[!.]* * ; do
[ -"$i" ] && eval "$2 "$i""
[ -"$i" ] && myfind "$i" "$2" "${PWD%/}/$i"
done
)

用法如下:

handler () { case "$1" in *~) [ -"$1" ] && rm -"$1" ;; esac ; }
myfind /tmp handler   # Remove all backup files found in /tmp

在递归遍历“$1”的每个文件里,会用一个函数或者命令“$2”将文件包含在当前工作目录里,将文件名加入命令行的末尾。第三个位置参数“$3”,内部递归使用,防止符号链接的遍历归;它包含了预期的物理路径,PWD应该包含在cd -P "$1"命令后,保证“$1”不是一个符号链接。

Epoch后的“秒”

不幸的是,GNU日期的%s格式并不能移植,所以我们可以这样做:

secs=`date +%s

试试这样:

secs=$((`TZ=GMT0 date 
+"((%Y-1600)*365+(%Y-1600)/4-(%Y-1600)/100+(%Y-1600)/400+%j-135140)
*86400+%H*3600+%M*60+%S"`))

这里有段数字是135140,这是1600-01-01和1970-01-01(农历)之间相差的天数。使用1600而不是2000,来作为基准年,这是因为C系列的除法对于负数不太敏感。

后记

预计以后我还会添加更多的东西,我希望这些东西会让大家撰写正确的,健壮型的POSIX的shell脚本,尽管存在一些坑,会让编写反常和低效。如果上面的一些hack想法激发了某些人使用这样一个真正的语言,而不是sh、Bash之类的话,或者有人修复了shell语言所带来的坑,我会非常开心的。