需要使用 Base64 格式吗?那么本网站正好适合您!使用我们的在线工具对数据进行编码或解码,便捷好用。

Rust 中的 Base64 解码:完整指南

一条字符串落进了你的 Rust 程序:字母和数字,偶尔冒出一个加号或斜杠,末尾还挂着可疑的一两个 =。这就是 Base64,而本指南要讲的,是如何原样拿回最初的字节、不遇到任何意外。本站首页已经把这个格式讲得足够透彻,所以这里只需要重述一下这笔交易的样子:四个字母表字符代表三个输入字节,末尾的一两个 = 字符则标记出真实数据在哪里结束。解码就是把这笔交易倒着做,而下面所有内容都是关于如何刻意、清醒地做这件事。

Rust 和大多数语言不同的那一个关键点:它的标准库里压根没有 Base64。std 里没有任何藏着的 base64_decode(),也没有哪句 use std::... 能把它召唤出来。整个生态最终选定了一个名字就叫 base64 的 crate,而它已经成了承重墙:0.23.1 版本于 2026 年 8 月 4 日发布,这个 crate 自 2015 年 12 月首次发布以来已经推出 45 个版本,下载量计数器接近 15 亿。你几乎可以肯定已经在通过它解码 Base64 了,无论是直接使用,还是被 jsonwebtokenpemserde_with 这样的 crate 间接引入,它们全都依赖它。

一个 crate 和它的小圈子

如果机器上还没有 Rust 本身,你的操作系统会把它带上:Debian 和 Ubuntu 上的 rustccargo,macOS 和 Windows 上的软件包或安装器,或者那个会装好 rustup 的官方安装器:

# Debian / Ubuntu
sudo apt install rustc cargo
# 或者官方安装器,它会装好 rustup 和 cargo
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

然后是 crate,放进任意一个 cargo 项目里。这单独一行就是完整的安装,它引入的依赖恰好是零个:

cargo new my-app
cd my-app
cargo add base64

三个可选的 feature 塑造着构建。std 默认开启,启用 std::io 流式类型、标准的 Error 实现和堆分配。alloc 为没有完整标准库的嵌入式 no_std 构建提供带分配的 API。simd-unsafe 默认开启,把守着你后面会遇到的向量化引擎。最低支持的 Rust 版本是 1.71.0,所以任何较新的版本都能跑它。核心 crate 周围环绕着一小圈专家,每一个都负责核心刻意留给你的某个边界:

Crate 版本(2026) 它带来什么 何时选它
base64ct 1.8 来自 RustCrypto 项目的恒定时间解码;堆 API 放在 alloc feature 之后 你解码的字节可能通过计时泄露信息,比如密钥材料
data-encoding 2.11 Base64 加上 base32、十六进制等一大家子,带宽容的 MIME 变体和切片级编解码 某个组件必须解析脏乱的、带换行的或多协议的输入
base64-turbo 0.3 一个更新的编解码器,峰值超过 100 GiB/s,带 AVX512、AVX2 和 NEON 内核,外加安全的标量回退 吞吐量就是全部目的,而标准引擎还让周期白白闲置

这些没有一个能替代日常工作。对绝大多数 Rust 程序来说,单靠 base64 就是正确而完整的答案,本文其余部分解码本身就用这一个 crate,只有当任务超出 base64 的范围时,才会请出圈子里的专家。

三行代码拿到你的字节

解码人生的百分之九十都装得进三行。标准的冒烟测试用的是大名鼎鼎的 TWFu 字符串:

use base64::prelude::*;
fn main() {
  let packed = "TWFu";
  let bytes = BASE64_STANDARD.decode(packed).expect("valid base64");
  println!("{}", String::from_utf8(bytes).expect("valid utf-8"));
}

