跳转到主要内容

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 - 鉴权前不做 I/O,Header 不授予权限

本文记录 SILO 本地提交 938603458 中的 CORS 热路径与复制请求信任边界修复。

截至 2026-09-02 的状态: 实现、定向与 race 测试、完整服务端 package 套件、对象锁测试、vet、build、两轮 Fable 5 设计评审、多轮 Opus 5 对抗验收,以及真实本地 TLS 双站复制均已完成。候选实现已推送为 pgsty/silo#101;远端 CI、merge、tag、软件包、镜像、部署与生产验证仍是独立门槛。
范围: S3 handler 之前与内部的 HTTP 请求解释。不修改 S3 wire field、对象格式、bucket metadata 格式、复制协议、加密格式或客户端命令。
安全属性: CORS 预鉴权处理不执行对象层 I/O;header 本身永远不授予复制语义;SSE-C 密文路径与 replica-only metadata 必须同时通过身份认证与对应复制权限检查。

太长不看(TL;DR)

两个问题表面上互不相干:

  1. Origin header 会让最外层 CORS middleware 把 URL 第一段当成桶,在鉴权前同步加载它的 metadata;
  2. X-Minio-Source-Replication-Request 只要存在,下游代码就会把请求当作内部复制。

它们共享同一个设计错误:不可信的请求形态在越过授权边界之前,就获得了昂贵或特权化的内部含义。

修复建立了两个不变量:

鉴权之前:只做廉价解析,绝不加载 bucket metadata
鉴权之后:只计算一次信任结论,下游只消费这个结论

对于 CORS,外层 middleware 只读已经 resident 的内存 metadata。对于复制,handler 先认证原始签名请求,再检查对应复制权限,最后把私有信任结论写入 request context。不可信内部 header 只在验签完成后剥离。真正的权威是 context 中的决定,而不是“是否成功删掉了某个 header”;option builder、加密路径、对象锁、事件与 metadata 持久化都只读取这一决定。

故障 A:CORS 预鉴权资源放大

corsHandler 包在完整服务器 router 的最外层。任何携带 Origin 的请求都会在 S3 鉴权、请求有效性检查与普通 API 限流之前到达这里。

Per-bucket CORS 最初调用普通 bucket metadata getter:

携带 Origin 的请求
  -> URL 第一段变成“桶名”
  -> GetCorsConfig
  -> GetConfig cache miss
  -> 读取 .metadata.bin
  -> 探测十个 legacy config 路径
  -> 缓存一条默认 BucketMetadata

.metadata.bin 不存在时,loader 会按兼容要求继续寻找 legacy 配置;什么都没找到后,它返回一条合法的空 metadata,而不是 NoSuchBucket。通用 getter 随后把这条记录写入 metadataMap

未认证客户端只需不断变化看似合理的名字,就能让每个新值产生两种代价:

  • 在普通 limiter 之前反复执行纠删码/对象 metadata 读取;
  • 增长内存中的 bucket metadata map。

只校验桶名不能修复:攻击者可以生成近乎无限的、语法合法但不存在的桶名。分布式部署会在 15 分钟 metadata refresh 中最终清理 stale entry;单节点不会启动这条 refresh loop,因此合成条目会一直存在到重启。

故障 B:marker header 变成了权威

SILO 及其 MinIO-compatible 客户端使用内部 header,在复制期间保留源状态。其中最重要的 marker 是:

X-Minio-Source-Replication-Request: true

修复前,多条路径把 header 存在,或未经授权的原始字符串值,当成复制请求证明。影响远不止 metadata extraction:

  • SSE-C 对象的 GET 可以设置 NoDecryption,让只有普通读取权限、没有 customer key 的调用者取得密文;
  • source ETag 与 modification time 可以覆盖服务器生成值;
  • source tagging、retention、legal-hold timestamp 可以进入 last-writer-wins 比较;
  • 仅凭 marker 就可以接受已经过去的 object-lock retention date;
  • delete marker 的 identity 与 modification time 可以由调用者提供;
  • 成功对象事件可以被抑制;
  • multipart completion 可以注入 actual size 与加密 checksum metadata;
  • 普通 PUT、COPY 或 POST-policy metadata extraction 可以持久化 X-Amz-Replication-Status

此前的 CVE-2026-34204 修复 已经正确阻止普通 PUT/COPY 导入可能让对象不可读的 replication SSE metadata。但它尚未为 marker、source field、event state、object-lock exception 与 multipart completion metadata 的所有消费者提供同一个权威。

最终设计

一个精确 marker,两级信任

只有 marker 恰好出现一次、且值恰好为小写 true 时,才承认其形态。重复值、大小写变化与任何其他值都不可信。

Handler 随后派生两个相关结论:

决定 必须满足 可以启用的语义
trusted 原始请求完成认证;非匿名主体;精确 marker;目标资源上具有 s3:ReplicateObjects3:ReplicateDelete source ETag/MTime 与 source timestamp;actual size 与加密 checksum 传递;event 与重复复制抑制;复制删除的 pool/version pinning
replicaTrusted trusted,再加原始请求状态为 REPLICA,或 multipart upload 已保存 REPLICA 状态 replica status 持久化;replication SSE sealed-key 导入;SSE-C 密文/NoDecryption 路径;replica-only 对象锁行为

必须拆成两级,因为真实 wire 并不会在每个合法复制请求上重复 X-Amz-Replication-Status: REPLICA

接收端遵守以下矩阵:

输入形态 结果
没有 marker 普通 S3 操作
marker 但没有复制权限 忽略内部字段,按普通语义继续执行
REPLICA 但没有复制权限 403 AccessDenied
精确 marker + 复制权限,没有 REPLICA 只有 trusted
精确 marker + 复制权限 + REPLICA 同时获得 trustedreplicaTrusted

未授权 REPLICA 必须明确返回 403,不能静默降级成一个会再次被复制的新普通对象。

先认证原始请求,再做清洗

SigV4 会签名请求 header。如果在鉴权前删除内部 header,canonical request 会发生变化,原本有效的签名将变成 SignatureDoesNotMatch

因此顺序是硬约束:

原始请求
  -> 既有 signature/authentication path
  -> 普通 S3 action 授权
  -> replication action 授权
  -> 计算 trusted / replicaTrusted
  -> 把决定写入 request context
  -> clone 并剥离不可信内部字段
  -> option 解析、加密、对象锁、存储、事件

