首页
/ Moby TarSum 校验和算法规范解析:tar 层文件系统的确定性校验实现

Moby TarSum 校验和算法规范解析:tar 层文件系统的确定性校验实现

2026-09-04 12:53:21作者:吴年前Myrtle

本文基于 Moby 仓库中的 TarSum 校验和规范文档 展开,完整讲解 TarSum 的算法构成、输出格式、版本演进与计算流程,并结合 tarsum 包源码 与构建上下文中的实际调用(FromArchive)说明这一校验机制如何在镜像层与构建缓存场景下落地。读完本文,你将能够理解为什么 Docker 生态不使用"对整个 tar 文件做哈希"来校验镜像层,并能在源码级别复现、验证 TarSum 的计算规则。

一、TarSum 解决什么问题

规范文档的开篇就点明了背景:在 Moby(Docker)中,文件系统的传输都是通过 tar(1) 归档完成的。tar 序列化格式有多种变体,而核心诉求是:给定一个通用 tar 归档的一组输入,能够得到可重复的校验和。这类传输场景包括:

  • 镜像在 registry 端点之间的分发(pull/push);
  • 通过命令或 Docker daemon API 进行 save/load;
  • 构建上下文(build context)从客户端传输到 daemon;
  • 将容器文件系统 commit 成新的镜像。

关键在于:tar 归档只作为传输介质,在许多场景下并不被永久保存。因此算法的目标不是校验"归档字节流本身"(否则直接对 tar 文件做哈希即可),而是校验解压后保留下来的文件系统,同时保持确定性的可追责性。这意味着算法不能对文件的排序、打包/解包过程中的操作施加约束,也不依赖文件系统属性的额外元数据状态——文件顺序不同、时间戳不同,只要文件树内容一致,校验和就应一致。

规范文档同时给出了一个重要的安全声明(Warning):TarSum 是一种带模糊逻辑(fuzzy logic)的文件树尽力比对算法,它不是密码学认证(not a cryptographic attestation),不应被视为安全机制。这一约束在阅读源码时也要始终牢记。

