尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

CPython tarfile 修复解析:GNU sparse 1.0 成员在 pax 扩展头中设置 size 时后续成员不可达问题(gh-83869)

CPython tarfile 修复解析:GNU sparse 1.0 成员在 pax 扩展头中设置 size 时后续成员不可达问题(gh-83869) CPython tarfile 修复解析GNU sparse 1.0 成员在 pax 扩展头中设置 size 时后续成员不可达问题gh-83869【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython本篇技术指南围绕 CPython 仓库中Misc/NEWS.d/next/Library/2020-02-19-16-35-52.gh-issue-83869.EPD_zn.rst所记录的tarfile模块缺陷展开深入剖析读取包含 GNU sparse 1.0 稀疏成员的归档时由于 pax 扩展头覆盖 size 字段、偏移计算错误导致归档后续所有成员不可达的根因、修复实现与回归测试。读完本文你将理解 GNU sparse 在 tar 归档中的多种存储格式、pax 扩展头中size与GNU.sparse.realsize的语义差异以及tarfile内部如何正确推算下一个成员头的偏移量并掌握通过标准库测试用例验证该修复的方法。一、问题背景什么是 GNU sparse 成员稀疏文件sparse file是指逻辑长度远大于实际占用磁盘空间的普通文件其内部存在大量内容为全零的空洞hole区间。常见于数据库镜像、虚拟机磁盘如 qcow2/raw 快照、交换文件等场景。例如一个逻辑大小 1 GiB 的稀疏文件可能只在开头和结尾各写了少量真实数据其余全部是空洞。tarfile模块Lib/tarfile.py在归档稀疏文件时如果不做特殊处理就会把全部空洞以物理零字节写入归档造成巨大的空间浪费。因此 GNU tar 引入了一系列sparse 成员表示格式仅在归档中记录稀疏映射表sparse map——即每个数据区间的(offset, numbytes)对——以及被实际写入的数据块。在 Lib/tarfile.py 的头部可以找到对 sparse 类型的定义GNUTYPE_SPARSE bS # GNU tar sparse file从 Lib/tarfile.py 的_proc_pax()分派逻辑可以看到CPython 的tarfile支持识别以下三类通过 pax 扩展头描述的 GNU sparse 格式格式版本判据pax 头关键字处理方法GNU sparse 0.0存在GNU.sparse.size_proc_gnusparse_00()GNU sparse 0.1存在GNU.sparse.map_proc_gnusparse_01()GNU sparse 1.0GNU.sparse.major 1且GNU.sparse.minor 0_proc_gnusparse_10()此外还有完全不依赖 pax 头的旧式 GNU sparse 格式GNUTYPE_SPARSE成员类型为bS由 Lib/tarfile.py 的_frombuf()在解析 512 字节头块时从空闲字段中直接收集最多 4 个稀疏结构再由 Lib/tarfile.py 的_proc_sparse()继续读取扩展头块完成处理。gh-83869 所修复的缺陷正发生在GNU sparse 1.0这一分支上。二、缺陷描述后续成员全部不可达NEWS 条目Misc/NEWS.d/next/Library/2020-02-19-16-35-52.gh-issue-83869.EPD_zn.rst对缺陷的描述可以拆解为三个关键点场景读取一个归档其中某个成员是 GNU sparse 1.0 稀疏成员且其size被设置在 pax 扩展头中而非 tar 头的 size 字段。根因下一个成员头的偏移量此前是由数据的偏移量与成员的大小计算得出的——但此时数据的偏移量已经越过了 sparse map即指向稀疏映射表之后的真实数据区而成员的大小取到的可能是稀疏文件的表观大小apparent size。后果偏移量被严重高估表观大小通常远大于实际存储字节数归档中所有后续成员都变得不可达。换句话说对于一个逻辑大小 1 MiB、实际只存储了几十字节数据的 sparse 1.0 成员如果用 1 MiB 去计算下一个头的偏移读取指针会直接越过归档中后续的所有成员导致TarFile.next()之后返回空EOFgetnames()、extractall()等操作都会漏掉归档后半部分的内容。三、源码级根因分析size 字段的两层语义要理解这个 bug必须先厘清 pax 扩展头Lib/tarfile.py 的_proc_pax()所解析的 XHDTYPE 成员中两个关键字的不同含义sizePOSIX pax 标准关键字在 sparse 1.0 场景下由 GNU tar 写入表示该稀疏成员在归档中实际存储的数据块总字节数即sparse map 长度 真实数据长度GNU.sparse.realsizeGNU 私有关键字表示稀疏文件的表观逻辑大小即空洞填充后的完整文件长度。在_apply_pax_info()Lib/tarfile.py中两者都会回写TarInfo.sizeif keyword GNU.sparse.name: setattr(self, path, value) elif keyword GNU.sparse.size: setattr(self, size, int(value)) elif keyword GNU.sparse.realsize: setattr(self, size, int(value)) elif keyword in PAX_FIELDS: ...由于 pax 头记录是按顺序逐条setattr的一旦GNU.sparse.realsize出现在size之后TarInfo.size最终就会变成表观大小。而稀疏映射表本身在归档中也是要占字节的数据区的起点位于 sparse map 之后——这两点叠加正是 NEWS 条目所指出的偏移从数据偏移量出发、且乘上了表观大小的错误来源。GNU sparse 1.0 的完整成员在归档中的字节布局如下┌─────────────┬─────────────────────┬──────────────┐ │ pax 扩展头 │ sparse map文本行 │ 真实数据块 │ │ (XHDTYPE) │ 首行字段数 N │ │ │ sizemap数据│ 随后 N 个 offset/ │ │ │ │ numbytes 数字行 │ │ └─────────────┴─────────────────────┴──────────────┘ │ └── 下一个成员头从这里开始偏移需精确计算四、修复实现从 pax 头原始记录取实际大小重算偏移修复集中在 Lib/tarfile.py 的_proc_pax()尾部。在处理完 sparse 信息并应用 pax 头后如果发现 pax 头中存在size关键字即 size 被扩展头替换则不再依赖成员对象上可能被污染的TarInfo.size而是直接取 pax 头中的原始字符串值并解析为整数if self.type in (XHDTYPE, SOLARIS_XHDTYPE): # Patch the TarInfo object with the extended header info. next._apply_pax_info(pax_headers, tarfile.encoding, tarfile.errors) if size in pax_headers: # If the extended header replaces the size field, # we need to recalculate the offset where the next # header starts. offset next.offset BLOCKSIZE if next.isreg() or next.type not in SUPPORTED_TYPES: try: size PAX_NUMBER_FIELDSsize except ValueError: size 0 offset next._block(size) tarfile.offset offset next.offset self.offset这段代码的正确性建立在一个关键前提上pax 头中的size记录代表该稀疏成员在归档内真实占用的数据块大小sparse map 与真实数据之和。因此offset next.offset BLOCKSIZE先跳到 sparse map 的起点数据区之前size PAX_NUMBER_FIELDSsize从 pax 头原文解析实际存储大小而不是从成员对象读取后者可能已被GNU.sparse.realsize覆盖为表观大小offset next._block(size)按 512 字节块向上取整_block()负责补齐到BLOCKSIZE整数倍得到下一个成员头的准确偏移。与此同时Lib/tarfile.py 的_proc_gnusparse_10()负责在读取 sparse map 后精确定位数据区起点def _proc_gnusparse_10(self, next, pax_headers, tarfile): Process a GNU tar extended sparse header, version 1.0. fields None sparse [] buf tarfile.fileobj.read(BLOCKSIZE) fields, buf buf.split(b\n, 1) fields int(fields) while len(sparse) fields * 2: if b\n not in buf: buf tarfile.fileobj.read(BLOCKSIZE) number, buf buf.split(b\n, 1) sparse.append(int(number)) next.offset_data tarfile.fileobj.tell() next.sparse list(zip(sparse[::2], sparse[1::2]))注意其中next.offset_data tarfile.fileobj.tell()——它把offset_data设置为恰好越过 sparse map 的位置。offset_data是 Lib/tarfile.py 中TarInfo记录文件数据起始处的字段后续extractfile()Lib/tarfile.py正是通过它构造_FileInFile来读取成员内容的。只有offset_data与tarfile.offset分别指向真实数据区起点和下一成员头起点稀疏成员及其后继成员才能被同时正确读取。五、回归测试test_sparse_file_10_pax_size该修复的回归测试位于 Lib/test/test_tarfile.py 的test_sparse_file_10_pax_size()其注释直接引用了 gh-83869def test_sparse_file_10_pax_size(self): # gh-83869: when the pax header replaces the size field, the offset # of the next header must be computed from the size of the data in # the archive, not from the apparent size of the sparse file. data bpayload! * 4 realsize 1 20 smap b1\n%d\n%d\n % (realsize - len(data), len(data)) smap b\0 * (-len(smap) % tarfile.BLOCKSIZE) sparse tarfile.TarInfo(sparse) sparse.size len(smap) len(data) sparse.pax_headers { GNU.sparse.major: 1, GNU.sparse.minor: 0, GNU.sparse.name: sparse, GNU.sparse.realsize: str(realsize), size: str(sparse.size), } buf sparse.tobuf(tarfile.PAX_FORMAT) buf smap data b\0 * (-len(data) % tarfile.BLOCKSIZE) last tarfile.TarInfo(last) last.size len(data) buf last.tobuf(tarfile.PAX_FORMAT) buf data b\0 * (-len(data) % tarfile.BLOCKSIZE) buf b\0 * (tarfile.BLOCKSIZE * 2) with tarfile.open(fileobjio.BytesIO(buf)) as tar: self.assertEqual(tar.getnames(), [sparse, last]) self.assertEqual(tar.extractfile(last).read(), data)该测试精心构造了与真实缺陷完全对齐的场景稀疏成员表观大小为realsize 1 201 MiB而真实数据只有 32 字节bpayload! * 4sparse map 只有一对区间(realsize - len(data), len(data))表示空洞在前、数据在尾部pax 头同时给出GNU.sparse.realsize表观大小与size实际存储大小len(smap) len(data)sparse 成员之后紧跟第二个普通成员last这正是验证后续成员是否可达的关键设计最终断言tar.getnames()必须同时返回[sparse, last]且last的内容必须能被完整读出。在修复前的行为下偏移计算会使用表观大小 1 MiB 跳过大量字节last成员将永远无法被定位上述断言会失败修复后偏移取自 pax 头原始size记录两个断言均通过。同类 sparse 场景的其他回归用例集中在 Lib/test/test_tarfile.pytest_find_sparse、test_find_gnusparse、test_find_gnusparse_00/01/10以及 Lib/test/test_tarfile.py 的_test_sparse_file()系列test_sparse_file_old、test_sparse_file_00/01/10后者还会在支持空洞的文件系统上校验还原出的稀疏文件内容 SHA-256 与预期一致。六、影响范围与验证建议影响范围该缺陷影响所有通过tarfile读取由 GNU tar 以 sparse 1.0 格式pax 扩展头打包的归档的场景且稀疏成员在归档中越靠前、表观大小与实际大小差距越大后续成员丢失得越严重。使用旧式GNUTYPE_SPARSE或 sparse 0.0/0.1 格式的归档不受此问题影响——旧式格式由 Lib/tarfile.py 的_proc_sparse()处理其tarfile.offset在 size 被替换为origsize之前就已用原始头中的存储大小计算完毕顺序上不存在表观大小污染偏移的问题。验证与排查建议若怀疑本地 Python 受到该缺陷影响可用与测试用例等价的思路自检用tarfile.open()打开归档后调用tar.getnames()确认所有成员尤其是 sparse 成员之后的成员都在列表中升级到包含该修复的 CPython 版本后可运行标准库测试定位验证./python -m test test_tarfile -m test_sparse_file_10_pax_size该命令仅执行与本次缺陷直接相关的回归用例如需完整覆盖所有 sparse 相关用例可运行./python -m test test_tarfile -m test_sparse*生产环境中如长期依赖tarfile处理第三方 GNU tar 生成的稀疏归档建议在升级后对存量归档做一次getnames()全量比对确认无成员遗漏。修复要点回顾整个修复的核心原则可以概括为一句话——计算归档内物理偏移时必须使用归档中实际存储的字节数而非文件展开后的表观大小。从 pax 头的原始记录pax_headers[size]取值而非从可能被GNU.sparse.realsize覆盖的TarInfo.size取值正是这一原则在 Lib/tarfile.py 中的具体落地。【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表