输出是 Man,那个小小的仪式里有三样东西值得记住。第一,decode() 交给你的永远是一个 Vec<u8>,绝不是字符串。这是特性,不是意外:Base64 装得下一句话、一张 JPEG 或一个证书,而在你知道自己拿到的是什么之前,谁也不该被区别对待。第二,从字节跳到文本是单独一步、刻意的一步,经由 String::from_utf8() 完成,字符集的决定就活在这一步里。第三,prelude 模块不动声色地一次递给你两样东西:BASE64_STANDARD 引擎,以及你正在调用其方法的 Engine trait。如果你更喜欢显式导入,use base64::engine::general_purpose::STANDARD; 配上 use base64::Engine; 就是同一扇门,只是装上了门牌。

因为你迟早会解码自己编码过的东西,这里给出一次往返,证明两个方向说的是同一种话。编码在姊妹站点上有自己的完整指南;它在这里出现只是为了制造测试数据:

use base64::prelude::*;
fn main() {
  let packed = BASE64_STANDARD.encode("Hello, world!");
  println!("{packed}");                             // SGVsbG8sIHdvcmxkIQ==
  let back = BASE64_STANDARD.decode(packed).unwrap();
  println!("{}", String::from_utf8(back).unwrap()); // Hello, world!
}

TWFu 揣在兜里,当作你写的任何解码路径的冒烟测试:只要它能把 TWFu 变回 Man,这台机器就是诚实的。

四种说“不”的方式

这一节是凌晨两点救你命的部分,因为当生产环境里的字符串爆炸时,你想知道这个 crate 到底在抱怨什么。好消息是:它抱怨得响亮又精确。DecodeError 恰好有四个变体,下面是它们面对一大家子典型冒犯者时各自的声音,全部喂给严格的标准引擎:

输入 哪里出了问题 确切错误
"SGVs bG8s" 混进了一个空格 Invalid symbol 32, offset 4.
"SGVs\nbG8s" 混进了一个换行符 Invalid symbol 10, offset 4.
"SG=VsbG8="" 字符串中间出现了填充 Invalid symbol 61, offset 2.
"SGVsbG8sIHdvcmxkIQ==xx" 填充后面跟着垃圾数据 Invalid symbol 61, offset 18.
"S" 一个符号凑不出一个字节 Invalid input length: 1
"SGV" 三个符号,却没有必须跟在后面的填充 Invalid padding
"SGVs$bG8="" $ 不在字母表里 Invalid symbol 36, offset 4.

注意 Invalid symbol 消息同时告诉你了肇事字节的值和它的偏移量,这样你就能直接跳到案发现场。InvalidLength 变体是那个挑剔的家伙:自 0.22.0 版本起,它专门在有效符号的数量不可能成立时触发,也就是长度比四的倍数多一的情况,而其他坏长度则表现为填充错误。下面是完整的 match,留给那些想把每种失败区别对待的日子:

use base64::DecodeError;
use base64::prelude::*;
fn triage(dirty: &str) {
  match BASE64_STANDARD.decode(dirty) {
    Ok(_) => println!("{dirty:?} sailed through"),
    Err(DecodeError::InvalidByte(offset, byte)) =>
      println!("{dirty:?}: symbol {byte} at {offset} is not in the alphabet"),
    Err(DecodeError::InvalidLength(symbols)) =>
      println!("{dirty:?}: {symbols} valid symbols is impossible"),
    Err(DecodeError::InvalidLastSymbol { offset, .. }) =>
      println!("{dirty:?}: trailing bits at {offset} suggest truncation"),
    Err(DecodeError::InvalidPadding) =>
      println!("{dirty:?}: padding is wrong or missing"),
  }
}

这种刻意的严格要追溯到标准本身。RFC 4648 第 12 节警告说,字母表之外的字符可能被滥用作隐蔽通道,夹带带外信息,或者戳中草率解析器里的虫子,它建议解码器拒绝这些字符。MIME 规范是那个著名的例外,它明确告诉解码器忽略多余的字符,而下文的邮件一节会展示如何驯服这种形状的输入。

对填充的三种态度

现实世界里的每一条 Base64 字符串都对填充做出了无声的承诺,而 0.23 版本让你通过 DecodePaddingMode 枚举选择执行哪一种承诺。一共有三种模式,行为上的差异值得背下来。下面是 Zm8 的成绩单,它就是去掉 = 之后的单词 fo