Audit logger 仍保留原始请求。Effective request clone 保留公开 S3、SSE、checksum、object-lock、copy-source、proxy 与 replication validity header;只剥离内部 source/replication control,包括 source ETag/MTime/delete-marker/timestamp、replication SSE state、actual object size、加密 checksum 传递,以及作为内部请求控制使用的 X-Amz-Replication-Status

Header stripping 只是纵深防御。所有特权消费者都读取私有 context 决定或显式 Boolean,不会再靠检查 clone 来猜测信任。

Replica status 不是普通用户 metadata

X-Amz-Replication-Status 是一个 S3 response header,MinIO-compatible server 同时把它用作内部请求控制。它不再属于通用 supported-request-metadata 列表。

普通 PUT、COPY、multipart initiation、Snowball/PAX extraction 与 POST policy 不能仅凭提交字段就持久化它。接收端只在 replicaTrusted 分支显式写入 REPLICA

这同时关闭了一条隐蔽的 POST-policy 路径:form field 曾经可以写入 REPLICA,让对象绕开正常复制调度,而 POST principal 根本没有复制权限。

对象锁接收显式决定

Object-lock parser 过去只要看到原始 marker header,就会接受已经过去的 retention date。现在它从 replicaTrusted 接收显式的 allowPastRetainDate

外围 handler 在判断 replica 能否覆盖既有 compliance/legal-hold version 时也使用同一个决定。可复用的 object-lock package 不再依赖内部 HTTP header。

真实复制 wire 矩阵

设计核对的是服务器 go.mod 实际选择的 silo-go v7.3.1 emitter,而不是注释或上游文档中的假设。

操作 Marker 本请求携带 REPLICA 接收端决定
普通对象复制 PutObject replicaTrusted
复制 NewMultipartUpload 保存可信 MPU replica provenance
复制 PutObjectPart trusted;只有已存 MPU 状态为 REPLICA 才获得 replicaTrusted
复制 CompleteMultipartUpload trusted;保留 source ETag/MTime、actual size 与加密 checksum
CopyObject metadata replication replicaTrusted
复制 RemoveObject 具有 s3:ReplicateDeletereplicaTrusted
Batch replication PUT/Complete trusted;目标凭据必须拥有 s3:ReplicateObject
Proxy/readiness/validity probe 独立 probe header marker 不授予权限 保持 probe 行为;本修复不会剥离这些 header

s3:ReplicateDelete 是信任闸门,但不是 receiver 的唯一权限。为了兼容已经部署的 目标端策略,可信复制删除仍要求 s3:DeleteObject;显式 Deny s3:DeleteObjectVersion 仍会阻止指定版本的清理。普通客户端不走这条兼容路径: 显式 UUID 或 versionId=null 必须获得 s3:DeleteObjectVersion 的 Allow。

如果要求所有可信请求都带 REPLICA,PutPart、multipart completion 与 batch replication 会立即回归;如果相信所有 marker,则漏洞会原样重现。已保存的 multipart provenance 在加密 raw part 上连接了这两个要求。

CORS resident-only 状态机

最外层 CORS middleware 必须比即将进入的请求更便宜。它现在调用专用 resident-only getter,只拿一次读锁并读取内存状态。

Bucket metadata 状态 CORS 结果 对象层工作
resident,per-bucket CORS 合法 应用桶级规则
resident,没有 CORS 文档 使用 global CORS fallback
resident,持久化 CORS 非法 fail closed;继续处理请求但不加 CORS header,并只记一次日志
subsystem 尚未初始化 fail closed
已知 metadata load failure fail closed
内部 .minio.sys namespace fail closed
reserved 或非法桶形路径 global fallback
已初始化,除此之外的未知名字 global fallback

loadFailed 只会从启动或 refresh 得到的 disk-derived bucket list 中写入,客户端路径无法增长它。Metadata 成功加载、Set、bucket removal、stale reconciliation 与 subsystem reset 都会清理相应状态。

冷缓存残余边界

仍有一个已接受的边缘:如果某节点漏掉 peer metadata-load 通知,一个真实桶可能同时不在 metadataMaploadFailed。在下一轮 bucket refresh 发现并加载它之前,该节点会把这个名字当作 unknown,使用 global CORS。

CORS 是浏览器响应策略,不是授权机制;普通 S3 authentication 与 bucket policy 仍然生效。但如果运维明确依赖 restrictive per-bucket CORS document,就应该知道这段短暂放宽。后续可以在 refresh 尝试加载之前,把 disk list 中存在但不 resident 的桶标记为 load-failed;这样不增加请求同步 I/O,也能继续 fail closed。

被否决的方案

方案 为什么否决
在旧 CORS getter 前校验桶名 合法但不存在的名字仍提供无限攻击空间,且继续触发预鉴权 I/O
加载 CORS 前调用 GetBucketInfo 只是把十一轮 metadata 读取换成每个攻击者名字至少一次未限流 backend operation
给每个 negative result 做 TTL cache 只限制持续时间,不限制攻击者 cardinality 与第一次 I/O 放大
鉴权前剥离 replication header 破坏 SigV4 canonical request 验证
拒绝任何携带内部 marker 的请求 把过去被忽略的多余 header 扩大成普遍客户端失败,并破坏合法 marker-only 复制调用
要求所有可信调用都携带 REPLICA 破坏复制 PutPart、CompleteMultipartUpload 与 batch replication wire
每个 handler 各自重新检查 raw header 重建不一致信任规则,未来新增消费者也极易漏掉
只在 ObjectOptions 放 Boolean,event/object lock 仍看 header 产生两个可能互相矛盾的权威,原漏洞类别仍然存在

实现边界

最终改动按层组织:

  1. 一个小型 request-trust 模块定义精确 marker 解析、复制授权、私有 context state 与鉴权后的 effective request;
  2. object option builder 只有在调用方提供可信状态时才解析 source field;
  3. DecryptObjectInfo、event request parameter、multipart completion、delete option 与 object lock 消费同一个决定;
  4. handler 在既有 authentication path 之后立即计算信任;
  5. multipart part 把当前请求信任与已保存 MPU replica provenance 结合;
  6. 通用 metadata extraction 不再接受 replica status;
  7. CORS middleware 使用独立的 resident-only accessor,永远不调用 load-on-miss getter。

对象层 API 无需再猜测 HTTP trust。内部程序化调用者直接构造的 ObjectOptions{ReplicationRequest: true} 不受影响。

