深入解析 zstd 解压器勘误表:6 类合法帧被拒绝的边界缺陷与修复实践
【免费下载链接】fluent-bitFast and Lightweight Logs, Metrics and Traces processor for Linux, BSD, OSX and Windows项目地址: https://gitcode.com/GitHub_Trending/fl/fluent-bit
本文以 zstd 官方
decompressor_errata.md勘误文档为骨架,结合本仓库中 zstd 1.5.7 的完整源码(勘误文档、压缩器实现、解压器实现、回归测试)逐条剖析 6 类被解压器错误拒绝的合法 zstd 帧:每条缺陷的受影响版本、影响组件、复现帧、根因与修复状态,并给出可操作的规避与数据恢复方案,帮助读者理解 zstd 帧格式规范边界,避免在自研或二次开发解压器时重蹈覆辙。
zstd(Zstandard)在追求极致压缩比的同时,其帧格式规范也在不断演进,这导致历史上部分合法帧曾被解压器错误拒绝。本仓库(fluent-bit 项目内置了 zstd 1.5.7 用于压缩与解压)完整保留了这份勘误记录与其回归测试资产。本文按时间倒序逐条展开,每条均给出可复现的十六进制示例帧,并结合本仓库源码说明根因与当前修复状态,最后汇总为一张速查表,供开发者在排查"解压失败但帧明明合法"类问题时直接对照。
勘误文档的价值与阅读方式
zstd 的 decompressor_errata.md 是一份非常罕见的"缺陷档案":它记录的不是解压器拒绝损坏数据(这是正确行为),而是解压器拒绝了规范允许的合法帧——即decoder rejects a valid zstd frame。这类缺陷比普通 bug 更隐蔽,因为:
- 帧本身完全符合 zstd 帧格式规范,任何第三方压缩器都可能产出;
- 修复往往要同时改动解压路径,并给压缩路径加"规避"补丁,防止参考压缩器再产出此类帧;
- 复现需要精心构造的黄金样例帧(golden file),普通模糊测试很难命中。
文档为每个条目固定提供 5 项信息,本仓库中的对应资产可以直接验证:
- 最后受影响版本(Last affected version);
- 受影响的解压组件(Library / CLI 或两者);
- 参考压缩器是否可能产出该帧(Produced by the reference compressor);
- 示例帧(短帧直接给出十六进制串,长帧指向黄金文件);
- 缺陷描述与根因。
对应地,仓库中 tests/golden-decompression 目录保存了 4 个黄金样例帧(zeroSeq_2B.zst、block-128k.zst、empty-block.zst、rle-first-block.zst),playTests.sh 中的decompression only tests小节在每次 CI 时都会用当前解压器解压这些帧,确保缺陷不会回归。
缺陷 1:2 字节格式编码的 0 序列(v1.5.5 修复)
最后受影响版本:v1.5.5受影响组件:Library 与 CLI参考压缩器是否产出:否示例帧:tests/golden-decompression/zeroSeq_2B.zst
缺陷现象
zstd 帧中,每个压缩块的"序列段"(Sequences section)开头是Number_of_Sequences字段。该字段有两种编码格式:
- 1 字节格式:字节值小于等于 0x7F 时直接表示序列数量;
- 2 字节格式:字节值大于 0x7F 时,与后续一个字节组合成一个 15 位的数值(zstd_decompress_block.c 中
ZSTD_decodeSeqHeaders()的解析逻辑)。
本缺陷正是发生在 2 字节格式的取值边界上:当块内实际有 0 条序列,且 0 恰好用 2 字节格式编码时,旧版解压器会错误地继续期待 FSE 表,而正确的行为应当是立即结束序列段、进入下一个块。
根因分析
对照当前 zstd_decompress_block.c 中的修复后逻辑:
if (nbSeq == 0) { /* No sequence : section ends immediately */ RETURN_ERROR_IF(ip != iend, corruption_detected, "extraneous data present in the Sequences section"); return (size_t)(ip - istart); }即:解析出nbSeq == 0后,序列段必须立即结束,同时还要校验序列段内不存在多余字节(extraneous data)。而 v1.5.5 及更早版本只在 1 字节格式解析出 0 时走这条短路路径,2 字节格式解析出的 0 会错误地落入"继续解析 FSE 表描述符"的分支。
为何参考压缩器从不产出
文档明确指出,参考压缩器从未产出过这类帧,原因很直接:用 2 字节格式表示 0 条序列是低效的——既然 1 字节格式就能表示 0,压缩器永远优先使用 1 字节格式。这也是此类缺陷能存活多年未被发现的根本原因:只有追求极端的第三方压缩器或手工构造帧才会踩中。
仓库回归测试
playTests.sh 用如下命令持续守护该场景:
zstd -t "$TESTDIR/golden-decompression/zeroSeq_2B.zst"同时 golden.sh 会对整个黄金目录执行zstd -r -t批量测试;反向的 detectErrors.sh 则验证损坏帧必须被正确拒绝(例如 playTests.sh 中的zeroSeq_extraneous.zst携带多余字节,必须报错),一正一反共同锁死行为边界。
缺陷 2:大小恰为 128 KB 的压缩块(v1.5.2 修复)
最后受影响版本:v1.5.2受影响组件:Library 与 CLI参考压缩器是否产出:否示例帧:tests/golden-decompression/block-128k.zst
缺陷现象
zstd 解码器曾经错误地拒绝大小恰好为 128 KB(131072 字节)的Compressed_Block类型块。注意这个边界非常刁钻:
128 KB - 1的块被正常接受;128 KB + 1的块被规范明确禁止(超出ZSTD_BLOCKSIZE_MAX);- 唯独恰好
128 KB的块,被旧版解压器当作非法拒绝。
在 zstd_compress.c 中,压缩器侧有明确的规避注释与断言:
/* libzstd decoder before > v1.5.4 is not compatible with * compressed blocks of size ZSTD_BLOCKSIZE_MAX exactly. */ assert(cSize < ZSTD_BLOCKSIZE_MAX);根因与规范变迁
zstd 帧格式规范(zstd_compression_format.md)在0.3.2 版本之前对Compressed_Block有一条额外限制:
A Compressed_Block has the extra restriction that Block_Size is always strictly less than the decompressed size. If this condition cannot be respected, the block must be sent uncompressed instead (Raw_Block).
这条限制意味着:压缩块大小必须严格小于解压后大小,若不满足则必须改用未压缩的Raw_Block发送。旧版解压器正是基于这条过期限制拒绝 128 KB 的压缩块。该限制在规范 0.3.2 时被解除,解压器直到 v1.5.2 才同步修复。文档引用的解除依据是 upstream 的 PR#1689(该链接为外部引用,仅作背景说明)。
复现与验证
由于参考压缩器从不主动产出恰好 128 KB 的压缩块,仓库通过黄金文件 block-128k.zst 固化复现用例。对关心此边界的开发者,结论是:只要解压器版本 ≥ v1.5.2,恰好 128 KB 的压缩块即可正常解码。
缺陷 3:0 字面量 + 0 序列的压缩块(v1.5.2 修复)
最后受影响版本:v1.5.2受影响组件:Library 与 CLI参考压缩器是否产出:否示例帧(十六进制):
28b5 2ffd 2000 1500 0000 00缺陷现象
zstd 解码器曾经错误地拒绝这样一类Compressed_Block:其字面量部分以Raw_Literals_Block(未压缩字面量块)形式编码且字面量为 0 个,同时序列数也为 0。换言之,这是一个"空转"的压缩块:不携带任何字面量、不携带任何序列,却仍然被标记为压缩块类型。
根因
与缺陷 2 同源:这类块同样触发了规范 0.3.2 之前的那条过期限制(Block_Size必须严格小于解压后大小)。一个 0 字面量 + 0 序列的压缩块,其解压后大小为零,显然无法满足"严格小于"关系,旧版解压器据此将其判为非法。规范 0.3.2 解除限制后,此类帧成为合法帧,v1.5.2 的解压器修复随之跟进。
仓库回归测试
playTests.sh 中的对应用例:
touch tmp_empty zstd -d -o tmp2 "$TESTDIR/golden-decompression/empty-block.zst" $DIFF -s tmp2 tmp_empty测试逻辑非常直白:先创建一个 0 字节的空文件tmp_empty,再将黄金帧empty-block.zst解压,最后用diff断言解压结果与空文件逐字节一致——即该帧必须成功解压出 0 字节内容。
缺陷 4:首个块为 RLE 块(v1.4.3 修复,CLI 专属)
最后受影响版本:v1.4.3受影响组件:仅 CLI(Library 不受影响)参考压缩器是否产出:否示例帧(十六进制):
28b5 2ffd a001 0002 0002 0010 000b 0000 00缺陷现象
zstdCLI解压器曾经拒绝这样的帧:第一个块是 RLE(Run-Length Encoding)块,且其Block_Size为 131072(即 128 KB),同时帧内包含不止一个块。该示例帧的结构是两个 RLE 块:第一个 RLE 块 131072 字节,第二个 RLE 块 1 字节。
值得注意的是,这个缺陷只影响 zstd CLI,不影响库——这是因为 CLI 的解压入口比库多了一层文件/流的处理逻辑,在解析块头边界时对 RLE 块大小的处理存在偏差。这也是勘误表中唯一一个 Library 不受影响的条目。
压缩器的规避策略
由于历史 CLI 版本无法解码"首块为满尺寸 RLE"的帧,参考压缩器选择在产出侧主动规避,而不是只修解压侧。本仓库 zstd_compress.c 中有多处注释与代码体现了这一策略,例如:
- zstd_compress.c:
/* We don't want to emit our first block as a RLE even if it qualifies * because ... */ - zstd_compress.c 与 zstd_compress.c:在常规压缩路径与多块压缩路径中,都先判断
ZSTD_isRLE()(zstd_compress.c)确认内容是否为 RLE,若满足 RLE 条件却位于首块,则改写为非 RLE 的常规压缩块。 - 在内部模拟解压(repcode 历史维护)路径 zstd_compress.c 中同样保留注释:don't emit the first block as RLE even if it qualifies。
也就是说,即使数据本身是完美的 RLE 候选(例如整块全零字节),压缩器也会刻意让第一个块以普通压缩块形式输出,以换取与旧版 CLI 解压器的兼容性。代价是首块压缩率略微下降,收益是跨版本兼容。
回归测试
playTests.sh 专门为该场景构造了 1 MiB 的全零输入进行对照验证:
# the following test verifies that the decoder is compatible with RLE as first block # older versions of zstd cli are not able to decode such corner case. dd bs=1048576 count=1 if=/dev/zero of=tmp zstd -d -o tmp1 "$TESTDIR/golden-decompression/rle-first-block.zst" $DIFF -s tmp1 tmp脚本注释直言:"旧版 zstd CLI 无法解码此类边角情况,因此 zstd CLI 不产出它们以维持兼容性",与文档描述完全吻合。
缺陷 5:微型 FSE 表 + 微型块(v1.3.4 修复)
最后受影响版本:v1.3.4受影响组件:Library 与 CLI参考压缩器是否产出:可能直到 v1.3.4 都曾产出,但大概率从未实际发生示例帧(十六进制):
28b5 2ffd 2027 c500 0080 f3f1 f0ec ebc6 c5c7 f09d 4300 0000 e0e0 0658 0100 603e 52缺陷现象
这是勘误表中最古老、也最精巧的一条:zstd 库曾经拒绝这样一类Compressed_Block——块内最后一个类型为FSE_Compressed_Mode的表,其起始位置距离块末尾不足 4 字节。
更形式化地描述:设Last_Table_Offset为压缩块内(不含块头)最后一个FSE_Compressed_Mode表的起始偏移,若满足:
Block_Content - Last_Table_Offset < 4则旧版解压器会拒绝该块。这一条件成立的典型场景是:最后一个序列化的 FSE 表占 2 字节,且紧随其后的比特流(bitstream)只有 1 字节,合计不足 4 字节。
一个具体的触发构造
文档给出了一个 5 字节Block_Content的构造示例:
- 块内仅有1 条序列;
Literals_Lengths_Mode(字面量长度模式)为FSE_Compressed_Mode,且其序列化表大小为2 字节;Offsets_Mode(偏移模式)为Predefined_Mode(预定义表);Match_Lengths_Mode(匹配长度模式)为Predefined_Mode;- 比特流仅1 字节(1 条序列恰好能用 1 字节表达)。
此时Block_Content总计 5 字节,Last_Table_Offset为 2,5 - 2 = 3 < 4,触发拒绝。这里的Predefined_Mode/FSE_Compressed_Mode等模式定义可对照 zstd_compression_format.md:Compression_Mode共有Predefined_Mode、RLE_Mode、FSE_Compressed_Mode、Repeat_Mode四种取值,其中FSE_Compressed_Mode表示使用标准 FSE 压缩的分布表,且规范要求"仅当只有一个符号存在时不得使用"。
根因与修复
这是 zstd 解压器早期对序列头/表解析边界判断过严的历史遗留:它没有考虑到"表 + 比特流"的极短组合也可能合法。文档中给出的参考修复依据是 upstream 压缩器侧的 workaround 提交(zstd_compress.c中约 L2667-L2682 处,该行号对应 upstream 特定 commit,本仓库版本行号可能略有差异)。从本仓库 zstd_decompress_block.c 当前实现对nbSeq == 0的短路处理可见,解压器如今对序列段边界的判定已经足够宽容且精确。
由于该缺陷自 v1.3.4 起修复,距今已跨越多个大版本,实际影响面集中在使用古董版本解压器的存量系统上。
缺陷 6:Magicless(无魔数)格式的误判(v1.5.6 修复)
最后受影响版本:v1.5.5受影响组件:仅 Library(CLI 不受影响)参考压缩器是否产出:是(这是勘误表中唯一"参考压缩器确实能产出"的条目)示例帧(十六进制):
27 b5 2f fd 00 03 19 00 00 66 6f 6f 3f ba c4 59背景:什么是 Magicless 格式
zstd 的普通帧以 4 字节魔数0xFD2FB528(小端序)开头。而magicless 格式(ZSTD_f_zstd1_magicless)去掉了这 4 字节前缀,帧直接从帧头字段开始。在本仓库解压器 zstd_decompress.c 中可以看到,magicless 格式的帧头前缀长度被单独处理:
size_t const startingInputLength = ZSTD_FRAMEHEADERSIZE_PREFIX(format); /* only supports formats ZSTD_f_zstd1 and ZSTD_f_zstd1_magicless */ assert( (format == ZSTD_f_zstd1) || (format == ZSTD_f_zstd1_magicless) );同时 zstd_decompress.c 中,帧类型判定逻辑会显式跳过 magicless 帧的魔数检查,并区分普通帧与 skippable(可跳过)帧。
缺陷现象
v1.5.6 修复了 magicless 格式解码器的一批缺陷,导致其错误拒绝合法帧,包括但不限于:
- 合法帧恰好以小端序的 legacy 魔数开头:即 magicless 帧的第一个 4 字节内容恰好等于历史遗留格式(legacy format)的魔数,被误判为 legacy 帧;
- 合法帧恰好以小端序的 skippable 魔数开头:即 magicless 帧的第一个 4 字节恰好落在
0x184D2A50~0x184D2A5F的 skippable 魔数区间(zstd_decompress.c 的ZSTD_skippableFrame判断),被误判为可跳过帧而走错解析路径。
问题的本质是:去掉魔数后,magicless 帧的"帧头"内容是不可控的,任何 4 字节组合都可能出现,其中恰好命中 legacy 或 skippable 魔数的概率虽然低,但并非为零,一旦命中即被误判。
受影响数据的恢复方案
文档为无法立即升级到 v1.5.6 及以上的用户提供了明确的恢复方法:
将 zstd 魔数
0xFD2FB528(小端序)前置拼接到受影响的数据前,然后用标准格式解压器解压。
# 以 shell 示意(实际可用 printf 或任意二进制拼接工具): # 在受损数据前写入 4 字节小端序魔数 28 b5 2f fd,再用标准解压器解压其原理正是"以空间换兼容":补回被 magicless 格式去掉的 4 字节魔数后,帧重新变为标准格式,标准解码路径不再需要猜测帧类型,从而绕开误判。这也是勘误表中唯一提供了数据恢复方案的条目。
勘误速查总表
| 缺陷 | 最后受影响版本 | 受影响组件 | 参考压缩器产出 | 示例帧位置 |
|---|---|---|---|---|
| 2 字节格式编码的 0 序列 | v1.5.5 | Library + CLI | 否 | zeroSeq_2B.zst |
| 大小恰为 128 KB 的压缩块 | v1.5.2 | Library + CLI | 否 | block-128k.zst |
| 0 字面量 + 0 序列压缩块 | v1.5.2 | Library + CLI | 否 | 28b5 2ffd 2000 1500 0000 00(另有 empty-block.zst) |
| 首块为满尺寸 RLE 块 | v1.4.3 | 仅 CLI | 否 | 28b5 2ffd a001 0002 0002 0010 000b 0000 00(另有 rle-first-block.zst) |
| 微型 FSE 表 + 微型块 | v1.3.4 | Library + CLI | 可能曾产出 | 十六进制帧见正文 |
| Magicless 格式误判 | v1.5.5 | 仅 Library | 是 | 27 b5 2f fd 00 03 19 00 00 66 6f 6f 3f ba c4 59 |
给开发者与集成方的实践建议
结合本仓库(fluent-bit 内置 zstd 1.5.7)的实际使用场景,给出四条可落地的建议:
版本是第一道防线:勘误表中最后受影响版本最高为 v1.5.5(magicless 与 0 序列两条),因此解压器版本≥ v1.5.6即可覆盖表中全部已修复缺陷。本仓库使用的 zstd 1.5.7 已满足要求,可直接以 zstd_decompress_block.c 与 zstd_decompress.c 为参考实现。
自研/移植解压器时逐条对照:如果正在基于本仓库源码移植 zstd 解压逻辑,请重点检查四类边界:(a)
nbSeq == 0时的立即短路(zstd_decompress_block.c);(b) 压缩块大小恰好等于ZSTD_BLOCKSIZE_MAX(128 KB)时的放行;(c) 0 字面量 + 0 序列空压缩块的放行;(d) magicless 模式下帧头 4 字节命中 legacy/skippable 魔数时的误判规避。回归测试资产可直接复用:仓库 tests/golden-decompression 目录下的 4 个黄金帧与 playTests.sh 中的测试逻辑,可以原样移植进自己的 CI 管道;配合 golden.sh 的正向批量测试与 detectErrors.sh 的反向损坏帧测试,形成完整闭环。
遇到"合法帧解压失败"时先查勘误表:当对接的第三方系统报告解压失败,而用
zstd -t或库接口校验帧结构又完全正常时,优先检查是否为上述边界帧;若涉及 magicless 数据且无法升级,可用"前置拼接0xFD2FB528魔数"的恢复方案先行抢救数据。
总结
zstd 的这份勘误文档是压缩领域少见的"规范演进与实现缺陷"双重视角档案:它既揭示了帧格式规范在 0.3.2 版本对Compressed_Block限制的放宽(缺陷 2、3 的根源),也展示了实现细节中的两类典型问题——对编码格式取值边界判断过严(缺陷 1、5、6)与 CLI/库两条解析路径的行为不一致(缺陷 4)。通过本仓库的源码与黄金测试资产,开发者可以完整复现每一条缺陷、理解修复逻辑,并将其固化为自身的兼容性测试用例,从而在自己的解压实现中规避同类问题。
【免费下载链接】fluent-bitFast and Lightweight Logs, Metrics and Traces processor for Linux, BSD, OSX and Windows项目地址: https://gitcode.com/GitHub_Trending/fl/fluent-bit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考