模式 "Zm8",无填充 "Zm8=",有填充 何时使用
RequireCanonical,默认值 Err(Invalid padding) Ok([102, 111]) 数据由你自己产生、自己消费
Indifferent Ok([102, 111]) Ok([102, 111]) 接收来自混合来源的数据
RequireNone Ok([102, 111]) Err(Invalid padding) 运行无填充协议
use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD_PAD_INDIFFERENT};
use base64::engine::DecodePaddingMode;
use base64::prelude::*;
let strict = BASE64_STANDARD;  // RequireCanonical 是默认值
let flexible = STANDARD_PAD_INDIFFERENT;
let bare = GeneralPurpose::new(
  &base64::alphabet::STANDARD,
  GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::RequireNone),
);
println!("{:?}", strict.decode("Zm8"));    // Err(Invalid padding)
println!("{:?}", flexible.decode("Zm8"));  // Ok([102, 111])
println!("{:?}", flexible.decode("Zm8=")); // Ok([102, 111])
println!("{:?}", bare.decode("Zm8="));     // Err(Invalid padding)

默认值遵循标准:RFC 4648 第 3.2 节说,除非周围的规范另有说明,实现必须在编码数据的末尾包含适当的填充字符,这就是为什么原样的 STANDARD 引擎要求它们。而且这个选择关乎安全,不只是吹毛求疵。同时接受同一数据的带填充和不带填充两种写法,会让 Base64 变得可塑:同一个逻辑负载可以被写成两种形式,而任何假设一个值只有一种规范写法的代码都可能被吓一跳。2022 年的论文 "实践中的 Base64 可塑性"(Chatzigiannis 和 Chalkias,ePrint 2022/361)记录了真实的后果,这个 crate 自己的文档也链接了它。实用规则:每个协议选定一种模式,在你不控制生产者的每一个边界上都保持严格。

最后一个符号的隐藏比特

这里有一种损坏,它能通过每一次字符检查。每个 Base64 符号携带 6 比特,3 个输入字节(24 比特)恰好变成 4 个符号。当输入只有 1 或 2 个字节时,最后一个符号里就有用不到的比特,而 RFC 说得很清楚:符合规范的编码器必须把这些闲置比特置零。有缺陷或恶意的编码器则可以在那里留下垃圾,结果依然能通过字母表检查、长度检查和填充检查,却悄悄带着一个损坏的尾巴。严格引擎会替你兜底,它有一个异常详尽的错误,甚至会把可疑比特展示给你看:

use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig};
use base64::prelude::*;
println!("{:?}", BASE64_STANDARD.decode("MT=="));
// Err(Invalid last symbol 0x54 ('T') at offset 1, decoded as 0b00010011.)
let lenient = GeneralPurpose::new(
  &base64::alphabet::STANDARD,
  GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
);
println!("{}", String::from_utf8_lossy(&lenient.decode("MT==").unwrap()));
// 1

错误信息里的那个 0b00010011,是肇事符号的解码值,连非法的高位比特都包含在内。0.23.0 版本特意让这个细节可见,正是因为除此之外它极难调试。如果你知道自己的生产方不讲究,with_decode_allow_trailing_bits(true) 会把垃圾吞下去,而不是拒绝它。浏览器押了相反的注:WHATWG 的 forgiving-base64 算法,也就是 JavaScript 里 atob() 背后的那一个,对尾部比特明确宽容,而 Rust 的默认值则是法医式的审查员。搞清楚你坐在桌子的哪一边。

Base64url:会旅行的字母表

标准 Base64 把字母表最后两个位置花在了 +/ 上,而这恰恰是 URL 最不想见到它们的地方:在查询字符串里,加号意味着空格,斜杠开启新的路径段,而悬着的 = 读起来像个分隔符。所以 RFC 4648 第 5 节定义了 URL 和文件名安全的字母表,它把两个麻烦制造者换成 -_,而且由于长度通常可以恢复出来,填充一般也一起去掉了。RFC 甚至警告说,这种编码不应被视为与标准 Base64 相同,引擎的名字也表达了同样的态度:

use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let packed = URL_SAFE_NO_PAD.encode(b"\xfb\xef\xbe");
println!("{packed}");                            // ----
let back = URL_SAFE_NO_PAD.decode(packed).unwrap();
println!("{back:02x?}");                         // [fb, ef, be]
// 标准引擎会拒绝同样的输入,
// 因为连字符根本不在它的字母表里
println!("{:?}", base64::prelude::BASE64_STANDARD.decode("----"));
// Err(Invalid symbol 45, offset 0.)

现在说大多数开发者为什么会遇到 base64url:JSON Web Token。一个 JWT 是三个用点连接的 base64url 部分,而偷看它里面是什么只需五行代码:

use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
let parts: Vec<&str> = jwt.split('.').collect();
let header = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[0]).unwrap()).unwrap();
let payload = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap();
println!("{header}");
println!("{payload}");
// {"alg":"HS256","typ":"JWT"}
// {"sub":"1234567890","name":"John Doe","iat":1516239022}

两条诚实的声明。解码 JWT 是偷看,不是信任:第三部分是签名,在对照密钥检查之前它什么都不是,而这是 jsonwebtoken crate 的活儿(2026 年是 11 版本)。11 版本有一个锋利的边缘:它要求 Cargo.toml 里恰好启用 rust_cryptoaws_lc_rs 其中一个 feature,否则你第一次签名或验证 token 时它就会 panic,而且它的 Validation 构建器默认把 exp claim 当作必填项,所以为其他库铸造的 token 可能需要调整过的验证。而解码正是字母表选择咬人的地方:把一个 URL 安全字符串喂给 BASE64_STANDARD,或者反过来,你得到的都是拒绝,因为对另一个引擎来说,-_ 和缺失的填充全都是非法的。每次都让引擎匹配协议。

字节不是词

本文里的每一个解码器都刻意停在字节,而在 Rust 里这比大多数语言更容易,因为没有一个可能出错的隐藏字符集步骤。Base64 是一个字节格式,句号。"那是什么文本?"这个问题由你来回答,而现代网络的默认答案是 UTF-8,你用一行标准库代码就能把关:

use base64::prelude::*;
let packed = "Y2Fmw6k=";   // 带重音的 cafe 一词,打包后
let bytes = BASE64_STANDARD.decode(packed).unwrap();
match std::str::from_utf8(&bytes) {
  Ok(text) => println!("{text}"),
  Err(_) => eprintln!("not utf-8: {bytes:02x?}"),
}

多字节幸福路径覆盖你在链路上会遇到的一切:

原始文本 Base64 能否解回原文
café Y2Fmw6k=
日本語 5pel5pys6Kqe
naïve résumé bmHDr3ZlIHLDqXN1bcOp
😀 8J+YgA==
π ≈ 3.14159 z4Ag4omIIDMuMTQxNTk=

而当负载根本不是文本时,同样的代码只是换了个结尾。这里是一个 PNG 文件的魔数,四个字节 89 50 4E 47 加上紧随其后的 CRLF 对:

use base64::prelude::*;
let packed = "iVBORw0KGgo=";
let bytes = BASE64_STANDARD.decode(packed).unwrap();
println!("{bytes:02x?}");                          // [89, 50, 4e, 47, 0d, 0a, 1a, 0a]
assert!(std::str::from_utf8(&bytes).is_err());
std::fs::write("sprite.png", &bytes).unwrap();     // 输出的是字节,不是文本

经验法则很短:假定 UTF-8,用 std::str::from_utf8() 验证,把一切失败的东西当作字节负载交给 fs::write、数据库 blob 或它来自的任何接收端。你唯一需要伸手拿真正的字符集的时候,是那些从未迁移过的遗留数据。encoding_rs crate(0.8 版本)能给旧编码命名并完成转换:

use base64::prelude::*;
use encoding_rs::Encoding;
let packed = "Y2Fm6Q==";   // 由 Latin-1 字节打包的带重音 cafe
let bytes = BASE64_STANDARD.decode(packed).unwrap();
let (text, _, _) = Encoding::for_label(b"windows-1252").unwrap().decode(&bytes);
println!("{text}");   // 带重音的 cafe,以 UTF-8 呈现

base64 crate 内部没有"按 Latin-1 解码"这种模式让你配错,因为它从不替你猜。这就是你要守住的纪律:crate 给你字节,由你决定它们意味着什么。

熬过了邮件系统的输入

熬过邮件系统的 Base64 带着换行符:MIME 每行在 76 个字符处换行(PEM 块是 64 个),MIME 规范明确告诉合规的解码器忽略字母表之外的字符,包括换行符。我们的引擎与 MIME 合规恰恰相反:它拒绝第一个换行符,而流式读取器把这次拒绝报告为一个 I/O 错误,里面包着同样精确的 DecodeError

use std::io::Read;
use base64::prelude::*;
use base64::read::DecoderReader;
let wrapped_in = "SGVs\nbG8s";
let mut reader = DecoderReader::new(wrapped_in.as_bytes(), &BASE64_STANDARD);
let mut out = Vec::new();
println!("{:?}", reader.read_to_end(&mut out).map(|_| out));
// Err(Custom { kind: InvalidData, error: Invalid symbol 10, offset 4. })

两种立场都追溯到同一份 RFC,它把选择权交给周围的规范,而 base64 crate 选择了当严格的那一个。这不是它第一次改主意:0.5.0 版本自带 MIME 换行和空白处理,0.10.0 版本把两者都移除了,理由是换行对一个通用库来说太有主见,还把 no_std 的故事搞复杂了。所以处理现实世界中带换行输入的配方,和 crate 自己文档建议的一样:先剥掉非字母表字符,再解码。对于内存中的字符串,一个过滤器就够了:

use base64::prelude::*;
fn strip_non_b64(input: &[u8]) -> Vec<u8> {
  input.iter().copied().filter(|b| !b" \n\r\t\x0b\x0c".contains(b)).collect()
}
fn main() {
  let wrapped = "SGVs\nbG8s\r\nIHN0\nYW5kYXJk";
  let clean = strip_non_b64(wrapped.as_bytes());
  let bytes = BASE64_STANDARD.decode(clean).unwrap();
  println!("{}", String::from_utf8_lossy(&bytes));   // Hello, standard
}

如果你更想要一个能直接看穿换行符的解码器,data-encoding crate 的 BASE64_MIME_PERMISSIVE 常量就是它:它把 "SGVsbG8s\r\nd29ybGQh\r\n" 解码成 Hello,world!,你一行都不用碰。对于无法把整个输入握在手中的流,crate 的 FAQ 指向 iter_read crate 来过滤字节流,或者自己写一个小小的 Read 封装,在不要的字节到达时立刻丢掉它们。在你手工打造一个"宽容解码器"之前,一个警告:默默忽略非字母表字符,正是 RFC 4648 第 12 节点名标记为隐蔽通道的行为,所以只剥掉你预期的空白,其余一律拒绝。

当你手上有完整消息时,邮件解析器会替你搞定 base64。mail-parser crate(0.11 版本)在解析时解码每个 Content-Transfer-Encoding: base64 部分,所以附件回来时已经是原始字节,拆了封装、解了码:

use mail_parser::MessageParser;
let email = br#"From: art@vandelay.com
To: jane@example.com
Subject: gift
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="festivus"

--festivus
Content-Type: text/plain; charset="us-ascii"
Content-Transfer-Encoding: base64

SGVsbG8gZnJvbSBlbWFpbA==
--festivus
Content-Type: image/gif
Content-Transfer-Encoding: Base64
Content-Disposition: attachment; filename="tiny.gif"