验证与对抗审查

回归覆盖包括:

  • 数百个不同的合法缺失桶名,actual/preflight 两类 CORS 请求,metadata read 为零且 map 不增长;
  • Console、reserved、invalid、startup、内部 namespace、非法持久化 CORS 与已知 load failure;
  • 最小权限 SSE-C GET、HEAD、GetObjectAttributes,对正确、缺失、大小写错误与未授权 marker 的处理;
  • marker-only batch 风格 PUT 只有在具备 s3:ReplicateObject 时才保留 source ETag/MTime;
  • 未授权 REPLICA PUT/DELETE 返回 403
  • POST policy 无法伪造 replica status;
  • object-lock past-date 在有无 replica trust 时的差异;
  • marker-only CopyObject 携带 SSE-C source header 时复制明文而不是密文;
  • 普通 SSE-C MPU 上的伪 marker 失败,不会写入 raw byte;
  • 一条真实的进程内 SSE-C multipart 复制链:加密源、raw ciphertext part、可信 replica initiation、marker-only PutPart/Complete,以及用原密钥精确恢复明文。

最终本地 tree 通过定向与 race 测试、完整 cmd suite、对象锁测试、vet、build 与 diff check。

另一轮黑盒测试用候选二进制启动两个 TLS-enabled SILO 实例并启用真实 site replication,验证:

  • SSE-C 4 KiB 对象;
  • SSE-C 12 MiB、三个 part 的 multipart 对象;
  • SSE-C CopyObject;
  • replicated delete marker。

源/目标 ETag、size、version ID、SSE-C key MD5、解密后 SHA-256 与 delete-marker version ID 均一致,目标报告 REPLICA

前两轮 Fable 5 评审先纠正 marker-only batch 与 multipart 调用的信任模型,再审计实现。最终独立 Claude Code Opus 5 给出 GO,没有 P0/P1,并独立重跑 build、vet、race、object-lock 与完整 cmd 测试。

兼容性与运维影响

  • 普通客户端: 请求无需改变;不可信内部 header 现在会被忽略,而不是获得内部语义。
  • 未授权 replica 声明: 携带 X-Amz-Replication-Status: REPLICA 的请求现在统一返回 403;过去部分 multipart 子路径没有这条一致检查。
  • Batch replication: 目标凭据必须包含 s3:ReplicateObject,参见 batch replication requirements。缺失权限时,接收端会把 marker-only write 当作普通写入,不保留 source ETag/MTime。
  • SSE-C: 普通读取仍需要 customer key;授权 replica read 可以使用保留加密字节所需的 raw ciphertext path。
  • 事件: 只有可信复制才抑制 replica creation/access event;伪 marker 不再让事件静默消失。
  • 对象锁: replica exception 来自权限决定,不再来自 header。
  • 性能: CORS 移除了预鉴权 backend work。可信写入增加的是复制契约本来就要求的 policy check,不增加对象数据 pass。
  • 滚动升级: wire 与 storage format 不变。新 receiver 执行信任边界;旧 receiver 在升级前仍保留旧 header 漏洞。滚动窗口中节点的 per-bucket CORS 行为可能不同。
  • 回滚: 修复版本写入的数据仍可被旧版本读取,但 rollback 会重新打开两个信任缺陷并恢复预鉴权 metadata load。

残余风险与后续

  • Refresh 加载之前,把 disk list 中存在但不 resident 的桶标记为 fail-closed,进一步缩小上述 CORS 冷缓存窗口。
  • 当 marker-bearing request 缺少复制权限时记录限频诊断;安全的 ordinary fallback 否则容易被误诊为 ETag/MTime 不一致。
  • Replication validity probe 保留上游继承的权限报告语义,应作为独立议题审计,而不是在本修复中静默改变。
  • 本次覆盖已命名的 source/replication header。未来任何内部控制都仍需回答同一个问题:哪一个认证后的决定允许这个客户端值获得内部含义?

结论

看起来像内部字段的 header 仍然是客户端输入;看起来像桶名的 URL 段也仍然是攻击者输入。耐久修复是阻止两者意外成为权威:

鉴权之前不做 backend work;鉴权之后只派生一次 trust,并把决定而不是声明传给下游。

这条规则并不只属于 CORS 或 replication。当廉价的公开请求语法与昂贵或特权化的内部状态相遇时,未来 SILO handler 都应该保持这条边界。

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

本文完整记录 上游 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 前只鉴权一次。

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

6 - 配置环境文件不是 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 保障。

7 - 两把 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。真正耐久的修复不是新增加密机制,而是建立明确上下文边界,然后只做一次解密、两次如实响应投影。

8 - 为什么 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、已打包、已构建镜像、已部署和已在生产验证严格区分。

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

文件夹流式 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 撒谎所必需的。下一阶段维护迭代应先恢复最小而诚实的契约:已知总量才显示百分比,未知总量就保持未知。

10 - 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 仍是独立门槛。

11 - 只读 Checksum 审计与可靠的 CLI 输出契约

本文是 MCLI 只读 checksum 校验流程,以及发布审查中发现的 non-TTY 输出缺陷 pgsty/mc#5 的设计与实现记录。

状态: 已随 mcli 20260901 发布。命令经 pull request #8#13 合入 main,在托管 CI 中针对真实 SILO 服务器验证, pgsty/mc#5 已关闭。把客户端打包进 Server 镜像仍是独立的后续门禁。
归属: pgsty/mc
跟踪: pgsty/mc#5
安全边界: 本命令只读校验,不负责修复。

太长不看(TL;DR)

历史 CopyObject 实现可能在转换后的存储字节上计算 additional checksum,而不是在 S3 返回给客户端的逻辑对象字节上计算。mcli checksum verify 会筛选对象,独立地 把逻辑对象流送入已记录的算法,并把每个候选分类为 MATCHMISMATCHNO_CHECKSUMWOULD_VERIFY(dry run)、十种 UNKNOWN_* 分类之一,或三种 SKIPPED_* 之一。

首版实现在终端中工作正常,但 stdout 被重定向时完全不输出。MCLI 为了在非终端 环境中禁用进度 UI,会自动把执行状态标记为 quiet;新命令错误地把这项内部状态 理解成了“用户要求隐藏审计结果”。修复将语义输出与进度抑制分离,同时不改变 全局 quiet 行为,也不会在 CI 中重新打开进度条。

命令与范围

