Zstandard 解码器已知缺陷备忘(Errata):zstd 曾经会拒绝哪些“合法”压缩帧,以及压缩器如何主动规避
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
本文以 MongoDB 仓库内打包的 Zstandard 第三方实现中的 decompressor_errata.md 为主体,完整还原三份“解码器误拒合法帧”的历史缺陷记录:每个缺陷最后影响的版本、受影响的组件(库/CLI)、参考压缩器是否可能产生该帧、示例帧字节、以及 bug 的精确触发条件;并结合当前仓库中 zstd_compress.c 的源码,说明压缩器侧为兼容旧版本解码器而内置的两处主动规避(workaround)逻辑。读完本文,你可以准确判断“某个版本 zstd 解码器会拒绝哪些合法输入”,并理解 zstd 压缩器代码中若干看似多余的防御性分支背后的兼容性动机。
一、文档定位:什么是“Decompressor Errata”
Errata 是 Zstandard 格式维护方维护的一份已知解码器缺陷清单,专门记录“解码器错误地拒绝了合法 zstd 帧”的 bug(而不是解码出错、崩溃这类 bug)。文档规定每条记录必须包含五个要素:
- 最后受影响的解码器版本(Last affected version);
- 受影响的解码器组件(Library、CLI 或两者);
- 参考压缩器(reference compressor)是否可能生成过这种帧;
- 一个可复现的最小示例帧(十六进制字节);
- 缺陷本身的精确描述。
条目按逆时间序排列,影响最新版本号的 bug 排在最前面。这份文档对下游用户有三重价值:排查“我的合法文件为什么解码报错”、评估跨版本部署的兼容性风险,以及理解压缩器源码中兼容性规避代码的由来。
二、缺陷 1:0 字面量、0 序列的 Compressed_Block(最后影响 v1.5.2)
- 最后受影响版本:v1.5.2
- 受影响组件:Library 与 CLI 均受影响
- 参考压缩器是否产生过此类帧:否(No)
- 示例帧:
28b5 2ffd 2000 1500 0000 00
缺陷描述:zstd 解码器错误地拒绝了这样一种Compressed_Block——它把字面量以Raw_Literals_Block模式编码但字面量为零个,同时也不含任何序列(sequence)。
为什么这种帧是“合法”的?从 zstd_compression_format.md 所对应的规范历史看,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).
规范版本 0.3.2 之前,这个“零字面量 + 零序列”的压缩块被明确禁止;规范 0.3.2 之后该限制被移除(上游 facebook/zstd 的 PR#1689),即此后这类帧在规范层面合法,但 v1.5.2 及以前的解码器仍会拒绝它们。由于参考压缩器从未生成过这种块,实际生态中遇到它的概率极低——它的意义主要在于规范与实现的边界对齐:以规范为准,该帧可解码。
三、缺陷 2:首块为 RLE 块(最后影响 v1.4.3,仅 CLI)
- 最后受影响版本:v1.4.3
- 受影响组件:仅 CLI(库不受影响)
- 参考压缩器是否产生过此类帧:否(但压缩器曾通过规避来保证这一点)
- 示例帧:
28b5 2ffd a001 0002 0002 0010 000b 0000 00
缺陷描述:zstd命令行工具的解码路径会在如下情形拒绝帧——第一块是 RLE 块、其Block_Size恰为 131072(128 KiB),且该帧包含多于一个块。上面的示例帧就是一个 131072 字节的 RLE 块后接一个 1 字节的 RLE 块。库(library)不受影响,问题只存在于 CLI 解码路径。
压缩器的规避代码:首块绝不发 RLE
这条缺陷催生了压缩器源码中最直观的一处兼容性 workaround。当前仓库 zstd_compress.c 中(对应 errata 文档引用的历史提交 L3527–L3535):
if (!zc->isFirstBlock && cSeqsSize < rleMaxLength && ZSTD_isRLE((BYTE const*)src, srcSize)) { /* We don't want to emit our first block as a RLE even if it qualifies because * doing so will cause the decoder (cli only) to throw a "should consume all input error." * This is only an issue for zstd <= v1.4.3 */ cSeqsSize = 1; }逻辑拆解:
ZSTD_isRLE()(zstd_compress.c)判断整块源数据是否为单一字节重复;- 只有当
!zc->isFirstBlock时,块才会被允许走 RLE 输出(后续cSeqsSize == 1分支调用ZSTD_rleCompressBlock输出); - 因此首块即使整块同字节,也会按普通压缩块或 nocompress 块输出,从而保证旧 CLI 解码器不会踩中该 bug。
同一规避在文件中的块分裂(partitioning)路径里也重复出现(L4258、L4291、L6744 附近均有 “We don't want to emit our first block as a RLE even if it qualifies” 的同款注释与判断),说明该约束贯穿了单块与多块两种压缩调度路径。
四、缺陷 3:Tiny FSE Table & Block(最后影响 v1.3.4,库 + CLI)
- 最后受影响版本: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
缺陷描述:解码器拒绝某类Compressed_Block——其最后一个FSE_Compressed_Mode类型熵表的起始位置距离块内容结尾不足 4 字节时即被误判为损坏。用文档中的记法:设Last_Table_Offset为压缩块(不含 3 字节头)内最后一个FSE_Compressed_Mode表的起始偏移,若Block_Content - Last_Table_Offset < 4,buggy 解码器就会拒绝该块。
触发这一条件的典型组合(文档给出的具体例子):
| 要素 | 取值 |
|---|---|
| 块内序列数 | 1 |
Literals_Lengths_Mode | FSE_Compressed_Mode,序列化表大小 2 字节 |
Offsets_Mode | Predefined_Mode |
Match_Lengths_Mode | Predefined_Mode |
| 序列比特流大小 | 1 字节(单条序列可装入 1 字节) |
合计Block_Content = 5字节,Last_Table_Offset = 2,5 - 2 = 3 < 4,恰好落入拒绝区间。根因在于旧版本解码器中FSE_readNCount()读取表头时未正确校验“剩余缓冲 < 4 字节”的情形。
压缩器的规避代码:宁可发 nocompress 块
当前仓库 zstd_compress.c 中保留了完整的规避分支(对应 errata 引用的历史提交 L2667–L2682):
/* zstd versions <= 1.3.4 mistakenly report corruption when * FSE_readNCount() receives a buffer < 4 bytes. * Fixed by https://github.com/facebook/zstd/pull/1146. * This can happen when the last set_compressed table present is 2 * bytes and the bitstream is only one byte. * In this exceedingly rare case, we will simply emit an uncompressed * block, since it isn't worth optimizing. */ if (lastCountSize && (lastCountSize + bitstreamSize) < 4) { /* lastCountSize >= 2 && bitstreamSize > 0 ==> lastCountSize == 3 */ assert(lastCountSize + bitstreamSize == 3); DEBUGLOG(5, "Avoiding bug in zstd decoder in versions <= 1.3.4 by " "emitting an uncompressed block."); return 0; /* 返回 0,上层将按 nocompress(未压缩)块输出 */ }这里lastCountSize是最后一个序列化 FSE 表的大小,bitstreamSize是ZSTD_encodeSequences()产出的序列比特流长度。两者之和小于 4 字节(实际上断言了必等于 3,因为lastCountSize >= 2且bitstreamSize > 0)时,函数返回 0 让调用方退化为输出一个未压缩块。注释直言“exceedingly rare”“isn't worth optimizing”——压缩率损失可以接受,换取与 ≤ v1.3.4 解码器的完全兼容。同样的 4 字节边界防御也出现在超级块路径 zstd_compress_superblock.c 中(FSE_readNCount() receives a buffer < 4 bytes的同款注释)。
五、三条缺陷的横向对比与工程启示
| 缺陷 | 最后影响版本 | 组件 | 参考压缩器是否产生过 | 压缩器现存规避 |
|---|---|---|---|---|
| 0 字面量 0 序列的 Compressed_Block | v1.5.2 | Library + CLI | 否 | 压缩器本就不产生该形态 |
| 首块 RLE 且 Block_Size=131072 多块帧 | v1.4.3 | 仅 CLI | 否(靠规避保证) | 首块强制不走 RLE 输出 |
| 末表距块尾 < 4 字节的 Compressed_Block | v1.3.4 | Library + CLI | 可能(≤ v1.3.4) | 末表+比特流 < 4 字节时退化为 nocompress 块 |
三点启示,均以上述文档与源码为依据:
- 误拒 bug 不等于解码器损坏数据:三条记录的共同特征是“拒绝合法帧”(fail-safe 方向出错),不会解出错误数据,但对可用性是硬伤,因此上游选择在 errata 中登记而非简单当作实现瑕疵;
- 压缩器为最低版本解码器兜底:zstd 的流式/库 API 长期面对异构的旧解码器,其源码中
isFirstBlock、lastCountSize + bitstreamSize < 4这类条件,注释里都明确标注了所规避的具体版本(≤ v1.4.3、≤ v1.3.4),是把 errata 转化为常驻代码约束的典型案例; - 版本判定应以本仓库实际代码为准:当前 MongoDB 仓库内这份 Zstandard 的库版本为 zstd.h 中声明的 v1.5.5(
ZSTD_VERSION_MAJOR=1, MINOR=5, RELEASE=5),高于三条缺陷中最高影响版本 v1.5.2;因此解码侧风险主要存在于外部旧环境,而本仓库代码中的压缩侧规避分支仍保留,用于产出能被更老解码器消费的数据。
六、如何验证这些结论(只读仓库内的路径)
- 缺陷记录全文:src/third_party/zstandard/zstd/doc/decompressor_errata.md
- 首块 RLE 规避:src/third_party/zstandard/zstd/lib/compress/zstd_compress.c#L3974-L3982
- Tiny FSE Table 规避:src/third_party/zstandard/zstd/lib/compress/zstd_compress.c#L2929-L2943
- RLE 判定函数:src/third_party/zstandard/zstd/lib/compress/zstd_compress.c#L3412
- 格式规范文档(Compressed_Block/Raw_Block/RLE 块定义):src/third_party/zstandard/zstd/doc/zstd_compression_format.md
注意:errata 原文给出的示例帧(如28b5 2ffd 2000 1500 0000 00)可用于人工构造解码测试;由于本仓库为只读环境,复现验证建议在你自己的环境中用上述字节序列配合对应历史版本的 zstd CLI/库执行,以确认“旧版本拒绝、新版本接受”的行为差异。
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考