tar.xz 深潜:格式、随机访问与导航解压提速实测
八月 10, 2026 更新: 八月 11, 2026 [Linux 与系统]背景
排查过程中遇到两个真实问题:
- 两个
.tar.xz包解压后,一个多出一个.目录、所有内容都在里面;另一个直接散开。 - 经常要从一个很大的
.tar.xz里读取固定的wfuzz-package.json,但不想每次全量解压。
这两个问题分别指向 tar 的路径/成员顺序语义 和 xz 容器的块结构是否支持导航(随机访问)。本文把两条线都讲透,并给出实测数据。
更新说明:本文经过多轮实测反复修订。两处关键纠错:(1) 原"纯 tar 末尾成员必须解压整个流"——实际 GNU tar 在可 seek 普通文件上用
lseek跳过数据,纯 tar 找末尾成员几乎免费(见 1.4 节);(2) 原"tar.xz 中文件在开头,tar -xOf流式天然很快"——实际tar -xOf在压缩流上从不提前退出,无论位置/块数都解压整个流(见 1.4 节),头部提速要靠自定义早停读取器(见 1.5 节)。另补充:成员顺序实测(子目录乱序)、tar 头校验和验证、硬链接判重(dev+inode)、wfuzz-db"目标不在最后一块"反例、json 最前打包方案等。
一、tar 格式:顺序磁带归档,无索引
1.1 存储结构
tar(Tape Archive)设计之初就是给磁带用的——纯顺序写入,从头到尾只有一种元素:512B 头 + 数据:
+---------+--------+---------+--------+---------+--------+------+
| 512B头 | 数据A | 512B头 | 数据B | 512B头 | 数据C | 2*512B 全零块
+---------+--------+---------+--------+---------+--------+------+
每个头里记录:文件名(100 字节)、大小(八进制,偏移 124 起的 12 字节)、权限、mtime、类型(普通文件/目录/软链…)、校验和(偏移 148-155 的 8 字节,见 3.2 节)。
关键特性:
- 没有索引/目录表:想知道某个文件在哪,必须从头把前面的头一个个解析过去。
- 没有压缩:tar 只是打包,压缩靠外层工具(xz/gzip/zstd)。
- 数据长度可推导:
文件数据区 = 头 + ceil(大小/512)*512,因此可以顺序跳读到下一个文件,但不能随机跳读到任意文件(不知道偏移)。 - 目录也是条目:目录以
typeflag='5'、size=0 的头存储,用于还原目录权限与时间戳。
1.2 路径前缀与成员顺序:.目录 之谜的根源
归档里的成员名是打包时写入的原始路径,解压方基本原样还原:
| 打包命令 | 存储的成员名 | 备注 |
|---|---|---|
cd dir && tar -cf a.tar * | file1、sub/、sub/f3 | 无 ./ 前缀 |
tar -cf a.tar -C dir . | ./、./file1、./sub/… | 带 ./ 前缀,且多了 . 条目 |
同一个 GNU tar 解压两者结果完全一致(实测 diff -r 无差异),因为 GNU tar 解压时会剥掉开头的 ./。你看到".目录"通常是因为解压工具不剥离 ./(如 bsdtar/libarchive、7-Zip、WinRAR 等按原始路径还原)。
更隐蔽的是成员顺序。实测(创建序 zz→aa,与字母序、哈希序分别对比):
cd dir && tar -cf a.tar *:顶层由 shell glob 展开,按字母序 → 确定、可移植。- 但子目录内部是乱序的!实测
sub/内顺序xx zz aa bb mm yy,既非字母序也非创建序——tar 递归进子目录后由它自己readdir()遍历,得到的是文件系统内部顺序(ext4 h-tree 按文件名哈希排序,哈希种子是 mkfs 时随机生成的)。 tar -C dir .:连顶层也是 readdir 序,整体都不可预测。
实测对比(同一份数据):
顶层(glob字母序): afile bfile cfile sub/
sub/ 内(tar 实际序): xx.txt zz.txt aa.txt bb.txt mm.txt yy.txt
字母序应为: aa.txt bb.txt mm.txt xx.txt yy.txt zz.txt
创建序应为: zz.txt yy.txt xx.txt mm.txt aa.txt bb.txt
结论:方法 B(readdir 序)不是随机,而是"实现相关、不可预测、不可依赖";方法 A 只有顶层字母序。想让包括子目录在内全部有序,唯一方式是 tar --sort=name(GNU tar 1.28+)。
1.3 重复归档与硬链接:tar 按 dev+inode 判重
显式参数 + 通配/目录遍历重复指定同一文件时(如 tar -cf x.tar wfuzz-package.json *、tar -cf x.tar -C d wfuzz-package.json .),tar 不会报错、不会重复存数据,而是把第二次出现存成硬链接指向第一次的名字:
-rw- wfuzz-package.json
... ./data.txt ...
hrw- ./wfuzz-package.json 连接到 wfuzz-package.json ← 硬链接自引用
判重依据是 dev + inode,不是名字。两个对照实验证明:
- 同 inode 不同名(
ln a b硬链接):tar -cf A.tar a b→b 连接到 a(判重)✓ - 同名不同 inode(两个目录下的同名文件):
tar -cf B.tar d1/x d2/x→ 存两份(不判重)✓
注意硬链接条目本身 size=0 不带数据,内容在 linkname 字段(头偏移 157-256)指向的成员里——这影响块导航读取(见 3.3 节)。
1.4 tar 支持文件导航吗?
格式层面不支持:没有文件名→偏移的索引,只能顺序解析头。但分两种场景:
- 纯 tar + 可 seek 的普通文件:GNU tar 跳过非目标成员数据时用
lseek(),只读 512B 头。实测 1.5GB 纯 tar 提取末尾成员仅 0.003s(cat全读需 0.109s)。所以纯 tar 找末尾文件本来就几乎免费。 - tar.xz(压缩流)/ 管道:无法 seek 明文 tar,必须顺序读。且关键发现——标准
tar -xOf/tar -xf在压缩流上从不提前退出,命中目标后仍会把整个流读完!
验证方法(追加垃圾字节):把多块包复制一份并 printf 'GARBAGE' >> 追加垃圾,然后 tar -xOf x.tar.xz wfuzz-package.json。实测无论 json 在头部还是尾部、单块还是多块,都报 xz:压缩数据已损坏——说明 tar 读到了流末尾。tar 面对的是 xz 吐出的连续字节流,它不认识 xz 块、也没有"匹配到目标就停"的逻辑,会一直读到 EOF 零块确认归档结束。xz 解压多少 = tar 读多少。
所以 tar.xz 下"文件在开头就快"是不成立的——标准 tar 会把整个流解压完。能早停的只有自定义读取器(见 1.5 节)或块级跳读(见第三章)。
1.5 早停流式读取:头部文件的正确读法
既然 tar 不早停,就自己写一个"边解压边解析 tar 头,命中即断管道"的读取器:
import subprocess
p = subprocess.Popen(['xz','-dc',archive], stdout=subprocess.PIPE, bufsize=0)
buf, off = b'', 0
while True:
chunk = p.stdout.read(65536)
if not chunk: break
buf += chunk
while len(buf)-off >= 512:
h = buf[off:off+512]
if h == b'\x00'*512: raise SystemExit('未命中')
name = h[0:100].split(b'\x00')[0]
if name == b'wfuzz-package.json':
size = int(h[124:136].split(b'\x00')[0].strip(), 8)
sys.stdout.buffer.write(buf[off+512:off+512+size])
raise SystemExit
size = int(h[124:136].split(b'\x00')[0].strip() or b'0', 8)
off += 512 + ((size+511)//512)*512
实测(50MB 单块包,json 在头部):早停读取器只解压 712 字节就命中退出,而 tar -xOf 解压完整 52,439,040 字节。头部位置 + 早停机制对单块包同样有效——这是单块包唯一能大幅提速的路径。
块级增量解压:上面的早停是在 xz -dc 层面(进程级管道)。其实单个 xz 块也不需要完整解压——LZMA2 是顺序 chunk 流,解码器是流式状态机,可以用 lzma.LZMADecompressor.decompress(data, max_length) 增量解压、命中即停。实测对 wfuzz-python 最后一块(压缩 2.38MB / 解压 17.6MB):只要 512 字节输出,只消费了 256 字节压缩数据(占整块 0.01%)就停。
但收益有上限:增量解压省的是"目标之前的解压量"。目标在块内越靠前省得越多;目标在块尾(如 wfuzz-python 的 json 在块内偏移 17.6MB/17.6MB)就得解压几乎整块。所以:
节省量 ≈ 目标在流/块中的偏移(前面的部分不用解)
目标在头部 → 只解几百字节
目标在中间 → 解到它为止
目标在末尾 → 解压几乎整块
1.6 tar.xz 的耗时构成
实测 wfuzz-python(184.8 MiB / 8 块):
| 操作 | 耗时 |
|---|---|
纯解压 xz -dc > /dev/null | 0.295s |
| 解压 + tar 提取末尾文件 | 0.299s |
| 解压 + tar 全量列表 | 0.302s |
三者几乎相同 → tar.xz 的耗时 ≈ 100% 来自 xz 解压,tar 的流式头解析/提取开销可忽略。要优化 tar.xz 读取,只能减少解压量。
二、xz 格式:可随机访问的块容器
2.1 容器结构
.xz 是容器格式,内部是"一个或多个 Block + 尾部 Index":
+------------+--------+--------+-----+---------+-------+--------------+
| 流头(12B) | 块1 | 块2 | ... | 块N | Index | 流尾(12B) |
+------------+--------+--------+-----+---------+-------+--------------+
| 部件 | 内容 |
|---|---|
| 流头 | 魔数 + 校验类型(CRC32/CRC64/SHA-256) |
| 块 | 块头(压缩设置、字典大小、可选压缩前/后大小)+ 独立 LZMA2 数据 + 填充 + 校验 |
| Index | 每块的 Unpadded Size(块头+数据+校验)和 Uncompressed Size |
| 流尾 | 指向 Index 的 Backward Size + 校验 + 魔数 |
2.2 为什么能导航
关键在两点:
- Index 在文件末尾,记录了每块的压缩大小和解压大小 → 从文件尾往前读几字节,就能算出每个块在文件里的精确偏移(
xz -lv就能看到"压缩偏移量/解压偏移量"两列)。 - 每个块是独立的 LZMA2 流。只要块与块之间做了"字典重置"(多块压缩时 xz 默认如此),任意一块都能独立解码,不需要前面块的数据。
这就是 .xz 官方规范里 Index 的用途之一:"quickly access the beginning of any Block (random access)"。
2.3 块数 ≠ 线程数
实测同一份 150MiB 数据的块数:
| 参数 | 块数 |
|---|---|
-T1 | 1 |
-T4(不指定块大小) | 7 |
-T4 --block-size=24MiB | 7 |
-T4 --block-size=1MiB | 151 |
-T2 --block-size=24MiB | 7 |
-T8 --block-size=24MiB | 7 |
规律很清晰:
- 块数 = ⌈输入大小 / 块大小⌉,与线程数无关。线程只决定并行度(同时压缩/解压多少个块,块多时按队列轮流处理)。
-T1不分割 → 单块;-T N(N>1)才按块切分。- 当前 xz 5.8 默认
-T0(用满所有核)且自动选块大小,所以连普通xz -c都会产出多块——这也是参考包里wfuzz-python恰好 8 个 24MiB 块的原因。 - 解压同理:
xz -d -T N把各块分给 N 线程并行解压。
顺带:1GB 数据压缩耗时 -T1 4 分钟 vs -T4 1 分钟(≈4x),块大小不伤压缩比。
--block-size 是压缩前(输入)的字节数。实测 1MiB 随机 + 1MiB 全零、--block-size=1MiB:两块未压缩大小都恰为 1MiB,压缩后分别 1,048,656 B 和 252 B。块边界按输入字节切,与压缩率无关(xz 手册原话:"start a new .xz block after every SIZE bytes of input")。
多线程压缩不影响成员排序。xz 只把 tar 传来的字节流按顺序切成块,从不重排。实测同一 tar 用 -T1 和 -T4 --block-size=1MiB 压缩,tar -tf 成员顺序 diff 完全一致。成员顺序 100% 由 tar 决定,xz 只决定块怎么切。
--block-size 的版本兼容性:该选项从 XZ Utils 5.2.0(2014) 就有(与多线程压缩 -T 一起引入),解析层面几乎无兼容问题。真正的坑在 -T0 语义:
| xz 版本 | -T0 含义 | -T0 --block-size 是否切块 |
|---|---|---|
| 5.2.0~5.3.x | 禁用多线程 | 单线程下不切块 → 单块(静默失效!) |
| 5.4.0+(2022) | 用满所有核 | 生效 → 多块 ✓ |
想跨版本行为确定,用显式线程数:XZ_OPT='-T2 --block-size=24MiB'(5.2.0 起任何版本都稳定切块),并可用 xz -l 事后验证块数。另外 XZ_OPT 是很老的环境变量(5.0 时代就有),XZ_DEFAULTS 则是 5.4.0 才加。
2.4 其他压缩格式:早停通用,跳块需索引
把"早停"和"跳块"拆成两个能力,结论不同:
- 早停(流式部分解压)——所有压缩算法通用。任何解码器都是顺序流式状态机(DEFLATE/LZMA2/bzip2/zstd…),从文件头开始就能增量解压、命中即断。实测 gzip:30MB 的
.gz早停只解压 1KB 输出就停。 - 跳块(随机访问)——需要格式自带"索引 + 独立块"。只有容器层记录了每块偏移的格式才能跳:
| 格式 | 独立块? | 块索引? | 可跳块随机访问? |
|---|---|---|---|
| xz | ✓ | ✓(尾部 Index) | ✓ |
| zip | ✓ | ✓(central directory) | ✓ |
| zstd(seekable 模式) | ✓ | ✓(专用格式) | ✓ |
| 7z | ✓ | ✓(头部目录) | ✓ |
| gzip | ✗ 单个 DEFLATE 流 | ✗ | ✗ |
| bzip2 | ✓(900KB 块) | ✗ 无索引 | ✗(找不到块边界) |
| zstd(普通模式) | 帧结构 | ✗ | ✗ |
| tar | ✗ | ✗ | ✗ |
关键:流式解码是解码器的天性(所有格式都有);"跳块"是容器格式的设计——必须有类似 xz 的 Index、zip 的 central directory 来记录每块偏移。目标在头部时用早停就够(任何格式);目标在末尾时,只有带索引的格式(xz/zip/zstd-seekable/7z)能跳过前面的大段解压。
三、tar.xz 组合:按块导航 + 校验和验证解析
3.1 能直接跳到任意文件吗?
不能直接跳,但可以"间接"跳:
.xz的 Index 告诉你解压流的哪个区间对应哪个块。- 但 tar 没有"文件名→解压偏移"的索引,要确定目标文件在解压流的偏移,必须解析一次 tar 头。
- 所以完整链路是:tar 头解析出目标偏移 → 用 Index 定位该偏移所在块 → 只解压这个块。
业界现成方案:ratarmount(FUSE 挂载建索引)、pixz(打包时建 .tpxz 索引)、自研脚本(见附录)。
3.2 块边界不在 tar 成员边界:用校验和定位,不用字符串匹配
常见疑问:xz 块边界可能切在某个成员的数据中间(实测 wfuzz-python 最后一块从 python/share/terminfo/w/... 中间开始,块内包含 3442 个真实 tar 头),那怎么知道块内哪个位置是真正的头?
答案:tar 头自带 8 字节校验和字段(偏移 148-155),值 = "把校验和字段当作空格后,全 512 字节求和":
valid ⟺ checksum字段是合法八进制 且
int(字段) == sum(h[0:148]) + sum(h[156:512]) + 8*0x20
两个对齐性质保证可枚举:
- tar 头永远 512 字节对齐;
- xz 块大小是 512 的倍数(24MiB = 512×49152)→ 块起始偏移也是 512 的倍数。
所以块内候选头位置就是 块长/512 个 512 网格点,逐个做校验和验证即可。随机数据块碰巧通过校验和的概率约 1/2^19(实测 34396 个候选位中只筛出真实头 3442 个),再用 size 字段向前走、与结尾的 EOF 零块衔接做二次校验,基本是确定性解析。
字符串匹配只能当快速预筛选(find(b'wfuzz-package.json')),不可靠——文件名可能恰好出现在某个文件的数据区里。最终解析必须走校验和 + size 字段。
3.3 探测策略:先试最后一块,未命中回退全量
由于清单类文件(wfuzz-package.json)在多数包里排在末尾,实用的启发式:
多块包 → 从最后一块向前探测(512 网格 + 校验和验证)
├─ 命中:只解压了目标块,秒级
└─ 未命中:回退全量解压扫描
单块包 → 无 Index 可定位,直接全量
注意反例:位置完全取决于打包机的 readdir 顺序,不保证在最后。见 4.3 节的 wfuzz-db。
链接条目处理:硬链接(typeflag='1'/'h')和软链接('2'/'l')自身不带数据,内容在 linkname 字段指向的成员里。若目标条目是链接:
- 硬链接:真实数据一定存储在更早的位置(tar 在第二次遇到同一 inode 时才写链接条目)——可能在更早的块里,探测最后一块解压不到时需回退全量。
- 软链接:目标可能在归档内,也可能指向归档外路径(归档里根本没有内容可读,只能报告无法解析)。
- 附录脚本的
extract()会递归解析linkname,并带成环检测。
两种快速读取路径对比:
| 策略 | 适用 | 优点 | 局限 |
|---|---|---|---|
| 早停流式(1.5 节) | json 在头部 | 单块/多块都行,只解压几百字节 | 目标在尾部时退化为全量 |
| 块级跳读(本章) | json 在末尾、多块包 | 跳过无关块,解压单个块 | 单块包无效;json 在中间时探测落空 |
四、实测提速数据
4.1 wfuzz-python(184.8 MiB / 8 块,json 在最后一块)
| 方式 | 耗时 |
|---|---|
| 全量解压 + 提取 | 1.23s |
| 按块跳读(只解最后一块 2.38MB) | 0.13s(≈10x) |
4.2 自造 1GiB 大包(1,036.7 MiB / 44 块,json 用 --sort=name 保证最后)
| 方式 | 耗时 | 说明 |
|---|---|---|
| 全量解压 + 提取 | 1.17s | 22 核并行解压 |
| 按块跳读(朴素版,读全文件再定位) | 0.315s | 瓶颈在读完 700MB 压缩文件 |
| 按块跳读(seek 版,只读尾部 2.3KB) | ≈0.005s | ≈230x |
注:全量解压才 1.17s,是因为 22 核并行 + 大量不可压缩随机数据走 LZMA2 无压缩快通道。真实高压缩比数据单核解压更慢,导航优势更明显。
4.3 反例:wfuzz-db(json 不在最后一块)
wfuzz-db-1.10.10(161 成员 / 2.7GB / 115 块):打包机 readdir 顺序把大 bin/ 目录排在了 wfuzz-package.json 后面(lib/ → json → bin/),导致 json 在成员 122/161、字节偏移仅 8.1%、位于第 10/115 块。
| 方式 | 耗时 |
|---|---|
| 探测最后一块 → 落空 → 全量解压 2.7GB | 14.0s |
| 知道它在第 10 块,直接跳读该块 | 0.21s(≈66x) |
教训:"目标在最后一块"只是 readdir 运气,不是设计。探测策略的收益 = 命中率 × 加速比,未命中就回到全量路径。
4.4 单块包:无法导航(对照)
同一份数据用 -T1 压成单块包,跳读无从谈起,只能全量解压。单块 = 放弃了随机访问能力,这是压缩时就要做的取舍。
五、真实案例:wfuzz 系列包为什么 json 位置飘忽
5.1 打包方式根因
wfuzz 系列的打包在 CMake 里(cmake/package.cmake):
set(ENV{XZ_DEFAULTS} "-T0")
execute_process(COMMAND ${CMAKE_COMMAND} -E chdir "${WFUZZ_TOOL_INSTALL_DIR}"
tar cvfJ ".../xxx.tar.xz" .)
即 tar .(方法 B)+ XZ_DEFAULTS=-T0。成员顺序 = 打包机器上安装目录的 readdir 序,与 wfuzz-package.json 相对大目录(bin/lib)的哈希位置有关。
5.2 实测各包位置
| 包 | wfuzz-package.json 位置 | xz 块数 |
|---|---|---|
| wfuzz-os-0.1.3 | 49/49(最后,100%) | 1 |
| wfuzz-agent-0.1.44 | 7587/7587(最后) | 12 |
| wfuzz-vscode-server | 12879/12879(最后) | 1 |
| wfuzz-python-v0.8.1 | 7354/7354(最后) | 8 |
| wfuzz-db-1.10.10 | 122/161(8.1%!) | 115 |
5.3 修复建议
两个方向,都能保证 json 位置确定且无硬链接(已实测验证)。
方案一(推荐):json 放最前 + 无硬链接,配合早停流式读取(单块/多块都有效):
# 写法1: 无 ./ 前缀
cd wfuzz-pkg && \
tar -cJf ../wfuzz-pkg.tar.xz wfuzz-package.json --exclude=wfuzz-package.json *
# 写法2: 保留 ./ 前缀
tar -cJf wfuzz-pkg.tar.xz -C wfuzz-pkg wfuzz-package.json --exclude=wfuzz-package.json .
tar按命令行参数顺序归档 →wfuzz-package.json第一个参数 = 归档第一个成员;--exclude=wfuzz-package.json让通配符*/ 目录.遍历时跳过 json,避免同一 inode 归档两次 → 无硬链接、json 只出现 1 次;- 实测:两种写法的 json 都是第 1 位、
tar -tvf无h硬链接条目、tar -tf里 json 仅出现 1 次,解压结果逐字节一致(差异仅./前缀)。
方案二:json 放最后,配合块级跳读(仅多块包有效):
cd "${WFUZZ_TOOL_INSTALL_DIR}" && tar --sort=name -cf - . | xz -T0 --block-size=24MiB -c > out.tar.xz
--sort=name 让 w 开头的 json 排在 bin/、lib/ 之后(最后)。
注意:两种方案的关键都是让 json 只归档一次。
tar -C dir wfuzz-package.json .这类显式+遍历重复的写法会产生硬链接自引用条目(见 1.4 节),虽然无害但多余。关于多块:只读头部 json(早停读取)不需要多块,
-cJf即可。若还需保证多块可导航/并行解压,两种方式任选:
- 方式1:
XZ_OPT环境变量(保持-cJf单命令,推荐):XZ_OPT='-T0 --block-size=24MiB' tar -cJf out.tar.xz wfuzz-package.json --exclude=wfuzz-package.json *- 方式2:管道:
tar -cf - wfuzz-package.json --exclude=wfuzz-package.json * | xz -T0 --block-size=24MiB -c > out.tar.xz注:
-cJf是否多块取决于机器 xz 版本(xz 5.8 默认-T0已多块,旧版是单块),所以要想稳定多块需显式指定。
六、实践建议
针对"反复读大包里固定某个文件":
- 先想清楚目标在包的哪个位置——
tar -xOf在压缩流上从不提前退出(读完整流),位置对标准 tar 无影响。 - 文件在开头:用早停流式读取器(1.5 节),只解压几百字节,单块/多块都行。
- 文件在末尾且包是多块的:按块跳读最优(见附录工具);或装
ratarmount挂载后直接读。 - 文件在末尾且包是单块的:无法跳块,只能全解压一次后缓存目标文件。
- 文件在中间(readdir 序导致):块跳读探测落空 → 一次全量扫描缓存偏移,之后直接跳该块;或从源头改打包(5.3 节方案)。
- 自己打包含固定目标时:首选"json 放最前 +
xz --block-size"(5.3 方案一),配合早停读取最通用;不要用会重复归档的写法(会产生硬链接)。
七、附录:按块探测工具(校验和验证版)
#!/usr/bin/env python3
"""xz_read.py — 从 tar.xz 中按块导航读取单个成员,只解压目标所在块。
原理:
1. 解析 .xz 流尾 Index,得到每块的压缩区间与解压区间
2. 多块包:从最后一块向前探测目标成员(tar 头用校验和验证,非字符串匹配)
3. 命中成员若是硬链接/软链接,解析 linkname 字段追踪到真实数据
4. 未命中、或真实数据不在已解压块内:回退全量解压扫描
用法:
python3 xz_read.py <archive.tar.xz> <member> # 输出到 stdout
python3 xz_read.py <archive.tar.xz> <member> -o out # 写入文件
"""
import sys, lzma, struct, time
REGULAR = b'\x00\x30' # 普通文件 typeflag: '\0' 或 '0'
HARDLINK = b'\x31\x68' # 硬链接: '1' 或 'h'
SYMLINK = b'\x32\x6c' # 软链接: '2' 或 'l'
def read_vli(b, o):
v = 0
for i in range(9):
x = b[o + i]
v |= (x & 0x7F) << (7 * i)
if not x & 0x80:
return v, o + i + 1
raise ValueError("bad VLI")
def xz_blocks(path):
data = open(path, 'rb').read()
sz = len(data)
footer = data[sz - 12:]
idx_real = (struct.unpack('<I', footer[4:8])[0] + 1) * 4
idx = data[sz - 12 - idx_real:sz - 12]
n, o = read_vli(idx, 1)
recs = []
for _ in range(n):
up, o = read_vli(idx, o)
uc, o = read_vli(idx, o)
recs.append((up, uc))
start = 12
blocks = []
for up, uc in recs:
padded = (up + 3) & ~3
blocks.append((start, start + padded, up, uc))
start += padded
return data, blocks
def decompress_block(data, blk):
so, eo, up, uc = blk
b = data[so:so + up]
hsize = ((b[0] & 0x3F) + 1) * 4
flags = b[1]
o = 2
if flags & 0x40:
_, o = read_vli(b, o)
if flags & 0x80:
_, o = read_vli(b, o)
nf = (flags & 0x03) + 1
filters = []
for _ in range(nf):
fid, o = read_vli(b, o)
plen, o = read_vli(b, o)
props = b[o:o + plen]
o += plen
if fid == 0x21:
bits = props[0] & 0x3F
dsize = (2 | (bits & 1)) << (bits // 2 + 11) if bits < 40 else 0xFFFFFFFF
filters.append({'id': lzma.FILTER_LZMA2, 'dict_size': dsize})
d = lzma.LZMADecompressor(format=lzma.FORMAT_RAW, filters=filters)
return d.decompress(b[hsize:])
def tar_checksum_ok(h):
field = h[148:156].split(b'\x00')[0].strip()
if not field or not all(c in b'01234567' for c in field):
return False
stored = int(field, 8)
return stored == sum(h[:148]) + sum(h[156:]) + 8 * 0x20
def find_member(raw, member):
"""512 网格 + 校验和扫描,返回匹配成员的头 (hdr, off) 或 None。"""
target = member.encode()
for p in range(0, len(raw) - 512, 512):
h = raw[p:p + 512]
if not tar_checksum_ok(h):
continue
name = h[0:100].split(b'\x00')[0]
if name.lstrip(b'./') != target.lstrip(b'./'):
continue
return h, p
return None
def extract(raw, member, seen):
"""从已解压数据中提取 member 内容,支持解析硬链接/软链接。"""
if member.lstrip('./') in seen:
return None, "链接成环"
hit = find_member(raw, member)
if hit is None:
return None, "成员或其链接目标不在已解压数据中"
h, p = hit
tflag = h[156:157]
size = int(h[124:136].split(b'\x00')[0].strip() or b'0', 8)
if tflag in REGULAR:
return raw[p + 512:p + 512 + size], None
if tflag in HARDLINK or tflag in SYMLINK:
linkname = h[157:257].split(b'\x00')[0].decode(errors='replace')
return extract(raw, linkname, seen | {member.lstrip('./')})
return None, f"不支持的成员类型 typeflag={tflag!r}"
def main():
if len(sys.argv) < 3:
sys.exit(__doc__)
path, member = sys.argv[1], sys.argv[2]
out = None
if '-o' in sys.argv:
out = sys.argv[sys.argv.index('-o') + 1]
data, blocks = xz_blocks(path)
t0 = time.time()
for blk in reversed(blocks):
if len(blocks) == 1:
break
raw = decompress_block(data, blk)
content, err = extract(raw, member, set())
if content is not None:
print(f"[probe] 命中! 跳过 {len(blocks)-1} 个块, "
f"仅解压 {blk[3]//1024}KB ({time.time()-t0:.3f}s)", file=sys.stderr)
if out:
open(out, 'wb').write(content)
else:
sys.stdout.buffer.write(content)
return
if err and '链接目标' in err and blk is blocks[0]:
# 链接目标在更早的块里, 交回全量扫描解决
break
print(f"[fallback] 全量解压扫描 ({time.time()-t0:.3f}s)", file=sys.stderr)
raw = b''.join(decompress_block(data, b) for b in blocks)
content, err = extract(raw, member, set())
if content is None:
sys.exit(f"!! 无法获取 {member}: {err}")
if out:
open(out, 'wb').write(content)
else:
sys.stdout.buffer.write(content)
if __name__ == '__main__':
main()
实测该脚本输出与 tar -xOf 全量提取逐字节一致;wfuzz-python 命中时只解压 17MB/184MB。
关于链接条目:tar 里的硬链接(typeflag='1'/'h')和软链接(typeflag='2'/'l')自身不带数据,内容存于 linkname 字段(头偏移 157-256)指向的成员。若目标条目恰好是链接:
- 硬链接:真实数据一定存储在更早的位置(tar 在第二次遇到同一 inode 时才写链接条目)——可能在更早的块里,探测最后一块解压不到时需回退全量。
- 软链接:目标可能在归档内,也可能指向归档外路径(归档里根本没有内容可读,只能报告无法解析)。
- 脚本的
extract()会递归解析linkname,并带成环检测。
总结
- tar:顺序无索引,不支持文件导航;但 GNU tar 在可 seek 普通文件上用 lseek 跳过数据,纯 tar 找末尾成员几乎免费。成员顺序/
./前缀取决于打包命令(glob 字母序 vs readdir 哈希序),子目录内部恒为 readdir 序,只有--sort=name能全局排序。重复归档同一文件会生成硬链接自引用,判重依据是 dev+inode 而非名字。 - xz:多块容器 + 尾部 Index,天然支持块级随机访问;但单块包(
-T1)无法导航。块数由块大小决定(⌈输入/块大小⌉,按输入字节计),与线程数无关;多线程压缩不影响成员排序。 - 标准 tar 在压缩流上从不提前退出:
tar -xOf/tar -xf命中目标后仍把整个流读完(无论单块/多块、头部/尾部),所以 tar.xz 提速必须靠自定义读取。 - 两条快速路径:目标在头部 → 早停流式读取(单块/多块都有效,只解压几百字节);目标在末尾且多块 → 块级跳读(Index 定位 + 只解压目标块)。实测 10x~230x 提速。
- 块可流式部分解压:LZMA2 是顺序 chunk 流,单个块也能增量解压(
decompress(max_length)),不需要解完整个块;省的是"目标之前的解压量"。 - 可靠性:块内定位靠 tar 头校验和(512 网格验证),不是字符串匹配;块边界切在成员中间不影响,因为块大小是 512 的倍数。链接条目(硬/软)需要解析
linkname字段追踪真实数据。 - 打包建议:想稳定快速读取,把目标文件放最前(配合早停)或放最后(
--sort=name配合多块跳读);两者都要避免重复归档以防硬链接。保证多块用XZ_OPT='-T2 --block-size=24MiB'(显式线程数跨版本稳定,-T0在旧版 xz 会静默失效)。 - 跨格式:早停(流式部分解压)对所有压缩算法通用;跳块随机访问只有带索引的格式支持(xz/zip/zstd-seekable/7z),gzip/bzip2 无索引不能跳块。
- 反例警示:目标是否在最后一块取决于打包机 readdir 顺序(wfuzz-db 实测 json 在第 10/115 块),探测未命中时回退全量;根治方法是
tar --sort=name保证顺序。