mcli checksum verify ALIAS/BUCKET/OBJECT
mcli checksum verify --recursive ALIAS/BUCKET[/PREFIX]
mcli checksum verify --manifest candidates.jsonl ALIAS

V1 支持标记为 FULL_OBJECT 的 CRC32、CRC32C、CRC64NVME、SHA1 与 SHA256。 候选可以是单个对象、精确 VersionID、前缀下的当前对象、所有版本,或 JSON Lines manifest 给出的精确条目。它还支持 SSE-C key 映射、时间与大小过滤、dry-run 成本 估计、有界 worker、下载限速、JSON 输出和可选的 JSON Lines report。

V1 不验证 COMPOSITE checksum,不从 ETag 推断类型,不读取 xl.meta,不能确定 历史 writer,也不会修复 metadata。端点必须随 checksum 一并报告其类型 (x-amz-checksum-type);对不报告类型的端点,每个带 checksum 的对象都会被归为 UNKNOWN_CHECKSUM_TYPE,而不是靠猜。

只读数据路径

对每个候选对象,MCLI:

  1. 使用 checksum mode 执行 HEAD,保留所有支持的 checksum 与 ChecksumType
  2. 对不支持或含糊的状态返回 UNKNOWN_*,绝不猜测;
  3. GET 返回的逻辑字节流送入有界 hasher,不把对象体写入磁盘;
  4. 对固定版本使用 VersionID;对可变的未版本化/null 对象使用 If-Match,并在 读取后再次 HEAD
  5. 将独立计算结果与已存值比较。

S3 边界只允许 LIST、HEAD 与 GET;如果 mock endpoint 收到写方法,测试必须失败。

结果与退出码契约

每个候选只产生一个稳定结果:

结果 含义
MATCH 所有支持的已存 checksum 都匹配逻辑对象字节
MISMATCH 至少一个已存 checksum 不同
NO_CHECKSUM 没有 additional checksum,因此不读取对象体
WOULD_VERIFY dry-run 找到可验证的 full-object checksum
UNKNOWN_* MCLI 无法给出可靠判断
SKIPPED_* 过滤器主动排除了对象

summary 携带 objectsverified 计数、每种结果状态的计数以及 incompleteverified 等于 MATCHMISMATCH:只有这两种结果真正把对象体流过了哈希器。 枚举了很多对象却一个都没核验的运行,会如实地显示出来。

--fail-on 支持 mismatchunknownno-checksumanynone。默认 any 会在 mismatch 或校验不完整时返回 exit 1。no-checksum 在任一对象没有 checksum 或根本没有核验任何对象时返回 exit 1,因此空前缀或过期的 manifest 不可能冒充 一次干净的审计。dry-run 不应用 --fail-on。参数、认证、枚举与 report 写入失败属于命令失败,而不是对象分类。

其中,SKIPPED_TOO_LARGE 会让默认 any 返回 exit 1,因为大小上限使审计不完整; 时间过滤与 delete-marker skip 本身不会触发失败。

输出与自动化契约

对象记录和最终 summary 都是命令的语义输出:

  • 除非调用方显式设置 --quiet-qMC_QUIET=true,TTY 与 non-TTY stdout 都必须收到全部对象记录和最终 summary。
  • non-TTY --json 每行输出一个紧凑 JSON 值;TTY JSON 保留 MCLI 既有的美化格式。
  • 全局参数在 app、checksumverify 三层位置都必须生效。
  • --report 独立于 stdout;即使显式 quiet 让 stdout 静默,它仍会写入对象记录和 最终 summary 的 JSON Lines。
  • 输出通道不会改变 --fail-on 的判定。

这一区分之所以必要,是因为 MCLI 历史上的 globalQuiet 有两个来源:用户显式的 quiet 参数,以及拿不到终端尺寸时自动启用、用于关闭进度 UI 的 non-TTY 状态。 直接修改这个全局量,可能让 copy、get、put、mirror 等命令在 CI 中重新输出进度条。

最终修复只作用于 checksum 命令。它沿完整 CLI context 链查找显式 quiet/JSON 参数,因为 CLI 库的 GlobalBool 会停在最近的祖先 flag set;同时在 checksum action 内部恢复 JSON Lines,因为嵌套 Before hook 可能在 app-level --json 之后重置它。 其他命令的进度与输出行为均不改变。

Report、秘密与运行成本

Report 文件以 0600 新建,目标必须不存在;它只包含 metadata 与结果,不包含对象体 或 SSE-C key。Manifest 同样只保存 bucket、key 与可选 VersionID。

校验会下载每个受支持对象的完整逻辑内容。运维人员应使用 --dry-run--max-size、 时间过滤、--max-workers 与全局下载限速控制成本和负载。NO_CHECKSUMUNKNOWN_* 数量必须显式展示,二者都不能被包装成“校验成功”。

Mismatch 能证明什么

Mismatch 只能证明:校验时 endpoint 返回的 additional checksum,不能描述同一时刻 返回的逻辑对象字节。它不能单独证明对象一定由某个历史压缩缺陷生成,也不是与外部 真值的比较。

不要原地覆盖 checksum metadata。应先只读审计和分类。对已经确认且确有业务影响的 mismatch,优先写入新 key 或新 version,验证替代对象后再显式切换消费者; UNKNOWN_* 对象不得进入自动修复。

验证记录与发布边界

本地验收矩阵覆盖 TTY human/JSON、non-TTY pipe、普通文件重定向、app/parent/leaf 三层 JSON 与 quiet、环境变量 quiet、quiet 下的 report、report 写失败,以及 MISMATCH/UNKNOWN 退出码。真实本地 S3 还覆盖了历史 MATCHMISMATCH 与不支持 的 composite 对象。

该命令已随 mcli 20260901main 顶端的签名 tag 发布,功能套件 —— 包括针对真实 SILO 服务器的一次 checksum 校验 —— 对该提交 全部通过,pgsty/mc#5 已关闭。Server 内置 客户端与生产审计仍是之后需要独立证明的门禁。

12 - 可选校验和,强制失败:修复 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 契约。

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

13 - 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 精确响应之前,贸然修改只会用一个未经验证的假设替换另一个。

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

14 - SILO 应该修复 ListMultipartUploads 吗?Issue #79 兼容性设计评审

这是 SILO Issue #79 的问题说明、设计分析与决策记录。

