跳转到主要内容

这是本节的多页打印视图。 .

返回本页常规视图.

设计归档

SILO 分支的产品需求、兼容性决策与实现契约。

这里归档 SILO 维护决策背后的完整思考:要解决的问题、兼容性边界、被否决的方案、实现要求,以及进入发布版本前必须取得的验证证据。

1 - 桶配置复制:来源时间、删除与确定性收敛

#77 是已复现的站点复制正确性问题。接收端把来源时间改成到达时间,可能拒绝真正较新的删除;部分配置删除后不再导出时间,断线期间遗漏的删除也无法被 heal 找回。单独补一个 DELETE 分支不能解决这两个问题。

合并状态(2026-09-12): 修复与研究归档已通过 PR #180 合入 main48ec10312),#77 已关闭。合并前 9 项 CI 检查全部通过。
评审边界: 计划经过四轮 Claude Code Opus 5 Max 审查,最后两轮通过;实现完整审查、修正复审和最终定向验收共三轮,均为 GO_WITH_NONBLOCKING_NOTES。最终阻断为零,要求等待的完整 cmd 与最终 lint 已通过。
适用范围: 下文描述已进入主干的修复。下载包与线上实例是否包含修复,需要核对其具体版本;完整删除自愈仍要求全部节点升级并统一开启删除导出。

既有工作与本轮范围

此前的发布说明安全加固记录Server 兼容性说明 已记载 #77 的删除收敛限制,但没有完整记录状态模型、替代方案和验证边界。本文补齐这部分设计依据。

源码仓库正式归档保存实施前复现、各版计划、七轮审查的最终报告与调用身份、逐项处置、验收日志、源码/二进制哈希及可重跑的双站点驱动。原始模型推理流、二进制和临时实验卷不纳入仓库;整理后的工作站路径及文档链接与原始产物分别记录哈希。

相关修复各有自己的责任边界:

已有工作 已解决的问题 不能据此推断的能力
#91 每站点配置计数、Policy/Quota 统计与畸形字段隔离 计数正确不代表配置值和来源时间已收敛
#103 整个 .metadata.bin 的写入串行化 有锁不代表锁外作出的新旧判断仍有效
#76#78 Object Lock 的复制载体和已有桶接管保护 不替代六类配置统一的来源状态比较
CORS 复制修复 桶级 CORS 的独立删除记录与复制信任边界 不能直接推广到所有元数据类型

本轮只处理 Policy、Tags、SSE、Quota、Versioning、Object Lock。Lifecycle/expiry 有自己的合并时间语义;CORS 保留独立机制;notification、IAM、对象复制、MRF、resync 和公开统计不在本轮重写。对象复制可靠性另有记录。

正式支持和发布验收面向维护中的 silosilo-consolemcsilo-pkg 组合。与未修改的上游 MinIO/MC 保持尽最大努力兼容,不以此要求维护组件降级或重建分叉依赖。

复现说明了什么

原始回归在 ErasureSD 和 16 盘 Erasure 两种 ObjectLayer 上复现:源站在过去的时刻 T 写入配置,对端落盘的却是当前到达时间;随后收到源时间为 T+1 分钟的删除,仍被当成旧事件拒绝。单看 RPC 成功无法判断最终状态是否正确。

配置 原实现保留 PUT 来源时间 空事件的既有语义 原实现导出无载荷时间
Policy 删除
Tags 删除
SSE 删除
Quota 删除;零值 JSON 另有语义
Versioning 不操作 不作为删除使用
Object Lock 不操作 不作为删除使用

另有三个必要入口问题:旧 bulk 可以覆盖新字段;请求在锁外判断后排队,拿到锁时仍使用过期结论;Tag 的远端 heal 漏传 UpdatedAt。这些都需要在实际入口修复,不能只调整 heal 的选源条件。

一个字段需要保存哪些事实

沿用已有字段载荷、字段 UpdatedAt 和桶 Created,不增加磁盘格式、SDK 字段或 deployment ID。比较时区分以下状态:

状态 条件 是否可以成为来源
未知或无效 创建时间未知、载荷非法,或字段时间早于创建时间 否;不能把缺少信息解释成删除
空基线 空载荷,时间为零或等于 Created
有值基线 合法非空载荷,时间为零或等于 Created 是;用于历史配置初始化
真实更新 合法非空载荷,时间晚于 Created
真实删除 可删除字段的空载荷,时间晚于 Created 是;这个带时间的空值即 tombstone(删除记录)

Versioning 和 Object Lock 的空事件继续不操作,不能因统一 helper 而获得删除能力。零时间字段在比较时使用 Created 作为基线;这不表示把历史空值补成一次新删除。

比较规则依次为:真实状态胜过基线;真实状态之间较晚来源时间胜出;同一时间删除胜过有值;同级有值冲突以稳定内容键的字节序裁决。完全相同的有效状态不保存、不通知。有值基线之间也按内容键选择,不能让一个较晚创建的默认状态压过真实修改。

这是一种确定性冲突裁决,不是“字节序较大的配置在业务上更正确”。并发配置冲突仍需操作者选择业务上期望的值,并重新提交。

比较键必须与真实保存行为一致

Policy 的集合由 map 支撑,直接 JSON 编码会受枚举顺序影响。Server 对已校验策略的完整 JSON 树排序,包括 Statement、Action/NotAction、Resource/NotResource、Principal、Condition;保留 Sid 和数字精度。这里不新增解析器不支持的语法。

既有策略解析器接受 NotAction / NotResource,但原结构的必填 Action/Resource 编码会对空集合报错。因此使用同一份显式字段编码,并让 Policy GET/admin export 也能读取已接受的策略。这项补齐防止“写入成功、读取失败”,并非单纯格式清理。

Quota 使用既有解析结果的 JSON 作为键;{}、JSON null 或合法零配额文档仍是有值文档,不能悄悄发送成删除。空 Policy 则按既有 peer 语义统一为删除:合法空策略 PUT 成功,后续 GET 返回既有 NotFound。

XML 配置使用有效文档的实际字节作为键,没有引入通用 XML 规范化器。Versioning 是一个必要例外:先应用已有 Object Lock 约束,再比较最终会保存的有效文档。否则比较器接受一个版本配置,Save 又改写它,下一轮 heal 便会重复发送。

锁住整个决策,而不只是最后的保存

六类字段共同存储在一份 .metadata.bin 中。读取原始记录、校验、比较、修改和保存必须位于同一把已有 metadata.lock 内:先锁外比较、再锁内写入,仍会使用过期状态;改成按字段加锁,也会让整条记录的读改写互相覆盖。

本地写入在锁内分配时间:

max(当前 UTC 时间, Created + 1ns, 当前字段时间 + 1ns)

这使一次本地纠正能超过已保存的未来字段时间。带来源时间的 peer 事件保留其原时间,不重新盖上本机时间;完整重复和较旧事件直接结束。

bulk 只处理明确提供的字段,逐项验证后至多保存一次;字段省略表示不动,显式 null 按类型处理。非法字段不会使前半份配置先落盘。导入在每桶最终提交锁内分配涉及六类字段的共同时间;磁盘状态和出站事件使用本次提交的最终快照,不能在解锁后另读“最新状态”拼接旧时间。空 Policy 另用现有专用删除事件表达,避免被 bulk 的 omitempty 丢掉。

保存函数回传归一化后的快照,公开 Update/Delete API 不变。其它元数据仍保留原来的处理路径和通知机制。

桶接管不能制造假删除

接管可能调整 Created。若把 Created 改早,却保留原本等于旧 Created 的空字段时间,空基线就会被误认成真实删除。改晚也可能使历史初值变成无效状态。

最小处理是在现有接管锁内,仅把这六类字段原本为零或等于旧 Created 的默认时间调整到新 Created。真实更新和真实删除的时间保持不变;不靠“载荷为空”猜测是否默认,也不重做已有桶的配置保护。真正不同的桶创建世代仍需人工处理。

所有入口使用同一个状态规则

入口 必要行为
本地 S3/Admin 写入 锁内单调时间;必要时由提交快照生成出站事件
专用 peer 事件 原始来源时间在同一锁内比较和保存;保留既有类型及旧 Object Lock 载体兼容
bulk/import 字段存在性明确、原子保存;导入时间在最终提交时确定
首次同步 保留历史有值基线;开关开启后补发真实删除
local/remote heal 同一比较器选源与应用,包含完整来源时间;未知 ID 和故障 peer 不阻断其它健康目标
状态导出 值与时间属于同一记录;新增删除时间受升级开关控制

首次同步保留已有五类配置发送范围;Versioning 仍通过 MakeBucketHook 初始化并由 heal 对齐。没有为了表格对称而新增第六套初始化流程。

heal 不再先取 map 的第一项作为胜者,再过滤默认时间。它先筛选有效候选,再取最大状态。公开 mismatch 计数不能作为唯一门控:内容相同、来源时间不同也需要同步;反过来,最终有效状态相同就不应再次写入或发 RPC。

为什么需要默认关闭的升级开关

新增启动变量 MINIO_SITE_REPLICATION_METADATA_TOMBSTONES 默认 off。它控制新增删除信息的可见性,不负责探测远端能力。

行为 off on
来源时间排序、原子 apply 启用 启用
普通删除事件 继续复制 继续复制
Policy 的已有删除时间导出 保留 保留
Tags/SSE/Quota 无载荷时的真实删除时间导出 隐藏 导出
初次同步补发真实删除 保持原有范围 补发四类可删除字段

旧实现不能安全消费全部新增删除信息,例如旧 Quota heal 会在清空载荷后留下已解析的缓存值。仅写一句“请升级”不足以隔离滚动升级窗口,因此先保持 off。

启用顺序:全部参与站点的全部节点升级到包含修复的构建,确认同站点配置一致并排空旧请求,然后统一设置 on 并重启。降级前先在全部修复节点设置 off 并重启,再滚动降级;旧软件原有缺陷会随降级恢复。

off 期间隐藏的 Tags/SSE/Quota 删除记录可能导致 heal 重复发送旧状态,由修复端拒绝。因此“第二轮零 RPC”只适用于完整状态已可见且稳定的情况,不是 off 模式的保证。

保留和拒绝了哪些方案

决策 理由
保留一个内部六字段 helper 六类已复现相同来源时间问题;统一比较避免入口间规则漂移,类型删除语义仍明确区分
保留现有整桶锁、持久化字段和 heal 周期 分别承担原子性、删除持久性与漏发恢复,无需新增协调服务
不只把 UTCNow 替换成来源时间 仍会留下锁外判断、缺失删除信息、等时冲突和初次同步缺口
不只添加墓碑时间导出 旧接收端与旧 Quota 缓存路径仍有问题,必须控制升级窗口
不以 deployment ID 决胜,也不添加 HLC/schema 当前范围可用已有时间和确定性内容键完成;不声称提供跨站点因果排序
不一律拒绝零时间专用事件 旧 Tag heal 确实遗漏时间;保留廉价协议兼容并明确限制
不把所有字段泛化为同一种删除语义 会错误删除 Versioning/Object Lock,或破坏 Lifecycle/CORS 独立规则

初次实现的生产 Go 代码新增 729 行、删除 692 行(净增 37 行),主要替换重复的应用和 heal 分支。行数不是最小性的证明。必要性要逐条对应复现;充分性要覆盖所有实际入口;最小性要判断删去某一机制后,是否会重新出现具体错误。

验证及其证据边界

环境为本机 go1.27.1 darwin/arm64。四组实施前审计用例在未修复基线上失败,修复后的回归套件在两种 ObjectLayer 上通过;核心测试入口位于 cmd/site-replication-metadata{,-heal,-gate}_test.go。原始失败与现有用例通过的完整输出见基线审计日志

验证 观察结果
来源时间、四类删除、重复/乱序、锁前排队、不同字段写入 回归通过,目标 race 通过
等时双向到达、删除优先、Policy 键与负集合 GET、bulk/import 边界回归及补充 race 通过
完整 cmd 包、internal/S3 Select race 最终产品代码 fcbb93e89 的 cmd 全量通过(492.776 秒);internal/S3 Select race 已于 62cf066ff 通过
构建、vet、lint、生成文件和兼容检查 最终产品代码 build/vet 通过,461e9a721 lint 零问题;生成文件与兼容检查已于 62cf066ff 通过。可选 typos 未安装,按 Makefile 跳过
Linux/Darwin/Windows × amd64/arm64 62cf066ff 六目标交叉编译通过,不等于六平台运行验收
两个真实站点进程,每站四个数据目录 历史六类配置初次同步保留 Created;丢弃真实 PUT 的出站 RPC 并注入乱序后,正常 30 秒 heal 恢复一致
删除遗漏及重启 四类删除落盘,来源进程重启后保留;恢复连接后收敛
稳态与日志 两次各观察 65 秒,连续两个正常 heal 周期无 metadata RPC;重复异常按每桶/字段/原因去重
修复版与固定旧版混合 全部关闭开关后,Tags PUT/DELETE 冒烟通过;不是完整混合版本正确性证明
评审修正:创建时间补齐、策略状态比较键、heal 诊断 真实 ObjectLayer 创建时间与旧顺序策略测试通过旧码覆盖确认失败;修正后通过。62cf066ff 的完整 cmd、internal、S3 Select race、lint、生成文件、品牌检查与六目标交叉编译重跑通过
最终收尾回归 fcbb93e89 的诊断、初次同步、物理时间边界、接管与 CORS 目标 race 通过;461e9a721 仅测试写法变更后,相关 race 再次通过
双站点验收在最终二进制上重跑 同一套验收先在干净 62cf066ff、再在干净 fcbb93e89 构建的二进制上通过;最终 461e9a721 仅调整测试写法,产品代码相同

本地证据集包含基线失败、测试日志、可重跑双站点驱动、元数据快照和源码/二进制 SHA-256。首次双站点运行使用的二进制标识为 c8f264f79 + dirty,已补录实际二进制的 Go build-info 和 SHA-256。评审修正及收尾后,分别以干净 62cf066fffcbb93e89 构建的二进制重跑。实际 --version、Go 构建元数据与 SHA-256 均记录,并保留基线 5c5765816 的对照二进制身份。最终 461e9a721fcbb93e89 之间只有测试格式及等价的分支写法调整,源码差异已单独保存。

