跳转到主要内容

1 - Conditional DELETE:为什么删除条件只能针对当前对象判断一次

本文是 SILO PR #12 的问题分析、设计讨论与修复决策归档。

截至 2026-08-26 的状态: PR #12 仍然 open,原 head 为 5b71a75e,落后最新 main 118 个提交。原提交没有 DCO sign-off,GitHub 上没有 check run。本文记录的改进方案已在独立本地 worktree 中实现、测试并完成两轮 review,但尚未提交、推送、合并或发布。
本轮范围: 正确实现单对象 DeleteObjectIf-Match;同时在收到尚未支持的 DeleteObjects 逐对象 ETag 时 fail closed,避免静默无条件删除。完整批量条件执行与 bucket policy 仍是独立交付。
发布边界: 本地实现、测试、review、commit、push、远端 CI、merge、tag、镜像与生产部署是相互独立的门槛。

太长不看(TL;DR)

这个问题是真的。SILO 当前会忽略 DELETE 请求的 If-Match,让客户端以为自己在执行 compare-and-delete,服务器却实际执行无条件删除。PR #12 试图补上条件检查,目标正确,也意识到必须在持锁、读取新鲜对象状态后判断。

但原实现把同一个 HTTP callback 传给每个 erasure pool。不同 pool 可能保留不同时间的对象副本,于是每个 pool 按自己的 ETag 各判各删。评审用双池测试复现了两个反例:

  • 请求最终返回 412,但匹配条件的旧副本已经被删除;
  • 请求返回成功,未匹配条件的旧副本保留,随后重新成为可见对象。

选定修复不增加新的条件框架。它复用 SILO 已有 GET 多池路径的模式:在外层 namespace lock 下选出当前对象,只判断一次条件,然后清除下层 callback。条件失败时任何 pool 都不能发生 mutation;条件通过后所有 pool 执行原有清理,不再重新解释客户端条件。

问题为什么成立

被忽略的条件不是无害扩展

AWS 条件删除文档 已经为通用 bucket 明确支持 DeleteObjectDeleteObjects

请求 含义 结果与权限
If-Match: <ETag> 只有当前对象仍是调用者看到的版本时才删除 匹配返回 204,不匹配返回 412;需要 s3:GetObjects3:DeleteObject
If-Match: * 只有当前对象存在时才删除 对象存在返回 204;只需要 s3:DeleteObject
key 不存在 无法满足条件 返回 Not Found
最新版本是 delete marker 当前对象不存在 If-Match: * 返回 412

因此,服务端收到条件后无条件执行,不是“尚未支持的 header 被忽略”这么简单。它破坏了调用者用于避免并发误删的前提。

严重性取决于调用面:不是每个客户端都会使用条件删除,所以总体出现频率未知;但一旦客户端依赖它,单次失败就可能删除另一写者刚刚提交的新对象。这属于数据正确性问题。

delete marker 的失败不是 ETag 比较问题

PR #12 复用了通用 isETagEqual:右值为 * 时直接返回 true,所以 isETagEqual("", "*") 也为真。

更根本的问题在更外层。erasureServerPools.DeleteObject 看到当前对象已经是 delete marker 时,会在原 PR 新增的内层 callback 执行前直接返回成功。评审测试观察到 callback 调用次数为零,结果却是成功。

这里要区分两种影响:

  • delete-marker fast path 在复现中没有删除历史版本,也没有再创建 marker;它是绕过条件并假报成功
  • 多池反例确实能在失败请求中删除一个副本,或在成功请求后留下可重新出现的副本;这才是实际存储状态被破坏。

所以不能只把 isETagEqual("", "*") 改成 false。那既触及 GET、PUT、COPY 共用的比较器,也无法让 callback 越过外层 fast path。

原 PR 哪些思路是对的

PR 的高层算法没有错:

  1. Handler 发现 If-Match
  2. 存储层在持锁后读取新鲜 ObjectInfo
  3. 条件不成立则在 mutation 前返回;
  4. Handler 把结果编码成 S3 响应。

它避免了先单独 HEAD、再执行 DELETE 的明显 TOCTOU 窗口,也为错误 ETag、匹配 ETag和缺失对象增加了测试。普通单池、当前对象、具体错误 ETag 的路径确实能够返回 412 并保留对象。

问题不在“应该在锁内判断”,而在“哪一层的锁内、针对哪个对象判断”。

原子性边界在哪里

SILO 的删除路径分为两层:

DeleteObjectHandler
    -> erasureServerPools.DeleteObject
         持有 namespace write lock
         从所有 pool 选出最新的当前对象 pinfo
         处理 delete marker fast path
         决定单池删除或多池清理
             -> erasureSets / erasureObjects.DeleteObject

只有 erasureServerPools.DeleteObject 同时知道:

  • 哪个副本代表当前对象;
  • 哪些 pool 还存在旧副本或不一致 metadata;
  • 是否会走 delete-marker fast path;
  • 是否要并发删除多个 pool。

所以客户端条件必须在这一层判断。单个 pool 只知道自己的副本,它没有资格重新解释“当前对象的 ETag 是否仍匹配”。

双池反例

测试构造两个 pool:pool 0 有较旧对象,pool 1 有较新对象,两者 ETag 不同。SILO 的读取选择 pool 1 作为当前对象,但非版本化删除会清理两个 pool。

条件匹配旧副本

原 PR 会在 pool 0 判定通过并删除旧副本,在 pool 1 判定失败。最终返回值取最新 pool 的 412,但失败请求已经修改了存储状态。

条件匹配当前副本

原 PR 会在 pool 1 判定通过并删除当前副本,在 pool 0 判定失败并保留旧副本。请求返回成功后,旧副本变成系统可见的最新对象,相当于对象“复活”。

callback 还捕获同一个 http.ResponseWriter;在多个 pool 并发执行时,可能并发写同一响应。即使暂时没有触发可见 race,也不应让存储副本并发决定 HTTP 结果。

降级旧池的既有局限

这里还有一个不由本补丁引入的相关旧限制:如果被选中的当前 pool 可读可写,但保存旧副本的非当前 pool 处于降级状态,现有多池删除路径可能返回当前 pool 的成功,而没有向上暴露旧 pool 的错误。残留副本在恢复后可能重新可见。

新条件检查没有制造这个行为:它先正确判断可读的当前对象,再进入无条件删除也会使用的非版本化多池清理。修复降级旧池的错误聚合与恢复语义会影响所有非版本化多池删除,不只 conditional delete,因此应单独跟踪。

选定的最小修复

1. 外层只检查一次

erasureServerPools.DeleteObject 已经取得 namespace write lock 后:

  1. 保存 opts.CheckPrecondFn
  2. 立即从传给下层的 opts 中清除 callback;
  3. 读取所有 pool 并选出当前 pinfo
  4. 如果当前对象不可可靠读取,返回 quorum 错误,callback 不运行;
  5. 针对 pinfo.ObjInfo 调用一次 callback;
  6. 条件通过后继续现有删除流程,所有 pool 不再重复判断。

这不是新发明。GetObjectNInfo 的多池实现已经使用同样的“外层保存 callback、清除下层 callback、选出 latest 后检查一次”模式。DELETE 复用该模式可以把修改限制在真实原子性边界。

2. * 显式判断当前 representation

DELETE 专用条件函数把 * 与具体 ETag 分开:

具体 ETag -> 比较当前对象的客户端可见 ETag
*         -> Name 非空并且不是 delete marker

缺失 key 在选择当前对象时已经返回 Not Found;delete marker 则进入 callback 并返回 412。通用 isETagEqual 保持不变,避免波及其他方法。

3. 具体 ETag 增加读权限

Handler 仍先检查 s3:DeleteObject。当规范化后的条件不是裸 * 时,再检查 s3:GetObject。因此:

  • Delete-only policy + *:允许;
  • Delete-only policy + 具体 ETag:403,且对象不变;
  • Get + Delete + 正确 ETag:允许。

这是授权前置检查,不会在拒绝后触碰存储。

4. 不为 DELETE 强制 SSE-C 解密请求

原 PR 直接复用 GET/PUT 的 DecryptObjectInfo,但 SSE-C 对象在没有 SSE-C 读取 header 时会被拒绝。DELETE 条件只需要客户端可见 ETag,不需要解密内容或尺寸。

选定实现只在具体 ETag 路径调用既有 getDecryptedETag* 不读取 ETag。这样复用 SILO 已有的 ETag 投影逻辑,不为 DELETE 引入内容解密要求。

5. 条件针对当前版本

AWS 规定 conditional delete 评估当前版本。SILO 的外层 pool 选择本来就读取当前对象,再把显式 versionId 留给真正的版本删除。因此上移判断后,即使请求携带历史 versionId,条件 callback 看到的也是当前 ETag。

测试固定了这一点:请求删除历史版本、条件却匹配历史 ETag而不匹配当前 ETag时,返回 412,历史版本和当前版本都保持不变。

6. 在暂不支持的边缘 fail closed

两个很小的 guard 防止单对象条件被绕过:

  • 空或仅含空白的 If-Match 会被拒绝,不会降级成无条件删除;
  • If-Match 不能与内部递归扩展 x-minio-force-delete 组合,因为 prefix 删除语义无法表达单对象 ETag 条件;HTTP Handler 会拒绝,存储层也会拒绝任何内部 prefix-delete + callback 组合。

批量 XML decoder 现在也会识别逐对象 <ETag>。在原子化逐项执行完成前,只要 batch 中出现非空 ETag,服务器就在任何删除发生前以 NotImplemented 拒绝整个请求。这不是批量条件删除支持,只是防止静默丢弃条件的数据安全护栏。

被否决的方案

只修 isETagEqual

不能解决外层 delete-marker fast path,也会改变多个 API 共用的比较语义。

保留每池 callback,再聚合结果

聚合错误无法回滚已经发生的副本删除。客户端条件是对逻辑当前对象的判断,不是对每个物理副本的独立条件。

新增复杂的条件对象或事务协调器

当前只支持一个 If-Match 条件,现有 CheckPrecondFn 足以表达;GET 已经展示了正确的一次性消费模式。新增通用 DSL、状态机或跨池事务抽象没有必要。

在同一改动中完成所有 conditional delete

DeleteObjects 与 policy condition 是相关但不同的接口和仓库边界。把它们塞进本 PR 会扩大 XML、逐项响应、IAM、quiet mode 和依赖发布的审查面,降低核心删除修复的可信度。

测试与验收契约

最小充分测试覆盖:

层级 验证内容
条件函数 正确/错误/带引号 ETag、*、delete marker、非 DELETE 方法,以及无需内容解密 header 的 SSE-C 客户端可见 ETag 投影
Handler 错误 ETag 返回 412 且对象保留;正确 ETag 返回 204;缺失 key 返回 Not Found;空条件与 conditional force-delete 无 mutation 拒绝
权限 Delete-only 的具体 ETag 返回 403且对象保留;同一策略下 * 成功
单池存储 正确/错误条件、缺失对象、delete marker callback 恰好一次,并拒绝 conditional prefix delete
Quorum 当前对象不可可靠读取时返回 quorum 错误,callback 零次,恢复磁盘后对象仍在
Versioning 显式历史 versionId 的条件仍针对当前版本
双池 412 后每个 pool 都不变;204 后所有 pool 副本都消失;callback 都只运行一次
Batch 安全护栏 尚未支持的逐对象 <ETag> 返回 NotImplemented,所有对象保持不变

原 PR 的 quorum 测试只把 16 块盘中的 8 块下线并断言“存在任意错误”。此时删除 write quorum 本来也不足,所以测试放在未实现 conditional delete 的主线上也会通过。新测试要求具体 quorum 错误、callback 未执行,并在恢复磁盘后确认对象仍在,避免同类假阳性。

独立对抗评审

两轮只读本地 Claude Code review 都使用 Fable 模型与 xhigh effort,检查精确的服务端差异和两篇设计记录。两轮结论都是 GO WITH NON-BLOCKING NOTES;第一轮意见落实后,不再存在 P0、P1 或 P2 finding。

第一轮发现 conditional force-delete 绕过、纯空白条件降级、batch ETag 被静默丢弃、版本化成功路径缺测试,以及降级旧 pool 的既有局限;上文的 guard、测试与限制说明都来自这些 finding。第二轮确认了外层原子性边界、错误处理、权限拆分、batch 字段影响面、response writer、双语一致性和最小复杂度。它剩余的一个可行动 P3 是内部调用者理论上仍可组合 prefix deletion 与 callback;存储层现在也会拒绝这个组合。

评审中有一句认为 SSE-C 未提供 customer-key header 时条件必然失败。直接检查既有实现表明并非如此:getDecryptedETag 无需解密对象内容,就会投影后端保存的客户端可见 ETag 后缀;新增定向回归测试已固定这一行为。其余非阻塞备注是多值 header 的规范化和刻意保留的“鉴权前返回 501”错误顺序。若要宣称超出公开文档的逐字节一致性,发布前仍值得用真实 AWS 对“当前 delete marker + 具体 ETag”以及“versionId + If-Match”做一次差分验证。

本轮刻意不做什么

DeleteObjects 的逐对象条件

AWS DeleteObjects API 允许每个 <Object> 携带 <ETag>,并在同一个 200 响应内逐项返回 <Deleted><Error>

安全补丁只为 ObjectToDelete 增加 ETag 字段,使 Handler 能识别条件并在 mutation 前拒绝整个请求。这样关闭了原先静默无条件删除的风险,但没有实现 AWS 要求的逐对象判断和 mixed <Deleted> / <Error> 响应。

完整兼容仍是独立且高优先级的工作:每项都要在正确锁下针对逻辑当前对象判断;逐项落实具体 ETag 的权限规则;保持 quiet mode;条件失败不能阻塞其他无关项,并在响应中分别报告。

s3:if-match policy condition key

AWS 允许通过 policy 强制 conditional delete。SILO 使用的 silo-pkg 尚未定义 s3:if-match,完整实现需要:

  1. silo-pkg 增加 condition key 与 action map;
  2. 发布新的 silo-pkg 版本;
  3. 在 server 提供单删 header 和批删逐对象 condition values;
  4. 升级依赖并做策略兼容测试。

这应是独立跨仓库交付,不是单对象原子性修复的前置依赖。

复杂度、收益与代价

生产修改仍然很小:一个 DELETE 条件函数、一次附加授权、外层十余行的一次性 callback 消费,以及针对畸形/递归请求和暂未支持 batch 条件的窄幅 fail-closed guard。复杂度主要在测试,因为删除路径横跨多池、版本、marker、quorum 和权限。

范围 复杂度 主要成本
本轮单对象修复与 batch 安全护栏 中等 删除热路径与多池/版本/权限回归
Batch conditional delete 中等偏高 XML、逐对象判断、mixed response、quiet mode
Policy condition key 中等、跨仓库 silo-pkg 发布、server condition values、策略测试

收益大于代价。它消除危险的静默无条件删除,并把条件判断放回系统已经存在的全局一致性边界。相比引入新框架,复用当前 outer-lock/latest-object 模式是最小、充分且必要的实现。

合并与发布门槛

单对象修复在满足以下条件后可以合并:

  1. 定向条件删除、权限、versioning、quorum 与双池测试通过;
  2. go test ./cmdgo vet ./cmd、格式与 diff 检查通过;
  3. 独立对抗 review 没有未解决 blocker;
  4. 作者在最新主线上整理提交并提供有效 DCO sign-off;
  5. DCO、Go CI、VulnCheck 等远端 workflow 全绿;
  6. PR 描述明确区分完整的 DeleteObject 支持与 batch fail-closed 护栏,并链接完整 batch/policy 后续项。

即使代码合并,仍不能把功能写成已发布。只有对应 release、软件包、docker.io/pgsty/minio 镜像、部署和真实 S3 客户端验证分别完成后,生产用户才能依赖它。

结论

Conditional DELETE 值得实现,PR #12 的目标和“锁内读取新鲜状态”方向也值得保留。真正需要改变的是判断边界:客户端条件属于逻辑当前对象,不能由每个物理副本分别解释。

选定方案只把 callback 上移到已经负责选择当前对象的 erasureServerPools,复用现有模式,显式处理 wildcard/delete marker,并补上具体 ETag 的读权限。它不改存储格式、不增加依赖、不引入新条件框架。batch 改动严格限于 mutation 前拒绝尚未支持的条件;完整批量执行与 policy 支持仍保持独立。

这就是本问题所需的最小、充分且必要的复杂度。

2 - 只预览文本,绝不执行:SILO Console 文本预览 PRD

状态: 设计已接受,实现待完成 · 归属: pgsty/silo-console · 跟踪: pgsty/silo#17 · 审阅: 产品、安全与前端架构三方共识

SILO Console 可以预览图片、PDF、音频和视频,却不能直接查看运维中最常见的小型日志、纯文本、JSON 与 XML。即使对象保存了完全正确的 Content-Type,前端也会在选择渲染器之前把它判为不支持。

恢复旧版浏览器原生预览很容易,却不是正确修复。对象内容由上传者控制;如果把它作为同源 HTML/XML 文档加载,一个便利功能就会变成代码执行边界。

因此最终设计给出一个更强的承诺:

SILO 只把符合条件的对象作为有界 UTF-8 文本预览,绝不让浏览器把其中的标记、MIME 或内容解释成文档。

本文固定产品边界、资源上限、安全不变量、实现形态,以及功能进入发布版本前必须取得的证据。

最终决策

第一版增加独立的 text 预览类型和 PreviewText 组件。

契约如下:

  1. 完整保留现有 image、PDF、audio、video 判定。
  2. 只有旧分类器返回 none 时,才考虑文本 fallback。
  3. 由四种目标扩展名或四种精确被动文本 MIME 触发。
  4. 通过普通鉴权下载路径获取字节,不传 preview=true
  5. 在应用层强制执行 1 MiB 读取硬上限。
  6. 只做严格 UTF-8 解码,并拒绝疑似二进制内容。
  7. 在可滚动 <pre> 中只渲染一个 React 文本节点。
  8. 永不使用 iframe、HTML/XML 解析器或 HTML 注入接口。
  9. 要么显示完整对象,要么完全不显示;不展示截断 JSON/XML。
  10. 文件超限、编码非法或加载失败时,始终保留 Download。

不新增 Console API 或 S3 API,也不扩大后端 inline MIME 白名单。

当前状况

撰写本文时,SILO 当前锁定的 SILO Console v2.1.1 仍存在这个问题。

前端预览联合类型只有:

image | pdf | audio | video | none

扩展名表包含媒体格式,却没有 .log.txt.json.xml;MIME 分类器也不识别 text/plainapplication/jsonapplication/xmltext/xml

运行时验证得到的分裂状态如下:

对象 前端结果 Console 下载响应
.log / text/plain none inline,SAMEORIGIN
.txt / text/plain none inline,SAMEORIGIN
.json / application/json “Preview unavailable” inline,SAMEORIGIN
.xml / application/xml none attachment,DENY

对象详情页判断 Preview 是否禁用时还使用了错误的与条件:有权限用户可以点开一个不支持对象,最后只看到 unavailable;另一些组合则会先提供按钮,再由服务端拒绝。

预览组件中仍残留一个通用同源 iframe fallback。按当前类型联合,这条分支实际上不可达,所以当前缺陷本身不是可利用的文本预览 XSS。但它很危险:如果只把 text 加入联合类型并让它落入旧 fallback,就会重新激活本文明确否决的同源文档加载。

根因

这是三个独立演进层之间的契约漂移。

分类契约漂移

浏览器端根据文件名和对象元数据决定资格,但封闭类型联合中根本没有文本。再正确的元数据也无法选择一个不存在的渲染器。

响应策略漂移

Console 服务端又独立判断响应能否 inline:它仍把纯文本与 JSON 视为被动安全 MIME,而 XML/HTML 保持 attachment。这个服务端决定没有映射到前端分类。

渲染器漂移

当可达预览类型已经只剩媒体时,旧通用 iframe 仍留在组件里。代码看起来保留了一项能力,类型系统却不可能再调用它。

修复必须重新对齐三层契约,同时绝不能把 MIME 元数据提升成安全边界。

为什么拒绝同源 iframe

X-Frame-Options: SAMEORIGIN 不是 sandbox。它只控制谁能嵌入响应,不限制同源 frame 中的代码能做什么。

一旦上传者控制的 HTML、XHTML、SVG 或主动 XML 被作为同源 inline 文档加载,它就可能获得 Console origin。HttpOnly Cookie 可以阻止脚本直接读取 Cookie,却不能阻止浏览器携带 Cookie 发出鉴权同源请求。只要 MIME 规则被错误放宽,存储对象就可能变成存储型应用代码。

nosniff、CSP 与 Content-Disposition 仍然是有价值的纵深防御,但都不能替代核心不变量:

不可信对象字节
      |
      v
严格文本解码器
      |
      v
React textContent

永远不进入:
iframe / innerHTML / DOMParser / XML parser / 可执行文档

产品契约

这是一个只读文本查看器,不是网页预览器,也不是在线编辑器。

用户应该能够:

  • 从列表或对象详情打开小型、符合条件的对象;
  • 在现有预览弹窗里阅读保留空白的源码文本;
  • 使用浏览器原生选择和复制;
  • 分清失败来自大小、编码、权限、对象被替换还是网络错误;
  • 随时下载原始字节。

系统绝不能让用户误以为:

  • 格式化后的 JSON 就是存储原文;
  • 截断 XML 是完整文档;
  • 替换字符本来就存在于对象;
  • 不支持的编码已经被忠实解码;
  • 主动 HTML/XML 经“消毒”后可以安全执行。

目标与非目标

目标

  1. 无需本地下载即可查看小型日志、纯文本、JSON 与 XML。
  2. 无论扩展名、MIME 与载荷如何,对象内容始终保持惰性。
  3. 把保留的响应字节与渲染文本限制在 1 MiB。
  4. 忠实显示存储文本,不做静默格式化。
  5. 列表与详情页按照相同权限和类型契约提供 Preview。
  6. 支持当前对象版本和显式选择的历史版本。
  7. 保持匿名访问和子路径部署行为。
  8. 先独立发布 Console,再由 SILO 精确消费该 Console 修订。

非目标

  • HTML/XHTML 渲染。
  • XML 解析、XSLT、外部实体与 Schema 校验。
  • Markdown 渲染。
  • JSON 自动格式化。
  • YAML/CSV 专用行为。
  • 编辑与保存。
  • 语法高亮、行号、搜索、折叠、ANSI 渲染与自动链接。
  • 大对象 head、tail 或截断预览。
  • 有损解码,以及 GBK、UTF-16、Latin-1 等编码自动探测。
  • 新增后端文本预览接口。
  • 修改现有 SVG、媒体、PDF、下载、分享或存储契约。

类似 notes.md 的对象如果精确 MIME 为 text/plain,仍可能作为原始文本显示,但不会获得 Markdown 语义。

资格判定契约

资格判定刻意分为两阶段。

第一阶段:保留旧媒体结论

完全不变地运行当前 image、PDF、audio、video 分类器。只要结果不是 none,直接返回。

这样可以保留文件名与 MIME 冲突时的历史行为。

第二阶段:文本 fallback

只有旧结果为 none 时:

  1. 最终扩展名为 .html.htm.xhtml 时明确拒绝;

  2. 按大小写不敏感方式匹配最终扩展名:

    • .log
    • .txt
    • .json
    • .xml
  3. 去掉参数、裁剪空白并转成小写,规范化 Content-Type;

  4. 精确匹配:

    • text/plain
    • application/json
    • application/xml
    • text/xml

允许扩展名或精确 MIME 任意一项命中。本版禁止 text/、子串匹配与 application/+json 等宽泛规则。

以下矩阵是强制契约:

文件名与 MIME 结果 原因
report.txt + image/png image 现有媒体结论优先。
report.json + application/pdf PDF 现有媒体结论优先。
server.LOG + application/octet-stream text 允许的扩展名,忽略大小写。
无扩展名 + application/json; charset=utf-8 text 规范化后精确 MIME 命中。
page.html + text/plain none 主动扩展名显式排除。
page.txt + text/html text 扩展名命中,但 HTML 源码保持惰性文本。
notes.md + text/plain text MIME 命中原始文本,不渲染 Markdown。
image.svg + image/svg+xml 现有 image 路径 不进入新 text/iframe 路径。

文件名和 MIME 只影响产品资格,永远不能选择可执行渲染模式。

资源契约

二进制上限定义为:

MAX_TEXT_PREVIEW_BYTES = 1,048,576

正好 1 MiB 可以预览,多一个字节就不可以。

已知大小

  • 选中版本的已知大小超过上限时,不请求正文;
  • 已知大小为零时,显示空文件状态;
  • 已知大小不超过上限时,开始有界请求;
  • 缺失大小不等于零,必须进入有界未知大小路径。

因此当前从列表向弹窗传值时,不能再用 truthy fallback 把 undefined 强制变成零。

有界请求

对于小型或未知大小对象,请求:

Range: bytes=0-1048576

额外一字节用于探测超限。

客户端必须:

  1. 在存在时检查 Content-RangeContent-Length
  2. 以 stream 读取响应,禁止调用 response.text() 或先构造完整 Blob;
  3. 最多保留上限加一字节;
  4. 观察到探测字节后立即取消;
  5. 服务端忽略 Range、返回 200 时仍执行同一限制;
  6. 只有 EOF 证明完整对象未超限后才开始渲染。

超限对象进入说明状态:显示已知大小、1 MiB 策略和 Download,不展示任何前缀片段。

请求身份与取消

预览请求身份是:

bucket + object name + version ID

请求必须复用现有生成 API 客户端或等价的 base-path-safe helper,从而保持:

  • same-origin credentials;
  • 当前 Console 子路径;
  • version_id
  • 匿名模式 X-Anonymous: 1
  • 当前错误处理和权限边界。

关闭、对象变化、版本变化、bucket 变化和组件卸载都必须中止活动请求并清空旧内容。

仅依靠 abort 不够。还要使用 generation token 或失效标记,防止已经读完或解码完成的旧响应更新新的预览。

被取消的请求不是错误,不应产生错误 Toast。

编码与内容保真

第一版只支持严格 UTF-8:

new TextDecoder("utf-8", { fatal: true })

要求:

  • 正确处理 UTF-8 BOM,不显示 BOM;
  • 保留 Unicode、emoji、TAB、LF、CRLF;
  • 非法 UTF-8 直接拒绝,不插入替换字符;
  • 解码后存在 NUL 时,按二进制或不支持内容拒绝;
  • 不猜测其他编码;
  • 不把对象正文写入日志或持久化;
  • 永远保留下载原始字节的出口。

不支持编码状态应解释:

该对象不是有效的 UTF-8 文本,或包含二进制内容。请下载后检查原始字节。

JSON 与 XML 都按解码后的原始源码显示。第一版不得执行 JSON.parseJSON.stringify:这会改变不安全整数、重复 key、空白、字面形式以及用户复制的文本。