R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7
--festivus--
"#;
let message = MessageParser::default().parse(email).unwrap();
for part in message.attachments() {
  let name = part
    .headers()
    .iter()
    .find(|h| h.name().eq_ignore_ascii_case("content-disposition"))
    .and_then(|h| h.value().clone().unwrap_content_type()
      .attribute("filename").map(|n| n.to_string()));
  let bytes = part.contents().to_vec();
  println!("{name:?}: {} bytes", bytes.len());
}
// Some("tiny.gif"): 42 bytes

那个 GIF 附件解码成 42 个字节,开头是四个字节 47 49 46 38,即 ASCII 字母 GIF8。你一行 Base64 代码都不用写,而这正是用解析器的意义所在:编码细节是库的问题。

大负载

字符串容易;文件才是 Base64 证明自己的地方,而这个 crate 用与 Rust 其余 io 相同的流式哲学来回应。read::DecoderReader 包装任意读取器,在你读取时透明地交出解码后的字节,所以一个好几 GB 的编码文件从不需要装进内存。对于下面的例子,把字符串 dGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw== 存成一个名为 fox.b64 的纯文本文件:

use std::io::Read;
use base64::prelude::*;
use base64::read::DecoderReader;
fn main() {
  let packed = std::fs::read("fox.b64").unwrap();
  let mut decoder = DecoderReader::new(&packed[..], &BASE64_STANDARD);
  let mut plain = Vec::new();
  decoder.read_to_end(&mut plain).unwrap();
  println!("{}", String::from_utf8_lossy(&plain));
  // the quick brown fox jumps over the lazy dog
}

同样的想法用 io::copy 能缩到一行:在文件外面套一个 DecoderReader,把它拷贝进任何写入器,解码就在路上完成了。官方文档里还有一个漂亮的小技巧,用常量空间验证负载:使用一个静态大小的缓冲区,完全不分配解码后的数据:

use std::io::Cursor;
use std::io::Read;
use base64::prelude::*;
use base64::read::DecoderReader;
fn is_valid_base64(input: &str) -> bool {
  let mut cursor = Cursor::new(input.as_bytes());
  let mut decoder = DecoderReader::new(&mut cursor, &BASE64_STANDARD);
  let mut buf = [0u8; 128];
  loop {
    match decoder.read(&mut buf) {
      Ok(0) => return true,   // 一路读到末尾,没有出错
      Ok(_) => continue,
      Err(_) => return false, // 有东西不是 base64
    }
  }
}
fn main() {
  println!("{}", is_valid_base64("SGVsbG8sIHdvcmxkIQ=="));  // true
  println!("{}", is_valid_base64("dt=="));                  // false
}

对于很大但仍然装得进你管理缓冲区的负载,切片 API 是零意外选项:base64::decoded_len_estimate(len) 给出 len 个符号的保守最大解码尺寸,decode_slice() 直接写进你预分配的缓冲区,并精确返回它写了多少字节:

use base64::prelude::*;
let packed = "SGVsbG8sIHdvcmxkIQ==";
let cap = base64::decoded_len_estimate(packed.len());  // 15,保守的最大值
let mut buf = vec![0u8; cap];
let written = BASE64_STANDARD.decode_slice(packed, &mut buf).unwrap();
buf.truncate(written);
println!("{}", std::str::from_utf8(&buf).unwrap());    // Hello, world!

如果你的缓冲区太小,你会得到一个干净的 DecodeSliceError::OutputSliceTooSmall,而不是 panic;还有一个按设计就会 panic 的 decode_slice_unchecked() 变体,留给那些"太小"本身就是程序员错误、你宁可让它崩溃也不想绕来绕去的地方。自 0.22.0 起,切片检查以对你有利的方式保守:只有当输出真的装不下时它才失败,所以尺寸恰好够用的缓冲区可以工作。

解码后的字节去哪儿