上述表格记录本地测试和隔离进程观察。主干集成另有远端证据:PR #180 的 9 项检查全部通过,包括 Go CI 的六项任务、DCO漏洞检查发布流程验证。合并后已核对主干树与通过检查的 PR 合并树完全一致。这些证据不等同于真实 Linux 多节点集群、正式发布制品或生产部署验证。

对抗评审记录

计划审查使用实际 Claude Code claude-opus-5 --effort max,共四轮。前两轮推动修正了状态比较、保存快照、历史基线及导入等边界;后两轮结论为 GO_WITH_NONBLOCKING_NOTES,实施前阻断为零。计划通过不等于实现已经正确。

本次实现审查固定 4089113e3,由同一模型和强度独立检查全部差异、生产调用链、正式测试及运行证据,重点挑战充分性、最小性、滚动升级和证据身份。结论为 GO_WITH_NONBLOCKING_NOTES:无条件阻断为零,条件性阻断一项,另有九项发现。它确认核心收敛机制成立,在已声明的契约范围内没有找到回退、删除复活、锁外判旧或重复广播的反例。其中三项是本次改动引入的真实缺陷,已在 62cf066ff 修正。随后 fcbb93e89 补齐“没有有效来源也应诊断无效状态”的日志边界与首次同步回归。

发现 判定 处置
F1:没有记录创建时间的桶,六类配置全部写不进去 确证回归,条件性阻断 已修。不要求元数据时,GetBucketInfo 原样返回物理探测结果,与 ListBuckets 一致;初次同步补齐该时间并传给建桶钩子
F2:复制状态按 Statement 顺序比较,heal 按规范键比较 确证;永久假 mismatch,heal 永远修不掉 已修。状态改用 heal 的同一比较键;每站点存在性计数不变
F3:四种 heal 情况共用一个日志 key,正常瞬态按 ERROR 输出 确证 已修。每个原因各自持有 key 并降为 Warning;空基线和缺桶保持安静,无有效来源时仍诊断实际存在的无效状态
F4:三处用户可见语义变更没有写入文档 部分成立 原审查提交 README 末尾已说明空 Policy 和零 Quota,首轮漏读;补充的是 Policy GET/export 的编码顺序及负集合策略行为
F5:Policy 规范编码器的接入是否多余 第二轮修正了首轮判断 比较键和状态键必须一致;GET/export/peer 编码器避免已能落盘的负集合策略读回或复制失败,必须保留。PUT/import 统一编码并非比较器所必需,但删去只会增加分支和表示差异,故保留
F6:孤立的辅助函数与过时的顺序注释 确证的小问题 注释已改;没有生产调用者的 isBucketMetadataEqual 及只测试该函数的过时用例已删除
F7:开关关闭时,遗漏的 Tags/SSE/Quota 删除不收敛 对计划取舍的正确理解 不改动;滚动升级一节与下文边界已声明
F8:证据缺口——recovery 测试用 stub、缺少旧顺序策略用例、二进制身份不可复核 确证 recovery 改用真实 ObjectLayer;补充被规范编码器重排的旧顺序策略用例;双站点验收改用干净最终树构建的二进制重跑并记录身份
F9:桶创建世代冲突 已声明在范围外,且不是回归 不改动;见下文边界
F10:接管将 Created 调晚并越过真实字段时间 覆盖缺口,不是缺陷 接管测试固定两面行为:保留原时间,旧世代状态不能成为来源;作为目标接受新世代的合法输入

其中的 stub 值得单独一提:原 recovery 测试注入的对象层在创建时间探测中直接返回期望值,于是它对一段生产中永远不会这样表现的代码判定通过。替换后的测试在每块本地盘上标记桶目录时间并驱动真实对象层,在未修复的代码上会失败。

第二轮复审固定 62cf066ff,再次由实际 claude-opus-5 --effort max 执行,结论仍为 GO_WITH_NONBLOCKING_NOTES,条件性与无条件阻断都为零。它逐项重查生产路径,并核对作者用正式测试配合旧生产代码 overlay 得到的 F1/F2 复现结果,修正了首轮对 Policy 编码器必要性的判断。审查者没有代为运行测试。

后续发现 收尾处置
NB-1:物理 Created 只是近似值 保留世代边界,写明目录修改时间的局限;真实盘测试固定“较早 peer 事件跳过、本地纠正成功”,不靠单个来源时间降低桶身份
NB-2、NB-8:无来源时过度静默、恢复失败日志丢失来源时间 已修;有实际无效状态仍诊断,恢复失败记录原事件时间。无来源不会发 RPC,空基线安静
NB-3:掉线日志按桶与字段放大 明确接受当前每桶/字段/原因的粒度;不将站点级日志聚合框架带入正确性修复
NB-4:草稿日志不能证明产品失败 findings-before.logfindings-after-1.log 标为已被替代的夹具失败。后续用同一正式测试叠加旧生产代码,分别复现物理时间、策略顺序、初次同步以及日志问题
NB-5、NB-6:无用辅助函数、恢复位置说明不完整 删除辅助函数及两处只测试它的用例;说明初次站点同步也会落盘 Created,保留真实 CORS 路径测试
NB-7:接管只验证来源半边 增补目标半边:旧世代状态失效后,新世代的合法输入可以覆盖它

日志去重和无来源诊断已使用现有 logger target 补成正式回归,覆盖级别、不同原因、重复调用与零 RPC。首次同步用例驱动真实源 ObjectLayer 和完整出站流程,对端是确认 RPC 的测试服务;它证明出站内容,真实双站点进程实验则提供另一个层次的证据。二者不能混称为同一验收。

第三轮定向验收固定最终产品代码 fcbb93e89,结论仍为 GO_WITH_NONBLOCKING_NOTES,阻断为零;它也检查了后续 461e9a721 的纯测试写法差异,确认语义等价。审查读取日志时,完整 cmd 与 lint 尚在运行;随后两项均以退出码 0 完成。fcbb93e89 本身的测试格式曾使 lint 失败,修正后的 461e9a721 才是通过全部已要求检查的交付基准。

仍有三项非阻断的改进建议,不影响本轮范围内的行为结论:heal 遇到解码/解析失败时,诊断属性可能显示零来源时间;初次同步单测没有执行本地 peer 分支,因此不证明恢复时间在本地落盘(该生产分支经源码复核);日志测试严格捕获系统日志,未来若引入并发后台日志,可考虑进一步隔离。没有据此新增生产取值 helper 或测试 hook。反向失败日志只证明实际走到的失败断言,例如空基线误报;修复后对 Warning 级别和按原因去重的检查通过,不能把二者误写成已经分别演示过旧码失败。

仍需保留的运行边界

  1. 历史时间污染不能自动还原。 到达时间覆盖和无时间旧事件已经丢失来源事实;部署修复后不会凭空恢复正确历史顺序。核对各站点,在权威站点重新提交期望配置或删除。
  2. 零时间专用事件继续兼容。 它们使用本地单调时间,并记录 legacy-zero;bulk 的零时间约束不变。这类事件不属于带来源时间收敛保证。
  3. 桶身份冲突不自动合并。 先解决创建世代分歧。早于目标 Created 的事件不应用;未知创建时间只从真实物理桶补齐,仍未知或桶不存在时不写入。恢复值是桶目录修改时间这一物理近似值,可能随顶层对象变化、逐盘不同,并晚于真实创建时间;早于它的事件仍会被跳过,不能只凭一个事件降低桶身份。补齐发生在成功配置写入或初次站点同步时;在落盘前,状态仍照实报告未知时间,周期 heal 在两个方向上都跳过该桶。
  4. 物理时钟不是因果时钟。 已知未来字段时间后的本地纠正可前进,但不能推断所有并发写入的业务意图。
  5. 诊断有界,成功响应不等于应用。 legacy-zerobefore-createdindeterminateunreachablepeer-error 复用现有 LogOnceIf;错误文本和 key 稳定,详情放入属性,沿用每小时清理。每个原因各自持有 key,一种情况不会把另一种顶掉;空基线以及尚未拥有该桶的对端不产生诊断。真实存在但无法排序的状态即使没有有效来源,也会输出 indeterminate,且不触发 RPC。正常重复和旧事件保持安静。这是每桶/字段/原因的上限;不可达站点的 unreachable 和无效状态的 indeterminate 都可能随桶与有值字段数量增长,不是全站固定条数的上限。
  6. 代码、合并与发布分别验收。 Issue 状态、Server 版本、镜像、软件包、文档上线及生产配置需要各自证据;本文记录的主干合并不代表发行制品或生产环境已经完成升级。

2 - 未签名的 Header 不属于请求

本文记录 SILO 的未签名头覆盖修复,核心提交为 123325430,已通过 PR #173 合并,台账编号 SN-2026-011。该问题由 Oren Yomtov 针对已发布版本报告,并在本地两条签名路径上均已复现。

2026-09-11 状态: 原始修复已推送,并通过 PR #173 合并。下文的后续签名与正文校验修复也已通过 PR #177 合并,8 项 PR 检查全部通过。源码验证与正式发布分别计数:当前已发布的 9 月 3 日 Server 版本尚未包含这些修复。
范围: SigV4 请求头覆盖、策略输入一致性与正文摘要校验。不改变 S3 字段名称、对象或桶元数据格式、复制协议、加密格式和客户端命令。
安全性质: 客户端未签名的 x-amz-* 操作头不能改变已授权请求;策略求值与正文校验使用实际参与签名的有效输入。

太长不看(TL;DR)

一个 presigned PUT URL 只签一个头:host。SILO 会确认签名头名单里点名的每个头都已到达,却从不遍历真正到达的头,于是名单之外的 x-amz-* 头被照单接受并使用。cmd/api-router.go 仅凭 x-amz-copy-source 头就把任意 PUT 派发到 CopyObjectHandler。两者相加,把"只能写某个对象"的授权,变成了以签名者身份读取签名密钥可及的任意对象的服务端复制——一个混淆代理(confused deputy)。当该头被排除在 SignedHeaders 之外时,Authorization 头路径也是同样的行为。

修复只确立一条不变式:

就 x-amz-* 语义而言,签名头名单“就是”整个请求。
任何不被它覆盖的 x-amz-* 头,都在 handler 运行前被拒绝。

这与 AWS S3 一致——AWS 对同一请求返回 AccessDenied(“There were headers present in the request which were not signed”)。签名代码是逐字节继承自上游 minio/minio 的,因此每个更早的 SILO 发布版本、以及上游本身,都带有这个缺口。

缺陷:覆盖缺口

cmd/signature-v4-utils.go 里的 extractSignedHeaders 遍历签名头名单,逐个从请求(或 query string)里取值。它证明了"承诺过的头都在",却从不问反方向的问题——到达的每个 x-amz-* 头,是否都在名单里

唯一遍历到达头的地方 checkMetaHeaders,只匹配 X-Amz-Meta- 前缀,且只被 presigned 路径(doesPresignedSignatureMatch)调用。Authorization 头验签器(doesSignatureMatch)没有任何等价调用。于是未签名的 x-amz-copy-source——或任何其它塑造操作的 x-amz-* 头——在两条路径上都能通过:

presigned PUT(SignedHeaders=host)  ->  加一个未签名的  x-amz-copy-source: /src/secret
  -> 路由看到 x-amz-copy-source  -> CopyObjectHandler
  -> 复制以签名者身份运行,读取了 URL 从未点名的桶

本地复现:对照组 PUT 返回 200 且响应体为空;同一 URL 加上那一个未签名头返回 200,响应是一个 CopyObjectResult,其 ETag 正是受害对象的 md5,且目标处回读出的就是受害者的字节。若目标桶本就允许匿名 GetObject,被复制进去的私有字节此后无需任何凭据即可读取。

溯源

这个缺口继承自上游 MinIO,并非 SILO 引入。cmd/signature-v4-utils.go 里的 SigV4 验签器、以及 cmd/signature-v4.go 里的 Authorization 头路径 doesSignatureMatch,都是可追溯到 2016 年的原始 MinIO 代码;cmd/api-router.go 里由头驱动的 CopyObject 派发可追溯到 2019 年。唯一遍历到达头的例程 checkMetaHeaders,是上游在 2023-07-27 通过 minio/minio#17737535f97ba6)加入的。也就是说,上游其实已经意识到了这一类问题——未签名的头必须与签名集合相符——却把检查限定在 X-Amz-Meta- 前缀和 presigned 路径上,把 x-amz-copy-source 和整条 Authorization 头路径都漏在外面。这个窗口在 MinIO 的 S3 层里一直开着。

在未签名头修复之前,SILO 对 cmd/signature-v4-utils.go 的改动是一行依赖路径迁移:9b11dc946policy 导入改为 pgsty/silo-pkg/v3。存在缺陷的验签行为来自上游。原始修复(123325430)以及 PR #177 的后续修复改变了这条边界。存在缺陷的代码早于 SILO 的分叉基线——即上游 2025-12-03 的 “maintenance mode” 提交,第一个 SILO 发布版本正是从那里切出的。

上游 minio/minio 自那次交接起即处于归档状态,没有上游维护者能接收补丁。SILO 原样继承了这份代码,也是唯一修复它的地方——正如安全台账对其它继承性发现的记录方式。

修复

checkMetaHeaders 更名为 checkUnsignedHeaders,把匹配前缀从 X-Amz-Meta- 拓宽到整个 X-Amz-,并在两条路径(presigned 与 Authorization 头)上都调用。不被签名集合覆盖的头,会在任何 handler 逻辑运行前以 ErrUnsignedHeaders 拒绝。

有四个决策界定了这条边界的确切位置。每个都有一个看似合理、但因具体理由被否掉的替代方案。

判成员资格,而非判值相等

继承来的检查比较的是 signedHeadersMap.Get(k) == val[0]。对一个不在签名集合里的头,Get 返回空串,于是首值为空的头会比出相等而通过。像 X-Amz-Copy-Source: ["", "/src/secret"] 这样的多值头,就能借此把未签名的 copy-source 从值相等检查下夹带过去。修复改为判成员资格(该头是否在签名集合中)。签名头的值本就被签名绑定,所以值相等从来不是关键性质;在名单里才是。