安全渲染器

成功状态只渲染一个文本节点:

<pre>{content}</pre>

禁止:

  • iframe、object、embed;
  • dangerouslySetInnerHTMLinnerHTML
  • DOMParser 或 XML parser;
  • Markdown/HTML 渲染;
  • HTML data/blob URL;
  • 按行或 token 生成大量 span;
  • 自动链接、ANSI escape 与语法标记。

单个有界文本节点让 DOM 成本可预测,也让安全性质容易审计。

预格式化区域使用等宽字体、保留空白、默认不换行、独立承担横纵滚动、可键盘聚焦,并支持原生选择和复制。不换行是刻意选择:它能保留日志列对齐,也能避免一条 1 MiB 长行触发昂贵折行布局。

UI 状态与权限

只有同时满足以下条件时,Preview 才可用:

预览类型符合条件
AND 有对象读取权限
AND 不是 delete marker
AND 不是 prefix

对象详情页当前的与条件错误必须修复;列表与详情页必须共享同一资格函数。

符合格式但超限的对象仍然提供 Preview。弹窗负责解释正文为何没有加载;如果直接禁用按钮,用户无法区分大小、权限和类型问题。

弹窗必须区分:

状态 必要表现
Loading 可访问 busy 状态,不显示旧文本。
Success 可滚动原文和 Download。
Empty 明确“文件为空”。
Too large 对象大小、1 MiB 上限、Download;已知超限时正文请求数为零。
Invalid UTF-8 / binary 独立解释和 Download。
Forbidden 权限专属提示,不保留正文。
Not found / replaced 对象变化提示,不保留正文。
Network / server error 可操作的重试/下载状态。
Aborted / closed 静默清理。

HTTP 错误响应正文绝不能被解码后当作对象内容展示。

所有新增用户文案都必须走现有翻译层,并同时提供中英文。内容区和控制项必须在明暗主题、窄屏宽屏下保持可用。

功能与安全要求

功能要求

  • FR1: 现有媒体与 PDF 分类不变。
  • FR2: 文本 fallback 严格遵守规范扩展名/MIME 矩阵。
  • FR3: 不超过 1 MiB 的完整合格对象按严格 UTF-8 源码显示。
  • FR4: 超限对象不显示部分内容。
  • FR5: 空对象具有独立成功空状态。
  • FR6: 当前版本与选定历史版本的元数据、大小和正文使用同一 version ID。
  • FR7: 匿名访问与子路径部署保持当前请求行为。
  • FR8: 列表与详情页采用相同类型/权限结论。
  • FR9: 下载、分享、媒体、PDF 与存储行为不变。

安全要求

  • SR1: 对象字节只能通过文本内容进入 DOM。
  • SR2: Text Preview 不得包含文档渲染器或解析器。
  • SR3: 最多保留 1 MiB 加一个探测字节。
  • SR4: 关闭或身份变化后,全部旧响应失效。
  • SR5: 非法 UTF-8 与 NUL 内容不得冒充忠实文本。
  • SR6: 错误、Redux、local storage、日志和遥测不得保存预览正文。
  • SR7: 直接请求仍以服务端鉴权为最终权威。
  • SR8: 不放宽 CSP 或后端 inline MIME。

实现范围

预计 Console 改动:

  1. 重构预览分类:完整保留当前媒体结论,显式增加文本 fallback;
  2. 在预览类型联合中加入 text
  3. 新增 PreviewText:流式上限、严格解码、请求取消和明确状态;
  4. 把文本对象显式路由到该组件;
  5. 删除不可达的通用 iframe fallback;
  6. 修复对象详情页 Preview 禁用表达式,并与列表共享资格逻辑;
  7. 保留 unknown size,不再把它强制变成零;
  8. 增加中英文文案;
  9. 增加分类、组件、资源、安全、权限、版本与浏览器测试。

预计保持不变:

  • Console 与 S3 API 路径;
  • 后端 safeMimeTypes
  • CSP;
  • 对象存储与元数据格式;
  • 图片、PDF、音频、视频、下载和分享 handler;
  • 外部前端依赖。

如果未来需要 tail、服务端转码、组织级策略,或者必须穿过不支持 Range 的代理链稳定工作,可另行设计专用服务端接口。

被否决的方案

继续禁用文本预览

优点: 没有新代码和浏览器内存成本。
拒绝原因: 日志与配置对象是日常对象存储工作流,强制下载查看是可以避免的 Console 能力退化。

复用同源 iframe

优点: 代码最少,浏览器原生展示。
拒绝原因: 它把上传者控制内容与可变 MIME 元数据变成同源文档边界,同时也不限制资源使用。

现在新增后端预览 API

优点: 服务端统一上限与文本响应。
第一版拒绝原因: 用户本来就有对象读取权限,现有下载端点已经提供版本、鉴权与 Range;新 API 会重复契约,却没有建立新的数据访问边界。

显示大对象前 1 MiB

优点: 大日志更方便。
拒绝原因: 部分 JSON/XML 在结构上会误导,UTF-8 边界还需要额外处理,而且同一个 Preview 动作不再意味着完整内容。

用替换字符解码非法 UTF-8

优点: 损坏或旧日志仍可能部分可读。
拒绝原因: 用户复制的文本不再忠实对应存储对象。有损查看和其他编码应建立独立、显式产品模式。

自动格式化 JSON

优点: 缩进更易读。
拒绝原因: parse/stringify 会改变数字、重复 key、字面形式和复制内容。未来可以增加可选格式化视图,但绝不能替代原文默认。

引入 Monaco 或其他代码编辑器

优点: 行号、搜索、高亮与折叠。
拒绝原因: Bundle、Worker、CSP 与维护成本超过有界只读预览所需;原生 <pre> 更小、更容易审计。

验收与测试计划

分类矩阵

自动化测试必须锁定规范矩阵全部行、扩展名大小写、MIME 参数剥离、HTML/XHTML 显式拒绝,以及媒体冲突行为不变。

资源测试

覆盖:

  • 0 字节;
  • 1 字节;
  • 正好 1,048,576 字节;
  • 1,048,577 字节;
  • 已知超限且正文请求数为零;
  • 未知大小;
  • 206 且 Content-Range 已暴露总大小;
  • 服务端忽略 Range 并返回 200;
  • Content-Length 缺失或错误;
  • 流式读取期间关闭和切换身份。

任何情况都不得保留或渲染超过允许的完整对象。

编码与保真测试

覆盖 UTF-8 中文、emoji、TAB、LF、CRLF、BOM、非法字节序列、NUL、JSON 不安全整数、重复 key、原始空白、XML 声明、DOCTYPE、CDATA 与 stylesheet 指令。

成功视图必须保留解码原文;非法与二进制情况必须进入独立状态。

安全测试

包含 <script>、事件属性、iframe 标签、SVG handler、XML stylesheet、外部实体与可疑 URL 的载荷必须:

  • 逐字出现在 <pre>.textContent
  • 不创建对应 DOM 元素;
  • 不执行脚本或弹窗;
  • 不发出由对象正文触发的请求;
  • 在 Text Preview 中接触不到 iframe、object、embed、HTML parser 或 XML parser。

权限与竞态测试

验证:

  • 没有 GetObject 时没有可用动作,也不保留正文;
  • 历史版本遵守对应权限;
  • 元数据与正文使用同一 version ID;
  • 迟到旧响应不能覆盖新对象;
  • 401、403、404、416、5xx 正文不成为预览内容;
  • 匿名访问和 Console 子路径不回归。

浏览器回归

使用真实 SILO/Console 测试实例检查中英文路由、明暗主题、窄屏与桌面宽度;新文本状态之外,还要对媒体、PDF、下载、分享与版本工作流进行冒烟验证。

交付与完成门槛

虽然用户报告记录在 SILO 服务端仓库,修复本身归属 pgsty/silo-console

交付分阶段进行:

  1. 合入边界明确的 Console 源码与测试;
  2. 通过 TypeScript 检查、生产构建、自动矩阵与真实浏览器安全回归;
  3. 更新 Console 发布说明并重新生成实际嵌入的 Web 资产;
  4. 发布 Console 版本;这项新增可见能力适合 minor 版本;
  5. 更新 SILO 中 github.com/minio/console => github.com/pgsty/silo-console replacement 到精确新 pseudo-version;
  6. 用精确依赖构建 SILO 候选版本并重复集成验证;
  7. 发布 SILO 二进制与镜像,注明第一个包含此功能的版本。

这些是不同状态:

门槛 含义
Console PR 合入 实现存在于源码。
Console 资产/tag 发布 Console 可以被独立消费。
SILO 更新依赖 SILO 主线已集成。
SILO 正式发布 用户可以获得功能。

不能因为本地预览或 Console 源码 PR 已存在,就对用户宣称 issue #17 已经修复。

利弊取舍

最终方案选择:

  • 明确范围,而不是通用浏览器查看器;
  • 完整小文件,而不是部分大文件;
  • 原文保真,而不是自动格式化;
  • 严格 UTF-8,而不是静默有损解码;
  • 单个惰性文本节点,而不是完整编辑器;
  • 复用下载 API,而不是新增后端契约;
  • 可验证安全不变量,而不是便利的同源渲染。

代价真实存在:大型日志和旧编码仍需下载,第一版也没有搜索、行号、换行开关和高亮。这些缺失是刻意的,它们让功能足够小,可以审计;也足够强,可以信任。

审阅记录

本设计从三个视角进行独立审阅:

  • 产品范围、交付与验收;
  • 安全与前端架构;
  • 兼容性与当前源码验证。

评审者最初在“仅 MIME 是否可触发”和“非法 UTF-8 是否有损回退”上存在不同意见。交叉审阅后,三方达成唯一契约:

  • 现有媒体分类优先;
  • 文本 fallback 接受四种目标扩展名或四种精确规范化 MIME;
  • HTML/XHTML 扩展名显式排除;
  • 必须严格 UTF-8 并拒绝 NUL;
  • 有损查看另立独立方案。

当前没有待裁决设计项,可以依照本文进入实现。

3 - 数据库通知统一连接串:#53 的兼容性边界

本文是 SILO #53 的产品需求文档与最终设计归档,记录 PostgreSQL/MySQL 桶通知目标的兼容性边界、实现结果与验证证据。

最终决策

SILO 保留 PostgreSQL 与 MySQL notification target,但每种数据库只支持一种当前配置方式:

  • PostgreSQL 必须提供完整的 connection_string
  • MySQL 必须提供完整的 dsn_string

旧的五字段形式——hostportusernamepassworddatabase——继续作为当前 KV 配置系统不支持的格式。SILO 不重新注册这些 key,也不在旧配置迁移时自动把它们拼成 DSN。

旧配置迁移契约刻意保持狭窄:

旧 target 状态 处理结果
未启用 忽略,不生成 target。
已启用,且已有非空 connection_stringdsn_string 只迁移规范连接串和其他已注册设置。
已启用,只有离散连接字段 在新配置生效前拒绝迁移并使服务器启动失败;错误必须可操作、指出子系统与 target 名称,但绝不能打印凭据。

这是配置边界决策,不是删除数据库通知功能。

状态: 已由服务端提交 f1ba68358 实现,发布待完成。
归属: SILO 服务端仓库。
跟踪: pgsty/silo#53
目标: 实现并验证后进入下一个 SILO 补丁版本。

背景

SILO 从 MinIO 继承了两代数据库通知配置。

KV 时代之前的 JSON 配置既可以保存完整连接串,也可以使用五个离散字段:

host
port
username
password
database

当前 KV 配置只暴露驱动原生形式:

notify_postgres  -> connection_string
notify_mysql     -> dsn_string

这不是新方向。MinIO 在 RELEASE.2020-04-10T03-34-42Z 就废弃了五个离散字段,并要求迁移到 connection_stringdsn_string。SILO 当前的帮助表、环境变量文档与示例也已经把完整连接串作为正式接口。

SILO 是一个迁移步骤显式的新社区分支。它优先保证 S3/Admin API、当前 MINIO_* 设置、盘上数据格式和当前 KV 配置的兼容性;当一个规范形式已经存在多年时,没有必要永久保留 2020 年以前的每一种配置拼法。

问题本质

修复之前,旧配置迁移器 SetNotifyPostgresSetNotifyMySQL 会把两种形式一起写入新 KV 配置。即使旧 target 已经有完整连接串,迁移器仍会附带五个离散 key,通常只是写入空值。

新解析器会拒绝这些 key,因为 DefaultPostgresKVSDefaultMySQLKVS 都没有注册它们。合法性检查只看 key 是否存在,不看值是不是空。因此两种旧来源都会失败:

旧完整连接串 -> 规范连接串 + 五个空的未知 key -> 拒绝
旧离散字段   -> 空规范连接串 + 五个有值的未知 key -> 拒绝

通知初始化又放大了这个错误。FetchEnabledTargets 对所有通知子系统采用 fail-fast:第一个非法子系统会返回错误和空 target list。上层只记录错误并继续启动对象存储服务,于是健康的 Webhook、Kafka、NATS 等 target 也全部不可用。

仅仅让两个迁移 helper 返回错误还不能修复这个行为。错误会经过 readConfigWithoutMigrateinitConfig 向上传播,但 initConfigSubsystem 当前会把不可重试的配置错误降级成 “some features may be missing” 日志并返回成功。服务器随后在没有设置 globalServerConfig 的情况下继续启动;通知失败只是其中一个后果,区域、存储类、压缩、身份与其他持久化设置也可能全部缺失。因此实现必须把类型化数据库迁移错误传到启动边界,并在那里按致命错误处理。把它标记为可重试同样不对,因为在没有外部状态变化时,服务器只会无限重试,配置永远不会自行修复。

这个行为格外危险,因为对象读写仍然正常。操作者看到的是健康的 S3 服务,但全部事件管道已经停止。target 根本没有建立,所以不能假定故障期间产生的事件日后还能投递或补放。

此外还有诊断信息暴露问题。未注册的 password 没有敏感字段元数据,可能被原样复制到健康检查或诊断材料中;正式注册的 connection_stringdsn_string 已经按敏感值处理。

为什么第一版修复被回滚

第一版修复注册了五个离散 key,并让解析器读取它们。这样迁移结果确实能通过 CheckValidKeys,而且 target 参数结构和构造器中也仍然保留着旧字段,看起来是很自然的接线方式。

但它破坏了文档明确支持的完整连接串路径。

共享的 mc admin config set 分词器通过查找已注册 key 来识别字段边界,并不能完整理解引号。一旦 port 成为已注册 key,下面这条合法输入中就出现了一个看似新的顶层字段:

connection_string="host=db port=5432 dbname=events user=app"

分词器会在引号内部的 port= 处切开,把 connection_string 截断,再把剩余部分交给 port 解析器,最终报出 invalid port

在当前分词器下,注册 hostportpassword 这类常见词,会让连接串语法与顶层 KV 语法发生直接冲突。因此第一版注册方案被回滚;重新注册这些字段不是可接受的修复。

产品判断

数据库 notification target 是一个专业但有价值的能力。它可以直接提供数据库中的对象命名空间视图或访问流水,不要求用户额外部署事件总线;对于小型部署以及本来就在运行 PostgreSQL/MySQL 的用户仍然有意义。

旧连接参数写法的价值则低得多。五字段模型无法表达常见驱动能力:TLS 模式与证书、连接超时、应用名、Unix socket、PostgreSQL 多主机配置、MySQL 驱动参数,以及未来新增的驱动选项。同时支持两种形式还会制造优先级、合并、脱敏与测试问题;单一规范值不存在这些歧义。

完整连接串才是正确的抽象边界:SILO 负责通知语义,数据库驱动负责连接语法。

因此产品决策是保留能力、删除兼容假象。不支持的旧 target 必须被明确拒绝,不能再被“接受”后转换成一个随后拖垮无关 target 的非法配置。

目标

  1. connection_stringdsn_string 固定为数据库通知唯一受支持的在线配置接口。
  2. 允许已经含有规范连接串的旧 JSON target 跨过迁移边界,不改变其连接语义。
  3. 在离散字段旧 target 产生半成品或非法 KV 配置之前明确拒绝。
  4. 把 #53 当前“服务看似健康、全部通知静默失效”的运行时故障模式,替换为操作者必须先解决才能启动的显式启动期失败。
  5. 确保迁移错误、日志、健康报告与诊断包都不会暴露数据库密码。
  6. 从未注册写入源代码审计中删除 Postgres/MySQL 的十条例外。
  7. 在发布与迁移文档中明确兼容性边界和操作者修复路径。

非目标

  • 在当前 KV 接口中同时支持 DSN 与数据库离散字段;
  • 自动从旧离散字段生成 DSN;
  • 重写共享 KV 分词器;
  • 在本补丁中改变 FetchEnabledTargets 的 fail-fast 语义;
  • 静默跳过已启用的数据库 target,再以残缺通知覆盖继续运行;
  • 删除 PostgreSQL 或 MySQL notification target;
  • 删除为解码和识别不受支持输入所需的旧结构体字段。这些字段仍位于在线构造器共用的 target 参数结构上;构造器中的离散字段连接串合成代码无法从当前 KV 配置到达,但这些字段不能重新成为受支持的配置 key。
  • 修复其他八个旧通知 setter 被忽略的错误。它们原有的静默跳过行为在这次狭窄的数据库迁移补丁中保持不变,必须另做审计和设计决策。

功能需求

当前配置

  1. notify_postgres 接受 connection_stringnotify_mysql 接受 dsn_string
  2. 五个离散 key 继续保持未注册,并被当前配置命令拒绝。
  3. 现有完整连接串必须继续支持数据库驱动语法,包括值内部出现 hostportuserpassworddatabase 等词的情况。
  4. 不增加新的公共环境变量或 KV key。
  5. 已声明的旧变量 MINIO_NOTIFY_POSTGRES_HOST/PORT/USERNAME/PASSWORD/DATABASE 及其 MySQL 对应形式没有接入当前解析,继续作为不受支持的形式,也不得在文档中被描述成完整连接串变量的可用替代。

旧配置迁移

  1. 旧 target 未启用时,SetNotifyPostgres 必须直接返回,不生成 target。
  2. 对已启用 target,SetNotifyPostgres 必须要求非空 ConnectionString,并且只写已注册的 Postgres key。如果规范连接串与离散字段同时存在,以规范连接串为准,所有离散值都被丢弃。
  3. SetNotifyMySQLDSN 执行同样规则。
  4. 两个 helper 都不得写出 hostportusernamepassworddatabase
  5. 缺少规范连接串时,必须返回带类型或包装上下文的迁移错误,指出子系统与 target 名称。
  6. cmd/config-migrate.go 必须检查并传播两个 helper 的错误,禁止忽略。
  7. 任一 helper 失败后,都不得启用或持久化半迁移配置。
  8. 错误可以指出所需 key 和修复动作,但不得包含任何连接字段值。
  9. 传播的类型化迁移错误必须中止服务器启动,尤其不得落入 initConfigSubsystem 中 “some features may be missing” 的非致命日志路径,也不得进入可重试错误循环。
  10. 已提供规范连接串的校验错误同样遵守启动致命和保密规则;包装错误只能增加 target 上下文,不能重复 DSN 或其组成部分。

推荐错误形式:

notify_postgres:archive uses unsupported legacy discrete connection fields;
set connection_string before migrating to SILO

操作者修复路径

遇到错误的操作者必须选择一条明确修复路径。这既适用于首次切换到 SILO,也适用于升级已经运行 SILO 的部署:旧配置迁移结果不会持久化,因此同一份旧 JSON 来源可能在每次启动时重新进入迁移。一个当前仍能启动、但通知已经静默失效的部署,在升级到修复版本后会直接启动失败,直到来源配置被修正。

  1. 使用兼容的中间 MinIO 版本,把旧字段替换成 connection_stringdsn_string,验证 target 后再迁移到 SILO;
  2. 禁用或删除旧数据库 target,迁移服务器,再用规范连接串重建 target;
  3. 对全新 SILO 安装,直接使用规范连接串创建 target,不经过旧配置迁移。
  4. 对仍在读取旧 JSON 文件的现有 SILO 部署,先停留在上一个可运行版本,备份来源配置,再转换、禁用或删除数据库 target,然后启动修复版本;不要删除或改写无关配置。

文档不得暗示离散字段 target 会被自动转换。

可用性权衡

这个决策有意把一种不受支持配置的“降级启动”变成“启动硬失败”。可用性代价是真实的:一台此前仍能提供对象读写、但全部通知已经静默死亡的服务器,在修复后可能拒绝启动。

我们接受这个代价,因为对象服务表面健康、已配置事件出口却全部消失,会造成静默且可能无法补救的下游数据丢失。SILO 是一个迁移边界显式的新 fork,而离散形式从 2020 年起就已废弃。一个致命、可操作的迁移前置条件,比一次看似成功却缩减通知覆盖的升级更安全。发布注记必须突出这个启动行为,不能把它藏在内部迁移清理里。

安全要求

  1. 不支持输入的错误不得格式化输出旧参数结构或其中任何值。
  2. 测试必须使用哨兵密码,并断言返回错误和捕获日志中都不存在它。
  3. 迁移输出只能包含已注册的敏感连接串 key,不能出现独立 password key。
  4. 如果受影响部署曾在修复前导出并分享诊断包,应将数据库密码视为可能泄露并进行轮换。

备选方案

注册并解析离散字段

优点: 保留旧来源形式,并复用现存参数字段。
拒绝原因: 注册会把常见字段名暴露给共享分词器,破坏引号内的完整连接串;而且这些字段早在 2020 年就已废弃,重新注册等于反向扩大公共配置面。

迁移时自动生成规范连接串

优点: 兼容仅使用离散字段的旧安装。
拒绝原因: 这会为过时输入建立永久代码与测试责任,包括 PostgreSQL 引用、MySQL DSN 格式、socket/IPv6 行为、默认值与未来驱动漂移。对于迁移边界显式的新 fork,这个收益不足以覆盖长期维护面。

只跳过不支持的 target

优点: 对象存储服务与其他通知 target 可以继续运行。
拒绝原因: 静默丢弃已经配置的事件出口可能造成不可见、不可恢复的事件丢失。清晰的迁移失败,比一次通知覆盖缩水却看似成功的升级更安全。

修改全局通知 fail-fast 行为

优点: 限制未来非法 target 的故障半径。
本次拒绝原因: 它既不能修复数据库 target,也不能关闭凭据暴露路径,还会改变全系统错误语义。可另立独立设计和运维契约评估。

删除数据库通知 target

优点: 删除全部数据库专用维护面。
拒绝原因: 这些 target 仍然有用且相对自洽。缺陷属于过时配置形式,不属于通知能力本身。

实现范围

服务端改动应保持狭窄:

  1. 修改 internal/config/notify/legacy.go:两个数据库 setter 只输出规范已注册 key;已启用但没有规范连接串时明确拒绝。
  2. 修改 cmd/config-migrate.go:传播两个数据库 helper 的错误,并补充子系统与 target 上下文。
  3. 定义类型化数据库迁移错误,修改 cmd/server-main.go,让 initConfigSubsystem 将其作为致命错误返回,而不是记录后忽略;该错误必须保持不可重试。
  4. 本补丁不改变其他八个旧通知 setter 错误被忽略的现状;将其留给独立审计,不能暗中扩大 #53。
  5. knownUnregisteredWrites 删除 Postgres/MySQL 十项;除非存在另一个独立且有充分理由的旧例外,否则这个棘轮应当归零。
  6. 增加聚焦的迁移、启动、校验、保密和共存测试。
  7. 更新 silo.pgsty.com 的数据库通知与迁移文档。

补丁不得注册旧 key、修改通用分词器,也不得重构无关通知 target。

验收标准

只有以下证据全部成立,才算实现完成:

  1. 含完整连接串的旧 PostgreSQL target 可以迁移,通过 CheckValidKeys,并由 GetNotifyPostgres 原样返回连接串。

  2. 含完整 DSN 的旧 MySQL target 完成同等验证。

  3. 两类已启用离散字段 target 都在 target 初始化前失败;错误包含子系统与 target 名称,给出可操作修复建议,且服务器启动中止。

  4. 缺少连接串和畸形连接串的错误都不包含哨兵 host、用户名、密码、数据库或 DSN 值。

  5. 未启用的离散旧 target 不生成配置项,也不阻塞迁移。

  6. 迁移后的 KVS 不含十个离散 key,包括空值形式。

  7. 旧 target 同时包含规范连接串与冲突离散值时,只迁移规范连接串,所有输出 KVS 值中都不存在离散哨兵值。

  8. 使用真实 DefaultPostgresKVSDefaultMySQLKVS key 集的 SetKVS 回归测试,能够接受引号内包含 port=host=password= 的完整连接串。

  9. 包含健康 Webhook、Kafka、NATS target 的配置不能再带着非法迁移数据库 target 进入 FetchEnabledTargetsreadConfigWithoutMigrate 返回错误,不返回、不持久化、也不启用任何半成品配置,启动路径随后因该类型化错误中止。

  10. initConfigSubsystem 返回类型化迁移错误,既不能记录后继续,也不能进入可重试循环。

  11. knownUnregisteredWrites 不再包含 Postgres/MySQL 例外。

  12. 以下验证全部通过:

    go test ./internal/config/notify ./internal/config ./internal/event/target -count=1
    go test -v ./cmd -run 'Test(ReadConfigWithoutMigrate|InitConfigSubsystem)' -count=1
    git diff --check

    cmd 的详细输出必须显示两个前缀的测试确实执行;零匹配警告视为验收失败。服务端常规 CI 测试也必须通过;文档仓库执行 make check

实现结果

服务端提交 f1ba68358 在不扩大公共配置面的前提下实现了最终设计:

  • 两个旧数据库 setter 只输出 connection_stringdsn_string 及已注册 target 设置;
  • 未启用 target 继续忽略;已启用但缺少规范连接串的 target 返回不携带配置值的 LegacyDatabaseTargetError
  • 仅新增传播两个数据库迁移错误;
  • 类型化错误不可重试,会穿过 initConfigSubsystem,并由 serverMain 判定为致命错误,最终通过 logger.FatalIf 退出进程;
  • knownUnregisteredWrites 中 Postgres/MySQL 的十条例外已经删除;
  • 聚焦测试覆盖完整连接串往返、规范值优先级、离散值丢弃、凭据保密、迁移失败原子性、启动分类和真实 tokenizer key 集。

最终本地 Claude Code 审阅使用 Claude Fable 5 max effort,结论为 GO,置信度 high,没有 blocking finding。验证范围包括聚焦包、race 测试、go vet ./cmd 与完整 go test ./cmd -count=1。该审阅仅授权六文件服务端提交;发布仍是独立门槛。