Base64 在 Rust 项目里出现的频率,远比"一条随机字符串"暗示的更高:

  • API 响应和 webhook:在负载里以 Base64 文本嵌入二进制或嵌套 JSON 的那一种,经典的"文件上传即 JSON"模式。
  • JWT 检视:解码头部和负载来偷看 claims,把签名交给 jsonwebtoken,让真正有意义的部分由它接手。
  • 邮件形状的数据:MIME 附件,以及一切经过邮件系统的东西,这正是空白那一节存在的原因。
  • PEM 块,证书和密钥里的那些 -----BEGIN CERTIFICATE----- 段落,每个 TLS 栈都要嚼一遍;pem crate 替你解析它们,而且不出所料,它内部正是建立在 base64 crate 之上。
  • Data URI:藏在你抓取或渲染的 HTML 和 CSS 里,data:image/png;base64,... 那种。
  • HTTP Basic 认证头Basic TWFuOnBhc3M= 不过是 Man:pass 化了个妆。
  • 数据库和配置文件:有人想在文本列或环境变量里塞二进制。
  • 跨语言交接:一个 Python 服务打包 blob,Rust 解包,双方按定义说着同一套字母表。

对 JSON 这种情况,有一个值得知道的捷径:serde_with crate(3 版本)可以注解结构体字段,让 serde 双向处理 Base64:出去时把 Vec<u8> 字段编码成文本,回来时再解码回去,URL 安全变体只差一个参数:

use serde::{Deserialize, Serialize};
use serde_with::serde_as;
#[serde_as]
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Config {
  #[serde_as(as = "serde_with::base64::Base64")]
  blob: Vec<u8>,
}
let cfg = Config { blob: b"stored in a database".to_vec() };
let json = serde_json::to_string(&cfg).unwrap();
// {"blob":"c3RvcmVkIGluIGEgZGF0YWJhc2U="}
let back: Config = serde_json::from_str(&json).unwrap();
assert_eq!(back, cfg);

Data URI 走两步字符串操作加一次普通解码:找到逗号,保留逗号之后的部分,再检查元数据以单词 base64 结尾:

use base64::prelude::*;
let uri = "data:image/png;base64,iVBORw0KGgo=";
let comma = uri.find(',').unwrap();
let meta = &uri[..comma];
let payload = &uri[comma + 1..];
let is_b64 = meta.rsplit(';').next().unwrap() == "base64";
let bytes = BASE64_STANDARD.decode(payload).unwrap();
println!("{is_b64}: {} bytes from {meta}", bytes.len());
// true: 8 bytes from data:image/png;base64

还有那条统管一切、值得重复的规则,因为它至今仍当场抓住人:Base64 是打包胶带,不是锁。它不是加密,也不是压缩 - 它是压缩的反面 - 而任何一个拿着这篇文章的人都能反转它做的一切。尽管解码,有选择地信任。

十年小心思

这个 crate 自己的历史读起来像一次缓慢的拧紧螺丝。它 2015 年 12 月首次出现在 crates.io,0.5.0 版本自豪地加上了 MIME 支持,换行符和换行长度都可配置。然后 2018 年的 0.10.0 版本移除了换行和空白处理,库做了一个决定:通用 crate 只管解码,把诗歌留给应用层;同一个版本还加入了流式编码器和无效尾部符号的检测。2022 年的 0.20.0 版本引入引擎抽象,并把填充默认值翻转过来,让标准引擎要求规范填充;0.21.0 弃用了 base64::decode() 这样的旧自由函数,改用引擎方法,编译器备注是 "Use Engine::decode"(它们仍然能用,所以大量遗留代码至今编译得很开心)。2024 年,0.22.0 版本磨利了错误语义,精确了 InvalidLength 的含义,并把解码速度提升了 5% 到 10%。2026 年 7 月,0.23.0 版本带着 SIMD 引擎、自定义填充符号、更清晰的 InvalidLastSymbol 消息和 MSRV 提升到 1.71 而来,8 月 4 日的 0.23.1 补丁为非 SIMD 架构修好了测试套件。十年小步慢走、步步小心,这个以"它就是 base64,人们还能想要更多什么呢?"起家的 crate,如今已经在发货向量化内核了。