豁免 X-Amz-Content-Sha256

X-Amz-Content-Sha256 可以不列入 SignedHeaders,因为有效载荷哈希已被单独绑定到规范请求。预签名请求优先使用 query 值,仅在 query 缺失时回退到 header;显式的 UNSIGNED-PAYLOAD 仍然有效。PR #177 让策略条件使用同一个有效值,同时保留 header 存在性的语义,并补齐通用认证路径中 header-only 预签名请求的正文摘要校验。这项豁免不允许策略求值或正文校验另取一个不同的值。

从签名日期计算签名年龄

原始修复曾豁免验签后写入的内部 scratch 头 x-amz-signature-age,但 PUT 和 UploadPart 的授权发生在验签之前,这个值建立得太晚。PR #177 改为直接从已签名的 X-Amz-Date 计算 s3:signatureAge,并删除 scratch 头、对应常量和豁免。伪造日期会导致验签失败;客户端提交旧名称的未签名头会被拒绝。验签不再修改请求头,重复验签仍然幂等。

X-Amz-Tagging 注入挪到鉴权之后

PutObjectTaggingHandler 会从请求派生出一个 X-Amz-Tagging 头供策略条件读取,此前是在 authenticateRequest 之前注入的。有了拓宽后的检查,这个服务端合成、客户端从不签名的头,会被当作未签名而拒绝。注入现在改到验签之后、授权之前——授权仍然拿得到它来做策略条件。否掉的替代方案: 像豁免 content-sha256 那样,直接整体豁免 X-Amz-Tagging。那会允许客户端在任意签名/presigned 写请求上,通过一个未签名头设置对象标签,重新打开这一类缺陷的一个缩小版本。

跨签名模式的适用范围

  • Authorization 头(签名)与 presigned SigV4: 两者现均已强制。这是可达的路径。
  • Streaming SigV4: 对复制不可达。authenticateRequest 对 streaming 鉴权类型返回 ErrSignatureVersionNotSupported,因此 CopyObjectHandlercheckRequestAuthType 会在任何复制发生前就拒绝一个 streaming 签名的复制。检查没有加到 streaming 验签器上,因为派发根本到不了那里;有回归测试钉住这一拒绝。
  • SigV2: 不受影响。V2 的规范化本就把 x-amz-* 头折进 string-to-sign,因此新增一个 x-amz-* 头会改变算出的签名,被当作签名不匹配拒绝。

状态码:400 还是 403

AWS 对未签名头返回 403 Forbidden;SILO 返回 400 AccessDeniedErrUnsignedHeaders),继承自上游。两种方式都拒绝了攻击,错误 Code 字符串也一致,仅 HTTP 状态码不同。把它提升到 403cmd/api-errors.go 里一行的改动,同时也会改变既有的 meta 头拒绝路径。此处把它留作一个刻意的、可逆的选择,而非在安全修复里悄悄夹带,因为它对既有的未签名 meta 头路径是一个行为变更,且并非关闭该漏洞所必需。

测试

有几个既有测试先构造一个签名请求,然后在签名之后才设置 x-amz-copy-sourcex-amz-copy-source-rangex-amz-metadata-directive——也就是说,它们依赖的正是本修复所移除的行为。它们现在改为在设置这些头之后用 signRequestV4 重新签名,这正是每个真实 S3 客户端的做法。signRequestV4 会把 Authorization 头排除在自己的签名集合之外,因此重签是安全的。当前覆盖包括 checkUnsignedHeaders 的单元用例(空首值、载荷哈希豁免,以及旧签名年龄头未签名时的拒绝行为)以及 TestPresignedVerifyIdempotent——对同一个 presigned 请求验签两次。

证据

  • 一个构建出的服务端在 presigned 与 Authorization 头两条路径上复现了混淆代理,修复后两者均被拒绝,而对照组 PUT、真实的 minio-go CopyObject、带用户元数据与标签的 PutObject、以及基于请求体的 PutObjectTagging 全部照常工作。
  • go test ./cmd/ 在修复树上通过;gofmtgofumptvet 干净。
  • 对抗性评审(第一轮)独立发现了初稿中的三个缺陷——scratch 头导致的验签不幂等、空首值绕过、以及对未签名 x-amz-content-sha256 的过度拒绝——均在上文处理,并通过把评审方自建的对抗测试套件跑在最终树上得到确认。
  • 对抗性评审(第二轮,针对已提交的修复)未发现回归,并确认重签后的测试保留了原意:无效 access key 仍返回 InvalidAccessKeyId,错误 SSE-C key 在签名通过后仍返回 403。它另外发现了三处相邻的、既有的缺口——在父提交上同样失败,且不在本次改动范围内——记录在下文后续项。

兼容性与运维

  • 普通客户端: 请求无变化。每个 AWS SDK、minio-gomc 本就会对它发送的 x-amz-* 头签名。
  • 未签名的 x-amz-* 头: 现在以 AccessDenied 拒绝,与 AWS 一致。一个不签名就加上此类头的客户端,本就在 SigV4 契约之外。
  • 滚动升级: wire 与存储格式不变。已升级节点强制该边界;仍跑旧版本的节点在升级前仍然暴露,因此滚动窗口内不同节点行为可能不同。
  • 回滚: 修复版本写入的数据仍可被旧版本读取,但回滚会重新打开混淆代理。

残余风险与后续

  • 发布交付: 源码修复与公开工程记录不代表已发布的二进制或镜像包含修复;需要单独核对所选发布版本与制品。
  • CVE: 报告人申请了一个;在 CVE 分配前,该发现以稳定的 fork 本地编号 SN-2026-011 追踪。
  • 状态码选择: 上文 400403 的取舍仍开放。
  • 相邻签名修复: PR #177 处理重复复制源头的歧义、签名年龄的授权时序和载荷哈希策略有效值,并补齐另行复现的 header-only 预签名正文摘要校验缺口。回归覆盖普通签名与预签名、上传验签前的策略求值,以及真实 HTTP 桶策略篡改。这些后续项与原始 SN-2026-011 分开记录;合并和发布状态见页首。
  • 通用问题: 本次修复覆盖的是 x-amz-* 请求头。任何未来让请求语法去选择操作的控制项,都必须回答这次同样的问题——在这个值被允许具有任何含义之前,它是否被签名覆盖了? 上面那处重复头缺口是同一问题的另一副面孔:签名所绑定的值,与处理器所消费的值,必须是同一个。

结语

签名即请求。x-amz-* 头所声称的一切,在签名覆盖它之前都只是声称:

确认"承诺过的头都到了",不等于确认"到了的头都被承诺过"。在 handler 运行前,在 handler 可被到达的每一条签名路径上,拒绝任何未签名的 x-amz-* 头。

3 - 复制可靠性:删除完成、MRF 可见性与 resync 取消

本文记录 #153#152#137 的分析、方案取舍、评审与实施结论。三者属于同一组复制可靠性问题,但分别发生在操作分类、后台恢复可见性和任务生命周期上,不能靠一个统一的重试补丁解决。

截至 2026-09-09: PR #162 已合并为 d1105bbb,三个 issue 均已关闭。被测 PR head 的八项检查与合并后主干的 Go CI、VulnCheck 全部通过。
第二轮,2026-09-16: PR #196(修复 0c61128d2,验证记录 aea3882c9)修复了复制 worker 自身的删除出口与持久 MRF 标记恢复路径——与第一轮不同的层面。已在 main、不在 Server 20260903 中;见第二轮小节
评审: 与环境中的 Claude Code Fable 5.1 Max 商榷方案,并在实施后复核;最终结论为 GO
交付边界: 本轮完成代码、测试和主干合并,没有创建 Server tag 或正式 release。本文不据此宣称现有软件包、镜像或生产部署已包含修复。

整体判断与系列边界

选择方案的标准是:在错误发生的最小边界修复已复现的不变量,保留现有恢复机制,用确定性测试证明它能够收尾。增加复杂度必须有具体反例支持。

问题 当前 SILO 中确认的缺陷 选定修复
#153:删除标记 purge 单对象 DELETE 把永久删除误归类为删除标记复制,远端已删而源端 purge 仍为 PENDING 按 purge 状态分类,与批量删除、scanner/heal、resync 对齐
#152:MRF 丢弃不可见 队列已有限额,但内部丢弃计数没有进入管理接口和监控;对象与删除的 worker 参数次序不一致 输出现有计数、在实际丢弃处记录去重警告、统一 worker 分配
#137:resync 取消不可靠 单个共享 token 无法取消多个任务;阻塞阶段不响应取消;旧任务可能覆盖终态 每个运行拥有 context,按 resync ID 取消,并约束注册、收尾和状态写回

这一系列此前已经区分了三个容易混淆的概念:

  • #136 / PR #138 修复的是计数完整性:先接收并应用最后一个结果,再持久化终态,不能等一分钟后的周期刷新补齐。
  • #139 修复的是结果真实性:目标对象存在,不代表这次更新成功;必须依据目标的实际复制结果统计成功和失败。
  • #137 修复的是取消与资源生命周期:任务能够停止,walker、worker 和结果消费者能够退出,旧任务不能污染新任务状态。本次延续前两项契约,没有另建一套计数机制。

复制请求是否有权使用内部语义,属于此前的 CORS 与复制信任边界;跨池 Object Lock 的权威状态选择仍由 #133 单独跟踪,截至本文归档时仍未关闭。本次三个 issue 关闭不等于所有复制问题都已解决。

正式验收对象是维护中的 pgsty/silo 及配套的 Console、mcli、silo-pkg。上游 MinIO/MC 兼容性保持尽最大努力;不能直接把上游报告的机制或实验结果当成当前 SILO 的实测结论。

#153:先分清复制删除标记,还是永久删除版本

报告与复现不完全相同

原 issue 描述 ILM 与复制共同触发持续的 HTTP 405 请求风暴,并建议把删除标记的 405 探测一律作为完成。当前 SILO 的源码和实测不支持直接采用这个解释和补丁。

基线为 450dcb848。实验使用两个本地构建的 SILO Server、独立临时数据目录,以及维护中的 mcli / minio-go 客户端。一个关键发现是:mcli 即使只删除一个 key,也会走批量 DELETE;这个路径原本就是正确的。 因而必须另发单对象 S3 DELETE 才能覆盖错误入口。

检查点 修复前的单对象 DELETE 修复后
目标端对应删除标记版本 已删除 已删除
源端 xl.meta 中同一版本的 purge 状态 仍为 PENDING,普通复制状态却已完成 首次复制后完成清理
原始数据版本 保留 保留
是否需要等待 scanner 补做清理 需要后续恢复 本次验证无需 scanner 帮助

仅观察“远端版本消失”会误判修复成功,必须同时检查源端 metadata。当前复现证明的是首次 purge 的分类与完成状态错误,以及一次多余探测;没有复现原报告的持续 405 风暴或请求量数字。现有 scanner/heal 与 resync 已按 purge 状态选择正确路径,不能把它们描述成每轮必然重复错误探测。

一处分类修正为什么足够

删除一个现有 delete marker 时,对象层可以同时返回 DeleteMarker=true 和非空 VersionPurgeStatus。前者说明被操作的版本是什么,后者说明现在要做什么,二者并不冲突。

旧的单删 handler 只看 DeleteMarker,把版本放入 DeleteMarkerVersionID。复制完成因此更新了普通 ReplicationStatus,而不是应当完成的 purge 状态。最终条件是:

if objInfo.DeleteMarker && objInfo.VersionPurgeStatus.Empty() {
    dmVersionID = objInfo.VersionID
} else {
    versionID = objInfo.VersionID
}

这与其他生产者已有的分类一致,修复落在 DeleteObject handler。不需要改变存储格式、放松 replica 删除保护,也不需要新增恢复任务;存量 PENDING 继续由现有 scanner/heal 的 purge 路径回收。

为什么不把 405 一律视为成功

版本化 HEAD 的 405 可以证明“目标上这个删除标记存在”。对复制一个删除标记来说,这可以是幂等完成;对永久删除该版本来说,它恰好说明仍有工作要做。

若仅因 405 就写入 VersionPurgeComplete,源端可能清理 metadata,而目标端仍保留本该删除的版本。最终实现保留原有 405 语义;真正的远端 403、405、503 等失败也不能被伪装成删除成功。

历史定位显示,按 delete marker 分类的代码可追溯到上游 2020-11-19 的提交2023-07-10 的优化 把调度判断从 dsc.ReplicateAny() 改为对象返回的复制/PENDING purge 状态,同时保留了只看 DeleteMarker 的分类。2025-04-02 的提交 在此处只是把 Pending 迁移为 replication.VersionPurgePending,并非首次引入该调度条件。以上是源码谱系定位,不是跨所有历史版本的二分验证,也不能据此给外部报告的整个请求风暴确定引入日期。

#152:让已有有界队列的丢弃行为可见

MRF(Most Recent Failures)用于记录近期失败、等待后台再次处理的复制任务。它并非没有容量限制:mrfSaveCh 上限为 100000mrfRetryLimit3;代码在 RetryCount > mrfRetryLimit 或保存通道已满时放弃该条队列记录。

源对象及其待复制状态仍在,丢弃队列条目不等于源数据丢失。不过后续恢复依赖 scanner,原本快速的重试可能退化为等待扫描,无法据此承诺固定的恢复时延。

真正的缺陷是 TotalDroppedCount / TotalDroppedBytes 在内部递增,却没有复制进两处对外统计快照,也没有 v2/v3 Prometheus 指标。容量为 1 的回归夹具能稳定证明:一个条目成功入队,20 字节的溢出条目和 30 字节的超限条目被丢弃;内部为 2 / 50,管理接口却显示 0 / 0

选定的最小改动

  1. 两处管理统计快照原子读取现有丢弃计数。
  2. v2 与 v3 注册并加载累计 counter,文档同步说明计量含义。
  3. 在重试超限、MRF 通道已满这两个实际丢弃点记录去重警告,使用固定的消息和 key,避免计数变化绕开去重而刷屏。普通 worker 队列转交 MRF 还不等于丢弃,不在那里增加警告。
  4. 正常对象、heal 和删除路径统一以 (bucket, objectName) 选择 worker,恢复同一对象的分配一致性。