跨仓库复核确认 pgsty/mcpgsty/silo-pkgpgsty/silo-console 都不需要实现修改:客户端只转发配置文本,package 仓库不拥有通知 schema,Console 已经把表单序列化为规范 connection_stringdsn_string。公共参考与兼容性文档随本文同步更新。

发布与兼容性声明

发布注记必须把它描述为一个被正式执行的兼容性边界:

SILO 数据库通知要求 PostgreSQL 使用 connection_string、MySQL 使用 dsn_string。2020 年前的离散 host/port/username/password/database 形式不会被迁移;请在切换到 SILO 前转换或重建这些 target。

仍使用旧格式来源配置、但已经运行 SILO 的部署同样受影响:从这个版本开始,只要存在已启用的旧数据库 target,服务器就不会启动,直到它被转换、禁用或删除。

只有当修复进入已发布的服务端 tag 后,Issue 才能关闭。补丁合入、本地网站构建、正式发布是三个不同的完成门槛。

审阅记录

Claude Fable 5 于 2026-08-23 使用 xhigh effort 审阅初稿,结论为 approve with required changes。必需校准已经吸收:启动致命错误传播扩展到 initConfigSubsystem;覆盖已经运行 SILO 的部署;明确可用性代价;补充规范连接串优先级、无效旧环境变量、其他 helper 错误范围和可执行测试。

同一模型随后完成了基于当前源码的最终复核。最终结论:approve,没有 blocking finding。复核确认中英文记录语义对齐,需求可以在当前服务端代码树上实现,验收标准覆盖启动、迁移、解析器回归和凭据保密边界。

实现完成后,又使用本地 Claude Code 的 Claude Fable 5 max effort 进行独立审阅,追踪到 ExitFunc(1),检查 driver 错误行为,并运行聚焦、race、vet 和完整 cmd 测试;最终结论为 GO,置信度 high,没有 blocking finding。

4 - 一个端点,两种权限:彻底分离用户与组状态

本文完整记录 上游 issue minio/minio#21478SILO PR #73 的讨论、修复过程和最终鉴权设计。

截至 2026-08-26 的状态: SILO PR #73 已合并为 2e2377d1c,并保留带 DCO sign-off 的修复提交 58735ee38;八项远端检查全部通过。上游 #21478 与 PR #21482 仍显示 open,但 minio/minio 已归档为只读仓库,无法继续评论或合并。
2026-08-28 组权限善后: 最终发布审查发现 set-group-status 存在同样的固定 action 问题。带 sign-off 的服务器提交 d98250110 现已根据目标状态选择 admin:EnableGroupadmin:DisableGroup,并加入真实四向 IAM 鉴权测试。本地验证与独立评审完成;push、远端 CI、merge、tag 与交付仍待后续。
本轮范围: 分别使用已有的两个 Admin Action 鉴权用户启用与禁用;不修改路由、状态值、账户存储、复制记录或客户端 API。
安全属性: 持有 admin:DisableUser 不能因此获得启用账户的能力,持有 admin:EnableUser 也不能因此获得禁用账户的能力。
发布边界: merge、tag、release package、container image、deployment 与 production verification 仍是相互独立的门槛。

太长不看(TL;DR)

SILO 同时提供 admin:EnableUseradmin:DisableUser,但共用的 set-user-status handler 过去无论目标状态是什么,都只检查 admin:EnableUser。因此,只授予 admin:DisableUser 的策略反而无法禁用账户;想让它工作,就必须额外授予 admin:EnableUser,等于主动破坏这两个 action 承诺的最小权限边界。

最终修复在鉴权前,根据请求的目标状态选择且只选择一个 action:

请求状态 必须具备的 action
enabled admin:EnableUser
disabled admin:DisableUser
非法或未知值 admin:EnableUser,保留原有“先鉴权、后校验”的默认边界

随后 handler 只调用一次 validateAdminReq。四向 IAM 集成测试同时证明两个允许路径与两个交叉拒绝路径。这个选择刻意比“兼容 Enable-only 策略过去也能禁用用户”的方案更严格,因为那种历史能力本身就是本次要修复的鉴权错误。

同一规则现在也适用于组状态:

请求的组状态 必须具备的 action
enabled admin:EnableGroup
disabled admin:DisableGroup
非法或未知值 admin:EnableGroup,保留原有“先鉴权、后校验”的默认边界

善后修复前,只有 EnableGroup 的 principal 可以禁用组,只有 DisableGroup 的 principal 反而会在执行禁用时收到 AccessDenied。组修复沿用“一个 selector、一次鉴权”的设计,不把两个 action 当成别名。

被报告的问题

Admin API 用同一个路由处理两个方向的状态变化:

PUT /minio/admin/v3/set-user-status
    ?accessKey=<target>
    &status=enabled|disabled

修复前,handler 在读取目标状态之前,就固定检查一个 action:

objectAPI, creds := validateAdminReq(ctx, w, r, policy.EnableUserAdminAction)

后面的 SetUserStatus 虽然会正确接收 enableddisabled,鉴权却已经把两种操作都当成 Enable。于是,admin:DisableUser 明明存在于策略词汇和公开文档中,却无法独立授权这个端点。

#21478 给出了真实反例:操作员希望在事件处置期间拥有“只能禁用、不能恢复账户”的策略。策略只包含 admin:DisableUser 时会收到 AccessDenied;加上 admin:EnableUser 后禁用才能成功,但操作员也同时获得了策略原本刻意不授予的恢复权限。

这不是少了一个便利权限,而是策略模型与执行点错位:

策略表达:      仅 DisableUser
请求表达:      目标状态 = disabled
Handler 检查:  EnableUser
结果:          合法禁用被拒绝
临时绕过:      额外授予不需要的启用能力

为什么两个 action 必须代表两种能力

账户状态变化具有方向性。禁用通常可以委派给事件响应人员、反欺诈控制、合规自动化或 break-glass 流程;重新启用意味着恢复访问,完全可能要求另一位审批者。

如果任意一个 action 都能授权两个方向,策略作者就无法表达这种职责分离。服务器表面上公布两个名字,实际却只执行一个合并能力。因此最终契约必须严格:

Principal 策略 禁用目标 启用目标
admin:DisableUser 允许 拒绝
admin:EnableUser 拒绝 允许
两者都有 允许 允许
两者都没有 拒绝 拒绝

内置 consoleAdmin 授予 admin:*,完整管理员仍然拥有两个操作。兼容性影响仅限于曾经依赖错误行为的自定义受限策略。

公开 PBAC 参考现在也为 admin:EnableUseradmin:DisableUser 明确写入同一契约。

设计目标与非目标

设计目标

  1. 让两个现有 Admin Action 按照各自名字真正生效;
  2. 在两个方向上都满足最小权限;
  3. 每个请求只做一次鉴权决策,最多写出一次鉴权错误;
  4. 保持路由、请求值、响应格式、自操作保护、IAM 存储调用和站点复制 hook 不变;
  5. 用测试锁死契约,防止两个权限再次被扩宽、合并或调换。

非目标

  • 把端点拆成单独的 enable 与 disable 路由;
  • 增加新的合并 action 或改变策略语法;
  • 修改用户状态持久化或复制机制;
  • 重新设计 Console 权限;
  • 把 source merge 推断为 release、镜像、部署或生产交付。

讨论过但没有采用的方案

两种状态继续只检查 admin:EnableUser

这能维持旧行为,却继续让 admin:DisableUser 失效,并迫使策略过度授权。它就是问题本身,不是值得保留的兼容契约。

任一状态都同时要求两个 action

这样两个名字只剩装饰作用,也无法委派 disable-only 操作。它在权限数量上更严格,却在表达能力和最小权限上更差。

Enable 鉴权失败后,再尝试 Disable 鉴权

上游 PR #21482 对禁用请求采用了类似形态:先用 EnableUser 调用 validateAdminReq,结果为 nil 时再用 DisableUser 调用一次。

这个 helper 有一条关键契约:返回 nil object layer 时,它已经向响应写入错误。于是 Disable-only 请求可能先提交 403,第二次鉴权又成功,随后 handler 继续修改账户状态。鉴权 fallback 绝不能在错误响应已经提交后继续执行 mutation。

禁用请求接受 Enable 或 Disable 任一个 action

validateAdminReq 本身支持多个 action,只要其中一个允许就成功。因此,如果目标是兼容旧行为,可以通过单次 variadic 调用安全实现:让 Disable-only 策略开始工作,同时保留 Enable-only 策略也能禁用用户的历史能力。

SILO 没有选择它,因为那项历史能力正是鉴权错误。它只能修复报告者的正向用例,却继续保留与双 action 模型冲突的交叉权限。需要完整账户生命周期的角色应显式授予两个 action。

先校验 status,再进行鉴权

先拒绝未知状态会改变错误优先级:过去必须先通过 Enable 鉴权门槛的调用者,现在可能在鉴权前得到参数校验结果。本次修复不需要扩大行为变化。

因此未知值继续采用 admin:EnableUser 作为默认鉴权 action;只有合法的 disabled 会选择 admin:DisableUser。通过鉴权后,仍由既有 IAM 路径拒绝非法状态值。

最终实现

修复增加一个纯选择函数:

func setUserStatusAdminAction(status string) policy.AdminAction {
    if madmin.AccountStatus(status) == madmin.AccountDisabled {
        return policy.DisableUserAdminAction
    }
    return policy.EnableUserAdminAction
}

Handler 先读取路由变量,选择 action,然后只鉴权一次:

vars := mux.Vars(r)
accessKey := vars["accessKey"]
status := vars["status"]

objectAPI, creds := validateAdminReq(ctx, w, r, setUserStatusAdminAction(status))
if objectAPI == nil {
    return
}

鉴权门之后的逻辑完全不变:

  • 调用者仍不能启用或禁用自己的账户;
  • globalIAMSys.SetUserStatus 继续校验并持久化目标状态;
  • 站点复制继续记录同一状态和更新时间;
  • 响应与审计仍走已有路径。

选择函数只依赖请求明确给出的目标状态。它不会读取当前用户,不会根据存储状态猜测 transition,也不会让鉴权结果取决于目标是否存在。这样既保证鉴权确定性,也避免在鉴权前引入读取依赖。

为什么这个修复是安全的

正确性由五条不变量组成:

  1. 每个合法状态只映射到一个 Admin Action;
  2. validateAdminReq 只调用一次,鉴权失败后不可能继续 mutation;
  3. 只有选定 action 鉴权成功,状态修改调用才可达;
  4. 非法状态保留旧的 Enable 鉴权边界,之后仍由已有状态校验路径拒绝;
  5. 存储、复制、wire 与 client contract 都不改变,变化的只有进入既有 mutation 所需的权限。

对于曾经用 Enable-only 自定义策略执行禁用操作的调用者,这是一项有意的鉴权收紧。也正是这项收紧,才让 admin:DisableUser 成为真正独立的能力。

测试设计

纯 action 映射

单元测试锁定三个选择结果:

输入 期望 action
enabled EnableUser
disabled DisableUser
非法值 旧的 EnableUser 默认值

四向 IAM 鉴权矩阵

集成测试创建彼此独立的用户和策略,再通过真实 Admin API 执行:

  1. Disable-only client 可以成功禁用目标;
  2. 同一 client 尝试启用目标时得到 AccessDenied
  3. Enable-only client 可以成功启用目标;
  4. 同一 client 尝试禁用目标时得到 AccessDenied

只检查两个正向用例不能证明最小权限:如果两个策略意外都能执行两个操作,正向测试仍会通过。两个交叉拒绝断言才是安全回归测试。

测试结束后会删除所有临时用户和策略。它运行在既有 IAM server suite 中,覆盖请求签名、策略挂载、handler 鉴权、状态持久化和 Admin client 错误解码,而不只是测试 helper。

修复与验证记录

服务端原工作树中混有依赖升级、生成的 credits、checksum 测试和安全文档修改,本地 main 也落后远端。两个用户状态文件因此被隔离到基于最新 origin/main 的干净 worktree;无关文件没有进入修复提交。

本地验证通过:

go test ./cmd -run '^TestSetUserStatusAdminAction$' -count=1
go test ./cmd -run '^TestIAMInternalIDPServerSuite$' -count=1
git diff --check

带 sign-off 的 58735ee38 被推送到 PR #73,八项远端检查全部通过:

  • DCO sign-off;
  • format、build 与 vet;
  • lint 与 generated files;
  • cmd/ tests;
  • internal/ tests;
  • race detector 与 S3 Select;
  • cross compile;
  • vulnerability analysis。

PR 使用仓库常规 merge 策略合并为 2e2377d1c。随后只有在原工作树中的两个文件与远端合并结果通过逐字节比较、patch ID 也完全一致后,本地 main 才被 fast-forward。其余无关本地改动完整保留;代码已经可以从 main 与 PR #73 恢复后,临时 worktree 与任务分支才被删除。

最小权限策略示例

只能禁用的操作员

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "admin:DisableUser",
        "admin:GetUser"
      ]
    }
  ]
}

该 principal 可以查看并禁用另一用户,但不能重新启用。

只能启用的操作员

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "admin:EnableUser",
        "admin:GetUser"
      ]
    }
  ]
}

该 principal 可以查看并启用另一用户,但不能禁用。负责完整账户生命周期的角色应显式授予两个 action。

组状态权限善后

组端点与用户端点具有相同结构:

PUT /minio/admin/v3/set-group-status
    ?group=<target>
    &status=enabled|disabled

它也公开了 admin:EnableGroupadmin:DisableGroup 两个既有 action,但继承 handler 在读取 status 前固定用 EnableGroup 鉴权。这不是一个“没有用到的权限”而已,而是同时反转了两个方向的最小权限:不该拥有禁用能力的 principal 可以禁用,真正的 disable-only principal 却不能。

善后提交增加 setGroupStatusAdminAction,刻意与 setUserStatusAdminAction 同构:

func setGroupStatusAdminAction(status string) policy.AdminAction {
    if madmin.GroupStatus(status) == madmin.GroupDisabled {
        return policy.DisableGroupAdminAction
    }
    return policy.EnableGroupAdminAction
}

集成测试创建相互独立的 EnableGroup-only、DisableGroup-only 管理员与真实目标组,证明:

  1. DisableGroup-only 可以禁用;
  2. DisableGroup-only 不能启用;
  3. EnableGroup-only 可以启用;
  4. EnableGroup-only 不能禁用。

测试覆盖签名 Admin 请求、策略挂载、handler 鉴权、IAM mutation、错误解码与清理。非法 status 仍先选择历史默认的 Enable action,再由既有逻辑返回校验错误,因此没有新增鉴权前信息泄漏。成功后的 site-replication hook 保持不变,被拒绝请求不会触发。

这个善后不改变用户状态行为,也没有新增 policy action;它只是让两个早已公开的组 action,执行与用户 action 相同的按目标状态严格选权契约。

兼容性与迁移

客户端和 API 都不需要迁移:endpoint、query parameter、status string、成功响应与 Admin client method 全部不变。

但受限管理员角色需要检查策略:

  • 只负责禁用用户的角色需要 admin:DisableUser
  • 只负责启用用户的角色需要 admin:EnableUser
  • 两种操作都需要的角色必须同时授予两个 action;
  • consoleAdmin 与其他 admin:* 策略不受影响;
  • 旧的自定义策略若只包含 admin:EnableUser,将不能再借此禁用用户;确实需要两个操作时,应增加 admin:DisableUser

组管理角色现在遵循完全对称的规则:

  • 只负责禁用组的角色需要 admin:DisableGroup
  • 只负责启用组的角色需要 admin:EnableGroup
  • 两个方向都需要时,必须同时授予两个 action;
  • 旧 EnableGroup-only 角色不能再借此禁用组。

这是 source-level 的鉴权行为兼容性变化,不是 wire-protocol break。

上游处置

在本文记录时,上游 #21478 与 PR #21482 仍显示 open,但上游仓库已经归档为只读。我们尝试把“只做一次鉴权”的分析留在 PR 上,GitHub 因归档、锁定的讨论不能新增评论而拒绝了请求。

上游 issue 与 PR 仍然是有价值的来源证据,但已经不再是可执行的交付路径。SILO 必须独立拥有自己的语义、测试、merge、release note 与最终生产验证。

交付状态

门槛 用户修复 2026-08-28 组善后
设计决策 完成 完成
实现与本地测试 完成 完成
独立对抗评审 完成 完成,GO
带 sign-off 的提交 完成 本地 d98250110
Push、PR CI 与 merge 完成 尚未确认
SILO tag 尚未确认 尚未确认
Release package 或 container image 尚未确认 尚未确认
部署 尚未确认 尚未确认
生产行为 尚未确认 尚未确认
上游合并 不可用;仓库已归档 不适用

结论

这些修复让鉴权模型说真话。启用和禁用用户或组,都是风险方向相反的状态变化,SILO 也早已为每个方向提供不同 policy action;每个 handler 都应该从请求的目标状态选择 action,并在 mutation 前只鉴权一次。

代码很小,是因为设计边界足够清晰。真正需要长期保留的是更完整的结果:明确的权限矩阵、被否决的兼容方案、非法输入规则、四向集成测试、干净的合并证据、迁移指引,以及不把“已合并”误报成“已发布”的交付边界。

5 - 配置环境文件不是 Shell 脚本

本文定义 MINIO_CONFIG_ENV_FILE 的启动契约,并记录 SILO 提交 ce456dba0 中的兼容性修复。

截至 2026-08-28 的状态: 实现、定向测试、完整 cmdinternal 套件、tagged tests、race、vet、lint、生成物检查、rebrand 守卫、构建与本机 Fable Max 独立评审均已完成。服务器提交已在本地创建;push、远端 CI、merge、tag、软件包、镜像、部署和生产验证仍是独立门槛。
范围: 只修改环境文件解析与命名 target 发现;不改配置键、子系统、取值优先级、存储格式或客户端 API。
兼容性原则: 这是 SILO 的输入格式。支持可选的 export 前缀,并不意味着它是一段 POSIX shell 程序。

太长不看(TL;DR)

SILO 可以从文件加载启动环境变量:

export MINIO_CONFIG_ENV_FILE=/etc/default/silo
silo server /data

解析器接受如下写法:

MINIO_ROOT_USER = silo-admin
MINIO_ROOT_PASSWORD = "  两侧空格有意义  "
MINIO_NOTIFY_WEBHOOK_ENABLE_my-hook = off
MINIO_NOTIFY_WEBHOOK_ENDPOINT_my-hook = https://events.example.com/minio

最后两个键尤其关键。支持多 target 的配置会把 target 名原样拼到下划线之后;配置子系统并没有要求 target 必须是 shell identifier。带 -.:、数字或可见 Unicode 的名称,都可以被精确发现和解析。

此前一轮加固意外把每个键都限制成 [A-Za-z_][A-Za-z0-9_]*。结果是 my-hook 变成非法名称,旧 loader 与配置模型原本接受的文件,会在服务器下次重启时阻止启动。最终修复改为验证 SILO 真正需要的约束:

  • 键名非空、是合法 UTF-8,并且只包含可见的非空白字符;
  • 键名不能包含 = 或 NUL;
  • 值不能包含 NUL;
  • 错误报告文件与行号,但不报告 value;
  • 整个文件先完整解析,再开始设置环境变量。

为什么这是真实兼容回归

环境文件 loader 解析完成后调用 os.Setenv。操作系统环境是一组字符串,不是 shell 变量命名空间。Shell 的赋值语法更窄,是因为 shell 还要在自己的语言中对变量名做分词和展开。

SILO 命名配置 target 的结构是:

MINIO_<SUBSYSTEM>_<PARAMETER>_<target>

例如:

MINIO_NOTIFY_WEBHOOK_ENABLE_my-hook
MINIO_NOTIFY_WEBHOOK_ENDPOINT_my-hook

Target discovery 会按固定 parameter 前缀枚举变量,并把剩余后缀当成 target;读取时也用同一个原样后缀重建变量名,不会大写或净化 target。环境文件解析器拒绝 -,因此破坏的是一条本来完整可用的发现—读取链,而不是在保护某条 shell 执行路径——因为这个文件根本不会被 shell 执行。

该问题的运维影响很尖锐:MINIO_CONFIG_ENV_FILE 只在启动时读取。服务器可能继续使用旧进程环境正常运行,却在文件或二进制更新后的下一次重启突然失败。错误输入当然应该 fail fast,但 parser 不能擅自发明比配置系统更窄的 target 语法。

文件语法

行与注释

  • 忽略空行;
  • 忽略第一个非空白字符为 # 的整行;
  • 删除后面紧跟空白的独立 export 前缀;
  • exportFOO=value 的键仍然是 exportFOO,不会误删前缀;
  • 第一个 = 分隔 key/value,后续 = 全部保留在 value 中。

这个文件不是 shell,不执行变量展开、命令替换、反斜杠处理或行尾注释解释。

键名

键名两侧空白会先删除,剩余内容必须:

  1. 非空且为合法 UTF-8;
  2. 只包含 Unicode graphic 字符;
  3. 不包含空白、=、NUL、控制字符或不可见格式字符。

这个契约保留 OS 兼容名称与多 target 后缀,同时拒绝视觉上为空或结构含糊的键。以数字或标点开头的键可以通过 parser;SILO 仍只读取自身组件实际使用的精确名称。

值与引号

未加引号的值会 trim。需要保留首尾空格时,请用匹配的单引号或双引号包裹完整值:

PLAIN = value
SPACED = "  两侧空格有意义  "
TOKEN = scheme://user:password@example.com?a=b
EMPTY =

解析器只删除一对匹配的外层引号,不解释引号内部的转义。NUL 永远非法,因为操作系统环境项无法表示它。

失败与保密契约

语法错误会阻止启动。诊断包含文件路径、行号和非法键或错误类别,但绝不包含 value;密码即使出现在坏行中,也不能被复制进日志。

语法解析是全有或全无:任一行出错都会返回空结果,只有整个文件成功后才开始赋值。如果操作系统拒绝一个已经通过 parser 的赋值,SILO 同样停止启动,并指出键名与文件。进程会退出,因此不会以“只加载一半”的环境继续对外服务。

环境文件本身仍然是包含机密的高权限输入。操作员必须设置正确的属主与权限;parser 校验不能替代文件系统访问控制。

回归矩阵

提交中的测试覆盖:

  • = 两侧的空格与 tab;
  • 需要保留空格的 quoted value;
  • 独立 export,包括后接 Unicode 空白;
  • _、数字或标点开头的键;
  • 使用 -.: 与 Unicode 的命名 target;
  • 通过配置子系统实际发现命名 target;
  • 空键、空白、NUL 与不可见 format character;
  • NUL value;
  • URL/token 中的多个 =
  • 不泄漏 value 的文件/行号诊断;
  • 解析失败返回空结果。

实现通过了完整本地服务器验证矩阵与只读对抗性评审。Windows runner 尚未实测新放行名称的 os.Setenv 行为;若平台拒绝,契约仍是显式 fail fast,而不是静默忽略。

兼容性与交付

不需要迁移配置。普通环境键行为不变;带 shell 风格空白的文件更可预测,原本合法的命名 target 恢复工作。

外部可见变化都是刻意的:

  • 非法或不可见键名现在失败,而不再静默失效;
  • 未加引号的 value 会删除首尾空白,有意义时必须加引号;
  • 畸形输入以带位置且脱敏的错误阻止启动;
  • 仅仅因为 shell 不能用 NAME=value 语法直接赋值,不再拒绝一个合法的标点 target。

本文记录的是 source commit,不是已交付 release。在提交完成 push、远端测试、merge、tag、打包、制镜像和部署之前,不能假设公开 SILO 二进制已经具备此契约。

结论

配置兼容性的前提,是验证 SILO 真正消费的格式。MINIO_CONFIG_ENV_FILE 只借用了少量 dotenv 风格语法方便运维,并不会被 shell 执行。最终修复在恢复命名 target 兼容性的同时,保留了 NUL、不可见字符、脱敏与 fail-fast 保障。

6 - 两把 SSE-C 密钥,一份 CopyObject 响应

本文记录 SILO 提交 e37b0134a 中的 CopyObject SSE-C checksum 响应修复。

截至 2026-08-28 的状态: 实现、加密与换钥测试、完整服务器套件、race、静态检查、构建和 Fable Max 独立验收均已完成。提交已在本地创建;push、远端 CI、merge、tag、软件包、镜像、部署和生产验证仍是独立门槛。
范围: 只处理目标对象提交成功后的 CopyObject XML 与 HTTP 响应。存储对象字节、checksum metadata、加密格式、源对象解密、federation、replication 与历史对象均不改变。
安全属性: source SSE-C header 只能解密源状态;destination SSE-C header 只能解释已提交的目标状态。

太长不看(TL;DR)

一次 SSE-C 复制可以同时使用两把相互独立的密钥:

角色 请求头 用途
源对象 X-Amz-Copy-Source-Server-Side-Encryption-Customer-* 解密源对象
目标对象 X-Amz-Server-Side-Encryption-Customer-* 加密并解释已提交目标对象

SILO 会用目标密钥正确写入目标对象。但提交完成后,XML generator 与通用 PUT 成功响应 helper 都收到了完整 CopyObject request。Checksum metadata decrypter 在 copy-source SSE-C header 存在时会优先使用它:读取源对象时这个优先级是正确的,解释已提交目标对象时却是错误的。

源 key A、目标 key B 时,旧流程是:

源对象 body       --用 A 解密--> 逻辑字节
逻辑字节          --用 B 加密--> 已提交目标对象
目标 checksum     --用 B 密封--> 已存 checksum metadata
响应 decoder      --误用 A----> key mismatch,省略 checksum

对象与持久化 checksum 都是正确的;缺陷只发生在成功响应上。最终修复复制请求头、删除恰好三个 copy-source SSE-C customer header,以此形成目标响应视图;随后只解密一次目标 checksum,并把同一个 map 同时用于 XML 和 HTTP response header。

可观察故障

触发条件是目标对象带 checksum,并且源/目标使用不同 SSE-C 上下文。代表性请求包含:

x-amz-copy-source: /bucket/source
x-amz-copy-source-server-side-encryption-customer-algorithm: AES256
x-amz-copy-source-server-side-encryption-customer-key: <key A>
x-amz-copy-source-server-side-encryption-customer-key-md5: <md5 A>
x-amz-server-side-encryption-customer-algorithm: AES256
x-amz-server-side-encryption-customer-key: <key B>
x-amz-server-side-encryption-customer-key-md5: <md5 B>
x-amz-checksum-algorithm: CRC32

修复前:

  • CopyObject 返回 HTTP 200;
  • 用 key B 读取目标对象得到正确 body;
  • 用 B 解密的持久化 checksum 与逻辑字节匹配;
  • CopyObject XML 与 HTTP response 却没有 CRC32 和 ChecksumType

所以这是 response contract 缺陷,不是对象数据已经损坏的证据。

