在日常写 Go 的时候,你一定见过 go.sum 这个文件:每次 go getgo mod tidy 之后,它总是自动变长,里面密密麻麻地写着一行行 h1:xxxx 的“奇怪字符串”。很多同学知道它“和安全、完整性有关”,但真正问一句:go.sum 里的这个特殊哈希值到底是怎么算出来的?大多数人其实说不太清楚。

这篇文章,我们就从实战开发者视角,把 go.sum 里的特殊哈希(特别是 h1: 开头的那串值)讲清楚:它是什么、为什么需要它、底层大致怎么算,以及你如何在本地手动验证它。

一、go.sum 里那串 h1 是什么?

先看一行典型的 go.sum 内容:

github.com/gin-gonic/gin v1.9.1 h1:Qh…Q3U=
github.com/gin-gonic/gin v1.9.1/go.mod h1:Wk…9s4=

结构可以拆成三列:

  1. 模块路径github.com/gin-gonic/gin
  2. 版本号v1.9.1v1.9.1/go.mod
  3. 哈希值h1:Qh…Q3U=

这里的 h1: 就是 哈希算法标识,目前 Go 模块系统只支持一种算法:

  • 算法:SHA-256
  • 表示形式:先对内容做 SHA-256,再把结果用 Base64 编码
  • 最终格式h1:<base64(SHA256(规范化后的内容))>

所以你在 go.sum 里看到的 h1:xxxx,本质就是:

用 SHA-256 对“某个内容”算出 32 字节哈希,再用 Base64 编码成可读字符串。

关键问题是:这个“某个内容”到底是什么?

二、Go 实际在“对什么”做哈希?

对于每个模块版本,Go 通常会在 go.sum 里记录两种哈希:

  • 完整模块包的哈希module v1.2.3 h1:xxxx
  • 仅 go.mod 文件的哈希module v1.2.3/go.mod h1:yyyy

它们分别对应两种不同的内容。

1. 完整模块哈希(module vX.Y.Z h1:...)

这一行的哈希,是对“模块源代码打包后的 zip 内容”计算出来的。流程大致是:

  1. 从代理或源码仓库拉取模块的 zip 包(你可以在本地的 GOMODCACHE 里找到它)
  2. Go 工具链对这个 zip 做一套规范化处理(非常关键):
    • 文件路径按固定顺序排序
    • 路径分隔符统一成 /
    • 忽略某些无关的元信息
    • 确保同一模块、同一版本在任何机器上打出来的“抽象内容”是一致的
  3. 对这个“规范化后的内容”做 SHA-256
  4. Base64 编码,前面加上 h1:,写入 go.sum

为什么要这么麻烦?因为:

  • 不同操作系统(Windows、Linux、macOS)打的 zip 细节可能不同
  • 直接对原始 zip 做哈希,很容易因为无关元数据不同导致哈希不一致
  • Go 需要一个跨平台稳定的哈希结果,方便全世界的开发者对比、校验

2. go.mod 专用哈希(module vX.Y.Z/go.mod h1:...)

这一行只针对模块的 go.mod 单独算哈希,逻辑要简单很多:

  1. 拉取该版本模块的 go.mod 文件内容
  2. 对这段文本(按 UTF-8 字节序列)直接做 SHA-256
  3. 把结果 Base64,前面加上 h1:

为什么要单独对 go.mod 算一份?

  • Go 在解析依赖图时,经常只需要 go.mod,不需要把整个模块都拉下来
  • 提前校验 go.mod 的完整性,可以防止“只改 go.mod 不改代码”的篡改攻击

因此你会看到,同一个模块版本在 go.sum 里常常有两行:一行是整个模块的哈希,一行是 go.mod 的哈希。

三、这个特殊哈希是怎么被用起来的?

理解原理之后,我们再看 Go 是如何在日常命令中使用这些哈希的。

1. 下载依赖时的校验流程

当你执行:

go mod tidy
go build ./...

Go 会做大致这么几件事:

  1. 根据 go.mod 里的版本信息,去模块代理(默认 proxy.golang.org)或源码仓库拉取依赖
  2. 得到模块 zip / go.mod 内容后,本地重新按 同样的算法 计算 SHA-256,再转成 h1:...
  3. 拿这个结果去对比两处:
    • 本地的 go.sum:如果已经存在这一行,必须完全一致,否则报错
    • 远程 checksum database(默认为 sum.golang.org):防止代理或中间人被篡改