这个格式比万维网还老,所以那份严格让人感觉是在针对你个人。1987 年,隐私增强邮件协议(RFC 989)需要在 7 位邮件通道上搬运二进制数据,它把这种编码标准化为 64 字符行;你的 TLS 栈信任过的每一个 -----BEGIN CERTIFICATE----- 块都是那个决定的直系后代。1996 年,MIME 规范(RFC 2045)采纳了这个方案,按它 64 字符的字母表把它命名为 "base64",并定下了 76 字符的行宽,直到今天仍在包裹你的邮件附件。2006 年,RFC 4648 成为人人引用的标准:字母表表格、第 5 节的 base64url 变体,以及这个 crate 以肉眼可见的兴致实现的严格规则。

趣闻

因为一份完整的指南应当以微笑收尾:

  • 单词 "base64" 编码后是 YmFzZTY0。一个描述自己的格式,相当于技术上的一面用摩尔斯电码说话的镜子。
  • 空字符串解码为 0 个字节,但 "AA==" 解码为个字节:NUL。在 Base64 里,"空无一物"和"一个零"是两种不同的生物。
  • 你见过的每一个 Base64 编码的 PNG 都以 iVBORw0K 开头。那是 PNG 魔数化了妆,也是整个互联网上最容易辨认的前缀之一。
  • YouTube 视频 ID 是无填充的 base64url:8 个字节的值给出 12 个 Base64 字符,去掉尾部填充就剩下那个你熟悉的 11 字符 ID,可以粘贴到 URL 的任何位置。整个互联网上无填充模式最显眼的用途之一。
  • Bash 用 64 进制数数好多年了:$((64#...)) 算术字面量按 0-9a-zA-Z 的顺序取数字,最后 @_ 代表 62 和 63,所以你的 shell 明晃晃地揣着一张 64 字符字母表。
  • 老的 crypt(3) 密码哈希用的是一种字母表以 ./ 开头的 Base64 变体,它有一个可爱的性质:对编码后的字符串排序,得到的顺序和对原始字节排序相同。家谱文件用同一张字母表内嵌多媒体(GEDCOM 5.5;5.5.1 修订版砍掉了它),base64 crate 把它作为 alphabet::CRYPT 随箱附送。
  • MIME 算术,按老的经验法则至今仍这样算:换行后的邮件负载大约花掉原始大小的 1.37 倍,外加数百字节量级的头部开销。1990 年代的邮件基础设施真的对每个附件都收过这笔过路费。
  • 整个 crate 都是 #![forbid(unsafe_code)],除了默认开启的 simd-unsafe feature,那个是让你退出的,而不是加入的。一个词,"unsafe",却是 feature 开关的名字。
  • Base64 的可塑性让安全研究者脊背发凉:同样的字节可以带填充写、不带填充写,尾部比特里塞满垃圾也照样写,而宽容的解码器不会察觉。2022 年的一篇论文展示了真实后果,这就是为什么这个 crate 的严格默认值像一位贴身保镖。
  • Base64 不是加密。要是它是,你就读不懂本文任何一个例子的输出。它是靠窗座位,不是金库。

收尾

按你打交道的环境挑引擎:BASE64_STANDARD 用于你产生并控制的一切,STANDARD_PAD_INDIFFERENT 用于混合来源的边界,URL_SAFE_NO_PAD 用于 token 和 URL,协议要自己立规矩时则上手打造 GeneralPurpose。让四个 DecodeError 变体尽兴地精确抱怨,让字节先过 std::str::from_utf8() 这关再叫它们文本,大东西用 DecoderReader 流式处理,只剥你预期的空白,计时成为威胁时伸手拿 base64ct。解码一切,只信任能验证的。如果有一天你需要走相反的方向,把字节打包成字符串上路,而不是拆包,姊妹文章讲的就是 Rust 里的编码,从尺寸计算一直讲到流式收尾。

最后更新: 2026-09-08

相关文章: Rust 中的 Base64 编码:完整指南