Perl 中的 Base64 解码:完整指南
有人递给你一串字符:字母、数字,偶尔冒出一个 + 或 /,而你的直觉很明确:它绝不是表面看上去的那样。它可能是骑在 Authorization 头里的一枚令牌,一个从支持工单里挖出来的 .b64 文件,一个穿着 -----BEGIN 护甲的证书,也可能是一团安安静静躺在配置文件里的数据块。你打开终端,敲下 perl,一个问题立刻盖过其他所有事:我怎么才能把真正的数据拿回来?
答案很小,也让人安心。Perl 从 2002 年起就把 Base64 模块随语言本身一起发货,一个函数调用 decode_base64 就能干完全部活:不用安装,不用配置。趁咖啡煮上的功夫快速复习一下:Base64 把数据每三个字节重写成四个字符,取自一个 64 符号的字母表,再用一两个 = 把尾巴补齐,让结果永远落在四的倍数上,所以编码形式通常比原始数据大 33% 左右。本站首页已经把这个格式完整讲透了,所以这份指南把所有时间花在 Perl 这一侧:解码器的规则、那些方言,以及你真正会遇到的现实格式。
工具箱:五个函数,零安装
你需要的每一个调用都住在 MIME::Base64 里,它从 Perl 5.8 起就是核心发行版的一部分,所以每一台像样的机器上都有它,从路由器固件里嵌入的那份,到数据库服务器上的那份。检查只要一行:
perl -MMIME::Base64 -e 'print $MIME::Base64::VERSION, "\n"'
# 3.16_01
下面是这个模块的解码半边,就是它的全部了:
| 函数 | 它做什么 | 备注 |
|---|---|---|
decode_base64($str) |
本文的主角:把一团 Base64 变成原始字节 | 永远默默忽略所有字母表之外的字符 |
MIME::Base64::decode($str) |
同一个解码器,不做导入直接调用 | 你会在不少老脚本里见到这种写法 |
decode_base64url($str) |
解码带 - 和 _ 的 URL 安全方言,有无填充都行 |
2010 年的 3.11 加入;读 JWT 的就是它 |
MIME::Base64::decoded_base64_length($str) |
不实际解码,就告诉你解码后的数据有多大 | 默认不导出,预分配缓冲区时很方便 |
unpack("u", $data) |
解码 uuencoded 数据,Base64 之前的老格式 | Perl 自带,无需模块 |
如果你要维护一大家子老机器,下面是那些函数的版本对照表:
| 特性 | 自何时可用 |
|---|---|
带 C 速度通道的 decode_base64() |
2002 年的 Perl 5.8,模块随核心入场 |
decoded_base64_length() |
模块 3.10,2010 年 |
decode_base64url() |
模块 3.11,2010 年 |
| 安静解码,对可疑输入不再警告 | 模块 3.11,2010 年 |
| 现行的 3.16 系列 | 2020 年,要求 Perl 5.6 或更新 |
如果你的系统 Perl 由于某种原因缺了这个模块(它本来不该缺),补救办法就是两行之一:Debian 和 Ubuntu 上的发行版包 libmime-base64-perl,或者用 cpanm MIME::Base64 从 CPAN 拉下当前版本,这个模块从核心时代起就以双栖包的身份住在那里。至于那几台没有 C 编译器的稀奇机器,CPAN 上的纯 Perl 孪生版 MIME::Base64::Perl 提供同样的基础接口,慢上几倍,但除了批量活儿之外都够用。这就是全部的依赖故事:没有别的东西了。
从不说不的解码器
契约只有一行长。给它一个字符串,它还给你解码后的字节,装在一个持有原始八位字节的普通 Perl 字符串里。没有对象,没有异常,没有标志。文档用一句话概括了定义它性格的两条规则:任何不属于那 65 字符 Base64 子集的字符都被默默忽略,任何出现在 = 填充字符之后的字符永远不被解码。这份礼貌是本文最重要的事,所以先让它为你工作一次:
use MIME::Base64 qw(decode_base64);
print decode_base64("TWFu!"), "\n"; # Man - 感叹号消失得无影无踪
print decode_base64("TWFu=XX"), "\n"; # Man - = 之后的一切都被跳过
print decode_base64("TQ"), "\n"; # M - 没有警告,没有评论
print decode_base64("T"), "\n"; # 空字符串,依旧毫无怨言
最后两行把这种宽容推到了极致。TQ 装着一个完整字节外加四个多余的位,解码器干脆保留那个字节、丢掉剩下的部分。T 连一个完整字节都不够,所以结果就是空的。模块里没有严格模式,也没有校验器能找回老派的挑剔:自 2010 年的 3.11 版起,decode_base64 对截断的输入连警告都不发了,而更早的版本会抱怨一条 提前结束的 base64 数据 警告(在 -w 之下)。如果这团数据是错的,它照样解码,于是质量把关的人就成了你。
这里把宽容政策集中放在一处,让你一眼看全:
| 输入 | 结果 | 原因 |
|---|---|---|
"TWFu" |
Man |
干净的输入,一帆风顺的路 |
"TWFu!" |
Man |
感叹号不在字母表里,所以被跳过 |
"TWFu=XX" |
Man |
填充之后没有任何东西会被解码 |
"TWFuIFdvcmxkIQ==" |
Man World! |
空白出现在哪里都无所谓 |
"TQ" |
M |
装得下一个完整字节,多余的位被悄悄丢掉 |
"T" |
空字符串 | 连一个完整字节都不够,而且连警告都没有 |
"ab-cd_efgh" |
悄悄出错的数据 | URL 安全字母被当噪音丢掉,经典陷阱 |
最后那行才是要记住的。把一个 base64url 分段交给标准解码器并不会失败:它会解码出一团看起来合理的垃圾,因为 - 和 _ 被当成外来噪音,而剩下的字母仍然组成有效的分组。解码器是证人,不是看门人,所以如果输入不可信,得你自己来校验。一个小小的严格检查就够了:
sub strict_base64 {
my ($blob) = @_;
$blob =~ s/[\r\n]//g; # 解码器忽略这些,我们也一样
return 0 unless length($blob) % 4 == 0;
return $blob =~ /\A[0-9A-Za-z+\/]+(?:={1,2})?\z/ ? 1 : 0;
}
print strict_base64("TWFu"), "\n"; # 1
print strict_base64("TQ="), "\n"; # 0 - 填充数量不对
print strict_base64("ab-cd"), "\n"; # 0 - URL 安全字母表
关于那个正则,还有一句从血泪教训里换来的话:如果一个子程序以光秃秃的 return $x =~ /.../ 收尾,而匹配失败的结果又被直接喂给 printf,Perl 会抛出一条令人误解的 printf 缺少参数 警告,而不是一个干净利落的零。学上面的函数那样,返回前用 ? 1 : 0 把匹配结果转换一下,这个陷阱就彻底消失了。
先字节,后字符
记住 decode_base64 返回的是什么:原始字节,一个没有设置 UTF-8 标志的普通字符串。那些字节意味着什么,是一个只有你能做的决定,而 Unicode 正是在这一步绊倒人。Perl 会跟踪一个字符串装的是字符还是字节,length()、substr() 以及大多数正则表达式的行为都取决于这个答案。解法是刻意地指名你的编码,用的是每一份 Perl 安装都随附的 Encode 模块:
use MIME::Base64 qw(decode_base64);
use Encode qw(decode);
my $raw = decode_base64("SMOrbGxvIFdvcmxkIQ==");
my $text = decode("UTF-8", $raw);
print $text, "\n"; # Hëllo World!
print length($text), " chars\n"; # 12
print length($raw), " bytes\n"; # 13
这一对数字就是全部教训。这团数据是 13 个字节,却只有 12 个字符,因为那个带重音的字母在 UTF-8 里占两个字节。跳过字符集这一步,字节照样能漂亮地打印在 UTF-8 终端上,而这恰恰是错误能一直藏着的原因:直到某个字符串函数去数它们,或者这些字节穿过一条期望字符的流水线,它才现出原形。拿不准时,用严格的字符集解码,让异常替你说出字节的真相:传入 Encode::FB_CROAK,decode() 就会在无效序列上直接死掉,而不是像默认行为那样悄悄地用 U+FFFD 替换 - 这正是一个特性。
你真正会用得上的字符集,短名单如下:
| 字符集 | 什么时候用它 | 小心什么 |
|---|---|---|
UTF-8 |
默认假设:API、JSON、网页内容、现代文本 | 无效序列默认变成 U+FFFD;加上 Encode::FB_CROAK 则会干净利落地死掉,这正是你想要的 |
Latin-1 |
老派西方文本,一个字符一个字节,永远不会失败 | 它乐呵呵地把 UTF-8 搅成双重编码的乱码 |
ASCII |
你确定是纯 7 位文本的数据 | 任何高于 127 的字节默认变成 U+FFFD(在 Encode::FB_CROAK 下直接死掉) |
UTF-16 |
Windows 文本,字节序标记在那里决定字节序 | BOM 是唯一的字节序线索,所以把它留在字节里 |
而这里正是字符集这一步替你挡开的陷阱。如果你解码出来的字节已经是 UTF-8,出口时又把它们过一遍 encode("UTF-8", ...),你得到的不是一份副本:你会得到一个双重编码,里面每个带重音的字符都膨胀成两个字符。经典症状是:原本读作 Hëllo 的文本现在读作 Hëllo,而线那头忠实的接收方会把它原样解码出来。字节进,字节出,中间只过一次指名道姓的转换。
base64url:URL 和令牌的字母表
穿越现代互联网的那一半 Base64 根本就不是标准字母表。+ 字符是浏览器在查询字符串里编码空格的方式,/ 是路径分隔符,所以标准字母在 URL 里是一场灾难。RFC 4648 第 5 节定义了修法:第二个字母表,把 + 和 / 换成 - 和 _,并按惯例连 = 填充和换行符一起丢掉。RFC 明说这种编码不应被视为与 base64 编码相同,而 Perl 自 2010 年的 3.11 版起就有一对专用的函数:
use MIME::Base64 qw(decode_base64url);
my $raw = decode_base64url("c3Vuc2V0LTQy");
print $raw, "\n"; # sunset-42
有两件事要知道。第一,decode_base64url 对没有填充的输入求之不得,那才是你在野外真正会找到的形态,所以其他语言要求的那种先恢复填充的仪式在这里不适用;带填充的输入也能用。第二,标准解码器是另一种生物:喂它一个 base64url 分段,你得到的是悄悄出错的数据,因为 - 和 _ 字符被当噪音丢掉,剩下的照样解码。用对解码器;或者当你被困在遗留代码路径上时,手动归一化一下:
my $seg = "ab-cd_efgh";
$seg =~ tr{-_}{+/}; # URL 安全字母,被翻译回原籍
$seg .= "=" x (-length($seg) % 4); # 为标准的解码器恢复填充
my $raw = decode_base64($seg);
print unpack("H*", $raw), "\n"; # 69bf9c77f79f82 - 七个字节,回到标准字母表
你会立刻在 JWT 里遇到 base64url - 也就是每个现代 API 发出来的那种令牌 - 还会遇到任何住在 URL 里的不透明 ID:十一字符的视频 ID、用 URL 安全字母表存储的 UUID(CPAN 上正好有 Data::UUID::Base64URLSafe,就是干这个的),以及需要熬过地址栏的数据库键。如果你手边的 Perl 老到早于核心函数,2006 年的独立模块 MIME::Base64::URLSafe(Python urlsafe 编解码器的移植版)提供 urlsafe_b64encode 和 urlsafe_b64decode;而任何 3.11 及以上的版本,内置函数都是更好的选择。
JWT:读取头部和载荷
从结构上看,JSON Web Token 就是两块化了妆的 JSON,外加一张加密收据。RFC 7515 的紧凑形式是三个用点号连接起来的 base64url 分段:受保护的头部、载荷,以及签名。拆分并读取一个,只要三行:
use MIME::Base64 qw(decode_base64url);
use JSON::PP;
my $jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJob21lciJ9.uzM6l0c4...";
my ($head_b64, $claims_b64, $sig_b64) = split /\./, $jwt, 3;
my $head = decode_json(decode_base64url($head_b64));
my $claims = decode_json(decode_base64url($claims_b64));
print $claims->{sub}, " (", $head->{alg}, ")\n"; # homer (HS256)
注意这里的分工:decode_base64url 把每个分段变成字节,而核心模块 JSON::PP(Perl 5.14 起就有)里的 decode_json 把头部和载荷的字节变成 Perl 数据结构。签名分段同样是 base64url,但它是加密摘要,所以永远只解码前两个分段,第三个交给正经的库去处理。
陷阱是那个所有人都忘掉的:可读不等于有效。头部和载荷按设计就是可读的,这也意味着任何人都能改写它们;签名才是唯一的证明。凡是正经用途,都要校验,不能只解码。基于 CryptX 构建的 CPAN 模块 Crypt::JWT 能把整件事干完:
use Crypt::JWT qw(decode_jwt);
my $claims = decode_jwt(
token => $jwt,
key => $secret,
accepted_alg => "HS256",
);
签名不对时它会 croak,而钉住 accepted_alg 能堵住算法混淆这个漏洞 - 否则攻击者可以把令牌翻成更弱的变体。解码再打印的工作流适合在支持电话里检查令牌;但它不是认证。
文件:从 .b64 回到原始文件
文件正是 Perl 单行命令文化真正发光的地方,整个活儿塞进一条命令里就够了。-0777 标志是秘密武器,因为它把整个文件一口气读进单个字符串,而不是逐行喂给解码器:
perl -MMIME::Base64 -0777 -ne 'print decode_base64($_)' < in.b64 > out
逐行形式对某一特定类的文件是安全的:那些每一行都装着四的倍数个 Base64 字符的文件,每一个正确 MIME 折行的正文都如此,因为 76 是 4 的倍数。折行点一旦变得参差不齐 - 而手工折行的文件通常就是如此 - 逐行解码就开始在数据中间生出填充来。一次性读取模式没有这种前提条件,这就是它成为默认选择的原因:
perl -MMIME::Base64 -ne 'print decode_base64($_)' < in.b64 > out
在脚本内部,套路是标准的 Perl 文件舞步,只有一个安静却重要的细节:两个文件句柄上都带 :raw 层,这样 Perl 进和出的时候都绝不试图把字节解读成平台文本:
use MIME::Base64 qw(decode_base64);
use Digest::SHA qw(sha256_hex);
open my $in, "<:raw", $ARGV[0] or die $!;
local $/;
my $blob = <$in>;
close $in;
my $decoded = decode_base64($blob);
print sha256_hex($decoded), "\n"; # 与发送方发布的校验和比对
open my $out, ">:raw", $ARGV[1] or die $!;
print {$out} $decoded;
close $out;
这行哈希的作用不只是炫技。因为解码器几乎接受任何东西,与发送方发布的值一致的校验和,才是这次旅程字节级精确的唯一证明。对于真正巨大的文件,逐行循环是省内存的替代方案 - 前提是折行都落在四字符边界上 - 而 MIME::Base64::decoded_base64_length 会在你分配缓冲区之前告诉你输出会有多大。
PEM 护甲:脱掉外套,留下 DER
每个安全栈里的 .pem 文件,都是同一个 Base64 穿着护甲:一行头部,一行尾部,正文按老 Privacy Enhanced Mail 的规矩每 64 字符折行。唯一有意思的部分是这层包装,因为这个模块的解码器根本不在乎行长:
use MIME::Base64 qw(decode_base64);
open my $fh, "<:raw", "cert.pem" or die $!;
local $/;
my $blob = <$fh>;
close $fh;
my @body = grep { !/^-----/ && /\S/ } split /\n/, $blob;
my $der = decode_base64(join "", @body);
print length($der), " bytes of DER\n";
BEGIN 和 END 行被剥掉,剩下的部分拼成一个字符串,一路之上每个换行都被忽略。日常的证书活儿 OpenSSL 工具已经替你干了;上面这八行是那种模式的记忆锚点 - 当你自己需要原始 DER 字节时,为了一个哈希、一个指纹,或一次比对。
data URI:自带地址的图片
RFC 2397 的 data: 方案把载荷直接内联进 URL 里:data:、一个可选的媒体类型、一个可选的 ;base64 标志、一个逗号,然后是数据。像图片这样的二进制媒体用那个标志,所以载荷是带填充的标准字母表,普通的解码器在小小裁剪一下之后就能处理:
use MIME::Base64 qw(decode_base64);
my $uri = "data:image/png;base64,iVBORw0KGgo...";
$uri =~ s/^data:[^,]+,// or die "not a data URI";
my $raw = decode_base64($uri);
print unpack("H8", $raw), "\n"; # 89504e47:PNG 的魔数
检查魔数就是那记妙手。如果开头那八个十六进制字符不是 89504e47,那这张图片就不是 PNG,不管媒体类型声称什么,而一个从不抱怨的解码器,恰恰让这种安静的谎言成为可能。
表亲与化石:uuencode 和其他字母表
在 Base64 获胜之前,UNIX 上发二进制邮件的经典方式就是 uuencode,你仍会在老邮件列表和老工具里遇到它。好消息是:Perl 为它内置了解码器,无需模块,这要归功于 pack 和 unpack 里的 u 模板:
my $uu = pack("u", "Hello, World!");
print $uu, "\n"; # -2&5L;&\L(%=O<FQD(0`` 外加一个换行
my $back = unpack("u", $uu);
print $back, "\n"; # Hello, World!
这两个调用互为精确的逆操作,这就是你需要知道的全部故事;而 UNIX 工具链里经典的 uuencode 命令只是把那些光板行包进一个 begin 头和一个 end 尾,所以你要解码的载荷就是夹在中间的那部分。
Base64 还有方言表亲,知道哪个解码器吃哪种,能省掉你一整个调试的下午:
| 方言 | 折行 | 在哪里遇到 | decode_base64 会怎样 |
|---|---|---|---|
| MIME(RFC 2045) | 76 字符 | 邮件正文 | 照原样解码:换行和 CRLF 都被忽略 |
| PEM(RFC 1421) | 64 字符 | 证书和密钥 | 照原样解码 |
| PKIX(RFC 7468) | 64 字符 | X.509 文本结构 | 照原样解码 |
| OpenPGP 护甲(RFC 9580) | 76 字符外加一行 CRC24 | PGP 密钥和签名 | 照原样解码,校验和行只是被忽略 |
| IMAP(RFC 3501) | 无 | 邮箱名 | 不是这个字母表:斜杠变成了逗号,先翻译字母 |
要点是:对于所有只是折行方式不同的标准字母表变体,一个宽容的解码器就能全部覆盖。只有当字母表本身变了,你才需要先翻译那些字符。
配置、数据库与环境变量
容器平台、云控制台,以及数量惊人的一批配置文件,会把凭据和小文档存成不透明的 Base64 字符串,因为一团字母数字看起来没有它实际是的那个密码那么危险。解码永远是同样的两步:decode_base64 加上一个字符集决定:
use MIME::Base64 qw(decode_base64);
use Encode qw(decode);
my $secret = decode("UTF-8", decode_base64($config->{api_key}));
这个格式在这个位置如此受欢迎,原因恰恰是 RFC 4648 警告过的那一条:人类会停止注意到数据是可读的。所以从解码输出被返回的那一刻起,就把当机密对待,并且让这团数据和它的结果都远离日志文件、告警和调试转储。
同样的形态也出现在数据库里,那里的二进制数据经常以 Base64 的形式搭乘 TEXT 列,因为这个列不能承诺让任意字节原封不动地通过:
use MIME::Base64 qw(decode_base64);
my $icon = decode_base64($row->{icon_data});
open my $fh, ">:raw", "icon.png" or die $!;
print {$fh} $icon;
close $fh;
邮件:MIME 部分与附件
邮件是 Base64 得名之处,而这个模块的宽容正是为这种流量设计的。带 Content-Transfer-Encoding: base64 的 MIME 部分,到达时是一行行 76 字符、以 CRLF 结尾的文本,解码器把整个信封照原样吃掉,连换行符在内:
use MIME::Base64 qw(decode_base64);
my $part_body = "SGVsbG8sIHF1ZXJ5IQpUaGlzIE1JTUUgcGFydCB0cmF2ZWxsZWQgYXMgYmFzZTY0LCB3cmFwcGVk";
$part_body .= "\r\nIGF0IDc2IGNoYXJhY3RlcnMsIENSTEYgYmV0d2VlbiBsaW5lcy4=";
my $text = decode_base64($part_body);
print $text; # 原始的两行邮件正文
如果你用框架来构建或解析邮件,这一切都不用手工做:给 attach 传 Encoding => "base64",MIME::Lite 就会替你 base64 编码附件,Email::MIME 也会自动做同样的事。上面那个手工版,是为那些以原始文本形式出现在日志、工单或转发消息里的邮件准备的 - 而实践中,这样的邮件占了相当多。
陷阱集:收集并按危害排序
这个模块小到能背下来,所以这里把完整的陷阱清单放在一处,大致按它咬人的频率排序:
| 陷阱 | 发生了什么 | 修法 |
|---|---|---|
| 把 base64url 分段喂给标准解码器 | - 和 _ 被当噪音丢掉,剩下的解码成悄悄出错的数据 |
用 decode_base64url,或者先翻译字母表并恢复填充 |
| 相信损坏输入时的沉默 | 外来字符、截断、错误的字母表,全都悄无声息地解码,没有一个警告 | 先跑严格检查,原始数据可用时再用哈希验证 |
| 数据团里的非 ASCII 字符 | 一个落单的带重音字母或粘进来的 Unicode 空格被默默忽略,结果不声不响地缩水 | 同样的严格检查会拒绝 7 位字母表之外的一切 |
| 把结果当文本对待 | 这些字节不带 UTF-8 标志,所以 length() 数的是字节,字符串函数拿到的是错的图景 |
任何文本处理之前,先接上 decode("UTF-8", $raw) 或你选的字符集 |
| 出口处的双重编码 | 把已经是 UTF-8 的字节再过一遍 encode("UTF-8", ...),Hëllo 就变成了 Hëllo |
编码字符,永远不要编码原始字节,拿不准时用 utf8::is_utf8() 检查标志 |
| 折行参差不齐时逐行解码文件 | 不在四字符边界结尾的行,会在输出中间生出填充 | 用 -0777 一次性读取,或者保证折行点在四字符边界 |
| 把失败的 regex 匹配返回进数值上下文 | 以 return $x =~ /.../ 收尾的子程序,把它喂给 printf,会抛出一条令人误解的 printf 缺少参数 警告 |
强制转换匹配结果:return $x =~ /.../ ? 1 : 0 |
| 依赖老警告的老代码 | 3.11 之前、依赖那条 提前结束的 base64 数据 抱怨(在 -w 之下)的脚本,现在什么都看不到了 |
加上你自己的严格检查;那条警告永远消失了 |
| 以为解码就是验证 | 解码器几乎接受任何东西,而且什么都不说 | 一致的校验和或已验证的签名,才是唯一算数的证明 |
| 把你解码的东西记进日志 | 这个格式什么都不藏,而日志文件恰恰是下一个人找到它的地方 | 让解码出来的秘密远离日志、告警和调试转储 |
好习惯
那些让 Base64 永远占不了你脚本便宜的习惯:
- 永远预期是字节。写清楚知道
decode_base64返回原始八位字节的代码,并显式地接上字符集decode,而不是指望终端做对事。 - 指名你的字符集。默认用
UTF-8,只有当数据明说不是时才换。decode("UTF-8", ..., Encode::FB_CROAK)的严格失败是一个特性:它告诉你字节不是你假设的那样。 - 让字母表匹配来源。URL、令牌和 ID 用
decode_base64url,其他一切用decode_base64。两种字母表不能互换,而解码器不会在你选错时告诉你。 - 先校验,再解码。这个模块里没有严格模式标志,所以那个小小的检查就是守门的。
- 文件默认一次性读取。
-0777或local $/ = undef消灭了整整一类折行点 bug,而对你真正会解码的那些文件来说,内存代价不是问题。 - 每个文件句柄都用
:raw。二进制进,二进制出。文本层是给人用的,不是给字节用的。 - 用哈希验证。原始数据可用时,一致的校验和是字节级精确解码的唯一证明。
- 永远不要把你解码的东西记进日志。这个格式什么都不藏。
一段简短的历史,由变更日志讲述
这个格式很老,而 Perl 与它的渊源比看上去更老。几个核实过的日期,按顺序:
- C 代码早于 Perl 5。模块里那个快速解码器源自 metamail 的代码,metamail 是 Bellcore 的邮件程序,版权登记于 1991 年,比 Perl 5 第一次发布早三年。今天当你调用
decode_base64时,干活的是九十年代的一块零件。 - 生于 web 工具。模块最初是九十年代中期 libwww-perl 里的
LWP::Base64,由 Martijn Koster 和 Joerg Reichelt 编写,1997 年 4 月毕业成自己的 CPAN 发行包MIME::Base64,版本 2.00,变更日志条目写着基于 libwww-perl-5.08。 - 警告年代。从 1997 年的 2.03 起,截断的输入产生一条提前结束的 base64 数据警告(在
-w之下),而不直接死掉;1999 年的 2.11 修好了那些对好数据也报警的构建。对解码器来说,那是更神经质的一段十年。 - 2002 年起入核心。Perl 5.8 把模块拉进核心发行版,同年 12 月的 2.13 与核心同步时顺带带来了 EBCDIC 支持,这就是编码器和解码器至今还能在主帧上工作的原因。
- URL 安全方言 2010 年进入 Perl 核心。3.11 版加入了
decode_base64url和它的兄弟,那是在独立模块MIME::Base64::URLSafe2006 年登上 CPAN 四年之后,而 2006 年正是 RFC 4648 把这个方言写成法典的同一年。 - 安静下来。同一个 3.11 版本连那条老的截断警告也移除了,万一是有人故意这样构造的输入呢 - 而此后的每个版本,包括 2020 年的现行 3.16 系列,都让解码器保持着礼貌和沉默。
趣闻,Perl 专属
给这趟游览收尾,是那些让这个故事变得精彩的趣闻:
- 解码这个格式自己的名字。
decode_base64("YmFzZTY0")返回base64。它从 1997 年起就是真的,以后也永远是真的。 - 解码器是个礼貌的幽灵。在它的 3.x 历史中,它从未对坏输入抛出过异常。损坏的、截断的、字母表错的:它把一切都解码,什么都不抱怨,这种行为在 2010 年被变更日志刻意地凝固了下来。
- MIME 折行是刻意为四的倍数。76 字符的上限是十九个三字节组,共 57 字节,乘以四个字符,这就是为什么逐行解码对任何正确折行的 MIME 正文都安全,对别的东西则不安全。
- uuencode 从不打印小写字母。它的字母表到下划线为止,这就是为什么老的 uuencoded 文件看起来像全大写机器敲出来的,也是 Perl 至今仍为一种比互联网还老的格式携带内置解码器的原因。
- Perl 曾经自带过 decode-base64 命令。从 2003 年的 2.14 到 2004 年的 3.05,各版本把
encode-base64、decode-base64以及它们的 quoted-printable 孪生兄弟作为脚本一起打包;2005 年的 3.06 把它们搬去了单独的 MIME-Base64-Scripts 发行包。如果你在某个老安装里发现 PATH 上有那个命令,现在你知道它打哪儿来的了。 - YouTube 视频 ID 是伪装的 base64url。你地址栏里那个十一字符的 ID,是去掉填充的、用 URL 安全字母表写的 64 位数字,所以你看过的每一个视频的 URL 里都住着一个 Base64 字符串,而
decode_base64url能读懂它。 - 宽容是标准,不是 bug。MIME 那句"在接收时宽容"的规矩,是这个解码器能熬过三十年脏数据的原因,也是 RFC 4648 警告同样这份宽容在你信任不可信输入时能被变成隐蔽通道的原因。
所以下一次,一串字母、数字、加号和斜杠落在你的终端里,你已经知道全部故事了。一个函数调用干完活,解码器是个礼貌的幽灵,永远不会拒绝你,base64url 有自己的解码器,字符集是你刻意做出的决定,文件以原始形式进来也以原始形式出去,而哈希才是唯一算数的证明。如果哪天你需要走反方向,把自己原始的字节包进文本信封再寄往世界,下面那篇关于 Perl 里 Base64 编码的相关文章会用同样的深度讲那场仪式。
最后更新: 2026-09-08
相关文章: Perl 中的 Base64 编码:完整指南