Java常用类库-30-ZIP与TAR的隔离解包和资源预算
商品归档导入需要比“能解开”更窄的契约
商品批量导入允许上传一个 ZIP 或 TAR,里面放 CSV 与图片。归档的条目名称、条目类型和解压后大小均来自输入,应用不能因为解析库识别了格式,就直接把这些字段用于文件系统操作。一个格式合法的归档,也可以包含父路径、链接、重复目标或大量展开数据。
本篇固定 Commons Compress 1.28.0,提交 852d9c23b94127feafc1649d9c7f13d4df338845。实现只处理 ZIP 与未压缩 TAR,不推断或自动递归展开其他格式。输入先成为可信目录中的稳定本地文件;解包时在可信父目录下创建新的私有工作目录,成功才把该目录返回调用方,失败尝试清理整个工作目录。
完整实验见 Chapter30Test.java,实现见 PrivateArchive.java、SafePaths.java 和 BoundedIo.java,执行方式见 RUN.md。代码使用 Java 8 API,并在 JDK 8、JDK 21 运行同一组场景。
工作目录不允许不可信并发写者,输入文件在检查后不能被外部进程替换。计时检查不能中断卡住的解析或 read。实现没有通过目录句柄消除全部竞态,也未证明异常归档解析所需内存均有固定上界。它只适用于这些前提成立的受限环境。
归档格式、压缩格式与文件政策分属不同层
ZIP 同时组织条目并可压缩条目数据;TAR 组织条目,但本例读取的 TAR 本身没有额外 gzip 层。Commons Compress 为 archive 与 compressor 提供不同接口,这种区分影响入口选择、流关闭与预算计数的位置。
如果以后增加 tar.gz,压缩输入字节与解压后的 TAR 字节、最终普通文件字节就有不同含义。把一个计数器简单放在最外层,只能限制它实际看到的那一层,无法自动限制其他层的膨胀。递归归档还会增加深度、嵌套条目数与累积展开量,本例完全不做递归解释。
官方 Compress 示例介绍了条目遍历、可读性检查及部分流的输入统计能力。这些能力帮助应用获取信息,但接受哪些路径、哪些类型和多少数据仍需由应用决定。库识别一个条目,不表示业务应该创建该条目描述的对象。
第一项可迁移模式是给每层定义计数单位。上传入口限制归档文件字节,条目读取限制实际展开字节,遍历限制条目数量,整个任务另有时长政策。写成一个含糊的“最大文件大小”配置,容易遗漏单位和作用层。
ZIP 使用中央目录,但元数据仍需核对
本例使用 ZipFile 读取已经落盘的 ZIP,而不是只沿本地文件头顺序读取。中央目录提供条目尺寸、CRC 和 Unix 模式等信息,有利于类型判断及读取后核验。这里选择可寻址文件接口,是实现契约需要这些元数据,而不是断言所有流式 ZIP 用法都不正确。
ZipFile 固定源码负责读取归档结构并建立条目访问。实现对每个条目检查 canReadEntryData,拒绝库不能读取的特性,然后单独打开该条目的输入流。每个条目流在处理结束时关闭,归档对象也在外层关闭。
中央目录里的 size 不是资源预算的可信替代品。实际读取可能失败,内容也可能与元数据不一致,所以实现同时计数展开字节,并在完整读取后比较真实数量与声明尺寸;ZIP 还把写出的字节经过 CRC32,最终与条目 CRC 比较。CRC 用于发现损坏,不是认证来源的密码学机制。
本组测试通过截去 ZIP 后半部分验证结构损坏时失败及清理,没有专门伪造一份仅 CRC 错误但结构完整的归档。因此,CRC 比较分支有源码依据,却不能写成所有 CRC 损坏组合均已实测。归档兼容性与损坏检测的更大矩阵应另行补齐。
TAR 的 isFile 不能直接成为类型白名单
TAR 不仅可以记录普通文件和目录,还可以记录符号链接、硬链接、FIFO 等对象。商品导入只需要普通文件与目录,因此接受集合应明确列出,其他类型全部拒绝。
初版实验使用 isFile() || isDirectory() 判断允许类型,FIFO 反例未被拒绝。回到 TarArchiveEntry 固定源码检查,isFile 在正常文件标志之外,还有“不是目录且名称不以斜杠结尾”的兜底分支。由此可见,这个便利判断不能直接等同于本业务要求的严格类型白名单。
最终实现直接读取 getLinkFlag,只允许 LF_NORMAL、LF_OLDNORM 与 LF_DIR,同时拒绝符号链接、硬链接、稀疏文件及不可读取条目。测试构造实际 FIFO、软链接和硬链接 TAR,逐个要求抛 IOException,且失败后输出父目录为空。
ZIP 也有类型信息。实现拒绝 Unix 符号链接和非普通文件、非目录的已知 Unix 类型;目录类型与名称表示明显冲突时拒绝。没有 Unix 类型信息的普通 ZIP 条目仍可按本例政策接受,所以不能把该策略说成要求所有 ZIP 都有完整 Unix 元数据。
这项反例可迁移到很多库:一个名字看似精确的便利谓词,可能为了兼容历史格式而采用较宽判断。安全政策应核对实际字段和分支,最好用一个“名称普通但类型特殊”的反例验证,而不是只测试合法文件和带斜杠的目录。
名称检查必须先于写入
每个条目名称先交给 SafePaths。它拒绝空名称、NUL、绝对路径、冒号、反斜杠以及当前目录和父目录段,再对目标 normalize,并用 Path.startsWith 检查根内关系。反斜杠拒绝是本例的跨环境名称协议,不依赖当前操作系统是否把它当分隔符。
实验同时给 ZIP 与 TAR 输入 ../escaped、/escaped、C:/escaped、a\\..\\escaped。ZIP 中先放一个合法条目 accepted-first,再放危险名称,证明后续失败会清理此前已经创建的文件。测试还检查临时测试区的 escaped 不存在,合法条目不因归档整体失败而被发布。
对于路径父目录,实现逐段确认对象不是符号链接,并对真实父路径再次检查根范围。最终文件以 CREATE_NEW 打开,避免覆盖已经存在的对象。字符串路径检查、实际父目录检查和创建选项承担不同职责,不能删掉其中一层后仍沿用原来的保证。
这些检查没有消除不可信进程在检查后替换父目录的全部可能性。私有工作目录及可信父目录是必要前提。需要在敌对共享目录中工作时,应重新设计基于目录句柄和运行权限的隔离,不应把本示例增加几条正则后就扩大适用范围。
重复目标与既有对象选择直接失败
归档可能有两个同名条目,甚至先创建一个普通文件,再用目录条目或另一份内容覆盖。实现保存已经处理的目标,并拒绝任何已存在目标;文件打开仍使用 CREATE_NEW,让创建阶段保留同样政策。
测试构造两个名为 same 的 ZIP 条目,要求失败且清理。另一个样本先写 nested/file,自动创建了 nested 父目录,随后再出现显式 nested/ 目录条目,也被拒绝。这是有意采用的严格政策:已由处理过程建立的目录,同样不会因为后来的归档元数据而被重新接管。
这种政策会拒绝一些合法但条目顺序不符合本例要求的归档。兼容性不是免费获得的;如果要容许重复目录,需要单独定义目录属性合并、目录与普通文件冲突,以及哪些既有对象确实由本次任务创建。当前实现选择可解释的拒绝,不声称接受全部正常工具生成的 ZIP 排列。
合法目录也有正向样本。测试只含一个 empty/ 条目的 ZIP,解包后实际存在目录,再由调用方删除整个返回根。普通文件样本分别通过 ZIP 与 TAR 写出商品 CSV,读取文件内容逐字节一致。拒绝矩阵与成功路径同时存在,避免一个“全部拒绝”的实现也通过安全测试。
实际读取预算不能只查声明尺寸
Limits 分别保存最大条目数、单项展开字节、总展开字节、归档文件字节与时长。条目计数包含目录,防止大量空目录绕过字节预算。归档文件字节在开始前用 Files.size 检查,它限制当前稳定本地输入的大小,不代表上传阶段已经受到约束。
写普通文件时,允许字节数是单项上限与剩余总量的较小值。BoundedIo 使用 long 计数,只请求剩余允许量再加一个探测字节;发现超限后在写出这一块之前抛异常。这个额外读取用于区分“恰好达到上限且结束”与“还有数据”,所以输入侧可能比允许写出量多消耗一个探测字节。
例如单项预算为五字节,实际内容为六字节,不能因为声明尺寸写着五就停止读取并报告成功。正确结果应是发现仍有数据、任务失败、删除私有工作目录。部分文件可能已经暂时出现在工作目录,但它不会作为成功返回值交给调用者。
总量实验包含两个各六字节的文件,总预算为十。第一项完成后剩余四字节,第二项应失败;只检查每项都小于十不能实现总预算。另一个实验把条目上限设为一,同一双条目归档同样失败,即使字节总量很小。
解压缩算法可能有内部缓冲,实际请求的输出字节上限并不等于内部 CPU 或内存的严格上限。计数策略限制应用写出的展开数据,并提供尽早停止条件;如果要求强资源隔离,还需要进程或容器级资源控制及解析库的安全维护。
高压缩比与时间预算分别验证
高压缩比样本包含十万零字节,生成的 ZIP 小于两千字节。它通过正常归档文件大小门槛,却在一千零二十四字节单项展开限制处失败,并删除工作目录。这说明仅限制上传大小无法约束展开大小,也说明本例不必先精确计算压缩比才能停止该样本。
压缩比阈值可以作为额外信号,但极小压缩输入的比值可能敏感,合法高度重复数据也可能比值很大。这里直接采用业务允许的绝对展开字节上限,使验收条件更明确。若增加压缩比策略,应说明分母来源、最小观测量以及与绝对限制的关系。
时间预算使用 System.nanoTime 的经过时间,在条目处理、读取前后和最终完成前检查。测试把预算设为一纳秒,实际任务必然超过这个已用时间并失败。这个样本证明预算分支会拒绝已超时任务,没有证明任务会在一纳秒以内被中断。
ZipFile 打开中央目录、TAR 读取下一条目及底层 read 都可能在两次检查之间耗时。计时检查无法抢占正在阻塞的调用;把 Future.cancel 当成可靠中断也需要底层操作配合。需要硬截止时间时,应在可终止的隔离执行环境中设计任务取消,本例没有实现这层能力。
损坏与失败清理必须检查实际文件系统
损坏测试将 ZIP 截去后半段,将包含六百字节普通文件的 TAR 截成五百二十字节。两者都要求 IOException,并在失败后列举输出父目录,断言没有遗留本次工作目录。只检查异常类型而不检查文件系统,无法证明部分输出已经处理。
实现先关闭条目流、归档流与目标输出,再清理私有根。递归删除使用默认不跟随符号链接的 walkFileTree;删除范围始终是本次 createTempDirectory 返回的路径,不删除用户提供的可信父目录,也不根据出错条目名称重新计算清理根。
清理本身也可能失败,例如权限变化或文件系统错误。实现保留原始异常,并把清理 IOException 加为 suppressed exception。调用方必须记录这一情况并交由受控清理流程处理,不能仅因 extract 抛异常就假定磁盘必然已经恢复为空。
本地失败样本的清理都成功,尚未注入清理权限失败,也没有验证断电或进程被强杀后的恢复。生产流程需要记录临时目录归属与任务状态,对遗留目录做有边界的回收。清理测试所证明的是当前进程正常处理异常时的路径,不是崩溃一致性。
成功返回仍不是业务发布事务
extract 成功意味着受本例检查的条目已写入私有目录,尺寸与 ZIP CRC 核验通过;它不意味着商品 CSV 符合业务模式,也不意味着图片已经通过内容验证。后续应在这个私有目录内完成 CSV 表头、字段值、图片类型等检查,然后才进入业务发布步骤。
将整个工作目录重命名或移动到正式位置,是否原子取决于文件系统、目标位置和所用操作选项。跨文件系统移动不能凭方法名假定为原子,数据库更新与文件发布也不会自动组成一个事务。本例只返回工作目录,由调用方决定发布与回收。
第二项可迁移模式是把“暂存完成”“业务验证完成”“发布完成”设为不同状态。失败清理只删除当前阶段拥有的暂存产物;一旦调用方接管成功目录,所有权也随之转移。这样才能避免一个导入异常误删已经发布或另一个任务拥有的数据。
JDK 替代与兼容取舍
JDK 提供 ZIP 读取与压缩设施,但没有直接对应的 TAR 读取 API。仅处理普通 ZIP 且不需要本例所使用的 Unix 类型元数据时,可以评估 JDK 方案;无论选哪个库,路径、既有对象与资源预算都不会自动由压缩 API 完成。
Commons Compress 的价值在于多种格式、条目元数据和一致的读取接口。本例仍选择两个明确入口,而不根据任意输入自动加载所有格式能力。支持集合越窄,拒绝矩阵越容易维护;需要增加格式时,应同时增加条目类型、损坏、链接和预算测试。
解析前元数据内存也是限制。ZipFile 在开始条目遍历前会解析中央目录,因此后续条目数检查并不意味着此前完全没有处理额外元数据。归档文件上限提供了外围边界,但不是对解析器堆分配的数学证明。TAR 的扩展头处理同样发生在应用取得普通条目之前,不能用最终输出大小覆盖全部解析成本。
验收表与练习
归档文件上传与解包之间还存在交接边界。入口应先完成接收、关闭上传输出并确定稳定文件,再开始 Files.size 和 ZipFile 打开;如果一边上传一边用相同路径解包,检查到的尺寸和随后读取的内容可能不是同一个状态。即便没有恶意写者,普通并发上传也会破坏本例假定。
任务并发度也独立于单归档预算。允许每个归档展开四兆字节,不代表同时处理数千个任务只需要四兆空间;总磁盘配额、并发解包数和等待队列都要在调度层限制。这个辅助类只知道当前任务的 Limits,不管理服务整体的资源池。
错误分类应保留触发位置。路径拒绝、类型拒绝、数据预算超限、格式损坏与清理失败,虽然都可以通过 IOException 向上传播,但对外诊断应由应用映射到稳定类别。不能仅匹配某一条第三方异常英文文本决定是否自动重试。确定性政策拒绝继续重试同一输入通常没有意义,暂时性文件系统故障则需要结合任务状态判断。
审计记录也不必复制全部归档内容。可以记录上传批次、声明格式、已接受条目数量、失败类别和私有目录标识;条目名称来自输入,写日志时仍需要采用结构化字段,避免换行等字符改变日志外观。本例名称协议拒绝 NUL,却没有把所有控制字符都作为独立名称政策拒绝,因而日志层不能直接假定名称适合裸拼接。
安全失败与兼容失败需要分别统计。严格拒绝后置目录条目,可能让某个正常打包工具的文件无法导入;这个结果应推动协议协调或专门的兼容改进,而不是在捕获异常后跳过条目继续返回成功。若选择跳过,归档完整性契约已经变化,业务必须能够识别缺失文件,测试也应重新定义成功条件。
双 JDK 的五组测试分别验证合法 ZIP/TAR 与目录、两格式危险名称及重复对象、链接和特殊类型、五种预算及高压缩比、损坏输入与清理。测试都在隔离临时目录中运行,没有写入真实业务位置。固定源码解释了类型与读取分支,实际断言解释了本地可观察结果,两者不互相冒充。
| 判断关键词 | 可迁移模式 | 本例选择与边界 |
|---|---|---|
| 名称与类型 | 输入元数据不是写入授权 | 相对名称协议、普通文件与目录白名单 |
| size | 每层明确计数单位 | 输入字节、真实展开量、条目数分别限制 |
| timeout | 检查超时与抢占中断分开 | 检查点拒绝,不保证阻塞调用硬截止 |
| partial | 暂存、验证、发布分离 | 私有目录,失败清理,成功转移所有权 |
| existing | 检查与创建共享政策 | CREATE_NEW,重复目录也按严格政策拒绝 |
手算题:两项各六字节,总预算十字节、单项预算八字节,第二项开始时允许多少?是四字节,不是八。读取确认超限后整个解包失败,即使第一项已经暂存完成。
改动练习是构造一个完整 ZIP,只修改条目 CRC,并补充“抛异常、没有成功返回根、父目录为空”的断言。另一项练习是在受控测试环境模拟删除失败,验证原始异常保留且清理异常位于 suppressed 列表。当前交付不把这两项尚未运行的扩展练习记为已验证能力。
可独立复跑的 Maven 项目见 下载 25–30 实验包,包含固定依赖配置、完整源码与 Maven Wrapper。
系列起点:可复现基线。