同样的歧义也出现在同对象 SSE-C 换钥。Metadata 已经用 B 重新密封后,请求中仍带着表示旧源对象的 copy-source key A;响应描述的是换钥后的对象,因此必须使用 B。

为什么不能修改全局 decrypter

Metadata decrypter 的 source 优先级本身没有错。CopyObject 在更早阶段需要读取源 checksum metadata,决定是保留算法、把 composite 重算成 full-object,还是为无 checksum 源增加默认 CRC64NVME。SSE-C 源对象的这份 metadata 由源对象密钥保护,必须使用 copy-source header。

如果把全局优先级改成“目标 SSE-C 优先”,最终响应会恢复,源 checksum 解释却会被破坏。安全边界必须按对象和时间区分:

提交前:完整请求头,源对象上下文
提交后:仅目标 SSE-C 头,目标对象上下文

最终修复只作用于提交后的响应边界。

最终实现

目标响应视图

Handler clone 请求头并精确删除:

  • X-Amz-Copy-Source-Server-Side-Encryption-Customer-Algorithm
  • X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key
  • X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key-MD5

普通目标 SSE-C header 保留。SSE-S3 与 SSE-KMS 的目标 metadata 不需要 customer key,继续走原有路径。

解密一次,投影两次

修复前,CopyObject 构造 XML 时调用一次 decryptChecksums,写成功 response header 时又调用一次。对于 SSE-S3 或 SSE-KMS,这可能重复执行 KMS unseal。

修复后:

已提交 ObjectInfo
  -> 使用目标 header 调用一次 decryptChecksums
  -> 填充 CopyObjectResult XML
  -> 填充 x-amz-checksum-* 与 x-amz-checksum-type header

通用 setPutObjHeaders wrapper 仍服务于 PutObject、CompleteMultipartUpload 与 DeleteObject;CopyObject 调用一个接收已解密 checksum map 的窄 helper。ETag、VersionID、delete marker、lifecycle prediction 与 checksum header 仍共享同一份实现。

回归矩阵

测试覆盖:

  • 明文源到 SSE-C 目标;
  • 压缩与未压缩 SSE-C 目标;
  • SSE-C 源 key A 到目标 key B;
  • CopyObject XML 与 HTTP header 中的 checksum value/type;
  • 用目标 key B 解密持久化 checksum;
  • 用 B 读取目标 body;
  • 同对象从 A 换钥到 B;
  • 换钥后的 checksum 响应;
  • SSE-S3 源/目标组合;
  • API test harness 使用的全部对象层后端。

最终组合 tree 通过了定向加密测试、完整 cmd/internal 套件、项目 tagged test 配置、全量 go test -race ./...、vet、lint、生成物检查、rebrand 守卫和本地构建。Fable Max 镜像评审没有发现 P0–P2,并独立确认:源对象解密仍接收完整请求,目标响应解密只接收过滤后的视图。

兼容性与运维影响

  • 成功响应: 已提交目标带 checksum 时,过去缺失的字段现在会出现。
  • 存储对象: 不重写数据,不修改 metadata format 或 encryption format,不需要迁移。
  • 既有对象: 不受影响;缺陷只存在于一次性的成功响应中。
  • 客户端: 请求无需改变;已经同时提供源/目标 SSE-C key 的客户端会得到更完整的 S3 兼容结果。
  • 性能: metadata checksum 从解密两次降为一次;不增加对象读取或 hash pass。
  • 滚动升级: 旧节点可能省略字段,新节点会返回;存储对象仍互相可读。
  • 回滚: 只恢复响应省略,不会损坏修复版本期间创建的对象。
  • 安全: 不向日志或错误响应增加 key/digest;只返回该成功写入本来就授权可见的 checksum。

本修复不处理另行保留的 legacy federation CopyObject 分支,也不审计或修改历史压缩对象 checksum;它们具有不同的数据与运维边界。

结论

CopyObject 是一条请求,但涉及两个对象身份。提交后继续复用完整请求,会抹掉这种区别:描述目标 metadata 时,源 key 仍能遮蔽目标 key。真正耐久的修复不是新增加密机制,而是建立明确上下文边界,然后只做一次解密、两次如实响应投影。

7 - 为什么 CompleteMultipartUpload 必须返回 ChecksumType:PR #57 评审记录

本文是 SILO #47PR #57 的设计、评审与决策归档。

截至 2026-08-26 的状态: PR #57 已批准并合并为 a96116b1#47 随后自动关闭。被测 PR head 的九个检查项全部通过,合并后 main 的 Go CI 与 VulnCheck 也全绿。尚未验证任何 tag、release package、container image、deployment 或 production endpoint 已包含本修复。
范围:CompleteMultipartUploadResult 返回服务器已经知道的 checksum type;不增加任何新 checksum 算法。
归属: pgsty/silo 服务端仓库。
发布边界: 代码评审、合并、main 全绿、tag、软件包、容器镜像、部署与生产验证是相互独立的门槛。

太长不看(TL;DR)

SILO 早已为完成后的 multipart 对象计算并持久化正确的 checksum type。HEADListPartsGetObjectAttributes 都能返回它,唯独 completion 响应不行,因为对应的 Go response struct 只有各算法 checksum value,没有 ChecksumType 字段。

PR #57 增加这个字段,从已有 checksum map 中复制现成值,在 compatibility baseline 中登记新的导出符号,并测试 FULL_OBJECTCOMPOSITE 和无 checksum 三种情况。它不重新计算数据、不修改 metadata、不迁移对象,也不放松任何完整性检查。

这个修复正确而且范围刻意狭窄。Maintainer 批准了 fork workflow,把陈旧 PR 分支更新到当前 main,要求新一轮检查全部通过,提交正式批准评审,并在保留贡献者 sign-off commit 的前提下完成合并。仓库集成已经完成,release delivery 仍是独立门槛。

问题从哪里来

这个缺陷是在调查 #31 时发现的。真实 boto3 客户端暴露出一组彼此相邻但边界不同的 multipart checksum 兼容问题。#31 是数据路径故障:FULL_OBJECT CRC32 multipart upload 可能在 completion 阶段失败;它由 0cff48f6c75859690b 独立修复,并在 2026-08-04 关闭。那次审查有意把四个相邻发现拆成 #46、#47、#48 与 #50,而没有把它们混成一个 checksum bug。

对象能够成功完成后,还残留着另一处不一致:

complete_multipart_upload() -> ChecksumType: None
head_object()               -> ChecksumType: FULL_OBJECT

AWS S3 在两处都会返回 FULL_OBJECT。SILO 的 completion XML 已经返回 checksum value,完成后的对象也保留着正确 type,但 completion 的 SDK 结果却把 type 暴露成 null。

这个观察形成了 #47。它是响应展示缺陷,不是 checksum 计算或存储缺陷;它不能解释 #31 之前的 InvalidPart,修复它也不能替代 #46 的服务端逐 part checksum 工作,后者随后以 7fea6d5a5 独立落地。

S3 响应契约

AWS CompleteMultipartUpload APIChecksumType 定义为 CompleteMultipartUploadResult 的 XML 元素,合法值只有:

含义
FULL_OBJECT 返回的 checksum 覆盖完成后对象的逻辑字节。
COMPOSITE 对象 checksum 由 multipart 各 part checksum 派生。

对象没有额外 S3 checksum 时,这个元素应当缺席;服务器不能在没有 checksum value 时凭空制造一个 type。

这个区别对客户端很重要。同名的 Base64 checksum 字段既可能表示完整对象直接摘要,也可能表示 multipart 组合结果。客户端要验证 completion 响应,就需要知道 type,才能正确解释 checksum,并与 CreateMultipartUpload 阶段选择的模式比较。

PR 之前 SILO 做了什么

completion handler 已经把提交完成的 ObjectInfo 交给 generateCompleteMultipartUploadResponse,而 generator 也早已调用:

cs, _ := oi.decryptChecksums(0, h)

checksum decoder 返回的 map 同时包含算法值和规范化对象类型:

CRC32                 -> "...Base64..."
x-amz-checksum-type    -> "FULL_OBJECT" 或 "COMPOSITE"

response struct 会复制 CRC32、CRC32C、CRC64NVME、SHA1、SHA256,却根本没有位置存放 type:

已提交的 ObjectInfo.Checksum
        -> decryptChecksums
        -> checksum values + x-amz-checksum-type
        -> CompleteMultipartUploadResponse
        -> value 被复制,type 被丢弃
        -> XML 没有 <ChecksumType>
        -> SDK 返回 None / null

其他接口使用同一份状态时没有问题。ListPartsGetObjectAttributes 已经返回 ChecksumTypeHEAD 也会报告持久化 type;丢失只发生在 CompleteMultipartUpload 的成功 XML。

PR #57 修改了什么

贡献者 diff 只有一个已 sign-off 的提交,修改三个文件,新增 60 行、删除 0 行;生产代码只有两行。Maintainer 随后把当前 main 合入贡献者分支以刷新 CI 上下文;这次 merge 改变的是历史,不是三文件产品 diff。

增加响应字段

ChecksumType string `xml:"ChecksumType,omitempty"`

omitempty 是兼容契约的一部分:没有 checksum 的上传继续保持原来的 XML 形状。

复制已经规范化的值

ChecksumType: cs[xhttp.AmzChecksumType],

generator 不会根据 ETag、算法名或 part 数量重新猜测 type,而是使用与其他 checksum value 同源的解码 metadata。

测试响应表面

新增测试覆盖:

  • 无 checksum:Go 字段为空,XML 不出现 <ChecksumType>
  • full-object checksum:字段为 FULL_OBJECT,XML tag 存在;
  • multipart composite checksum:字段为 COMPOSITE,XML tag 存在。

测试先检查 XML 编码前的 response value,再独立检查编码后的省略/出现行为。

登记导出兼容符号

CompleteMultipartUploadResponse.ChecksumType 是导出的 Go 字段。SILO rebrand guard 会对导出兼容表面做精确集合比较,因此 PR 正确地把它加入 buildscripts/rebrand-guard/compat-baseline.json。这是对有意公共表面变化的确认,不是绕过 guard。

为什么这个修复有效

正确性建立在一条很短的既有不变量链上。

  1. ObjectInfo.Checksum 是已经提交的 checksum metadata;对象层返回已提交 ObjectInfo 以后,completion 才生成响应。
  2. decryptChecksums(0, h) 复用现有 metadata 解密路径,包括 SSE-C 所需的请求 header;没有第二套解密机制。
  3. checksum decoder 只有在解出非空 checksum value 时,才写入 x-amz-checksum-type
  4. 既有 ChecksumType.ObjType() 会把可到达状态规范化成 FULL_OBJECTCOMPOSITE
  5. nil map 或不存在 key 的索引结果是空字符串。
  6. XML omitempty 会删除空字符串对应的元素。

最终行为完全确定:

已提交 checksum 状态 Map 值 Completion XML
没有额外 checksum 没有 <ChecksumType>
full-object checksum FULL_OBJECT <ChecksumType>FULL_OBJECT</ChecksumType>
multipart composite checksum COMPOSITE <ChecksumType>COMPOSITE</ChecksumType>

所以这次修改只是把已经成立的状态投影到 wire response。它不创建 checksum state,也不能把错误 checksum 变正确;它只是让响应如实描述服务器已经验证并提交的状态。

评审与验证

评审在贡献者分支更新到当前 main 后进行。更新产生 head c4b9d38d;其 tree hash 39ec44c6b390c441413e490370f70fbacc4e6a91 与隔离本地 no-commit merge 完全一致。合并结果干净,并包含 main 中间新增的 checksum 工作。

在这份精确 merge result 上完成的本地验证包括:

定向 ChecksumType 回归测试
CGO_ENABLED=0 go test ./cmd/ -count=1 -timeout 30m
go vet ./cmd/
gofmt 与 git diff --check
rebrand compatibility guard
本地 DCO 规则

定向回归测试在 2.174 秒内通过,完整 cmd package 测试在 168.956 秒内通过。commit author email 与 Signed-off-by trailer 完全匹配。Git commit 密码学签名与 DCO 是两件事,本仓库不要求前者。

另一次独立、本机、只读 Claude Code 对抗审查检查了合并 diff、checksum 序列化、XML 路径、当前 main、测试、DCO 和 compatibility guard。它的结论是 COMMENT:生产修改正确且安全,但倾向于在合并前再加一个 HTTP 级 completion 测试。Maintainer 认同该测试能提升保真度,但不同意把它列为 blocker:handler 直接委托给已经测试的 generator,现有真实 MPU 测试也已经覆盖持久化的 FULL_OBJECTCOMPOSITE 状态。因此正式 GitHub review 记录为 APPROVED,HTTP 级测试作为后续项。

Actions、分支刷新与合并

最初四个 action_required run 创建于 2026-08-09,使用的是 PR 旧 base。批准后 DCO 通过,但旧 VulnCheck run 使用 Go 1.26.5,命中了后来公布、在 Go 1.26.6 修复的标准库漏洞。此时当前 main 已经迁移到 Go 1.27.0,最近一次 VulnCheck 也是绿色。把这次陈旧失败解释为产品回归不对,把红灯直接忽略同样不对。

最终决策是刷新测试上下文,而不是重跑或豁免陈旧结果:

  1. GitHub update-branch API 把当前 main8d76a255c)合入贡献者 head d014a12cf,无冲突地产生 c4b9d38d
  2. GitHub 为刷新后的 head 新建四个 fork workflow;四个 run 再次被显式批准。
  3. 九个检查项全部通过:DCOVulnCheckGo CI 的六个 job 与 Test Release Pipeline;其中 release validation 用时 11 分 26 秒。
  4. 针对 c4b9d38d 提交正式批准评审。
  5. 合并使用 expected-head guard 与仓库常规 merge 策略,产生 a96116b1;它保留贡献者 sign-off commit,而没有通过 squash 重写。PR 的 Resolves #47 在一秒后自动关闭 issue。
  6. 合并后 mainVulnCheckGo CI 六个 job 再次全部通过;最慢的 cross-compile 用时 9 分 54 秒。

这段过程很重要,因为验收标准不是“这份 patch 曾经通过一次”。真正被合入当前 main 的精确 tree 必须就是被评审、被测试的 tree,陈旧 CI 环境不能代替这份证据。

对这个 PR 的评价

做得好的地方

  • 范围与缺陷完全匹配。 两行生产代码恢复一个丢失的响应元素。
  • 复用权威状态。 没有重复推导 type,也没有新增 checksum algorithm 分支。
  • 向后兼容明确。 omitempty 保持无 checksum 响应不变。
  • 测试覆盖两个合法值与缺席状态。 回归不能再静默恢复成 null。
  • 兼容基线有意更新。 CI 没有被削弱。
  • DCO 来源完整。 唯一提交的 sign-off 匹配。

非阻断评审注记

测试对 generator 改动本身是正确的,但 fixture 没有逐字节模拟生产环境的全部 multipart metadata flag:

  • FULL_OBJECT fixture 通过非 multipart checksum 状态得到正确值,而不是真实完成对象所携带的 ChecksumMultipartChecksumIncludesMultipartChecksumFullObject
  • COMPOSITE fixture 带 multipart flag,但没有真实持久化的逐 part checksum block。

现有 API 级测试已经运行真正的 FULL_OBJECTCOMPOSITE completion,并验证提交后的 type;PR #57 补上剩余的“解码状态到 response field/XML”投影测试。给完整 API 测试再加一条 response 断言会提升保真度,但不是这次两行修复的合并前置条件。

PR 把 ChecksumType 放在算法字段之前,而 AWS 示例与 SILO 较新的 CopyObjectResponse 都把它放在最后。主流 S3 SDK 按元素名解析 XML,所以这属于 parity/style 细节,不是兼容 blocker;是否移动字段是可选项。

最后,贡献者 commit title 使用 feat:,但 PR 自己正确标记为 bug fix。最终 merge 保留了这个 sign-off commit,没有重写历史。这是 history/style 瑕疵,不是协议或发布 blocker。

为什么不能把新算法塞进这个 PR

AWS 现在还列出 SHA512、MD5、XXHASH 等字段,但只增加这些 XML 字段会制造虚假兼容性。

SILO 当前 checksum 实现支持 CRC32、CRC32C、CRC64NVME、SHA1、SHA256。真正增加一种算法,需要同时实现:

  • request header 解析与校验;
  • 流式 checksum 计算;
  • multipart FULL_OBJECTCOMPOSITE 语义;
  • 盘上 checksum 编码与解码;
  • UploadPart、UploadPartCopy、completion、copy、replication、HEAD、GET、ListParts、GetObjectAttributes;
  • SDK/client 互操作,以及完整的加密、压缩、版本化测试矩阵。

PR #57 不应为服务器不会计算、不会持久化的算法增加 response-only 占位字段。每一类新算法都需要独立兼容性决策、实现与评审。

兼容性与运维影响

  • S3 客户端: 支持 checksum 的客户端在此后成功完成 MPU 时收到 ChecksumType,不再得到 null。
  • Wire format: 只有存在额外 checksum 时才新增一个 XML 元素;忽略未知元素的旧客户端不受影响。
  • 完整性: 不重新计算 checksum,也不改变接受条件;原有校验语义不变。
  • 存储数据: 对象、part、metadata 与纠删码格式均不变化;无需迁移或回填。
  • 既有对象: 对象状态原本就是正确的;过去的一次性 completion response 无法补发,可用 HEAD 或 GetObjectAttributes 查看 type。
  • 加密: 响应复用既有 checksum metadata 解密路径,不暴露 key material 或新的秘密。
  • 性能: 一次 map lookup 和一个可选 XML 元素;不增加对象读取、hash pass 或与对象大小成比例的分配。
  • 滚动升级: 旧节点省略元素,新节点返回元素;请求与存储兼容,但所有服务节点升级后客户端可见行为才稳定。
  • 回滚: 回滚只会让今后的 completion 再次缺字段,不会破坏修复版本期间创建的对象。
  • 其他仓库: 不需要服务端依赖、silo-pkg、MCLI 或 Console 修改;公共文档归本站所有。

这是一个增量兼容修复,不是要求操作者重写数据的新功能。唯一外部可见变化是成功响应更加完整。

合并与发布决策

最终决策包含六部分:

  1. 接受狭窄的状态投影修复,不重新计算 checksum,也不改变存储;
  2. SHA512、MD5、XXHASH 等算法族在得到服务端全链路支持前不得塞入 #57;
  3. HTTP 级 completion 测试是有价值的后续工作,但不是这个直接受测 generator 修复的 blocker;
  4. 拒绝把陈旧 CI 当作合并证据,把分支更新到当前 main 并批准新创建的 workflow;
  5. 刷新后的 head 正式批准且所有检查全绿后,使用 expected-head guard 与普通 merge,保留 DCO sign-off contribution;
  6. Resolves #47 自动关闭 issue,再独立验证生成的 main workflow。

本次不需要 dependency update、storage migration 或跨仓库实现。仓库集成门槛已经完成。

绿色 main 仍不能证明 SILO tag、release package、container image、deployment 或 production endpoint 已包含本修复。下一次 release 交付时,仍须分别记录这些尚未验证的门槛。

结论

PR #57 是一个很好的小型兼容修复范例:它的正确性来自尊重已有单一事实源。checksum type 早已被计算、校验、持久化、解密,并能通过其他 API 看到;completion response 只是漏了把它投影到 XML。

被接受的修复只补上这个投影,不做任何其他事情。它让 wire response 说实话,却不触碰用户数据、checksum 数学、存储布局或算法范围。Fork workflow、刷新 head 评审、合并、自动关闭 issue 与合并后 main 验证都已完成。剩余的是交付纪律:必须把这个已合并修复与已 tag、已打包、已构建镜像、已部署和已在生产验证严格区分。

8 - 总量未知时,进度条应该说什么

文件夹流式 ZIP 下载显示 NaN% 的修复 PRD:不改变服务端 API 与普通文件下载,用诚实的不确定进度替代非法百分比。

状态:已在本地实现并验证;提交、Console 发布与 Silo 依赖更新待办 · 优先级:P1 · 归属pgsty/silo-console · 关联问题pgsty/silo#62 · PRD 复核:Claude Fable 5(xhigh)— APPROVE · 实现复核:Claude Fable 5(xhigh),2026-08-23 — APPROVE,无 P0/P1/P2 发现

SILO Console 下载文件夹时,Downloads / Uploads 面板会显示 NaN%。ZIP 通常仍在正常传输,存储对象也完好无损,但进度条已经从“总量未知”错误地跨进了一个非法的确定进度状态。用户看到一条近乎满格的进度条,以为下载失败或已经完成,于是重复点击。

建议的修复刻意保持狭窄:

只有当下载拥有一个有限、正数、并且适用于当前响应字节的总量时,才能进入 determinate 状态;否则必须保持 indeterminate,直到完成、失败或取消。

服务端继续流式生成 ZIP,普通文件继续显示百分比。前端只增加一道安全计算边界,复用已经存在的 indeterminate 渲染,再补齐一条缺失的取消状态转换。本文说明为什么这套方案既充分,又是最小且诚实的修复。

已观察到的故障

这个缺陷存在于当前 silo-console v2.1.1,Silo RELEASE.2026-08-06T00-00-00Z 内嵌的正是这一版本。

复现步骤:

  1. 在某个 prefix 下放入若干对象,例如 folder/
  2. 停留在父目录,选择 folder/ 并点击 Download
  3. 在传输完成前打开 Downloads / Uploads
  4. 任务行显示 NaN%,而 ZIP 请求仍在继续。

运行时验证使用了一个约 88.7 MiB 的 prefix,并对 Chromium 限速以保留观察窗口。两次独立下载都进入了相同的 NaN% 状态。

这是前端正确性问题,不代表对象损坏、磁盘格式变化或 S3 GET 失败。

实际发生了什么

可见的 NaN% 是三层契约错位的最终结果。

Prefix 没有对象大小

S3 的文件夹是 common prefix,不是实际存储的目录对象。在列表模型里,prefix 以 / 结尾并携带 size=0。Console 已经把这种大小显示为 -,正确地表达了“不适用”。

生成的 API 模型为 size 标记了 omitempty,所以逻辑上的零不会出现在列表 JSON 中。单选下载 thunk 却把 object.size 原样传给辅助函数:prefix 与零字节对象在运行时提供的是 undefined(人工构造的 prefix 记录也可能提供 0)。两者都不是有效分母。

流式 ZIP 没有事先可知的网络长度

服务端通过末尾的 / 识别文件夹,递归列出对象,再把 zip.Writer 接到 io.Pipe 上。对象一边读取、一边 Deflate、一边复制进 HTTP 响应,档案生成多少就发送多少。

这是一项有价值的行为:服务端不用把完整 ZIP 全部放进内存或临时磁盘,就能尽早发出首字节。它也带来一个同样刻意的结果:发送响应头时,最终压缩字节数尚不存在,因此响应只有 Content-Type: application/zip 和文件名,没有 Content-Length

源对象大小之和不能替代这个总量。对象大小是压缩前字节;ProgressEvent.loaded 统计的是 ZIP 压缩与封装后的响应字节。它们不是同一个单位。

收到 progress 事件,不代表百分比可计算

客户端当前对每个事件都执行:

Math.round((event.loaded / fileSize) * 100)

Prefix 的分母为零或缺失。根据实际值与事件,JavaScript 会产生 NaNloaded / undefined0 / 0)或 Infinity(正数字节除以零)。

progress callback 随后把非有限值写入 Redux,同时设置 waitingForFile=false。第二个操作才是决定性的状态错误:任务仅仅因为“来了一个事件”就离开了现有 indeterminate 分支,而不是因为事件真的提供了可用总量。确定进度组件拿到非法值,最终渲染出非法标签。

完整链路如下:

common prefix: size = 0
        |
        v
download(..., fileSize = 0)
        |
        v
流式 Deflate ZIP,没有 Content-Length
        |
        v
event.loaded / 0 => NaN 或 Infinity
        |
        v
非法百分比进入 Redux;waitingForFile 变成 false
        |
        v
determinate ProgressBar 渲染 NaN%

普通非空文件之所以不出问题,是因为服务端可以 stat 对象、设置 Content-Length,列表中的大小也为正数。如果浏览器为空响应触发 progress 事件,零字节文件虽然是真对象,却会抵达与 prefix 相同的算术边界,因此必须纳入回归契约。

产品契约

UI 只需要诚实地区分两种情况:

  • Determinate:已传输字节与总字节都已知,而且单位相同。
  • Indeterminate:请求正在进行,但总量未知。

由此得到四条承重不变量:

determinate  => total 有限且 total > 0
determinate  => percentage 有限且 0 <= percentage <= 100
unknown total => indeterminate
terminal state => 非 indeterminate

这些不变量比 objectPath.endsWith("/") 更一般:无需发明对象类型特例,就能同时覆盖 prefix、零字节文件、异常元数据和未来任何未知长度响应。

目标与非目标

目标

  1. 文件夹下载不再显示 NaN%Infinity% 或伪造的确定百分比。
  2. 总长度未知的传输使用现有 indeterminate 动画。
  3. 总长度已知的普通文件保留当前百分比体验。
  4. 完成、失败与取消都必须离开 indeterminate。
  5. 零字节文件不得产生非有限百分比,并且仍能成功完成。
  6. 非有限或越界下载百分比不得进入 Redux。
  7. 修复可以先在 Console 独立发布,再由 Silo 更新依赖。

非目标

  • 不在服务端预生成或缓存完整 ZIP。
  • 不把文件夹内对象的未压缩大小之和冒充网络传输总量。
  • 不重构整个 Object Manager 状态模型。
  • 不把文件夹切换到当前“点击即完成”的 BrowserDownload 路径。
  • 不在这里解决 XMLHttpRequest.responseType="blob" 的浏览器内存占用。
  • 不改变取消记录是否保留到用户手动清理的现有产品行为。
  • 不重新设计 HTTP 响应头发出之后,流式 ZIP 中途失败的错误表达。
  • 不改变 S3 API、Console API、对象布局或 ZIP 内容。

这些都是合理的后续工作,但把它们绑进当前缺陷会扩大风险,却不是恢复诚实进度所必需的。

最终决策

最小生产修复由四部分组成。

D1. 只使用有效总量计算

增加一个不依赖 DOM 和 Redux 副作用的小型纯函数:

type DownloadProgressEvent = Pick<
  ProgressEvent,
  "loaded" | "lengthComputable" | "total"
>;

export const calculateDownloadPercent = (
  event: DownloadProgressEvent,
  objectSize: number,
): number | null => {
  let total: number | null = null;

  if (Number.isFinite(objectSize) && objectSize > 0) {
    total = objectSize;
  } else if (
    event.lengthComputable &&
    Number.isFinite(event.total) &&
    event.total > 0
  ) {
    total = event.total;
  }

  if (
    total === null ||
    !Number.isFinite(event.loaded) ||
    event.loaded < 0
  ) {
    return null;
  }

  return Math.min(
    100,
    Math.max(0, Math.round((event.loaded / total) * 100)),
  );
};