截至 2026-08-30 的状态: 已确认的兼容性缺陷,目前只有设计提案。本文不代表服务端实现、发布产物、部署或生产验证已经完成。
建议: 把它作为有计划的 P1 兼容性项目修复,而不是给缓存打一个小补丁。如果项目决定不实现完整兼容,也应该明确拒绝无法正确处理的请求,不能返回一个看似成功、实际上没有遵守参数的响应。
范围: 通用 S3 存储桶的 ListMultipartUploads。本文不增加目录存储桶语义,也不处理 AbortIncompleteMultipartUpload 生命周期动作。
责任仓库: pgsty/silo,即 SILO 服务端仓库。
发布边界: 设计、规范取证、原型、实现、源码 QA、提交、发布产物、文档、部署与线上验证是彼此独立的关卡。

先用最简单的话说明问题

假设现在有四个大文件还没传完:

tables/a/part-1
tables/a/part-2
tables/b/part-1
other/file

S3 客户端问:“把 tables/ 下面所有没传完的上传列出来。”AWS S3 会返回前三个。SILO 目前却把 tables/ 当成一个完整对象名,只查找是否存在一个名字恰好等于 tables/ 的上传,于是返回空列表。

如果客户端去掉 prefix,要求列出整个存储桶里的所有未完成上传,SILO 又会走另一条捷径:读取当前进程里的内存缓存。创建这些上传的节点也许能看到四个结果,但缓存重启就消失,不同节点之间也不是权威一致的。上传数据仍然在磁盘上,只是列表说错了。

所以这不是“少支持了一个查询参数”那么简单。清理工具可能收到 200 OK,认定不存在未完成上传并报告成功,而磁盘上其实还有这些上传。服务端没有丢失已经提交的对象,但它向调用者展示了一个错误的未完成工作视图。

决策摘要

只要 SILO 仍希望宣称具有实用的 S3 兼容性,就应该修复这个行为。

修复的理由很明确:当前接口静默返回虚假的成功结果;重启或切换节点会改变答案;标准的 prefix 清理与分页流程无法工作。默认 24 小时的 stale-upload 清理器可以限制默认配置下的空间累积,却不能让 API 响应变得真实。

但这也不是一个小改动。现有上传目录只保存了 bucket 与 object key 的单向哈希,原始对象键没有写入 xl.meta。正确实现必须从新上传开始持久化这份身份信息,按纠删码 quorum 规则发现候选项,在所有 pool/set 之上统一执行 S3 语义,并处理滚动升级期间的 legacy 上传。

因此建议的方向是:

  1. 把 bucket 与 object key 写进上传现有的 quorum 元数据;
  2. 以有界的按需扫描作为持久化正确性路径;
  3. 缓存只能是可重建的优化,不能是真相来源;
  4. 只有所有 writer 都完成升级、无键 legacy 上传全部排空后,才能启用严格 S3 行为;
  5. 只有测量证明扫描达不到产品批准的服务目标时,才考虑持久化二级索引。

问题描述与证据来源

本文的问题判断与方案建立在五类证据之上。

S3 正式契约

AWS ListMultipartUploads API 定义了通用存储桶的公开契约:

  • prefix 选择所有 key 以该字符串开头的上传;
  • delimiter 把匹配的 key 汇总成 CommonPrefixes
  • max-uploads 限制单页数量,文档规定上限为 1,000;
  • key-markerupload-id-marker 用来继续被截断的列表;
  • 没有 key-marker 时必须忽略 upload-id-marker
  • 结果先按对象键排序,同一对象键的上传再按发起时间升序排列。

AWS 文档并没有无歧义地覆盖所有实现边角。同一时间戳、非法或越界的 max-uploads、URL 编码、marker 边界,以及 CommonPrefixes 如何占用分页名额,都应该在实现前对 AWS 做一次取证,并把结果保存成固定 fixture。

Issue 的原始报告

Issue #79 提供了一个自包含、自己签名请求的复现程序,在 pgsty/silo:latest 上运行,并与 AWS、RustFS、SeaweedFS 和 Garage 比较。它的四个核心观察都可以复现:

请求 应有行为 SILO 实际行为
prefix=t/ 返回三个以 t/ 开头的 key 一个上传也不返回
max-uploads=1 返回一项和续页 marker 返回缓存中的全部上传
key-marker=t/a_b/p2 从这个 key 之后继续 返回缓存中的全部上传
prefix=t/&delimiter=/ 返回汇总后的 CommonPrefixes upload 与 prefix 都不返回

Issue 正确识别了兼容性失败,但“max-uploads 总是被忽略、IsTruncated 永远为 false”的表述覆盖面过大。这些结论在复现程序经过的无 prefix 缓存路径成立;精确对象路径能够处理 max-uploadsupload-id-marker,也能够设置 IsTruncated

上游设计历史

这个行为是继承来的,并非 SILO 独自发明:

  • MinIO 在 2017 年通过 PR #5248 有意删除纠删码后端的 prefix listing,理由是“简化” multipart 支持;
  • MinIO 在 2024 年通过 PR #20407 增加了无 prefix multipart 缓存,主要为了满足 Alluxio 测试;
  • 2025 年报告相同 exact-key 行为的 MinIO Issue #20989 被以 working as intended 关闭;
  • SILO 当前的 S3 兼容性参考 已经记录“必须使用精确对象名”的差异,但在本文之前没有解释缓存、分页、marker、delimiter 与重启限制。

这些历史可以解释代码为什么是有意如此,却不能让接口符合 AWS 契约。

源码审查

当前源码存在两条互斥的 listing 路径:

erasureServerPools.ListMultipartUploads
  prefix == ""  -> 返回节点本地 mpCache 中的条目
  prefix != ""  -> 把 prefix 当成完整对象键做哈希
                    -> 只选择一个 set
                    -> 只列一个 sha256(bucket/object) 目录

关键位置包括:

  • cmd/erasure-server-pool.go:无 prefix 的 mpCache、逐 pool 拼接,以及 NewMultipartUpload 内部使用的精确对象查询;
  • cmd/erasure-multipart.go:精确对象 listing、上传目录构造、stale-upload 清理,以及新上传 xl.meta 的 quorum 写入;
  • cmd/erasure-sets.go:把传入的对象名哈希到单个 erasure set;
  • cmd/bucket-handlers.go:公开请求校验,其中包括 key-marker 不属于 prefix 时返回 501 NotImplemented 的保护;
  • cmd/object-api-multipart_test.go:虽然有大型期望结果表,但最后的断言只检查回显的标量,没有验证 uploads、prefixes、markers 或截断状态。