接口 新增指标
Prometheus v2 minio_node_replication_mrf_dropped_operations_total
Prometheus v2 minio_node_replication_mrf_dropped_bytes_total
Prometheus v3 minio_replication_mrf_dropped_operations_total
Prometheus v3 minio_replication_mrf_dropped_bytes_total

它们是自 Server 启动以来的累计值,重启后重置。操作数统计条目而非唯一对象,同一对象可重复计数;字节数统计已知大小,删除条目按零字节计算。它们既不是数据丢失量,也不是完整积压量,运维应关注增量并结合复制积压与目标健康状况判断。

本轮没有扩大队列、提高重试次数、增加持久化重试调度器或另建退避框架。已有上限继续限制内存成本,scanner 继续承担最终恢复;本次解决的是“发生了什么却看不到”,而不是许诺任意故障下的恢复时限。

#137:把取消作为一次运行的生命周期

一个 token 无法取消一组任务

原实现把一个未按任务标识区分的 token 放进共享 channel。一个站点 resync 可以对应多个桶,最多有 10 个桶任务并发;dispatcher 和 worker 又竞争消费同一个 token。结果可能只有一个桶停止、无关任务消费取消,或残留 token 影响后来任务。

还有两个独立的阻塞点:裸读 Walk 输出不检查取消;向已满的 worker 通道发送也不检查取消。worker 退出后,dispatcher 可能永久堵在无人接收的通道上。Walk 沿用父 context,函数提前返回时也无法结束自己的 walker。

状态层另有配套缺陷:站点 updateState 修改局部值却未写回 map;桶级 Canceled 缺少完整处理;旧 finalizer 可能把取消状态覆盖为 Completed。

注册、取消与状态使用同一个写入边界

每个 resyncBucket 创建自己的 context.WithCancelCause,在等待并发槽位之前注册。注册和 cancelResyncID 共用 resyncer 的状态锁:

  • 注册时校验目标仍存在、resync ID 仍匹配,并读取当前取消状态。
  • 取消时先把同 ID 的 Pending / Started 标为 Canceled,再取消全部已注册的对应 context。
  • 先注册、尚在排队的任务能收到取消;先取消、后注册的任务也会看到已取消状态。
  • Walk、dispatcher、worker 和结果发送共同观察本次运行的 context;无关 ID 不受影响,也没有可被后来任务消费的残留 token。

站点 start/cancel 的配置准备由专用操作锁串行化。即使目标配置循环部分失败,取消运行中任务的动作仍会执行。桶级 finalizer 和计数更新同时检查当前目标与 resync ID,忽略已删除、已替换或已取消运行的迟到结果;站点状态实际写回 map,Canceled 不再被后到的 Completed 覆盖。

成功与失败的收尾顺序不同

这里必须保留 #136 建立的“结果消费者结束后再持久化”契约:

正常完成:关闭 worker 输入 → 等 worker 结束 → 排空结果
          → 持久化终态 → 归还槽位 → 取消自有 context 并注销

失败/取消:先取消 context,解除阻塞 → 等 worker 和结果消费者退出
          → 持久化相应终态 → 归还槽位 → 注销

若正常完成时先 cancel,再等待 worker,尚未处理的工作可能被丢弃,原本成功的任务会变成 Failed。最终代码只在一个 defer 中调用一次 finish,利用 defer 顺序完成收尾,因此不需要额外 sync.Once

WithCancelCause 区分用户主动取消与父 context 中断:用户取消成为 Canceled;收尾时若观察到父 context 中断,不能把中断的运行记为 Completed;仍在等待槽位时发生关机则保留原有 Pending 状态,供重启恢复。

取消终态与恢复任务也必须受保护

context 检查与终态保存之间仍可能发生取消。因此 markStatus 在锁内遇到已有 Canceled 时必须保存 Canceled,即使 finalizer 先前算出了 Completed;旧 resync ID 也不能覆盖新运行。

这不是跨文件事务。如果一个桶已经先于取消完成并持久化,随后发生的站点取消可能保留“桶 Completed、站点 Canceled”的不同记录;这表示完成发生在取消之前。保证的是已经被取消的运行不能被迟到的完成结果翻回 Completed,不是抹掉取消前已经完成的工作。

恢复路径的 loadResync 原本启动 goroutine 后立即执行 defer cancel()。核对 SILO 实际 shared-lock 实现后确认,这里的 cancel 确实会结束合并后的 leader context,并非空操作。最终用一个 WaitGroup 让该 context 活到恢复任务退出;失去 leader 时仍按原 context 取消,不能换成全局 context 来绕过 leader 约束。加载磁盘状态时也不能覆盖内存中较新的 start/cancel 状态。

与 Fable 的评审和复杂度取舍

评审实际使用环境中的 Claude Code,模型参数为 claude-fable-5-1[1m]--effort max。先提供基线复现和最小方案,形成三项执行共识;实施后再提供最终补丁与验证结果,获得 GO。这个结论建立在代码和证据上,而不是仅凭模型赞同。

方案或评审意见 最终判断
对 purge 的 405 探测直接判完成 否决:marker 存在不是永久删除完成的证据
MRF 新增有界重试、退避与持久化调度层 本轮不引入:现有队列与 scanner 已提供恢复机制,已证实缺口是可见性
增加共享 cancel token 数量 否决:仍不能保证身份路由、广播和后来任务隔离
为取消增加独立墓碑注册表 不需要:已有目标状态与 resync ID 在同一锁下足以关闭注册竞争
每个任务持有 context,并使阻塞操作可取消 保留:满队列死锁与 walker 泄漏已有确定性复现
finish 使用 sync.Once 初评要求防止双重关闭;最终改成唯一 defer 调用点,复核认可省去 Once
恢复任务等待后才释放 leader context 初评要求先核实是否必要;实际 SILO cancel 有效,故保留 WaitGroup,并补测失去 leader 的行为

终审还接受了两个实现边界:罕见的 resync start 管理操作在状态锁内完成配置读写,与现有终态保存方式一致;活动注册表复用包含 resyncBeforeresyncOpts 作为 key,现有调用传递相同内存值,没有复现身份不一致。若未来改变时间值重建或任务恢复的方式,应重新核对身份等价性,而不是未经证据立即增加另一套注册机制。

验收证据与可复验入口

先在未改生产代码的基线上加入回归测试,确认错误能够失败,再实施修复。新增用例直接覆盖生产 handler、真实 erasure 存储、实际指标注册和 resyncBucket,不是只验证抽出来的同构 helper。

验收范围 结果与证据
单删 purge ErasureSD 与 Erasure 均覆盖源/目标清理;覆盖存量 PENDING 恢复、删除标记幂等,以及真实 403/405/503 失败语义
MRF 容量 1 的实际队列溢出与重试超限;检查管理 JSON、v2/v3 注册后的 counter 类型和值、对象/删除 worker 分配
resync testing/synctest 覆盖 Walk 阻塞、满 worker 队列、主动取消、排队取消、异 ID 隔离、后续任务、终态竞争、旧 ID、槽位释放与 leader 恢复
本地完整套件 go test ./... -count=1 -timeout=30m 通过,共 50 个有测试的 package
并发与重复 复制相关定向 race 通过;取消回归重复 100 次通过
工具与契约 本地 build、vet、lint、生成文件检查、rebrand compatibility guard、git diff --check 通过;未更改依赖和兼容基线
原生双 Server 使用本地源码构建,直接单对象 DELETE;目标 marker 消失,源端 xl.meta 清理,原始数据版本保留;没有使用下载的 Server Docker 镜像
远端集成 PR 八项检查通过;合并后主干 Go CI 六项任务及 VulnCheck 通过

固定版本的回归用例:删除标记MRF 可见性取消生命周期。在该提交、Go 1.27.1 下可复跑:

go test ./cmd -run '^(TestReplication|TestReplicateDeleteMarker|TestResync|TestSiteResync)' -count=1
go test -race ./cmd -run '^(TestReplication|TestReplicateDeleteMarker|TestResync|TestSiteResync)' -count=1
go test ./cmd -run '^TestResyncCancel' -count=100
go test ./... -count=1 -timeout=30m

完整本地验证后仅调整了新测试的格式和夹具:复用既有 ARN、通过 collector 取得指标前缀,避免兼容性扫描器把测试字符串当成新协议标识;没有放宽 guard。调整后重跑相关测试、lint 与兼容检查,远端 CI 验证最终提交。

证据点 精确标识
修复前基线 450dcb8484bc1337deba0cf608cc893a6691d794
最终 PR head 66fe61ff65c83d68b74baa637a11623015c7aa21
合并主干 d1105bbb3d4a0afa33b3a4ac11b821235038ed0e,代码树与最终 PR head 相同
PR Go CI 34320440012
主干 Go CI 34321319278
主干 VulnCheck 34321319274

后续维护与发布判断

后续改动必须继续分别证明:操作类型正确、失败可见、逐对象结果真实、终态计数完整、取消能够结束自己的资源。任何一项都不能由“接口返回 Completed”或“目标上对象存在”代替。

本次代码结论是 GO,交付事实是主干合并及 CI 通过。正式发布仍需另行选定 tag,验证软件包与镜像,并确认实际部署包含修复。既有 MRF scanner 恢复时延、未关闭的跨池问题,以及外部 405 风暴尚未在当前 SILO 复现的边界,都应随这份决策一起保留。

第二轮(2026-09-16):worker 侧 purge 分类与持久 MRF 恢复