如果本地没有这一行,且远程 checksum database 也返回了一个可信的哈希,Go 会把它写入 go.sum,下一次就可以直接本地校验。

2. 为什么删除 go.sum 之后依然能恢复?

很多人会试过把 go.sum 删掉,然后执行:

go clean -modcache
go mod tidy

结果 go.sum 又被重新写满了哈希值。原因就是:

  • Go 会从远程 checksum database 获取“官方记录的哈希”
  • 本地下载到模块后,重新计算哈希,并和远程记录比对
  • 校验通过,就把哈希写回新的 go.sum

所以 go.sum 更像是你项目本地的“已确认安全版本快照”。删掉不是灾难,但会多一次网络校验的过程。

四、能不能在本地手动“复现” h1 哈希?

理论上是可以的,但要完全和 Go 内部一致,需要严格遵循它的“规范化规则”,自己实现一套会非常麻烦。比较现实的做法有两种:

1. 借助 go 命令本身

例如,你可以用:

go mod download -json github.com/gin-gonic/gin@v1.9.1

命令会输出模块的 ZipInfoGoMod 等路径。此时你可以:

  • 用 Go 源码里的工具包(cmd/go/internal/modfetch 系列)复用官方逻辑重新计算哈希
  • 或者写一个小 Go 程序,直接调用这些内部实现(一般只在学习或工具开发时这么做)

对于大多数业务开发者来说,更简单的思路是:把 go 命令当成“黑盒计算器”——你让它下载模块、写 go.sum,它自动帮你保证结果和 checksum database 一致。

2. 利用社区脚本或现成工具

社区里也有一些脚本示例,在 Stack Overflow、GitHub 上演示了:

  • 如何从 go.sum 里解析出 h1:...
  • 如何用 Bash 或其他语言调用 SHA-256 + Base64 去验证某个 go.mod 内容

但要做到“完整模块 zip 的哈希与 Go 完全一致”,依然需要实现同样的规范化流程,一般不值得自己重造轮子。

五、和安全有什么关系?私有仓库怎么办?

理解哈希的计算方式之后,再看安全和私有仓库这两个常见问题。

1. 安全性:防篡改的最后一道锁

有了 go.sum + 远程 checksum database,Go 能够保证:

  • 即使有人在代理中“掉包”模块,哈希一对不上立刻就会报错
  • 即使有人篡改了你本地的 GOMODCACHE,重新校验也能发现异常

所以在安全角度,go.sum 的每一行 h1: 实际上是你项目“依赖供应链”的一部分防线。不要随意手改它,更不要把它当成“可以随便删的缓存文件”。

2. 私有仓库的特殊情况

对于私有模块(比如公司内网 Git 仓库),Go 默认不会把它们的哈希上传到 sum.golang.org,否则就泄露了模块路径信息。

这时你会看到:

  • go.sum 里依然有 h1:...,用于本地和团队内部校验
  • 但这些哈希不会出现在公共 checksum database 中
  • 需要通过环境变量 GONOSUMDBGOPRIVATE 等来配置哪些域名走“私有模式”

换句话说:公共模块用“全网可验证”的 checksum database,私有模块用“本地/团队内部可验证”的 go.sum。

六、写在最后:如何在工作中正确对待 go.sum?

最后,用几个非常实用的建议做个小结:

  • 不要手改 go.sum:所有变更都应该通过 go getgo mod tidy 等命令产生
  • go.sum 纳入版本控制:它是依赖完整性的一部分,必须跟随代码一起提交
  • 依赖升级后留意 diff:看一眼 go.sum 的变更,确认没有莫名其妙新增奇怪模块
  • 理解 h1: 的含义:知道它是“规范化内容的 SHA-256 + Base64”,有助于你在排查构建问题、依赖冲突时更有底气

当你下次再看到 go.sum 里那一长串 h1:xxxx,可以把它想象成:

Go 帮你为每一个依赖,都刻了一枚“防伪钢印”。

理解了这套特殊哈希的计算逻辑,你就真正掌握了 Go 模块体系背后最关键的一环。