总量来源的优先级用于保持兼容:

  1. 有限且为正的 objectSize 保留普通文件当前算法。
  2. 当对象大小不可用,但浏览器声明响应长度可计算,且 event.total 有限为正时,使用响应总量。
  3. 其余情况返回 null:此时还不存在诚实的百分比。

辅助函数的输出契约是闭合的:要么是 null,要么是 [0,100] 内的有限数。

D2. 未知总量保持 indeterminate

XHR handler 只 dispatch 真实百分比:

req.addEventListener("progress", (event) => {
  const percent = calculateDownloadPercent(event, fileSize);

  if (percent !== null) {
    progressCallback(percent);
  }

  // 没有有效总量:保留 waitingForFile=true,让现有 UI 继续保持
  // indeterminate,而不是制造一个 determinate 数字。
});

下载任务本来就以 waitingForFile=true 创建,ObjectHandled 也已经把这个状态渲染成 variant="indeterminate"。没有必要把 Redux 扩成 number | null,也不用再加一个布尔值或修改 MDS。

首次获得有效百分比时,现有 updateProgress 会写入数值并设置 waitingForFile=false。如果整个请求始终没有有效总量,任务就保持 indeterminate,直到终态 action 到来。

D3. 让取消成为真正的终态

完成和失败路径已经会清除 waitingForFile,取消路径没有。需要在 cancelObjectInList 中补上:

item.waitingForFile = false;

没有这一行,修复后的 prefix 下载会在 abort 后继续进入 indeterminate 渲染分支,遮住 Cancelled 状态。任务行继续遵循现有产品行为:保留一条已取消记录,由用户手动移除。本次不要求自动清理。

XHR 边界还需要一条事件顺序守卫。abort() 会先触发 readystatechange(DONE, status=0),随后才触发 abort 事件;如果不提前返回,通用 DONE 分支会先把请求标成失败,onabort 再把它标成取消。DONE/status zero 因此交给专用的 onerroronabort handler 处理,onabort 同时删除已存储的请求引用。

D4. 还原被省略的零字节大小

单选下载 thunk 改为传递 object.size || 0,与另一个下载入口保持一致。这样会在 Blob.size === fileSize 完成校验之前,还原 API 模型省略的逻辑零,使 HTTP 200 的零字节对象以 100% 完成,而不是被误报为 incomplete。

D5. 服务端流式行为保持不变

文件夹 handler 继续通过 io.Pipe 生成 Deflate ZIP,并且不设置 Content-Length。API、档案、存储和资源管理契约均不变化。

状态机

状态 waitingForFile percentage 终态标志 表现
排队 / 尚无有效进度 true 0 indeterminate
未知总量传输中 true 0 indeterminate
已知总量传输中 false 0..100 确定百分比
完成 false 100 done=true 成功
失败 false 最后有效值 failed=true, done=true 错误
取消 false 0 cancelled=true, done=true 已取消

状态不从 determinate 回退到 indeterminate。如果取得过有效百分比,之后某个事件又没有有效总量,handler 保留最后一个有效值即可。

现有 reducer 会在 Failed 与 Cancelled 时同时设置 done=trueObjectHandled 依据 done 把关闭按钮从“中止请求”切换为“移除记录”;本次保持这一行为。取消后的 Redux 数值仍为 0,但现有 ProgressBarWrapper 会因为 ready=true 渲染一条满格橙色终态进度条并显示 Cancelled 标签;这种既有表现不属于本次修复范围。

waitingForFile 并不是“没有可计算进度”的理想长期命名。重命名它,或用 discriminated union 替代当前多个布尔值,都能改善模型,但那属于独立重构。本次所需的状态和渲染已经存在,复用它的兼容风险最低。

为什么这套方案充分

可以按情况验证修复的闭合性。

普通非空文件

objectSize > 0,辅助函数继续使用当前分母。结果有限且经过边界限制,updateProgress 进入 determinate,完成时仍为 100%。

当前流式文件夹

objectSize 被归一化为 0,同时 lengthComputable=falseevent.total=0。辅助函数返回 null;没有非法 action 被 dispatch,因此任务保持 indeterminate。完成时现有 reducer 设置 waitingForFile=falsepercentage=100done=true

未来提供真实长度的响应

如果代理或未来服务端实现提供了可信响应总量,lengthComputable=trueevent.total>0。同一份代码会自动给出真实百分比,不需要再次修改产品逻辑。

零字节文件

列表中被省略的大小先还原为零,此后两个总量都为零,中间百分比在数学上未定义。任务在通常极短的生命周期里保持 indeterminate;零字节 Blob 与归一化后的预期大小相等,成功响应随即切换到 100%。整个过程不会计算 0/0

失败与取消

失败路径本来就会离开 indeterminate;新增的取消转换让 abort 也同样进入终态。终态任务不会仅仅因为总量未知而继续表现得像正在运行。

从数学上说,只有当 total 属于 (0, +infinity) 才会执行除法,结果随后被限制到 [0,100]。因此 NaNInfinity 都不可能穿过计算边界进入 Redux 或确定进度组件。

被否决的替代方案

缓存 ZIP 以获得 Content-Length

服务端可以先在内存或临时文件中生成完整档案,测量以后再发送。这样能得到精确网络总量,但代价是内存或磁盘压力、首字节延迟、清理复杂度与更差的并发下载表现。一个可观测性缺陷不足以成为放弃流式行为的理由。

对 prefix 下对象大小求和

这个和是未压缩逻辑数据;event.loaded 是压缩响应加 ZIP 封装后的字节。单位不同,进度条可能停在 100% 以下、提前超过 100%,或随着压缩率而不是传输完成度移动。否决。

把非法进度变成 0%

这只会隐藏字符串,却会撒另一个谎:determinate 0% 表示总量已知,只是还没有传输。用户仍然会把它理解为下载卡死。未知就应该保持未知。

只特判以 / 结尾的路径

它能修报告中的 prefix,却会漏掉真实零字节对象、非法元数据与其他未知长度响应。正确边界是 denominator 是否可用,而不是对象类型。

把文件夹交给 BrowserDownload

当前大文件路径创建 <a> 并在点击后立刻调用完成回调。它无法报告真实完成、Console 内取消或后续 HTTP 失败。它可以成为未来流式下载设计的基础,但今天使用它只会用另一个谎替换当前的谎。

在 ProgressBar 内部吞掉非法值

通用组件守卫可以作为第二道防线,但它会把非法数据留在 Redux,并向所有其他消费者隐藏错误状态转换。主要修复应该位于“进度成为应用状态”的边界。

现在引入 percentage: number | null

如果要重新设计 Object Manager,discriminated progress state 会比当前布尔值组合更干净。但在保留 waitingForFiledonefailedcancelled 的同时再加入 null,只会制造更多矛盾组合。彻底移除旧字段又超过当前缺陷所需范围。现在复用已经能渲染的 indeterminate,状态重构另立任务。

需求与验收

功能需求

  • FR1: 总量未知时,任务保持 indeterminate。
  • FR2: 对象大小有限为正时,普通文件保留确定百分比。
  • FR3: 只有 lengthComputable=true 时,有限为正的 event.total 才能作为回退。
  • FR4: 所有 dispatch 的百分比都必须有限且位于 [0,100]
  • FR5: 零字节文件不显示非有限进度,并且最终成功。
  • FR6: 完成、失败与取消都必须离开 indeterminate。
  • FR7: 版本化对象、匿名下载、预览与长文件名入口保持现有调用契约。

非功能需求

  • 不增加服务端 CPU、内存、磁盘缓存或请求成本。
  • 不增加前端依赖或构建步骤。
  • 不改变 S3 API、Console API、ZIP 内容或存储对象。
  • 计算函数必须能在没有 DOM 与真实 store 的环境中测试。
  • TypeScript typecheck 与生产前端构建必须通过。

验收标准

  1. 没有 Content-Length 的文件夹 ZIP 传输期间,任务行显示 indeterminate 动画且没有百分比文本。
  2. 成功完成后,任务显示成功/100%,ZIP 可以正常打开。
  3. 普通非空文件继续显示有限的确定进度,并以 100% 完成。
  4. 零字节文件不显示 NaN%Infinity%,并且成功完成。
  5. 取消未知总量下载会 abort 请求并显示 Cancelled,而不是继续播放活动动画。
  6. 任何下载路径都不能把非有限或越界百分比放进 Redux。

测试计划

纯计算矩阵

使用现有 @playwright/test runner 测试纯模块,不增加测试框架。这需要在 web-app/playwright.config.ts 中新增一个无依赖的 unit project,例如使用 testMatch: /.*\.unit\.ts/。现有 chromium project 依赖针对 localhost:9090 真实实例的登录 setup,纯计算与 reducer 测试不应被该环境门控。此为纯配置变更,不引入新依赖。

场景 loaded objectSize lengthComputable event.total 期望
普通文件一半 50 100 false 0 50
Common prefix 1024 0 false 0 null
初始零除零 0 0 false 0 null
响应总量回退 50 0 true 200 25
零总量不可用 0 0 true 0 null
loaded 超过总量 150 100 true 100 100
非法对象大小 10 NaN false 0 null
被省略的零大小 10 undefined false 0 null
非法响应总量 10 0 true Infinity null
负 loaded -1 100 true 100 null

状态测试

直接覆盖状态转换契约:

  1. 新下载以 waitingForFile=true 开始。
  2. 没有有效 progress action 时保持 indeterminate。
  3. 有效 progress 产生有限值并设置 waitingForFile=false
  4. complete 产生 done=truewaitingForFile=falsepercentage=100
  5. failure 产生 failed=truedone=truewaitingForFile=false
  6. cancel 产生 cancelled=truedone=truewaitingForFile=falsepercentage=0

浏览器回归

使用真实 Console 测试实例与 Chromium:

  1. 创建临时桶,在 folder/ 下放入多个对象。
  2. 从父目录选择 prefix 并开始下载。
  3. 使用 CDP 限制下载速度,保证中间状态可观察。 限速用例需用 test.setTimeout 放宽默认 30 秒超时。
  4. 打开 Downloads / Uploads,确认任务存在、没有百分比标签,也不存在 NaN%Infinity%
  5. 取消下载并验证 Cancelled 终态。
  6. finally 中恢复网络条件。
  7. 不限速再次下载,等待浏览器下载事件并验证 ZIP。
  8. 对普通非空文件与零字节文件重复相应断言。
  9. teardown 删除桶、对象、下载与临时文件。

当前 Playwright 项目只启用了 Chromium,因此 CDP 是可接受的测试机制。如果以后启用 Firefox 或 WebKit,纯函数和状态测试保持跨浏览器,只让限速观察测试受 Chromium project 门控。

实现边界

预计 Console 变更:

  1. 新增 downloadProgress.ts,承载纯计算逻辑。
  2. 修改 Objects/utils.ts:只 dispatch 非 null 百分比,把 status-zero 终态交给专用 handler,并清理已取消请求。
  3. 在单选下载 thunk 中还原被省略的零大小。
  4. 修改 cancelObjectInList,清除 waitingForFile
  5. 使用现有依赖补充计算、状态与浏览器回归,并在 playwright.config.ts 中新增无依赖的 unit project。

预计保持不变:

  • Go 文件夹下载 handler 与流式 ZIP。
  • ObjectHandledProgressBarWrapper 与 MDS。
  • IFileItem.percentage: number 及现有 thunk callback 类型。
  • S3 与 Console API 路径。
  • 存储对象与档案格式。

交付与回滚

修复归属于 pgsty/silo-console,而不是当前收到报告的 Silo 服务端仓库。

交付顺序:

  1. 把 #62 转移或交叉关联到 pgsty/silo-console
  2. 实现边界明确的 Console 修改。
  3. 通过 typecheck、生产构建、纯函数/状态测试与真实浏览器回归。
  4. 发布新的 Console 版本。
  5. 更新 Silo 固定的 Console pseudo-version 或发布依赖。
  6. 构建 Silo 候选版本,重复文件夹、普通文件、零字节、取消与 ZIP 完整性验证。
  7. 发布 Silo,并在 Issue 中记录受影响与已修复版本。

没有数据迁移。如果前端修改出现回归,Silo 只需回退 Console 依赖;服务端数据与 API 行为保持兼容。

完成定义

  • 计算函数只返回 null 或有限的 [0,100] 数字。
  • 活跃的未知总量文件夹下载渲染 indeterminate。
  • 普通文件保留确定进度。
  • 零字节文件不渲染非法进度。
  • 完成、失败与取消任务都离开 indeterminate。
  • 流式 ZIP 与服务端响应契约保持不变。
  • typecheck、生产构建与自动化回归已在本地通过。
  • Console 发布完成。
  • Silo 更新 Console 依赖并通过候选版本验证。

后续工作

四项相邻改进应该分别建立设计档案:

  1. 把大文件夹直接流式写入浏览器或文件系统,避免在内存中持有完整 Blob。
  2. 用 discriminated progress/terminal state 替代 Object Manager 的布尔值组合。
  3. 改进响应头已经发出后,ZIP 失败的端到端完整性与错误表达。
  4. 为共享进度组件增加通用非有限值守卫,作为第二道防线。
  5. 修复既有的 Blob JSON 错误解码与 HTTP 失败路径请求引用清理问题。

它们都不是停止当前 UI 撒谎所必需的。下一阶段维护迭代应先恢复最小而诚实的契约:已知总量才显示百分比,未知总量就保持未知。

9 - ListObjects 快捷路径不能把不存在的桶伪装成空桶

本文是 SILO #32PR #37 的问题分析、设计讨论与修复决策归档。

截至 2026-08-26 的状态: PR #37 已更新为带 DCO sign-off 的 head e9c5340be,通过正式批准并合并为 49c8aeac4#32 随后自动关闭。精确 PR head 的 DCO、VulnCheck 与六项 Go CI 全部通过,合并后 main 的 VulnCheck 与六项 Go CI 也全部通过。尚未验证任何 tag、软件包、容器镜像、部署或生产端点已经包含本修复。
范围: 只为三条绕过存储的列表快捷路径补上桶存在性检查;不恢复通用 checkBucketExist,不改变正常列表路径,也不增加存在性缓存。
发布边界: 本地提交、push、远端 CI、merge、tag、软件包、容器镜像、部署与生产验证是相互独立的门槛。

太长不看(TL;DR)

这个问题是真的,而且值得修。对不存在的桶执行 ListObjectsListObjectsV2ListObjectVersions 时,普通请求会在扫描存储时得到 BucketNotFound;但以下三种输入会提前结束:

  • marker 不属于 prefix;
  • max-keys=0
  • prefix 以 / 开头,包括 #32 中 boto3 使用的 Prefix="/"

这些分支直接返回 io.EOF,上层将它解释为“列表正常结束”,于是客户端收到空的 200,而不是 S3 的 404 NoSuchBucket。同一个不存在的资源,仅仅因为过滤参数不同就从错误变成成功,这既破坏 S3 兼容性,也阻塞了从回归前版本升级的真实用户。

修复不应把昂贵的桶检查放回每一次列表请求。选定方案只把三个裸 io.EOF 改为调用一个小 helper:helper 调用一次 GetBucketInfo;桶不存在或集群无法确认时返回真实错误,桶存在时仍返回 io.EOF。因此正常列表热路径完全不变,额外的 peer/disk 扇出只由原本会在访问存储前结束的请求承担。

这项决策现已执行完成:补强修复通过本地评审,精确 PR head 通过全部远端检查,并通过 expected-head guard 合入绿色 main

这是什么问题

同一个 API 出现两套桶存在性语义

#32 给出的最小复现是在不存在的桶上调用:

s3.list_objects(Bucket="missing-bucket", Prefix="/")

AWS S3 抛出 NoSuchBucket,SILO 却返回一个成功的空列表。差异不在认证、路由或 XML 编码,而在对象层 listPath 的控制流:

普通 prefix
  -> 进入 listMerged
  -> 访问存储
  -> 不存在的 volume/bucket 变成 BucketNotFound
  -> HTTP 404 NoSuchBucket

快捷输入
  -> listPath 提前返回 io.EOF
  -> 完全没有访问存储
  -> 上层把 EOF 当成正常结束
  -> HTTP 200 + 空列表

触发提前返回的不是只有 / prefix:

快捷条件 为什么结果必为空 修复前的缺陷
marker 不以 prefix 开头 当前实现不扫描这个不相交区间 未确认桶是否存在就返回 EOF
max-keys=0 调用者明确要求返回零个 key 把“零结果”错误地等同于“资源有效”
prefix 以 / 开头 SILO 的扁平 key 空间不会生成这种列表项 过滤条件在桶身份之前短路

对存在的桶,这三个分支返回空列表是合理优化;对不存在的桶,同一个 EOF 会掩盖应该优先返回的资源错误。

这是一个有明确起点的回归

报告者确认 RELEASE.2024-01-29T03-56-32Z 行为正确,从 RELEASE.2024-01-31T20-20-33Z 开始出现回归。对应上游变更是 minio/minio#18917 / 80ca12008:它从通用参数检查中删除了 GetBucketInfo,让实际 Put、List 与 Multipart 存储操作自行暴露不存在的桶。

这个优化对正常路径成立,但留下一个边角:提前返回的路径根本不会到达能够暴露错误的存储操作。#32 不是要求全面撤销上游优化,而是补上优化后遗漏的控制流分支。

为什么要修复

S3 契约明确要求 NoSuchBucket

AWS ListObjectsListObjectsV2 都把 NoSuchBucket 定义为 404:指定桶不存在。prefix、marker、start-aftermax-keys 是结果选择条件,不应让不存在的 bucket identity 变成一次成功请求。

ListObjectVersions 共享同一个对象层列表引擎。让三个公开列表 API 在同样的 shortcut 输入上遵守同一桶存在性语义,可以避免 V1、V2 与版本列表继续分叉。

错误的空列表会改变调用者决策

空 200 与 404 不是可互换的展示细节:

  • 404 告诉 provisioning 或测试代码先创建桶、修正配置或终止流程;
  • 空 200 声称桶存在,只是暂时没有匹配对象;
  • SDK、同步工具和集成测试会沿两条不同的控制流继续执行;
  • 使用 SILO 模拟 S3 的测试可能在本地通过,却在 AWS 上失败。

#32 还给出了直接升级影响:依赖旧有正确行为的应用无法升级到回归后的版本。修复因此同时恢复 S3 parity 和版本升级兼容性。

修复面很窄,也容易建立强回归契约

问题集中在三个相邻的 early return,不涉及对象数据、元数据格式、排序、分页 token 编码、权限或 wire schema。可以用很少的生产代码修复,并在对象层与 HTTP 层精确锁定行为,收益明显高于实现风险。

为什么不能简单恢复全局检查

上游删除通用 GetBucketInfo 不是随意清理。#18917 的动机 明确指出:Put、List 与 Multipart 每次先检查桶会在所有 server 间扇出;即使做过向量化,超过 100 个节点后成本仍明显可见。

在 SILO 当前实现中,erasureServerPools.GetBucketInfo 会调用 S3PeerSys.GetBucketInfo:请求并发发往所有 peer,再按 pool 聚合 quorum。每个 peer 还要检查本地 bucket 状态。它不是一次廉价的内存 map 查询。

因此存在两个都不应接受的极端:

  • 完全不检查: 保留错误的空 200;
  • 每次 List 都先检查: 恢复正确语义,却撤销大型集群上的关键优化。

真正的设计问题是:能否只给“不会访问存储、因此无法自然发现缺桶”的分支补检查。答案是可以。

怎么修复

只替换三个裸 EOF

cmd/metacache-server-pool.go 中,三个 shortcut 原来都执行:

return entries, io.EOF

改为:

return entries, z.listPathShortcutEOF(ctx, o.Bucket)

helper 的契约只有两类结果:

func (z *erasureServerPools) listPathShortcutEOF(ctx context.Context, bucket string) error {
    if _, err := z.GetBucketInfo(ctx, bucket, BucketOptions{}); err != nil {
        return err
    }
    return io.EOF
}
  • 桶存在:保留原有空列表行为;
  • 桶不存在:把 BucketNotFound 交给既有错误映射,HTTP 返回 404 NoSuchBucket
  • 集群无法可靠确认:传播 quorum、offline、timeout 或 context 错误,不再伪造成功。

正常的 listMerged、metacache 扫描、排序、分页和响应生成全部不变。

为什么 helper 放在这里

检查必须紧贴 shortcut,原因有三点:

  1. 只有这一层知道自己即将绕过全部存储访问;
  2. 上移到通用参数校验会让所有调用付费;
  3. 下移到扫描层对这些分支无效,因为它们永远不会进入扫描。

helper 名称也刻意表达边界:它不是新的通用 checkBucketExist,而是“在 shortcut 返回 EOF 前补齐缺失的存在性语义”。

不引入缓存

用 bucket-existence cache 可以降低扇出,但会立即引入创建、删除、site replication、恢复与过期策略的一致性问题。为了三个低频 shortcut 增加一套新的事实源,复杂度与失效风险都高于收益。

当前选择使用已有 GetBucketInfo 作为事实源。如果未来遥测证明大型集群频繁收到 max-keys=0 或 slash-prefix 探测,再基于数据考虑专用元数据快路、限流或安全缓存,而不是在这个兼容修复中预先设计。

测试与评审证据

对象层契约

对象层测试在单盘与多盘 erasure setup 上,对以下四类输入逐一调用:

  • slash-prefixed prefix;
  • zero limit;
  • marker outside prefix;
  • regular prefix,作为仍由存储自然报错的控制组。

每组都覆盖 ListObjectsListObjectsV2ListObjectVersions,并使用类型化的 isErrBucketNotFound 判断,而不是比较易碎的英文错误文本。

HTTP 契约

Handler 测试使用真实签名请求验证三个公开 API:

API 请求形态 断言
ListObjects GET /missing-bucket?prefix=/ HTTP 404,XML code 为 NoSuchBucket
ListObjectsV2 list-type=2 HTTP 404,XML code 为 NoSuchBucket
ListObjectVersions versions HTTP 404,XML code 为 NoSuchBucket

HTTP 测试选择 #32 的真实 slash-prefix 复现;另外两个 shortcut 已在对象层穷举。这样既证明最终 wire behavior,又避免把同一矩阵在较慢的 handler fixture 中重复三遍。

本地质量门槛

本地改进提交完成了以下验证:

go test ./cmd -count=1
新对象层与 HTTP 回归测试(10 个子场景)
定向 go test -race
相关既有列表测试
CGO_ENABLED=0 go build ./...
go vet ./...
CI 范围 gofmt 与 git diff --check
提交后的定向回归复验

本地完整 cmd 测试用时 116.215 秒。独立本机 Claude Code 使用 Fable 模型与 Max effort 审查了精确代码树、调用路径、错误映射、测试、性能边界和本轮决策,给出 GO,没有 mandatory pre-merge change。

带 DCO sign-off 的 PR head e9c5340be 随后通过八项远端检查:DCOVulnCheckGo CI 六个 job。合并后生成的 main@49c8aeac4 又独立通过 VulnCheckGo CI 六个 job。最慢的 PR cross-compile 用时 9 分 47 秒,合并后 cross-compile 用时 9 分 30 秒。

会不会引入新问题

Shortcut 现在会产生集群扇出

这是本修复最重要、也是刻意接受的代价。存在桶上的三类请求过去约等于一次本地分支判断,现在需要 GetBucketInfo。本机方向性 microbenchmark 得到:

路径 观察到的量级
修复前 shortcut 约 0.55 μs,7 allocs
修复后单盘 shortcut 约 7.8–8.1 μs,45–47 allocs
修复后 32 盘 shortcut 约 70–81 μs,977 allocs
32 盘正常列表 约 0.95 ms

这些数字只说明本地相对关系,不是 100+ 节点生产延迟预测。真实分布式环境还包含 peer 网络、quorum 与最慢节点尾延迟,可能比本机差得多。也正因为如此,检查绝不能扩展到正常列表路径。

风险集中在异常或探测式流量:如果某个错误配置的客户端高频轮询 max-keys=0、slash prefix 或不相交 marker,它会把原本廉价的请求放大成 peer/disk 工作。合并后值得从 S3 trace 或 metrics 观察这些输入的实际频率;有证据时再限流或优化。

降级集群会暴露更多真实错误

过去 shortcut 在 peer 离线或 bucket quorum 不足时也可能返回空 200,因为它根本不接触集群状态。修复后,这些请求可能返回 quorum、timeout 或 service error。

这属于更诚实的行为,不是可用性回归:服务器无法确认桶存在时,不应声称它是一个有效的空桶。但依赖“无论集群状态如何都空成功”的客户端会观察到变化。

创建与删除并发仍不具备线性化快照

GetBucketInfo 与返回空列表是两个动作。桶可能在检查后立即删除,或在缺桶结果形成后立即创建。本补丁没有也不应该为列表 shortcut 引入跨 bucket lifecycle 的事务。

这与其他先验证资源、再执行操作的 API 属于同一并发类别。修复保证请求不会在没有任何存在性证据时直接成功,不承诺一个跨节点、跨生命周期的线性化空列表快照。

依赖旧错误行为的客户端会看到 404

有客户端可能已经把缺桶的空 200 当作事实使用。修复会让它们进入错误分支。这是可见兼容变化,但它恢复的是 S3 文档契约与回归前行为;保留 bug 只会把迁移成本留给正确依赖 404 的用户。

两个相邻边缘仍不在本轮范围

对抗评审记录了两个非阻塞的 P3 边界:

  1. 恢复 metacache continuation 时,c.fileNotFound 分支仍直接返回 io.EOF。一个陈旧或构造的 continuation token 遇上已经删除的桶,理论上仍可能得到空 200。把 GetBucketInfo 放到这里会影响正常分页 continuation,性能与错误语义需要单独设计。
  2. V1 与版本列表的某些 marker/prefix 组合会在 HTTP handler 参数校验中先返回 NotImplemented,尚未到对象层;V2 的 start-after 能进入对象层。本补丁修复的是存储 shortcut 掩盖缺桶,不重新定义畸形参数与资源错误的优先级。

它们都不是合并 blocker:第一项不在 #32 的普通初始列表复现中,第二项是既有 handler 行为。文档保留它们,是为了避免把“已覆盖三个 shortcut”误写成“所有可能的参数组合都已实现逐字节 AWS parity”。

讨论过的替代方案

保持上游现状

优点是零性能变化并减少与上游差异。缺点是继续违反 S3 契约、保留有版本边界的回归,并让 SILO 作为集成测试替身时给出错误信号。对一个范围清楚、测试充分的兼容修复,这个取舍不再合理。

恢复通用 checkBucketExist

它能一次覆盖所有路径,却把 peer fan-out 加回每个 Put、List 与 Multipart 操作,直接撤销 #18917 的大型集群优化。收益与成本不成比例,应明确拒绝。

