perlfilter - 源代码过滤器
本文介绍了 Perl 的一个鲜为人知的特性,称为源代码过滤器。源代码过滤器在 Perl 看到模块的程序文本之前对其进行修改,就像 C 预处理器在编译器看到 C 程序的源文本之前对其进行修改一样。本文将详细介绍源代码过滤器是什么,它们是如何工作的以及如何编写自己的过滤器。
源代码过滤器的最初目的是允许您加密程序源代码以防止随意阅读。正如您很快就会了解到的,它们的功能远不止于此。但首先,让我们了解一些基本知识。
在 Perl 解释器执行 Perl 脚本之前,它必须先从文件中读取脚本到内存中进行解析和编译。如果该脚本本身使用 use
或 require
语句包含了其他脚本,那么每个脚本都必须从各自的文件中读取。
现在,将 Perl 解析器与单个文件之间的每个逻辑连接视为一个源流。当 Perl 解析器打开一个文件时,就会创建一个源流,它会随着源代码被读入内存而继续存在,并在 Perl 完成解析文件后被销毁。如果解析器在源流中遇到 require
或 use
语句,就会为该文件创建一个新的独立流。
下图表示单个源流,左侧是 Perl 脚本文件中的源代码流入右侧的 Perl 解析器。这是 Perl 通常的工作方式。
file -------> parser
有两点需要注意
虽然在任何给定时间可能存在任意数量的源流,但只有一个是活动的。
每个源流只与一个文件相关联。
源过滤器是一种特殊的 Perl 模块,它在源流到达解析器之前拦截并修改源流。源过滤器会改变我们的图表,如下所示
file ----> filter ----> parser
如果这不太清楚,可以考虑命令管道的类比。假设你有一个存储在压缩文件 trial.gz 中的 shell 脚本。下面的简单管道命令运行脚本,而无需创建临时文件来保存解压缩的文件。
gunzip -c trial.gz | sh
在这种情况下,管道中的数据流可以表示如下
trial.gz ----> gunzip ----> sh
使用源过滤器,你可以将脚本的文本存储为压缩形式,并使用源过滤器为 Perl 的解析器解压缩它
compressed gunzip
Perl program ---> source filter ---> parser
那么如何在 Perl 脚本中使用源过滤器呢?上面我提到源过滤器只是一种特殊的模块。与所有 Perl 模块一样,源过滤器使用 use
语句调用。
假设你希望在执行之前将 Perl 源代码传递给 C 预处理器。碰巧的是,源过滤器发行版附带了一个名为 Filter::cpp 的 C 预处理器过滤器模块。
下面是一个示例程序 cpp_test
,它使用了这个过滤器。为了便于引用特定行,已添加行号。
1: use Filter::cpp;
2: #define TRUE 1
3: $a = TRUE;
4: print "a = $a\n";
当你执行这个脚本时,Perl 会为该文件创建一个源流。在解析器处理文件中的任何行之前,源流看起来像这样
cpp_test ---------> parser
第 1 行,use Filter::cpp
,包含并安装了 cpp
过滤器模块。所有源过滤器都以这种方式工作。use 语句在编译时编译并执行,在读取文件中的任何其他内容之前,它将 cpp 过滤器附加到幕后的源流。现在数据流如下所示
cpp_test ----> cpp filter ----> parser
当解析器从源流中读取第二行及后续行时,它会在处理这些行之前将这些行通过 cpp
源过滤器。cpp
过滤器只是将每一行通过真正的 C 预处理器。然后,过滤器将来自 C 预处理器的输出插入回源流。
.-> cpp --.
| |
| |
| <-'
cpp_test ----> cpp filter ----> parser
然后解析器看到以下代码
use Filter::cpp;
$a = 1;
print "a = $a\n";
让我们考虑当过滤后的代码使用 use 包含另一个模块时会发生什么
1: use Filter::cpp;
2: #define TRUE 1
3: use Fred;
4: $a = TRUE;
5: print "a = $a\n";
cpp
过滤器不适用于 Fred 模块的文本,而仅适用于使用它的文件(cpp_test
)的文本。尽管第 3 行的 use 语句将通过 cpp 过滤器,但被包含的模块(Fred
)不会。在解析第 3 行之后并且在解析第 4 行之前,源流如下所示
cpp_test ---> cpp filter ---> parser (INACTIVE)
Fred.pm ----> parser
如您所见,已创建了一个新流用于从 Fred.pm
读取源。此流将保持活动状态,直到解析完 Fred.pm
。cpp_test
的源流仍然存在,但处于非活动状态。解析器完成读取 Fred.pm 后,与其关联的源流将被销毁。然后,cpp_test
的源流再次变为活动状态,解析器从 cpp_test
读取第 4 行及后续行。
您可以在单个文件上使用多个源过滤器。同样,您可以在任意数量的文件中重复使用相同的过滤器。
例如,如果您有一个 uuencoded 和压缩的源文件,则可以像这样堆叠一个 uudecode 过滤器和一个解压缩过滤器
use Filter::uudecode; use Filter::uncompress;
M'XL(".H<US4''V9I;F%L')Q;>7/;1I;_>_I3=&E=%:F*I"T?22Q/
M6]9*<IQCO*XFT"0[PL%%'Y+IG?WN^ZYN-$'J.[.JE$,20/?K=_[>
...
处理完第一行后,流程将如下所示
file ---> uudecode ---> uncompress ---> parser
filter filter
数据通过过滤器的顺序与它们在源文件中出现的顺序相同。uudecode 过滤器出现在 uncompress 过滤器之前,因此源文件将在解压缩之前被 uudecode。
有三种方法可以编写您自己的源过滤器。您可以用 C 语言编写它,使用外部程序作为过滤器,或者用 Perl 编写过滤器。我不会详细介绍前两种方法,所以我先把它们排除在外。用 Perl 编写过滤器最方便,因此我会在它上面花费最多的篇幅。
三种可用技术中的第一种是用 C 语言完全编写过滤器。您创建的外部模块直接与 Perl 提供的源过滤器钩子接口。
这种技术的优点是您可以完全控制过滤器的实现。最大的缺点是编写过滤器所需的复杂性增加 - 您不仅需要了解源过滤器钩子,还需要对 Perl 的内部结构有一定的了解。在编写源混淆器时,这是值得付出这种努力的少数情况之一。与源过滤器分发一起提供的 decrypt
过滤器(在 Perl 解析之前对源进行解混淆)是 C 源过滤器的示例(请参阅下面的解密过滤器)。
所有解密过滤器都基于“安全通过模糊”的原则。无论你写得多么好的解密过滤器,你的加密算法多么强大,任何有决心的人都可以检索到原始源代码。原因很简单:为了执行你的程序,Perl 必须解析其源代码。这意味着 Perl 必须拥有解密程序所需的所有信息,这意味着这些信息也对任何能够运行程序的人可用。
也就是说,可以采取一些措施来让潜在的读者难以阅读。最重要的是:用 C 语言编写你的解密过滤器,并将解密模块静态链接到 Perl 二进制文件中。有关让潜在读者难以阅读的更多提示,请参阅源过滤器分发中的 decrypt.pm 文件。
除了用 C 语言编写过滤器之外,你还可以用你选择的语言创建一个独立的可执行文件。独立的可执行文件从标准输入读取,执行必要的处理,并将过滤后的数据写入标准输出。Filter::cpp
是一个作为独立可执行文件实现的源过滤器的示例 - 可执行文件是与你的 C 编译器捆绑在一起的 C 预处理器。
源过滤器分发包含两个模块,可以简化此任务:Filter::exec
和 Filter::sh
。两者都允许你运行任何外部可执行文件。两者都使用协同进程来控制数据流进出外部可执行文件。(有关协同进程的详细信息,请参阅 Stephens, W.R.,“UNIX 环境高级编程”。Addison-Wesley,ISBN 0-210-56317-7,第 441-445 页。)它们之间的区别在于 Filter::exec
直接生成外部命令,而 Filter::sh
生成一个 shell 来执行外部命令。(Unix 使用 Bourne shell;NT 使用 cmd shell。)生成一个 shell 允许你使用 shell 元字符和重定向功能。
以下是一个使用 Filter::sh
的示例脚本
use Filter::sh 'tr XYZ PQR';
$a = 1;
print "XYZ a = $a\n";
脚本执行时你将获得的输出
PQR a = 1
将源过滤器编写为独立的可执行文件可以正常工作,但会产生少许性能损失。例如,如果你执行上面的一个小示例,将创建一个单独的子进程来运行 Unix tr
命令。每次使用过滤器都需要自己的子进程。如果在你的系统上创建子进程很昂贵,你可能需要考虑其他创建源过滤器的选项。
创建自己的源过滤器最简单、最便携的方式是在 Perl 中完全编写它。为了区别于前两种技术,我将它称为 Perl 源过滤器。
为了帮助理解如何编写 Perl 源过滤器,我们需要一个示例来学习。以下是一个完整的源过滤器,它执行 rot13 解码。(Rot13 是一种非常简单的加密方案,用于 Usenet 帖子中隐藏攻击性帖子的内容。它将每个字母向前移动 13 位,因此 A 变为 N,B 变为 O,Z 变为 M。)
package Rot13;
use Filter::Util::Call;
sub import {
my ($type) = @_;
my ($ref) = [];
filter_add(bless $ref);
}
sub filter {
my ($self) = @_;
my ($status);
tr/n-za-mN-ZA-M/a-zA-Z/
if ($status = filter_read()) > 0;
$status;
}
1;
所有 Perl 源过滤器都作为 Perl 类实现,并且具有与上述示例相同的基本结构。
首先,我们包含 Filter::Util::Call
模块,该模块将许多函数导出到过滤器的命名空间中。上面显示的过滤器使用了其中的两个函数,filter_add()
和 filter_read()
。
接下来,我们创建过滤器对象并通过定义 import
函数将其与源流关联。如果您对 Perl 非常了解,您就会知道 import
在每次使用 use 语句包含模块时都会自动调用。这使得 import
成为创建和安装过滤器对象的理想位置。
在示例过滤器中,对象 ($ref
) 就像任何其他 Perl 对象一样被祝福。我们的示例使用了一个匿名数组,但这并不是必需的。因为这个示例不需要存储任何上下文信息,所以我们也可以使用标量或哈希引用。下一节将演示上下文数据。
过滤器对象与源流之间的关联是通过 filter_add()
函数建立的。它接受一个过滤器对象作为参数(在本例中为 $ref
)并将其安装在源流中。
最后,是实际执行过滤的代码。对于这种类型的 Perl 源代码过滤器,所有过滤都在名为 filter()
的方法中完成。(也可以使用闭包编写 Perl 源代码过滤器。有关更多详细信息,请参阅 Filter::Util::Call
手册页。)每次 Perl 解析器需要另一行源代码进行处理时,都会调用它。filter()
方法反过来使用 filter_read()
函数从源代码流中读取行。
如果源代码流中有一行可用,filter_read()
将返回一个大于零的状态值,并将该行追加到 $_
。状态值为零表示文件结束,小于零表示错误。过滤器函数本身应该以相同的方式返回其状态,并将它想要写入源代码流的过滤后的行放在 $_
中。使用 $_
说明了大多数 Perl 源代码过滤器的简洁性。
为了使用 rot13 过滤器,我们需要一种方法来将源文件编码为 rot13 格式。下面的脚本 mkrot13
就可以做到这一点。
die "usage mkrot13 filename\n" unless @ARGV;
my $in = $ARGV[0];
my $out = "$in.tmp";
open(IN, "<$in") or die "Cannot open file $in: $!\n";
open(OUT, ">$out") or die "Cannot open file $out: $!\n";
print OUT "use Rot13;\n";
while (<IN>) {
tr/a-zA-Z/n-za-mN-ZA-M/;
print OUT;
}
close IN;
close OUT;
unlink $in;
rename $out, $in;
如果我们用 mkrot13
加密它
print " hello fred \n";
结果将是这个
use Rot13;
cevag "uryyb serq\a";
运行它会产生以下输出
hello fred
rot13 示例是一个简单的示例。这里还有另一个演示,展示了更多功能。
假设您想在开发过程中在 Perl 脚本中包含大量调试代码,但您不希望它在发布的产品中可用。源代码过滤器提供了解决方案。为了使示例保持简单,假设您希望调试输出由环境变量 DEBUG
控制。如果变量存在,则启用调试代码,否则禁用调试代码。
两行特殊的标记行将括起调试代码,如下所示
## DEBUG_BEGIN
if ($year > 1999) {
warn "Debug: millennium bug in year $year\n";
}
## DEBUG_END
过滤器确保 Perl 仅在 DEBUG
环境变量存在时解析 <DEBUG_BEGIN>
和 DEBUG_END
标记之间的代码。这意味着当 DEBUG
存在时,上面的代码应该通过过滤器不变地传递。标记行也可以原样传递,因为 Perl 解析器会将它们视为注释行。当 DEBUG
未设置时,我们需要一种方法来禁用调试代码。实现这一点的一个简单方法是将两个标记之间的行转换为注释
## DEBUG_BEGIN
#if ($year > 1999) {
# warn "Debug: millennium bug in year $year\n";
#}
## DEBUG_END
以下是完整的调试过滤器
package Debug;
use v5.36;
use Filter::Util::Call;
use constant TRUE => 1;
use constant FALSE => 0;
sub import {
my ($type) = @_;
my (%context) = (
Enabled => defined $ENV{DEBUG},
InTraceBlock => FALSE,
Filename => (caller)[1],
LineNo => 0,
LastBegin => 0,
);
filter_add(bless \%context);
}
sub Die {
my ($self) = shift;
my ($message) = shift;
my ($line_no) = shift || $self->{LastBegin};
die "$message at $self->{Filename} line $line_no.\n"
}
sub filter {
my ($self) = @_;
my ($status);
$status = filter_read();
++ $self->{LineNo};
# deal with EOF/error first
if ($status <= 0) {
$self->Die("DEBUG_BEGIN has no DEBUG_END")
if $self->{InTraceBlock};
return $status;
}
if ($self->{InTraceBlock}) {
if (/^\s*##\s*DEBUG_BEGIN/ ) {
$self->Die("Nested DEBUG_BEGIN", $self->{LineNo})
} elsif (/^\s*##\s*DEBUG_END/) {
$self->{InTraceBlock} = FALSE;
}
# comment out the debug lines when the filter is disabled
s/^/#/ if ! $self->{Enabled};
} elsif ( /^\s*##\s*DEBUG_BEGIN/ ) {
$self->{InTraceBlock} = TRUE;
$self->{LastBegin} = $self->{LineNo};
} elsif ( /^\s*##\s*DEBUG_END/ ) {
$self->Die("DEBUG_END has no DEBUG_BEGIN", $self->{LineNo});
}
return $status;
}
1;
此过滤器与之前示例的主要区别在于过滤器对象中使用了上下文数据。过滤器对象基于哈希引用,用于在调用过滤器函数之间保存各种上下文信息。除了两个哈希字段之外,所有字段都用于错误报告。这两个字段中的第一个,Enabled,用于过滤器确定是否应将调试代码提供给 Perl 解析器。第二个,InTraceBlock,在过滤器遇到 DEBUG_BEGIN
行但尚未遇到后续 DEBUG_END
行时为真。
如果您忽略了大多数代码执行的所有错误检查,过滤器的本质如下
sub filter {
my ($self) = @_;
my ($status);
$status = filter_read();
# deal with EOF/error first
return $status if $status <= 0;
if ($self->{InTraceBlock}) {
if (/^\s*##\s*DEBUG_END/) {
$self->{InTraceBlock} = FALSE
}
# comment out debug lines when the filter is disabled
s/^/#/ if ! $self->{Enabled};
} elsif ( /^\s*##\s*DEBUG_BEGIN/ ) {
$self->{InTraceBlock} = TRUE;
}
return $status;
}
注意:就像 C 预处理器不了解 C 一样,调试过滤器也不了解 Perl。它很容易被愚弄
print <<EOM;
##DEBUG_BEGIN
EOM
撇开这些不谈,您可以看到可以用少量代码实现很多功能。
您现在对源过滤器是什么有了更好的理解,您甚至可能找到了使用它们的可能用途。如果您想玩源过滤器,但需要一些灵感,以下是一些您可以添加到调试过滤器中的额外功能。
首先,一个简单的。与其使用全有或全无的调试代码,不如能够控制哪些特定的调试代码块被包含进来。尝试扩展调试块的语法以允许每个块被标识。然后,DEBUG
环境变量的内容可用于控制哪些块被包含进来。
一旦您可以识别单个块,尝试允许它们嵌套。这也不难。
以下是一个不涉及调试过滤器的有趣想法。目前,Perl 子例程对正式参数列表的支持非常有限。您可以指定参数的数量及其类型,但您仍然必须手动从 @_
数组中取出它们。编写一个源过滤器,允许您拥有一个命名参数列表。这样的过滤器会将以下内容
sub MySub ($first, $second, @rest) { ... }
转换为以下内容
sub MySub($$@) {
my ($first) = shift;
my ($second) = shift;
my (@rest) = @_;
...
}
最后,如果您想挑战一下,尝试编写一个完整的 Perl 宏预处理器作为源过滤器。借鉴您所知的 C 预处理器和任何其他宏处理器的有用功能。棘手的部分将是选择您的过滤器希望了解多少 Perl 语法。
源代码过滤器仅在字符串级别上工作,因此在动态更改源代码方面的能力非常有限。它无法检测注释、引号字符串、here文档,它不能替代真正的解析器。源代码过滤器的唯一稳定用途是加密、压缩或字节加载器,用于将二进制代码转换回源代码。
例如,请查看Switch中的限制,它使用源代码过滤器,因此它不能在字符串评估中工作,包含嵌入换行符的正则表达式(使用原始/.../
分隔符指定,并且没有修饰符//x
)与以除法运算符/
开头的代码块无法区分。作为解决方法,您必须对这些模式使用m/.../
或m?...?
。此外,使用原始?...?
分隔符指定的正则表达式可能会导致神秘错误。解决方法是使用m?...?
代替。请参阅https://metacpan.org/pod/Switch#LIMITATIONS.
目前,__DATA__
块的内容不会被过滤。
目前,内部缓冲区长度仅限于 32 位。
DATA
句柄一些源代码过滤器使用DATA
句柄来读取调用程序。当使用这些源代码过滤器时,您不能依赖此句柄,也不能期望在操作它时有任何特定的行为。基于 Filter::Util::Call(因此也基于 Filter::Simple)的过滤器不会更改DATA
文件句柄,但另一方面会完全忽略__DATA__
之后的文本。
源代码过滤器发行版可在 CPAN 上获得,位于
CPAN/modules/by-module/Filter
从 Perl 5.8 开始,Filter::Util::Call(源代码过滤器发行版的核心部分)是标准 Perl 发行版的一部分。还包括一个更友好的接口,称为 Filter::Simple,由 Damian Conway 提供。
Paul Marquess <[email protected]>
Reini Urban <[email protected]>
本文的第一个版本最初出现在 The Perl Journal #11 中,版权所有 1998 The Perl Journal。它在 Jon Orwant 和 The Perl Journal 的许可下发布。本文件可以根据与 Perl 本身相同的条款进行分发。