內容

名稱

perlfilter - 來源過濾器

說明

本文探討 Perl 一項鮮為人知的特色,稱為「來源過濾器」。來源過濾器會在 Perl 讀取模組程式碼文字之前,先對其進行變更,就像 C 預處理器會在編譯器讀取 C 程式碼文字之前,先對其進行變更一樣。本文將進一步說明來源過濾器是什麼、如何運作,以及如何撰寫自己的過濾器。

來源過濾器的原始目的是讓您可以加密程式碼來源,以防止一般人讀取。這並非過濾器能做的一切,您很快就能了解。但首先,我們來了解基礎知識。

概念

在 Perl 詮釋器執行 Perl 指令碼之前,它必須先從檔案讀取到記憶體中,以便進行剖析和編譯。如果該指令碼本身包含其他指令碼,並使用 userequire 陳述式,則必須從各自的檔案中讀取每個指令碼。

現在,將 Perl 剖析器和個別檔案之間的每個邏輯連接視為「來源串流」。當 Perl 剖析器開啟檔案時,就會建立來源串流,當程式碼讀取到記憶體中時,來源串流會持續存在,當 Perl 完成剖析檔案時,來源串流就會被銷毀。如果剖析器在來源串流中遇到 requireuse 陳述式,就會只為該檔案建立一個新的、不同的串流。

下方的圖表表示單一來源串流,其中程式碼從左方的 Perl 指令碼檔案流入右方的 Perl 剖析器。這是 Perl 的正常運作方式。

file -------> parser

有兩個重要的重點要記住

  1. 雖然在任何特定時間點都可以存在任意數量的來源串流,但只會有一個串流處於活動狀態。

  2. 每個來源串流只會與一個檔案相關聯。

來源篩選器是一種特殊的 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 陳述式來呼叫。

假設您想要在執行前透過 C 預處理器傳遞您的 Perl 來源。碰巧的是,來源篩選器發行版附帶了一個稱為 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";

讓我們考慮當過濾後的程式碼包含另一個模組時會發生什麼事

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 篩選器出現在解壓縮篩選器之前,因此來源檔案會在解壓縮之前先進行 uudecode。

撰寫來源篩選器

有三個方法可以撰寫你自己的來源篩選器。你可以用 C 撰寫,使用外部程式作為篩選器,或用 Perl 撰寫篩選器。我不會詳細說明前兩種方法,所以我就先把它們排除在外。用 Perl 撰寫篩選器最方便,所以我會花最多篇幅說明它。

用 C 撰寫來源篩選器

三個可用技術中的第一個是用 C 完全撰寫篩選器。你建立的外部模組會直接與 Perl 提供的來源篩選器掛鉤介面。

這種技術的優點是你對篩選器的實作擁有完全的控制權。最大的缺點是撰寫篩選器所需的複雜性增加 - 你不僅需要了解來源篩選器掛鉤,還需要對 Perl 的內部結構有合理的認識。值得這麼麻煩的少數情況之一是撰寫來源擾碼器。decrypt 篩選器 (在 Perl 剖析來源之前對其進行擾碼) 與來源篩選器套件一起包含,是一個 C 來源篩選器的範例 (請參閱下方的解密篩選器)。

解密篩選器

所有解密篩選器都遵循「安全來自於模糊」的原則。無論您編寫解密篩選器的技巧有多好,或者您的加密演算法有多強,只要決心夠堅定,任何人都可以擷取原始原始碼。原因很簡單:Perl 必須剖析其原始碼才能執行您的程式。這表示 Perl 必須擁有解密程式所需的所有資訊,而這表示該資訊也對任何能夠執行程式的人員開放。

話雖如此,仍有許多步驟可以讓潛在讀者難以得逞。最重要的步驟:以 C 編寫您的解密篩選器,並將解密模組靜態連結至 Perl 二進位檔。如需讓潛在讀者難以得逞的更多秘訣,請參閱原始碼篩選器發行版中的檔案 decrypt.pm

建立來源篩選器作為獨立的可執行檔

除了以 C 編寫篩選器之外,另一個選擇是使用您選擇的語言建立獨立的可執行檔。獨立的可執行檔會從標準輸入讀取資料,執行必要的處理,並將篩選後的資料寫入標準輸出。Filter::cpp 是以獨立的可執行檔實作的來源篩選器範例 - 可執行檔是與您的 C 編譯器綑綁的 C 預處理器。