从源码结构看,包注释(tarsum.go#L1-L18)与规范文档的 Introduction 段落几乎逐句对应,说明规范就是按该实现撰写的,二者构成"文档 + 代码"的相互印证。

二、校验和算法画像与输出格式

规范要求一个校验和机制必须定义以下四个要素:

要素 说明
关联哈希算法(cipher) 用于对每个文件的 payload 和属性信息做校验
校验和列表(checksum list) 归档中每个文件都基于其 payload 和属性计算出一个和;最终校验和按特定顺序从该列表计算得出
版本(Version) 算法随需求演进,需要用版本号管理行为差异
被计算的归档(archive) 正在计算校验和的 tar 归档

输出字符串的格式

计算结果是一个文本字符串,包含验证该和所需的信息(TarSum 版本、所用哈希算法)以及十六进制表示的期望校验和。格式由两个分隔符定义:

  • + 分隔 TarSum 版本与哈希算法;
  • : 分隔计算机制与期望哈希值。

规范给出的示例:

"tarsum.v1+sha256:220a60ecd4a3c32c282622a625a54db9ba0ff55b5ba9c29c7064a2bc358b6a3e"
|         |       \                                                               |
|         |        \                                                              |
|_version_|_cipher__|__                                                           |
|                      \                                                          |
|_calculation_mechanics_|______________________expected_sum_______________________|

这个格式在源码中可以直接对应。Sum 方法 的最终拼接就是:

checksum := ts.Version().String() + "+" + ts.tHash.Name() + ":" + hex.EncodeToString(h.Sum(nil))

反向解析则由 versioning.go 提供:

  • VersionLabelForChecksum:取第一个 + 之前的部分作为版本标签;
  • GetVersionFromTarsum:把版本名映射回 Version 枚举,未知前缀返回 ErrNotVersion

测试 versioning_test.go#L34-L82 验证了 tarsum+sha256:...Version0tarsum.dev+sha256:deadbeefVersionDev 等映射,并确认 weak+md5:abcdeabcde 这类未知标签会报错。

三、版本管理:Version0 / Version1 / VersionDev

规范说明:版本机制被引入,是为了在算法需要差异化计算时保持向后兼容。源码中版本定义为整型枚举(versioning.go#L15-L23):

type Version int

const (
    Version0 Version = iota  // "tarsum"
    Version1                 // "tarsum.v1"
    VersionDev               // "tarsum.dev"
)

三个版本的行为差异:

版本 标签 行为
Version0 tarsum 初始版本,文件校验计算包含 mtime,不含 xattrs
Version1 tarsum.v1 两项变更:① 文件信息头中排除 mtime;② 纳入扩展属性(xattrs)——即 SCHILY.xattr. 前缀的 Pax tar 文件头,键值对参与每个文件的校验计算
VersionDev tarsum.dev 除非在验证校验和算法的改进,否则不要使用。它是"下一版本"的浮动占位符和试验场,计算方法可能随时变化,仅用于测试、不用于生产

源码中版本与标签的映射表见 tarSumVersions / tarSumVersionsByName。每个版本对应一个"头选择器"(tarHeaderSelector),registeredHeaderSelectorsVersion0Version1VersionDev 分别注册到 v0TarHeaderSelectv1TarHeaderSelect(Dev 与 V1 当前共用实现)。

v1TarHeaderSelect 的实现精确体现了规范中描述的两项变更:

// 复制 v0 的全部头,但剔除第 5 项 'mtime'
v0headers := v0TarHeaderSelect(h)
orderedHeaders = append(orderedHeaders, v0headers[0:5]...)
orderedHeaders = append(orderedHeaders, v0headers[6:]...)
// 最后追加按 key 排序的 xattrs
orderedHeaders = append(orderedHeaders, xattrs...)

其中 xattrs 的收集合并了 tar.HeaderPAXRecordsSCHILY.xattr. 前缀的键)与 Xattrs 字段,且 Xattrs 中的值优先(与 archive/tar 写盘时的优先级一致),最后 sort.Slice 按 key 排序——对应规范中"这些 xattrs 键值先按 key 排序"的要求。TestSelectXattrsV1 验证了 PAXRecords 与 Xattrs 合并、去重和排序的结果。

四、哈希算法(Ciphers)

规范声明:官方默认的哈希算法是 sha256(即 FIPS 180-4 定义的 SHA256)。TarSum 算法本身并不绑定单一算法,后续版本增加了替代哈希的支持,用途包括为 TarSum 校验和格式做"未来防御",以及为 tar 文件系统校验和采用更快的哈希。

源码中这一点由 standardHashConfigs 实现:

// NOTE: DO NOT include MD5 or SHA1, which are considered insecure.
var standardHashConfigs = map[string]tHashConfig{
    "sha256": {name: "sha256", hash: crypto.SHA256},
    "sha512": {name: "sha512", hash: crypto.SHA512},
}

// DefaultTHash is default TarSum hashing algorithm - "sha256".
var DefaultTHash = NewTHash("sha256", sha256.New)

值得注意:官方标签解析路径(NewTarSumForLabel)只接受 standardHashConfigs 中登记的算法(sha256/sha512),而 NewTarSumHash 允许传入任意 THash——测试代码中便用 md5、sha1、sha224、sha384、sha512 做过算法无关性的验证(tarsum_test.go#L292-L298),但注释明确禁止把 MD5/SHA1 放进生产可用的配置表。

五、计算流程(Calculation)

5.1 前提要求

规范指出:tar 归档不是不可变的持久产物。以 Docker 镜像为例,归档一旦内容被解出即被丢弃,因此"文件在归档中的顺序""时间戳"这类一致性要素在接收端都可能变化。算法必须容忍这些差异,这正是逐文件校验、列表排序的设计动机。

5.2 逐文件计算

实现上这是一个流式迭代过程:边从归档流读取 tar 头,边为每个文件独立计算校验和。源码中这一职责由 tarSum.Read 承担——它同时实现 io.Reader,在透传数据流给下游的同时喂给哈希器和内部 tar writer,因此消费数据流本身就是在计算校验和

每个文件(头 + 体)用指定哈希算法单独校验:

  1. 先写有序的头信息:头按固定顺序写入,格式为 "{key}{value}" 拼接,无换行符,即直接 key 后紧跟 value 写入哈希。这由 encodeHeader 完成:

    for _, elem := range ts.headerSelector.selectHeaders(h) {
        if _, err := ts.h.Write([]byte(elem[0] + elem[1])); err != nil {
    

    参与计算的字段(按顺序)及其值表示如下:

    顺序 字段 值的表示
    1 name 字符串
    2 mode base10 整数字符串
    3 uid 整数字符串
    4 gid 整数字符串
    5 size 整数字符串
    6 mtime(仅 Version0) 1970-01-01 00:00:00 UTC 起的秒数
    7 typeflag 单字符字符串
    8 linkname 字符串
    9 uname 字符串
    10 gname 字符串
    11 devmajor 整数字符串
    12 devminor 整数字符串
    13+ xattrs(≥Version1) SCHILY.xattr. 前缀的 Pax 头,键值对,先按 key 排序

    一个与规范略有差异的实现细节:encodeHeader 中会把 uname/gname 的值强制置空以保持与 Go 1.10 之前版本的行为兼容。

  2. 再写文件体(body):头全部写入后,把文件内容字节写入同一个哈希。

  3. 结果入列表:该文件的校验和(十六进制摘要字符串)追加到"文件校验和列表"。规范强调:文件名和它在归档中的位置(pos)会被保留,用于特殊排序。这对应 fileInfoSum 结构:

    type fileInfoSum struct {
        name string // 文件路径
        sum  string // 头+体的十六进制校验和
        pos  int64  // 在归档中的出现顺序
    }
    

5.3 文件校验和列表的排序规则

规范给出两条规则:

  • 列表按十六进制摘要字符串排序
  • 若 tar 中存在相同路径的两个文件,则它们对应校验和的相对顺序按该路径的**出现顺序(pos)**保持。

源码 SortBySums 精确实现了这一组合规则:

func (bs bySum) Less(i, j int) bool {
    if bs.dups != nil && bs.FileInfoSums[i].Name() == bs.FileInfoSums[j].Name() {
        return bs.FileInfoSums[i].Pos() < bs.FileInfoSums[j].Pos()
    }
    return bs.FileInfoSums[i].Sum() < bs.FileInfoSums[j].Sum()
}

即:普通情况比 sum,同路径(重复项,由 GetDuplicatePaths 识别)则比 pos。

5.4 最终校验和

规范描述的最终步骤:

  1. 以哈希算法的全新初始状态开始;
  2. 若归档有额外要并入计算的 payload(如镜像的 json 元数据字节),先写入它
  3. 然后按排序后的文件校验和列表顺序,把每个文件的和字符串写入哈希;
  4. 输出的十六进制摘要再按"Elements"章节的格式,拼上版本与算法名。

对应的 Sum 方法

func (ts *tarSum) Sum(extra []byte) string {
    ts.sums.SortBySums()
    h := ts.tHash.Hash()
    if extra != nil {
        h.Write(extra)          // 先写额外 payload(如 json 元数据)
    }
    for _, fis := range ts.sums {
        h.Write([]byte(fis.Sum()))
    }
    return ts.Version().String() + "+" + ts.tHash.Name() + ":" + hex.EncodeToString(h.Sum(nil))
}

测试 TestTarSumstestdata 下的真实层文件验证了期望值,例如 layer.tar + json 在 Version0 下得到 tarsum+sha256:4095cc12fa5fdb1ab2760377e1cd0c4ecdd3e61b4f9b82319d96fcea6c9a41c6,而同一层在 VersionDev 下得到不同的 tarsum.dev+sha256:db56e35eec...——直观体现了版本切换会改变计算结果。TestIterationtarsum_test.go#L417-L528)则对单个文件头逐字段(包括 xattrs 大小写敏感性)断言了固定摘要,是规范头字段顺序的最强验证。

六、安全考量:同名文件覆盖问题

规范"Security Considerations"一节记录了一次能使手工构造的 tar 归档失效的算法更新:tar 格式支持追加与先前文件同名的文件,后写入者会覆盖(clobber)前者的内容。为此算法现在会识别路径相同的文件,并据此对文件校验和列表排序。

仓库中的测试数据直接对应了该场景:testdata/collision/ 下有 4 个归档,collision-0.tarcollision-1.tar 含有同路径的两个文件但顺序相反,collision-2/3.tar 是各自更新版本。tarsum_test.go#L76-L95 断言四者产生四个不同的校验和(7cabb5e9...805fd393...85d2b838...cbe4dee7...),证明"顺序不同/内容不同"都能被区分出来。

七、TarSum 在 Moby 构建流水线中的实际用法

规范描述是通用的,而当前仓库中最直接的落地场景是构建上下文的校验

  1. 构建上下文来源FromArchive 把客户端传来的 tar 流解压到临时目录时,用 tarsum.NewTarSum(decompressedStream, true, tarsum.Version1) 包装流,在 chrootarchive.Untar 解包的同时为每个文件计算 tarsum,解压完成后的 sum.GetSums() 成为该构建上下文所有操作的"事实来源"(source of truth)。注意这里使用的是 Version1,与规范中"排除 mtime、纳入 xattrs"的描述一致。
  2. 单文件哈希archiveContext.Hashsums.GetFile 按相对路径查表返回文件校验和(archive.go#L98-L117),构建器据此判断构建缓存是否可复用;FileInfoSums.GetFile 在 Windows 上做了大小写不敏感匹配(对应 issue #33107 的历史行为,见 fileinfosums.go#L42-L53)。
  3. .dockerignore 处理BuilderContext 接口TarSum 基础上扩展了 Remove 方法,专门用于 .dockerignore 文件处理时把被排除的文件从校验和列表中剔除——Remove 会删除所有同名项(注释明确"不止一个同名文件"的考量)。
  4. 文件级 V1 头写入WriteV1Header 单独导出,供 remotecontext/filehash.go 中的 tarsumHash 按 V1 规则对单个文件头做哈希,保证逐文件哈希与整体 tarsum 的头编码规则一致。
  5. 标签式构造NewTarSumForLabel 接收形如 tarsum.v1+sha256 的标签字符串,分别解析版本名与算法名,任一未知即报错——这正是规范"输出字符串包含验证信息"设计的反向应用:校验和的 label 前缀可以直接用来重建同一算法实例。TestNewTarSumForLabelInvalid 覆盖了 invalidlabelinvalid+sha256tarsum.v1+invalid 三类非法输入。

NewTarSum 的第二个参数 dc(DisableCompression)控制透传输出是否 gzip 压缩(默认压缩),TestEmptyTar 验证了空归档时压缩与非压缩两种模式下摘要一致,且与"空哈希的十六进制摘要"相等。

八、小结:设计取舍一览

把规范文档与源码对照,可以归纳出 TarSum 的核心设计取舍:

  • 对文件树而非归档字节流求和:文件顺序无关(列表按 sum 排序),因此不同打包顺序的同一文件树能得到相同校验和;
  • 放弃 mtime、纳入 xattrs(V1):时间戳在传输/落盘后不可靠,而扩展属性是文件系统语义的一部分;
  • 同路径文件按出现顺序保留:应对 tar 追加覆盖语义带来的手工构造攻击面;
  • 输出自带版本+算法标识tarsum.v1+sha256:... 的 label 前缀使校验和可以自我描述,GetVersionFromTarsum/NewTarSumForLabel 据此重建算法实例;
  • 明确非安全定位:文档反复声明这是尽力比对的模糊校验,不是密码学认证;生产可用的算法表也刻意排除了 MD5/SHA1。

规范文档末尾致谢了 Joffrey F(shin-)与 Guillaume J. Charmes(creack)在 TarSum 初始工作上的贡献;其引用的版本化、替代哈希、同名文件处理三次关键演进,均可在 tarsum 包源码测试数据 中找到对应实现与回归用例。

登录后查看全文
热门项目推荐
相关项目推荐