只修 Prefix="/"

这会通过 issue 的单个复现,却留下 max-keys=0 与 marker-outside-prefix 两个同根缺陷。三个分支相邻、语义相同,用同一个 helper 收敛更简单,也更不容易再次遗漏。

增加 bucket-existence cache

它可以让 shortcut 便宜,但需要定义创建、删除、复制、故障恢复和 TTL 期间的陈旧语义。当前没有遥测证明这些 shortcut 的流量足以支撑这种复杂度,因此不采用。

复杂度与成本收益

维度 评价 说明
生产代码复杂度 三处调用点与一个 7 行 helper;无新状态、依赖或格式
测试复杂度 低到中 要同时覆盖 V1、V2、versions、三类 shortcut、控制组与 HTTP 映射
正常路径风险 很低 listMerged 热路径没有新增检查
Shortcut 运行时成本 明显上升 从本地 EOF 变成 cluster-wide GetBucketInfo
兼容收益 恢复 404 NoSuchBucket、回归前行为和 S3 测试保真度
运维复杂度 无迁移、配置、feature flag、缓存或跨仓库依赖

成本收益比总体良好,关键原因不是 GetBucketInfo 很便宜——它并不便宜——而是额外成本被严格限制在原本无法自然发现缺桶的三条 shortcut。用窄幅性能成本换取明确的协议正确性,比全局回退或长期保留错误行为都更合理。

接受决策与后续门槛

最终决策是:接受并合并补强后的 PR #37,不继续扩大生产修改范围。

实际执行顺序是:

  1. 用基于当前 main、带 DCO sign-off 的版本替换陈旧 fork head,同时保留 Jason Lin 的 co-author 归属;
  2. 保留类型化错误判断、V1/V2/版本列表对象层覆盖与 HTTP 级 404 / NoSuchBucket 断言;
  3. 更新 PR 描述,明确 shortcut 扇出成本与正常路径不变的边界;
  4. 批准 fork workflow,并要求精确 head e9c5340be 的八项检查全部通过;
  5. 针对该 head 提交正式批准评审;
  6. 使用 expected-head guard 合并为 49c8aeac4,让 #32 自动关闭,再独立要求生成的 main Go CI 与 VulnCheck 全绿。

本轮不需要新增缓存、feature flag、更多抽象或修改 continuation-token 语义。高频 shortcut 流量与大型集群尾延迟仍是后续可观测项,不是继续凭假设扩代码的理由。

仓库集成已经完成。只有 tag、软件包、docker.io/pgsty/minio 镜像、部署与真实 S3 客户端验证分别完成后,才能宣称用户已经获得修复。

结论

问题的本质不是“prefix 为 / 时少报了一个错误”,而是列表引擎把 io.EOF 同时当成了两件不同的事:存在桶的空结果,以及从未确认桶存在的提前结束。上游为大型集群移除通用存在性检查是合理优化,但 shortcut 绕过了“让真实存储操作自然报错”的前提。

选定修复恢复这条前提,只在三个绕过存储的出口调用已有 GetBucketInfo。它会让这些请求变贵,也会在降级集群上暴露真实错误;这两点都是明确成本。作为交换,SILO 恢复 S3 404 语义、升级兼容性与测试保真度,同时完整保留正常列表热路径的上游优化。

这个值得接受、范围受控的兼容修复现已合入绿色 main;release delivery 仍是独立门槛。

10 - 可选校验和,强制失败:修复 UploadPart 与 UploadPartCopy 兼容性

本文是 SILO #46 的完整设计与实现归档。它记录的并不只是一个 if 条件如何修改,而是一个看似简单的 S3 可选 header,如何一路牵动 multipart 完成语义、复制响应、压缩与加密数据流、兼容基线和发布验证。

状态: 服务端实现与本地验证完成;commit、PR、远端 CI、发布与线上验证待完成。
归属: pgsty/silo 服务端仓库。
跟踪: #46
独立后续: #63 CopyObject + compression checksum#64 federated UploadPartCopy checksum
对抗审查: 本机 Claude Code、Fable 5、--effort max,最终结论 GO,无阻断项。

太长不看(TL;DR)

Multipart upload 会把大文件切成多个 part 再上传。客户端可以给每个 part 附上 checksum,帮助服务器确认传输没有出错,但 AWS 规定这个 checksum 是可选的。SILO 原来却把它当成必填项:普通 UploadPart 没带 checksum 就会失败,而 UploadPartCopy 根本没有 checksum 可以提供,所以一定失败。

修复后,客户端提供 checksum 时,SILO 仍然认真校验;客户端没提供时,SILO 就在读取原始数据的同时自己计算,并把结果保存下来。计算发生在压缩和加密之前,不需要重读文件,也不改变盘上格式。这样既兼容 AWS,也没有放松数据完整性检查。

最终决策

当 multipart upload 在 CreateMultipartUpload 阶段声明 checksum algorithm 后,SILO 采用以下契约:

  1. 客户端若提供逐 part checksum,服务器继续校验它;错误值与错误算法必须失败,绝不能被 fallback 掩盖。
  2. 客户端若省略逐 part checksum,服务器使用 MPU 记录的算法,在压缩与加密之前的逻辑明文流上单遍计算并持久化结果。
  3. 普通 UploadPart 只在客户端提供 checksum 时回显响应 header;服务器自行计算的值不回显。
  4. UploadPartCopy 没有客户端请求体 checksum,服务器必须计算,并在 CopyPartResult 中返回对应值。
  5. ListParts 返回持久化的 part checksum。
  6. FULL_OBJECT completion 继续从各 part checksum 线性合并完整对象 checksum;COMPOSITE completion 继续要求客户端提交每个 part checksum,客户端可从 ListParts 取回。
  7. 计算必须发生在现有数据读取过程中,不得在 completion 阶段重新读取整个对象。

一句话概括:

可选的是客户端提供的校验值,不是服务器维护 checksum-enabled MPU 内部一致性的责任。

我们如何发现问题

问题是在排查另一个 multipart checksum 缺陷 #31 时发现的。

#31 处理的是 CompleteMultipartUpload:当 checksum type 为 FULL_OBJECT 时,客户端可以只提交 part number、ETag 和可选的完整对象 checksum,而不必在 completion XML 中重复保存所有 part checksum。沿着完成路径向前追踪时,我们发现 erasureObjects.PutObjectPart 在写入任何 part 前有一条更早、更强的约束:

if cs := fi.Metadata[hash.MinIOMultipartChecksum]; cs != "" {
    if r.ContentCRCType().String() != cs {
        return InvalidArgument{/* checksum missing */}
    }
}

也就是说,只要 MPU 声明了 checksum algorithm,每个普通 UploadPart 请求都必须携带匹配的 x-amz-checksum-*,否则返回:

400 InvalidArgument:
checksum missing, want "CRC32", got ""

API 级探针在单盘和纠删码后端上都复现了这一行为。

进一步审查 CopyObjectPartHandler 后,问题从“部分客户端不兼容”升级成了 P0:UploadPartCopy 没有可供调用方校验的请求体。处理器从源对象读取字节,构造内部 reader,然后进入同一个 PutObjectPart。客户端没有 header 可以补上,也没有 SDK 配置可以绕开。这使得 checksum-enabled MPU 上的 UploadPartCopy 成为必然失败,而不是偶发失败。

AWS 契约到底是什么

这个问题不能靠“MinIO 一直这么做”来裁决,必须回到 S3 协议。

AWS UploadPart API 把算法特定的 checksum header 描述为 “can be used as a data integrity check”。更关键的是,响应字段明确说明:只有请求提供了 checksum,响应才返回对应 checksum header。

AWS UploadPartCopy API 的规则不同:如果创建 MPU 时声明了算法,复制结果中会出现该 part 的 checksum。复制请求没有 part body,因此这是服务器计算的结果。

AWS ListParts API 则提供恢复进行中 MPU 各 part checksum 的标准接口。

算法与 checksum type 的矩阵也决定了实现不能只考虑一个布尔开关:

Algorithm FULL_OBJECT COMPOSITE
CRC64NVME 支持 不支持
CRC32 / CRC32C 支持 支持
SHA1 / SHA256 不支持 支持

FULL_OBJECT 只适用于可线性合并的 CRC;但 SHA1/SHA256 仍然需要正确的逐 part digest 才能完成 COMPOSITE 上传。

这也解释了为什么 SDK 配置会暴露问题。新版 AWS SDK 默认倾向于为支持 checksum 的请求自动计算值,但用户可以选择 request_checksum_calculation = when_required,也可以直接使用低级 API 而不在每个 part 上重复声明算法。S3 服务端接受这些请求;SILO 当时不接受。

为什么不能只删除强制检查

最诱人的修复是删除上面的比较,让没有 checksum 的 part 继续写入。但这只会把失败推迟到 completion。

SILO 完成 MPU 时不会重新读取并组装全部对象字节。它读取每个 part.N.meta 中的 ObjectPartInfo.Checksums

  • 若该值不存在,立即返回 InvalidPart
  • FULL_OBJECT 使用 Checksum.AddPart 按 part 长度线性合并;
  • COMPOSITE 拼接各 part digest 的原始字节,再对它们计算对象级 checksum。

因此内部不变量是:

checksum-enabled MPU
        => every committed part has a checksum for the MPU algorithm

删除入口检查却不填充 metadata,会让 UploadPart 表面成功、ListParts 缺字段、UploadPartCopy 缺响应、completion 再失败。这比立即失败更难诊断。

我们研究过的方案

方案 优点 致命问题 结论
只删除 strict check 改动最少 part metadata 仍缺 checksum,completion 必然失败 否决
只放宽 FULL_OBJECT 能覆盖部分默认 CRC 客户端 COMPOSITE 与 SHA 仍不兼容,不能关闭 #46 否决
completion 时重读全部 part 不必在上传时保存 digest 增加 O(object size) 二次 I/O,复制响应与 ListParts 仍然错误 否决
普通 UploadPart 总是返回服务器值 federation 容易转发 违反 AWS “仅在请求提供时返回”的响应契约 否决
原样复制 AIStor 实现 有商业产品先例 只 fallback 可合并 CRC,且 hasher 挂载层次存在 transformed-byte 风险 否决
在逻辑明文流上单遍计算并持久化 协议完整,无二次 I/O,覆盖 CRC 与 SHA 需要明确区分明文 checksum reader 与存储 reader 采用

商业版给了什么线索

我们下载并校验了当时最新的 MinIO AIStor RELEASE.2026-08-07T18-34-35Z。没有商业许可证时服务器会进入 offline mode 并拒绝 S3 操作,因此只能基于 Go pclntab 与 ARM64 反汇编做静态分析,不能把结果包装成黑盒兼容性测试。

静态分析显示,AIStor 已经:

  • 在缺少客户端 checksum 时使用服务器 hasher;
  • 把计算结果写入 part metadata;
  • CopyPartResult 中加入 checksum 字段。

但它只为 CanMerge() 算法启用 fallback,也就是 CRC32、CRC32C、CRC64NVME;SHA1/SHA256 COMPOSITE 仍会走 checksum missing。更重要的是,hasher 在对象层附着到当前 r.Reader;在压缩或加密路径中,该 reader 可能已经是变换后的存储流。

AIStor 因此证明了“服务器计算并保存”这个方向,但没有提供一个可以无条件照搬的最终设计。

对抗审查如何推翻第一版设计

第一版计划希望把所有决定集中到 erasureObjects.PutObjectPart:对象层读取 MPU metadata,发现客户端没有 checksum 后,再为 reader 安装服务器 hasher。这样看起来最统一,因为所有内部调用者都会遵守同一规则。

Fable 5 Max 的第一次对抗审查指出,这个方案在压缩路径上是错的。

newS2CompressReader 并不是惰性包装器。构造函数会立即启动 goroutine:

go func() {
    _, err := io.Copy(comp, r)
    // ...
}()

S2 writer 还会并发预读多个 block。处理器创建 compressor 后,才会经过更多选项解析、加密准备和对象层调用。等 PutObjectPart 安装 hasher 时,明文 reader 可能已经被消费了数 MiB:

  • 大 part 得到缺少前缀的 checksum;
  • 小 part 可能在 hasher 安装前已经读完,根本没有结果;
  • ServerSideHasher 的写入与 Read 并发,形成数据竞争。

这个发现改变了责任划分:

Handler 负责在任何 eager transform 启动前安装 hasher;object layer 负责复核算法、确认结果存在并原子持久化。

这是本次设计中最关键的转折。把逻辑集中在更低层并不天然更正确;对于流式系统,何时开始消费字节在哪一层看到哪种字节同样是接口契约。

最终实现

独立的逻辑 checksum reader

PutObjReader 原本有两个概念:

  • Reader:真正交给存储层的流,可能已压缩或加密;
  • rawReader:用于 ETag 等旧逻辑的 reader。

压缩路径中的 rawReader 也不一定直接看到明文,它可能只是通过 etag.Tagger 透传 ETag。因此本次没有重载它,而是新增未导出的:

checksumReader *hash.Reader

该 reader 永远代表 S3 逻辑 part 的明文字节。WithEncryption 可以替换存储 Reader,但不能替换 checksumReader

PutObjReader 同时提供未导出的 accessor:

  • 取得客户端提供或服务器计算的 effective checksum type;
  • 客户端值存在时优先返回客户端值;
  • 否则返回服务器在 EOF 处生成的结果。

保持方法未导出有两个目的:缩小公共 Go API 变化,也为后续 #63 保留统一内部机制,而不提前改变普通 CopyObject 行为。

在 transform 之前准备 hasher

prepareMultipartChecksumReader 读取 MPU 保存的 algorithm 与 checksum type:

  1. 没有声明算法时不做任何事;
  2. 客户端已有 checksum 时比较 base algorithm;
  3. 算法错误时延续 InvalidArgument
  4. 客户端没有 checksum 时,为明文 reader 安装对应 server-side hasher。

普通 UploadPart

  • 压缩路径在 actualReader.AddChecksum 之后、newS2CompressReader 之前准备;
  • 非压缩路径在 request checksum 解析之后、加密 reader 构造之前准备。

UploadPartCopy

  • checksum-enabled MPU 先在源对象的逻辑范围上构造内层 hash.Reader
  • range copy 只覆盖指定字节范围;
  • 内层 reader 准备完成后才进入压缩和目标加密。

对象层仍然是最终权威

Handler 的提前准备不能替代对象层不变量。erasureObjects.PutObjectPart 仍然:

  • 重新解析 MPU 的期望算法;
  • 要求 effective checksum type 存在且匹配;
  • 完成 erasure encode 后取得 checksum map;
  • 如果算法已启用但结果缺失,记录 internal error 并拒绝提交;
  • 把 checksum 与 ETag、size、index 一起写入 part.N.meta,随后原子 rename part。

于是内部调用者若绕过 handler,又没有准备合法 checksum,仍然得到旧的拒绝行为,不会静默写入破坏不变量的 part。

CopyPart 响应

CopyObjectPartResponse 增加了当前代码树支持的五个字段:

ChecksumCRC32
ChecksumCRC32C
ChecksumCRC64NVME
ChecksumSHA1
ChecksumSHA256

字段使用 omitempty,所以没有启用 checksum 的 MPU 保持旧 XML。普通 UploadPart 仍只通过原有 TransferChecksumHeader 回显客户端请求值;服务器 fallback 不改变它的响应。

为什么这个修改能解决问题

修复后数据流变成:

logical plaintext part
        |
        +--> client checksum verifier (if supplied)
        |         or
        +--> server-side hasher (if omitted)
        |
        v
compression (optional)
        |
        v
encryption (optional)
        |
        v
erasure encode / storage
        |
        v
persist ETag + size + logical part checksum atomically

它同时满足四个以前冲突的目标:

  1. 协议兼容: 可选 header 省略后上传成功。
  2. 完整性不降级: 客户端给值时仍做端到端比对;服务器不会用自己的计算结果掩盖错误客户端值。
  3. 对象语义正确: checksum 覆盖逻辑 S3 字节,而不是压缩数据或密文。
  4. 性能可控: checksum 与原有读取同一遍完成,只增加 hash CPU,不增加第二遍磁盘或网络 I/O。

EOF 也有明确作用:hash.Reader 只有在读到 EOF 后才固定 ServerSideChecksumResult。压缩 pipe 的关闭同步了 goroutine 与存储读取;对象层只在 encode 返回后读取结果。定向 -race 测试验证了这个并发边界。

兼容基线 blocker

CopyObjectPartResponse 的五个新字段是导出的 Go API。SILO 的 buildscripts/rebrand-guard 会重新扫描 import、环境变量、header、route、存储 marker 与导出符号,并与 buildscripts/rebrand-guard/compat-baseline.json 做双向精确集合比较。新增符号若没有显式登记,CI 会失败。

我们先登记了 #46 的五个字段,guard 随后仍报告两个新增符号:

internal/config/notify:notify:type:LegacyDatabaseTargetError
internal/config/notify:notify:method:LegacyDatabaseTargetError.Error

它们不是 #46 引入的,而是本地 main 上更早的数据库通知修复 f1ba68358 有意导出的类型:cmd 启动路径需要通过 errors.As 识别它。此前提交没有同步 baseline,因此任何建立在当前 HEAD 上的改动都会在 CI guard 处失败。

最终采用“方案 A”:把两条 notification 符号登记归属到原修复,同时保留 #46 五条字段。最终 baseline diff 恰好是七条新增、零删除,guard 输出:

exported=9021
Silo rebrand compatibility baseline is unchanged

这不是把检查关闭。guard 的精确集合比较意味着多登记一个不存在的符号也不能通过。它只是显式确认两组有意的兼容表面变化。

golangci-lint 尚未在本地执行;它仍是远端 go.yml 的发布前检查之一。go testgo vet、race 与 rebrand guard 的本地通过,不能替代远端 CI 全绿。

验证证据

新增测试实际执行 76 个子测试,覆盖:

  • CRC32、CRC32C、CRC64NVME FULL_OBJECT
  • CRC32、SHA1、SHA256 COMPOSITE
  • 正确客户端 checksum、错误算法、错误值;
  • 服务器计算值不出现在普通 UploadPart 响应;
  • UploadPartCopy 响应和 ListParts 返回服务器值;
  • 真实的 5 MiB + 1 KiB 两 part 合并;
  • 零长度 part、覆盖同一 part number;
  • range copy,只对复制区间计算 SHA256;
  • 单盘与 16 盘纠删码;
  • default、versioned、compressed、encrypted、compressed + encrypted;
  • 显式 SSE-C 与 SSE-S3。

本地验证包括:

go test -race ./cmd -run '^TestAPIUploadPartServerSideChecksum' -count=1
go test ./cmd -count=1
go test ./... -count=1
go vet ./cmd
git diff --check
go run ./buildscripts/rebrand-guard

全部通过。随后两次 Claude Code Fable 5 Max 实现审查与最终验收都给出 GO,无 blocking finding。

成本、风险与发布边界

服务器为省略 checksum 的 part 增加一次 hash CPU 成本。CRC 成本很低,SHA 的成本更高,但仍在本来就要经过的字节流上完成,不增加内存中完整 part 缓冲,也不增加完成阶段的第二遍读取。

滚动升级期间,新旧节点可能对同一个省略 checksum 的请求给出不同结果:新节点接受,旧节点返回 400。盘上 ObjectPartInfo.Checksums 格式没有变化,降级读取是兼容的;但客户端可见行为要到所有服务节点升级后才稳定。发布说明必须提示完成滚动升级。

本记录描述的是本地 main 工作树。实现尚未 commit、push 或进入远端 CI,也没有形成发布包。SILO 文档属于 silo.pgsty.com,不能因为本地 Hugo 构建成功就宣称 pgsty.com 生态中的产品版本已经发布。

为什么拆出两个独立后续

对抗审查还发现两个相关但独立的问题。

#63:CopyObject + compression

普通 CopyObject 的 server-side checksum 也可能挂在 transformed stream 上。它与本次共享根因和 checksumReader 机制,但属于不同 API、测试矩阵和回滚边界。我们决定单独修复,并要求后续 PR 复用本次明文 reader 契约,不建立第二套抽象。

#64:legacy federation

旧式 etcd federation 会把 UploadPartCopy 转成远端普通 UploadPart。按照本次坚持的 AWS 语义,远端普通 UploadPart 不应返回服务器 fallback 值,因此代理仍可能拿不到 CopyPartResult 所需 checksum。后续要在远端响应与经 ETag 校验的 ListParts fallback 之间做独立设计,不能通过破坏所有外部 UploadPart 响应来取巧。

把它们拆开并不是忽略一致性,而是让一致性通过一个明确的共享原则维持:

所有服务器计算的 S3 checksum 都必须绑定逻辑明文流,在任何 eager transform 之前安装,并由拥有存储不变量的对象层复核和持久化。

沉淀下来的经验

这次修复留下了几条比具体代码更重要的经验:

  1. “header 可选”不等于服务器可以缺少内部数据。 协议允许客户端省略,服务器就必须补足自身完成流程需要的状态。
  2. 接受请求与返回响应是两个契约。 普通 UploadPart 可以在内部计算,却仍须按 AWS 规则不返回该值;UploadPartCopy 则必须返回。
  3. 流式系统的层次由字节语义决定。 最低层最统一,但不一定还能看到正确的逻辑字节;eager goroutine 还会让“稍后安装”变成竞态。
  4. 商业实现是证据,不是规范。 AIStor 展示了方向,也展示了不能照抄的边界。
  5. 兼容 guard 是变更确认机制。 compat-baseline.json 不是为了让 CI 闭嘴,而是要求每一个新兼容表面都有明确归属。
  6. 独立问题应独立交付,但要共享设计不变量。 #63 与 #64 分开做,仍然必须引用并遵守本记录建立的 checksum reader 契约。

最终得到的不是一次宽松化,而是一条更严格也更准确的边界:客户端可以省略可选信息;服务器不能省略正确性。

11 - BadDigest、InvalidRequest 与 CompleteMultipartUpload 校验和契约

本文是 SILO #48 的完整设计、调查与验证记录,同时划清它与相关问题 SILO #50 的决策边界。

状态: pgsty/silo#74 已合并为 590aeaa7dpgsty/silo.pgsty.com#6 已合并为 9805dd7;对应变更完成完整本地验证、远端 CI 与独立 Opus 5 Max 验收。tag、release、package、image、deployment 与 production verification 仍是彼此独立、尚未完成的门槛。
2026-08-28 善后: 带 sign-off 的服务器提交 f7bc725d8 关闭剩余 type-only 与非法 token 绕过,同时保持 CRC64NVME canonicalization 不变。完整本地、tagged、race、静态、构建与 Fable Max 验收均通过;push、远端 CI、merge、tag 与交付仍待后续。
归属: 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
完成时声明不同 type,但不发送 whole-object checksum type assertion 被忽略 BadDigest
完成时发送未知非空 type,无论是否同时带 checksum value 可能被忽略或按 checksum 默认规则解释 InvalidArgument
组合式上传的某个分片缺少校验和 InvalidPart InvalidRequest,并点名算法与分片号

修复采用“完成操作专用错误类型”。它明确不修改 hash.ChecksumMismatch 的全局映射,因此 PutObjectUploadPart、流式 Trailer 等操作仍保持现有的 XAmzContentChecksumMismatch 契约。

#50 是另一个问题。AWS 明确说 CRC64NVME 只支持完整对象校验和,但当前证据无法证明:当客户端显式请求 CRC64NVME + COMPOSITE 时,S3 一定拒绝,而不是接受后规范化。上游 MinIO 有意实现了规范化,并且在创建响应中返回 FULL_OBJECT,所以这也不是“静默”替换。改变它之前必须先做真实 AWS 原始请求探针。

范围与决策

本文回答两个不同问题:

  1. #48 中的错误码偏差,是否是可观察、证据充分、应当修复的兼容性缺陷?
  2. 同一批证据是否足以授权修改 #50 描述的 CRC64NVME 规范化行为?

结论是:

  • #48:修正描述后接受并实现。 S3 错误码属于线上协议契约。即使两边都拒绝请求,不同错误码仍会导致 SDK 分支、重试逻辑和运维诊断偏离。
  • #50:暂不实现。 能力矩阵只能证明结果必须是完整对象校验和,不能证明非法请求应被拒绝、忽略还是规范化。这三种线上行为并不等价。

本次修复严格收口:不增加算法、不重算历史数据、不改变成功请求,也不重新解释 #31#46 已修复的可选性规则。

证据台账

不同来源的证明力并不相同,本次实现按以下层级作出判断。

等级 来源 能证明什么 局限
A AWS 校验和上传指南 完整对象校验和不一致返回 BadDigest;算法/类型能力矩阵 不展示所有响应消息
A AWS CompleteMultipartUpload APIAWS 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 映射为:

400 XAmzContentChecksumMismatch

AWS 明确规定,相应的完成阶段完整性失败应返回 BadDigest。但如果只复用现有通用 ErrBadDigest,静态消息仍会说 Content-MD5;CRC32、CRC32C、CRC64NVME 都不是 Content-MD5,因此依旧具有误导性。

新响应改为操作域专用消息:

400 BadDigest
The CRC32 checksum you specified did not match the calculated checksum.

响应不会泄露期望值或客户端提供的摘要值。

校验和类型不匹配

CreateMultipartUpload 保存的校验和类型属于本次上传契约。完成时不能在 COMPOSITEFULL_OBJECT 之间切换。

旧比较逻辑是:

!provided.Type.Is(expectedType)

ChecksumType.Is 是位掩码包含判断,不是相等判断。以 CRC32 为例:

创建 FULL_OBJECT + 完成 COMPOSITE => 被拒绝
创建 COMPOSITE   + 完成 FULL_OBJECT => 包含判断通过

第二种请求会继续按照持久化的组合式规则执行。如果调用方在 FULL_OBJECT 声明下提供的其实是组合校验和值,完成甚至可能成功。这不仅是错误标签不对,而是协议校验绕过。

修复先把双方都规范化为分片校验和类型,再分别比较:

  1. 基础算法是否相同;
  2. 对象类型是否相同(COMPOSITEFULL_OBJECT)。

对于两种对象类型在语法上都成立的算法——目前是 CRC32 与 CRC32C——两个不匹配方向现在都返回 400 BadDigest。SHA1/SHA256 与 FULL_OBJECT 的组合会更早被现有解析器以 InvalidArgument 拒绝;CRC64NVME 则是下文 #50 所述的规范化特例。基础算法不匹配仍保留独立的 InvalidArgument 路径,因为 #48 与引用的 AWS 类型契约不足以授权扩大修改范围。

Type-only assertion 与非法 token

第一轮 #48 修复记住了请求是否出现 x-amz-checksum-type,但对象层比较仍然嵌套在 WantChecksum != nil 下面。只有 completion 同时携带 checksum value 时,WantChecksum 才非空。因此客户端可以只发送 type assertion:

