BadDigest、InvalidRequest 与 CompleteMultipartUpload 校验和契约
本文是 SILO #48 的完整设计、调查与验证记录,同时划清它与相关问题 SILO #50 的决策边界。
状态: 实现与文档已分别提交为
pgsty/silo#74和pgsty/silo.pgsty.com#6;对应提交已完成完整本地验证、远端 CI 与独立 Opus 5 Max 验收。合并、发布与部署仍是彼此独立、尚未完成的门槛。
归属:pgsty/silo,即 SILO 服务端仓库。
实现范围: 仅调整CompleteMultipartUpload的错误语义;不改变存储格式、校验和数学、依赖、Console、软件包或客户端。
独立决策: #50 仍需 AWS 探针证明,不纳入本次修复。
摘要
#48 是成立且应该修复的问题,但原始描述需要两点校正。
第一,校验和类型比较比 Issue 描述的更严重。SILO 使用了位掩码包含关系,而不是相等比较。因此“创建为 FULL_OBJECT、完成时声明 COMPOSITE”会失败,反过来“创建为 COMPOSITE、完成时声明 FULL_OBJECT”却可能绕过类型检查。修复必须把基础算法和规范化后的分片对象类型分开、双向对称比较。
第二,“缺少分片校验和”这一行原本没有直接 AWS 响应证据。现在官方 boto/s3transfer 项目提供了足够强的证据:Issue #241 记录了真实 S3 响应——错误码为 InvalidRequest,消息点名 sha256 与缺失的第 1 个分片;PR #242 随后修复客户端并加入测试。因此无需再等待新的 AWS 账号探针,就可以实现这条响应契约。
本次接受的行为如下:
CompleteMultipartUpload 失败条件 |
修复前 SILO | 正确行为 |
|---|---|---|
| 客户端提供的对象校验和与组装结果不一致 | XAmzContentChecksumMismatch |
BadDigest |
| 完成时的校验和类型与创建时不同,无论哪个方向 | 一个方向为 InvalidArgument;反方向可能放行 |
BadDigest |
| 组合式上传的某个分片缺少校验和 | InvalidPart |
InvalidRequest,并点名算法与分片号 |
修复采用“完成操作专用错误类型”。它明确不修改 hash.ChecksumMismatch 的全局映射,因此 PutObject、UploadPart、流式 Trailer 等操作仍保持现有的 XAmzContentChecksumMismatch 契约。
#50 是另一个问题。AWS 明确说 CRC64NVME 只支持完整对象校验和,但当前证据无法证明:当客户端显式请求 CRC64NVME + COMPOSITE 时,S3 一定拒绝,而不是接受后规范化。上游 MinIO 有意实现了规范化,并且在创建响应中返回 FULL_OBJECT,所以这也不是“静默”替换。改变它之前必须先做真实 AWS 原始请求探针。
范围与决策
本文回答两个不同问题:
- #48 中的错误码偏差,是否是可观察、证据充分、应当修复的兼容性缺陷?
- 同一批证据是否足以授权修改 #50 描述的 CRC64NVME 规范化行为?
结论是:
- #48:修正描述后接受并实现。 S3 错误码属于线上协议契约。即使两边都拒绝请求,不同错误码仍会导致 SDK 分支、重试逻辑和运维诊断偏离。
- #50:暂不实现。 能力矩阵只能证明结果必须是完整对象校验和,不能证明非法请求应被拒绝、忽略还是规范化。这三种线上行为并不等价。
本次修复严格收口:不增加算法、不重算历史数据、不改变成功请求,也不重新解释 #31 与 #46 已修复的可选性规则。
证据台账
不同来源的证明力并不相同,本次实现按以下层级作出判断。
| 等级 | 来源 | 能证明什么 | 局限 |
|---|---|---|---|
| A | AWS 校验和上传指南 | 完整对象校验和不一致返回 BadDigest;算法/类型能力矩阵 |
不展示所有响应消息 |
| A | AWS CompleteMultipartUpload API 与 AWS CLI 参考 | 完成时类型与创建时不同返回 BadDigest |
没有公布精确消息文本 |
| B+ | boto/s3transfer #241 | 真实 AWS S3 响应:SHA256 的第 1 个分片缺少校验和时返回 InvalidRequest,并点名算法与分片 |
证据位于官方 SDK 项目 Issue,而不是 AWS API 参考页 |
| B+ | boto/s3transfer #242 与 0.6.1 变更记录 | 官方传输客户端改为把 UploadPartCopy 校验和传入完成请求,并用功能测试防止回归 | 主要是客户端侧证据 |
| B | 本地 API 探针与回归测试 | SILO 旧有三个错误码与反向绕过,在两个对象层后端上都可复现 | 只能证明 SILO,不能证明 AWS |
| C | MinIO 上游历史 | 解释现有行为如何进入代码谱系、为何一直存在 | 上游意图不等于 AWS 兼容性证明 |
这个区分很重要。#48 后来的校正评论在第三行只有二手资料时,正确地下调了其证据等级。现在,boto 的真实响应记录与已经合并的客户端修复补齐了缺口。
可观察协议契约
对象校验和不匹配
对于 FULL_OBJECT 分片上传,SILO 会合并已保存的分片校验和,再与完成请求中可选的对象级校验和比较。旧代码返回 hash.ChecksumMismatch,随后被全局 API 映射为:
AWS 明确规定,相应的完成阶段完整性失败应返回 BadDigest。但如果只复用现有通用 ErrBadDigest,静态消息仍会说 Content-MD5;CRC32、CRC32C、CRC64NVME 都不是 Content-MD5,因此依旧具有误导性。
新响应改为操作域专用消息:
响应不会泄露期望值或客户端提供的摘要值。
校验和类型不匹配
CreateMultipartUpload 保存的校验和类型属于本次上传契约。完成时不能在 COMPOSITE 和 FULL_OBJECT 之间切换。
旧比较逻辑是:
ChecksumType.Is 是位掩码包含判断,不是相等判断。以 CRC32 为例:
第二种请求会继续按照持久化的组合式规则执行。如果调用方在 FULL_OBJECT 声明下提供的其实是组合校验和值,完成甚至可能成功。这不仅是错误标签不对,而是协议校验绕过。
修复先把双方都规范化为分片校验和类型,再分别比较:
- 基础算法是否相同;
- 对象类型是否相同(
COMPOSITE与FULL_OBJECT)。
对于两种对象类型在语法上都成立的算法——目前是 CRC32 与 CRC32C——两个不匹配方向现在都返回 400 BadDigest。SHA1/SHA256 与 FULL_OBJECT 的组合会更早被现有解析器以 InvalidArgument 拒绝;CRC64NVME 则是下文 #50 所述的规范化特例。基础算法不匹配仍保留独立的 InvalidArgument 路径,因为 #48 与引用的 AWS 类型契约不足以授权扩大修改范围。
组合式分片缺少校验和
组合式上传的完成 XML 必须为每个列出的分片提供选定算法的校验和。SILO 过去把空客户端值与已保存值比较,然后返回 InvalidPart。
这混淆了三种不同状态:
- 分片或 ETag 不存在;
- 客户端提供了校验和,但值或算法错误;
- 必需的校验和元素完全缺失。
第三种状态现在拥有专用错误,线上消息遵循 boto/s3transfer 记录的 AWS 响应:
服务端在遇到第一个缺失分片时返回错误,并携带真实分片号。FULL_OBJECT 行为不变:完成请求可以省略逐分片校验和,但一旦提供,仍必须正确。
上游真实情况
这些行为来自继承代码,而非 SILO 有意重新设计。
- MinIO PR #15433 引入扩展校验和与全局
hash.ChecksumMismatch -> XAmzContentChecksumMismatch映射。该映射适合上传数据流验证,却过度覆盖了完成阶段语义。 - MinIO PR #20855 增加完整对象校验和与 CRC64NVME,并引入类型比较。它还通过“看起来 AWS 会忽略模式并自行假设”的注释,有意把 CRC64NVME 规范化为完整对象。
- MinIO PR #20953 收紧非法算法/类型组合,却保留 CRC64NVME 特例。这证明它是上游的明确行为,而非偶然漏掉一个分支。
- MinIO Issue #20944 报告过 AWS
BadDigest与 MinIOInvalidPart的差异。上游承认偏差,但没有修复。
MinIO 上游仓库现在已经归档。SILO 必须自行承担兼容性判断、测试和后续维护,不能再等待上游修正。
修复设计
操作域专用错误
如果修改 hash.ChecksumMismatch 的全局映射,就会改变所有使用它的操作,形成一个证据不足、范围更大的兼容性变更。
本次在服务端 cmd 包中新增三个包内私有、由哨兵错误支撑的错误辅助函数。辅助函数与“请求头是否出现”标记均保持私有,避免扩大 SILO 对外 Go 兼容符号面:
completeMultipartChecksumMismatch:映射到BadDigest,并提供校验和语义正确的描述;completeMultipartChecksumTypeMismatch:映射到BadDigest,并点名请求类型与创建类型;missingPartChecksum:映射到InvalidRequest,携带算法与分片号。
只有 CompleteMultipartUpload 会产生它们。全局映射保持:
因此 PutObject 与 UploadPart 行为不变,而且兼容性边界在代码中清晰可见。
双向对称类型验证
比较前,持久化类型与请求类型都要加上分片上下文标志。原因是:裸 CRC 类型在 ObjType() 中表示非分片的完整对象校验和;同一个基础值进入分片上下文后则代表组合式类型。只有完成请求显式携带 x-amz-checksum-type 时才比较对象类型;省略一个可选头不能被解释为主动声明 COMPOSITE。
最终不变量是:
第二个条件只在类型头显式出现时生效。该比较是对称的,同时继续兼容现有 CRC64NVME 规范化。它修复 #48,却不会暗中替 #50 作出决定。
精确识别“缺失”
服务端原本就会为每个分片建立“完成 XML 中所有校验和字段”的映射。本次把行为拆成:
这里没有把所有分片校验和失败都改成 InvalidRequest;只修改 AWS 证据直接覆盖的“字段缺失”状态。
同一处修改还纠正了内部 InvalidPart 的 expected/actual 字段顺序。通用 S3 InvalidPart 响应不会把摘要值发送给客户端,但内部错误文本与日志仍应描述正确。
回归与检测矩阵
API 级测试通过签名 HTTP 请求运行,并覆盖单盘与纠删码两个对象层后端。
| 测试 | 请求 | 必须断言 |
|---|---|---|
| 完整对象摘要错误 | 分片正确、对象 CRC32 错误 | HTTP 400、BadDigest、正确消息、对象未提交 |
| 组合式对象摘要错误 | CRC32 分片值正确、组合对象值错误 | HTTP 400、BadDigest,覆盖独立的“校验和之校验和”路径 |
| 类型错误:完整到组合 | 创建 CRC32 FULL_OBJECT,完成 COMPOSITE |
HTTP 400、BadDigest,消息点名请求/期望类型 |
| 类型错误:组合到完整 | 创建 CRC32 COMPOSITE,完成 FULL_OBJECT |
HTTP 400、BadDigest,关闭旧包含关系绕过 |
| 省略可选类型头 | 创建 FULL_OBJECT,完成时只有摘要值、没有类型头 |
成功;省略不被视为显式 COMPOSITE |
| 算法不匹配护栏 | 创建 CRC32,完成时使用 CRC32C | 仍为 InvalidArgument |
| CRC64NVME #50 护栏 | CRC64NVME 创建时显式写 COMPOSITE,完成时再次显式写 COMPOSITE |
仍通过现有完整对象规范化成功;记录完成侧残留,而不是声称 #48 已验证原始类型字段 |
| 组合式缺失校验和 | CRC32 与 SHA256 组合上传;先全部省略,再只省略第 2 片 | HTTP 400、InvalidRequest,点名小写算法与真实缺失分片 |
| 全局映射护栏 | 直接映射 hash.ChecksumMismatch |
仍为 XAmzContentChecksumMismatch |
| UploadPart 护栏 | 客户端分片校验和值错误 | 仍为 XAmzContentChecksumMismatch |
提交的类型不匹配回归测试使用 CRC32,独立验收探针还覆盖了 CRC32C。该探针同时确认:SHA1/SHA256 的 FULL_OBJECT 请求会更早停在现有的非法组合检查,而 CRC64NVME 仍会把显式 COMPOSITE 规范化。这些差异是协议边界,不应被误写成“所有算法都进入同一个错误映射器”。
聚焦验证命令:
2026-08-27 的实测结果:
吸收审查建议后,又重新执行完整本地包门禁:
git diff --check 同样通过。最终 diff 的独立审查仍是单独门禁。本地通过不等于远端 CI,通过合并不等于发布,发布也不等于生产部署。
独立对抗审查
本地 Claude Code 以只读 safe mode 对真实服务端 diff 完成了第一轮审查,结论为 GO、无阻断项。它独立确认了操作域映射、位掩码双向规范化、逐分片缺失检测、两个对象层后端覆盖,以及 UploadPart 行为保持不变。
审查指出四个有价值的缺口,并在第二次完整测试之前全部吸收:
- 区分“省略可选类型头”和“显式声明
COMPOSITE”; - 把摘要值不匹配与类型不匹配拆成两个错误类型;
- 覆盖组合式“校验和之校验和”错误路径;
- 固定缺少第 2 片、算法不匹配与 CRC64NVME 规范化保持不变。
第一轮有一条审查疑问被一手资料否决:它怀疑校验和类型不匹配应返回 InvalidRequest。但 AWS CompleteMultipartUpload 参考 与 AWS CLI 参考 明确规定,完成类型与创建类型不一致时返回 BadDigest。
第一轮最终复审结论为 FINAL GO、无阻断项。Claude 明确撤回了先前的错误码疑问,同意接受 #48、推迟 #50,确认新增护栏保持了所有有意不变的行为,并确认中英文不存在漂移。
随后又使用 Claude Code claude-opus-5、最高推理强度进行了独立验收,结论为 ACCEPT、无阻断项。它对修复前代码端到端复现了“组合式上传冒充 FULL_OBJECT”的绕过,验证新增 API 断言会在旧代码上失败,并双向探测了全部五种校验和算法。其发现的残留边界在此明确披露,而不是悄悄并入“验收通过”的结论。
仍有六个修复前就存在或有意推迟、且不阻断本次工作的观察:
- SHA1/SHA256 的
FULL_OBJECT组合会被现有解析器提前以InvalidArgument拒绝;只有 CRC32/CRC32C 能进入两个类型不匹配方向; - CRC64NVME 会把任意类型值视为完整对象状态,因此完成时显式写
COMPOSITE仍会在 #50 AWS 探针结论出来前经规范化后被接受; - 如果创建阶段没有记录校验和算法、完成阶段却提供对象校验和,SILO 会返回
BadDigest;AWS 文档说明该值应被接受并忽略,应另立兼容性问题处理; - 组合式分片数不匹配与摘要值不匹配都会成为
BadDigest,并使用同一描述; - 完整对象校验和值如果带
-N后缀,后缀会被忽略,但摘要本身仍会验证; - 未知且非空的
x-amz-checksum-type会被解析成组合式,而不是直接拒绝。
它们都不是本补丁引入的,也不改变 #48 结论。如果未来要追求更严格的消息或非法请求头兼容性,应分别立项处理。
为什么不顺便修 #50
#50 认为,应当在创建阶段拒绝 CRC64NVME + COMPOSITE。目前可以确认三件事:
- AWS 算法矩阵只允许 CRC64NVME 作为完整对象校验和;
- SILO 与 MinIO 上游会把请求规范化为完整对象状态;
- 服务端在
CreateMultipartUpload响应中返回x-amz-checksum-type: FULL_OBJECT,因此替换是外部可见的,并非静默发生。
真正决定是否改代码的线上行为尚未确认:AWS 对显式非法组合究竟是拒绝,还是接受并返回/保存完整对象状态?能力矩阵无法回答。
上游历史也要求我们不要猜。PR #20855 有意加入规范化;PR #20953 在收紧其他非法组合时仍保留它。这可能来自真实 AWS 观察,但一句注释不是可复现的原始响应。
同一种内部表示也影响完成阶段:FullObjectRequested 会把任何 CRC64NVME 校验和都视为完整对象状态。因此,已经保存为 FULL_OBJECT 的上传即使在完成时收到原始头值 COMPOSITE,也会按完整对象接受,而不是以类型不匹配拒绝。这个完成侧残留与创建侧一样,取决于“原始字段还是规范化状态”的 AWS 证据;本文明确不声称 #48 已经修复它。
PutObject 也不应捆绑在这里。其 API 参考并未定义 x-amz-checksum-type,因此接受、拒绝还是忽略这个头,属于另一个“未文档化请求头”问题。
必需的 AWS 探针
修改 #50 之前,应对普通 AWS S3 Bucket 捕获一组原始 SigV4 请求与响应:
- 发送带
x-amz-checksum-algorithm: CRC64NVME与x-amz-checksum-type: COMPOSITE的CreateMultipartUpload; - 记录 HTTP 状态、错误码/消息、请求 ID 与全部校验和响应头;
- 如果创建成功,上传一个分片并完成,记录 S3 是否要求逐分片值,以及
HeadObject报告的类型; - 使用
FULL_OBJECT作为控制组重复; PutObject单独测试,并明确标记为“未文档化请求头实验”。
只有捕获到 AWS 拒绝响应,才足以授权把规范化改成参数校验。如果 AWS 接受并规范化,就应修正或关闭 #50,而不是实现它。
兼容性与运维影响
- 成功请求: 校验语义不变,但省略可选的
x-amz-checksum-type头不再被误判为显式COMPOSITE声明。这项有意的互操作性放宽会把旧实现错误返回的 400 改为成功。 - 失败请求: 除上述省略请求头的情况外,HTTP 状态仍为 400;受影响的 S3 错误码与消息改为 AWS 兼容语义。
- 完整性: 不减弱,并关闭反向类型绕过;任何失败完成都不会提交对象。
- 存储数据: 不改变格式、编码、元数据、纠删码布局;无需迁移或回填。
- 性能: 只有常数时间比较与错误构造;不增加数据读取或哈希遍历。
- 安全/隐私: 新消息不返回摘要值,也不会额外加入 Bucket 或对象名。
- 滚动升级: 全部节点升级前,失败请求可能得到不同错误码;成功对象仍完全兼容。
- 回滚: 会恢复旧错误码和非对称比较;无需回滚数据。
- 其他仓库: 不需要 Console、共享包、MCLI 或 SDK 修改;跨仓库交付只有这份公共设计记录。
合并与发布门禁
合并之前:
- 完成包级测试矩阵与格式检查;
- 对真实服务端 diff 和本文证据记录进行独立审查;
- 除非 AWS 原始响应改变结论,否则不得把 #50 混入补丁;
- 对最终提交运行远端 DCO、Go CI、漏洞与发布流水线检查;
- 合并前确认分支仍基于最新 SILO
main。
合并之后,仓库集成、发布制品、容器镜像、部署与生产探针必须分别记录。任何一项都不能由本地测试或文档构建结果推导出来。
结论
#48 是正确的兼容性问题,现在三行行为都有足够证据。最安全的修复不是全局重命名校验和错误,而是让 CompleteMultipartUpload 报告自己的协议错误:对称比较校验和类型,并把真正缺少组合式分片校验和的状态与“分片不存在”“值错误”区分开。
#50 只是在发现历史上相关,证明链并不相同。当前 CRC64NVME 规范化是有意且可见的。没有 AWS 精确响应之前,贸然修改只会用一个未经验证的假设替换另一个。
这就是本次设计的核心边界:实现官方契约与官方测试能够证明的内容;把代码审查发现的隐藏后果纳入回归;剩余策略问题必须通过明确、可重复的证据门禁。