独立复现与对抗性审查

审查者用单节点服务和 SigV4 请求,在被审查的 SILO 源码上独立复现了 Issue 场景。额外探针确认:

  • 精确对象键能够给该对象自己的多个上传分页;
  • 当前精确键路径即使没有 key-marker 也会使用 upload-id-marker,与 AWS 不符;
  • 精确键被截断时,NextKeyMarker 仍为空;
  • 当前路径把 max-uploads=0 当成无限制;
  • 普通服务重启会让 bucket-wide 视图变空,而精确键查询仍能找到磁盘上的上传。

第二轮对抗性架构审查进一步挑战了存储、quorum、迁移、suspended pool、混合版本与性能假设。相关修正已经进入下文;本文不会把 AI 审查当作代码测试或 AWS 兼容性取证的替代品。

当前代码到底做了什么

空 prefix:易失的节点本地视图

没有 prefix 时,pool 层从 mpCache 返回该 bucket 的全部 MultipartInfo,并且只按发起时间排序。它不应用 max-uploadskey-markerupload-id-markerdelimiter,也不计算续页 marker 或 IsTruncated

进程启动时缓存为空。创建上传只填充处理该请求的节点。完成和中止会删除缓存项,部分路径还会通知 peer 删除,但创建没有等价的持久化集群广播,也没有启动重建。因此:

  • 重启可以让非空列表变成空列表;
  • 两个节点可以对同一存储桶返回不同答案;
  • 成功响应不能证明服务端已经枚举了持久化上传状态。

非空 prefix:精确对象查询

存在非空 prefix 时,这个字符串会像完整对象名一样进入对象哈希。系统选择一个 erasure set,然后读取由 sha256(bucket/object) 派生的目录。

这条路径可以枚举同一精确对象的多个 upload ID。它按发起时间排序,应用自己的 upload-id-marker,在 max-uploads 处停止并设置 IsTruncated。但它仍然没有实现词法 prefix 匹配、CommonPrefixes、通用 key-marker 语义或 NextKeyMarker

多 pool 让分页更加不正确

多 pool 部署收到非空请求时,pool 层会用相同上限分别调用每个活动 pool,然后直接拼接结果。它不会做全局有序归并,也不会重新计算分页边界和 next markers。请求 N 个结果时,可能从每个 pool 各取 N 个。

Listing 与其他公开 multipart 动词都会跳过 suspended pool。因此,留在 suspended 或 decommissioning pool 上的未完成上传不只是“没有列出来”,而是完全无法访问。这是相关的生命周期缺陷,但 listing 不能单独展示 PutObjectPartListPartsCompleteMultipartUploadAbortMultipartUpload 都无法操作的句柄。pool drain 或强制 abort 应当作为覆盖所有动词的独立设计。

为什么现有上传无法回填

Multipart 命名空间是平的:

.minio.sys/multipart/<sha256(bucket/object)>/<upload-id>/xl.meta

哈希是单向的,路径里没有原始 bucket 和 key。当前 multipart xl.meta 里也没有保存名称字段;传入的对象名只在 newFileInfo 构造过程中影响 erasure distribution。

因此,全目录扫描可以知道“这里有一个上传”,却无法知道它属于哪个 bucket 或 key。当前节点本地缓存也无法可靠修复这件事,因为它跨节点不完整,重启后还会消失。

这否决了一个很诱人的“小修复”:扫描所有现有 xl.meta 再应用 prefix 过滤。系统必须为新上传增加可恢复的身份元数据或持久化索引,同时为旧的 keyless 上传制定明确迁移策略。

复杂度评估

纯语义算法不是最难的部分。真正困难的是:如何在不把一次 listing 变成失控的全集群元数据风暴的前提下,得到完整、满足 quorum、能够全局排序的输入集合。

领域 复杂度 原因
纯 S3 过滤与分页 规则数量有限,但 marker 和 delimiter 边角需要 AWS 取证。
把 bucket/key 写入新上传元数据 复用现有 quorum 写入,但要验证完成、回退、healing 与复制兼容性。
候选发现 命名空间混合所有 bucket,并且每个上传在 erasure drives 上重复;只查一块盘会漏掉仍满足 quorum 的上传。
Quorum 与并发删除 扫描既要拒绝少数盘残留的幽灵项,又要容忍 abort、complete、GC rename-to-trash 与瞬时 ENOENT
多 pool 全局分页 必须在所有可访问 pool/set 之上统一归并、排序、截断并生成 marker。
滚动迁移 旧 writer 会继续创建 keyless 上传,旧 completer 可能保留未知内部元数据。
性能与资源控制 一个 bucket 请求可能需要检查全集群的所有活动上传,而不只是该 bucket。

总体判断:这是一个高复杂度兼容性项目,wire 兼容风险为中,正确实现的风险为高。它不是破坏性的对象格式迁移:推荐方案只给新的未完成上传增加内部元数据,并保持现有目录结构不变。

兼容性与运维影响

Wire 行为变化

正确实现会有意改变外部可见结果:

  • prefix=foo 将匹配 foofoobarfoo/...,不再只匹配精确键 foo
  • bucket-wide 结果将按 key 与发起时间排序,而不是只按发起时间;
  • max-uploads 会真正限制单页;
  • 默认值与最大值会从 SILO 当前的 10,000 常量向 AWS 的 1,000 收敛,具体边角以取证契约为准;
  • 客户端必须跟随 NextKeyMarkerNextUploadIdMarker,不能再假设一个响应包含全部结果;
  • delimiter 请求会返回 CommonPrefixes
  • 当前 handler 在 marker 不属于 prefix 时返回的 501,将被取证后的 AWS 语义替代。

这些是兼容性修复,但可能破坏意外依赖 SILO 旧行为的软件。特别是忽略分页的客户端,修复后可能只看到更少的首屏结果。因此严格行为应通过明确的发布和 rollout 契约引入,不能偷偷混进一个无关 patch。

存储格式兼容性

推荐写路径是在新上传现有的 quorum xl.meta 中加入保留的内部 bucket 与 object key 元数据。它不重命名 multipart 目录,也不创建第二个事务写入位置。

CompleteMultipartUpload 把上传元数据 rename 成最终对象之前,必须删除这些只属于上传的字段,位置与当前已经删除 multipart checksum 字段的逻辑相同。