CreateMultipartUpload:   CRC32 + COMPOSITE
CompleteMultipartUpload: x-amz-checksum-type: FULL_OBJECT
                         没有 x-amz-checksum-crc32 value

服务器会返回成功,并继续持久化创建阶段的 composite 状态。对象没有损坏,但服务器接受了与上传契约矛盾的显式完整性声明。

解析器还有第二处不对称。在 completion 常见的“只有 checksum header、没有 algorithm header”路径中,NOT_A_TYPE 这样的未知值可能在同时携带 checksum 时被忽略。不能先构造 invalid bitmask,再依赖 ChecksumType.ObjType():非法的 non-multipart 值可能落入默认 full-object 分支。原始枚举值必须先独立校验。

善后修复把显式 raw type string 保存在 ObjectOptions 中,只接受 COMPOSITEFULL_OBJECT,并让对象类型比较完全独立于 WantChecksum。顺序是刻意设计的:

  1. 所有未知非空 token 先以 InvalidArgument 拒绝;
  2. 提供 checksum value 时,再比较基础算法;
  3. 只要上传记录了 checksum algorithm,就比较显式 object type;
  4. 即使没有 object checksum value,显式 type mismatch 仍返回 BadDigest

CRC64NVME 继续作为刻意例外:raw COMPOSITE 会先规范化为 FULL_OBJECT,保持继承行为,等待 #50 AWS 探针。对于创建时没有记录 checksum algorithm 的上传,合法 type-only header 仍不参与比较,因为不存在可供 assertion 的创建阶段 checksum type;它的精确 AWS 错误语义尚无证据,本次没有扩大修改范围。

组合式分片缺少校验和

组合式上传的完成 XML 必须为每个列出的分片提供选定算法的校验和。SILO 过去把空客户端值与已保存值比较,然后返回 InvalidPart

这混淆了三种不同状态:

  • 分片或 ETag 不存在;
  • 客户端提供了校验和,但值或算法错误;
  • 必需的校验和元素完全缺失。

第三种状态现在拥有专用错误,线上消息遵循 boto/s3transfer 记录的 AWS 响应:

400 InvalidRequest
The upload was created using a sha256 checksum. The complete request must include
the checksum for each part. It was missing for part 1 in the request.

服务端在遇到第一个缺失分片时返回错误,并携带真实分片号。FULL_OBJECT 行为不变:完成请求可以省略逐分片校验和,但一旦提供,仍必须正确。

上游真实情况

这些行为来自继承代码,而非 SILO 有意重新设计。

  • MinIO PR #15433 引入扩展校验和与全局 hash.ChecksumMismatch -> XAmzContentChecksumMismatch 映射。该映射适合上传数据流验证,却过度覆盖了完成阶段语义。
  • MinIO PR #20855 增加完整对象校验和与 CRC64NVME,并引入类型比较。它还通过“看起来 AWS 会忽略模式并自行假设”的注释,有意把 CRC64NVME 规范化为完整对象。
  • MinIO PR #20953 收紧非法算法/类型组合,却保留 CRC64NVME 特例。这证明它是上游的明确行为,而非偶然漏掉一个分支。
  • MinIO Issue #20944 报告过 AWS BadDigest 与 MinIO InvalidPart 的差异。上游承认偏差,但没有修复。

MinIO 上游仓库现在已经归档。SILO 必须自行承担兼容性判断、测试和后续维护,不能再等待上游修正。

修复设计

操作域专用错误

如果修改 hash.ChecksumMismatch 的全局映射,就会改变所有使用它的操作,形成一个证据不足、范围更大的兼容性变更。

本次在服务端 cmd 包中新增三个包内私有、由哨兵错误支撑的错误辅助函数。辅助函数与“请求头是否出现”标记均保持私有,避免扩大 SILO 对外 Go 兼容符号面:

  • completeMultipartChecksumMismatch:映射到 BadDigest,并提供校验和语义正确的描述;
  • completeMultipartChecksumTypeMismatch:映射到 BadDigest,并点名请求类型与创建类型;
  • missingPartChecksum:映射到 InvalidRequest,携带算法与分片号。

只有 CompleteMultipartUpload 会产生它们。全局映射保持:

hash.ChecksumMismatch => XAmzContentChecksumMismatch

因此 PutObjectUploadPart 行为不变,而且兼容性边界在代码中清晰可见。

双向对称类型验证

比较前,持久化类型与请求类型都要加上分片上下文标志。原因是:裸 CRC 类型在 ObjType() 中表示非分片的完整对象校验和;同一个基础值进入分片上下文后则代表组合式类型。只有完成请求显式携带 x-amz-checksum-type 时才比较对象类型;省略一个可选头不能被解释为主动声明 COMPOSITE

最终不变量是:

请求基础算法 == 创建时基础算法
并且
请求分片对象类型 == 创建时分片对象类型

第二个条件只在类型头显式出现时生效。该比较是对称的,同时继续兼容现有 CRC64NVME 规范化。它修复 #48,却不会暗中替 #50 作出决定。

精确识别“缺失”

服务端原本就会为每个分片建立“完成 XML 中所有校验和字段”的映射。本次把行为拆成:

COMPOSITE + 没有任何校验和字段 => missingPartChecksum / InvalidRequest
存在期望字段但值错误             => InvalidPart
只提供了另一种算法               => InvalidPart
FULL_OBJECT + 没有校验和字段      => 允许
FULL_OBJECT + 提供任意校验和字段  => 必须验证

这里没有把所有分片校验和失败都改成 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,关闭旧包含关系绕过
两个方向的 type-only mismatch CRC32 创建为一种类型;完成时只声明另一种 type,不提供对象 checksum value HTTP 400、BadDigest;不能通过省略 digest 绕过显式 assertion 校验
非法显式 type 完成时发送 NOT_A_TYPE 或小写 full_object,分别覆盖有/无 checksum value HTTP 400、InvalidArgument,对象不提交
匹配的 type-only assertion 创建与完成均为 CRC32 COMPOSITE,省略对象 checksum value 成功;执行合法 assertion,但不凭空要求 digest
省略可选类型头 创建 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。善后矩阵另外覆盖 type-only、unknown、lowercase、matching 与“非法 token 同时带 checksum”的场景。整套测试同时确认:SHA1/SHA256 的 FULL_OBJECT 请求会更早停在现有非法组合检查,而 CRC64NVME 仍会把显式 COMPOSITE 规范化。这些差异是协议边界,不应被误写成“所有算法都进入同一个错误映射器”。

聚焦验证命令:

go test ./cmd -run 'TestAPIErrCode$|TestAPICompleteMultipart(FullObjectChecksumMismatch|CompositeStillRequiresPartChecksums|CompositeChecksumMismatch|ChecksumTypeMismatch)$|TestAPIUploadPartServerSideChecksumDoesNotMaskClientErrors$' -count=1

2026-08-27 的实测结果:

ok  github.com/minio/minio/cmd

吸收审查建议后,又重新执行完整本地包门禁:

go test ./cmd ./internal/hash -count=1
ok  github.com/minio/minio/cmd           121.462s
ok  github.com/minio/minio/internal/hash   0.566s

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 断言会在旧代码上失败,并双向探测了全部五种校验和算法。

2026-08-28 的善后 diff 又接受了一次本机 Fable Max 镜像审查,结论为 GO,没有 P0–P2。主审独立核查七条 P3:五条是非阻断边界,另外两条因果推断被真实 config 与 key-rotation 调用链否定。审查确认 raw 非法 type 会在规范化前拒绝、type-only mismatch 会执行、源 checksum 解密仍收到完整请求、CRC64NVME canonicalization 完全未动。

仍有五个修复前就存在或有意推迟、且不阻断本次工作的观察:

  • SHA1/SHA256 的 FULL_OBJECT 组合会被现有解析器提前以 InvalidArgument 拒绝;只有 CRC32/CRC32C 能进入两个类型不匹配方向;
  • CRC64NVME 会把任意类型值视为完整对象状态,因此完成时显式写 COMPOSITE 仍会在 #50 AWS 探针结论出来前经规范化后被接受;
  • 如果创建阶段没有记录校验和算法、完成阶段却提供对象校验和,SILO 会返回 BadDigest;AWS 文档说明该值应被接受并忽略,应另立兼容性问题处理;
  • 组合式分片数不匹配与摘要值不匹配都会成为 BadDigest,并使用同一描述;
  • 完整对象校验和值如果带 -N 后缀,后缀会被忽略,但摘要本身仍会验证。

它们都不是这些补丁引入的,也不改变 #48 结论。如果未来要追求更严格的消息或非法请求头兼容性,应分别立项处理。

为什么不顺便修 #50

#50 认为,应当在创建阶段拒绝 CRC64NVME + COMPOSITE。目前可以确认三件事:

  1. AWS 算法矩阵只允许 CRC64NVME 作为完整对象校验和;
  2. SILO 与 MinIO 上游会把请求规范化为完整对象状态;
  3. 服务端在 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 请求与响应:

  1. 发送带 x-amz-checksum-algorithm: CRC64NVMEx-amz-checksum-type: COMPOSITECreateMultipartUpload
  2. 记录 HTTP 状态、错误码/消息、请求 ID 与全部校验和响应头;
  3. 如果创建成功,上传一个分片并完成,记录 S3 是否要求逐分片值,以及 HeadObject 报告的类型;
  4. 使用 FULL_OBJECT 作为控制组重复;
  5. PutObject 单独测试,并明确标记为“未文档化请求头实验”。

只有捕获到 AWS 拒绝响应,才足以授权把规范化改成参数校验。如果 AWS 接受并规范化,就应修正或关闭 #50,而不是实现它。

兼容性与运维影响

  • 成功请求: 校验语义不变,但省略可选的 x-amz-checksum-type 头不再被误判为显式 COMPOSITE 声明。这项有意的互操作性放宽会把旧实现错误返回的 400 改为成功。
  • 失败请求: 除上述省略请求头的情况外,HTTP 状态仍为 400;受影响的 S3 错误码与消息改为 AWS 兼容语义。显式 type-only mismatch 现在会执行,未知非空 type 会在 bitmask 规范化之前以 InvalidArgument 拒绝。
  • 完整性: 不减弱,并关闭反向类型绕过;任何失败完成都不会提交对象。
  • 存储数据: 不改变格式、编码、元数据、纠删码布局;无需迁移或回填。
  • 性能: 只有常数时间比较与错误构造;不增加数据读取或哈希遍历。
  • 安全/隐私: 新消息不返回摘要值,也不会额外加入 Bucket 或对象名。
  • 滚动升级: 全部节点升级前,失败请求可能得到不同错误码;成功对象仍完全兼容。
  • 回滚: 会恢复旧错误码和非对称比较;无需回滚数据。
  • 其他仓库: 不需要 Console、共享包、MCLI 或 SDK 修改;跨仓库交付只有这份公共设计记录。

合并与发布门禁

门槛 #48 基础修复 2026-08-28 善后
设计与本地验证 完成 完成
独立对抗评审 完成,ACCEPT 完成,GO
带 sign-off 的服务器提交 完成 本地 f7bc725d8
Push、远端 CI 与 merge 已合并为 590aeaa7d 尚未确认
公共设计记录 已合并为 9805dd7 本次文档更新仍在本地
Tag 与 release artifact 尚未确认 尚未确认
Container image 与 package 尚未确认 尚未确认
Deployment 与 production probe 尚未确认 尚未确认

善后提交必须继续把 #50 排除在外,除非 AWS 原始响应改变决策;最终提交还需运行远端 DCO、Go CI、漏洞与 release pipeline,并基于当前 SILO main 合并。仓库集成、release artifact、image、deployment 与 production probe 仍是独立门槛,不能由本地测试或文档构建结果推导。

结论

#48 是正确的兼容性问题,现在三行行为都有足够证据。最安全的修复不是全局重命名校验和错误,而是让 CompleteMultipartUpload 报告自己的协议错误:对称比较校验和类型,并把真正缺少组合式分片校验和的状态与“分片不存在”“值错误”区分开。

#50 只是在发现历史上相关,证明链并不相同。当前 CRC64NVME 规范化是有意且可见的。没有 AWS 精确响应之前,贸然修改只会用一个未经验证的假设替换另一个。

这就是本次设计的核心边界:实现官方契约与官方测试能够证明的内容;把代码审查发现的隐藏后果纳入回归;剩余策略问题必须通过明确、可重复的证据门禁。

12 - 桶级 CORS:让删除与恢复正确收敛

本文完整记录 SILO PR #71 与发布善后任务 SILO #75 的问题、评审、合并决策、对抗辩论和最终实现契约。

状态: PR #71 已合并为 e4e3007da。B2 收敛修复是 PR #80 中的 724f8703d,其首轮 DCO、CI、race、cross-compile 与漏洞检查共八项全部通过。最终 B2+B3 代码是 signed commit 0eebc928f;组合 Opus finding 已修复,全量 tagged/race/build/vet/lint/compatibility 门禁、真实本地双站点离线 DELETE/heal/restart 与 raw SigV4 B3 探针均通过。EN/ZH 记录通过 warning-fatal Hugo 构建、渲染链接检查和本地浏览器 QA。Issue #75 仍是发布阻断:尚未 merge、tag、打包、发布镜像、部署或完成生产验证。
归属: pgsty/silo 负责服务端修改;这份公共设计记录归 pgsty/silo.pgsty.com 所有;Console UI 仍是独立交付。
决策: 接受有价值的功能并保留贡献者成果,但在站点复制删除、恢复、通配符响应和窄幅协议善后正确收敛前,不允许发布。

用浅显语言说明问题与方案

PR #71 之前,SILO 只能为整个集群设置一份 CORS。浏览器应用无法表达:“允许这个网站使用桶 A,但不允许它使用桶 B。”标准 S3 的桶级 CORS 读取、写入和删除接口虽然有路由,却只是返回 NotImplemented 的占位实现。

PR #71 补上了这项能力。每个桶现在都可以保存自己的允许网站、HTTP 方法、请求头、开放响应头和预检缓存时间。标准 S3 客户端可以管理配置;没有专属配置的桶继续使用原来的全局行为。

核心功能已经可以工作。剩余问题出现在同一个桶跨站点复制时。

假设管理员先允许 https://old.example.com,随后撤销这个权限。第一个站点正确删除了规则。如果另一个站点临时漏收 DELETE,恢复过程必须知道“10:05 的删除”比“10:00 的配置”更新。当前代码有时会忘掉删除时间,或把来源时间替换成对端收到消息的时间。恢复过程于是可能误把仍然存活的旧规则当成最新状态,再把它写回来。

修复不需要新的复制系统。删除后的配置可以用 SILO 已有的数据表示:

配置内容 = 不存在
更新时间 = DELETE 发生的时间

这对状态就是删除墓碑(tombstone)。修复会为 PUT 和 DELETE 保留来源时间;即使 XML 已经不存在,也传播这个时间;并让 heal 与普通 peer 交付复用同一条 CORS 应用路径。旧事件因此不能再复活更新的删除。

直接代价有清晰边界:一个 CORS 专用分布式 namespace lock 与单调状态转换、穿透真实 ObjectLayer 的测试、严格 wire 校验、响应兼容收尾和运维文档。它不增加存储字段、依赖、功能开关、分布式时钟或通用复制框架。长期代价是 SILO 需要维护这些测试和桶级 CORS 兼容契约;站点复制仍像原来一样依赖时钟同步。

CORS 不是 IAM 鉴权。过期 CORS 规则不会让无权访问 S3 的主体获得权限,但它可能让管理员本想撤销的浏览器来源继续读取原本已获授权的跨域响应。因此复制收敛是发布阻断,而不是外观问题。

PR #71 增加了什么

PR #71 把继承而来的 Bucket CORS 占位实现替换成了一项完整功能:

  • 标准 PutBucketCorsGetBucketCorsDeleteBucketCors API;
  • PUT 的 Content-MD5 或受支持 checksum 校验;
  • Origin、Method、AllowedHeader、ExposeHeader、规则 ID 和 MaxAge 的 XML 解析与校验;
  • 原始 XML 与 CORS 更新时间写入 BucketMetadata
  • 桶级 OPTIONS 预检与实际响应 CORS 头;
  • 仅在桶没有专属配置时继续使用现有全局 CORS;
  • 站点复制的正常发送、接收、初始同步、状态和 heal 接线;
  • 单元、Handler、Middleware、Metadata 与传输测试。

本地评审把功能合入当时最新的 main,执行了构建、定向普通与 race 测试、完整 cmd、固定版本 lint、生成文件检查、兼容性检查,以及真实 minio-go 冒烟测试。单站路径上的 PUT、GET、DELETE、允许与拒绝预检、Vary 和实际响应头全部正常。

这些证据足以接纳功能,但不能证明所有失败恢复路径。

已复现的收敛错误

评审使用真实 ObjectLayer 的临时测试稳定复现了下面三个问题。后续发布审查还证明:只比较 payload 的 status 会隐藏不同来源时间屏障;同时间戳冲突则依赖事件到达与 map 迭代顺序。

更新的 DELETE 可能被忽略

Peer handler 使用普通 metadata UpdateDelete。这些方法会在接收站点无条件写入 UTCNow()。如果旧 PUT 延迟到达,它生成的本地接收时间可能看起来比后续来源 DELETE 更晚,结果 DELETE 被当成旧事件丢弃。

旧 PUT 可能复活删除

活跃配置为 nil 后,GetCorsConfig 会返回 not-found 与零时间戳。删除时间仍保存在原始 bucket metadata 中,但 handler 通过这个 getter 看不到。旧 PUT 因此通过 staleness 判断并恢复规则。

Heal 可能选择仍存活的旧规则

当前 SiteReplicationMetaInfo 只有在 CORS XML 非空时才输出 CorsConfigUpdatedAt。DELETE 后,站点报告 nil 配置与零时间;仍保留旧 XML 的 peer 则报告非零旧时间。Heal 于是把旧规则选成“最新”,重新写回。

这三个现象其实是同一个缺陷在三个边界上的表现:peer apply、metadata status 与 recovery。

预期状态模型

桶级 CORS 只需要 BucketMetadata 中已经存在的状态:

逻辑状态 XML 时间戳 含义
从未配置 nil 零时间 baseline;既不发送,也不作为 winner
已配置 非 nil 来源 PUT 时间 生效中的桶级规则
已删除 nil 来源 DELETE 时间 tombstone;比任何更早的活跃规则更新

选定 register 使用确定性总序:

1. 来源 UpdatedAt
2. baseline < live < tombstone
3. 同时间 live/live:按解码后的 payload 字节字典序

Peer apply 是单调 join:只有严格更大的状态才会落盘,因此 retry 与重复交付天然幂等。同时间 PUT/DELETE 由 tombstone 胜出;两个 live 值则在每个站点选择相同的字节 winner。CreatedAt 不再表示 baseline,只作为 bucket lineage 下界,拒绝旧 bucket incarnation 的事件并输出按桶去重的诊断。

决策是如何形成的

初始评审

第一次评审确认需求真实、单站架构合理,但发现站点复制虽然发出了 CORS 事件,却没有完成所有接收、状态和恢复路径。贡献者随后补齐接线、checksum 校验、通配符/ID 限制、缓存变化维度与定向测试。

第二次运行时评审确认单站和正常直接复制路径,同时复现了上述 tombstone 与来源时间问题。功能已经足够接近,可以接纳,但还不够安全,不能当作完成状态发布。

合并不等于发布

维护者决定合并 PR #71,并接手剩余加固。这个决策明确分开了两个经常被混淆的问题:

  1. 贡献是否有价值、结构是否足够合理,可以接纳?可以。
  2. 结果是否已经可以打 tag、打包、发布镜像和部署?必须等 #75 关闭。

合并触发的完整 main CI 已经通过;正式发布和 Docker 发布仍是手工、独立门槛。

自我对抗性方案评审

最初的善后方案刻意写得很全面,随后按四种失败模式自审:是否过度设计、是否为修一个问题引入更多问题、是否没有复用现有基础设施,以及维护成本是否不成比例。

这次自审删除或延后了:

  • 新的通用 metadata apply 抽象;
  • 自定义 wildcard matcher;
  • Policy/Tag/SSE/Quota 的广泛重构;
  • 多进程站点复制测试实验室;
  • 没有差分证据的方法大小写、Unicode ID、trailing XML 严格化;
  • 可能改变现有 Vary 行为的无 Origin 热路径优化;
  • vector clock、新 tombstone 字段和全局时间戳重构。

独立 Claude Opus 5 评审

四轮只读本地 Claude Code 评审使用 canonical claude-opus-5 与 maximum effort。评审把方案从 timestamp-only 补丁推进成最终 zero-baseline、确定性 C-prime register;要求分布式 CORS lock 与单调本地 barrier;让 status/heal 比较完整状态;并关闭 strict base64、语义校验、缓存、Vary、wildcard credentials 与 initial-sync tombstone 缺口。

B2+B3 组合终审又同时检查 strict parser、精确 Method 与 Unicode ID 契约、MaxAge presence、Origin-null forwarding marker、checksum 分类以及复制/重启行为。它发现一处测试 helper 冲突,以及宽松开发版本接受的文档可能导致整份 bucket metadata 不可用的升级风险。Helper 已修;legacy-invalid CORS 现在保持其他 bucket metadata 可读,对浏览器 fail closed,拒绝新 invalid save,并允许通过有效 CORS PUT/DELETE 修复。

设计目标与非目标

设计目标

  • 让 CORS PUT/DELETE 在重复、延迟、乱序、漏发后正确收敛;
  • Peer apply 与 heal 都保留精确来源时间;
  • 更新的 nil tombstone 能打败更旧的活跃配置;
  • metadata 故障不能把已配置桶静默放宽到全局 CORS;
  • 字面通配符、credentials、expose headers 和缓存变化符合 S3 行为;
  • 修正新加入的 CORS 状态计数,以及现有 matcher 已证明的窄幅校验缺口;
  • 保持修复可独立评审、可单独回滚。

非目标

  • 重构所有 bucket metadata 复制 handler;
  • 全局解决分布式时钟偏差或同时间戳多写者冲突;
  • 新增 metadata schema、事件日志、队列、通用 metadata lock 或功能开关;
  • 建设长期多站点进程实验室;
  • 在没有证据时收紧无关 XML 或校验路径;
  • 增加 Console UI;
  • 把历史 Object Lock、Tag、SSE、Policy、Quota 或 Versioning 修复混入本分支。

最终修复设计

Commit 1:保留 tombstone 与来源顺序

CORS 复制 handler 继续作为显式 peer CORS 事件的唯一应用边界。

它在 CORS 专用分布式 namespace lock 内执行:

  1. 要求非空 bucket 与非零来源时间;
  2. 要求 bucket metadata 已经存在,而不是凭空创建;
  3. 读取原始 CorsConfigUpdatedAt,包括活跃配置为 nil 时保留的时间;
  4. 拒绝早于 bucket lineage 的事件,并忽略总序中不严格大于本地状态的事件;
  5. 严格解码并校验非 nil CORS payload,或把 nil 解释为 DELETE;
  6. 从来源事件设置 CorsConfigXMLCorsConfigUpdatedAt,保留精确来源屏障;
  7. 通过 BucketMetadataSys.save 持久化,保留现有磁盘、缓存、通知和 peer-node 刷新路径。

Legacy/default multi-field 路径可能携带非 nil CORS snapshot,因此也使用同一把锁、严格校验与 join;typed delete 继续走 CORS 专用 handler。SiteReplicationMetaInfo 无条件输出来源时间,只在 XML 存在时编码内容。Status 比较 kind、解码 payload 与时间;heal 选择确定性最大状态并通过同一 transition 传播,包括只差时间戳的场景。

Zero baseline 明确不默认成 bucket 创建时间。Initial sync 发送 live 与 tombstone,但不发送 baseline。本地 PUT/DELETE 生成严格晚于 max(UTCNow, CreatedAt, current barrier) 的时间。

Commit 2:Fail closed,并对齐 S3 响应

当前 middleware 会在所有 GetCorsConfig 错误上回退到全局 CORS。修复区分两种情况:

  • 确实没有配置:继续使用全局策略,保持现有行为;
  • 请求带 Origin 且出现其他 metadata 错误:log once,然后调用底层 S3 handler,不附加全局 CORS 头。

这样对浏览器 fail closed,又不会把 metadata 问题变成新的全局 500 契约。失败预检会进入 router 的普通非 CORS 错误响应。无 Origin 请求保持现有 middleware 路径,不做投机热路径优化。

成功预检还会返回配置的 Access-Control-Expose-HeadersS3 OPTIONS 契约明确列出了这个响应头。

Origin 匹配将返回真正命中的 pattern。响应行为是:

命中的 Origin 元素 Access-Control-Allow-Origin Access-Control-Allow-Credentials
* * 不发送
精确 Origin 请求 Origin true
https://* 等模式 请求 Origin true

当同一规则同时包含具体 Origin 和 * 时,响应语义跟随第一个实际命中的 Origin 元素,而不是事后发现规则里某处存在 wildcard 就统一处理。

三个缓存变化维度会在预检匹配前设置,因此 200 和 403 都按 Origin、请求方法和请求头区分缓存。

Commit 3:窄幅校验与状态善后

站点摘要使用当前站点的 s.CorsConfig != nil 增加 TotalCorsConfigCount,而不是使用可能已经累计早先站点的总数。

校验拒绝:

  • 空 AllowedOrigin;
  • ?,因为复用的通用 matcher 会把它当成 wildcard,而 S3 只定义单个 * 通配符。

实现保留现有 matcher 与最多一个 * 的限制;不修改方法大小写、ID 字符计数或 trailing XML 行为。

Handler 测试补上缺失与不匹配的 Content-MD5。这个测试又暴露了一个真实问题:handler 在 checksum reader 外套了长度恰好等于 ContentLengthLimitReader,后者会在 checksum wrapper 报摘要不匹配前先返回 EOF。完成既有正数与 64 KiB ContentLength guard 后,handler 现在直接把包装后的请求体读到 EOF。这样既继续复用共享 validateLengthAndChecksum,又能真正返回 BadDigest,不增加第二条 checksum 路径。

Commit 4:运维说明

仓库内说明记录:

  • 桶级 CORS 覆盖而不是合并全局 CORS;
  • DELETE 恢复全局 fallback;
  • 老版本不会执行新配置;
  • 老版本重写 bucket metadata 时可能丢弃未知 CORS 字段;
  • 老 peer 对未知 CORS 事件会无害 no-op,升级后通过 heal 收敛;
  • 站点复制继续依赖时钟同步。

公共运维文档仍是独立文档仓库交付;本文以及后续面向任务的参考更新共同承担这个边界。

测试设计

测试验证真实状态转换,但不建立长期多进程实验室。