第一轮在 handler 入口 做删除分类(#153)、公开 MRF 队列丢弃(#152)、补全 resync 取消生命周期(#137)。第二轮——PR #196,修复 0c61128d2,集成验证记录 aea3882c9——修复另一个层面:复制 worker 自身的出口、外层聚合,以及删除标记的持久 MRF 恢复路径。两轮互补,互不包含。

状态: 在核验过的 main 40220bd836cb 上,不在 Server 20260903。全部证据为合成实验(真实单盘与 16 盘存储、签名 DELETE、受控 HTTP 目标、真实落盘的 MRF 条目经全新 worker 池重放)。外部报告的 405 风暴未复现,也被归因于这些路径。

仍然坏着的部分

  • 旧形态任务被整体跳过。 VersionID 为空、DeleteMarkerVersionID 非空、创建目标 COMPLETED、purge 目标 PENDING 的任务从不发出 DELETE——按目标的创建早退把它挡掉了。
  • 只修目标函数会让聚合说谎。 外层状态选择以 VersionID 与创建状态为键,失败的旧形态 purge 聚合为 COMPLETED、发出 ObjectReplicationComplete,并跳过持久 MRF 入队——比基线更糟。
  • 持久 MRF 丢弃所有标记 405。 重放磁盘上删除标记版本的 MRF 条目时,取回的是真实标记元数据加 MethodNotAllowed,错误路径将其丢弃——标记 MRF 恢复路线是死路。
  • 失败的 purge 覆盖成功的 resync 标记,已完成的 purge 被重发,未就绪的 HEAD 无条件覆盖创建状态。
  • 多目标空状态正则误读可一次性损坏创建块。 磁盘标记只带创建元数据、任务带 purge 状态时,两个空目标状态被解析为伪 Pending;deleted 标志守卫随即把整个创建块改写为带新时间戳的空条目——需要磁盘/任务分歧才可达的一次性损坏。

根因:worker 缺少任务级 purge 分类(社区的 #184 中按目标判定的谓词本身是错的——复合 purge 状态经它永远到不了 COMPLETE;其调查与修复提案仍为本轮奠定了方向,感谢 Julien Laurenceau);MRF 恢复缺少有效 405 身份门;删除任务不带重试计数,既有 mrfRetryLimit 的丢弃在删除路径上不可达。

修复

  • 一个分类管所有出口。 isVersionPurge()VersionID 非空,或 DeleteMarkerVersionID 非空且复合 purge 状态非空)同时驱动内层目标函数与外层聚合。purge 出口只写 VersionPurgeStatusReplicationStatus 恒留空——这是存储层的"不更新"信号。
  • 三字段清空守卫把 purge 路径的复合状态真正清空,使多目标误读在磁盘写入处不可达。
  • purge 发送规范的永久删除请求——显式 versionIdReplicationDeleteMarker=false、不做 HEAD/就绪探测(授权由 DELETE 本身决定)。这同时防止丢响应重试在无版本回退处重建标记
  • MRF 恢复的有效 405 门。 MethodNotAllowed 只有在返回对象是删除标记、桶/对象/版本身份匹配、修改时间非零时才调度恢复。
  • 有界重试预算。 删除任务携带重试计数,在全部三个持久 MRF 入口(聚合失败、锁失败、队列满回退)递增,尊重既有上限;耗尽后 scanner 仍可重新发起 heal。
  • 审计与事件状态只在统计/事件边界COMPLETE 映射为 COMPLETED,复用既有 legacy 常量。

405 的准确含义

创建(复制删除标记),对目标标记版本的 HEAD 405 意味着已存在——幂等完成。对 purge(永久删除版本),MRF 身份探测的 405 且带完整标记身份意味着还有活要干;purge 自身的成败只由 DELETE 决定,DELETE 403/405/503 一律是真实失败。空或 null 版本标记返回 ObjectNotFound 而非 405,在门外。

仍然保留的边界

旧内存形态任务不跨重启序列化、当前没有生产者——对它的处理是健壮性而非活跃修复。未配置 client 的目标仍只记日志。目标级 resync 替换 purge 子集是本轮既未造成也未修复的既有缺陷。测试直接驱动持久化;不声明定时器 flush 与进程崩溃耐久性。多进程站点复制 mesh 与跨区域验收不在范围内。同轮可靠性修复中的标签排序与副本元数据修复另见复制标签排序副本元数据归一化

4 - 复制标签排序:修订时间戳、墓碑与复活

本文记录对象标签在复制中如何保序的两个缺陷的分析与修复,分别合入 Server main 为 PR #193(修复 03027727d)与 PR #196(修复 680eac66e)。

截至 2026-09-16: 两个修复均已在核验过的 main 40220bd836cb 上;不在已发布的 Server 20260903 中,请用链接的 PR 识别包含它们的构建。
交付边界: 仅源码级验收(回归测试 + R4–R8 集成运行,PR #196 的 11 项检查全部通过)。本记录不确立任何 tag、包、镜像或生产 rollout。
证据类别: 以下每个机制都用针对真实单盘与 16 盘纠删后端的合成实验验证。没有客户事故被归因于这些路径。

共同模型:标签值与其修订构成一个状态

标签复制携带内部修订时间戳(x-minio-internal-tagging-timestamp)。接收端的排序规则很简单:复制的标签状态只有在其修订新于存量时才胜出。本族两个缺陷都以"丢失修订而非丢失值"的方式破坏该规则:

  • R4 在写入 SSE-KMS 目的端的过程中丢失时间戳,新的标签更新因此在存储层对账中输给旧的存量状态。
  • R5 使值(删除)完全不携带修订,协议无法表达"在时刻 T 已删除"——迟到的事件可以复活客户端已经删除的内容。

R4:SSE-KMS 目的端复制丢弃标签修订时间戳

故障形态。 到 SSE-KMS 加密目的端的可信复制 COPY 返回 200,对象加密正确,明文 GET 正常——而目的端的标签及其时间戳停留在旧值。因为 HTTP 请求成功,丢失完全无告警。存储层对账(reconcileStoredObjectTags)在对象写锁内比较修订;没有来件时间戳,旧的存量状态获胜。

触发面。 不止显式 SSE-KMS 头。桶默认 KMS 与全局自动加密走同一代码路径,因此即使请求中没有任何 KMS 头也可能触发。

根因。 PUT 类请求的选项构建器先解析出可信来源标签时间戳,随后 SSE-KMS 分支构造并返回另一个 ObjectOptions,携带 mtime、ETag、复制信任与两个 Object Lock 时间戳——唯独没有 ReplicationSourceTaggingTimestamp。该遗漏可追溯到上游 c4373ef290(2021);2026 年的一次 Object Lock 修复给该字面量补了两个时间戳,仍漏掉这一个。该字段唯一的消费点就是 COPY 标签排序比较,这也是 PUT 与 multipart 不受影响的原因——这一分工也使 R4 与 R5 相互独立。

修复。 在既有 SSE-KMS ObjectOptions 字面量中补一个字段(03027727d),别无其他改动。回归测试覆盖全部目的端加密(无、SSE-S3、带与不带 key context 的 SSE-KMS、SSE-C)× 可信/不可信来源 × 缺失/有效/畸形时间戳,外加在两个单池后端(单盘、16 盘纠删)上合计 50 次签名 COPY+GET 有序序列(事件间隔 1–3 ns)。KMS 场景使用测试桩。

不回填。 丢失的来源标签时间戳无法在目的端重建。升级后,新的标签事件按序复制;旧事件重放仍按时间戳比较:传入事件必须严格更新,平局或传入事件更旧时保留存量值。

遗留观察。 同一 SSE-KMS 字面量还缺少 proxy 与 speedtest 选项字段;speedtest 标志在存储路径被读取,全局自动加密下 speedtest PUT 会丢失该标志。已登记为独立后续项,刻意不并入本次修复。

R5:空标签值没有修订,删除可被复活

故障形态。 九项基线回归,全部针对真实存储:

  • 成功的 DeleteObjectTagging 从不生成新修订,因此迟到的可信元数据 COPY 携带旧标签视图即可将其复位。
  • 携带标签状态的较新 COPY 被忽略——空的含义是"无事可说"而不是"已删除"。
  • 首个副本 PUT 解析了来源标签时间戳却从不持久化。
  • 可见值相等且时间戳相等时塌缩为"无需复制",删除 → 重加序列无法重建顺序。
  • 队列中复制事件的完成回调把快照里的旧标签写回、覆盖已提交的删除——且不带时间戳,复活的集合继承了删除的较新修订,比最初报告的症状更糟。

根因。 标签值与其时间戳(含空值的时间戳)构成一个状态。旧协议只能表达非空状态:DELETE tagging 从不打修订、发送端只在非空分支附带时间戳、接收端只在非空分支做决策。

修复680eac66e):

  1. 每次标签变更生成一个修订。 PUT/DELETE tagging handler 无条件打上单一 UTC RFC3339Nano 修订,与复制是否选中该对象无关。存储层在写锁内强制单调:不严格更新的本地修订被推进到 stored+1 ns;多池后端计算一个严格超过所有池副本的统一值。
  2. 发送端传输墓碑。 空值携带其已记录修订;空且无修订不伪造;畸形的已记录时间戳 fail-closed,不做静默修复。
  3. 接收端接受墓碑。 复制 COPY 在重建前捕获存量标签对,使带时间戳的空值作为可胜出的状态进入既有对账。重复抑制只对严格更新的来源修订放宽。Multipart 完成从已持久化的 upload 元数据排序标签。删除确认路径不再写回快照标签。

运维可见变化:

  • 时间戳相等时统一为存量胜(未限定版本与显式 null 请求)。旧 handler 在未限定路径上让来件胜出平局;该变化是方向正确的兼容性收紧。
  • 携带 tagging REPLACE 且无标签的普通 COPY 现在真正清空目的端标签。旧的默认元数据路径会把源标签带过去——这是 S3 一致性改进,但也是行为变化。
  • 每次普通 COPY(包括不改变内容的密钥轮换)都会记录一个新的本地修订。
  • 复制双方必须一起升级。 旧对端仍会丢弃空值修订,新发送端的墓碑对它不可见。
  • 有已记录修订的对象在显式 resync/heal 期间每对象多一次元数据 COPY。当目的端有桶默认或自动 KMS 加密时,该"元数据"COPY 实际重写对象数据——批量 resync 请据此预算。

限制按限制陈述:

  • 不迁移:历史中缺失或错误的删除时间戳无法重建,也不伪造历史墓碑。
  • 带标签过滤的复制规则按删除后的(空)标签状态评估目标资格,因此按被删标签过滤的规则永远看不到删除。这是既有的范围决策,本次不变。
  • 任意时钟偏差不构成全序。本修复建立的是逐跳排序,不是多站点因果。
  • 畸形的已记录时间戳使发送端构造永久失败并经 MRF 重试,直到显式的正确标签变更取代它——刻意 fail-closed。

被否决的方案

  • 为空且无修订的对象从 ModTime 合成墓碑。 每个从未打过标签的对象都会获得修订;叠加强制元数据复制后,每个对象每一跳都走元数据 COPY 路径。
  • 只在值为空时传输。 提出者在评审中自行撤回:相同值重加序列(T1 设 X、T2 删除、T3 重加 X)在该条件下丢失重加的修订。回归测试 TestTaggingRepeatedValueNeedsRevisionDelivery 固定了这个反例。
  • 新的 HEAD 修订协议。 固定的 minio-go 元数据提取器丢弃内部响应头,需要新 wire 契约,收益有限;最坏情形仍是一次强制元数据 COPY。
  • 分布式因果钟或提交时重新生成时间戳。 墙钟模型 + 写锁内单调护栏已是最小的正确修复。
  • 仅做逐池单调护栏。 普通读返回单池对象信息,发送端可能发出过期的主池修订;必须统一多池值。

验证与不证明什么

R4 回归覆盖加密 × 信任 × 时间戳矩阵,以及两个单池后端(单盘、16 盘纠删)上的有序序列,KMS 使用测试桩。R5 另有多池标签删除回归。R4–R8 集成运行在合并树上复跑了定向测试,包括隔离运行的 R5 多池测试;PR #196 的 11 项检查全部通过。这些确立了受测场景的排序行为,不确立真实时钟偏差下的多站点调度、跨区域故障切换,或只升级一对复制端中一端的部署行为。

升级摘要与发布边界见组件版本矩阵;阻止归一化副本元数据被重新注入的姊妹修复另见副本元数据归一化

5 - SILO Server 20260903 发布前复审

这是 SILO 20260903 背后的发布前长期工程记录:为什么不能直接接受早期“所有问题都已解决”的结论,独立复审发现了什么,修复如何收窄,以及复审时距离生产发布还差哪些门禁。链接的发布说明记录了之后的正式交付结果。

结论: 6e112d1856d4f3655f30fc81ee47e9f43d50d8f3 源码候选在代码层面可以 GO 到远端复审;生产发布仍是有条件 GO,必须等待远端 CI、Test Release、tag 与构件校验、签名、容器发布及公开 pull 验证完成。
基线: RELEASE.2026-08-06T00-00-00Z,提交 3be10fcc1a44f6620ded0bd303461f9d688cca23
范围: SILO Server 行为及其内嵌/锁定的运行时组件。文档、独立 Console、mcli、软件仓库、镜像与线上站点是彼此独立的交付物。
发布闭环: 后续最终源码树 9b11dc9469e650815b775cb47b039610644f5da4 在完成下列远端、软件包、provenance、容器与公开下载门禁后,于 2026-09-04 以 RELEASE.2026-09-03T13-18-01Z 正式发布。本页的有条件结论保留为当时的复审标准,不代表当前仍未发布。

2026-09-09 后续: #153、#152、#137 的修复与主干验收另见 复制可靠性设计归档。新文保留 #136 的计数契约、记录取消生命周期决策,并明确 #133 仍单独跟踪;它不改变下文对 0903 发布候选的历史判断。

为什么需要第二轮审查

第一轮实现有很强的测试结果,也解决了大多数报告缺陷。但“已经全部就绪”的结论仍然过宽:它把绿灯测试与干净工作树当成了所有安全不变量均已闭合的证明。

对抗性复审换了一组问题:

  • 同一不变量能否被另一种合法 wire representation 绕过?
  • 预鉴权 fast path 是否仍会做 I/O 或获得状态?
  • metadata 存在但加载失败时会怎样?
  • 两条各自正确的 read-modify-write path 是否共享同一 serialization boundary?
  • request sanitization 是否保留了全部 SigV4 streaming state?
  • 验证声明说的是最终树,还是此前某棵树?
  • 一个复杂机制是在保护已复现故障,还是只保护假想未来?

这轮审查在第一次“ready”之后继续找到了真实缺陷。正确做法不是推翻全部既有工作,而是把每个结论收窄到具体不变量与具体源码树。

分领域复审结果

领域 对抗性发现 最终解决方案 状态
桶元数据 独立配置锁会丢失共享 .metadata.bin 记录中的更新(#102 一个有界 metadata.lock 包围所有整记录 writer、migration、import、adoption、healing;复制只应用变化字段,避免陈旧整记录替换 候选已关闭
桶创建 ForceCreate 与站点 adoption 可能用默认值覆盖既有配置 保留既有记录,只更新 creation/adoption 状态;增加 clobber regression test 候选已关闭
Object Lock 将 lock 文档字节与一个 canonical XML 比较,会漏掉带 Default Retention rule 的合法配置 先 parse Object Lock,再从解析后的 enabled 状态推导 versioning 不变量;验证更新、读回、磁盘重载 候选已关闭
预鉴权 CORS 任意 path segment 会触发 metadata read 与 cache growth CORS lookup 只读 resident metadata,不进行 object-layer I/O 候选已关闭
CORS 启动期 metadata 尚未初始化时,非驻留名字可能回落全局 CORS 保留显式 fail-closed startup state 候选已关闭
CORS 加载失败 忘记一个真实桶曾加载失败,会让它与不存在的桶无法区分,使预签名请求落到全局 fallback 维护有界 failed-bucket set;成功加载、删除、refresh 等路径清除;这些桶保持 fail-closed 候选已关闭
CORS 恢复 按需 GetConfig 成功重载最初没有清除 load-failure bit 最后一行修复 84e1580a4,加定向 race 覆盖 候选已关闭
复制信任 多个 handler 把客户端可控内部 header 的存在当作特权 先鉴权;要求精确 marker 与 s3:ReplicateObject / s3:ReplicateDelete;用私有 context decision;之后再清洗 候选已关闭
Streaming upload 清洗后的 request clone 起初没有共享原 trailer map 保留 trailer map,使迟到的 streaming checksum 可见 候选已关闭
Snowball request-wide trust bit 可能在解压 entry 间泄漏 每个 entry 独立推导 trust,并在 worker 间保留请求默认值 候选已关闭
SSE-C 零字节读取与 GetObjectAttributes 可跳过客户密钥认证 要求成功解封 key;真正授权的 replica 走独立例外 候选已关闭
删除授权 显式版本删除检查普通 delete action,而不是要求 s3:DeleteObjectVersion 对齐单删与批删授权;复制删除保留 s3:ReplicateDelete;保留 auth/audit context 候选已关闭
管理授权 用户/组状态变更始终检查 enable action 检查与目标状态匹配的 action 候选已关闭
Checksum Multipart/copy 路径漏字段、接受非法组合,或在错误 representation 上计算 补齐算法/类型校验、服务端 part 计算、联邦传递、AWS 错误、CopyObject transform 顺序 候选已关闭
发布证据 完整验收最初描述的是之后仍发生变化的树 分别记录 ebac0ca73 的完整验收与当前树的定向门禁 证据缺陷已关闭

定义候选的四条不变量

信任只在鉴权后推导一次

看起来像内部字段的 header 仍然是客户端输入。请求必须保持原始签名形态,先通过现有 authentication path;随后 handler 才能组合:

  1. 唯一且精确的 replication marker;
  2. 非匿名、已认证身份;
  3. 对目标 resource 的 s3:ReplicateObjects3:ReplicateDelete
  4. 在更窄的 replica-only 语义中所需的 replica status。

结果存入私有 request context。header stripping 是给旧 consumer 的纵深防御,不是 authority 来源。

顺序很重要,因为 SigV4 可能签了这些 header。鉴权前清洗会让合法复制返回 SignatureDoesNotMatch。sanitized clone 还必须共享 request trailer:trailer 在初始 header parse 之后到达,承载 streaming checksum。

完整接收端模型见 鉴权前不做 I/O,Header 不产生权限

共享记录只有一个写边界

Policy、lifecycle、SSE、tags、quota、replication、Object Lock、versioning、CORS 是逻辑字段,却是同一 bucket record 的物理成员。每字段 mutex 无法保护整记录 read-modify-write。

选定修复比引入数据库或通用 transaction layer 更小:

acquire metadata.lock
  load or reuse current record
  mutate the requested field
  parse/normalize the complete record
  persist atomically
  publish the in-memory record
release metadata.lock

锁不覆盖对象数据 I/O,只限于一次 bucket-metadata 操作。Migration 与 healing 同样参与,因为它们也会替换整条记录。Replication receiver 只 merge 变化字段,避免远端旧 snapshot 擦除无关本地状态。

失败是一种状态,不等于不存在

CORS hot path 必须区分四种状态:

状态 结果
Metadata system 尚未初始化 不返回 CORS header
已知真实桶,但 metadata load 失败 不返回 CORS header
Resident bucket 且有桶级 CORS 文档 评估该文档
无 resident metadata,也没有已知失败 使用服务端全局 fallback

第二行解释了为什么 failed-bucket set 在简化审查后仍然保留。预签名 URL 已经通过自身签名获得授权,无需 bucket-policy evaluation;此时 bucket CORS 文档就是 browser-origin boundary。丢掉失败 bit 并使用宽松全局 fallback,会削弱该边界。

集合只由真实 bucket load attempt 产生,因此有界;生命周期通过两个 helper 维护。成功 load、remove、stale-bucket cleanup、refresh、reset、concurrent load 都有测试。

Object Lock 看语义,不看文本

任何解析有效且 enabled 的 Object Lock 配置都意味着 versioning。XML whitespace、element order 与 Default Retention rule 不改变语义。因此 normalization 必须发生在 parse 之后,而不是将 bytes 与某一个 canonical document 比较。

最终 versioning record 是纯 Enabled;suspended state 与 exclude-prefix 扩展和 lock 不变量冲突,会在 update、read-back、reload 时移除。

复杂度审计

发布前审查专门查找 over-design、重复、没有 threat model 的过度防御,以及陈旧兼容 machinery。

因保护已复现故障而保留

  • 一把 metadata lock: deterministic cross-type lost-update 测试已经复现数据丢失。
  • CORS tombstone: 没有 tombstone,站点复制无法区分删除与“从未观察到”。
  • CORS load-failure state: 预签名 URL 给出了已认证且不经 policy 的具体反例。
  • 两级 replication trust: 普通 replication 与 replica ciphertext/SSE 语义并不使用完全相同的 wire shape。
  • 鉴权后清洗: SigV4 之前清洗会破坏合法签名请求。
  • 多池/null-version 对抗测试: 单池 happy path 无法覆盖它们抓到的状态选择故障。

删除或收窄的复杂度

  • CORS failure-set mutation 收口到 noteLoadFailureclearLoadFailure
  • Replication import 只应用变化字段,不复制陈旧的完整记录。
  • 删除过期 encryption helper、死 event-target function 与废弃 handler branch。
  • Compatibility guard 不再枚举每个 exported source symbol,只保护真实 served route 与冻结的 wire/config surface。
  • 删除旧 wait_pipe lint exemption;用 gomodguard_v2 替代弃用配置。
  • Dynamic timeout 测试不再从 parallel package 调用全局 rand.Seed
  • 服务端从临时 silo-go 分叉回到已审查的上游兼容 minio-go revision。

刻意没有引入

  • 没有通用 metadata transaction framework;
  • 没有第二套 CORS cache 或无界 negative cache;
  • 没有新增公开“trusted replication”请求 header;
  • 没有让服务端发布依赖未来 Console 或文档发布的跨仓库 gate;
  • 没有把半套 conditional-delete contract 塞进候选;
  • 没有在缺少专用 convergence test 时重写全部继承的 site-replication register。

延期事项,以及为什么严重度不同

事项 分类 发布决定
条件删除 #10 继承的 S3 缺失功能;只对假定服务器会执行未支持 If-Match / per-object ETag 的调用方危险 显著记录;不合并不完整 PR,也不发布只有单对象的一半合同
多站点配置删除 #77 policy/SSE/tags/quota 的继承收敛缺陷;CORS 使用独立已修 register 不是单站点 blocker;依赖这些多站点删除的用户需要附加部署条件
ListMultipartUploads #79 继承的 listing conformance gap 已知问题;不是普通 multipart workflow 的数据完整性 blocker
联邦 CopyObject #99#100 旧后端 checksum/inline-object 缺口 阻塞受影响 feature 的使用,不阻塞通用 server 发布
ILM relocation PR #60 与 broad SSE issue #61 新能力请求 不属于本版本安全边界

“继承”不等于无害,而是说明缺陷不是本变更集引入,应按公开 release contract 评估。若某个部署依赖受影响路径,即使通用版本仍是 conditional GO,该部署也有自己的 stop condition。

证据

完整验收树

完整本地验收对应 ebac0ca73bbf251b070bb6df4d8005015841f901

  • 完整 cmdinternal 套件;
  • 完整 cmd race:365.448 秒,通过;
  • lint:0 issue;
  • rebrand/compatibility 与 generated-file guard;
  • govulncheck 无 reachable vulnerability;
  • 六种 make verify 部署形态:174 PASS / 0 FAIL。

前两次 make verify 在获取 mcli 时遇到环境/准备失败,并非测试失败。成功运行使用本地 checksum-pinned mcli,保留到 GitHub 的 outbound proxy,对 localhost 绕过代理,并把 GNU userland tool 放在 PATH 前部。这个区别属于证据,不应隐藏。

验收后的候选

完整运行之后唯一代码修改是 84e1580a4:metadata 按需重载成功后清除一个 CORS failure-state bit。候选 merge 不改代码;6e112d185 只修改 Helm 发布元数据与文档。在最终候选上以下门禁通过:

  • git diff --check
  • CORS 与 Object Lock 定向 go test -race
  • rebrand guard;
  • generated-file check;
  • lint 0 issue。
  • Helm lint、默认与可选 render、chart package,以及七资源旧版升级身份守卫。

证据强度与一行状态迁移修复相称,但推送后的树仍必须运行远端 CI 与 release workflow。

Go、No-Go 与剩余门禁归属

代码结论:GO

两轮复审确认的代码缺陷在候选中均已解决。修复落在对应不变量所在层;保留的复杂度都有已复现反例支撑。

生产结论:有条件 GO

以下全部成为事实前,不能把服务端称为“已发布”:

  1. 候选提交推送并完成 review;
  2. 远端 CI 与 Test Release 在 pushed head 通过;
  3. 预定 tag 指向已审查的 chart 7.0.2、Server 0903、Client 0903 release tree;
  4. Draft artifact、checksum、SBOM、attestation、签名 RPM 全部通过校验;
  5. finalize 与 Docker release 发布 classic、distroless 两种镜像;
  6. 匿名下载与 pull 测试通过;
  7. 发布说明用 tagged fact 更新,文档站部署完成。

第 1~6 项任一失败都是 release blocker;本地绿灯无法替代它们。

部署特定停止条件

即使版本成功发布,以下条件无法满足的运维方也应延后升级:

  • 无法在同一维护操作中更新分布式集群全部节点;
  • 无法在使用桶级 CORS 前更新站点复制组全部成员;
  • 无法调整 s3:DeleteObjectVersion 与 status-action separation 相关 IAM 策略;
  • 业务依赖 #10、#77、#79、#99 或 #100 路径,却不能显式接受对应已知限制。

复审时的最终结论有意比“所有问题都已修好”更窄:经审候选已经可以进入发布机器;剩余限制全部显式;生产发布由可验证构件把关,而不是由信心把关。 上述门禁后来已为链接的正式版本闭环;部署特定条件仍然有效。

重启与读回验证(2026-09-11,#116)

后续的一次验收(#116,2026-09-11 执行)补上了就绪判断中"重启持久性"这一半。有长期价值的是方法,任何运维者在验证重启或升级窗口时都可以复用:

  • 确认台账。 每次被确认的 PUT 写入唯一的带版本键,确认时立即记录 VersionId、字节数与 SHA-256。读回按精确 VersionId 取回并断言三者一致——早期确认不可能被后续写入悄悄替换。
  • 读回时机。 数据 canary 成功后 15/30/60 秒经所有对端周期性重读;最终检查包含单节点中断期间写入的数据及其 rejoin 之后的写入。
  • 不允许重试掩盖。 每个 canary 带 60 秒硬性期限,覆盖 setup、请求、响应体读取与 sleep;SDK 重试禁用,恢复窗口不能被客户端重试糊弄过去。
  • 驱动拓扑。 同一主机上四个 Linux/arm64 容器,独立网络身份,每节点一盘(EC 2+2);tmpfs 卷由 holder 容器在整个停机期间保持挂载;节点以 10 秒宽限期并行优雅停止,再并行启动。

值得记住的运维结论:admin 端点就绪不等于数据就绪。 在记录的这次运行中,候选版本的 admin 端点约 2.5 秒上线,而第一个完整数据 canary 到约 14 秒才完成。在该窗口内,对 admin/health 的就绪探测对数据面一无所知,任何固定 sleep 都不能替代实际的数据面检查。

边界按边界陈述:该验收覆盖单台 Linux 主机上的进程/容器重启与 TCP 对端重连。它不证明跨独立主机、主机重启或物理介质故障的持久性;计时是个体观测,不是延迟保证。运行工件保留在文档树之外;上文方法是可泛化的部分。

6 - 副本元数据归一化:可信复制不得重新注入什么

本文记录可信复制接收端如何为副本恢复元数据的修复,合入 Server main 为 PR #194(修复 4fcdf37ce,合并为 9f3037e941)。

截至 2026-09-16: 修复在核验过的 main 40220bd836cb 上;不在已发布的 Server 20260903 中。
来源: 生产逻辑采纳 Mikhail Khadarenka 的 PR #187;合并后的改动保留其作者身份,并把范围收敛到经评审验证的边界。
证据类别: 针对真实单盘与 16 盘纠删后端的 64 叶 HTTP 级基线(修复前 44 对照通过 / 20 缺陷失败),以及同一套测试对基线 helper 的反事实重放。没有客户事故被归因。

哪里错了

普通 PUT 路径会对元数据归一化:从 Content-Encoding 中剥掉仅传输用的 aws-chunked token,并删除某项 GHSA 缓解刻意移除的 X-Amz-Meta-X-Amz-Unencrypted-Content-Length/-Md5 用户元数据键。而可信复制 接收端恢复副本元数据时,却以"允许复制"的开关重新运行同一个宽松提取器——把 原始请求的全部受支持头与用户元数据重放一遍。具体表现为,可信副本写入时服务器可能存储、并在之后的 GET/HEAD 返回:

  • Content-Encoding: aws-chunked(纯传输编码,按 AWS SigV4 streaming 规则绝不能存储),或未拆分的 aws-chunked,gzip 整串(应为 gzip);
  • 两个 GHSA 脱敏用户元数据键——对该缓解的部分回退,仅限可信副本写入;
  • 无自身 PAX 头的 Snowball 条目:外层归档的 content-type、cache-control 与用户元数据。

对象字节本身不一定受损;是存储的元数据错了。该回归由 56fa63bfd (2026-04-15,复制头信任边界加固,CVE-2026-34204)引入——其信任保护本身是对的,予以保留。

修复

只改一个文件(cmd/handler-utils.go)。删除布尔双模式 helper:

  • 普通提取器无条件跳过复制专用键;
  • 新的副本提取器只遍历复制到内部的头映射,恢复六个复制域字段:SSE-C 密封密钥材料、密封算法、IV、加密 multipart 标记(空标记按键存在性生效)、实际对象大小,以及 SSE-C checksum 恒等映射;
  • 绝不重新读取普通受支持头或用户元数据。

修复后期望的存储编码:

请求编码 存储的 Content-Encoding
aws-chunked
aws-chunked,gzip gzip
gzip gzip

aws-chunked, gzip(注意空格)仍存储带前导空格的 gzipgzip, aws-chunked 仍存储整串。这些是记录在案的现状,由测试按现状断言——不是修复声明。

运维可见变化

  • 无 PAX 头的 Snowball 条目的可信副本不再继承外层归档的普通元数据。六个复制域字段仍作用于已授权条目。这与普通(非信任)Snowball 行为一致,且仓库内没有生产代码发送 auto-extract 标记,没有内部依赖旧继承行为。
  • GHSA 脱敏键不再在副本恢复时被写回——与每次普通 PUT 的行为一致。
  • 认证、权限门控与复制信任语义不变;普通提取路径逐字节等价。
  • 回滚代码会重新打开注入路径,但不会修复已存储的元数据。

升级不会修复存量对象

升级阻止新的污染;不扫描、不改写既有对象。两个后果值得注意:

  • 权威来源仍被污染的对象在升级后会被判为不一致,并在 heal/resync 中被反复选中做元数据复制。先修复权威来源,再让副本收敛。
  • 普通 S3 自 COPY 不是通用的修复 API:它会创建新版本或移动时间戳,而不是原位改写单个版本的元数据。

存量元数据修复提案——状态

一个未来操作的设计已经存在:构建清单(包含非当前版本,不能只查最新);通过与可信来源版本或独立校验值比对来核验——绝不凭错误的响应头猜测,也绝不因为标签写着 gzip 就重新解压;先处理权威来源的精确版本,再收敛副本;保留不可变清单与元数据备份;小批量验证并演练过回滚。对没有受支持路径的对象,停下来不动它——直接编辑 xl.meta 不是受支持操作。

所选操作必须保护需要保留的版本身份与当前版本关系、Object Lock 保留期与 legal hold、标签、复制状态和加密上下文;写入前检查并发变更,受阻或无法核验的版本保持不动。先在本地克隆中证明具体操作与回滚可行,才能把提案变成可执行 runbook。

这是一个等待单独批准的设计提案,不是已执行的程序。 作为其一部分,没有进行任何生产清单扫描、对象写入、版本调整或部署。把它当作未来 runbook 的形状,而不是已验证的 runbook。

已知限制

  • POST 表单上传路径(bucket-handlers.go)直接调用低层提取器,从不归一化编码;该行为不变,已另立 issue。
  • 本地验证在测试专用的容量适配(宿主盘满)下运行;R4–R8 集成记录中的合并树复跑覆盖了未改动树的情形。
  • 不声明双站点调度、重启或网络故障验收。

验证

回归测试(TestExtractReplicationMetadata*TestAPIReplicaContentEncodingTestAPISnowballReplicaContentEncoding,外加含 race 的信任/SSE-C 回环)覆盖映射表、六个恢复字段与普通路径等价性;反事实运行(同一套测试对基线 helper)复现 20 个缺陷失败,证明测试确有咬合力。升级摘要见组件版本矩阵;姊妹修复见复制标签排序

7 - 请求头截止时间:绝对头部上限与滚动的正文空闲超时

本文记录 Server HTTP 读取截止时间的修复,作为 PR #196 的一部分合入 main(修复 055030ea5)。

截至 2026-09-16: 修复在核验过的 main 40220bd836cb 上;不在已发布的 Server 20260903 中。
证据类别: 合成实验——直接 TCP 对照(头部限 100 ms、头部用 400 ms 完成,标准 Go 拒绝而旧 SILO 返回 204),以及真实单盘进程探针(flag 与环境变量两种入口都拒绝 400 ms 慢头,且健康请求继续服务)。没有生产事故被归因;促成调查的 issue 是一份慢速 HTTP DoS 扫描器报告,未在已部署集群上复现。

一条连接上的两类超时

  • 请求头绝对截止时间。 ReadHeaderTimeout 约束从开始读头到读头完成的总时间,滴入字节不能延长它。在 HTTP/1 keep-alive 连接上,Go 先用 IdleTimeout 等待下一请求的起始字节,再开始新的头部截止时间。ReadHeaderTimeout 还参与单独的 TLS 握手超时计算。
  • 正文滚动空闲超时。 头部解析完成、连接进入活跃阶段后,回到既有滚动语义:读取会续期,由配置的 idle timeout 限制字节之间的停滞。本次 HTTP/1 修复不限制持续有进展的上传/下载总时长;其他协议、代理与应用超时仍可能生效。

修复之前,第一类超时实际上不存在:连接层包装器在每次部分读取前把 socket 截止时间重置为 now + idle + 250 ms,覆盖 net/http 设置的任何绝对截止时间——慢速读取者只要每个空闲窗口发一个字节,就能无限期占住连接。

两个独立缺陷

  1. 连接层中和了绝对截止时间。 DeadlineConn 包装器的读路径在每次部分读取前重置 socket 截止时间,使 Go 服务器设置的读头截止时间失效。直接 TCP 基线单独证明了这一点:头部限 100 ms、空闲窗 2 s 时,400 ms 才完成的头部被接受。
  2. 配置从未到达服务器。 CLI 接受 --read-header-timeoutMINIO_READ_HEADER_TIMEOUT,默认值也正常解析——但服务器上下文构建器复制了 IdleTimeout、完全丢掉 ReadHeaderTimeout,运行中的服务器看到的始终是零。只修缺陷 1 时真实进程仍接受慢头;第二处修复是紧邻 idle-timeout 绑定的一行。

长期不可见的原因:flag 默认值(30 s)与 idle timeout 默认值相等,而 flag 未接线时服务器回退到的恰好是同一个 30 s——所有可观测的默认行为都像配置过一样。

配置

  • 旗标: --read-header-timeout
  • 环境变量: MINIO_READ_HEADER_TIMEOUT
  • 默认值: 30 s(与 idle timeout 默认值相等)
  • 两个超时都没有 YAML 配置字段;值在启动时按 flag > 环境变量 > 默认 一次性绑定。
取值 效果
header > 0 HTTP/1 头部阶段的绝对上限;参与 Go 的 TLS 握手读取窗口(含 HTTP/2 握手)
header = 0(显式设置) 回退 Go 规则:读超时(= idle timeout)生效;CLI 默认值是 30 s
header < 0 关闭头部专用上限;正值的读/写超时仍限制 TLS 握手读取,正值 IdleTimeout 仍限制 keep-alive 等待,并非取消所有连接超时
缩短 idle、未设 header 头部阶段独立使用 30 s 默认值——唯一比朴素预期"更松"的组合,但仍严格紧于修复前的无限续期

各协议得到什么

  • HTTP/1:头部与 keep-alive 等待是绝对的;正文保持滚动空闲超时。连接状态钩子与调用方钩子组合而非替换。
  • TLS:握手读取取正的 header 截止时间与既有读/写超时的最小值;握手完成后重新开始一个新的头部上限。握手的写入侧仍是滚动的——本修复不是完整的 TLS 握手资源限制。
  • HTTP/2:不动。协商到 h2 时完全跳过相位切换;h2 保留自身原生的绝对 per-stream 读超时,ReadHeaderTimeout 根本不进入 h2 配置。
  • 内部调用方:Linux 内部节点拨号使用自己的滚动语义;grid hijack 的连接在任何相位切换之前就解包回裸 TCP 连接。

被否决的方案

  • 全局钳制所有未来截止时间。 Go 1.27 在部分路径设置整请求截止时间;在读超时等于 idle timeout 时,这会硬顶整个 HTTP/1 请求——头部加正文——杀死所有大上传。
  • 去掉读超时、把零解释为滚动 idle。 零是 net/http 对后台读取与 hijack 连接的"永不超时"语义;重新解释它会破坏长 handler,且 h2 会失去 per-stream 超时。
  • 包装正文读取器 / response controller。 完整的 chunked/drain/EOF 记账加 h2 特判,远超头部缺陷所需。(后续一个未合并的分支为同一 DoS 族探索了正文侧的 response controller;截至本记录,它不是 main 的一部分,也不在本修复的声明范围内。)
  • 在钩子里复刻标准库的截止时间算术。 复制会随 Go 版本漂移的 stdlib 内部逻辑;记住 stdlib 实际要求的值才是稳健做法。
  • 按值比较自动推导严格度。 三个默认值都是 30 s 时,“短于空闲窗口"在生产默认下不可区分;这种逻辑只在测试配置下有效。

验证与限制

测试固定了连接包装器跨三个连续更新周期(绝对上限不外推)、HTTP/1 keep-alive / TLS / 仅 HTTP/2 协商的相位切换,以及真实 CLI 上下文的 flag/env 绑定;进程探针让一个 100 ms 头部上限的活服务器拒绝了 400 ms 才完成的头部。已知限制:TLS 握手写入侧仍为滚动;handler 的 CPU/存储等待没有截止时间;绝对头部上限无松弛而滚动 idle 保留约 250 ms 的更新松弛;多节点、跨区域长传输验收是后续工作——集成记录明确不把脚本化的 S3 长传输计为本修复的通过项。

升级注意(更短的头部超时同时收窄 TLS 握手窗口;它不是上传/下载的总时长限制)见组件版本矩阵

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

2026-09-13 后续状态: 单对象 If-Match DELETE 后来经 40bee4b7b 合入,主分支又修复了多池串行化与回收。最新公开 Server 20260903 不含这些后续改动;下文保留 8 月 26 日的方案状态。见组件矩阵Server changelog

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

截至 2026-08-26 的状态: PR #12 仍然 open,原 head 为 5b71a75e,落后最新 main 118 个提交。原提交没有 DCO sign-off,GitHub 上没有 check run。本文记录的改进方案已在独立本地 worktree 中实现、测试并完成两轮 review,并已提交到本地分支 codex/pr12-conditional-delete;尚未推送、合并或发布。
本轮范围: 正确实现单对象 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/silo 镜像、部署和真实 S3 客户端验证分别完成后,生产用户才能依赖它。

结论

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

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

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

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

状态: 已随 SILO Console 2.2.0 发布 · 归属: 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;
  • 有损查看另立独立方案。

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

10 - 数据库通知统一连接串:#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。

11 - Go 1.27 TLS 默认值与 OIDC Discovery 故障形态

SILO 工具链迁移到 Go 1.27 后,Server TLS 修复 48e184652 (“fix(tls): honor Go key exchange defaults across transports”)移除了显式曲线覆盖。本文记录 TLS 层的变化、issue #154 调查中形成的按阶段诊断方法,以及管理员在身份系统启动即失踪时需要的事实。

发布边界,截至 2026-09-16: Server 20260903 已使用 Go 1.27.1,但不包含 48e184652;后者是 main 上的后续 TLS 修复。升级编译器与采纳该修复是两项不同变更。

先声明证据类别。 以下每个机制都经合成实验验证:ClientHello 抓取、新鲜进程 CA 探针、夹具复现。#154 客户的 discovery URL 与入口配置始终未获得,因此不对该部署做任何根因断言——两个本地已验证的机制都能产生所报症状:入口拒绝新握手,或代理拒绝变更后的 User-Agent,均有可能。#154 保持打开,等待受影响环境复测。

Go 1.27 改变了什么

  • 显式曲线偏好现在会压过 ML-KEM 兼容开关。 GODEBUG=tlsmlkem=0(及 tlssecpmlkem=0)只从默认曲线集合中移除后量子混合方案。显式配置 CurvePreferences 的应用会在它给出的列表里保留 ML-KEM——这是 Go 1.27 的有意变更。SILO Server 有 8 个 TLS 配置点显式设置了含 X25519MLKEM768 的列表;修复移除这 8 处赋值并退役该 helper,使这些配置点遵循 Go 默认值,兼容开关重新生效。栈评审确认 pkg、mcli 和 Console 客户端原本已使用默认值;Console HTTPS 监听器保留单独的 P-256 策略。
  • ClientHello 新增 ML-DSA 签名算法编号0x09040x0906)。ML-DSA 是签名方案,与 ML-KEM 不同:禁用混合密钥交换不会禁用 ML-DSA offer,拒绝 ML-DSA 的入口不会被任何 ML-KEM 开关修复。
  • ClientHello 变大。 同源码同依赖实测:Go 1.26.5 默认 1497 字节;Go 1.27.1 默认 1509 字节;tlsmlkem=0 下的旧显式列表产生 275 字节、无 ML-KEM 的 hello,而 Go 1.27.1 加显式列表仍产生含 ML-KEM 的 1509 字节。仅更换编译器就改变了握手。
  • macOS 根 CA 行为随模块 go 指令翻转。 新鲜进程是遵循 SSL_CERT_FILE/SSL_CERT_DIR 还是 Keychain,由 x509sslcertoverrideplatform GODEBUG 默认值决定,而它跟随主模块的 go 指令:go 1.26 模块在 macOS 上忽略这两个变量(平台库优先),go 1.27 模块遵循——且以消费应用的指令为准,库模块更旧也不妨碍新行为。macOS 上的运维者应知道:设置任一变量都会用给定文件/目录整体替换 Keychain 信任;过期或不完整的路径会破坏 Keychain 本可接受的链,取消设置即可恢复。
  • 并非一切都变了。 TLS 版本、密码套件、证书与主机名校验、代理处理、HTTP/2 选择均不受影响。标准库 drain 上限(256 KiB / 50 ms)等已审计的 Go 1.27 变更无 SILO 依赖。Go 1.27 二进制要求 macOS 13 或更新。降级不受支持:模块图在 Server、Console、mc 三处都要求 Go ≥ 1.27.1。

为什么撤回了 OIDC 专用补丁

调查最初产出一个最小候选:只在 OIDC discovery transport 上清空 CurvePreferences。该候选没有发布。同一个 transport 还服务于身份插件、通知与 lambda 连通性检查、审计 webhook、S3 云后端分层——只修两个 OIDC 调用点会让其余消费者继续留在有缺陷的显式列表上。合并后的修复在这 8 个 Server 配置点移除显式曲线,使兼容开关在这些位置生效,保持证书校验严格,不加协议降级或自动回退。归档的单 transport 补丁不得再叠加到已合并修复之上。

按阶段诊断 discovery 故障

启动链是:服务器启动 → 身份系统初始化 → 抓取 .well-known/openid-configuration(discovery)→ 抓取 jwks_uri 密钥 → IAM 就绪 → Console 初始化。Console 自身的 OIDC 配置对话框经同一服务端 transport 校验。链条任何一环失败都会使 IAM 离线;discovery 成功不代表 JWKS 抓取成功,JWKS 503 与 discovery 失败一样阻塞 IAM。

按连接死在哪一层区分:

  • TLS ClientHello 之后立即 resettls_start 后 reset):怀疑入口的 ClientHello 处理——代理 CONNECT 规则、TLS 终止器、任何按键大小或内容匹配的逻辑。Go 1.27 的变化都落在这一层。
  • TLS 完成后 resetwrote_request 后 reset):TLS 层没问题;查 HTTP 层策略——WAF 规则、User-Agent 白名单(服务器 UA 已随 rebrand 从 MinIO 变为 Silo)、路由。此时换证书或换密钥交换没有针对性效果。
  • x509 错误:对比实际收到的链、SNI、以及进程解析到的信任库(见上文 macOS 一节)。
  • 务必从与故障进程相同的网络位置测试——新起容器不会继承故障容器的网络命名空间;同 IP、同代理的对照先行。

说真话的健康端点

IAM 离线期间,/minio/health/live/minio/health/ready 都保持 200——现有就绪检查不覆盖身份系统。真正报告它的是 /minio/health/cluster:它检查身份初始化,返回 503 并携带 X-Minio-Server-Status: iam-offline 标记。要捕捉 IdP 集成断裂的监控应探测 cluster health,外加一次已认证操作。

恢复是自动的:身份初始化以随机 0–3 秒间隔重试,IdP 恢复后 IAM 无需重启即回来(本地观测从亚秒到约 1.4 秒)。重试不能修复持续性不兼容——入口拒绝的 hello 会一直拒绝。

值得知道的 transport 事实

discovery/JWKS 客户端自建 transport:禁用 HTTP/2(无 ALPN、HTTP/1.1)、代理只取 HTTPS_PROXY/NO_PROXY(大写优先;不用 ALL_PROXY)、30 秒 DNS 缓存、拨号按序遍历地址不做 shuffle、超时为每次 TCP 拨号 5 秒、TLS 握手 10 秒、响应头 1 分钟。discovery 或 JWKS 抓取本身没有总超时——缓慢的 IdP 可以无限期拖住启动;收紧它是已评估过工作量的独立后续项。

归属

本文提炼自 issue #154 调查与九月的 Go 1.27 工具链栈评审;复现工件与完整证据链保留在文档树之外。可支持的表述是:合并后的修复在受影响的 8 个 Server 配置点恢复 Go 密钥交换默认值,并经合成负对照验证——它不声称诊断了任何特定隐藏部署,#154 在受影响环境复测前保持打开。

12 - 鉴权前不做 I/O,Header 不授予权限

本文记录以 PR #10193860345804b097fd9)合并进 SILO 的 CORS 热路径与复制请求信任边界修复。

截至 2026-09-03 的状态: PR #101 已于 2026-09-01 合并进 main,并带四个后续提交:Snowball 逐条目信任隔离(ff44527a3)、Snowball worker 间保留请求默认值(ab3ae99ca)、复制有效性探针校验复制权限(c9ad74673)且合成 key 置于规则前缀下(5db7be4ee)。实现、定向与 race 测试、完整服务端 package 套件、对象锁测试、vet、build、两轮 Fable 5 设计评审、多轮 Opus 5 对抗验收,以及真实本地 TLS 双站复制均已完成。随后的发布前清理保留了 resident-only 查找及其启动期与加载失败的 fail closed 状态,只去掉了内部 namespace 的特殊分支;剥头后的请求克隆与原请求共享 trailer,使不可信请求的流式校验和上传照常工作。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 合法 应用桶级规则;refresh 失败时沿用最近一次加载的文档,与其它所有桶配置一致
resident,没有 CORS 文档 使用 global CORS fallback
resident,持久化 CORS 非法 fail closed;继续处理请求但不加 CORS header,并只记一次日志
启动加载仍在进行时的不驻留名字 fail closed
启动完成后的不驻留名字:metadata 加载失败的真实桶 fail closed
启动完成后的不驻留名字:reserved、非法、内部或未知 global fallback

查找只看驻留 map 和一个有界集合——启动或 refresh 时 metadata 加载失败的真实桶。该集合只从磁盘得到的桶列表写入,客户端路径无法增长它,也从不记录已经驻留的桶;加载成功、Set、桶删除、stale 桶清理与 subsystem reset 都会清除相应条目。两种不驻留状态都 fail closed:预签名 URL 以自身签名完成认证,桶级 CORS 文档是浏览器对它施加的唯一 origin 边界,若回落到全局策略,泄露的 URL 就能从任意 origin 使用。内部 .minio.sys namespace 不再特殊处理:它与任何 reserved 或非法名字一样不是桶,使用 global fallback,请求随后会在下游被拒绝。

被否决的方案

方案 为什么否决
在旧 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;
  • 最小权限 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。

残余风险与后续

  • 2026-09-09 复制可靠性后续: 删除完成、MRF 可见性与 resync 取消 记录 #153、#152、#137 的复现、最小修复、Fable 评审与 PR #162 验收。它处理可信复制请求进入执行路径后的可靠性,沿用本文的权限边界。

  • 当 marker-bearing request 缺少复制权限时记录限频诊断;安全的 ordinary fallback 否则容易被误诊为 ETag/MTime 不一致。

  • Replication validity probe 现在会校验目标凭据所需的复制权限,并把合成的校验 key 放在规则前缀之下(c9ad746735db7be4ee)。

  • 本次覆盖已命名的 source/replication header。未来任何内部控制都仍需回答同一个问题:哪一个认证后的决定允许这个客户端值获得内部含义?

结论

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

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

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

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

本文完整记录 上游 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 的服务器提交 229fe2b3c 现已根据目标状态选择 admin:EnableGroupadmin:DisableGroup,并加入真实四向 IAM 鉴权测试。本地验证与独立评审完成;已于 2026-08-29 合并进 main,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 的提交 完成 229fe2b3c(已在 main
Push、PR CI 与 merge 完成 已于 2026-08-29 合并
SILO tag 尚未确认 尚未确认
Release package 或 container image 尚未确认 尚未确认
部署 尚未确认 尚未确认
生产行为 尚未确认 尚未确认
上游合并 不可用;仓库已归档 不适用

结论

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

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

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

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

截至 2026-08-28 的状态: 实现、定向测试、完整 cmdinternal 套件、tagged tests、race、vet、lint、生成物检查、rebrand 守卫、构建与本机 Fable Max 独立评审均已完成。该提交已于 2026-08-29 以 2aea7fe9c 合并进 main;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 保障。

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

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

截至 2026-08-28 的状态: 实现、加密与换钥测试、完整服务器套件、race、静态检查、构建和 Fable Max 独立验收均已完成。该提交已于 2026-08-29 以 e73436c99 合并进 main;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。真正耐久的修复不是新增加密机制,而是建立明确上下文边界,然后只做一次解密、两次如实响应投影。

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

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

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

状态:已随 SILO Console 2.2.0 发布(16960f7ab);服务端自更新 Console pin(4d6e1ea8e)起内嵌该修复 · 优先级: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 撒谎所必需的。下一阶段维护迭代应先恢复最小而诚实的契约:已知总量才显示百分比,未知总量就保持未知。

18 - 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/silo 镜像、部署与真实 S3 客户端验证分别完成后,才能宣称用户已经获得修复。

结论

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

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

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

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

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

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

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

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

状态: 已于 2026-08-24 以 7fea6d5a5 合并进 mainpgsty/silo#46 已关闭);发布与线上验证待完成。
归属: 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 契约。

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

21 - 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 的服务器提交 7e079ff05 关闭剩余 type-only 与非法 token 绕过,同时保持 CRC64NVME canonicalization 不变。完整本地、tagged、race、静态、构建与 Fable Max 验收均通过;已于 2026-08-29 合并进 main,tag 与交付仍待后续。
2026-09-02 更新: 下文描述的 CRC64NVME 例外已不再成立。main 现在在 multipart 初始化、trailer 与 completion 三处以 InvalidArgument 拒绝 CRC64NVMECOMPOSITE 的组合(d28885d0ed4c8da16232b2aa49f),pgsty/silo#50 以拒绝而非 canonicalization 收口。下文的推理保留为当时决策的记录。
归属: 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 的服务器提交 完成 7e079ff05(已在 main
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 精确响应之前,贸然修改只会用一个未经验证的假设替换另一个。

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

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

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

9 月 16 日实现与升级契约

PR #198 保留 mr javad seydi 的原始元数据与扫描实现,并追加维护者对 marker 消失、发现覆盖不足、取消确认和升级诊断的修复。发布前的兼容性修复将默认模式恢复为 legacy,严格模式仅通过进程环境显式启用。本节说明这些源码变更。Server 20260903 不包含此修复;源码实现也不代表生产性能与部署验收完成。 下方 8 月 30 日的分析保留为历史设计记录。

列表与取消语义

新上传将 bucket 与原始对象键写入既有 xl.meta,完成时删除这些上传专用字段。严格列表跨 pool/set 发现持久上传,以既有读取 quorum 验证元数据,再全局处理 prefix、delimiter、CommonPrefixes 和最多 1,000 项的分页。节点重启或切换入口无需等待上传缓存重建。

排序为 (key, 原生上传 ID 中的发起时间, 编码后的 upload ID)。即使 marker 对应的上传已完成或取消,返回的 marker 仍然表示这个边界。该契约支持客户端回传服务端 marker,不承诺对随机上传 ID 做任意字典序比较。并发变更下,跨页遍历不是快照。没有 key-marker 时忽略 upload-id-marker;有 key marker 时,非法 base64 保留既有 404 行为,可解码但不支持的原生 ID 返回 400。不能解析的持久 ID 属于旧格式,不会用当前时间伪造发起时间。

每个 set 的目录枚举需要 floor(N/2)+1 块盘成功。例如四盘只有两盘可扫描时返回 503,即使两份元数据仍可读取。只有验证 bucket/key 与目录哈希一致后,单来源盘身份读取才能排除其他 bucket;不确定时升级为 quorum 读取。严格模式遇到旧格式返回 MultipartListingNotReady(503),身份不合法返回 MultipartListingMetadataInvalid(503),不会让整个请求悄悄退回缓存列表。

默认 legacy 模式下,Abort 保留已发布版本的读取 quorum、尽力清理和按 pool 顺序返回的行为,避免把原本可成功的取消变为 503。strict 模式检查每个相关 pool,删除需要 floor(N/2)+1 份确认,元数据已低于读取 quorum 的残片也可以重试清理。多数盘已不存在、但仍观察到残片时,必须成功清理这些已观察副本;空盘的成功不能掩盖残片盘的删除失败。未知 pool 或确认不足仍返回 503。

两种模式都不会因错 key 或错 bucket 的 Abort 清除另一个有效上传的缓存。S3 HTTP 层保留既有幂等行为:上传不存在也返回 204;格式错误、权限不足或 quorum 错误仍返回错误。204 不是所有物理副本已删除的证明,离线分片仍可能等待后续清理。

尚未解决的创建写入边界: 如果创建上传的物理写入在调用方收到存储超时后继续执行,以上确认不能阻止它迟到提交。故障注入已复现 16 盘/EC:8 场景:取消成功后,七份迟到写入加上七份离线旧副本,可以恢复出可继续写入的上传。测试保留了这个已知限制;测试通过不表示此问题已修复。持久创建屏障需要独立的存储一致性设计。

协调升级

默认使用 legacy,保留原有精确键与缓存列表限制。普通升级无需为了启用新列表而暂停生产或强制排空上传。模式只读取服务器进程环境变量 MINIO_API_MULTIPART_LISTING=legacy|strict,不新增共享配置项。未设置时采用 legacy;非法值会记录诊断并回退 legacy,其他 API 配置继续生效。不要使用 mcli admin config set 设置此模式。

只有准备启用严格模式时,才需要先升级所有 writer,停止产生旧格式上传,再使用已知 key/ID 完成或取消旧上传,执行以下只读预检,并验证扫描容量能够满足实际负载。通过后,在每台服务器的服务环境中设置 MINIO_API_MULTIPART_LISTING=strict 并重启;核对每个入口的生效模式。切换模式后应从头开始分页遍历。默认模式保留的列表缺陷仍由 issue #79 跟踪,本批不宣称完整修复。

如果曾运行写入 api multipart_listing 的开发版本:先备份配置并记录 API 设置,让所有配置写入者升级到修复版,停止并发配置写入,再只删除这个历史键:

mcli admin config reset ALIAS api multipart_listing
mcli admin config get ALIAS api

修复版会忽略历史键的模式值,但不会自动改写共享配置或删除历史记录。config get/export 会隐藏退役键,因此列表里看不到它不代表已经删除。应确认单项 reset 成功,重启后核对原有设置,并在受控回滚检查中验证旧版读取;不要重置整个 api 子系统。旧开发版本再次写入配置可能重新引入该键,带键的历史配置也不应直接回放。需要临时保护的 API 设置可按原值放入相应环境变量,但这不能替代持久配置清理。此步骤只处理本项 API 配置兼容性,其他变更的回滚条件仍需独立核对。

只读预检接口为 GET /minio/admin/v3/multipart-preflight,使用 SigV4 签名,要求 admin:StorageInfo 权限。例如,由操作者提供凭据与地址:

curl --aws-sigv4 'aws:amz:us-east-1:s3' \
  --user "$SILO_ACCESS_KEY:$SILO_SECRET_KEY" \
  "$SILO_ENDPOINT/minio/admin/v3/multipart-preflight"

报告包含 modereadycompletescannedEntrieslegacyUploads,以及各 pool/set 的盘覆盖、未覆盖盘序号与最老旧上传的发起时间。它绕过上传缓存,检查包括暂停 pool 在内的持久状态,并识别只剩少数副本的旧上传。ready=true 要求全部盘可检查、候选元数据没有不可读状态、且未发现旧格式副本。它无法证明所有 writer 都已升级,也不能阻止并发旧 writer 再引入旧格式。离线盘、扫描错误、超时和预算耗尽均不能报告就绪;不完整计数不能当作零。磁盘恢复后以及切换严格模式前应重新预检。

丢失原始 key/ID 的旧上传由既有 stale-upload 清理器处理,各服务器扫描自己的本地盘。年龄按创建时间计算,不会被最近上传分片的活动刷新。保留现有清理策略,等待并验证实际排空;默认 24 小时过期、6 小时清理间隔不等于排空保证。不能据此缩短过期时间催促普通升级,因为这也会删除仍在进行的长上传。无法排空时继续使用默认 legacy 模式。本批不改变清理规则,也不新增按任意路径删除的管理接口。

扫描容量与证据边界

每个进程最多同时接受两个扫描,每扫描使用 16 个身份读取 worker 和四个全盘元数据读取 worker。目录读取传递有限 count 并检测溢出。请求合计预算为 100,000 个返回的目录条目,包含不同盘上的重复条目和哈希目录,不代表支持列出 100,000 个唯一上传。合计超限时,其他并发目录请求可能已经发出;超限返回 SlowDown(503),不能返回成功的局部页面。30 秒 context 预算停止后续调度,扫描 worker 退出前仍占用并发名额。这不是精确内存上限,也不保证已进入系统调用的物理 I/O 立即停止。

每一页仍需重扫持久状态,遍历成本随存储候选数与页数共同增长。测试覆盖 marker 消失、多 pool 覆盖、部分删除重试、身份读取回退、RPC 目录边界、取消后并发名额,以及已知迟到写入反例。临时多节点与维护客户端测试只证明记录环境中的功能行为;生产规模延迟和前台负载影响仍需按部署验收,源码合并不等于性能认证。

9 月 16 日的临时 Docker Desktop arm64 测试使用两节点、四个 APFS 绑定卷,两个 bucket 共 11,000 个上传,期间还有其他本地验证负载。目标 bucket 有 10,000 个上传,单次返回 1,000 项耗时 18.7 秒;两个并发请求约 25–27 秒后返回可重试的 SlowDownRead。这组观察没有达到暂定的单页五秒目标,也不是隔离的 SSD 基准。容量和前台负载验收仍未完成,不能把有界扫描宣传为大规模性能修复。

先用最简单的话说明问题

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

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。