旧二进制完成由新二进制创建的上传时,不知道要删除新内部键。这些键不会暴露成 S3 用户元数据,但会惰性地留在已完成对象的内部元数据中。滚动升级测试必须证明未知保留键不会影响 healing、复制、元数据比较或降级读取。随后产品需要在“容忍残留”和“增加 scrubber”之间选择,不能假设它会自动消失。

运维成本

因为所有 bucket 共用同一个平坦哈希命名空间,按需扫描的复杂度是 O(全体活动 multipart uploads),而不是 O(目标 bucket 的 uploads)。有界并行、取消、内存限制与失败行为属于正确性要求,不是可有可无的调优。

SILO 默认在 24 小时后过期 stale multipart upload,每 6 小时清理一次。最后一个旧 writer 升级后,keyless population 在通常情况下应当在大约 30 小时内排空。配置了更大自定义 expiry 的运维方会有更长迁移窗口。当前代码把零值映射回默认 24 小时,本次审查没有找到受支持的“禁用 expiry”取值。

清理器能限制默认空间累积,却不能修复虚假的 listing 响应,也不能替代对持续合法 multipart 活动、故障模式和自定义 expiry 的测试。

严重度

建议定级为 P1 / 高兼容性问题,而不是 P0:

  • 没有发现已经提交的对象数据丢失;
  • 没有绕过安全边界;
  • 未完成上传在 complete、abort 或被清理前仍在磁盘上;
  • 默认 stale-upload 清理可以限制普通配置下的累积。

之所以仍然是高而不是中,是因为服务端返回了伪造的成功结果,答案会在重启或切换节点后变化,并且会让清理与静默期检查工具产生错误信心。

候选方案

方案 0:保持现状

没有工程成本,也保留所有偶然行为;同时继续保留虚假的 200 OK、节点本地不一致、重启易失、prefix 清理失败,以及对 S3 支持范围不准确的印象。

只有当 SILO 明确降低公开兼容性承诺、把该接口视为不支持时,这个选择才勉强成立。即便如此,明确拒绝也优于静默返回不完整成功。

决策:不能作为长期方案。

方案 1:明确且有文档的差异

对 SILO 无法正确处理的参数组合返回稳定的 NotImplemented 类错误,并准确记录支持子集。这比完整兼容小得多,也在运维上更诚实。

它仍然是破坏性行为变化:现在拿到空列表或无界 200 OK 的工具可能会开始让作业失败;它也没有得到一个 S3 兼容接口。错误行为和默认发布策略必须有意识地确定。

决策:如果完整兼容被拒绝或推迟,可以作为短期止血;它不是兼容性修复。

方案 2:持久化身份、扫描持久状态、可选缓存

每次创建新上传时,把 bucket 与 key 写入上传现有 xl.meta 的保留内部元数据。Listing 时遍历可访问 pool/set 的上传目录,按 erasure read quorum 验证候选,再执行一个全局 S3 语义层。缓存只有在能够从持久化状态重建和对账时才允许加速这条路径。

它不增加第二个写事务,也不改变目录布局。主要代价是全集群扫描。

决策:推荐,但必须先通过性能与故障模式原型。

方案 3:持久化的 bucket 级有序索引

维护一个按 bucket、key 与 upload identity 排序的二级索引。Listing 可扩展且天然支持分页,但 create、complete、abort、healing、回退与 reconciliation 必须在各种失败下维持两个位置的一致性。这个设计类似 MinIO 在简化该子系统时有意移除的 multipart 索引结构。

决策:除非测量证明方案 2 无法满足产品批准的服务目标,否则 NO-GO。

被否决的变体:只修 mpCache

给当前缓存增加过滤、排序、分页、create 广播或启动重建可以改善表象,却不能单独建立一个持久化、满足 quorum 的真相来源。只修缓存很可能得到一个“看起来更可信、实质仍不正确”的答案。

决策:否决。缓存可以优化正确读路径,不能定义它。

1. 先冻结公开契约

为通用存储桶建立一套有记录的 AWS fixture,覆盖:

  • 跨 key 排序,以及同 key 多个上传的排序;
  • 相同发起时间与确定性的全序 tie-break;
  • prefix 与 exact-key 重叠;
  • key-marker 单独使用和配合 upload-id-marker;
  • 没有 key-marker 时的 upload-id-marker;
  • delimiter、CommonPrefixes 与分页计数;
  • 省略、0、1、1,000 和大于 1,000 的 max-uploads
  • encoding-type=url
  • 空页、末页与 next-marker 的取值。

把取证响应保存成仓库 fixture,CI 不应依赖实时 AWS 访问。

2. 在现有写入中持久化可恢复身份

NewMultipartUpload 中,在现有 writeAllMetadata quorum 写入之前,为规范化 bucket 与 object key 增加保留内部元数据。具体键名属于实现细节,但必须带版本、无歧义、受现有 object-key 大小限制约束,并且不能暴露到客户端元数据。

成功完成时,在把 fi.Metadata 复制到最终对象元数据、执行 renameData 之前删除这些 upload-only 键。Abort 与 stale cleanup 已经删除整个上传目录,不需要额外索引操作。

3. 分开“发现候选”与“验证有效”

候选发现和候选有效性是两个不同问题。

对于每个可访问、非 suspended 的 pool 与 set:

  1. 按配置的 list-quorum 策略,从所需的所有在线盘列出 hash 与 upload 候选目录;
  2. 对目录名做 union 和去重;
  3. 通过正常 erasure 元数据路径读取候选 xl.meta
  4. 只有元数据满足 quorum 且包含合法 bucket/key identity 时才纳入结果;
  5. 容忍候选在 abort、complete 或 stale cleanup 过程中消失;
  6. strict list quorum 下,如果所需 set 无法评估,应让请求失败,而不是返回部分成功的 200 OK

只用第一块健康盘做候选发现是不够的:那块盘可能在一个仍满足 quorum 的上传创建时处于离线状态。

4. 只做一次全局语义处理

把所有 pool/set 的有效候选送进一个纯语义层。它统一负责 bucket 过滤、prefix、delimiter 汇总、排序、markers、最大页计数、URL encoding、IsTruncated 与 next markers。

在全局归并前不能应用 pool-local limit 和 marker。相同候选被重复发现时结果必须确定,而且无论请求落到哪个节点都应该得到同一答案。

5. 保留内部精确对象操作