來源篩選器發行版包含兩個簡化此任務的模組:Filter::execFilter::sh。這兩個模組都讓您可以執行任何外部可執行檔。這兩個模組都使用共處理程序來控制資料流入和流出外部可執行檔。(有關共處理程序的詳細資訊,請參閱 Stephens, W.R. 所著的「UNIX 環境中的進階程式設計」。艾迪生-韋斯利,ISBN 0-210-56317-7,第 441-445 頁。)兩者之間的差異在於 Filter::exec 直接產生外部指令,而 Filter::sh 則產生殼層來執行外部指令。(Unix 使用 Bourne 殼層;NT 使用 cmd 殼層。)產生殼層讓您可以使用殼層的元字元和重新導向功能。

以下是使用 Filter::sh 的範例指令碼

use Filter::sh 'tr XYZ PQR';
$a = 1;
print "XYZ a = $a\n";

執行指令碼時您將取得的輸出

PQR a = 1

將來源篩選器寫成獨立的可執行檔運作良好,但會產生輕微的效能損失。例如,如果您執行上述小型範例,將會建立獨立的子程序來執行 Unix tr 指令。每次使用篩選器都需要其自己的子程序。如果在您的系統上建立子程序的成本很高,您可能需要考慮其他建立來源篩選器的選項。

以 PERL 編寫來源濾波器

建立您自己的來源濾波器最容易且最可攜帶的選項是完全以 Perl 編寫。為了與前兩個技術區分,我將其稱為 Perl 來源濾波器。

為了協助了解如何編寫 Perl 來源濾波器,我們需要一個範例來研究。以下是執行 rot13 解碼的完整來源濾波器。(Rot13 是一種非常簡單的加密機制,用於 Usenet 貼文中隱藏攻擊性貼文的內容。它將每個字母向前移動十三個位置,因此 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 夠熟悉,您就知道每次使用 use 陳述式包含模組時,就會自動呼叫 import。這使得 import 成為建立和安裝濾波器物件的理想位置。

在範例濾波器中,物件 ($ref) 會像任何其他 Perl 物件一樣被祝福。我們的範例使用匿名陣列,但這不是必需的。由於此範例不需要儲存任何內容文字,我們也可以使用純量或雜湊參考。下一部分示範內容資料。

濾波器物件和來源串流之間的關聯是透過 filter_add() 函式建立。這會將濾波器物件作為參數 (此範例中為 $ref) 並將其安裝在來源串流中。

最後,有實際執行濾波的程式碼。對於此類型的 Perl 來源濾波器,所有濾波都是在稱為 filter() 的方法中完成。(也可以使用封閉寫入 Perl 來源濾波器。請參閱 Filter::Util::Call 手冊頁面以取得更多詳細資料。)每次 Perl 剖析器需要另一行來源來處理時,就會呼叫它。filter() 方法反過來使用 filter_read() 函式從來源串流讀取行。

如果原始串流中有可用的行,filter_read() 會傳回大於 0 的狀態值,並將該行附加到 $_。狀態值為 0 表示檔案結束,小於 0 表示發生錯誤。過濾函數本身預期會以相同方式傳回其狀態,並將其想要寫入原始串流的過濾行置於 $_ 中。使用 $_ 是因為大多數 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

以下是完整的 Debug 過濾器

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 行時為 true。

如果您忽略大多數程式碼執行的所有錯誤檢查,過濾器的精髓如下

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 一樣,Debug 過濾器也不認識 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 文件,它無法取代真正的剖析器。來源濾鏡唯一穩定的用法是加密、壓縮或 byteloader,將二進位碼轉譯回原始碼。

例如,請參閱使用來源濾鏡的 Switch 中的限制,因此它不適用於字串 eval 內部,存在使用原始 /.../ 分隔符號指定的新行嵌入正規表示式的存在,且沒有 //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 套件的一部分。同時還包含由 Damian Conway 提供的更友善介面 Filter::Simple。

作者

Paul Marquess <Paul.Marquess@btinternet.com>

Reini Urban <rurban@cpan.org>

版權

本文的第一個版本最初出現在 The Perl Journal #11 中,版權為 1998 The Perl Journal。本文由 Jon Orwant 和 The Perl Journal 提供,並經其同意刊登。本文檔可依據與 Perl 相同的條款散布。