测试边界 必须覆盖的场景
Peer apply + ObjectLayer 延迟 PUT 后较新 DELETE;tombstone 后旧 PUT;重复交付;精确来源时间;metadata 不存在时返回错误且不创建记录
Transport 到 apply 保留现有 nil/非 nil JSON 往返;至少把一个 JSON 解码事件送入真实 peer handler
SiteReplicationMetaInfo nil 配置仍携带 DELETE 时间;功能前的零时间默认成 Created
Heal 更新的 nil tombstone 打败旧活跃 XML;本地变为 nil,时间精确等于 tombstone
Middleware 错误 无配置使用全局 fallback;其他 metadata 错误的 actual/preflight 都不附加全局 CORS
Origin 响应 精确、字面量 *、模式和混合规则;仅在允许时发送 credentials
Preflight ExposeHeader、AllowedHeader、MaxAge;成功与拒绝都携带三个 Vary
校验与 Handler 空 Origin、?、缺失 Content-MD5、不匹配 Content-MD5

完整 admin-auth dispatch 不新增独立 fixture。它只是一个两行 switch,编译与评审已经覆盖;wire 与真实 handler 才承载有意义的状态机风险。也不固定 startup 的具体 errBucketMetadataNotInitialized 值;一个代表性非 not-found metadata 错误足以覆盖 middleware 决策。

被否决的方案

把 CORS tombstone 逻辑塞进通用 metadata merger

否决,因为这会让一个七字段 merge 函数中的单个字段拥有特殊 nil、staleness 和提前返回语义。CORS 专用 handler 已经存在,是更小的边界。

新建 timestamp-aware metadata 抽象

在至少两个 metadata 类型证明需求完全一致前否决。今天创建通用 helper 会提前编码 Policy、Tag、SSE、Object Lock、Quota、Versioning 之间并不相同的删除语义。

新增物理 tombstone 字段或事件日志

否决,因为 (nil 配置, DELETE 时间戳) 已经表达全部所需信息。新 schema 只会增加降级与迁移成本。

用分布式顺序系统替换 wall clock

否决,因为代价不成比例,也与现有 Site Replication 不一致。正确保留来源时间可以恢复现有契约,但不能全局解决时钟偏差。

建设完整多站点测试实验室

否决,因为问题属于本地状态机缺陷,所有重要边界都可进程内直接测试。实验室更慢、更脆弱,也更难诊断。

编写自定义 CORS wildcard matcher

否决,因为输入校验可以把现有 matcher 限制到 S3 支持的单 * 语言。重新实现匹配只会制造更多边缘。

现在收紧所有校验

否决,因为强制方法大写、Unicode ID 计数与 trailing document 拒绝可能改变已接受输入,却没有安全或真实客户端兼容证据。

在同一分支修复所有相邻复制问题

否决,因为代码看起来相似不代表语义相同。每个历史问题都必须有独立复现、Issue、评审和发布边界。

成本、收益与剩余风险

收益

  • 浏览器应用与常见 SDK 获得标准 S3 Bucket CORS;
  • 每个桶可使用比集群全局 fallback 更窄的 Origin 策略;
  • 正常交付、漏发、乱序、重试和 heal 最终收敛到同一状态;
  • Peer 漏收 DELETE 不会导致已撤销浏览器来源被恢复;
  • metadata 错误不能把已配置桶静默扩大到全局 CORS;
  • wildcard 与 credentials 行为符合既有 S3 客户端预期。

实现与维护代价

生产修改只涉及 bucket metadata 时间戳、CORS peer apply/heal/status、CORS middleware 和 CORS 校验。最大的新增量是回归测试,因为必须在两个受支持 ObjectLayer 测试后端证明状态收敛。

不增加依赖、服务、配置键、存储字段、后台 worker 或跨仓库服务端依赖。长期成本是维护 S3 兼容矩阵、来源时间测试与文档。

设计上接受的剩余风险

  • wall-clock 顺序依赖站点时钟同步;
  • 同时间戳使用 CORS 局部确定性 tie-breaker,而不是全局复制重构;
  • CORS 写入不支持混合版本运行;启用功能前必须升级所有站点;
  • 降级写入时老版本可能忽略或丢弃 CORS metadata;
  • 完整 Console 管理仍不存在;
  • CORS 之外继承的复制缺陷仍是独立工作。

这些是可见约束,不是隐藏的“完全一致”宣传。

保持独立的历史善后

对抗评审确认了现有初始同步路径中的一个无关缺陷:Object Lock 事件虽然使用 SRBucketMetaTypeObjectLockConfig,却把 payload 放进 Tags 而不是 ObjectLockConfig。它需要独立 Issue 与修复。

相邻站点摘要也存在累计计数模式,Policy/Tag/SSE/Quota/Versioning peer handler 还可能共享来源时间或 tombstone 弱点。后续规则是:

  1. 分别复现每个行为;
  2. 按受影响的 metadata 契约建立聚焦 Issue;
  3. 不在 CORS 分支修改;
  4. 只有至少两个类型证明语义完全相同后,才考虑共享 helper。

这样既诚实清理历史问题,又不会把有边界的 CORS 修复变成 Site Replication 大重构。

早于本地 CreatedAt 的事件被视为旧 bucket incarnation 并忽略,同时使用按桶去重的日志记录。Status 会继续暴露 mismatch;删除这个 floor 反而可能把旧 CORS grant 安装到同名新 bucket。

兼容性影响

现有用户或部署 预期影响
没有配置 Bucket CORS 继续使用现有全局 CORS
单站 Bucket CORS 标准控制面与执行行为保留;响应一致性改善
启用 Site Replication,但不使用 Bucket CORS 无行为变化
Site Replication + Bucket CORS 来源顺序、DELETE、重试和 heal 变得可靠
原始 PUT 调用者 必须发送 S3 要求的 Content-MD5 或受支持 checksum
老 peer 升级前 no-op 未知 CORS 事件;升级后由 heal 收敛
降级 不执行 Bucket CORS;老版本重写记录时可能丢失 metadata
只使用 Console 的运维者 暂无 CORS 编辑器;需使用 SDK、CLI 或 S3 API

空 Origin 与 ? 的严格化发生在任何包含 PR #71 的 SILO 正式版本之前,因此不存在已经发布的 SILO Bucket CORS 配置需要迁移。

验证与发布门槛

只有以下条件分别成立,修复才算完成:

  1. 定向复制、middleware、校验和 handler 测试通过;
  2. 定向 race 测试通过;
  3. 完整 cmd 测试通过;
  4. go build ./...、固定版本 lint、生成文件和兼容性检查通过;
  5. 标准 minio-go PUT/GET/DELETE 与预检冒烟通过;
  6. 独立对抗评审没有未解决 blocker;
  7. 跟进服务端 PR 已提交、推送、评审并合并;
  8. PR CI 与合并后的 main CI 全绿;
  9. 文档构建和双语链接检查通过;
  10. release tag、软件包、镜像发布、部署与生产验证分别完成。

在此之前,Issue #75 保持 open,任何 release 或 Docker 镜像都不能把桶级 CORS 宣称为可发布完成状态。

结论

桶级 CORS 解决了真实的兼容性和浏览器隔离问题,PR #71 的核心实现值得接纳。剩余缺陷不是丢弃功能的理由,而是要求在发布前精确定义并完成复制状态模型的理由。

最终设计让正常 peer apply、status 与 heal 都保留来源时间和 nil tombstone;对 metadata 错误 fail closed,又不把它升级成新的 S3 可用性故障;修正字面 wildcard 与缓存行为;校验变化坚持证据边界。它复用现有 CORS handler、bucket metadata、save 路径、matcher 和 ObjectLayer 测试,不增加通用框架,也不把无关历史修复塞进分支。

这就是让已经合并的功能达到充分、安全、可维护所需的最小复杂度。

13 - 桶级 CORS Wire 契约:严格 XML、Checksum 与浏览器响应

本文记录 SILO PR #71e4e3007da 合并后,桶级 CORS 的 B3 协议加固决策。范围只包括 S3 请求体、校验、checksum、匹配和浏览器响应契约。站点复制顺序、tombstone、heal、状态计数与通用 metadata 重构仍是 SILO #75 下的独立工作。

状态: 独立 B3 实现是本地 commit ae879f6cc,最终 B2+B3 整合是 issue-75 分支上的 signed commit 0eebc928f。组合 Opus 5 Max 评审保留了 strict parser、Validate、checksum、MaxAge、wildcard、Origin: null 与 fail-closed replication 契约,并发现、修正了一处冲突 helper 拼接错误和 legacy-invalid metadata 恢复风险。组合全量本地门禁、raw SigV4 B3 探针与真实双站点 CORS 复制/恢复均通过;EN/ZH 记录通过 warning-fatal 构建、渲染链接检查和本地浏览器 QA。组合修改仍未 merge、tag、发布、部署或完成生产验证。
决策: 在第一个包含桶级 CORS 的 SILO 正式版本之前完成严格 B3 wire 契约。不能把无效输入规范化成有效配置,不能把补丁扩大成 site replication 重构,也不能用本地 B3 证据宣称整体发布 GO。

为什么这是发布阻断

Bucket CORS 是标准 S3 控制面。输入不只是“看起来像配置的文本”:原始客户端会对精确 XML 请求体签名,现代 AWS SDK 会附带必需的 payload checksum,SILO 会原样保存接受的字节,GetBucketCors 随后又会把这些字节返回给严格 XML 客户端。

三个对抗用例暴露了合并实现的缺口:

  1. 合法 <CORSConfiguration> 后追加第二个 XML root 仍被接受并保存;
  2. 恰好包含 255 个 Unicode 字符的 ID 会被拒绝,因为 Go len(string) 统计 UTF-8 字节;
  3. <AllowedMethod>get</AllowedMethod> 会被接受,因为校验先把它转成大写再检查 S3 枚举。

这些是服务端 wire 问题。AWS 官方 SDK 模型不会在客户端完整校验规则 ID 或方法字符串,原始签名客户端也始终可以绕过 typed SDK 构造。因此必须由服务端执行契约。

第二个 root 的后果尤其严重。SILO 保存的是完整 body,而不是第一个已解码元素。一次成功 PUT 因此可能让后续 GET 返回含两个 root 的文档,标准 XML 客户端会直接拒绝它。

权威契约

实现以当前 AWS 文档与生成 SDK 模型作为协议基线:

  • PutBucketCors 定义 XML root、64 KB 文档上限、Content-MD5 与 SDK checksum header、最多 100 条规则,以及 Origin、Method、所有请求 Header 必须同时匹配的规则条件。
  • CORSRule 定义大写方法值与包含端点的 255 字符 ID 上限。
  • CORS 配置元素规定每个 AllowedOrigin 或 AllowedHeader 最多包含一个 *
  • 测试 CORS展示成功预检返回命中规则的完整方法列表、请求且允许的 Header、ExposeHeader、credentials 与缓存变化 Header。
  • S3 错误响应把不符合 S3 schema 的 XML 定义为 MalformedXML,把 Content-MD5 或 checksum 不匹配定义为 BadDigest
  • 生成的 AWS SDK for Go v2 PutBucketCors 操作把请求 checksum 标记为 required;其 CORS 类型使用 int32 MaxAgeSeconds,并把大多数语义校验留给服务端。
  • WHATWG Fetch Standard禁止在 Access-Control-Allow-Origin* 时共享带 credentials 的响应。

对公开 AWS landsat-pds bucket 的只读 OPTIONS 请求又独立确认了当前响应行为:字面 wildcard 规则返回 Access-Control-Allow-Origin: *、完整 GET, HEAD 方法列表,并且不返回 Access-Control-Allow-Credentials

复现分类

行为 结论 证据与决策
接受第二个 XML root REAL 修复前 Parser、进程内签名 Handler 与真实 TCP SigV4 均接受
拒绝 255 个 Unicode 字符的 ID REAL Parser/Validate、签名 Handler 与真实 boto3 请求复现了 SILO 的拒绝;接受 255 个 code point 依据 AWS 的字符表述与 SDK 模型,而不是 AWS 授权 PUT
接受小写 Method REAL Parser/Validate、签名 Handler 与真实 boto3 请求均复现
64 KiB 边界 NOT REAL 65,536 字节原本就通过,65,537 失败;保留回归测试
100-rule 边界 NOT REAL 恰好 100 原本就通过,101 失败;保留回归测试
第一条完全匹配规则 NOT REAL 现有匹配会越过 Header 受限的较早规则;保持该行为
Checksum EOF 绕过 CONDITIONAL 内存 Reader 可能让 checksum wrapper 看不到 EOF,真实 TCP 原本已拒绝坏摘要;仍消除 reader-dependent 行为
空与未知 XML member CONDITIONAL,按 strict 解决 AWS schema/error 文档支持拒绝,但没有可用的 AWS 授权 PUT 黑盒结果
wildcard Origin + credentials REAL 合并版 SILO 会对 * 回显 Origin 并开启 credentials;真实 AWS 与 Fetch 要求 * 且不带 credentials
Origin: null 被改写成 wildcard REAL,终审发现 内层 forwarding middleware 把明确命中的 null 改成 *,却保留 credentials;B3 现在标记专属响应,让旧 rewrite 跳过它
拒绝负 MaxAge CONDITIONAL,原有行为 因浏览器 max-age 非负而保留;没有可用的 AWS 授权 PUT 差分结果

目标与非目标

目标

  • 只接受一个 S3 CORS 文档元素,之后仅允许 XML Misc;
  • 执行文档定义的 64 KiB、100-rule、ID、Method、wildcard 与 MaxAge 契约;
  • 让 Content-MD5 与现代 SDK checksum 校验不依赖 Reader 分块方式;
  • 保留第一条完全匹配规则的语义;
  • 返回兼容 S3 且对浏览器安全的成功预检与实际请求 Header;
  • 通过 Parser、Validate、签名 Handler 与真实客户端测试让每个变化可审阅;
  • 保持 exported compatibility manifest 不变。

非目标

  • 修改 site-replication 交付、tombstone、heal 或状态计数;
  • 重构全局 CORS fallback 或 metadata 错误处理;
  • 增加 Console UI;
  • 引入通用 XML schema 框架;
  • 校验任意 XML 属性或强制唯一 namespace 写法;
  • 在缺少更强当前证据时强制跨规则 ID 唯一;
  • 重构无关 Lifecycle、Tagging、Policy、SSE、Quota 或 Versioning parser;
  • 在本工作项中 commit、push、tag、发布镜像、部署或宣称生产一致。

方案比较

A. 只修报告的三行

可以补 EOF 检查、改用 rune count、删除 Method 大写转换。这个方案很诱人,但并不完整:Unknown Element、重复 singleton、空数值、int32 overflow、通用 ? wildcard、依赖 Reader 的 checksum 校验,以及错误的 wildcard/preflight 响应仍然存在。

否决: 对已经明确要求核验的 B3 契约过窄。

B. 把输入规范化成 canonical 配置

服务端可以把 Method 转成大写、trim value、丢弃 unknown element,只保留第一个 XML root。对友好客户端很方便,却会把无效的已签名 wire 输入变成另一份有效配置,也会保留无法通过 GetBucketCors 干净 round-trip 的字节。

否决: S3 兼容要求校验,而不是静默修复。

C. 增加 B3 专用 strict wire 表示

解码到私有 XML wire struct,捕获直接文本、unknown element、重复 singleton 与 MaxAge presence。只有 XML shape 合法后才转换成现有公开 ConfigRule;语义检查继续留在 Validate 和 matcher。

采用: 在有证据处严格、元素顺序无关、namespace 宽容、局限于 CORS,且不增加 exported compatibility symbol。

D. 校验所有可能 XML 与 Header 细节

这会强制 namespace URI、拒绝所有 unknown attribute、按 RFC token 校验所有响应 Header,并强制跨规则 ID 唯一。

暂不采用: 这些约束缺少足够差分证据,可能制造无必要不兼容。

最终设计

1. XML wire parser

ParseBucketCorsConfig 解码到私有 wire-only 类型:

  • CORSConfiguration 是唯一 root;
  • root 与 rule 层拒绝非空白直接字符数据;
  • 拒绝 unknown root、rule 与 leaf 内嵌元素;
  • 每条规则最多一个 IDMaxAgeSeconds
  • 列表成员仍可重复,元素顺序不受限制;
  • MaxAge 文本必须能解析为有符号 32 位整数;
  • root 关闭后允许空白、Comment 与 Processing Instruction;拒绝另一个 root、文本、Directive 或 malformed token。

Namespace prefix 与标准 namespace 声明仍然可用,因为匹配使用 XML local name;本次不新增 unknown attribute 拒绝。现有 ConfigRule XML tag 为序列化兼容继续保留,但生产请求和 metadata 解析使用 ParseBucketCorsConfig

这个 parser 也用于加载已保存 bucket metadata。这是明确的发布前选择:PR #71 之后没有 SILO tag,因此不存在已经发布的桶级 CORS 配置群需要迁移。曾经保存 malformed CORS XML 的开发版本会让整个 bucket metadata record 无法加载,而不只是 CORS view;必须先替换或删除已保存的 CORS 文档。

2. 语义校验

Validate 执行:

  • 1 到 100 条规则;
  • ID 是有效 UTF-8,且不超过 255 个 Unicode code point;
  • 每条规则至少一个非空 AllowedOrigin 和一个 Method;
  • Method 必须精确等于 GETPUTHEADPOSTDELETE
  • AllowedOrigin 与 AllowedHeader 不得包含 ?,因为继承 matcher 会把它当 wildcard,而 S3 只定义 *
  • 每个 AllowedOrigin 与 AllowedHeader 最多一个 *
  • AllowedHeader 与 ExposeHeader 元素非空;
  • MaxAgeSeconds 位于 0 到 2^31-1

空 ID 继续允许,因为 ID 本身可选,当前 AWS 文档也没有发布非空约束;跨规则 ID 唯一仍不在本补丁范围。

3. 匹配

本路径用小型单 * matcher 替代通用 matcher:

没有 *  -> 精确匹配
一个 *  -> Prefix 与 Suffix 都必须匹配;* 可以匹配零字节

AllowedHeader 匹配继续忽略大小写,响应保留请求 Header 原始拼写。S3 PUT 路径会校验 canonical 保存值,因此 Method 匹配按大小写精确进行;合并基线中的直接 site-replication 与 heal 写入会绕过该校验,它们仍是独立集成要求,否则可能保存一条 B3 matcher 不会执行的方法。

MatchPreflight 会越过 Origin 与 Method 匹配、但拒绝某个请求 Header 的规则;最终选中的因此是满足三项文档条件的第一条规则。它还返回真正命中的 Origin 元素与 MaxAgeSeconds 是否出现,从而保留“缺失”与“显式 0”的差别。

4. 请求大小与 checksum

Handler 保留现有正 Content-Length 与 64 KiB guard。validateLengthAndChecksum 继续用共享 checker 包装 body,但 CORS handler 现在把包装后的 body 读到 EOF,不再在外面套另一个长度恰好的 LimitReader

这样无论底层 Reader 是在同一次调用中返回最后字节与 io.EOF,还是下一次调用才返回 EOF,checksum 校验都一致。格式合法但内容不匹配的 Content-MD5 或 full-header SDK checksum 返回 BadDigest;缺少 checksum material 继续返回现有 required-checksum 错误。共享 helper 可能把 malformed checksum syntax 归类为 missing,本小 body 路径也不实现 aws-chunked trailing-checksum 解码;这些 fidelity 缺口保持在 B3 之外。不新增第二套 checksum 实现。

现代 boto3 流量是实质兼容门,因为当前 botocore 对这个 required-checksum 操作发送的是 x-amz-sdk-checksum-algorithm: CRC32x-amz-checksum-crc32,而不是 Content-MD5。

5. 浏览器响应

命中的 Origin 元素决定响应:

命中元素 Access-Control-Allow-Origin Access-Control-Allow-Credentials
* * 不发送
null null true
精确 Origin 请求 Origin true
https://* 等 pattern 请求 Origin true

成功预检返回:

  • 命中规则的完整 AllowedMethods 列表;
  • 规则允许且请求实际提出的 Header;
  • 配置的 ExposeHeaders
  • MaxAgeSeconds,包括显式 0;
  • 现有成功预检的三个 Vary 维度。

实际请求保持现有 continue-through 行为;规则匹配时增加 Origin、credentials、Expose 与 Vary: Origin Header。请求 context marker 会阻止内层旧 forwarding middleware 把明确允许的 null Origin 改写成 *;没有 marker 的全局响应继续保留历史 workaround。由于 sandbox document 与 file:// Origin 共享 null,只有在确实要允许所有这些上下文携带 credentials 时才应配置它。

AllowedOrigin 元素按文档顺序求值。如果同一规则同时包含具体 Origin 与 *,而具体 Origin 需要保留反射 Origin + credentials 语义,应把具体 Origin 放在前面。

拒绝预检在 B3 中继续返回现有空 body 403。生成完整 AWS AccessForbidden XML,以及调整拒绝响应缓存/audit 行为,需要独立 wire 决策,不能偷偷混入 parser 加固。

实现映射

区域 文件 职责
Parser 与 Validate internal/bucket/cors/cors.go 私有 wire struct、严格 trailing token、rune/enum/wildcard/MaxAge 校验、匹配
Parser 测试 internal/bucket/cors/cors_test.gocors_adversarial_test.go Root、XML Misc、unknown/nested/duplicate、边界与匹配
PUT Handler cmd/bucket-cors-handlers.go 大小/checksum guard、EOF 消费、S3 error mapping
签名 Handler 测试 cmd/bucket-cors-adversarial_test.go 三个报告用例、64 KiB、100 rules、MD5/CRC32 正反例
浏览器响应 cmd/api-router.gocmd/generic-handlers.go 命中 Origin 语义、null marker、完整 Method、Expose、显式 MaxAge 0
响应测试 cmd/bucket-cors-middleware_test.go 精确/pattern/wildcard/null Origin、第一条完全匹配规则、Header、Method、Expose、MaxAge、credentials

任何 site-replication 源文件都不属于本实现边界。

测试与证据矩阵

层次 必须证据
Parser 拒绝第二 root/文本/悬空关闭;接受 trailing 空白/Comment/PI;拒绝 unknown/nested/duplicate
Validate 255 个 Unicode 字符接受、256 拒绝;拒绝小写与不支持 Method;wildcard 与空值用例
边界 恰好 64 KiB 与 100 rules 接受;多一个字节/规则拒绝;MaxAge 缺失/0/负数/int32 overflow
签名 Handler 三个报告失败的原始 SigV4 PUT;缺失/错误 MD5;有效/错误 SDK CRC32
Middleware 第一条完全匹配规则;wildcard、pattern 与 null credentials;无 marker 的旧 null rewrite;完整 Method;请求 Header;Expose;显式 MaxAge 0
定向 race race detector 下的 CORS package 与 CORS Handler/Middleware 测试
完整本地门 无 tag 与 kqueue,dev 完整 cmd;build;vet;固定 lint;generated/rebrand;diff check
真实客户端 minio-go v7.3.1 PUT/GET/preflight/DELETE;boto3/botocore CRC32 PUT/GET/preflight/DELETE 与对抗拒绝
外部行为 对公开 AWS bucket 的只读 OPTIONS,确认 wildcard、Methods、credentials 与 Vary

对抗评审裁决

Claude Code Opus 5 以 max effort 依次评审证据、实现,以及这份双语设计与最终代码。较早的实现评审结论为 GO;发布前终审结论为 GO WITH FIXES,没有 P0/P1,共有五项 P2 finding。纳入接受的代码与文档修改后,同一会话给出 GO,没有 P0–P2 finding。

其非阻断意见经过独立裁决:

  • 唯一行为 P2 已接受:实际请求中明确允许的 Origin: null 现在能穿过内层旧 forwarding middleware;
  • metadata load 影响范围与 replication 校验例外现在已精确说明;
  • 返回内部 MaxAge pointer 仅供读取,且选中 Rule 原本就是内部 pointer;没有新增写入;
  • 保留 BadDigest,因为当前 AWS S3 错误参考明确把它用于 Content-MD5 或 checksum 不匹配;
  • malformed checksum syntax、trailing-checksum 解码、no-match Vary、完整 AccessForbidden XML 与外层 Middleware audit 行为被记录,但保持在 B3 之外;
  • 不启用非 UTF-8 XML declaration,因为 S3 请求语法是 UTF-8,当前 SDK 也生成 UTF-8;
  • Method 空白保持无效,Integer 空白继续接受,符合不同 XML lexical domain;
  • 在缺少 AWS 差分证据时,延后用 RFC token 校验每个 ExposeHeader。

兼容性与上线

现有用法 影响
Typed minio-go 或 boto3 CORS 合法配置继续 round-trip;现代 CRC32 请求得到校验
原始合法 XML 在相同大小与规则上限内继续接受
小写 Method 不再规范化,直接拒绝
255 个非 ASCII ID 字符 现在接受;超过 255 拒绝
第二 root、unknown element、重复 singleton、空/overflow MaxAge 作为 malformed XML 拒绝
字面 wildcard Origin 现在返回 * 且不带 credentials
Pattern Origin 继续反射具体请求 Origin 并带 credentials
含 malformed CORS XML 的旧开发 metadata 整个 bucket metadata record 可能无法加载,直到替换或删除 CORS XML
Site replication B3 不改代码;其收敛修复与测试保持独立

严格化发生在任何带桶级 CORS 的 SILO tag 之前,这就是兼容窗口。一旦发布,该 wire 契约即成为稳定接口;今后放宽或收紧都必须另有差分证据。

验证结果与剩余门槛

最终本地实现通过:

  • 定向 Parser、Validate、签名 Handler 与 Middleware 测试;
  • internal/bucket/cors 与 CORS cmd 路径的定向 race;
  • go test ./cmd -count=1 与完整 kqueue,dev cmd lane;
  • go build ./...go vet ./...
  • golangci-lint 2.13.1,零 issue;
  • generated、compatibility/rebrand、entrypoint 与 diff check;
  • 对新构建本地 server 执行真实 boto3/botocore 1.43.58 与 minio-go v7.3.1 回归;
  • Claude Code Opus 5 max effort 最终复审。

这些结果只建立 B3 IMPLEMENTATION GO。整体发布仍被独立 replication 工作、服务端/文档 commit 与 push、PR 与 merged-main CI、release artifact、部署和生产验证分别阻断。

结论

最终 B3 设计把 Bucket CORS 当作已签名 S3 wire 契约,而不是宽容的配置文件。它在持久化前拒绝 malformed 或非 canonical 输入,按字符统计 ID,校验现代 SDK checksum,保留文档定义的第一条完全匹配规则,并生成对浏览器安全的 S3 响应。

补丁只涉及 CORS Parser、Validate、Handler、Matcher、响应代码与测试。不增加新服务、schema、依赖、exported compatibility symbol 或 site-replication 重构。这是协议和真实证据支持的最小完整方案。