erasureServerPools.NewMultipartUpload 当前调用 ListMultipartUploads(bucket, object, ...),目的是让同一对象的另一个上传进入相同 pool。如果公开函数开始把参数解释为词法 prefix,foo 可能匹配 foobar 并选错 pool。

增加一个命名收敛的内部 helper,例如 FindMultipartUploadPoolListMultipartUploadsExact。它应继续使用现有对象哈希路径,不能共享公开 prefix 语义。

6. 缓存只能是优化

可以直接删除现有 mpCache。如果保留,它必须满足:

  • 持久化状态始终是权威;
  • 启动时能够重建;
  • create、complete 与 abort 更新能够一致传播;
  • reconciliation 能发现漏掉的事件与过期条目;
  • 冷缓存或分歧缓存会回退到 quorum 扫描;
  • 关闭缓存时所有 correctness 测试仍然通过。

7. 通过滚动迁移门槛启用严格行为

Legacy 上传记录缺少 bucket/key identity,无法可靠反推。使用两个对外有意义的模式:

  • legacy mode,升级后的初始默认:新 writer 持久化身份;统计并排空 keyless 上传;混合 keyed/keyless population 的响应策略必须明确选择;
  • strict mode:只有所有 writer 节点都声明支持新元数据、观测到的 keyless count 为零时才能启用。此后重新发现 keyless 上传必须报错并产生异常遥测,不能静默遗漏。

短期 shadow 对比可以验证新 scanner,但除非原型发现必要性,否则不需要永久的第三种运行模式。在默认 expiry 下,最后一个旧 writer 停止后,预计用一天再加一个清理周期排空 legacy。

Legacy mode 仍有一个未决产品选择:

策略 优点 代价
返回完整的 keyed 子集,并用文档和遥测说明 有界排空期间工具仍可运行 普通客户端看不到“不完整”事实,仍会收到不完整 200 OK
只要存在 keyless 上传就让 listing 失败 永远不伪造完整性 整个排空窗口会阻塞清理和现有作业

这个选择必须写进 ADR。Strict mode 没有这种歧义:前提被破坏时必须显式失败。

8. 把 suspended-pool 生命周期拆开处理

Listing 的第一版应与其他 multipart 动词的可访问性契约一致,只扫描非 suspended pool。单独把 suspended-pool 条目加入 listing,会暴露无法继续上传、完成或中止的句柄。

为 pool drain 期间的未完成上传另开生命周期设计:可以在上传结束前保持全部 multipart 动词可用、迁移上传,或按文档策略强制 abort。不要把它偷偷塞进 #79。

性能原型与决策规则

方案 2 只有一个持久写入位置,因此是首选;但它的扫描成本必须测量,不能假设。

在不同 pool、set 与 drive 数量组合下生成 1,000、10,000 与 100,000 个活动上传,测量:

  • 冷热状态下的 p50/p95/p99 延迟;
  • 总体与逐盘 ListDir 操作数;
  • 元数据读取和节点间 RPC 数;
  • 峰值内存与分配量;
  • 取消延迟;
  • 慢盘、离线盘、healing 与间歇消失磁盘下的行为;
  • 同时发生 create、complete、abort 与 stale cleanup;
  • 高选择性 prefix、空 prefix、首页与深页成本。

验收阈值是产品决策,必须在解释结果前记录。猜测的一两秒目标不是证据。如果扫描能在有界资源下满足批准的目标,就否决方案 3;如果不能,就用测量结果设计解决已证明瓶颈的最小持久化索引。

测试与发布关卡

语义与单元测试

  • 根据记录的 AWS fixture 生成纯表格测试;
  • 覆盖排序、marker、delimiter、encoding、截断与 maximum 边角;
  • 用属性测试保证分页后每个逻辑上传恰好出现一次;
  • 重复候选和相同时间戳下保持确定性。

Object 与 handler 测试

  • 强化现有 object-layer 表格,断言 uploads、common prefixes、markers 与截断;
  • 解析并验证 handler XML body,而不是只检查状态码;
  • 验证默认和非法 max-uploads
  • 独立测试 exact helper 的 pool 选择,不与公开 prefix 语义混用。

分布式与故障测试

  • 重启等价性与切换节点等价性;
  • 多 set、多 pool 下只有一个全局页边界;
  • 候选在一块盘缺失但整体满足 quorum;
  • 部分 abort 后少数盘残留的幽灵条目;
  • 并发 complete 与 GC rename-to-trash;
  • 每种受支持 list_quorum 策略下的 set 不可用;
  • 滚动升级、旧 writer 重新加入、降级 complete 与 strict mode 门槛;
  • healing 与 replication 对未知内部元数据的处理。

交付关卡

  1. 批准 ADR,包括产品模式与性能 SLO;
  2. 提交兼容性 fixture;
  3. 完成并评审存储原型;
  4. 实现并通过定向、全量、race 与故障 QA;
  5. 更新 S3 兼容性参考与运维说明;
  6. 提交并合并源码变更;
  7. 构建并标识 release artifact 或容器镜像;
  8. 对滚动升级做 canary,观察 keyless-drain 遥测;
  9. 只有所有门槛满足时才启用 strict mode;
  10. 验证线上端点后再关闭 #79。

前一个关卡通过,不代表后一个关卡已经发生。

最终建议:应该改,但不能急着改

无限期保留当前接口是错误的权衡。这不是一个冷门响应字段不一致:它影响未完成数据的发现与清理,返回成功但错误的答案,而且会随节点与重启变化。这些性质损害了 S3 兼容性在实践中的含义。

但直接写一个实现补丁同样是错误的权衡。当前磁盘布局无法识别 legacy 上传;正确扫描必须具备 erasure-aware discovery 与 quorum;wire-correct 分页又会改变客户端可见行为。

平衡后的决策是:

  • GO:ADR、AWS fixture 取证、metadata-plus-scan 原型,以及性能/故障原型;
  • 有条件 GO:产品 SLO 与 legacy 响应策略批准后采用方案 2;
  • NO-GO:只修缓存、立刻引入持久化二级索引、在 patch release 中默认 strict,或在证明滚动升级门槛可达之前关闭 Issue;
  • 如果没有实现资源,GO:采用明确、有文档、稳定报错的差异行为,而不是继续伪造成功 listing。

这样既守住兼容性纪律,也不会假装一个高风险的分布式 listing 变更只是两行 bug fix。