这是本节的多页打印视图。 .
设计归档
- 1: 写死 TLS 参数与握手兼容性
- 2: 桶配置复制:来源时间、删除与确定性收敛
- 3: 未签名的 Header 不属于请求
- 4: Object Lock 复制排序
- 5: SSE-C 副本完整性
- 6: 持久化 IAM 撤销
- 7: 多池对象一致性
- 8: 联邦 CopyObject:保持目标对象契约
- 9: 复制可靠性:删除完成、MRF 可见性与 resync 取消
- 10: 复制标签排序:修订时间戳、墓碑与复活
- 11: SILO Server 20260903 发布前复审
- 12: 副本元数据归一化:可信复制不得重新注入什么
- 13: 请求头截止时间:绝对头部上限与滚动的正文空闲超时
- 14: 条件 DELETE:一个条件对应一个逻辑对象
- 15: 只预览文本,绝不执行:SILO Console 文本预览 PRD
- 16: 数据库通知统一连接串:#53 的兼容性边界
- 17: Go 1.27 TLS 默认值与 OIDC Discovery 故障形态
- 18: 鉴权前不做 I/O,Header 不授予权限
- 19: 一个端点,两种权限:彻底分离用户与组状态
- 20: IAM Admin API 授权边界修复
- 21: 配置环境文件不是 Shell 脚本
- 22: 两把 SSE-C 密钥,一份 CopyObject 响应
- 23: 为什么 CompleteMultipartUpload 必须返回 ChecksumType:PR #57 评审记录
- 24: 总量未知时,进度条应该说什么
- 25: 列举不能丢掉仍有仲裁的 null 版本
- 26: ListObjects 快捷路径不能把不存在的桶伪装成空桶
- 27: 只读 Checksum 审计与可靠的 CLI 输出契约
- 28: 可选校验和,强制失败:修复 UploadPart 与 UploadPartCopy 兼容性
- 29: BadDigest、InvalidRequest 与 CompleteMultipartUpload 校验和契约
- 30: ListMultipartUploads:实现、升级契约与历史设计
- 31: 旧版元数据与扫描响应复用时的残留字段修复
- 32: LIST 漏项修复:只在有疑问时确认,并把等待控制住
- 33: 对象锁保留期越权与版本覆写漏洞修复设计(SN-2026-015 / SN-2026-016)
这里归档 SILO 维护决策背后的完整思考:要解决的问题、兼容性边界、被否决的方案、实现要求,以及进入发布版本前必须取得的验证证据。
阅读时先看日期与状态:设计提案说明尚待实现或验证的方案;已合入 main只说明源码包含改动;已发布必须关联实际 tag/发布说明。历史评审和当时的未完成项不自动代表当前行为,后续更正应明确版本。证据优先使用固定提交、PR、稳定测试及发布记录;本地测试、远端 CI、制品与生产部署分别计数。
1 - 写死 TLS 参数与握手兼容性
状态,2026-09-17。 Go TLS 默认值修复
48e184652已随 Server 20260916 发布。 issue #154 的报告人在该版本复测, OIDC discovery 仍然是同一条connection reset by peer。 该部署的根因至今未确认。 #154 于 2026-09-11 以「补丁已合并」为由关闭, 而不是以报告人复测通过为准;这个处理是错的。本文记录修复覆盖了什么、覆盖不了什么、以及必须改变什么。
更新,2026-09-17。 上游 Go 有一条 issue 报告了完全相同的症状(另一个产品), 另一条则给出了机制的完整诊断。两者汇总在上游的同款案例。 实际后果是:对受影响的运维,第一个该问的问题是什么设备在检查这条流量, 而不是在 SILO 里设什么。
证据类别。 下文的 Go 行为取自 Go 1.27.1 标准库源码与官方发布说明。 握手字节数与分支复现来自 #154 调查的合成夹具,记录在 Go 1.27 TLS 与 OIDC。 至今没有获得受影响部署的任何取证抓包。
先把本质说清楚
TLS 连接的第一句话叫 ClientHello,客户端在其中列出自己支持的算法。 很多企业网络会在链路中放一台设备——WAF、TLS 检查设备、负载均衡器——去解析这句话。 其中一部分设备在见到不认识的算法编号时不是忽略,而是直接重置连接。
Go 1.27 往这句话里新增了三个编号——ML-DSA 后量子签名方案——一共 12 个字节。
用 Go 1.27 重新编译后,SILO 在线上说的话就和 Go 1.26 时不一样了。
报告人 IdP 前面的某个环节不接受它,于是回了 TCP RST。
同一个容器里 curl 却正常,因为 curl 用的是 OpenSSL,不会提供这些编号。
Go 预料到了这类兼容问题,给出了逃生开关 GODEBUG=tlsmlkem=0。
这个开关有一个前提:它只在应用没有自行指定算法清单时生效。
SILO 带着一份从上游继承来的显式曲线清单,而这份清单里恰好有一个后量子条目——
于是开关对我们失效。2026-09-09 的修复删除了这些显式清单,让开关重新生效。
关键的限制就是全部故事:这个开关能关闭后量子密钥交换(ML-KEM), 关不掉后量子签名(ML-DSA),而多出来的 12 个字节正是 ML-DSA。Go 没有为它提供任何开关。 如果某个部署是被 ML-DSA offer 卡住的,这次修复对它毫无作用——这与复测结果一致。
Go 到底在解决什么问题
后量子密钥交换是一条截止线,不是偏好。 威胁模型是 harvest-now-decrypt-later:
攻击者今天把加密流量录下来,等若干年后有了足够强的量子计算机再解密。
因此机密性必须在这种机器出现之前完成迁移,而不是之后。
NIST 于 2024 年定稿 ML-KEM(FIPS 203)
与 ML-DSA(FIPS 204),
实际部署形态是混合:X25519MLKEM768 同时运行经典 X25519 与 ML-KEM 并合并两个秘密,
因此即便 ML-KEM 被发现缺陷,连接强度也不低于单独的 X25519。
浏览器、CDN 与 SSH 实现自 2024 年起陆续默认启用混合密钥交换,Go 在 1.24 跟进。
签名不在同一条时间表上。 签名不会被追溯伪造——2035 年造出的量子计算机
不会让 2026 年签出的握手失效。所以今天 TLS 1.3 里的 ML-DSA 只是声明支持,
并非实际依赖。Go 1.27 把 MLDSA44、MLDSA65、MLDSA87 加入默认签名算法列表,
那 12 个字节就来自这里:
这些编号只在 TLS 1.3 下定义。isDisabledSignatureAlgorithm 会在配置无法协商到
TLS 1.3 时将其剔除——如下文所述,这也是应用层对它唯一的杠杆。
这个开关本来就不管显式清单
真正把我们坑到的那处变更范围很窄,而且放在上下文里看是讲得通的。Go 1.27 发布说明原文:
Post-quantum hybrid key exchanges can now be explicitly enabled in
Config.CurvePreferenceseven if thetlsmlkem=0ortlssecpmlkem=0GODEBUG options are used. Those options were always meant to only apply to the default set used whenConfig.CurvePreferencesis nil.(后量子混合密钥交换现在可以在
Config.CurvePreferences中显式启用, 即使设置了tlsmlkem=0或tlssecpmlkem=0。这些选项本来就只适用于Config.CurvePreferences为 nil 时所用的默认集合。)
标准库自 Go 1.24 起就把两条路径写成二选一:
代码里就是一个分支:给了显式清单就原样使用,只有为空时才查默认集合,而 GODEBUG 住在默认那条路上。
其中的原则是显式配置应当压过环境变量——否则运维改一个环境变量, 就能悄悄推翻开发者写在代码里的安全策略。Go 1.26 及更早版本会把显式清单也过滤一遍, 因此 SILO 在 Go 1.26 上「开关有效」其实是依赖了一个 Go 认定的错误行为。
后续升级需要记住一点:Go 把 GODEBUG 的默认值绑定在主模块的 go 指令上,
声明旧版本的模块会保持旧行为直到主动上调。SILO 的 go.mod 写的是 go 1.27.1,
等于明确声明我们采用 1.27 的行为。
SILO 的技术债在哪,以及哪些还没还
出问题的清单是 {X25519MLKEM768, CurveP256, X25519, CurveP384, CurveP521},
继承自上游,后来被加入了那个后量子条目。写死它带来的是两头不靠:
既享受不到标准库默认值的演进,又用不了标准库提供的兼容开关。
48e184652 在全部 8 个 Server TLS 配置点移除该赋值——外部通用 HTTP transport、
复制 transport、带客户端证书的云 transport、节点间 transport、两条 grid 链路、etcd,
以及入站 S3/Console 监听器——退役 TLSCurveIDs helper,
并补充了跨 5 个出站构造函数、2 个对端 TLS 版本、2 种 GODEBUG 设置的线上级回归测试。
证书与主机名校验、密码套件策略、代理处理、HTTP/2 选择均未改变,
也没有引入任何失败后降级或重试。此前只改 OIDC 的候选补丁被放弃,
是因为同一个 transport 还服务身份插件、通知与 Lambda 可达性检查、审计 webhook 和 S3 云分层。
同一类债在下一层依然存在。 密码套件仍然写死:
crypto.TLSCiphers() 与 crypto.TLSCiphersBackwardCompatible() 被赋值在出站 transport、
LDAP、etcd、grid 链路和入站监听器上。监听器至少还有 MINIO_API_SECURE_CIPHERS
可以在两套之间切换,所有出站路径一个开关都没有。
下次 Go 调整套件默认值时,同样的剧本会再演一遍。
本记录采纳的通用准则:设 MinVersion,其余交给标准库。
如果确有不兼容的对端需要更窄的配置档位,把它做成产品配置——
在 mc admin config 与支持包里可见——而不是写死在源码里。
协议僵化是个老问题:TLS 1.3 不得不在线上伪装成 TLS 1.2,
GREASE 的存在正是为了逼中间设备容忍自己不认识的编号。
线上真正的差异
在同一个合成 IdP 夹具前测量,默认设置下:
| 构建 | ClientHello | ML-KEM (4588) | ML-DSA 0x0904-6 | User-Agent |
|---|---|---|---|---|
| 20260804,Go 1.26.5 —— 正常 | 1497 B | 有 | 无 | MinIO (…) |
| 20260903,Go 1.27.1 —— 故障 | 1509 B | 有 | 有 | Silo (…) |
| 20260916,Go 1.27.1 —— 已修复 | 1509 B | 有(默认) | 有 | Silo (…) |
20260804 + tlsmlkem=0 |
275 B | 无 | 无 | MinIO (…) |
20260903 + tlsmlkem=0 |
1509 B | 仍然有 | 有 | Silo (…) |
有两点容易被忽略,但至关重要。
能正常工作的版本本来就在发 ML-KEM。 除非运维在升级之前就设过 tlsmlkem=0,
否则 ML-KEM 不是新旧版本之间的变量,恢复这个开关也就无法单独解释或修复他们的故障。
默认设置下,两个版本之间只有两处变化: 三个 ML-DSA 编号,
以及改名带来的 HTTP User-Agent 变化(MinIO (…) → Silo (…))。
后者只有在 RST 发生于握手完成之后时才成立。
影响面,以及症状为什么具有误导性
写死的清单覆盖了所有 TLS 路径的两个方向,但后果差别很大:
| 路径 | 入口不兼容时的后果 | 可见度 |
|---|---|---|
| OIDC discovery / JWKS | 身份初始化阻塞,Console 不启动,节点不提供任何服务 | 立刻致命 |
| KMS/KES、LDAP(S) | 加密或目录认证不可用。这两处本来就用默认曲线,GODEBUG 一直有效 | 立刻可见 |
| 跨站复制目标 | 复制静默积压,只能从指标看出来 | 容易漏 |
| 审计 / 通知 webhook、Lambda 检查 | 审计记录丢失、事件不投递 | 容易漏 |
| S3 分层 / 云后端 | 转冷失败,远端对象读不回 | 容易漏 |
| etcd、节点间 grid | 集群内部流量,一般不经过这类设备 | 低 |
| 入站 S3/Console 监听器 | 客户端连不上;由对端的握手决定 | 取决于客户端 |
只有身份路径会阻塞启动,因此它最先被报告。其余几条会安静降级, 所以一个部署完全可能已经受影响而无人提单。
哪些部署有暴露面:
| 情形 | 原因 |
|---|---|
| 出站链路上有 SSL 检查 NGFW、SASE、SWG 或 IDS/IPS | 本次报告的场景,也是已确认存在厂商缺陷的那一类 |
| 出口经过会检查 TLS 的企业 VPN | 同一机制,作用于整条出口 |
| 环境按 JA3/JA4 客户端指纹做白名单 | 握手一变指纹就变,每次工具链升级都可能触发,且同样表现为无提示的重置 |
| 端点前面是老式 TLS 终止器或负载均衡器 | 不容忍未知扩展,或对跨分片的 hello 处理有问题 |
| 从用 Go ≤ 1.22 构建的上游 MinIO 版本迁移过来 | 那份握手既没有后量子密钥交换也没有 ML-DSA——约 275 字节对约 1509 字节。这个群体跨度最大,暴露面也最大 |
| 政策上不允许后量子算法的合规环境 | 他们需要经典档位是出于政策,而非互操作原因 |
出站链路上没有检查设备的部署不受影响,也不应设置下文任何兼容选项。
四个特性叠加,使这个症状在现场几乎无法诊断:身份初始化以随机 0–3 秒间隔重试;
discovery 与 JWKS 抓取没有总超时,启动可以无限期挂住;
身份离线期间 /minio/health/live 与 /minio/health/ready 都保持 200,
Kubernetes 就绪探针会报成功(只有 /minio/health/cluster 返回 503
并携带 X-Minio-Server-Status: iam-offline);
而错误文本 read: connection reset by peer 不说明 TLS 握手是否完成。
此时从同一镜像跑 curl 还会成功,因为 curl 用的是另一套 TLS 栈并协商了 HTTP/2。
20260916 自身带来的变化
移除写死清单不会恢复此前的线上格式,而是采用当前默认值,而它更大:
| 版本 | 实际提供的 supported_groups |
|---|---|
| 20260903 及更早(写死) | X25519MLKEM768, X25519, P256, P384, P521 |
| 20260916(Go 默认) | X25519MLKEM768, SecP256r1MLKEM768, SecP384r1MLKEM1024, X25519, P256, P384, P521 |
因此对于什么都不设置的运维,20260916 会在每一个 TLS 端点(入站与出站)
多提供两个后量子编号。对绝大多数部署这无害,
但一个按 group 编号做白名单的入口,理论上可能在 20260903 正常而在 20260916 失败。
GODEBUG=tlssecpmlkem=0 可以只关掉这两个并保留 X25519MLKEM768。
而对于确实设置了 tlsmlkem=0 的运维,20260916 产生的握手比此前任何版本都更小更保守——
这正是修复的目的。
三种机制,一个决定性事实
调查复现了三种都能产生所报错误文本的机制,而修复只覆盖其中一种。
| 分支 | 机制 | 20260916 是否覆盖 |
|---|---|---|
| A | 入口拒绝 ML-KEM,且运维在升级前就设过 tlsmlkem=0 |
是——修复恢复的正是这条 |
| B | 入口拒绝 ML-DSA 签名编号 | 否。只有 TLS 1.2 上限能抑制该 offer,而产品没有暴露这个设置 |
| C | HTTP 层规则在握手成功之后拒绝变更后的 Silo User-Agent |
否,任何 TLS 改动都与之无关 |
还有第四项观察,它能解释 curl 对照为什么具有误导性,但解释不了升级回归: discovery transport 禁用 HTTP/2 且不发 ALPN,而 curl 协商了 h2。 这个差异在正常版本和故障版本里都存在。
一个事实能把 A/B 与 C 分开,夹具结果能把 B 与 A 分开: RST 是紧跟 ClientHello 到达,还是在 GET 写出之后才到达。 在故障网络位置做一次抓包,或者拿到入口在同一秒的拒绝原因,就能定案。 这份证据从未向报告人索取过——本文存在的目的之一就是修正这个流程缺陷。
上游的同款案例
分支 B 不是假设。上游 Go 有两条 issue 描述了它:
- golang/go#81199,标题即
“add a GODEBUG to disable advertising ML-DSA signature algorithms in the
ClientHello (Go 1.27 regression against TLS-inspecting middleboxes)",
2026-08-28 至今 open。所报症状与 #154 完全一致:经过 TLS 检查防火墙的所有
TLS 1.3 连接
read: connection reset by peer,而同一台主机上的 curl、 OpenSSL 3.6、Node.js 24 正常,同一程序用 Go 1.26 编译正常, 设置MaxVersion: tls.VersionTLS12也正常。其中点名的产品是 Palo Alto Prisma Access,PAN-OS 10.2.10-h37;Go 团队的回复指出该产品已有修复版本。 - golang/go#79626 已关闭,
但其中有完整诊断:一位网络管理员与本单位防火墙团队一起定位到,
Palo Alto 的一条 IDS/IPS 威胁特征——针对 OpenSSL CVE-2020-1967,
该 issue 中记为 PA 威胁 ID 58033,最后更新于 2022-07-12——
会检查
signature_algorithms_cert扩展,并对其算法列表不符合预期的连接发送重置。 Palo Alto 已于 2026-06-23 发布内容更新禁用该特征。
CVE-2020-1967 当年正是通过畸形的 signature_algorithms_cert 触发的空指针解引用;
厂商为它写的检测规则,如今已经对合法的现代握手开火多年。
这也精确解释了那 12 个字节:Go 1.27 把三个 ML-DSA 编号同时加进了
signature_algorithms 和 signature_algorithms_cert,即两个扩展各 3 × 2 字节。
Go 不会提供开关。 安全负责人在 #81199 的立场是:
不可能为 ClientHello 的每一处底层变化都加 GODEBUG;
以往加开关的是会改变协商结果的变更;而增加一个签名算法本不应该改变任何东西。
提出的 tlsmldsa 设置(golang/go#81307)尚未合入。
此外 Chrome 预计将开始对 signature_algorithms_cert 做 GREASE,
这会让剩余的问题设备更快、更彻底地暴露。
对 SILO 有两个结论。其一,任何方案都不能寄希望于上游出现开关: 下文需求中的显式兼容档位是必须项,而不是备选项。其二, 对受影响运维最快的诊断问题是什么设备在检查这条流量—— 厂商的一次内容更新可能一步结案,而且那是真正的修复,不是绕过。
全部可选解法
按「谁来动手」分组。每一行只解决特定分支;在拿到定位证据之前,任何一条都是在赌。
入口侧——唯一完整的解法。
| 做法 | 分支 | 代价 |
|---|---|---|
| 更新中间设备——威胁特征库与固件都算——使其容忍不认识的算法编号 | A、B、C | 需要设备归属方配合。对已确认的那个产品,内容更新已经存在,因此这可能是最短路径而不是最长路径 |
绕开它:把 MINIO_IDENTITY_OPENID_CONFIG_URL 指向不经过该设备的端点,或使用 split-horizon DNS。discovery 文档中的 issuer 必须保持不变 |
A、B、C | 证书主机名与 issuer 一致性必须成立 |
| 用 sidecar(stunnel、Envoy)自行终止 TLS 并连接 IdP | A、B、C | 多一个组件及其信任链 |
走 HTTPS_PROXY |
无 | CONNECT 隧道原样转发同一个 ClientHello;只有自行终止 TLS 的代理才有意义 |
部署侧——今天就能用。
| 做法 | 分支 | 代价 |
|---|---|---|
GODEBUG=tlsmlkem=0 |
A | 进程级关闭混合密钥交换,含节点间链路与入站监听器;证书校验不受影响 |
GODEBUG=tlssecpmlkem=0 |
20260916 新增的两个 group | 更窄;保留 X25519MLKEM768 |
GODEBUG=fips140=on |
A 与 B | 不推荐。 FIPS 白名单里没有 ML-KEM 与 ML-DSA,但它同时替换密码套件与曲线,并对整个进程排除 Ed25519/X25519 |
| 停留在 20260804 | A、B、C | 放弃后续全部安全修复;仅作应急 |
在入口放行 Silo User-Agent |
C | 一条规则改动 |
今天没有任何办法关掉 ML-DSA。 Go 没有为签名算法提供 GODEBUG——
internal/godebugs/table.go 中 crypto/tls 的条目只有 fips140ems、tlsmaxrsasize、
tlsmlkem、tlssecpmlkem、tlssha1——tls.Config 也没有签名算法字段。
唯一的杠杆是版本门:这些编号只在 TLS 1.3 下定义,把 MaxVersion 压到 TLS 1.2 即可抑制。
而 SILO 没有在任何地方暴露这个入口。这就是缺口。
为什么不回退工具链
用 Go 1.26 重新编译确实能恢复到正常版本的那份握手,上游的报告也确认降级有效。 但它仍然是错误的手段,有五条理由。
- 用整个产品的工具链,去替别人的缺陷买单。 而那台设备的厂商已有修复。
- 它有保质期。 Go 只维护最近两个版本。Go 1.28 一发布, 1.26 构建就运行在不再收安全修复的标准库上,同一个决定会带着更差的选项回来。
- 上游不会回头。 Go 已明确拒绝加开关,Chrome 还准备对同一个扩展做 GREASE。 回退是拖延,不是解决。
- 这不是换个编译器那么简单。 Server、Console、mcli 和 silo-pkg 都声明
go 1.27.1;用 Go 1.26.7 编译当前源码会被模块要求直接拒绝, 因此回退是跨四个仓库的协同变更,还要处理已经要求 1.27 的传递依赖。 - 它只覆盖一条分支。 如果重置来自分支 C 的 HTTP 层规则,工具链根本不相关。
还有一个相邻的想法同样无效:保持用 1.27 编译,只把 go.mod 的 go 指令降下去。
该机制只管有 GODEBUG 的行为,而 ML-DSA 的广告没有开关——
这样做只会改掉 macOS 根证书之类的无关默认值,握手一个字节都不会变。
回滚发布镜像是另一件事,而且是合理的。 受影响的运维在等待设备补丁期间 停留在 20260804,是稳妥的应急处置;产品为同样的理由冻住编译器,则不是。
由此确立的需求
- 为 discovery 与 JWKS 抓取加上分阶段诊断。 通过
httptrace记录 连接建立 → 握手开始 → 握手完成(附协商到的版本、套件、group)→ 请求写出 → 首个响应字节, 并在返回的错误中标明最后到达的阶段。无需配置、不改协议行为, 且可以用一条日志代替抓包来区分 A、B、C 三条分支。这是价值最高的一项。 - 显式的出站 TLS 兼容档位,至少覆盖身份提供方:经典曲线,以及可选的 TLS 1.2 上限。
必须 opt-in、启动时打出醒目警告,并在
mc admin config与支持包中可见。MINIO_API_SECURE_CIPHERS是既有先例。这是分支 B 唯一的进程内解法; 既然 Go 已拒绝提供签名算法开关,它就是必须项,而不是备选项。 - 启动健壮性:为 discovery 与 JWKS 抓取加总期限与 context 取消; 并就就绪检查是否应当反映身份状态做出决定——目前它并不反映。
- 受支持的诊断子命令,由调查用探针演化而来:握手分阶段、协商参数, 以及 classical / TLS 1.2 / User-Agent 对照,使运维无需我们介入即可定位这类故障。
- 清理剩余的写死密码套件,至少让出站路径拥有监听器已有的那个开关。
有两个选项被明确否决。发布一个降低了的默认值——在 go.mod 写 godebug 指令,
或在镜像里塞 ENV GODEBUG=…——会悄悄削弱所有连接的后量子保护,
而且仍然解决不了分支 B。握手被 RST 后自动降级重试
等于把「注入一个 RST 就能把连接压到 TLS 1.2 加经典曲线」交给主动攻击者;
浏览器正是因为这个原因移除了这类 fallback。若将来确要实现,
它只能挂在需求 2 的显式开关之后。
发布与沟通门槛
这次修复的兼容前提确实写了——写在 Server README 的 TLS 小节、 Go 1.27 TLS 与 OIDC, 以及 20260916 发布说明 中。 没有做的是以下三件事,每一件都直接导致了当前结果:
- 从未在 issue 中告诉报告人:修复需要运维设置 GODEBUG 才会生效,以及它针对的是哪条分支。
- 该要求从未进入升级检查清单。它被归档为「我们改了什么」的记录,而不是「你必须做什么」的动作。
- issue 以补丁合并为依据关闭,而不是以报告人复测为准。
根子上的定性错误才是教训:这次改动被当作正确性修复(恢复标准库默认值), 而它同时是一次兼容契约变更——其效果取决于运维的动作。 定性正确的话,它会走发布门禁与用户沟通流程,而不是只走文档流程。
由此确立三条门槛,适用于今后所有同类改动:
- 任何「效果取决于运维动作」的修复,都要在发布说明和升级检查清单中列为 required action,与协调升级类条目同等待遇。
- 外部用户报告的 issue,只有在报告人确认复测通过后才能关闭。 补丁合并只改变状态标签,不改变 issue 状态。
- 修复无法完整解决所报症状时,issue 中必须写明:处理的是哪条分支、 还剩哪些分支、以及具体需要什么证据——而不是指望报告人自己去读 README 的某一节。
归属
本文在 #154 调查与九月 Go 1.27 栈评审 的基础上,补充发布后的复测结果、机制分析、解法空间与流程门槛。 复现工件与完整证据链保留在文档树之外。可支持的表述仍然是: 合并后的修复在受影响的 8 个 Server 配置点恢复 Go 密钥交换默认值,并经合成负对照验证。 它不诊断任何特定部署;在拿到阶段级证据的复测之前,#154 不做任何根因断言。
2 - 桶配置复制:来源时间、删除与确定性收敛
2026-09-17 发布更新: 本文记录的 #77 修复已随 Server 20260916 发布,删除记录导出默认仍关闭;协调升级、可选功能启用条件和剩余限制仍按各节执行。下方带日期的源码状态与验证记录保留当时的范围。
#77 是已复现的站点复制正确性问题。接收端把来源时间改成到达时间,可能拒绝真正较新的删除;部分配置删除后不再导出时间,断线期间遗漏的删除也无法被 heal 找回。单独补一个 DELETE 分支不能解决这两个问题。
合并状态(2026-09-12): 修复与研究归档已通过 PR #180 合入
main(48ec10312),#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 和公开统计不在本轮重写。对象复制可靠性另有记录。
正式支持和发布验收面向维护中的 silo、silo-console、mc、silo-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 作为基线;这不表示把历史空值补成一次新删除。
比较规则依次为:真实状态胜过基线;真实状态之间较晚来源时间胜出;同一时间删除胜过有值;同级有值冲突以稳定内容键的字节序裁决,较大的键胜出。对带时间的 peer 事件和 heal,完全相同的有效状态不保存、不通知;本地写入即使可见载荷相同,也分配新的单调修订。有值基线之间也按内容键选择,不能让一个较晚创建的默认状态压过真实修改。
这是一种确定性冲突裁决,不是“字节序较大的配置在业务上更正确”。并发配置冲突仍需操作者选择业务上期望的值,并重新提交。
比较键必须与真实保存行为一致
Policy 的集合由 map 支撑,直接 JSON 编码会受枚举顺序影响。Server 对已校验策略的完整 JSON 树排序,包括 Statement、Action/NotAction、Resource/NotResource、Principal、Condition;保留 Sid 和数字精度。这里不新增解析器不支持的语法。
既有策略解析器接受 NotAction / NotResource,但原结构的必填 Action/Resource 编码会对空集合报错。因此使用同一份显式字段编码,并让 Policy GET/admin export 也能读取已接受的策略。这项补齐防止“写入成功、读取失败”,并非单纯格式清理。GET/export 现在对原先按存储顺序输出的 Statement 数组排序,也对原先随 map 枚举、可能每次都不同的集合数组排序。授权语义不变,升级不会重写全部存储策略。
Quota 使用既有解析结果的 JSON 作为键;{}、JSON null 或合法零配额文档仍是有值文档,不能悄悄发送成删除。旧发送端会将零配额 PUT 转成对端删除,但本地仍保存文档。mcli quota clear 发送零配额,升级后各站保留有值的零配额文档,两种情况下都不限制容量。空 Policy 则按既有 peer 语义统一为删除:合法空策略 PUT 成功,后续 GET 返回既有 NotFound。
XML 配置使用有效文档的实际字节作为键,没有引入通用 XML 规范化器。Versioning 是一个必要例外:先应用已有 Object Lock 约束,再比较最终会保存的有效文档。否则比较器接受一个版本配置,Save 又改写它,下一轮 heal 便会重复发送。
锁住整个决策,而不只是最后的保存
六类字段共同存储在一份 .metadata.bin 中。读取原始记录、校验、比较、修改和保存必须位于同一把已有 metadata.lock 内:先锁外比较、再锁内写入,仍会使用过期状态;改成按字段加锁,也会让整条记录的读改写互相覆盖。
本地写入在锁内分配时间:
这使一次本地纠正能超过已保存的未来字段时间。带来源时间的 peer 事件保留其原时间,不重新盖上本机时间;完整重复和较旧的带时间 peer 事件直接结束;这不是对本地 PUT/Delete 的无写入保证。
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。首次双站点运行使用的二进制标识为 01aaef2b5 + dirty,已补录实际二进制的 Go build-info 和 SHA-256。评审修正及收尾后,分别以干净 62cf066ff 和 fcbb93e89 构建的二进制重跑。实际 --version、Go 构建元数据与 SHA-256 均记录,并保留基线 5c5765816 的对照二进制身份。最终 461e9a721 与 fcbb93e89 之间只有测试格式及等价的分支写法调整,源码差异已单独保存。
上述表格记录本地测试和隔离进程观察。主干集成另有远端证据:PR #180 的 9 项检查全部通过,包括 Go CI 的六项任务、DCO、漏洞检查和发布流程验证。合并后已核对主干树与通过检查的 PR 合并树完全一致。这些证据不等同于真实 Linux 多节点集群、正式发布制品或生产部署验证。
对抗评审记录
计划审查使用实际 Claude Code claude-opus-5 --effort max,共四轮。前两轮推动修正了状态比较、保存快照、历史基线及导入等边界;后两轮结论为 GO_WITH_NONBLOCKING_NOTES,实施前阻断为零。计划通过不等于实现已经正确。
本次实现审查固定 1ee64a8d8,由同一模型和强度独立检查全部差异、生产调用链、正式测试及运行证据,重点挑战充分性、最小性、滚动升级和证据身份。结论为 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.log、findings-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 级别和按原因去重的检查通过,不能把二者误写成已经分别演示过旧码失败。
仍需保留的运行边界
- 历史时间污染不能自动还原。 到达时间覆盖和无时间旧事件已经丢失来源事实;部署修复后不会凭空恢复正确历史顺序。核对各站点,在权威站点重新提交期望配置或删除。
- 零时间专用事件继续兼容。 它们使用本地单调时间,并记录
legacy-zero;bulk 的零时间约束不变。这类事件不属于带来源时间收敛保证。 - 桶身份冲突不自动合并。 先解决创建世代分歧。早于目标 Created 的事件不应用;未知创建时间只从真实物理桶补齐,仍未知或桶不存在时不写入。已存在桶的物理创建时间仍为零时,返回 InternalError(500)并中止首次同步;缺失桶则保持 NoSuchBucket(404)。已有字段非法也可能使专用写入返回 InternalError,heal 仍可用合法对端来源替换它。恢复值是桶目录修改时间这一物理近似值,可能随顶层对象变化、逐盘不同,并晚于真实创建时间;早于它的事件仍会被跳过,不能只凭一个事件降低桶身份。补齐发生在成功配置写入或初次站点同步时;在落盘前,状态仍照实报告未知时间,周期 heal 在两个方向上都跳过该桶。
- 物理时钟不是因果时钟。 已知未来字段时间后的本地纠正可前进,但不能推断所有并发写入的业务意图。
- 诊断有界,成功响应不等于应用。
legacy-zero、before-created、indeterminate、unreachable、peer-error复用现有 LogOnceIf;错误文本和 key 稳定,详情放入属性,沿用每小时清理。每个原因各自持有 key,一种情况不会把另一种顶掉;空基线以及尚未拥有该桶的对端不产生诊断。真实存在但无法排序的状态即使没有有效来源,也会输出indeterminate,且不触发 RPC。正常重复和旧事件保持安静。这是每桶/字段/原因的上限;不可达站点的unreachable和无效状态的indeterminate都可能随桶与有值字段数量增长,不是全站固定条数的上限。 - 代码、合并与发布分别验收。 Issue 状态、Server 版本、镜像、软件包、文档上线及生产配置需要各自证据;本文记录的主干合并不代表发行制品或生产环境已经完成升级。
3 - 未签名的 Header 不属于请求
2026-09-17 发布更新: 本文记录的九月源码修复已随 Server 20260916 发布;协调升级、可选功能启用条件和剩余限制仍按各节执行。下方带日期的源码状态与验证记录保留当时的范围。
本文记录 SILO 的未签名头覆盖修复,核心提交为 123325430,已通过 PR #173 合并,台账编号 SN-2026-011。该问题由 Oren Yomtov 针对已发布版本报告,并在本地两条签名路径上均已复现。
2026-09-11 状态: 原始修复已推送,并通过 PR #173 合并。下文的后续签名与正文校验修复也已通过 PR #177 合并,8 项 PR 检查全部通过。源码验证与正式发布分别计数:当前已发布的 9 月 3 日 Server 版本尚未包含这些修复。
范围: SigV4 请求头覆盖、策略输入一致性与正文摘要校验。不改变 S3 字段名称、对象或桶元数据格式、复制协议、加密格式和客户端命令。
普通签名与预签名 SigV4 的安全性质: 客户端未签名的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 头路径也是同样的行为。
修复只确立一条不变式:
这与 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-* 头——在两条路径上都能通过:
本地复现:对照组 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#17737(535f97ba6)加入的。也就是说,上游其实已经意识到了这一类问题——未签名的头必须与签名集合相符——却把检查限定在 X-Amz-Meta- 前缀和 presigned 路径上,把 x-amz-copy-source 和整条 Authorization 头路径都漏在外面。继承的验证器早于 SILO 分叉。
在未签名头修复之前,SILO 对 cmd/signature-v4-utils.go 的改动是一行依赖路径迁移:9b11dc946 将 policy 导入改为 pgsty/silo-pkg/v3。存在缺陷的验签行为来自上游。原始修复(123325430)以及 PR #177 的后续修复改变了这条边界。存在缺陷的代码早于 SILO 的分叉基线——即上游 2025-12-03 的 “maintenance mode” 提交,第一个 SILO 发布版本正是从那里切出的。
本文确认维护中的 SILO 源码已修复该边界,不声称 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:**种子验签没有调用
checkUnsignedHeaders。COPY 处理器通过普通认证分发拒绝 streaming auth,但不代表 streaming PUT/UploadPart 的所有头都已覆盖。这里不声称存在专门验证 streaming COPY 拒绝的回归测试;完整覆盖需要单独实现和证据。 - SigV2: 不受影响。V2 的规范化本就把
x-amz-*头折进 string-to-sign,因此新增一个x-amz-*头会改变算出的签名,被当作签名不匹配拒绝。
状态码:400 还是 403
AWS 对未签名头返回 403 Forbidden;SILO 返回 400 AccessDenied(ErrUnsignedHeaders),继承自上游。两种方式都拒绝了攻击,错误 Code 字符串也一致,仅 HTTP 状态码不同。把它提升到 403 是 cmd/api-errors.go 里一行的改动,同时也会改变既有的 meta 头拒绝路径。此处把它留作一个刻意的、可逆的选择,而非在安全修复里悄悄夹带,因为它对既有的未签名 meta 头路径是一个行为变更,且并非关闭该漏洞所必需。
测试
有几个既有测试先构造一个签名请求,然后在签名之后才设置 x-amz-copy-source、x-amz-copy-source-range 或 x-amz-metadata-directive——也就是说,它们依赖的正是本修复所移除的行为。它们现在改为在设置这些头之后用 signRequestV4 重新签名,这符合修复后的验证器对操作头的签名要求。signRequestV4 会把 Authorization 头排除在自己的签名集合之外,因此重签是安全的。当前覆盖包括 checkUnsignedHeaders 的单元用例(空首值、载荷哈希豁免,以及旧签名年龄头未签名时的拒绝行为)以及 TestPresignedVerifyIdempotent——对同一个 presigned 请求验签两次。
证据
- 一个构建出的服务端在 presigned 与 Authorization 头两条路径上复现了混淆代理,修复后两者均被拒绝,而对照组
PUT、真实的minio-goCopyObject、带用户元数据与标签的PutObject、以及基于请求体的PutObjectTagging全部照常工作。 go test ./cmd/在修复树上通过;gofmt、gofumpt、vet干净。- 对抗性评审(第一轮)独立发现了初稿中的三个缺陷——scratch 头导致的验签不幂等、空首值绕过、以及对未签名
x-amz-content-sha256的过度拒绝——均在上文处理,并通过把评审方自建的对抗测试套件跑在最终树上得到确认。 - 对抗性评审(第二轮,针对已提交的修复)未发现回归,并确认重签后的测试保留了原意:无效 access key 仍返回
InvalidAccessKeyId,错误 SSE-C key 在签名通过后仍返回403。它另外发现了三处相邻的、既有的缺口——在父提交上同样失败,且不在本次改动范围内——记录在下文后续项。
兼容性与运维
- 普通客户端: 请求无变化。已验证的标准 SDK 路径会签入其操作头;这不是对所有 SDK 版本和自定义签名器的保证。
- 未签名的
x-amz-*头: 现在以AccessDenied拒绝,与 AWS 一致。一个不签名就加上此类头的客户端,本就在 SigV4 契约之外。 - 滚动升级: wire 与存储格式不变。已升级节点强制该边界;仍跑旧版本的节点在升级前仍然暴露,因此滚动窗口内不同节点行为可能不同。
- 回滚: 修复版本写入的数据仍可被旧版本读取,但回滚会重新打开混淆代理。
残余风险与后续
- 发布交付: 源码修复与公开工程记录不代表已发布的二进制或镜像包含修复;需要单独核对所选发布版本与制品。
- CVE: 报告人申请了一个;在 CVE 分配前,该发现以稳定的 fork 本地编号
SN-2026-011追踪。 - 状态码选择: 上文
400与403的取舍仍开放。 - 相邻签名修复: PR #177 处理重复复制源头的歧义、签名年龄的授权时序和载荷哈希策略有效值,并补齐另行复现的 header-only 预签名正文摘要校验缺口。回归覆盖普通签名与预签名、上传验签前的策略求值,以及真实 HTTP 桶策略篡改。这些后续项与原始
SN-2026-011分开记录;合并和发布状态见页首。 - 通用问题: 本次修复覆盖的是
x-amz-*请求头。任何未来让请求语法去选择操作的控制项,都必须回答这次同样的问题——在这个值被允许具有任何含义之前,它是否被签名覆盖了? 上面那处重复头缺口是同一问题的另一副面孔:签名所绑定的值,与处理器所消费的值,必须是同一个。
结语
签名即请求。x-amz-* 头所声称的一切,在签名覆盖它之前都只是声称:
确认"承诺过的头都到了",不等于确认"到了的头都被承诺过"。应让参与普通签名与预签名操作的头受同一认证契约约束;载荷哈希豁免与 streaming seed 限制仍如上文。
台账把载荷验证修复单列为 SN-2026-012。签名修改本身不改变存储格式,但同一 main 候选还包含要求协调升级的 IAM 变化,不能用本文授权整个候选版本滚动升级。
4 - Object Lock 复制排序
2026-09-17 发布更新: 后续的 #129、#134 与 #178 修复已随 Server 20260916 发布;协调升级、可选功能启用条件和剩余限制仍按各节执行。下方带日期的源码状态与验证记录保留当时的范围。
2026-09-16 发布边界:f4c1286c9 已随 Server 20260903 发布。后续仅时间戳删除、SSE-C 重传和跨池修复见 #129、#134、#178,它们在 main 中,但不在该发布中。公告台账 记录原始排序缺陷。
重建元数据前保留旧状态
副本 COPY 曾在比较保留期和 legal hold 时间戳之前,就用入站请求重建元数据。旧时间戳因此丢失,旧请求看起来也能成为权威更新;legal hold 时间戳还写错到保留期时间戳键。第一批修复在重建目标 map 前捕获已存状态,并让各字段版本保存在各自键中。
保留期和 legal hold 是独立寄存器。较新的保留期不会让较旧的 hold 变得权威,对象修改时间也不能替代任一字段版本。只有入站字段时间戳更新时才应用,旧值或等时间戳更新保留已存状态。
删除同样是有序值
移除保留期或 hold 后可能没有活值,但仍携带源时间戳。丢掉时间戳会让延迟旧值回来。后续修复在接收、比较和重发时识别仅时间戳的删除;没有排序证据的缺失字段,与已记录删除是不同状态。
接收端必须在普通元数据 COPY 和完整 SSE-C 副本重传中保留这些差异。对象体重写不能成为删除目标更新 hold 或恢复旧保留期的理由。
跨池权威状态
同一版本在多个池有副本时,单个 set 不能独自决定最新字段状态。多池协调层 在共享对象锁下收集同版本副本,分别按时间戳合并保留期和 hold。未知池状态不是空值;该修复关闭了原先以 #133 留下的源码边界。
授权与限制
排序不会授予修改 Object Lock 的权限,复制信任检查和相关 S3/admin 权限仍然适用。这不是绕过 governance 或 compliance 保留的新用户 API,也不建立不同步源时钟间的因果顺序,更不提供部分存储失败后的分布式回滚。
历史旧更新可能已经改变存储状态。升级只能阻止修复路径再次接受同类错误,不能重建丢失的保留历史。宣布恢复前,应在各站点按权威记录核对精确版本的保留期和 legal hold。
证据
参考 Object Lock 合并实现、erasure-object.go 的副本写入路径及关联 PR。测试覆盖字段的新旧更新、空值删除、独立字段时间戳、SSE-C 重传和多池;这些源码测试不证明所有历史副本都已收敛。
结合副本审计手册、SSE-C 完整性记录和发布矩阵使用。依赖组合排序契约前应升级所有参与节点。
5 - SSE-C 副本完整性
2026-09-17 发布更新: 本文记录的九月源码修复已随 Server 20260916 发布;协调升级、可选功能启用条件和剩余限制仍按各节执行。下方带日期的源码状态与验证记录保留当时的范围。
2026-09-16 源码状态:#122、#123、#124、#126 和 #134 已在 main,但不在已发布的 Server 20260903 中。此前零字节/属性读取认证与目标密钥 checksum 响应修复已随 20260903 发布;不能把所有 SSE-C 修复当作同一次发布。
密文与信任
普通 SSE-C 请求提供客户密钥,操作明文。经过授权的副本传输可以携带原始密文和保留原对象所需的密封对象密钥元数据。目标必须原样存储这些字节,再加密一次会产生不可读的双重加密对象。内部标记本身不是授权:仍需精确复制标记和要求的复制权限。
该路径不会泄露客户密钥,也不会允许普通无密钥读取。普通 GET/HEAD 与 GetObjectAttributes 保留各自密钥/权限检查。联邦原始 SSE-C 副本 COPY 明确不支持。
分段大小与校验和
加密分段大小与逻辑明文分段大小不同。副本多段记录必须保留每段实际逻辑大小,使覆盖后的 partNumber 读取、范围读取和 GetObjectAttributes 与源一致。Checksum 必须保留正确加密上下文和逻辑含义;对象已用另一把目标密钥提交后,不能仍用源密钥解密响应 checksum。
普通 SSE-C 密钥轮换在当前 main 中,只要显式请求 checksum 算法,就走完整重写,即使算法与原值相同。多段源成为单段目标,ETag 可能变化,复制重传对象字节。不带该请求且满足元数据原位更新条件时,保留原 checksum 状态,包括缺失状态。详见操作步骤。
重传与 Object Lock
目标无密钥 HEAD 可能把已存在的 SSE-C 对象报告为不可访问,而不是不存在。发送方须区分它与 NoSuchKey,不能假设普通元数据 COPY 能修好副本。已存在 SSE-C 副本走对象重传。旧副本状态无法解码时,可通过修复后的重传路径替换,同时保留目标更新的 Object Lock 状态,并正确排序删除时间戳。
升级 Server 不会自动证明旧副本已修好。应使用批准的密钥访问流程重新读取精确源/副本版本,比较逻辑字节和分段边界,另行检查保留期及 legal hold。不能仅凭元数据 HEAD 成功推断完整性。
压缩与历史对象
当前 main 排除所有新 SSE-C 写入的压缩,包括普通 PUT、多段初始化、COPY 和 Snowball。SSE-S3/SSE-KMS 仍服从加密压缩选项。这避免生成传送密文却缺少所需压缩元数据的副本格式。
历史压缩 SSE-C 对象不会自动重写。保留密钥和精确版本身份,清点影响范围并演练受支持的重写/恢复路径。该变更是预防措施,不是后台迁移,也不保证任意旧密文都能恢复。
验证边界
压缩判定、replication-trust-ssec-replica_test.go、erasure-multipart-ssec-replica_test.go、replication-ssec-retransmit_test.go 和 compression-ssec_test.go 保留相关源码及回归契约。测试包含错误密钥/未授权对照、多段字节比较和重传情况,不能替代部署的历史状态清点。
相关内容见 Object Lock 排序、多池一致性及副本恢复。依赖组合行为前须升级所有参与 Server。
6 - 持久化 IAM 撤销
2026-09-17 发布更新: 本文记录的九月源码修复已随 Server 20260916 发布;协调升级、可选功能启用条件和剩余限制仍按各节执行。下方带日期的源码状态与验证记录保留当时的范围。
2026-09-16 源码状态:#191 与 #192 已合入 main,但不在已发布的 Server 20260903 中。它们改变 IAM 持久状态,要求协调升级。执行时使用升级恢复手册,安全边界见 SN-2026-013。
SILO 保留已删除 IAM 记录的版本,使离线站点重新连接后不能恢复旧身份或旧授权。覆盖内置用户、服务账号、组、策略文档,以及用户、STS 父身份和组各自命名空间中的策略映射。内置用户删除后,即使有意重建同名用户,旧服务账号、STS 凭证与组授权也不能因此复活。
排序与持久化
删除记录保留在原 IAM 配置路径,包含源时间戳、Deleted 及必要的 RevokedBefore。身份删除记录不含 secret key 或 session token。普通 IAM 列表和授权隐藏已删除记录。对象存储与 etcd 都用分布式锁串行化每个路径的版本比较及写入;接收端持久化源时间戳,不替换成自己的接收时间。
旧事件不能覆盖新版本;相同时间戳下,删除胜过活记录。有意的本地重建取得比已有删除更晚的版本。重建之后才到达的旧用户或组删除,会保留撤销边界,同时保留较新的活记录。重复收到已应用的 tombstone 不重写或推进版本。冷启动加载后同样遵守这些规则。
用户或组版本写入是删除提交点,映射与子凭证清理随后发生。清理失败不能撤销已提交的撤销;API 返回清理错误,同时通知同后端节点重载提交状态。错误不表示身份仍然有效。同一撤销可以重试;重试期间应暂停同名重建与其它 IAM 修改。发生已提交但清理失败的错误时,admin 处理器不会执行即时跨站点 hook,跨站点传播依赖正常删除 healing 重试。同后端删除通知重读当前共享状态,因此延迟通知不会删除随后重建的身份。etcd 节点也通过 watch 接收持久变化。通知或 watch 失败属于最终传播,不是瞬时分布式撤销事务。
必须同步节点时钟并监控偏移。排序使用源墙钟时间戳,同一路径的本地写入保证单调推进,但不建立不同站点并发写入的因果顺序,也不保证相同时间戳的冲突活更新得到确定性的统一结果。
用户、子凭证与组
重建用户保留 RevokedBefore。新服务账号和内置 STS 签发携带签名的 siloParentRevocation claim,记录签发时已知的父身份撤销边界。编辑或重放旧子凭证不会更新该 claim;即使旧子凭证自身更新时间更晚,它仍然失效。针对重建父身份新签发的子凭证继续有效。加载与写入凭证时从已验证 token 中读取 claim。
较新的复制服务账号快照可以替换同 access key 的旧服务账号,包括有意更换所有者或 secret。本地重复创建仍被拒绝。快照保留 disabled 状态和账号自身的撤销边界,旧映射不能附着到新账号。等版本重试重载提交身份,不重写记录。周期性的活记录 healing 比较源版本,即使公共状态摘要没变,也会检测差异;禁用身份也可以作为 healing 来源。
服务账号快照保留绝对过期时间;接收站点不会重新套用本地新凭证的最短有效期。较新但已过期的快照仍覆盖旧 key,认证拒绝它,正常加载把它收集为持久服务账号 tombstone。缓存或 claim 加载失败返回错误等待重试,不能提前确认成功;替换提交后立即移除旧缓存 secret。与内置用户或缓存 STS 身份的 key 冲突会报错,需要管理员明确处理,复制不能悄悄改变凭证类型。完全相同时间戳的并发冲突服务更新仍可能在不同站点留下不同赢家,状态摘要不会解决该冲突。
组的每个成员都有独立 MemberGrants 时间戳。改变另一个成员或组启用状态不会重新授予其他所有成员权限。有效成员授权必须晚于用户及组各自保留的撤销边界;列表与策略计算使用同一有效成员关系。对端快照保留原授权时间,包括未知的旧格式授权时间,不能用最近快照时间替旧身份制造新授权。显式的新管理授权可以恢复访问。
这不是所有组成员编辑的通用冲突解决协议。继承的活组快照合并会添加成员,不会修复离线期间漏掉的普通成员移除。站点断开时从活组移除成员仍是已知限制;这里解决的是用户或组删除及同名重建的边界。
保留与过期
永久身份、组、策略文档与映射没有自动 tombstone TTL。断开的站点或旧备份可能很晚才返回;重放确认只是减少重复工作的优化,不是回收历史的授权。
不可变 STS token 自然过期时,物理删除 token-key 记录及旧式 token-key 映射,不制造永久 tombstone。提前撤销的 STS 则保留至 token 过期加现有时钟偏差余量;不能用更晚的事件时间戳重放同一已撤销 token 使其复活。etcd 使用过期 lease;对象存储在既有凭证加载与清理中回收过期 STS tombstone。未知过期时间的记录保守保留。
可选清理采用短锁预算,尽力写入。失败时,本轮停止可选回收、记录错误,仍加载正常用户;过期凭证保持拒绝并保留持久版本,下次加载再试。健康清理没有固定记录配额。可复用 STS 父身份策略映射不继承 token TTL。外部 IdP 禁用属于提前撤销,不是自然过期,其缓存 STS 与服务账号也纳入清理。过期服务账号的 key 可复用,旧版本可能永久有效,因此仍保留持久版本。
直接的跨站点逐 token RevokeTokens 交付不是本次新增保证。内置父身份删除的保证来自持久父边界,包括删除节点缓存中尚未出现的子凭证。
Healing、失败与运维成本
每个进程启动一个 healing 循环。丢失分布式 leader lease 时暂停,重新取得后恢复,不永久退出;配置重载不会增加循环。取得领导权以及每轮结束后等待 30 秒,初始锁重试和端点恢复还会增加延迟,因此 30 秒不是收敛 SLA。
普通 IAM 加载器维护不含 secret 的删除与保留边界内存索引。Healing 使用该索引,不每轮再完整遍历一次 config/iam/;既有的启动与刷新加载仍扫描持久记录,包括 tombstone。
每个 peer 每批最多接收 128 条记录。发送方记住对方已确认的路径/版本,无关更新不重置进度。失败批次保留待重试,后续独立批次仍可前进;响应丢失会导致安全的幂等重放。每轮有时长上限,超时前成功的确认不会丢失。协议报告节点名与进程实例;负载均衡在已知实例间切换不重置确认,新实例首次出现时保守作废旧确认。在恢复持久状态前必须停止相关进程,避免恢复后复用旧进程的确认。
稳定状态仍需遍历、排序保留的内存集合并检查 peer 协议状态,只是在确认后抑制重复删除 PUT。内存成本随路径与 peer 数增加,启动存储读取随历史增长;本次不提供通用历史压缩。升级后,旧活记录如果曾被接收站点赋予不同时间戳,可能触发一轮集中协调写入;维护窗口要预留其存储写与同后端通知成本。
指标包括 revocation_records、revocation_heal_failures、revocation_heal_duration_millis 和 revocation_heal_last_success_timestamp_seconds,错误同时记录日志。断网、大量积压、锁争用及失败重试可能需要多轮。
缓存凭证查询只检查内存父版本索引,不额外访问存储。STS 签发与冷凭证加载仍读持久父版本。版本 I/O 与分布式锁等待释放 IAM 缓存锁,另一个本地 writer mutex 保持写顺序;这些操作都有受限 context,包括 etcd 锁及 lease 清理。
协议与支持的升级方式
服务端自有版本化路径为 /minio/admin/v3/site-replication/peer/iam-revisions,携带源版本、成员授权时间以及独立用户/组撤销项,不改变 admin 客户端 SDK 或 S3 API。旧服务器拒绝这个路径,发送方报告失败,不回退到丢失元数据的协议。旧 IAM 路由保留可读性,只提供尽力兼容,不让旧 peer 自动获得新保证。
所有参与服务器必须升级,包括未启用站点复制但共享 IAM 后端的节点。不支持同后端新旧混跑或滚动降级:旧二进制不能正确解释 tombstone 和签名父边界。请安排维护窗口:
- 暂停 IAM 修改,隔离状态未知的离线站点和备份。
- 备份各站点完整 IAM 存储及所需加密材料;活记录 admin 导出不包含删除历史,不能替代完整备份。
- 停止每个后端的全部节点,更换二进制并用新版本启动。所有参与站点升级完成后才能依赖新撤销语义。
- 检查 IAM 加载、站点复制错误和撤销收敛,验证代表性的旧凭证被拒绝、新签发凭证有效。
- 显式处理升级前已丢失的撤销历史:在仍有旧记录的站点删除它们,按批准状态重建旧离线 peer 后再准入,不能为了查看删除凭证而接回未知旧快照。
旧服务器为重建父身份签发的凭证缺少签名边界,必须由新服务器重发。没有保留撤销历史的父身份保持既有凭证行为。
未修改的内置策略仍不能本地删除。管理员显式覆盖内置策略后再删除覆盖,会留下持久删除,重载不再自动恢复内置策略。需要恢复时执行显式策略创建。本地删除不存在策略仍幂等,不创建新 tombstone;对端未知删除则保留版本。
回滚前停止并隔离站点,评估备份以来的变化,再恢复兼容状态。旧备份本身会丢掉后续撤销,开放访问前必须重新协调或换 key。不能在线删 tombstone,也不能只转换活记录来让旧二进制启动。Server、客户端、Console、包与部署验收分别成立。
回归与性能验证
定向测试包括 iam-revocation_test.go、iam-revision_test.go、iam-revision-lock_test.go、iam-revision-boundary_test.go、iam-replication-protocol_test.go、iam-credential-retention_test.go、iam-peer-reload_test.go 和 iam-replay_test.go。用 SILO_TEST_IAM_REVOCATION_ETCD 指向可丢弃 etcd 才会包含对应后端生命周期、锁与边界测试;它们使用隔离 key 命名空间。
BenchmarkIAMCachedCredential、BenchmarkIAMSetTempUser 可与修复前源码比较。BenchmarkIAMRevisionConvergedHealing 覆盖 1,000/10,000 条保留记录,测量稳定状态的索引/网络工作,并断言没有重复 PUT;模拟 peer 不测持久追赶吞吐。BenchmarkIAMColdLoadExpiredServices 覆盖 100/1,000 个过期可复用凭证,使用 -benchtime=1x。还需要实际多站点签名 S3/STS 验证以及部署规模与延迟测量。
错误与可观测性
权威撤销提交后,依赖清理失败可能让 admin 删除返回 HTTP 500。这不是旧身份仍可使用的证据。暂停 IAM 修改和同名重建后重试预期撤销,再逐站点验证旧凭证与重新签发的凭证。持久父版本不可读或签发无法提交时,STS 通过内部错误路径拒绝签发,包括 STSInternalError,不能猜测一个缺失边界后放行。
抓取每个进程需认证的 /minio/metrics/v3/cluster/iam:
| 指标 | 含义 |
|---|---|
minio_cluster_iam_revocation_records |
本进程索引中保留的删除记录与父边界 |
minio_cluster_iam_revocation_heal_failures |
进程启动后的失败协调轮数 |
minio_cluster_iam_revocation_heal_duration_millis |
最近一轮耗时 |
minio_cluster_iam_revocation_heal_last_success_timestamp_seconds |
最近一轮成功的 Unix 时间 |
索引是逐进程的,同后端节点求和会重复统计。Healing 要求启用站点复制并取得领导 lease;只有共享后端、没有站点复制的部署不运行站点 healing,三个 healing 指标为零并不表示故障。一个 leader 的健康计数也不能证明所有 peer 的凭证检查通过。
为什么更简单的方案不成立
直接物理删除会丢掉离线 peer 所需的排序证据。只比较子凭证更新时间也不够:父删除后编辑旧子凭证,会显得更新并可能恢复访问;签发时的签名父边界则不会随编辑改变。用接收时间代替源版本破坏源顺序,可能把重试变成新修改。实现保留源时间戳,只为有意本地写入单独推进版本。给永久 tombstone 加 TTL,或者仅恢复活记录导出,都会丢掉阻止重放的历史。
7 - 多池对象一致性
2026-09-17 发布更新: 本文记录的九月源码修复已随 Server 20260916 发布;协调升级、可选功能启用条件和剩余限制仍按各节执行。下方带日期的源码状态与验证记录保留当时的范围。
2026-09-16 源码状态:#178 修复多池修改顺序,随后 #207 把当前对象选择扩展到条件 PUT。它们已合入 main,但不在 Server 20260903 中,关闭了 #133 与 #144 跟踪的源码缺陷;这不构成新发布产物的验收。
一个键可以有多份物理副本
扩池、退役或中断的清理可能在不同池留下同一个键的副本。逐 set 锁不能与选择另一池的写入串行化;读取第一个副本也不能证明其 ETag、标签或保留期代表逻辑对象。
相关失败包括:旧 ETag 错误满足条件写入或删除,较新的保留期/hold 被旧对象元数据遮蔽,以及删除选中副本后旧副本重新可见。独立操作每个池、最后只汇总成功状态不能解决这些竞争。
共享选择与锁边界
池协调层在选择到修改的全过程持有同一命名空间对象锁。objectPoolInfos 读取每个池中指定的版本,包括正在排空的池。未知或不可读状态是错误,不是对象不存在的证据。副本按修改时间排序,时间相同时以池索引决胜。
不带版本的元数据请求先选定逻辑当前版本,再收集那个版本的副本,不能混合不同对象版本的元数据。Object Lock 保留期、legal hold 和标签各有独立源时间戳;mergedPoolObjectInfo 按各字段自己的顺序合并,不把对象修改时间当作每个字段的版本。有时间戳的删除同样是状态。
元数据写入、相关对象写入和 healing 使用同一协调锁。这把锁不提供能在后续失败时撤销所有池已完成磁盘写入的事务。
条件与删除
条件 DELETE 在清理前判断一次,并在进入下层前清掉回调;显式 versionId 比较该版本。条件 PUT 在外层锁内从相关池选择最新逻辑表示,包括删除标记状态,再安装新内容。#207 修复的是目标池旧副本让 If-Match/If-None-Match 与当前对象不一致的情况。
清理或元数据传播仍可能先修改一部分物理副本,再遇到存储错误。仲裁/清理失败不能解释为对象没变,也不能证明所有旧副本都已消失。恢复后应核对状态并重试。批量 XML ETag 条件仍未支持,见条件 DELETE。
Object Lock 与复制
副本写入从跨池同版本的权威副本中协调目标保留期和 legal hold。较旧入站副本不能缩短较新的保留期或关闭较新的 hold。标签同样与其时间戳共同传递,包括表示删除的空值。详见 Object Lock 排序及复制标签。
跨池迁移中的标签
rebalance 与 decommission 把每个版本读成 FileInfo,转换成 ObjectInfo——这一步把标签头从普通元数据表移到 UserTags 字段——再把对象写到目标池。迁移写入方自 2022 年引入以来只复制普通元数据表,于是普通和分片对象在两个入口都会丢标签;2020 年的字段拆分本身没有问题。提交 fced86303 让四个迁移写入方共用一个元数据组装:克隆元数据表、恢复 UserTags、按存储原样保留标签修订字段,不伪造时间戳,并沿用既有的协调锁。回归覆盖普通与分片对象、初始标签、更新与清空的标签、版本化与旧格式读取。
修复只能防止今后的丢失。此前迁移已经丢掉的标签不会恢复,要恢复必须有可信的旧值来源。修复后的八节点 rebalance 与 decommission 验收在本文写作时尚未完成。
证据与运维影响
协调层实现、池入口和两份已合并 PR 界定源码契约。覆盖包含多池、旧副本、删除标记、显式版本、并发写入及失败路径,不代表原子回滚或任意故障容错。
依赖共享锁保证前应升级所有参与节点。可能已遇到缺陷的部署仍需清点历史副本、标签和 Object Lock 状态;源码修复不证明历史清理完成。使用副本审计手册及组件矩阵。本修复与多段上传列举的默认限制和扫描成本是不同问题。
8 - 联邦 CopyObject:保持目标对象契约
2026-09-17 发布更新: 本文记录的联邦 CopyObject 修复已随 Server 20260916 发布;协调升级、可选功能启用条件和剩余限制仍按各节执行。下方带日期的源码状态与验证记录保留当时的范围。
2026-09-16 源码状态:本文记录 main 中 #157、#159、#163、#177 和 #179 的 CopyObject 修复,Server 20260903 尚未包含。讨论对象是把复制作为 PutObject 转发到另一部署的旧 etcd 存储桶联邦路径,不是桶/站点复制调度器。
转发一次逻辑字节,由目标端加密
代理读取源的逻辑字节,按需解密和解压,并向目标声明逻辑长度。不能先在本地加密/压缩,又要求远端再做一次。SSE-C 源读取仍需源密钥及安全传输。新对象由客户端显式 SSE 请求头或目标自身的默认加密规则决定存储方式;没有显式选择时,代理不能注入自己的默认值。SSE-KMS context 按预期的 JSON 对象形式转发。
联邦路径明确以 501 NotImplemented 拒绝可信原始 SSE-C 副本 CopyObject,并在创建目标前返回。仅附加复制标记不能让这个组合安全。普通的持密钥 SSE-C COPY 与专用副本路径是不同操作。
校验和与元数据规则
普通转发写入应按大小写不敏感规则移除整个保留内部元数据前缀类别。公共元数据与标签通过支持的字段传递,而非内部存储编码。Checksum 必须描述实际写入的逻辑完整对象:
- 显式算法或多段复合 checksum 源要求目标生成完整对象 checksum。
- 非空流使用客户端尾部 checksum,包括
x-amz-trailer,目标须读取并验证 trailer。 - 空对象用普通 checksum 请求头,因为没有承载摘要的流式尾部。
- 源已经保存的完整对象 checksum 可以作为普通请求头传给目标验证。
- 必需的远端 checksum 缺失、格式错误或带多段
-N后缀时必须报错,不能冒充有效完整对象结果。
发现错误成功响应时,远端写入可能已经提交。随后返回错误不证明目标不存在;对版本化 COPY,应先检查实际写入版本再决定重试。
Object Lock 不是用户元数据
通过类型化 LegalHold 选项传递 legal hold,使其成为 x-amz-object-lock-legal-hold,而不是 x-amz-meta-*。保留期时间戳应保持完整精度,转成整秒会削弱请求值。认证与目标 Object Lock 规则仍然适用。
返回实际提交的写入
响应与 ObjectCreated:Copy 事件描述目标键、逻辑大小、ETag 和精确版本 ID。修改时间来自目标本次写入,不能由代理编造,也不能用后续不带版本的 HEAD 获取——那可能看到另一写入方的对象。内部写入时间响应绑定经过认证的联邦请求。显式选定源版本时,源版本响应头仍标识对应源版本。
证据与部署边界
源码见处理器、写入时间 transport,测试见 object-copy-federation*_test.go、object-federation-time_test.go。覆盖普通、空、多段、压缩和加密源,远端 checksum 失败,目标默认值、legal hold、响应身份和事件。这些组件测试不构成生产 etcd 联邦或任意 S3 兼容目标的验收。
按组件矩阵同时核对代理与目标构建。升级不会重写历史对象。相关设计见 SSE-C 副本完整性和 Object Lock 排序。
9 - 复制可靠性:删除完成、MRF 可见性与 resync 取消
2026-09-17 发布更新: 本文记录的修复(PR #162、第二轮 PR #196 及第三轮清除修复)已随 Server 20260916 发布;协调升级、可选功能启用条件和剩余限制仍按各节执行。下方带日期的源码状态与验证记录保留当时的范围。
本文记录 #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 中;见第二轮小节。
第三轮,2026-09-16: 提交eb4f5e5b3、254b19ac0与358ab38fb修复指定版本清除对各盘的作用,以及排队中的创建任务在清除之后的行为。已在 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 单独跟踪,在初始归档时尚未关闭,随后由 #178 在 main 修复;见多池对象一致性,Server 20260903 仍不包含该修复。本次三个 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 状态。最终条件是:
这与其他生产者已有的分类一致,修复落在 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 上限为 100000,mrfRetryLimit 为 3;代码在 RetryCount > mrfRetryLimit 或保存通道已满时放弃该条队列记录。
源对象及其待复制状态仍在,丢弃队列条目不等于源数据丢失。不过后续恢复依赖 scanner,原本快速的重试可能退化为等待扫描,无法据此承诺固定的恢复时延。
真正的缺陷是 TotalDroppedCount / TotalDroppedBytes 在内部递增,却没有复制进两处对外统计快照,也没有 v2/v3 Prometheus 指标。容量为 1 的回归夹具能稳定证明:一个条目成功入队,20 字节的溢出条目和 30 字节的超限条目被丢弃;内部为 2 / 50,管理接口却显示 0 / 0。
选定的最小改动
- 两处管理统计快照原子读取现有丢弃计数。
- v2 与 v3 注册并加载累计 counter,文档同步说明计量含义。
- 在重试超限、MRF 通道已满这两个实际丢弃点记录去重警告,使用固定的消息和 key,避免计数变化绕开去重而刷屏。普通 worker 队列转交 MRF 还不等于丢弃,不在那里增加警告。
- 正常对象、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 建立的“结果消费者结束后再持久化”契约:
若正常完成时先 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 管理操作在状态锁内完成配置读写,与现有终态保存方式一致;活动注册表复用包含 resyncBefore 的 resyncOpts 作为 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 下可复跑:
完整本地验证后仅调整了新测试的格式和夹具:复用既有 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 恢复时延、仍未进入 20260903 的跨池修复,以及外部 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 出口只写VersionPurgeStatus,ReplicationStatus恒留空——这是存储层的"不更新"信号。 - 三字段清空守卫把 purge 路径的复合状态真正清空,使多目标误读在磁盘写入处不可达。
- purge 发送规范的永久删除请求——显式
versionId、ReplicationDeleteMarker=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 与跨区域验收不在范围内。同轮可靠性修复中的标签排序与副本元数据修复另见复制标签排序与副本元数据归一化。
第三轮(2026-09-16):指定版本清除、标记 heal 与过期的排队创建
第二轮让 worker 的 purge 出口如实汇报。第三轮——提交 eb4f5e5b3、254b19ac0 与 358ab38fb——修的是清除对各盘做了什么,以及排队中的创建任务在清除之后做了什么。起因是三站容器运行(三站,每站四进程十六盘,EC 12+4)中,已清除的删除标记在两个目标站重新出现,而源站仍能读到数据版本。
状态: 已在 main,晚于已发布的 Server 20260903。单盘与 16 盘夹具上有单元与 race 覆盖。三站运行用的是包含同一清除修复的组合构建(
6f27ee6),不是最终 main。一轮对抗评审(Codex,gpt-6-astra)给出 REQUEST CHANGES,唯一的 should-fix 已在358ab38fb修正。
仍然坏着的部分
- 清除可能创建它正要删除的标记。 查询层为了可见性把待清除版本标成已删除,清除写回又把这个标志当成落盘指令。缺少该标记的盘上因此被补上标记;清了一半的盘组来回翻转而不是收敛。
- 缺失版本确认得太早。 读路径在一半的盘报告缺失时就判定版本不存在,于是对缺失版本的重试返回 204,而多达一半的盘上标记还在。
- heal 标记会丢掉它的元数据。 heal 路径用空元数据表重建删除标记。heal 后的标记丢失复制与清除状态,再次显得 pending,可能被当作新创建再复制一次。
- 排队中的创建在清除之后运行。 对 pending 标记的每次 GET/HEAD/LIST、扫描器和 MRF 都会入队一个携带标记快照的创建任务;多个前端的任务在按对象的复制锁上排队。复现的时序里,用户的清除被两个目标站确认,约 85 ms 后同一 VersionID、同一原始修改时间的排队创建到达两站;末态源站 0/16 份标记,两个目标站各 16/16。已不持有标记的目标站会创建它:请求里没有任何东西能区分首次创建和过期创建。
修复
- 清除意图只判定一次(
isVersionPurge):有效的显式 VersionID;没有创建标记、前缀、数据搬迁、free version、过期或转换选项;并且要么是已完成的清除状态,要么完全没有复制状态,或是可信的副本请求。清除从不设置创建标记的标志,其结果把物理删除与可靠缺失的盘回复合并计票。响应只在真正删除了存储型标记时才报告删除标记;此前清除仍 pending 的数据版本按数据版本报告。 - 缺失需要写仲裁多数。 查询报告版本缺失的重试清除会重读每一块盘。缺失回复少于
N/2+1则以写仲裁不足失败,仍有副本的盘组被安排 MRF heal。离线、损坏或不可读的盘从不算作缺失。池层对查询遗漏的池做同样检查,副本接收端按版本跨池解析清除。 - heal 后的标记保留存储的元数据。 heal 复制标记持久化的元数据表,只去掉 heal、搬迁与分层这些操作期键。类型化状态只用于没有存储元数据的创建写入方,不伪造任何时间戳。
- 创建任务在锁内复核。 发送前,删除复制 worker 重读源端版本。版本缺失、不是标记或处于清除中,任务即过期,直接丢弃而不碰源端;无法确认的读取通过 MRF 重试,而不是当作缺失。
证据与边界
两组三站分批恢复(源站重启与不重启,一个目标站分两半回归)都在约 15 s 内收敛,并在 450 s 窗口的余下时间保持稳定。过期创建的时序在组合构建上复现;新回归在未修改的 main 上失败,带修复后在两种夹具上通过。
复核拦不住已经在网络上的创建,也拦不住另一站点重放的创建;关闭这一点需要接收端的持久清除记录或序号,由 #217 跟踪。多数确认的清除之后所有进程丢失时,曾离线的盘上的少数副本在 450 s 与每站七个扫描周期内一直保留。所有前端都读到数据版本,但没有证明这项清理有持久的责任人。这两个边界是继承行为,没有作为 2026-09-16 发布的条件。
10 - 复制标签排序:修订时间戳、墓碑与复活
2026-09-17 发布更新: 本文记录的九月源码修复已随 Server 20260916 发布;协调升级、可选功能启用条件和剩余限制仍按各节执行。下方带日期的源码状态与验证记录保留当时的范围。
本文记录对象标签在复制中如何保序的两个缺陷的分析与修复,分别合入 Server main 为
PR #193(修复
03027727d)与
PR #196(修复
680eac66e)。
截至 2026-09-16: 两个修复均已在核验过的 main
40220bd836cb上;不在已发布的 Server 20260903 中,请用链接的 PR 识别包含它们的构建。
交付边界: 仅源码级验收(回归测试 + R4–R8 集成运行,PR #196 的 11 项检查全部通过)。本记录不确立任何 tag、包、镜像或生产 rollout。
证据类别: 合成签名 HTTP 测试使用真实单盘、16 盘与多池纠删后端;发送端、wire 形态与前置条件还包含函数级测试。没有客户事故被归因于这些路径。
共同模型:标签值与其修订构成一个状态
标签复制通过 wire 头 X-Minio-Source-Tagging-Timestamp 携带修订,存储键为 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 修复给该字面量补了两个时间戳,仍漏掉这一个。R5 之前,消费点为 COPY 排序;R5 新增副本 PUT 与分段初始化的时间戳持久化及重复前置条件消费者。这些 SSE-KMS 路径也依赖 R4 保留选项,因此回移植需共同考虑两项修复。
修复。 在既有 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 会丢失该标志。在此记录为独立后续项,不声称已经建立公开 issue,刻意不并入本次修复。
R5:空标签值没有修订,删除可被复活
故障形态。 九项基线回归,包含真实存储与函数级测试:
- 成功的
DeleteObjectTagging从不生成新修订,因此迟到的可信元数据 COPY 携带旧标签视图即可将其复位。 - 携带空标签状态的较新 COPY 被忽略——空的含义是"无事可说"而不是"已删除"。
- 首个副本 PUT 解析了来源标签时间戳却从不持久化。
- 可见标签值相等时塌缩为“无需复制”;HEAD 不暴露标签修订,更新的删除或重加因而不可见,无法重建顺序。
- 队列中复制事件的完成回调把快照里的旧标签写回、覆盖已提交的删除——且不带时间戳,复活的集合继承了删除的较新修订,比最初报告的症状更糟。
根因。 标签值与其时间戳(含空值的时间戳)构成一个状态。旧协议只能表达非空状态:DELETE tagging 从不打修订、发送端只在非空分支附带时间戳、接收端只在非空分支做决策。
修复(680eac66e):
- 每次标签变更生成一个修订。 PUT/DELETE tagging handler 无条件打上单一 UTC RFC3339Nano 修订,与复制是否选中该对象无关。存储层在写锁内强制单调:不严格更新的本地修订被推进到 stored+1 ns;多池后端计算一个严格超过所有池副本的统一值。
- 发送端传输墓碑。 空值携带其已记录修订;空且无修订不伪造;畸形的已记录时间戳 fail-closed,不做静默修复。
- 接收端接受墓碑。 复制 COPY 在重建前捕获存量标签对,使带时间戳的空值作为可胜出的状态进入既有对账。重复抑制只对严格更新的来源修订放宽。Multipart 完成从已持久化的 upload 元数据排序标签。删除确认路径不再写回快照标签。普通 SSE-C 轮换会从复制的加密元数据中删除旧标签时间戳,防止其覆盖新的本地修订。
运维可见变化:
- 时间戳相等时统一为存量胜(未限定版本与显式 null 请求)。旧 handler 在未限定路径上让来件胜出平局;该变化是方向正确的兼容性收紧。
- 携带
tagging REPLACE且无标签的普通 COPY 现在真正清空目的端标签。旧的默认元数据路径会把源标签带过去——这是 S3 一致性改进,但也是行为变化。 - 每次普通 COPY(包括不改变内容的密钥轮换)都会记录一个新的本地修订。
- 复制双方必须一起升级。 旧对端仍会丢弃空值修订,新发送端的墓碑对它不可见。
- 有已记录修订的对象在显式 resync/heal 期间每对象多一次元数据 COPY。当目的端有桶默认或自动 KMS 加密时,该"元数据"COPY 实际重写对象数据——批量 resync 请据此预算。
历史非空标签没有修订时,发送端以对象 ModTime 为后备;空且无修订的集合不会伪造墓碑。严格更新的可信标签修订只绕过内部 ETag/version 重复保护(否则返回 412 PreconditionFailed);客户端 If-Match 与 If-None-Match 仍然执行。
标签修复没有增加 wire 字段或存储格式,也没有能力协商。双端升级才能保证删除排序,旧跳仍保留旧行为;这不授权整个九月候选滚动升级或降级,因为它同时包含独立的 IAM 迁移。已经受损的标签需从权威来源核实后显式重新写入,无法重建丢失的历史顺序。
限制按限制陈述:
- 不迁移:历史中缺失或错误的删除时间戳无法重建,也不伪造历史墓碑。
- 带标签过滤的复制规则按删除后的(空)标签状态评估目标资格,因此按被删标签过滤的规则永远看不到删除。这是既有的范围决策,本次不变。
- 任意时钟偏差不构成全序。本修复建立的是逐跳排序,不是多站点因果。
- 畸形的已记录时间戳使发送端构造永久失败并经 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 项检查全部通过。这些确立了受测场景的排序行为,不确立真实时钟偏差下的多站点调度、跨区域故障切换,或只升级一对复制端中一端的部署行为。
11 - SILO Server 20260903 发布前复审
旧版分片列举设计已被覆盖 · Earlier multipart-listing designs superseded (2026-10-17)
中文: 本文关于 #79 分片上传列举的旧方案、启用建议与当时进度仅保留为历史记录;后续设计已由最新设计决策(中文)覆盖,请参考新版。新实现已合入主干、尚未随 Server 版本发布;这里记载的旧版本行为和其他主题结论不因此改变。
English: The #79 multipart-listing proposals, enablement advice and status in this article are historical. Subsequent design work is governed by the latest design decision (English), which supersedes those earlier proposals. The new implementation is merged into main and remains unreleased; this does not change the documented behavior of older releases or conclusions on other topics.
这是 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 才能组合:
- 唯一且精确的 replication marker;
- 非匿名、已认证身份;
- 对目标 resource 的
s3:ReplicateObject或s3:ReplicateDelete; - 在更窄的 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 更小:
锁不覆盖对象数据 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 收口到
noteLoadFailure与clearLoadFailure。 - Replication import 只应用变化字段,不复制陈旧的完整记录。
- 删除过期 encryption helper、死 event-target function 与废弃 handler branch。
- Compatibility guard 不再枚举每个 exported source symbol,只保护真实 served route 与冻结的 wire/config surface。
- 删除旧
wait_pipelint exemption;用gomodguard_v2替代弃用配置。 - Dynamic timeout 测试不再从 parallel package 调用全局
rand.Seed。 - 服务端从临时
silo-go分叉回到已审查的上游兼容minio-gorevision。
刻意没有引入
- 没有通用 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:
- 完整
cmd与internal套件; - 完整
cmdrace: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
以下全部成为事实前,不能把服务端称为“已发布”:
- 候选提交推送并完成 review;
- 远端 CI 与 Test Release 在 pushed head 通过;
- 预定 tag 指向已审查的 chart 7.0.2、Server 0903、Client 0903 release tree;
- Draft artifact、checksum、SBOM、attestation、签名 RPM 全部通过校验;
- finalize 与 Docker release 发布 classic、distroless 两种镜像;
- 匿名下载与 pull 测试通过;
- 发布说明用 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 端点就绪不等于数据就绪。 原始固定提交记录测了三个二进制:0806、0903 与当时的 main 修复构建。main 构建在重启后 2.461 秒通过 admin 门禁,再经过 14.489 秒才完成数据 canary;这两个数的计时起点不同。0903 在该次实验的门禁后延迟为 0.191 秒,但不能据此认为它没有继承的启动窗口。在该窗口内,对 admin/health 的就绪探测对数据面一无所知,任何固定 sleep 都不能替代实际的数据面检查。
边界按边界陈述:该验收覆盖单台 Linux 主机上的进程/容器重启与 TCP 对端重连。它不证明跨独立主机、主机重启或物理介质故障的持久性;计时是个体观测,不是延迟保证。运行工件保留在文档树之外;上文方法是可泛化的部分。
9 月 16 日源码进展: #10 已由独立的单对象条件删除 #145 关闭,批量 ETag 删除仍未实现;#77 及 #99/#100 已在 main 修复。#133/#144 后续由 #178 处理,#79 默认列表限制仍开放。它们均不能倒写为 20260903 的保证,已随 Server 20260916 发布。见当前组件与源码矩阵。
12 - 副本元数据归一化:可信复制不得重新注入什么
2026-09-17 发布更新: 本文记录的九月源码修复已随 Server 20260916 发布;协调升级、可选功能启用条件和剩余限制仍按各节执行。下方带日期的源码状态与验证记录保留当时的范围。
本文记录可信复制接收端如何为副本恢复元数据的修复,合入 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-76wf-9vgp-pj7w 缓解刻意移除的
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(注意空格)仍存储带前导空格的 gzip,gzip, aws-chunked 仍存储整串。这些是记录在案的现状,由测试按现状断言——不是修复声明。
运维可见变化
- 可信 Snowball 条目不再继承外层归档的普通元数据。没有 PAX 时,不再继承外层 content-type、cache-control、expires 与用户元数据;有 PAX 时,也不再继承条目自身未重新声明的字段。条目的
minio.metadata.*仍生效,六个复制域字段仍作用于已授权条目,归档的 storage class 仍然继承。仓库内 batch 生产者调用PutObjectsSnowball,SDK 会发送 auto-extract 标记,但外层请求不标记为可信副本,因此没有使用本次受影响的继承路径。 - GHSA 脱敏键不再在副本恢复时被写回——与每次普通 PUT 的行为一致。
- 认证、权限门控与复制信任语义不变;普通提取路径逐字节等价。
- 回滚代码会重新打开注入路径,但不会修复已存储的元数据。
升级不会修复存量对象
升级阻止新的污染;不扫描、不改写既有对象。两个后果值得注意:
- 权威来源仍被污染时,与已归一化副本的比较可能检测到差异,从而在 heal/resync 中反复选择元数据复制。先修复权威来源,再让副本收敛。
- 普通 S3 自 COPY 不是通用的修复 API:它会创建新版本或移动时间戳,而不是原位改写单个版本的元数据。
只读审计 runbook已提供可执行清单工具与分类规则,但不授权或执行修复。
只读审计 runbook已提供可执行清单工具与分类规则,但不授权或执行修复。
存量元数据修复提案——状态
一个未来操作的设计已经存在:构建清单(包含非当前版本,不能只查最新);通过与可信来源版本或独立校验值比对来核验——绝不凭错误的响应头猜测,也绝不因为标签写着 gzip 就重新解压;先处理权威来源的精确版本,再收敛副本;保留不可变清单与元数据备份;小批量验证并演练过回滚。对没有受支持路径的对象,停下来不动它——直接编辑 xl.meta 不是受支持操作。
所选操作必须保护需要保留的版本身份与当前版本关系、Object Lock 保留期与 legal hold、标签、复制状态和加密上下文;写入前检查并发变更,受阻或无法核验的版本保持不动。先在本地克隆中证明具体操作与回滚可行,才能把提案变成可执行 runbook。
这是一个等待单独批准的设计提案,不是已执行的程序。 作为其一部分,没有进行任何生产清单扫描、对象写入、版本调整或部署。把它当作未来 runbook 的形状,而不是已验证的 runbook。
已知限制
- POST 表单上传路径(
bucket-handlers.go)直接调用低层提取器,从不归一化编码;该行为不变,作为已知后续项记录;此处不声称已经建立公开 issue。 - 本地验证在测试专用的容量适配(宿主盘满)下运行;R4–R8 集成记录中的合并树复跑覆盖了未改动树的情形。
- 不声明双站点调度、重启或网络故障验收。
验证
回归测试(TestExtractReplicationMetadata*、TestAPIReplicaContentEncoding、TestAPISnowballReplicaContentEncoding,外加含 race 的信任/SSE-C 回环)覆盖映射表、六个恢复字段与普通路径等价性;原修复记录中的反事实运行对基线 helper 得到 36 个预期失败(20 个 HTTP 与 16 个 helper 用例),44 个对照通过;这不是本次文档修改重新运行的结果。升级摘要见组件版本矩阵;姊妹修复见复制标签排序。
13 - 请求头截止时间:绝对头部上限与滚动的正文空闲超时
2026-09-17 发布更新: 本文记录的九月源码修复已随 Server 20260916 发布;协调升级、可选功能启用条件和剩余限制仍按各节执行。下方带日期的源码状态与验证记录保留当时的范围。
本文记录 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 设置的任何绝对截止时间——慢速读取者只要每个空闲窗口发一个字节,就能无限期占住连接。
两个独立缺陷
- 连接层中和了绝对截止时间。
DeadlineConn包装器的读路径在每次部分读取前重置 socket 截止时间,使 Go 服务器设置的读头截止时间失效。直接 TCP 基线单独证明了这一点:头部限 100 ms、空闲窗 2 s 时,400 ms 才完成的头部被接受。 - 配置从未到达服务器。 CLI 接受
--read-header-timeout与MINIO_READ_HEADER_TIMEOUT,默认值也正常解析——但服务器上下文构建器复制了IdleTimeout、完全丢掉ReadHeaderTimeout,运行中的服务器看到的始终是零。只修缺陷 1 时真实进程仍接受慢头;第二处修复是紧邻 idle-timeout 绑定的一行。
长期不可见的原因:flag 默认值(30 s)与 idle timeout 默认值相等,而 flag 未接线时服务器回退到的恰好是同一个 30 s——所有可观测的默认行为都像配置过一样。
配置
- 旗标:
--read-header-timeout(Hidden: true,普通 CLI help 不显示) - 环境变量:
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 错误状态。来源为 @AEGEGE 的 #183 扫描器报告,修复经 PR #195 集成进 #196;这不把实验结果升级为该部署的复现。
各协议得到什么
- 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 握手窗口;它不是上传/下载的总时长限制)见组件版本矩阵。
14 - 条件 DELETE:一个条件对应一个逻辑对象
2026-09-17 发布更新: 本文记录的九月源码修复已随 Server 20260916 发布;协调升级、可选功能启用条件和剩余限制仍按各节执行。下方带日期的源码状态与验证记录保留当时的范围。
2026-09-16 状态:#145 引入单对象条件删除,随后 #178 修复多池串行化与清理。这两批修改均不在已发布的 Server 20260903 中。依赖该行为前请核对组件矩阵。
围绕 PR #12 的八月方案比实际合并代码范围更大。其中批量拒绝、额外读取授权和只比较当前版本等规则,并不是已实现的保证。本文以维护主线 f99ed829b 为依据。
已实现的契约
| 请求 | 当前 main 的行为 |
|---|---|
单个 DeleteObject,非空 If-Match: <ETag> |
在删除锁内比较客户端可见 ETag;不匹配时先返回 412,不执行删除 |
If-Match: * |
要求选中的表示存在且不是删除标记 |
显式 versionId |
比较指定版本,不是另一个当前版本 |
没有 If-Match 或头值为空 |
不安装条件回调,按普通删除处理 |
批量 DeleteObjects 的逐项 <ETag> |
请求模型没有 ETag 字段,这个 XML 字段不提供删除保护 |
内部递归 x-minio-force-delete |
前缀删除先于对象条件路径返回,不能用作条件删除 |
条件判断不会额外增加 s3:GetObject 授权检查。普通删除授权仍然生效,包括显式版本对应的 s3:DeleteObjectVersion、Object Lock 检查,以及单独的可信复制路径。
为什么条件属于跨池协调层
不同池可能保留同一对象的不同时期副本。请求条件针对本次操作选择的逻辑对象。如果每个池独立判断并修改,就可能先删掉一个副本、再从另一个池返回 412;也可能删掉最新副本后让旧副本重新可见。
因此,多池路径先持有共享命名空间写锁,收集相关状态,只判断一次条件,再清除向下传递的回调并跨池协调删除。并发写入和元数据更新也必须使用同一锁边界。这是后续多池修复的目标,并不表示所有物理磁盘能原子更新。
早期设计中的反例
假设一个池保存旧 ETag A,另一个池保存当前 ETag B:
If-Match: A不能先删 A,再因 B 不匹配而失败。If-Match: B不能只删 B,留下 A 重新成为可见对象。
逐池 HTTP 回调还可能并发写同一个响应对象。应在协调层消费请求条件。
失败边界
协调路径在判断条件前读取各池状态,并报告失败,不把未知池视为空池。清理失败仍可能发生在部分物理修改之后:请求失败不等于分布式回滚。存储出错后仍需重试并核对实际状态,详见多池一致性。
选择与判断
只判断一次
erasureServerPools.DeleteObject 持有外层锁。多池使用 deleteObjectReconciled;单池读取选中表示,在删除标记快捷返回前调用 CheckPrecondFn。下层不再针对每份副本解释条件。
通配符与删除标记
DELETE 专用辅助函数把 * 解释为表示存在,删除标记不能满足该条件。缺失对象或指定版本走相应的未找到路径,不会被伪造成空 ETag 匹配。
授权
处理器按有效版本选择删除动作。它没有实现原提案针对具体 ETag 要求的额外 s3:GetObject 检查。原提案的权限矩阵不能作为已实现 AWS 兼容性的证明。
SSE-C ETag
条件删除比较既有的客户端可见 ETag 投影,不读取或解密对象载荷。SSE-C 读取密钥认证与删除对象是不同契约。
显式版本
显式 versionId 选择被比较和删除的版本。因此,即使当前版本拥有另一个 ETag,只要历史 ETag 匹配,仍可删除那个历史版本。这与早期方案的“只比较当前版本”不同。
尚未支持的边界
空 If-Match 不安装条件。递归前缀删除扩展绕过对象条件。ObjectToDelete 不识别批量 XML ETag,也不存在整请求 NotImplemented 拒绝保护。需要比较后删除的调用方必须使用支持的单对象路径和非空条件,并确认所选发布版本包含实现。
替代方案与范围
修改共享 ETag 比较器无法解决池选择和修改顺序。逐池回调后汇总错误也无法撤销已经发生的删除。在既有命名空间锁内消费一个回调不需要新事务框架,但批量执行与策略强制仍需单独实现。
证据与发布边界
源码依据为处理器、池协调层和批量请求模型。相关覆盖包括 ETag 不匹配、通配符与删除标记、显式版本、仲裁失败和多池。早期针对另一份实现的本地审阅与测试结论,不能证明这些缺失保护已经存在。合并、发布和实际部署仍是不同事实。
后续工作
批量条件
逐项 ETag 支持需要解析字段,在正确锁内判断每个逻辑对象,保留 quiet 模式,并在逐项响应中报告条件失败。目前批量 ETag 会被忽略,不能作为并发保护。
策略强制
维护的策略包尚未定义 s3:if-match。支持它需要修改并发布包、正确填充请求条件、更新 Server 依赖并验证授权。执行 If-Match 本身不等于能用策略强制客户端提供条件。
设计成本
单对象执行复用既有的对象选择和锁边界。完整批量条件与策略强制涉及更多接口,仍是独立工作。有效不变量应限定为:支持的单对象条件为假时,必须在删除前针对选中的逻辑表示判断一次。
15 - 只预览文本,绝不执行: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 组件。
契约如下:
- 完整保留现有 image、PDF、audio、video 判定。
- 只有旧分类器返回
none时,才考虑文本 fallback。 - 由四种目标扩展名或四种精确被动文本 MIME 触发。
- 通过普通鉴权下载路径获取字节,不传
preview=true。 - 在应用层强制执行 1 MiB 读取硬上限。
- 只做严格 UTF-8 解码,并拒绝疑似二进制内容。
- 在可滚动
<pre>中只渲染一个 React 文本节点。 - 永不使用 iframe、HTML/XML 解析器或 HTML 注入接口。
- 要么显示完整对象,要么完全不显示;不展示截断 JSON/XML。
- 文件超限、编码非法或加载失败时,始终保留 Download。
不新增 Console 或 S3 路由,也不扩大后端 inline MIME 白名单。但交付修改了 Console 响应语义:零字节对象的 Range 请求返回空 200;不可满足的范围返回 416 与 Content-Range: bytes */N;对象 JSON 总是输出 size。见 Console 2.2.0。
当前状况
撰写本文时,SILO 当前锁定的 SILO Console v2.1.1 仍存在这个问题。
前端预览联合类型只有:
扩展名表包含媒体格式,却没有 .log、.txt、.json、.xml;MIME 分类器也不识别 text/plain、application/json、application/xml、text/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 仍然是有价值的纵深防御,但都不能替代核心不变量:
产品契约
这是一个只读文本查看器,不是网页预览器,也不是在线编辑器。
用户应该能够:
- 从列表或对象详情打开小型、符合条件的对象;
- 在现有预览弹窗里阅读保留空白的源码文本;
- 使用浏览器原生选择和复制;
- 分清失败来自大小、编码、权限、对象被替换还是网络错误;
- 随时下载原始字节。
系统绝不能让用户误以为:
- 格式化后的 JSON 就是存储原文;
- 截断 XML 是完整文档;
- 替换字符本来就存在于对象;
- 不支持的编码已经被忠实解码;
- 主动 HTML/XML 经“消毒”后可以安全执行。
目标与非目标
目标
- 无需本地下载即可查看小型日志、纯文本、JSON 与 XML。
- 无论扩展名、MIME 与载荷如何,对象内容始终保持惰性。
- 把保留的响应字节与渲染文本限制在 1 MiB。
- 忠实显示存储文本,不做静默格式化。
- 列表与详情页按照相同权限和类型契约提供 Preview。
- 支持当前对象版本和显式选择的历史版本。
- 保持匿名访问和子路径部署行为。
- 先独立发布 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 时:
-
最终扩展名为
.html、.htm、.xhtml时明确拒绝; -
按大小写不敏感方式匹配最终扩展名:
.log.txt.json.xml
-
去掉参数、裁剪空白并转成小写,规范化 Content-Type;
-
精确匹配:
text/plainapplication/jsonapplication/xmltext/xml
允许扩展名或精确 MIME 任意一项命中。本版禁止 text/、子串匹配与 application/+json 等宽泛规则。
以下矩阵是强制契约:
| 文件名与 MIME | 结果 | 原因 |
|---|---|---|
report.txt + image/png |
image | 现有媒体结论优先。 |
report.json + application/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 只影响产品资格,永远不能选择可执行渲染模式。
资源契约
二进制上限定义为:
正好 1 MiB 可以预览,多一个字节就不可以。
已知大小
- 选中版本的已知大小超过上限时,首次不请求正文;显式 Retry 可绕过可能过期的列表大小,但仍保持有界 Range 和字节上限;
- 已知大小为零仍走有界请求,空响应显示空文件状态;
- 已知大小不超过上限时,开始有界请求;
- 缺失大小不等于零,必须进入有界未知大小路径。
因此当前从列表向弹窗传值时,不能再用 truthy fallback 把 undefined 强制变成零。
有界请求
对于小型或未知大小对象,请求:
额外一字节用于探测超限。
客户端必须:
- 在存在时检查
Content-Range与Content-Length; - 以 stream 读取响应,禁止调用
response.text()或先构造完整 Blob; - 最多保留上限加一字节;
- 观察到探测字节后立即取消;
- 服务端忽略 Range、返回 200 时仍执行同一限制;
- 只有 EOF 证明完整对象未超限后才开始渲染。
超限对象进入说明状态:显示已知大小、1 MiB 策略和 Download,不展示任何前缀片段。
请求身份与取消
预览请求身份是:
请求必须复用现有生成 API 客户端或等价的 base-path-safe helper,从而保持:
- same-origin credentials;
- 当前 Console 子路径;
version_id;- 匿名模式
X-Anonymous: 1; - 当前错误处理和权限边界。
关闭、对象变化、版本变化、bucket 变化和组件卸载都必须中止活动请求并清空旧内容。
仅依靠 abort 不够。还要使用 generation token 或失效标记,防止已经读完或解码完成的旧响应更新新的预览。
被取消的请求不是错误,不应产生错误 Toast。
编码与内容保真
第一版只支持严格 UTF-8:
要求:
- 正确处理 UTF-8 BOM,不显示 BOM;
- 保留 Unicode、emoji、TAB、LF、CRLF;
- 非法 UTF-8 直接拒绝,不插入替换字符;
- 解码后存在 NUL 时,按二进制或不支持内容拒绝;
- 不猜测其他编码;
- 不把对象正文写入日志或持久化;
- 永远保留下载原始字节的出口。
不支持编码状态应解释:
该对象不是有效的 UTF-8 文本,或包含二进制内容。请下载后检查原始字节。
JSON 与 XML 都按解码后的原始源码显示。第一版不得执行 JSON.parse 再 JSON.stringify:这会改变不安全整数、重复 key、空白、字面形式以及用户复制的文本。
安全渲染器
成功状态只渲染一个文本节点:
禁止:
- iframe、object、embed;
dangerouslySetInnerHTML、innerHTML;DOMParser或 XML parser;- Markdown/HTML 渲染;
- HTML data/blob URL;
- 按行或 token 生成大量 span;
- 自动链接、ANSI escape 与语法标记。
单个有界文本节点让 DOM 成本可预测,也让安全性质容易审计。
预格式化区域使用等宽字体、保留空白、默认不换行、独立承担横纵滚动、可键盘聚焦,并支持原生选择和复制。不换行是刻意选择:它能保留日志列对齐,也能避免一条 1 MiB 长行触发昂贵折行布局。
UI 状态与权限
只有同时满足以下条件时,Preview 才可用:
对象详情页当前的与条件错误必须修复;列表与详情页必须共享同一资格函数。
符合格式但超限的对象仍然提供 Preview。弹窗负责解释正文为何没有加载;如果直接禁用按钮,用户无法区分大小、权限和类型问题。
弹窗必须区分:
| 状态 | 必要表现 |
|---|---|
| Loading | 可访问 busy 状态,不显示旧文本。 |
| Success | 可滚动原文和 Download。 |
| Empty | 明确“文件为空”。 |
| Too large | 对象大小、1 MiB 上限、Download;已知超限时首次正文请求数为零;Retry 仍为有界探测。 |
| 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 改动:
- 重构预览分类:完整保留当前媒体结论,显式增加文本 fallback;
- 在预览类型联合中加入
text; - 新增
PreviewText:流式上限、严格解码、请求取消和明确状态; - 把文本对象显式路由到该组件;
- 删除不可达的通用 iframe fallback;
- 修复对象详情页 Preview 禁用表达式,并与列表共享资格逻辑;
- 保留 unknown size,不再把它强制变成零;
- 增加中英文文案;
- 增加分类、组件、资源、安全、权限、版本与浏览器测试。
预计保持不变:
- 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 字节;
- 已知超限时首次正文请求数为零,以及有界 Retry;
- 未知大小;
- 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。
交付分阶段进行:
- 合入边界明确的 Console 源码与测试;
- 通过 TypeScript 检查、生产构建、自动矩阵与真实浏览器安全回归;
- 更新 Console 发布说明并重新生成实际嵌入的 Web 资产;
- 发布 Console 版本;这项新增可见能力适合 minor 版本;
- 更新 SILO 中
github.com/minio/console => github.com/pgsty/silo-consolereplacement 到精确新 pseudo-version; - 用精确依赖构建 SILO 候选版本并重复集成验证;
- 发布 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;
- 有损查看另立独立方案。
当前没有待裁决设计项,可以依照本文进入实现。
当前 Retry 边界: “过大”状态允许重新探测可能过期的列表大小,但不会无限下载;请求仍为有界 Range,响应头和最多 1 MiB + 1 字节的读取限制继续执行。空文件必须通过响应路径验证,不能将未知大小当成零。
16 - 数据库通知统一连接串:#53 的兼容性边界
发布核对(2026-09-16): 本文原始修复已进入 Server 20260903。下文带日期的评审与测试叙述记录当时证据,不代表当前仍待发布,也不代表特定生产部署已验收。后续源码与组件选择见版本表。
本文是 SILO #53 的产品需求文档与最终设计归档,记录 PostgreSQL/MySQL 桶通知目标的兼容性边界、实现结果与验证证据。
最终决策
SILO 保留 PostgreSQL 与 MySQL notification target,但每种数据库只支持一种当前配置方式:
- PostgreSQL 必须提供完整的
connection_string; - MySQL 必须提供完整的
dsn_string。
旧的五字段形式——host、port、username、password、database——继续作为当前 KV 配置系统不支持的格式。SILO 不重新注册这些 key,也不在旧配置迁移时自动把它们拼成 DSN。
旧配置迁移契约刻意保持狭窄:
| 旧 target 状态 | 处理结果 |
|---|---|
| 未启用 | 忽略,不生成 target。 |
已启用,且已有非空 connection_string 或 dsn_string |
只迁移规范连接串和其他已注册设置。 |
| 已启用,只有离散连接字段 | 在新配置生效前拒绝迁移并使服务器启动失败;错误必须可操作、指出子系统与 target 名称,但绝不能打印凭据。 |
这是配置边界决策,不是删除数据库通知功能。
状态: 已由 f1ba68358 实现,包含在 Server 20260903 中。
归属: SILO 服务端仓库。
跟踪: pgsty/silo#53。
目标: 实现并验证后进入下一个 SILO 补丁版本。
背景
SILO 从 MinIO 继承了两代数据库通知配置。
KV 时代之前的 JSON 配置既可以保存完整连接串,也可以使用五个离散字段:
当前 KV 配置只暴露驱动原生形式:
这不是新方向。MinIO 在 RELEASE.2020-04-10T03-34-42Z 就废弃了五个离散字段,并要求迁移到 connection_string 或 dsn_string。SILO 当前的帮助表、环境变量文档与示例也已经把完整连接串作为正式接口。
SILO 是一个迁移步骤显式的新社区分支。它优先保证 S3/Admin API、当前 MINIO_* 设置、盘上数据格式和当前 KV 配置的兼容性;当一个规范形式已经存在多年时,没有必要永久保留 2020 年以前的每一种配置拼法。
问题本质
修复之前,旧配置迁移器 SetNotifyPostgres 与 SetNotifyMySQL 会把两种形式一起写入新 KV 配置。即使旧 target 已经有完整连接串,迁移器仍会附带五个离散 key,通常只是写入空值。
新解析器会拒绝这些 key,因为 DefaultPostgresKVS 与 DefaultMySQLKVS 都没有注册它们。合法性检查只看 key 是否存在,不看值是不是空。因此两种旧来源都会失败:
通知初始化又放大了这个错误。FetchEnabledTargets 对所有通知子系统采用 fail-fast:第一个非法子系统会返回错误和空 target list。上层只记录错误并继续启动对象存储服务,于是健康的 Webhook、Kafka、NATS 等 target 也全部不可用。
仅仅让两个迁移 helper 返回错误还不能修复这个行为。错误会经过 readConfigWithoutMigrate 与 initConfig 向上传播,但 initConfigSubsystem 当前会把不可重试的配置错误降级成 “some features may be missing” 日志并返回成功。服务器随后在没有设置 globalServerConfig 的情况下继续启动;通知失败只是其中一个后果,区域、存储类、压缩、身份与其他持久化设置也可能全部缺失。因此实现必须把类型化数据库迁移错误传到启动边界,并在那里按致命错误处理。把它标记为可重试同样不对,因为在没有外部状态变化时,服务器只会无限重试,配置永远不会自行修复。
这个行为格外危险,因为对象读写仍然正常。操作者看到的是健康的 S3 服务,但全部事件管道已经停止。target 根本没有建立,所以不能假定故障期间产生的事件日后还能投递或补放。
此外还有诊断信息暴露问题。未注册的 password 没有敏感字段元数据,可能被原样复制到健康检查或诊断材料中;正式注册的 connection_string 与 dsn_string 已经按敏感值处理。
为什么第一版修复被回滚
第一版修复注册了五个离散 key,并让解析器读取它们。这样迁移结果确实能通过 CheckValidKeys,而且 target 参数结构和构造器中也仍然保留着旧字段,看起来是很自然的接线方式。
但它破坏了文档明确支持的完整连接串路径。
共享的 mc admin config set 分词器通过查找已注册 key 来识别字段边界,并不能完整理解引号。一旦 port 成为已注册 key,下面这条合法输入中就出现了一个看似新的顶层字段:
分词器会在引号内部的 port= 处切开,把 connection_string 截断,再把剩余部分交给 port 解析器,最终报出 invalid port。
在当前分词器下,注册 host、port、password 这类常见词,会让连接串语法与顶层 KV 语法发生直接冲突。因此第一版注册方案被回滚;重新注册这些字段不是可接受的修复。
产品判断
数据库 notification target 是一个专业但有价值的能力。它可以直接提供数据库中的对象命名空间视图或访问流水,不要求用户额外部署事件总线;对于小型部署以及本来就在运行 PostgreSQL/MySQL 的用户仍然有意义。
旧连接参数写法的价值则低得多。五字段模型无法表达常见驱动能力:TLS 模式与证书、连接超时、应用名、Unix socket、PostgreSQL 多主机配置、MySQL 驱动参数,以及未来新增的驱动选项。同时支持两种形式还会制造优先级、合并、脱敏与测试问题;单一规范值不存在这些歧义。
完整连接串才是正确的抽象边界:SILO 负责通知语义,数据库驱动负责连接语法。
因此产品决策是保留能力、删除兼容假象。不支持的旧 target 必须被明确拒绝,不能再被“接受”后转换成一个随后拖垮无关 target 的非法配置。
目标
- 把
connection_string与dsn_string固定为数据库通知唯一受支持的在线配置接口。 - 允许已经含有规范连接串的旧 JSON target 跨过迁移边界,不改变其连接语义。
- 在离散字段旧 target 产生半成品或非法 KV 配置之前明确拒绝。
- 把 #53 当前“服务看似健康、全部通知静默失效”的运行时故障模式,替换为操作者必须先解决才能启动的显式启动期失败。
- 确保迁移错误、日志、健康报告与诊断包都不会暴露数据库密码。
- 从未注册写入源代码审计中删除 Postgres/MySQL 的十条例外。
- 在发布与迁移文档中明确兼容性边界和操作者修复路径。
非目标
- 在当前 KV 接口中同时支持 DSN 与数据库离散字段;
- 自动从旧离散字段生成 DSN;
- 重写共享 KV 分词器;
- 在本补丁中改变
FetchEnabledTargets的 fail-fast 语义; - 静默跳过已启用的数据库 target,再以残缺通知覆盖继续运行;
- 删除 PostgreSQL 或 MySQL notification target;
- 删除为解码和识别不受支持输入所需的旧结构体字段。这些字段仍位于在线构造器共用的 target 参数结构上;构造器中的离散字段连接串合成代码无法从当前 KV 配置到达,但这些字段不能重新成为受支持的配置 key。
- 修复其他八个旧通知 setter 被忽略的错误。它们原有的静默跳过行为在这次狭窄的数据库迁移补丁中保持不变,必须另做审计和设计决策。
功能需求
当前配置
notify_postgres接受connection_string;notify_mysql接受dsn_string。- 五个离散 key 继续保持未注册,并被当前配置命令拒绝。
- 现有完整连接串必须继续支持数据库驱动语法,包括值内部出现
host、port、user、password、database等词的情况。 - 不增加新的公共环境变量或 KV key。
- 已声明的旧变量
MINIO_NOTIFY_POSTGRES_HOST/PORT/USERNAME/PASSWORD/DATABASE及其 MySQL 对应形式没有接入当前解析,继续作为不受支持的形式,也不得在文档中被描述成完整连接串变量的可用替代。
旧配置迁移
- 旧 target 未启用时,
SetNotifyPostgres必须直接返回,不生成 target。 - 对已启用 target,
SetNotifyPostgres必须要求非空ConnectionString,并且只写已注册的 Postgres key。如果规范连接串与离散字段同时存在,以规范连接串为准,所有离散值都被丢弃。 SetNotifyMySQL对DSN执行同样规则。- 两个 helper 都不得写出
host、port、username、password、database。 - 缺少规范连接串时,必须返回带类型或包装上下文的迁移错误,指出子系统与 target 名称。
cmd/config-migrate.go必须检查并传播两个 helper 的错误,禁止忽略。- 任一 helper 失败后,都不得启用或持久化半迁移配置。
- 错误可以指出所需 key 和修复动作,但不得包含任何连接字段值。
- 传播的类型化迁移错误必须中止服务器启动,尤其不得落入
initConfigSubsystem中 “some features may be missing” 的非致命日志路径,也不得进入可重试错误循环。 - 已提供规范连接串的校验错误同样遵守启动致命和保密规则;包装错误只能增加 target 上下文,不能重复 DSN 或其组成部分。
推荐错误形式:
操作者修复路径
遇到错误的操作者必须选择一条明确修复路径。这既适用于首次切换到 SILO,也适用于升级已经运行 SILO 的部署:旧配置迁移结果不会持久化,因此同一份旧 JSON 来源可能在每次启动时重新进入迁移。一个当前仍能启动、但通知已经静默失效的部署,在升级到修复版本后会直接启动失败,直到来源配置被修正。
- 使用兼容的中间 MinIO 版本,把旧字段替换成
connection_string或dsn_string,验证 target 后再迁移到 SILO; - 禁用或删除旧数据库 target,迁移服务器,再用规范连接串重建 target;
- 对全新 SILO 安装,直接使用规范连接串创建 target,不经过旧配置迁移。
- 对仍在读取旧 JSON 文件的现有 SILO 部署,先停留在上一个可运行版本,备份来源配置,再转换、禁用或删除数据库 target,然后启动修复版本;不要删除或改写无关配置。
文档不得暗示离散字段 target 会被自动转换。
可用性权衡
这个决策有意把一种不受支持配置的“降级启动”变成“启动硬失败”。可用性代价是真实的:一台此前仍能提供对象读写、但全部通知已经静默死亡的服务器,在修复后可能拒绝启动。
我们接受这个代价,因为对象服务表面健康、已配置事件出口却全部消失,会造成静默且可能无法补救的下游数据丢失。SILO 是一个迁移边界显式的新 fork,而离散形式从 2020 年起就已废弃。一个致命、可操作的迁移前置条件,比一次看似成功却缩减通知覆盖的升级更安全。发布注记必须突出这个启动行为,不能把它藏在内部迁移清理里。
安全要求
- 不支持输入的错误不得格式化输出旧参数结构或其中任何值。
- 测试必须使用哨兵密码,并断言返回错误和捕获日志中都不存在它。
- 迁移输出只能包含已注册的敏感连接串 key,不能出现独立
passwordkey。 - 如果受影响部署曾在修复前导出并分享诊断包,应将数据库密码视为可能泄露并进行轮换。
备选方案
注册并解析离散字段
优点: 保留旧来源形式,并复用现存参数字段。
拒绝原因: 注册会把常见字段名暴露给共享分词器,破坏引号内的完整连接串;而且这些字段早在 2020 年就已废弃,重新注册等于反向扩大公共配置面。
迁移时自动生成规范连接串
优点: 兼容仅使用离散字段的旧安装。
拒绝原因: 这会为过时输入建立永久代码与测试责任,包括 PostgreSQL 引用、MySQL DSN 格式、socket/IPv6 行为、默认值与未来驱动漂移。对于迁移边界显式的新 fork,这个收益不足以覆盖长期维护面。
只跳过不支持的 target
优点: 对象存储服务与其他通知 target 可以继续运行。
拒绝原因: 静默丢弃已经配置的事件出口可能造成不可见、不可恢复的事件丢失。清晰的迁移失败,比一次通知覆盖缩水却看似成功的升级更安全。
修改全局通知 fail-fast 行为
优点: 限制未来非法 target 的故障半径。
本次拒绝原因: 它既不能修复数据库 target,也不能关闭凭据暴露路径,还会改变全系统错误语义。可另立独立设计和运维契约评估。
删除数据库通知 target
优点: 删除全部数据库专用维护面。
拒绝原因: 这些 target 仍然有用且相对自洽。缺陷属于过时配置形式,不属于通知能力本身。
实现范围
服务端改动应保持狭窄:
- 修改
internal/config/notify/legacy.go:两个数据库 setter 只输出规范已注册 key;已启用但没有规范连接串时明确拒绝。 - 修改
cmd/config-migrate.go:传播两个数据库 helper 的错误,并补充子系统与 target 上下文。 - 定义类型化数据库迁移错误,修改
cmd/server-main.go,让initConfigSubsystem将其作为致命错误返回,而不是记录后忽略;该错误必须保持不可重试。 - 本补丁不改变其他八个旧通知 setter 错误被忽略的现状;将其留给独立审计,不能暗中扩大 #53。
- 从
knownUnregisteredWrites删除 Postgres/MySQL 十项;除非存在另一个独立且有充分理由的旧例外,否则这个棘轮应当归零。 - 增加聚焦的迁移、启动、校验、保密和共存测试。
- 更新
silo.pgsty.com的数据库通知与迁移文档。
补丁不得注册旧 key、修改通用分词器,也不得重构无关通知 target。
验收标准
只有以下证据全部成立,才算实现完成:
-
含完整连接串的旧 PostgreSQL target 可以迁移,通过
CheckValidKeys,并由GetNotifyPostgres原样返回连接串。 -
含完整 DSN 的旧 MySQL target 完成同等验证。
-
两类已启用离散字段 target 都在 target 初始化前失败;错误包含子系统与 target 名称,给出可操作修复建议,且服务器启动中止。
-
缺少连接串和畸形连接串的错误都不包含哨兵 host、用户名、密码、数据库或 DSN 值。
-
未启用的离散旧 target 不生成配置项,也不阻塞迁移。
-
迁移后的 KVS 不含十个离散 key,包括空值形式。
-
旧 target 同时包含规范连接串与冲突离散值时,只迁移规范连接串,所有输出 KVS 值中都不存在离散哨兵值。
-
使用真实
DefaultPostgresKVS和DefaultMySQLKVSkey 集的SetKVS回归测试,能够接受引号内包含port=、host=、password=的完整连接串。 -
包含健康 Webhook、Kafka、NATS target 的配置不能再带着非法迁移数据库 target 进入
FetchEnabledTargets:readConfigWithoutMigrate返回错误,不返回、不持久化、也不启用任何半成品配置,启动路径随后因该类型化错误中止。 -
initConfigSubsystem返回类型化迁移错误,既不能记录后继续,也不能进入可重试循环。 -
knownUnregisteredWrites不再包含 Postgres/MySQL 例外。 -
以下验证全部通过:
cmd的详细输出必须显示两个前缀的测试确实执行;零匹配警告视为验收失败。服务端常规 CI 测试也必须通过;文档仓库执行make check。
实现结果
服务端提交 f1ba68358 在不扩大公共配置面的前提下实现了最终设计:
- 两个旧数据库 setter 只输出
connection_string或dsn_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/mc、pgsty/silo-pkg 与 pgsty/silo-console 都不需要实现修改:客户端只转发配置文本,package 仓库不拥有通知 schema,Console 已经把表单序列化为规范 connection_string 或 dsn_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。
17 - Go 1.27 TLS 默认值与 OIDC Discovery 故障形态
2026-09-17 发布更新: Go TLS 默认值修复(
48e184652)已随 Server 20260916 发布;协调升级、可选功能启用条件和剩余限制仍按各节执行。下方带日期的源码状态与验证记录保留当时的范围。
2026-09-17 后续: #154 报告人在 20260916 上复测,故障依旧。该修复恢复的是
GODEBUG=tlsmlkem=0的效果,并不改变默认握手,也无法处理拒绝新增 ML-DSA 签名编号的入口。由此形成的机制分析、完整解法空间与发布沟通门槛记录在《写死 TLS 参数与握手兼容性》。
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 于 2026-09-11 以补丁合并为由关闭;2026-09-17 在 20260916 上的复测仍然失败,因此仍欠一次带阶段级证据的受影响环境复测。
Go 1.27 改变了什么
- 显式曲线偏好现在会压过 ML-KEM 兼容开关。
GODEBUG=tlsmlkem=0从默认集合移除全部 ML-KEM 混合方案;tlssecpmlkem=0只移除 Go 1.26 新增的 P-256/P-384 混合,仍保留 X25519MLKEM768。显式配置CurvePreferences的应用会在它给出的列表里保留 ML-KEM——这是 Go 1.27 的有意变更。SILO Server 有 8 个 TLS 配置点显式设置了含 X25519MLKEM768 的列表;修复移除这 8 处赋值并退役该 helper,使这些配置点遵循 Go 默认值,兼容开关重新生效。栈评审确认 pkg、mcli 和 Console 客户端原本已使用默认值;Console HTTPS 监听器保留单独的 P-256 策略。 - ClientHello 新增 ML-DSA 签名算法编号(
0x0904–0x0906)。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,由x509sslcertoverrideplatformGODEBUG 默认值决定,而它跟随主模块的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 之后立即 reset(
tls_start后 reset):怀疑入口的 ClientHello 处理——代理 CONNECT 规则、TLS 终止器、任何按键大小或内容匹配的逻辑。Go 1.27 的变化都落在这一层。 - TLS 完成后 reset(
wrote_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)、默认在 Kubernetes/Docker 中使用 30 秒 DNS 刷新、其它环境使用 10 分钟(可由 DNS cache TTL 设置覆盖)、拨号按序遍历地址不做 shuffle、超时为每次 TCP 拨号 5 秒、TLS 握手 10 秒、响应头 1 分钟。discovery 或 JWKS 抓取本身没有总超时——缓慢的 IdP 可以无限期拖住启动;收紧它是已评估过工作量的独立后续项。
归属
本文提炼自 issue #154 调查与九月的 Go 1.27 工具链栈评审;复现工件与完整证据链保留在文档树之外。可支持的表述是:合并后的修复在受影响的 8 个 Server 配置点恢复 Go 密钥交换默认值,并经合成负对照验证——它不声称诊断了任何特定隐藏部署;在受影响环境的复测提供阶段级证据之前,#154 不做任何根因断言。
Go 行为以官方 1.27 发布说明及实际工具链为准。本文的 ClientHello 字节数属于所述夹具测量,不是所有连接的固定大小。
18 - 鉴权前不做 I/O,Header 不授予权限
发布核对(2026-09-16): 本文原始修复已进入 Server 20260903。下文带日期的评审与测试叙述记录当时证据,不代表当前仍待发布,也不代表特定生产部署已验收。后续源码与组件选择见版本表。
本文记录以 PR #101(938603458 至 04b097fd9)合并进 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)
两个问题表面上互不相干:
Originheader 会让最外层 CORS middleware 把 URL 第一段当成桶,在鉴权前同步加载它的 metadata;X-Minio-Source-Replication-Request只要存在,下游代码就会把请求当作内部复制。
它们共享同一个设计错误:不可信的请求形态在越过授权边界之前,就获得了昂贵或特权化的内部含义。
修复建立了两个不变量:
对于 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:
.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 是:
修复前,多条路径把 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:ReplicateObject 或 s3: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 |
同时获得 trusted 与 replicaTrusted |
未授权 REPLICA 必须明确返回 403,不能静默降级成一个会再次被复制的新普通对象。
先认证原始请求,再做清洗
SigV4 会签名请求 header。如果在鉴权前删除内部 header,canonical request 会发生变化,原本有效的签名将变成 SignatureDoesNotMatch。
因此顺序是硬约束:
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:ReplicateDelete 的 replicaTrusted |
| 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 |
产生两个可能互相矛盾的权威,原漏洞类别仍然存在 |
实现边界
最终改动按层组织:
- 一个小型 request-trust 模块定义精确 marker 解析、复制授权、私有 context state 与鉴权后的 effective request;
- object option builder 只有在调用方提供可信状态时才解析 source field;
DecryptObjectInfo、event request parameter、multipart completion、delete option 与 object lock 消费同一个决定;- handler 在既有 authentication path 之后立即计算信任;
- multipart part 把当前请求信任与已保存 MPU replica provenance 结合;
- 通用 metadata extraction 不再接受 replica status;
- 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; - 未授权
REPLICAPUT/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 放在规则前缀之下(
c9ad74673、5db7be4ee)。 -
本次覆盖已命名的 source/replication header。未来任何内部控制都仍需回答同一个问题:哪一个认证后的决定允许这个客户端值获得内部含义?
结论
看起来像内部字段的 header 仍然是客户端输入;看起来像桶名的 URL 段也仍然是攻击者输入。耐久修复是阻止两者意外成为权威:
鉴权之前不做 backend work;鉴权之后只派生一次 trust,并把决定而不是声明传给下游。
这条规则并不只属于 CORS 或 replication。当廉价的公开请求语法与昂贵或特权化的内部状态相遇时,未来 SILO handler 都应该保持这条边界。
本修复在安全台账中编号为 SN-2026-008,20260903 纪事记录发布范围。上文 silo-go 指原评审的临时依赖;0079723d3 后 Server 恢复使用经过验证的上游 minio-go,当前所选版本见组件矩阵。
19 - 一个端点,两种权限:彻底分离用户与组状态
发布核对(2026-09-16): 本文原始修复已进入 Server 20260903。下文带日期的评审与测试叙述记录当时证据,不代表当前仍待发布,也不代表特定生产部署已验收。后续源码与组件选择见版本表。
本文完整记录 上游 issue minio/minio#21478 与 SILO 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:EnableGroup或admin: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:EnableUser 与 admin: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 用同一个路由处理两个方向的状态变化:
修复前,handler 在读取目标状态之前,就固定检查一个 action:
后面的 SetUserStatus 虽然会正确接收 enabled 或 disabled,鉴权却已经把两种操作都当成 Enable。于是,admin:DisableUser 明明存在于策略词汇和公开文档中,却无法独立授权这个端点。
#21478 给出了真实反例:操作员希望在事件处置期间拥有“只能禁用、不能恢复账户”的策略。策略只包含 admin:DisableUser 时会收到 AccessDenied;加上 admin:EnableUser 后禁用才能成功,但操作员也同时获得了策略原本刻意不授予的恢复权限。
这不是少了一个便利权限,而是策略模型与执行点错位:
为什么两个 action 必须代表两种能力
账户状态变化具有方向性。禁用通常可以委派给事件响应人员、反欺诈控制、合规自动化或 break-glass 流程;重新启用意味着恢复访问,完全可能要求另一位审批者。
如果任意一个 action 都能授权两个方向,策略作者就无法表达这种职责分离。服务器表面上公布两个名字,实际却只执行一个合并能力。因此最终契约必须严格:
| Principal 策略 | 禁用目标 | 启用目标 |
|---|---|---|
仅 admin:DisableUser |
允许 | 拒绝 |
仅 admin:EnableUser |
拒绝 | 允许 |
| 两者都有 | 允许 | 允许 |
| 两者都没有 | 拒绝 | 拒绝 |
内置 consoleAdmin 授予 admin:*,完整管理员仍然拥有两个操作。兼容性影响仅限于曾经依赖错误行为的自定义受限策略。
公开 PBAC 参考现在也为 admin:EnableUser 与 admin:DisableUser 明确写入同一契约。
设计目标与非目标
设计目标
- 让两个现有 Admin Action 按照各自名字真正生效;
- 在两个方向上都满足最小权限;
- 每个请求只做一次鉴权决策,最多写出一次鉴权错误;
- 保持路由、请求值、响应格式、自操作保护、IAM 存储调用和站点复制 hook 不变;
- 用测试锁死契约,防止两个权限再次被扩宽、合并或调换。
非目标
- 把端点拆成单独的 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 路径拒绝非法状态值。
最终实现
修复增加一个纯选择函数:
Handler 先读取路由变量,选择 action,然后只鉴权一次:
鉴权门之后的逻辑完全不变:
- 调用者仍不能启用或禁用自己的账户;
globalIAMSys.SetUserStatus继续校验并持久化目标状态;- 站点复制继续记录同一状态和更新时间;
- 响应与审计仍走已有路径。
选择函数只依赖请求明确给出的目标状态。它不会读取当前用户,不会根据存储状态猜测 transition,也不会让鉴权结果取决于目标是否存在。这样既保证鉴权确定性,也避免在鉴权前引入读取依赖。
为什么这个修复是安全的
正确性由五条不变量组成:
- 每个合法状态只映射到一个 Admin Action;
validateAdminReq只调用一次,鉴权失败后不可能继续 mutation;- 只有选定 action 鉴权成功,状态修改调用才可达;
- 非法状态保留旧的 Enable 鉴权边界,之后仍由已有状态校验路径拒绝;
- 存储、复制、wire 与 client contract 都不改变,变化的只有进入既有 mutation 所需的权限。
对于曾经用 Enable-only 自定义策略执行禁用操作的调用者,这是一项有意的鉴权收紧。也正是这项收紧,才让 admin:DisableUser 成为真正独立的能力。
测试设计
纯 action 映射
单元测试锁定三个选择结果:
| 输入 | 期望 action |
|---|---|
enabled |
EnableUser |
disabled |
DisableUser |
| 非法值 | 旧的 EnableUser 默认值 |
四向 IAM 鉴权矩阵
集成测试创建彼此独立的用户和策略,再通过真实 Admin API 执行:
- Disable-only client 可以成功禁用目标;
- 同一 client 尝试启用目标时得到
AccessDenied; - Enable-only client 可以成功启用目标;
- 同一 client 尝试禁用目标时得到
AccessDenied。
只检查两个正向用例不能证明最小权限:如果两个策略意外都能执行两个操作,正向测试仍会通过。两个交叉拒绝断言才是安全回归测试。
测试结束后会删除所有临时用户和策略。它运行在既有 IAM server suite 中,覆盖请求签名、策略挂载、handler 鉴权、状态持久化和 Admin client 错误解码,而不只是测试 helper。
修复与验证记录
服务端原工作树中混有依赖升级、生成的 credits、checksum 测试和安全文档修改,本地 main 也落后远端。两个用户状态文件因此被隔离到基于最新 origin/main 的干净 worktree;无关文件没有进入修复提交。
本地验证通过:
带 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 与任务分支才被删除。
最小权限策略示例
只能禁用的操作员
该 principal 可以查看并禁用另一用户,但不能重新启用。
只能启用的操作员
该 principal 可以查看并启用另一用户,但不能禁用。负责完整账户生命周期的角色应显式授予两个 action。
组状态权限善后
组端点与用户端点具有相同结构:
它也公开了 admin:EnableGroup 与 admin:DisableGroup 两个既有 action,但继承 handler 在读取 status 前固定用 EnableGroup 鉴权。这不是一个“没有用到的权限”而已,而是同时反转了两个方向的最小权限:不该拥有禁用能力的 principal 可以禁用,真正的 disable-only principal 却不能。
善后提交增加 setGroupStatusAdminAction,刻意与 setUserStatusAdminAction 同构:
集成测试创建相互独立的 EnableGroup-only、DisableGroup-only 管理员与真实目标组,证明:
- DisableGroup-only 可以禁用;
- DisableGroup-only 不能启用;
- EnableGroup-only 可以启用;
- 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 | Server 20260903 | Server 20260903 |
| Release package 或 container image | 见 20260903 发布记录 | 见 20260903 发布记录 |
| 部署 | 尚未确认 | 尚未确认 |
| 生产行为 | 尚未确认 | 尚未确认 |
| 上游合并 | 不可用;仓库已归档 | 不适用 |
结论
这些修复让鉴权模型说真话。启用和禁用用户或组,都是风险方向相反的状态变化,SILO 也早已为每个方向提供不同 policy action;每个 handler 都应该从请求的目标状态选择 action,并在 mutation 前只鉴权一次。
代码很小,是因为设计边界足够清晰。真正需要长期保留的是更完整的结果:明确的权限矩阵、被否决的兼容方案、非法输入规则、四向集成测试、干净的合并证据、迁移指引,以及不把“已合并”误报成“已发布”的交付边界。
本页的 enable/disable 权限修复与后续 admin:ChangeMyPassword 拆分是不同改动。升级九月候选前须按密码迁移指南为原有自助改密限制保留配对 Deny。
20 - IAM Admin API 授权边界修复
2026-10-16 决策:GO,源码验收与合并已完成。
服务端 PR #242 已将带 DCO 签署的修复提交
2616d43fb
合入 main,合并提交为
4e55fc7db。
最终候选直接基于 main 的 00a375c4e 验证,与待处理的 LIST 修复独立。
八项云端检查及完整本地 IAM/STS 集成测试全部通过。
本文记录源码集成状态。打标签、发布软件包或容器、生产部署仍是独立事项; 合并不代表已有发行镜像已经包含修复。
问题与范围
部分 Admin Handler 在授权已经拒绝后,仍以“目标属于自己”为由放行;列出自己的 服务账号还可能完全跳过授权。这些路径会覆盖用户或组策略的显式 Deny、内联会话 策略限制,以及外部授权插件的拒绝结果。
创建和更新凭据还存在另一类边界问题:拥有相应 Admin Action 的受限派生凭据, 可能创建一个继承父账号更大权限的新密钥,或通过更新移除服务账号的限制。 拥有广泛 Admin 权限的非 root 管理员也可能管理 root 的派生凭据。 在内置授权下,仅获准执行某个 Action,不足以放行这些操作。
本次修复覆盖五个查询/清理 Handler 和两条创建/更新路径,包括 LDAP 创建入口。 保留策略引擎的全局语义、现有 Admin 路由和 Action、请求响应格式及 IAM 存储格式, 不需要修改依赖版本。
本文的内置授权指没有配置外部授权插件,包括由本地 IAM 评估 LDAP/OIDC 身份 权限的情况,不等于调用者必须是内置用户。相关策略模型见 访问管理 与外部授权。
授权契约
传给 IAM 的操作主体始终是已认证的调用者 AccessKey。比较归属时,有 ParentUser
就使用父账号,否则使用 AccessKey。归一化归属不会在策略评估时把派生凭据替换
成父账号身份。
自查询和自清理通过 DenyOnly: true 表达既有的自管理语义,并在授权之前设置。
普通用户不需要额外的 Allow,但适用的显式 Deny 仍然有效。派生凭据继续完整经过
IAM 评估,有效会话策略必须明确允许相应操作。外部插件的拒绝结果不可被覆盖。
Handler 不再因为目标属于调用者,就把拒绝改成成功。
| Handler | 现有 Admin Action | 自管理语义与附加边界 |
|---|---|---|
InfoServiceAccount |
admin:ListServiceAccounts |
比较有效用户与服务账号的存储父账号,授权通过后返回信息。 |
ListServiceAccounts |
admin:ListServiceAccounts |
空用户参数、调用者 AccessKey、调用者父账号均为自身别名;每次请求都授权,并为这些别名列出父账号的密钥。 |
InfoAccessKey |
admin:ListServiceAccounts |
目标必须存在、属于派生凭据且父账号相同才算自身;不存在的目标不能形成自身例外。 |
DeleteServiceAccount |
admin:RemoveServiceAccount |
自清理先授权,再检查 root 边界;目标 token 损坏时仍使用存储身份元数据。 |
RevokeTokens |
admin:RemoveServiceAccount |
省略用户或解析后的用户等于调用者父账号时视为自身;先授权,且保护 root 目标。 |
AddServiceAccount、AddServiceAccountLDAP |
admin:CreateServiceAccount |
保留既有自身/其他用户的 IAM 授权,再拒绝有效会话策略调用者及无权管理的实际 root 目标。 |
UpdateServiceAccount |
admin:UpdateServiceAccount |
保留现有 Action 要求,再拒绝有效会话策略调用者及无权管理的 root 目标;不新增自更新权限。 |
即使会话策略允许创建或更新 Action,受该策略限制的调用者也不能执行这些操作。 安全委派需要另行设计限制的传递或策略包含关系判定,本次不实现这一能力。 更新守卫也覆盖仅修改名称等元数据的请求:现有更新实现可能在请求未提供新策略时 清除原有内联策略。
三个局部辅助函数
生产修改集中在 cmd/admin-handlers-users.go 和
cmd/admin-handlers-idp-ldap.go 的 claims 复制循环。
三个小型辅助函数表达边界,IAMSys.IsAllowed 保持不变。
hasActiveSessionPolicy 复用现有会话策略评估函数,先判断临时凭据,再判断服务
账号,与 IAM 的派发顺序一致。带过期时间的服务账号也满足 IsTemp(),会按 STS
路径评估。因此,标记为 inherited-policy、带过期时间的历史凭据仍可能携带有效的内联
限制。如果先检查 inherited 标记,就可能错误地放行它创建或更新更大权限的密钥。
非临时服务账号则遵循现有服务账号评估器对继承标记与空策略的处理。
canManageRootCredentials 是内置授权成功之后的附加守卫,接受 root 本身或不受
会话策略限制的 root 派生凭据,并排除内部站点复制服务账号。它自身不授予 Action。
签名验证的 owner 标记不足以完成这项判断:只要原始会话策略 claim 存在,该标记
就可能被清除,即使服务账号评估器认为策略为空。检查实际限制,才能保留历史上
使用 null、{} 或 {"Statement":null} 的 root 服务账号管理能力。
isAllowedServiceAccountAction 保留普通用户在最后一个绑定策略被移除后,既有的
自查询、自清理能力。仅对内置授权、非 owner、非派生、DenyOnly 的调用者,直接
解析用户和组的策略并评估合并结果,允许合并结果为空;显式 Deny 仍然有效。
其他调用者继续使用 IAMSys.IsAllowed。修改引擎全局的“无绑定策略”行为会影响
无关的 S3 与 Admin 操作,因此不在此处修改。
创建、更新、删除和吊销操作的 root 守卫仅作用于内置授权。外部授权插件继续决定 这些操作,包括 root 目标;此前被自身旁路跳过的请求,现在也必须尊重插件结果。
空策略与 claims 的归属
复核发现创建路径还有一项可用性缺陷:把父服务账号的空内联策略复制给新的继承型 密钥,会留下过时的策略内部 claim。若新密钥带过期时间,STS 评估因空策略没有 Version 而拒绝请求,造成“创建成功,但密钥不能执行预期操作”。
新请求自己显式指定语义空策略并附带过期时间,也会产生同样的故障。 两种情况都在 Admin 签发入口解决:
- 两个创建 Handler 复制调用者 claims 时,跳过
exp、policy.SessionPolicyName、sessionPolicyNameExtracted和iamPolicyClaimNameSA()。新密钥使用自己的过期时间和请求策略,其他身份信息, 包括 LDAP/OIDC 上下文,继续保留。 commonAddServiceAccount仅在解析结果满足Version == "" && Statements == nil && ID == ""时规范化为继承策略。 这与现有服务账号评估器的空策略定义一致。同时清空本地策略选项和返回请求的 Policy,使站点复制的创建消息与本地生成的密钥保持一致。
| 新密钥请求中的策略 | 结果 |
|---|---|
省略、null、{}、{"Statement":null} |
继承父账号权限,包括设置了过期时间的情况。 |
| 只允许特定 Action 的策略 | 保留请求限制,未允许的操作继续被拒绝。 |
{"Version":"2012-10-17"} 或 {"Statement":[]} |
不规范化为继承;带过期时间的回归用例继续拒绝访问。 |
这里刻意不使用 len(Statements) == 0 判断:没有语句的策略不一定属于 Console
空策略的特殊情况。
IAMSys.NewServiceAccount 同时服务于导入和站点复制。如果在这一公共构造函数中
清洗 claims,可能丢掉仅存在于带过期时间的历史凭据 claims 内的有效限制。因此不修改公共
构造函数和历史记录的重放语义。本次修复新 Admin 签发行为,不重写已存储凭据。
删除与错误语义
删除需要可信的身份元数据,不需要目标 session token 能被成功解码。
DeleteServiceAccount 直接读取 globalIAMSys.store.GetUser,检查服务账号类型,
并保留存储的父账号。底层删除操作也使用同一查找原语。这样既避开 token 解析,
也避开公开用户查找对禁用/过期凭据的过滤,同时保留 store 已有的父账号撤销检查。
有权限的管理员仍能清理损坏、禁用或过期的非 root 服务账号;非 root 管理员不能 借 token 损坏隐藏 root 归属。保留下来的父账号也让站点复制钩子正确判断是否应发送 删除消息。这不意味着可以恢复身份元数据缺失或已无法识别为服务账号的任意记录。
无权删除他人的密钥、被 root 守卫拒绝、以及服务账号不存在时,返回
ErrAdminServiceAccountNotFound(HTTP 404)。自身操作被策略拒绝时返回
ErrAccessDenied(HTTP 403)。其他端点保留已有的查询与报错顺序;本次不重新设计
所有 Admin 接口的防枚举行为。
兼容性与边界
预期收紧包括:自身操作也必须尊重既有拒绝;会话受限凭据不能创建或更新密钥; 内置授权下保护 root 凭据变更。依赖旧旁路的自动化需要改用具备相应权限、且不受 会话限制的身份。普通用户仍保留既有查询与清理能力,包括解绑策略后;历史 root 空策略服务账号仍保留管理能力。
新空策略密钥的元数据规范化为 inherited policy,而不再保存嵌入式空策略。 检查策略表示的客户端可能观察到这一变化;服务账号继承权限的含义保持一致, 带有效期的密钥在两种评估路径上不一致的问题得到修复。策略内部保留字段也不再从调用者复制给新密钥。 检查这些内部字段的外部插件应将其视为新凭据自身的属性,而不是调用者会话限制的 副本。
ListAccessKeysBulk 不在本次修改范围内。其自身路径仍使用 IAMSys.IsAllowed,
所以没有任何绑定策略的普通用户可能在该接口被拒绝,但能通过
ListServiceAccounts 自查。这是既有端点差异,不是本次引入的授权旁路,没必要
仅为一致性而在此扩大权限。
已经不可用、带过期时间的历史凭据不会被自动修复。应由有权限的父账号或管理员重新签发 预期密钥,验证访问后移除旧密钥。不要通过批量擦除导入或复制凭据的策略 claims 进行迁移;历史凭据已有的有效限制必须保留。
正式支持的产品组合为 SILO、SILO Console、mcli 和 silo-pkg;与未修改的上游 MinIO/MC 保持尽最大努力的兼容。本次不改变依赖选择,也不新增上游发布门禁。 同样不改变分布式撤销顺序,也不保证并发管理变更与授权之间形成原子事务;这些 边界见持久化 IAM 撤销。
成本与替代方案
两个生产 Handler 文件合计净增 59 行。没有新增缓存、后台任务、锁协议、存储迁移, 也没有修改 S3 数据路径。字段过滤使用已有复制循环,空策略规范化复用创建请求 已经解析的策略。
Admin 创建/更新检查可能额外解析调用者的内联策略;服务账号策略已有 4 KiB 上限。复用现有评估器避免了维护第二套“有效策略”定义。部分原先跳过授权的自查询 现在增加了必要的策略评估。策略查找通常使用 IAM 缓存,但合并策略的现有加载器在 文档缺失时仍可能访问存储。插件部署中,新增的授权检查可能产生插件请求,其延迟 取决于插件服务。
在 Darwin/arm64、Apple M5 Max、Go 1.27.2 上进行的局部诊断基准,使用三次 200 ms 采样:普通/继承型会话检查约 11–16 ns,无分配;空服务策略约 0.30–0.32 微秒; 约 3 KiB 的策略约 10 微秒。缓存命中的普通用户自身授权与现有 IAM 路径同处于 亚微秒量级,每次均为 800 字节、15 次分配。这些只是局部测量,不是端点延迟或 S3 吞吐承诺;没有证据表明需要为这些 Admin 路径引入缓存或更复杂的策略表示。
未采用的方案包括:全局修改 IsAllowed、仅凭原始 claim 存在就判断受限、在
NewServiceAccount 统一删除策略字段、实现委派策略包含关系分析,以及一律拒绝
损坏密钥的清理。这些方案要么扩大改动范围,要么破坏局部检查可以保留的既有行为。
回归证据
最终候选提交 2616d43fbc3400a02cc1e6f9299e26ae8b2e71a6 于 2026-10-16 通过以下检查:
| 检查 | 结果 |
|---|---|
完整 TestIAM* / TestSTS*,使用真实 etcd、LDAP、两个 OIDC Provider 和授权插件 |
57 个顶层测试;380 条包含父测试和子测试的通过记录;0 失败、0 跳过。 |
十个 TestIAMAdmin* 回归测试,使用签名请求及加密 Admin 请求体 |
通过。 |
TestIAMAdmin* 与 peer 删除/重载测试的定向 race 检查 |
通过。 |
| 认证、身份配置、策略配置相关包 | 通过;无测试文件的包如实报告。 |
make verifiers |
lint、生成文件、品牌与兼容性检查通过;可选的 typos 未安装,已跳过。 |
make build |
通过,生成 silo 可执行文件。 |
| 最终 PR 提交的云端检查 | DCO、漏洞扫描、格式/构建/Vet、lint/生成文件、race、交叉编译、internal 与 cmd 测试,共八项全部通过。 |
cmd/admin-handlers-users-authz_test.go 覆盖 root 空策略、无绑定策略用户自管理、
父用户/组/会话/插件拒绝、带过期时间的历史 inherited 凭据、非 root 操作 root 目标、
禁用/过期损坏密钥清理、删除复制、自身别名,以及空策略子密钥签发的正反向访问
检查。LDAP 套件还通过 AddServiceAccountLDAP 签发带过期时间的子密钥并实际访问 S3。
另有本地对抗性探针验证损坏 root 密钥的清理边界,以及五个自管理 Handler 对 STS
显式/隐式拒绝的处理。
同时修正两处既有测试问题:peer 重载测试在构造对象存储 fixture 前明确清空残留
的 etcd 客户端;IAM 导入测试从 policy.DefaultPolicies 获取默认策略名称,替代
固定名单。两者都不改变生产授权,也不弱化断言;额外的 fixture 顺序检查同样通过。
主要验证命令如下:
完整集成验证需要为 _MINIO_ETCD_TEST_SERVER、SILO_TEST_IAM_REVOCATION_ETCD、
_MINIO_LDAP_TEST_SERVER、_MINIO_OPENID_TEST_SERVER、
_MINIO_OPENID_TEST_SERVER_2 和 _MINIO_POLICY_PLUGIN_ENDPOINT 配置可用服务。
缺少这些环境变量的运行可能跳过集成测试,不能等同于本次结果。
完整运行设置了 CGO_ENABLED=0 和 MINIO_API_REQUESTS_MAX=10000;独立的 race
检查使用支持 race 的正常构建环境。
验收与交付
对抗性复核在上述范围内未发现剩余阻塞问题。补丁足以关闭已识别的授权路径,保留 经过验证的兼容边界,且 Admin 操作的复杂度和执行成本合理。 结论为本次代码候选版本 GO。
带签署的源码提交与合并记录见本文开头,发行说明仍需标明最终包含修复的版本。所有接受 Admin 流量 的服务端都需要升级,混合版本环境仍可能把请求路由到旧 Handler。本次没有新的 磁盘格式,也不需要批量重写凭据。回滚会恢复之前的授权缺陷,因此回滚不能作为 安全修复。服务端发布、文档发布和部署验证仍是独立步骤,源码验收不等于这些步骤完成。
21 - 配置环境文件不是 Shell 脚本
发布核对(2026-09-16): 本文原始修复已进入 Server 20260903。下文带日期的评审与测试叙述记录当时证据,不代表当前仍待发布,也不代表特定生产部署已验收。后续源码与组件选择见版本表。
本文定义 MINIO_CONFIG_ENV_FILE 的启动契约,并记录 SILO 提交 2aea7fe9c 中的兼容性修复。
截至 2026-08-28 的状态: 实现、定向测试、完整
cmd与internal套件、tagged tests、race、vet、lint、生成物检查、rebrand 守卫、构建与本机 Fable Max 独立评审均已完成。该提交已于 2026-08-29 以2aea7fe9c合并进main;tag、软件包、镜像、部署和生产验证仍是独立门槛。
范围: 只修改环境文件解析与命名 target 发现;不改配置键、子系统、取值优先级、存储格式或客户端 API。
兼容性原则: 这是 SILO 的输入格式。支持可选的export前缀,并不意味着它是一段 POSIX shell 程序。
太长不看(TL;DR)
SILO 可以从文件加载启动环境变量:
解析器接受如下写法:
最后两个键尤其关键。支持多 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 的结构是:
例如:
Target discovery 会按固定 parameter 前缀枚举变量,并把剩余后缀当成 target;读取时也用同一个原样后缀重建变量名,不会大写或净化 target。环境文件解析器拒绝 -,因此破坏的是一条本来完整可用的发现—读取链,而不是在保护某条 shell 执行路径——因为这个文件根本不会被 shell 执行。
该问题的运维影响很尖锐:MINIO_CONFIG_ENV_FILE 只在启动时读取。服务器可能继续使用旧进程环境正常运行,却在文件或二进制更新后的下一次重启突然失败。错误输入当然应该 fail fast,但 parser 不能擅自发明比配置系统更窄的 target 语法。
文件语法
行与注释
- 忽略空行;
- 忽略第一个非空白字符为
#的整行; - 删除后面紧跟空白的独立
export前缀; exportFOO=value的键仍然是exportFOO,不会误删前缀;- 第一个
=分隔 key/value,后续=全部保留在 value 中。
这个文件不是 shell,不执行变量展开、命令替换、反斜杠处理或行尾注释解释。
键名
键名两侧空白会先删除,剩余内容必须:
- 非空且为合法 UTF-8;
- 只包含 Unicode graphic 字符;
- 不包含空白、
=、NUL、控制字符或不可见格式字符。
这个契约保留 OS 兼容名称与多 target 后缀,同时拒绝视觉上为空或结构含糊的键。以数字或标点开头的键可以通过 parser;SILO 仍只读取自身组件实际使用的精确名称。
值与引号
未加引号的值会 trim。需要保留首尾空格时,请用匹配的单引号或双引号包裹完整值:
解析器只删除一对匹配的外层引号,不解释引号内部的转义。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。
解析契约已进入 Server 20260903,具体部署仍需核对实际运行制品。
结论
配置兼容性的前提,是验证 SILO 真正消费的格式。MINIO_CONFIG_ENV_FILE 只借用了少量 dotenv 风格语法方便运维,并不会被 shell 执行。最终修复在恢复命名 target 兼容性的同时,保留了 NUL、不可见字符、脱敏与 fail-fast 保障。
最初的 #65 还包含重启陷阱:旧解析器将 KEY = new 保存为带尾随空格的键 KEY 。systemd 冷启动可能正常,因为它自己解析 EnvironmentFile;管理 API 的 re-exec 继承 KEY=old 后,带空格的新键不能覆盖它,重启可成功却继续使用旧值。修复后空白赋值真正生效,升级前应核对 root、KMS、IdP 等敏感配置的预期值。
22 - 两把 SSE-C 密钥,一份 CopyObject 响应
发布核对(2026-09-16): 本文原始修复已进入 Server 20260903。下文带日期的评审与测试叙述记录当时证据,不代表当前仍待发布,也不代表特定生产部署已验收。后续源码与组件选择见版本表。
本文记录 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 时,旧流程是:
对象与持久化 checksum 都是正确的;缺陷只发生在成功响应上。最终修复复制请求头、删除恰好三个 copy-source SSE-C customer header,以此形成目标响应视图;随后只解密一次目标 checksum,并把同一个 map 同时用于 XML 和 HTTP response header。
可观察故障
触发条件是目标对象带 checksum,并且源/目标使用不同 SSE-C 上下文。代表性请求包含:
修复前:
- 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 解释却会被破坏。安全边界必须按对象和时间区分:
最终修复只作用于提交后的响应边界。
最终实现
目标响应视图
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。
修复后:
通用 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。真正耐久的修复不是新增加密机制,而是建立明确上下文边界,然后只做一次解密、两次如实响应投影。
23 - 为什么 CompleteMultipartUpload 必须返回 ChecksumType:PR #57 评审记录
Shooks(@Dansyuqri)贡献的 PR #57 修复了 #47,已包含在 Server 20260903 中。 本文记录响应契约;具体生产部署是否包含修复,仍取决于实际运行的制品与安装。
缺陷与范围
对 @cbornet 报告的 #31 的调查拆出了两个问题。
CRC32 分段上传完成的数据路径由 c8590413f 与 3e14733f1 修复。
成功完成后,对象保存了校验和类型,HEAD 也可返回,但完成响应的 XML 丢弃了这个类型,
导致 SDK 获得有效校验和值,却没有 ChecksumType。
这不会破坏存储的数据,但使客户端无法直接区分整对象校验和与组合校验和。 仅凭 Base64 编码,消费者不能确定应该复现哪一种计算。
响应契约
| 存储状态 | 完成响应 |
|---|---|
| 整对象校验和 | ChecksumType=FULL_OBJECT 及对应算法值 |
| 分段组合校验和 | ChecksumType=COMPOSITE 及对应算法值 |
| 没有额外校验和 | 不输出 ChecksumType 元素,也不伪造校验和 |
S3 完成上传 API定义了这两种类型。 ETag 是独立字段,不能替代额外的 S3 校验和。
实现与证据
生产代码在 CompleteMultipartUploadResponse 中添加带 xml:"ChecksumType,omitempty" 的
ChecksumType string 字段,并在解码存储校验和元数据后赋值为 cs[xhttp.AmzChecksumType]。
生成器复用其他校验和 API 使用的同一份状态,不重新计算内容,也不根据分片数量推断类型。
合并提交包含整对象、组合与无校验和三种响应测试。
原改动还在当时的 rebrand 清单登记了导出字段;该清单的导出符号部分随后由 bc3b35f97 删除,不能视为当前公开 API 兼容性保证。
兼容性与相邻改动
忽略未知 XML 元素的客户端继续兼容。本修复没有增加算法、修改存储元数据格式、迁移对象或绕过校验。
UploadPart 修复与完成时校验
属于独立改动,也已进入 Server 20260903。其中 CRC64NVME + COMPOSITE 会被拒绝,不再静默规范化。
24 - 总量未知时,进度条应该说什么
历史范围: 本页记录 Console 2.2.0 的进度修复,已随 Server 20260903 的内嵌 Console 交付。Console 2.4.1 后续增加文件流写入与浏览器原生下载交接,已不再把整个 ZIP 缓存在 JavaScript Blob 中。下方原始 PRD 的传输路径与待办按 2.2.0 时点理解。
状态:已随 SILO Console 2.2.0 发布(
16960f7ab);服务端自更新 Console pin(edc8be6ed)起内嵌该修复 · 优先级: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 内嵌的正是这一版本。
复现步骤:
- 在某个 prefix 下放入若干对象,例如
folder/。 - 停留在父目录,选择
folder/并点击 Download。 - 在传输完成前打开 Downloads / Uploads。
- 任务行显示
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 事件,不代表百分比可计算
客户端当前对每个事件都执行:
Prefix 的分母为零或缺失。根据实际值与事件,JavaScript 会产生 NaN(loaded / undefined 或 0 / 0)或 Infinity(正数字节除以零)。
progress callback 随后把非有限值写入 Redux,同时设置 waitingForFile=false。第二个操作才是决定性的状态错误:任务仅仅因为“来了一个事件”就离开了现有 indeterminate 分支,而不是因为事件真的提供了可用总量。确定进度组件拿到非法值,最终渲染出非法标签。
完整链路如下:
普通非空文件之所以不出问题,是因为服务端可以 stat 对象、设置 Content-Length,列表中的大小也为正数。如果浏览器为空响应触发 progress 事件,零字节文件虽然是真对象,却会抵达与 prefix 相同的算术边界,因此必须纳入回归契约。
产品契约
UI 只需要诚实地区分两种情况:
- Determinate:已传输字节与总字节都已知,而且单位相同。
- Indeterminate:请求正在进行,但总量未知。
由此得到四条承重不变量:
这些不变量比 objectPath.endsWith("/") 更一般:无需发明对象类型特例,就能同时覆盖 prefix、零字节文件、异常元数据和未来任何未知长度响应。
目标与非目标
目标
- 文件夹下载不再显示
NaN%、Infinity%或伪造的确定百分比。 - 总长度未知的传输使用现有 indeterminate 动画。
- 总长度已知的普通文件保留当前百分比体验。
- 完成、失败与取消都必须离开 indeterminate。
- 零字节文件不得产生非有限百分比,并且仍能成功完成。
- 非有限或越界下载百分比不得进入 Redux。
- 修复可以先在 Console 独立发布,再由 Silo 更新依赖。
非目标
- 不在服务端预生成或缓存完整 ZIP。
- 不把文件夹内对象的未压缩大小之和冒充网络传输总量。
- 不重构整个 Object Manager 状态模型。
- 不把文件夹切换到当前“点击即完成”的
BrowserDownload路径。 - 不在这里解决
XMLHttpRequest.responseType="blob"的浏览器内存占用。 - 不改变取消记录是否保留到用户手动清理的现有产品行为。
- 不重新设计 HTTP 响应头发出之后,流式 ZIP 中途失败的错误表达。
- 不改变 S3 API、Console API、对象布局或 ZIP 内容。
这些都是合理的后续工作,但把它们绑进当前缺陷会扩大风险,却不是恢复诚实进度所必需的。
最终决策
最小生产修复由四部分组成。
D1. 只使用有效总量计算
增加一个不依赖 DOM 和 Redux 副作用的小型纯函数:
总量来源的优先级用于保持兼容:
- 有限且为正的
objectSize保留普通文件当前算法。 - 当对象大小不可用,但浏览器声明响应长度可计算,且
event.total有限为正时,使用响应总量。 - 其余情况返回
null:此时还不存在诚实的百分比。
辅助函数的输出契约是闭合的:要么是 null,要么是 [0,100] 内的有限数。
D2. 未知总量保持 indeterminate
XHR handler 只 dispatch 真实百分比:
下载任务本来就以 waitingForFile=true 创建,ObjectHandled 也已经把这个状态渲染成 variant="indeterminate"。没有必要把 Redux 扩成 number | null,也不用再加一个布尔值或修改 MDS。
首次获得有效百分比时,现有 updateProgress 会写入数值并设置 waitingForFile=false。如果整个请求始终没有有效总量,任务就保持 indeterminate,直到终态 action 到来。
D3. 让取消成为真正的终态
完成和失败路径已经会清除 waitingForFile,取消路径没有。需要在 cancelObjectInList 中补上:
没有这一行,修复后的 prefix 下载会在 abort 后继续进入 indeterminate 渲染分支,遮住 Cancelled 状态。任务行继续遵循现有产品行为:保留一条已取消记录,由用户手动移除。本次不要求自动清理。
XHR 边界还需要一条事件顺序守卫。abort() 会先触发 readystatechange(DONE, status=0),随后才触发 abort 事件;如果不提前返回,通用 DONE 分支会先把请求标成失败,onabort 再把它标成取消。DONE/status zero 因此交给专用的 onerror 或 onabort 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=true。ObjectHandled 依据 done 把关闭按钮从“中止请求”切换为“移除记录”;本次保持这一行为。取消后的 Redux 数值仍为 0,但现有 ProgressBarWrapper 会因为 ready=true 渲染一条满格橙色终态进度条并显示 Cancelled 标签;这种既有表现不属于本次修复范围。
waitingForFile 并不是“没有可计算进度”的理想长期命名。重命名它,或用 discriminated union 替代当前多个布尔值,都能改善模型,但那属于独立重构。本次所需的状态和渲染已经存在,复用它的兼容风险最低。
为什么这套方案充分
可以按情况验证修复的闭合性。
普通非空文件
objectSize > 0,辅助函数继续使用当前分母。结果有限且经过边界限制,updateProgress 进入 determinate,完成时仍为 100%。
当前流式文件夹
objectSize 被归一化为 0,同时 lengthComputable=false、event.total=0。辅助函数返回 null;没有非法 action 被 dispatch,因此任务保持 indeterminate。完成时现有 reducer 设置 waitingForFile=false、percentage=100、done=true。
未来提供真实长度的响应
如果代理或未来服务端实现提供了可信响应总量,lengthComputable=true 且 event.total>0。同一份代码会自动给出真实百分比,不需要再次修改产品逻辑。
零字节文件
列表中被省略的大小先还原为零,此后两个总量都为零,中间百分比在数学上未定义。任务在通常极短的生命周期里保持 indeterminate;零字节 Blob 与归一化后的预期大小相等,成功响应随即切换到 100%。整个过程不会计算 0/0。
失败与取消
失败路径本来就会离开 indeterminate;新增的取消转换让 abort 也同样进入终态。终态任务不会仅仅因为总量未知而继续表现得像正在运行。
从数学上说,只有当 total 属于 (0, +infinity) 才会执行除法,结果随后被限制到 [0,100]。因此 NaN 和 Infinity 都不可能穿过计算边界进入 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 会比当前布尔值组合更干净。但在保留 waitingForFile、done、failed、cancelled 的同时再加入 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 与生产前端构建必须通过。
验收标准
- 没有
Content-Length的文件夹 ZIP 传输期间,任务行显示 indeterminate 动画且没有百分比文本。 - 成功完成后,任务显示成功/100%,ZIP 可以正常打开。
- 普通非空文件继续显示有限的确定进度,并以 100% 完成。
- 零字节文件不显示
NaN%或Infinity%,并且成功完成。 - 取消未知总量下载会 abort 请求并显示 Cancelled,而不是继续播放活动动画。
- 任何下载路径都不能把非有限或越界百分比放进 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 |
状态测试
直接覆盖状态转换契约:
- 新下载以
waitingForFile=true开始。 - 没有有效 progress action 时保持 indeterminate。
- 有效 progress 产生有限值并设置
waitingForFile=false。 - complete 产生
done=true、waitingForFile=false、percentage=100。 - failure 产生
failed=true、done=true、waitingForFile=false。 - cancel 产生
cancelled=true、done=true、waitingForFile=false、percentage=0。
浏览器回归
使用真实 Console 测试实例与 Chromium:
- 创建临时桶,在
folder/下放入多个对象。 - 从父目录选择 prefix 并开始下载。
- 使用 CDP 限制下载速度,保证中间状态可观察。
限速用例需用
test.setTimeout放宽默认 30 秒超时。 - 打开 Downloads / Uploads,确认任务存在、没有百分比标签,也不存在
NaN%或Infinity%。 - 取消下载并验证 Cancelled 终态。
- 在
finally中恢复网络条件。 - 不限速再次下载,等待浏览器下载事件并验证 ZIP。
- 对普通非空文件与零字节文件重复相应断言。
- teardown 删除桶、对象、下载与临时文件。
当前 Playwright 项目只启用了 Chromium,因此 CDP 是可接受的测试机制。如果以后启用 Firefox 或 WebKit,纯函数和状态测试保持跨浏览器,只让限速观察测试受 Chromium project 门控。
实现边界
预计 Console 变更:
- 新增
downloadProgress.ts,承载纯计算逻辑。 - 修改
Objects/utils.ts:只 dispatch 非 null 百分比,把 status-zero 终态交给专用 handler,并清理已取消请求。 - 在单选下载 thunk 中还原被省略的零大小。
- 修改
cancelObjectInList,清除waitingForFile。 - 使用现有依赖补充计算、状态与浏览器回归,并在
playwright.config.ts中新增无依赖的unitproject。
预计保持不变:
- Go 文件夹下载 handler 与流式 ZIP。
ObjectHandled、ProgressBarWrapper与 MDS。IFileItem.percentage: number及现有 thunk callback 类型。- S3 与 Console API 路径。
- 存储对象与档案格式。
交付与回滚
修复归属于 pgsty/silo-console,而不是当前收到报告的 Silo 服务端仓库。
交付顺序:
- 把 #62 转移或交叉关联到
pgsty/silo-console。 - 实现边界明确的 Console 修改。
- 通过 typecheck、生产构建、纯函数/状态测试与真实浏览器回归。
- 发布新的 Console 版本。
- 更新 Silo 固定的 Console pseudo-version 或发布依赖。
- 构建 Silo 候选版本,重复文件夹、普通文件、零字节、取消与 ZIP 完整性验证。
- 发布 Silo,并在 Issue 中记录受影响与已修复版本。
没有数据迁移。如果前端修改出现回归,Silo 只需回退 Console 依赖;服务端数据与 API 行为保持兼容。
完成定义
- 计算函数只返回
null或有限的[0,100]数字。 - 活跃的未知总量文件夹下载渲染 indeterminate。
- 普通文件保留确定进度。
- 零字节文件不渲染非法进度。
- 完成、失败与取消任务都离开 indeterminate。
- 流式 ZIP 与服务端响应契约保持不变。
- typecheck、生产构建与自动化回归已在本地通过。
- Console 2.2.0 已发布。
- Server 20260903 已包含更新的 Console,见发布记录。
后续工作
四项相邻改进应该分别建立设计档案:
- 把大文件夹直接流式写入浏览器或文件系统,避免在内存中持有完整 Blob。
- 用 discriminated progress/terminal state 替代 Object Manager 的布尔值组合。
- 改进响应头已经发出后,ZIP 失败的端到端完整性与错误表达。
- 为共享进度组件增加通用非有限值守卫,作为第二道防线。
- 修复既有的 Blob JSON 错误解码与 HTTP 失败路径请求引用清理问题。
它们都不是停止当前 UI 撒谎所必需的。下一阶段维护迭代应先恢复最小而诚实的契约:已知总量才显示百分比,未知总量就保持未知。
后续实现与原设计的差异
Console 2.2.0 的整合还把 size 改为必输出的 JSON 字段,并修复 ZIP 错误传播:读取失败不再静默跳过条目;响应尚未开始可返回 500,开始后以流中断暴露失败。因此上文“不改 API/资源管理”的约束仅适用于最初进度计算补丁,不能概括整个发布。
当前单目录下载会交给浏览器,Console 的完成行表示交接,不证明字节已经全部下载;跟踪和取消应在浏览器下载管理器中进行。多选 ZIP 的文件 writer 与原生交接路径见 Console 2.4.1。大小归一化仍可兼容旧响应,但不再说明当前模型缺少零值字段。
25 - 列举不能丢掉仍有仲裁的 null 版本
历史 LIST 设计已由新决策覆盖 · Earlier LIST design superseded (2026-10-17)
English: The investigation below is retained as historical evidence. Its current-design and pending-work guidance is superseded by the latest LIST recovery decision (English). Refer to that decision for the final scope, compatibility, costs and acceptance limits. This does not change older binaries or announce a production release.
中文: 下文保留历史调查证据;其中关于当前方案和待办的说明已被新设计覆盖,请参考最新 LIST 恢复设计决策(中文),以了解最终范围、兼容性、代价和验收边界。这不改变旧二进制行为,也不是生产发布公告。
发布更新,2026-09-20: null 版本仲裁修复已随 Server 20260916 发布。#218 跟踪的滚动重启列举限制仍然开放。以下调查与验证记录保留当时的日期和范围。
本文记录 2026-09-16 调查的列举漏项:未开版本控制的桶在并发覆盖写下返回 HTTP 200 的列表,却少了同一端点仍能 GET 的 key。这里说明已确认的机制、提交 8d06424b1 的窄修复、这个修复证明了什么,以及仍由 #218 跟踪的反例。
状态: 已在 main,晚于已发布的 Server 20260903。修复在实施前经过三轮外部评审,用确定性的顺序反例、签名 HTTP 列举和差分输入验证。它没有关闭列举问题;见边界。
缺陷
列举会合并被询问各盘的版本流。各盘不一致时,mergeXLV2Versions 选出一个最新版本并统计同意它的流。上游 2022 年的改动(PR #14125)让这个计票容忍 healing 留下的签名差异,这是必要的,但它的选择与计票方式对输入顺序敏感。当每块盘各有一个普通 null 版本、而更新的少数盘排在最后时,旧一代的计数会被重置。实际达到仲裁的旧一代于是被丢弃,合并返回空,解析器把这个 key 当作不存在。请求本身仍然成功,客户端看到的是一份看起来完整、其实有洞的列表。读取该 key 能成功,因为读路径自行解析仲裁。
旧代码在九种盘序下如此失败,并有完整的签名 LIST 证据,而同样内容在其他顺序下列举正确。这个缺陷在已发布的 Server 20260903 中同样存在。
修复
补做的计票只在原选择没有达到仲裁时进行,并且只针对能够精确判断的输入:每个非空盘流恰好有一个普通 null 版本,没有 free version,纠删参数全部相同,而且按裁剪前的原始输入判断。满足时按头部重新分组,只有达到原有效仲裁的组才会返回。混合历史、显式版本 ID、删除标记、free version 与混合纠删布局保持原有行为;严格模式、签名、平票与代表条目的规则不变,合并的共享调用方对所有原本成功的输入得到同样的结果。
验证:九个旧顺序与七个新顺序反例通过;5,620 个差分输入保持约定结果;完整签名 HTTP 列举、对象读取、普通与 race 运行,以及扫描器、healing、迁移这些合并的消费方都做了检查。合入 main 前,六个顶层与 23 个嵌套用例在普通与 race 模式下通过。四节点十六盘集群上并发覆盖写下的 20,000 次稳态成功 LIST 没有漏项。
仍然开放的部分
同一集群上,四个节点滚动重启叠加并发覆盖写,27,966 次成功 LIST 中有 2,624 次少了一到四个 key。这些 key 是可读的:8,192 组同端点 GET/HEAD 核查中 8,186 组返回 200,内容是最后确认的写入。最强的样本开始于最后一个节点健康检查返回后 17.6 s,漏掉的 key 最后一次成功 PUT 在 11.5 s 前已确认,之后没有新写入。原始 XML、HTTP 关联与正文哈希经独立复核,分页与解析解释不了它。
修复后的构建仍有把"无法决策"当作"不存在"而不让请求失败的出口:没有任何一代达到仲裁的合并;有效条目少于仲裁或没有缓存元数据的解析器;丢弃该条目、只在失效 reader 多于仲裁允许时才让列举失败的 partial 回调;以及元数据读取意外失败时跳过该条目的 walker。列举仲裁按被询问的盘数计算,不随中途失效的 reader 调整。样本命中了哪一个出口,从 HTTP 证据看不出来;失败请求的逐盘流、有效仲裁、补票资格与缓存状态没有采集,运行后恢复的元数据也不是失败时的输入。
下一步是对一次失败 LIST 做有界的诊断采集,再据此构造确定性回归,然后才裁决契约是否改成让请求失败或返回三态结果。在此之前,滚动重启期间不要运行会删除源端列表中缺失对象的同步工具,集群稳定后重新列举。
26 - ListObjects 快捷路径不能把不存在的桶伪装成空桶
发布核对(2026-09-16): 本文原始修复已进入 Server 20260903。下文带日期的评审与测试叙述记录当时证据,不代表当前仍待发布,也不代表特定生产部署已验收。后续源码与组件选择见版本表。
本文是 SILO #32 与 PR #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)
这个问题是真的,而且值得修。对不存在的桶执行 ListObjects、ListObjectsV2 或 ListObjectVersions 时,普通请求会在扫描存储时得到 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 给出的最小复现是在不存在的桶上调用:
AWS S3 抛出 NoSuchBucket,SILO 却返回一个成功的空列表。差异不在认证、路由或 XML 编码,而在对象层 listPath 的控制流:
触发提前返回的不是只有 / 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 ListObjects 与 ListObjectsV2 都把 NoSuchBucket 定义为 404:指定桶不存在。prefix、marker、start-after 与 max-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 原来都执行:
改为:
helper 的契约只有两类结果:
- 桶存在:保留原有空列表行为;
- 桶不存在:把
BucketNotFound交给既有错误映射,HTTP 返回 404NoSuchBucket; - 集群无法可靠确认:传播 quorum、offline、timeout 或 context 错误,不再伪造成功。
正常的 listMerged、metacache 扫描、排序、分页和响应生成全部不变。
为什么 helper 放在这里
检查必须紧贴 shortcut,原因有三点:
- 只有这一层知道自己即将绕过全部存储访问;
- 上移到通用参数校验会让所有调用付费;
- 下移到扫描层对这些分支无效,因为它们永远不会进入扫描。
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,作为仍由存储自然报错的控制组。
每组都覆盖 ListObjects、ListObjectsV2 与 ListObjectVersions,并使用类型化的 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 中重复三遍。
本地质量门槛
本地改进提交完成了以下验证:
本地完整 cmd 测试用时 116.215 秒。独立本机 Claude Code 使用 Fable 模型与 Max effort 审查了精确代码树、调用路径、错误映射、测试、性能边界和本轮决策,给出 GO,没有 mandatory pre-merge change。
带 DCO sign-off 的 PR head e9c5340be 随后通过八项远端检查:DCO、VulnCheck 与 Go CI 六个 job。合并后生成的 main@49c8aeac4 又独立通过 VulnCheck 和 Go 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 边界:
- 恢复 metacache continuation 时,
c.fileNotFound分支仍直接返回io.EOF。一个陈旧或构造的 continuation token 遇上已经删除的桶,理论上仍可能得到空 200。把GetBucketInfo放到这里会影响正常分页 continuation,性能与错误语义需要单独设计。 - 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,不继续扩大生产修改范围。
实际执行顺序是:
- 用基于当前
main、带 DCO sign-off 的版本替换陈旧 fork head,同时保留 Jason Lin 的 co-author 归属; - 保留类型化错误判断、V1/V2/版本列表对象层覆盖与 HTTP 级 404 /
NoSuchBucket断言; - 更新 PR 描述,明确 shortcut 扇出成本与正常路径不变的边界;
- 批准 fork workflow,并要求精确 head
e9c5340be的八项检查全部通过; - 针对该 head 提交正式批准评审;
- 使用 expected-head guard 合并为
49c8aeac4,让 #32 自动关闭,再独立要求生成的mainGo CI 与 VulnCheck 全绿。
本轮不需要新增缓存、feature flag、更多抽象或修改 continuation-token 语义。高频 shortcut 流量与大型集群尾延迟仍是后续可观测项,不是继续凭假设扩代码的理由。
仓库集成已经完成。只有 tag、软件包、docker.io/pgsty/silo 镜像、部署与真实 S3 客户端验证分别完成后,才能宣称用户已经获得修复。
结论
问题的本质不是“prefix 为 / 时少报了一个错误”,而是列表引擎把 io.EOF 同时当成了两件不同的事:存在桶的空结果,以及从未确认桶存在的提前结束。上游为大型集群移除通用存在性检查是合理优化,但 shortcut 绕过了“让真实存储操作自然报错”的前提。
选定修复恢复这条前提,只在三个绕过存储的出口调用已有 GetBucketInfo。它会让这些请求变贵,也会在降级集群上暴露真实错误;这两点都是明确成本。作为交换,SILO 恢复 S3 404 语义、升级兼容性与测试保真度,同时完整保留正常列表热路径的上游优化。
这个值得接受、范围受控的兼容修复现已合入绿色 main;release delivery 仍是独立门槛。
27 - 只读 Checksum 审计与可靠的 CLI 输出契约
本文是 MCLI 只读 checksum 校验流程,以及发布审查中发现的 non-TTY 输出缺陷 pgsty/mc#5 的设计与实现记录。
状态: 已随最终的 mcli 20260903 正式发布。命令经 pull request #8 与 #13 合入
main,在托管 CI 中针对真实 SILO 服务器验证, pgsty/mc#5 已关闭。Server 20260903 镜像已捆绑包含本命令的 mcli 20260903。
归属:pgsty/mc。
跟踪: pgsty/mc#5。
安全边界: 本命令只读校验,不负责修复。
太长不看(TL;DR)
历史 CopyObject 实现可能在转换后的存储字节上计算 additional checksum,而不是在
S3 返回给客户端的逻辑对象字节上计算。mcli checksum verify 会筛选对象,独立地
把逻辑对象流送入已记录的算法,并把每个候选分类为 MATCH、MISMATCH、
NO_CHECKSUM、WOULD_VERIFY(dry run)、十种 UNKNOWN_* 分类之一,或三种 SKIPPED_* 之一。
首版实现在终端中工作正常,但 stdout 被重定向时完全不输出。MCLI 为了在非终端 环境中禁用进度 UI,会自动把执行状态标记为 quiet;新命令错误地把这项内部状态 理解成了“用户要求隐藏审计结果”。修复将语义输出与进度抑制分离,同时不改变 全局 quiet 行为,也不会在 CI 中重新打开进度条。
命令与范围
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:
- 使用 checksum mode 执行
HEAD,保留所有支持的 checksum 与ChecksumType; - 对不支持或含糊的状态返回
UNKNOWN_*,绝不猜测; - 将
GET返回的逻辑字节流送入有界 hasher,不把对象体写入磁盘; - 对固定版本使用 VersionID;对可变的未版本化/null 对象使用
If-Match,并在 读取后再次HEAD; - 将独立计算结果与已存值比较。
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 携带 objects、verified 计数、每种结果状态的计数以及 incomplete。
verified 等于 MATCH 加 MISMATCH:只有这两种结果真正把对象体流过了哈希器。
枚举了很多对象却一个都没核验的运行,会如实地显示出来。
--fail-on 支持 mismatch、unknown、no-checksum、any 与 none。默认 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、-q或MC_QUIET=true,TTY 与 non-TTY stdout 都必须收到全部对象记录和最终 summary。 - non-TTY
--json每行输出一个紧凑 JSON 值;TTY JSON 保留 MCLI 既有的美化格式。 - 全局参数在 app、
checksum与verify三层位置都必须生效。 --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_CHECKSUM 与
UNKNOWN_* 数量必须显式展示,二者都不能被包装成“校验成功”。
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 还覆盖了历史 MATCH、MISMATCH 与不支持
的 composite 对象。
该命令已随最终的 mcli 20260903 从 main 顶端的签名
tag 发布,功能套件 —— 包括针对真实 SILO 服务器的一次 checksum 校验 —— 对该提交
全部通过,pgsty/mc#5 已关闭。Server 20260903 已内置该客户端;生产审计仍是需要独立执行并提供证据的工作。
JSON 消费者应检查 schemaVersion: 1,以 type: object 与 type: summary 区分逐对象记录和最终汇总。--max-workers 默认 4,接受 1–64,限制并发对象处理数,不是总内存或服务端 I/O 上限。
28 - 可选校验和,强制失败:修复 UploadPart 与 UploadPartCopy 兼容性
发布核对(2026-09-16): 本文原始修复已进入 Server 20260903。下文带日期的评审与测试叙述记录当时证据,不代表当前仍待发布,也不代表特定生产部署已验收。后续源码与组件选择见版本表。
本文是 SILO #46 的完整设计与实现归档。它记录的并不只是一个 if 条件如何修改,而是一个看似简单的 S3 可选 header,如何一路牵动 multipart 完成语义、复制响应、压缩与加密数据流、兼容基线和发布验证。
状态: 已于 2026-08-24 以
7fea6d5a5合并进main(pgsty/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 采用以下契约:
- 客户端若提供逐 part checksum,服务器继续校验它;错误值与错误算法必须失败,绝不能被 fallback 掩盖。
- 客户端若省略逐 part checksum,服务器使用 MPU 记录的算法,在压缩与加密之前的逻辑明文流上单遍计算并持久化结果。
- 普通
UploadPart只在客户端提供 checksum 时回显响应 header;服务器自行计算的值不回显。 UploadPartCopy没有客户端请求体 checksum,服务器必须计算,并在CopyPartResult中返回对应值。ListParts返回持久化的 part checksum。FULL_OBJECTcompletion 继续从各 part checksum 线性合并完整对象 checksum;COMPOSITEcompletion 继续要求客户端提交每个 part checksum,客户端可从ListParts取回。- 计算必须发生在现有数据读取过程中,不得在 completion 阶段重新读取整个对象。
一句话概括:
可选的是客户端提供的校验值,不是服务器维护 checksum-enabled MPU 内部一致性的责任。
我们如何发现问题
问题是在排查另一个 multipart checksum 缺陷 #31 时发现的。
#31 处理的是 CompleteMultipartUpload:当 checksum type 为 FULL_OBJECT 时,客户端可以只提交 part number、ETag 和可选的完整对象 checksum,而不必在 completion XML 中重复保存所有 part checksum。沿着完成路径向前追踪时,我们发现 erasureObjects.PutObjectPart 在写入任何 part 前有一条更早、更强的约束:
也就是说,只要 MPU 声明了 checksum algorithm,每个普通 UploadPart 请求都必须携带匹配的 x-amz-checksum-*,否则返回:
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。
因此内部不变量是:
删除入口检查却不填充 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:
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。因此本次没有重载它,而是新增未导出的:
该 reader 永远代表 S3 逻辑 part 的明文字节。WithEncryption 可以替换存储 Reader,但不能替换 checksumReader。
PutObjReader 同时提供未导出的 accessor:
- 取得客户端提供或服务器计算的 effective checksum type;
- 客户端值存在时优先返回客户端值;
- 否则返回服务器在 EOF 处生成的结果。
保持方法未导出有两个目的:缩小公共 Go API 变化,也为后续 #63 保留统一内部机制,而不提前改变普通 CopyObject 行为。
在 transform 之前准备 hasher
prepareMultipartChecksumReader 读取 MPU 保存的 algorithm 与 checksum type:
- 没有声明算法时不做任何事;
- 客户端已有 checksum 时比较 base algorithm;
- 算法错误时延续
InvalidArgument; - 客户端没有 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 增加了当前代码树支持的五个字段:
字段使用 omitempty,所以没有启用 checksum 的 MPU 保持旧 XML。普通 UploadPart 仍只通过原有 TransferChecksumHeader 回显客户端请求值;服务器 fallback 不改变它的响应。
为什么这个修改能解决问题
修复后数据流变成:
它同时满足四个以前冲突的目标:
- 协议兼容: 可选 header 省略后上传成功。
- 完整性不降级: 客户端给值时仍做端到端比对;服务器不会用自己的计算结果掩盖错误客户端值。
- 对象语义正确: checksum 覆盖逻辑 S3 字节,而不是压缩数据或密文。
- 性能可控: 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 随后仍报告两个新增符号:
它们不是 #46 引入的,而是本地 main 上更早的数据库通知修复 f1ba68358 有意导出的类型:cmd 启动路径需要通过 errors.As 识别它。此前提交没有同步 baseline,因此任何建立在当前 HEAD 上的改动都会在 CI guard 处失败。
最终采用“方案 A”:把两条 notification 符号登记归属到原修复,同时保留 #46 五条字段。最终 baseline diff 恰好是七条新增、零删除,guard 输出:
这不是把检查关闭。guard 的精确集合比较意味着多登记一个不存在的符号也不能通过。它只是显式确认两组有意的兼容表面变化。
golangci-lint 尚未在本地执行;它仍是远端 go.yml 的发布前检查之一。go test、go 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。
本地验证包括:
全部通过。随后两次 Claude Code Fable 5 Max 实现审查与最终验收都给出 GO,无 blocking finding。
成本、风险与发布边界
服务器为省略 checksum 的 part 增加一次 hash CPU 成本。CRC 成本很低,SHA 的成本更高,但仍在本来就要经过的字节流上完成,不增加内存中完整 part 缓冲,也不增加完成阶段的第二遍读取。
滚动升级期间,新旧节点可能对同一个省略 checksum 的请求给出不同结果:新节点接受,旧节点返回 400。盘上 ObjectPartInfo.Checksums 格式没有变化,降级读取是兼容的;但客户端可见行为要到所有服务节点升级后才稳定。发布说明必须提示完成滚动升级。
原先的本地实现后来已合并并随 Server 20260903 发布。历史本地验证记录不证明任何具体生产部署的状态。
为什么拆出两个独立后续
对抗审查还发现两个相关但独立的问题。
#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 之前安装,并由拥有存储不变量的对象层复核和持久化。
沉淀下来的经验
这次修复留下了几条比具体代码更重要的经验:
- “header 可选”不等于服务器可以缺少内部数据。 协议允许客户端省略,服务器就必须补足自身完成流程需要的状态。
- 接受请求与返回响应是两个契约。 普通 UploadPart 可以在内部计算,却仍须按 AWS 规则不返回该值;UploadPartCopy 则必须返回。
- 流式系统的层次由字节语义决定。 最低层最统一,但不一定还能看到正确的逻辑字节;eager goroutine 还会让“稍后安装”变成竞态。
- 商业实现是证据,不是规范。 AIStor 展示了方向,也展示了不能照抄的边界。
- 兼容 guard 是变更确认机制。
compat-baseline.json不是为了让 CI 闭嘴,而是要求每一个新兼容表面都有明确归属。 - 独立问题应独立交付,但要共享设计不变量。 #63 与 #64 分开做,仍然必须引用并遵守本记录建立的 checksum reader 契约。
最终得到的不是一次宽松化,而是一条更严格也更准确的边界:客户端可以省略可选信息;服务器不能省略正确性。
29 - BadDigest、InvalidRequest 与 CompleteMultipartUpload 校验和契约
2026-09-17 发布更新: 流式校验和后续修复(PR #143)已随 Server 20260916 发布;协调升级、可选功能启用条件和剩余限制仍按各节执行。下方带日期的源码状态与验证记录保留当时的范围。
#48 与 #50 的修复 已包含在 Server 20260903 中。 本文取代八月提案中“暂时保持 CRC64NVME 规范化”的结论。
为什么必须在完成时校验
初始化上传时选择算法和对象校验和类型,各分片保存其校验和值。
完成请求必须依据已存储的契约校验最终值与显式类型断言,不能因为基础算法相同就将 COMPOSITE 改为 FULL_OBJECT,
也不能通过省略最终摘要绕过类型断言。
修复区分省略类型与显式类型,只规范化内部表示标志,并采用操作专用错误类型,保持 UploadPart 与全局校验和错误映射不变。
当前错误契约
下列失败均返回 HTTP 400,不会提交新的已完成对象。
| 请求条件 | S3 错误 |
|---|---|
| 整对象或组合对象摘要错误 | BadDigest |
| 显式有效类型与初始化类型不同,包括仅提供类型的断言 | BadDigest |
| 未知或小写类型 token | InvalidArgument |
| 完成时算法不同于初始化算法 | InvalidArgument |
| 缺少组合校验和所需的分片值 | InvalidRequest,指出算法与分片 |
| 初始化时 CRC64NVME 配合 COMPOSITE | InvalidArgument |
| 完成时 CRC64NVME 值与 COMPOSITE 在解析阶段被拒绝 | InvalidArgument |
| CRC64NVME FULL_OBJECT 上传在完成时仅断言 COMPOSITE | BadDigest |
| UploadPart 的客户端校验和错误 | 保留 XAmzContentChecksumMismatch |
FULL_OBJECT 可以省略分片校验和,但仍校验已提供的值。允许匹配的仅类型断言。
省略可选类型不等于断言 COMPOSITE。SHA1/SHA256 配合 FULL_OBJECT 会在算法/类型解析阶段被拒绝,不会进入存储类型比较。
CRC64NVME 决策与源码证据
最初评审因等待证据而推迟 #50,这是历史决策,不是当前契约。 PR #93 在 header 与 trailer 路径拒绝非法算法/类型组合, PR #96 则移除完成阶段的规范化,两者都早于 20260903 tag。 完成请求可在解析阶段或存储类型比较阶段失败,因此存在上表中两种不同错误码。
最初错误映射位于 PR #74,显式类型后续修复 7e079ff05 经
PR #85 合入。
当前处理器测试
覆盖整对象与组合不匹配、仅类型断言、非法 token 及 CRC64NVME 两个拒绝阶段。
这是已提交的回归覆盖,不表示本次文档修改重新运行了所有 Server 测试。
独立的流式校验和后续修复
PR #143 跟进 @cbornet 报告的
#107,处理 AWS Java SDK v2 使用的路径:aws-chunked 请求通过
x-amz-trailer 声明校验和,却在 header 中提供其值。它也拒绝非法的 header 校验和值,避免丢弃校验。
此后续修复已在 main,不在 Server 20260903 中,与完成错误映射是独立变化。
兼容性与剩余边界
检查错误码的客户端会看到摘要或类型失败变为 BadDigest,缺少组合分片值变为 InvalidRequest。
成功的无校验和上传与 ETag 语义不变,无需迁移元数据。
仍有较窄的差异:初始化未记录校验和却在完成时提供最终摘要,会返回 BadDigest;组合分片数与值不匹配使用相同描述;
整对象校验和的 -N 后缀本身不作为分片数验证。这些是已记录的观察,不表示已分别建立公开 issue,也不代表完整 AWS 一致性。
30 - ListMultipartUploads:实现、升级契约与历史设计
旧版分片列举设计已被覆盖 · Earlier multipart-listing designs superseded (2026-10-17)
中文: 本页保留的 8 月分析与 9 月 legacy/strict 方案已被新设计覆盖,请以最新设计决策(中文)为准。新设计统一使用持久列举,逐条跳过经确认缺少身份的历史上传,取消迁移开关;代码已合入主干,尚未随 Server 版本发布。旧章节只用于理解历史,不改变旧版本二进制的实际行为。
English: The August analysis and September legacy/strict proposal retained below are superseded by the latest design decision (English). It selects one persistent listing path, individual omission of confirmed unidentified historical uploads, and no migration switch. The code is merged into main but has not shipped in a Server release. Historical sections do not change the behavior of older binaries.
2026-10-17 源码集成更新: 统一列举修复通过全部八项 PR 检查,经 PR #243 合入主干,合并提交为
7e9454456。它面向下一次 Server 发布,未包含在 Server 20261015 中;源码接入不会更新已安装的二进制或容器镜像。
2026-09-17 发布更新: 本文记录的九月源码修复已随 Server 20260916 发布;发布时的启用条件保留在历史章节;待发布候选的变化见下节。下方带日期的源码状态与验证记录保留当时的范围。
这是 SILO Issue #79 的问题说明、设计分析与决策记录。
阅读顺序:待发布行为说明客户端与运维会看到什么;2026-10-17 决策记录解释为什么这样改、放弃了什么、付出什么代价;9 月发布契约与8 月设计分析保留历史依据。
待发布:统一使用持久元数据列举
本节描述 #79 后续修复候选,不代表已经发布。 对包含本次修改的构建,本节取代下方的模式切换设计;Server 20260916 仍按其发布时的 legacy/strict 行为运行。此次去掉模式选择,在已有集群上升级后也自动使用磁盘元数据列举。
客户端会看到什么
prefix 是 S3 已有的字符串参数,不是新增的布尔开关。例如,GET /photos?uploads&prefix=trips/ 列出对象名以 trips/ 开头的未完成上传;GET /photos?uploads&max-uploads=1 每页列出一项,有后续结果时返回翻页标记。两种请求现在都先发现磁盘上的上传,再筛选、排序、分页;不会因入口节点缓存不同而得到不同清单,也不会把前缀当成完整文件名。
之前的实现已经在新上传的 xl.meta 中写入两个内部身份字段。本次不再新增字段、元数据文件、数据库、索引,也不迁移目录。元数据中的相关内容可以直观理解为:
这只是元数据映射的示意,不是 xl.meta 的实际二进制格式。两个字段随原有元数据一起按上传的写入 quorum 保存,完成上传时删除。旧版本的映射读取器能够接受未知条目;此次不改变编码或存储格式。回退会恢复旧的列表局限,并不会补出原本缺失的身份信息。
旧上传不再挡住新上传
按每条记录判断,而不是按集群安装时间判断:
| 磁盘上发现的记录 | 列表如何处理 |
|---|---|
| 两个身份字段完整、合法,且与目录哈希一致 | 符合请求的桶和前缀时列出 |
| 经既有读取 quorum 确认可读、选出的元数据中两个字段都不存在 | 只跳过这条历史上传,继续列举 |
| 只存在一个字段、身份为空或非法、可读副本的身份矛盾、哈希不一致,或带身份的记录使用不支持的原生上传 ID | 返回错误,不能把损坏的新记录当成历史数据 |
| 扫描覆盖不足、元数据无法满足读取 quorum、超时或超出资源预算 | 返回错误,不能成功返回不完整清单 |
旧上传若已知桶名、对象名和 upload ID,仍能继续传分片、完成或取消。即使请求前缀恰好等于旧上传的完整对象名,也会跳过该记录;公开 API 不再走原来的精确键捷径。既有过期清理策略不变,过期年龄按发起时间计算,不按最近传分片的时间计算,不应为了消除历史例外而缩短过期时间。
不做回填,不强制排空,也不要求预检通过才开放列表。要让今后创建的上传都带身份,仍需升级所有写入节点。滚动升级期间,旧写入节点可能继续产生无身份上传,旧服务入口也可能继续提供旧的列表语义。切换服务版本后应从头开始分页遍历。历史上传被省略是明确的兼容性例外:列表为空,不足以证明磁盘上没有旧上传。
退役配置与诊断接口
候选版本忽略 MINIO_API_MULTIPART_LISTING 的所有值,启动时提示它已失效;可以从部署配置中移除。历史共享配置键 api multipart_listing 仍被容忍,不会导致其它 API 配置失效。不再增加替代环境变量。若要回退到拒绝该共享键的旧构建,下方历史章节中的定向 reset 步骤仍有参考价值。
既有只读签名接口 GET /minio/admin/v3/multipart-preflight 仍要求 admin:StorageInfo。现在返回 mode: "persistent";legacyUploads 统计观察到的历史记录,complete 表示所有盘都已扫描且无扫描错误,ready 表示完整诊断后没有发现历史记录。ready=false 不会关闭列表功能。 离线或无法读取的副本仍会导致诊断不完整,不能把不完整的计数当成不存在旧上传的证据。
代价和保留边界
此次没有在上传路径增加磁盘写入、RPC、锁、删除墓碑或第二份索引。两个身份字段此前已经保存。新增的日常开销在列举操作上:每一页都会跨 pool/set 扫描上传目录、读取候选元数据,再排序和筛选;其它桶的上传和被跳过的旧上传也会消耗扫描资源。把每页条数调小,只减少响应量,不减少前面的发现工作。
保留既有保护:每进程最多两个并发扫描,每次扫描最多 16 个身份读取 worker、4 个完整元数据读取 worker、30 秒上下文预算,以及 100,000 个累计目录条目。条目包含两层目录以及多盘重复项。16 盘上每个上传使用独立对象名时,一个上传约消耗 32 项,10,000 个这样的上传会超预算,返回 SlowDown(503)。如果上传总量不降低,反复重试也不会消除这种确定性的预算超限。本次解决列表语义,不宣称解决大规模列举性能;持久索引或其它有界发现方式需要独立的性能设计与验收。
取消操作保留此前的默认行为:满足读取 quorum 后尽力删除,按 pool 顺序返回。退役列表开关也会移除它附带的可选 strict 取消分支;此前主动依赖严格多数删除确认的调用方需要了解这个变化。HTTP 204 不是所有物理副本均已删除的证明。迟到创建竞争仍单独保留:默认路径故障测试展示,在 16 盘/EC:8 下,七份迟到写入加一份离线旧副本可以在取消后恢复八份可读副本。本次没有增加阻止迟到创建的持久屏障。
回归覆盖新旧混合 HTTP 分页、目录分组、按已知 ID 继续并完成旧上传、字段缺失或损坏、读取失败、重新打开存储层后无缓存列举、多 set/pool、已删除的翻页标记、扫描限流,以及原有默认取消行为。显式运行的 BenchmarkMultipartListingCapacity 使用 1,000 和 10,000 个真实磁盘上传,包含新旧混合与其它桶;超限场景验证返回错误而非成功的残缺页面。这些本地检查不等于生产分布式存储延迟和前台上传吞吐验收。
2026-10-17 决策记录:统一列举,接受历史例外
状态:方案已选定,本地验证与 PR CI 已通过,源码已合入主干,尚未发布。 本节对应实现提交 4ed86dfe62ed6e07016885552feebcb59f779631,于 2026-10-17 经 PR #243 接入。源码合并不代表已有发行制品或已部署生产。Server 20260916 与 Server 20261015 的二进制仍适用下方的历史 legacy/strict 契约。
决策与优先级
采用一条公开列表路径:从磁盘元数据发现上传,再统一执行 S3 的前缀筛选、目录分组、排序和分页。对于经过读取校验、确认没有桶名和对象名身份的历史上传,只省略该条记录。新版本创建的上传继续使用已有身份字段;升级老集群也会自动获得新行为,无需迁移开关、历史数据回填或先清空全部上传。
选择的优先级是:先让有完整身份的上传得到可靠的标准 API 结果;保持原有上传写入路径和默认取消的可用性;接受临时历史上传无法完整列举的有限例外;再单独解决大规模扫描和迟到写入问题。“最小且充分”针对的是这项已界定的列表修复,不等于 issue #79 的所有问题都已关闭。
“老人老办法,新人新办法”按记录内容判断。“老”指两个身份字段都不存在,不是集群创建得早;“新”指写入了可验证身份,不是请求日期更新。即使集群已运行多年,新写入节点创建的上传也适用新规则。混合版本期间,未升级的旧 writer 仍可能继续产生无身份记录,因此不能承诺切换一台服务器后所有上传立即可见。
这里明确放弃一个更强的保证:成功列表不一定覆盖磁盘上的全部历史上传。被省略的记录没有被删除,已知桶名、对象名与 upload ID 的客户端仍能继续操作它们。对新格式候选,无法完成必要校验时返回错误,不用一个成功的残缺页面掩盖问题;扫描的 pool 生命周期范围和容量限制仍然适用。
为什么会有 strict,为什么这次可以去掉开关
分段上传可以理解为“先领取一个 upload ID,再分几次交付文件内容,最后确认合并”。ListMultipartUploads 列出的是这些尚未完成的上传任务;它不是列出已经保存好的对象,也不是列出某个上传内部的分片。
原来的两条快捷路径缺少统一的真实清单。请求没有 prefix 时读取节点内存缓存,重启或换节点后可能看不全;有 prefix 时把整个字符串当成一个完整对象名进行查找。假设桶 photos 有三个未完成上传:trips/a.mp4、trips/b.mp4 和 trips/sub/c.mp4:
| 请求示例 | 客户端合理期待 | 旧快捷路径的问题 | 本次候选行为 |
|---|---|---|---|
GET /photos?uploads&prefix=trips/ |
三个上传 | 去找名为 trips/ 的单个对象,可能返回空 |
返回三个匹配的上传 |
GET /photos?uploads&max-uploads=1 |
一项及后续页标记 | 缓存路径可能一次返回多项,分页失效 | 返回一项,并在仍有结果时返回翻页标记 |
GET /photos?uploads&prefix=trips/&delimiter=/ |
两个直接子项和分组 trips/sub/ |
仍走精确对象查找,无法形成前缀分组 | 返回两个 Upload 和一个 CommonPrefixes,分组也占分页名额 |
| 重启后重复上述请求 | 同样的可读上传仍可被发现 | 内存清单消失,磁盘上的上传并未消失 | 重新读取持久状态,不等缓存恢复 |
prefix 从来就是 S3 的字符串参数;这里没有新增 prefix=true。分页客户端回传上一页的 NextKeyMarker 和 NextUploadIdMarker,分别作为 key-marker 和 upload-id-marker,交给 SDK 做 URL 编码,不应自己生成 upload ID 标记。即使标记对应的上传已完成或取消,仍可继续翻页;并发创建或删除下,多页结果并不是同一时刻的快照。
PR #198 已经实现“保存身份、扫描磁盘、统一分页”的算法,称为 strict。它的特殊之处是采用更严格的完整性要求,并非另一套 S3 API。由于无法确定无身份旧上传属于哪个桶,原 strict 方案遇到这种记录就拒绝整次列举;别的桶遗留的一条记录也可能使当前请求失败。
随后 PR #213 恢复 legacy 默认值,用服务器环境变量让运维人员在准备完成后再启用 strict,并避免普通升级改变默认 Abort 的成功条件。当时的开关承担的是迁移与可用性选择,不是启用 S3 prefix 参数所必需的机制。
本次改变了历史记录的处理契约:允许逐条省略已确认的无身份上传,并保留默认 Abort 的可用性。这就移除了为了等待旧上传排空而保留模式开关的主要理由。继续保留开关,会让默认路径的缺陷继续存在,还要求运维和测试长期维护两套行为。因此退役开关,不再增加新的迁移环境变量。
备选方案与选择理由
下表用名称区分本轮方案;下方 8 月历史章节里的“方案 1/2/3”属于另一轮比较,不沿用其编号。
| 方案 | 得到什么 | 代价与不足 | 本次取舍 |
|---|---|---|---|
| 统一持久扫描,逐条省略确认无身份的旧上传 | 普通升级就能修正有身份上传的前缀与分页;不加迁移配置或第二份存储状态 | 旧上传清单不完整;每页扫描有明显读放大,受现有预算限制 | 采用。 正好满足接受历史例外、确保新写入可发现的要求 |
保留 legacy/strict 两种模式 |
可暂时沿用旧行为,由操作者选择切换时机 | 默认问题继续存在;增加配置、排障和测试组合,strict 仍需处理历史阻塞 | 不采用。新的逐条兼容策略已经消除原来的切换前提 |
| 默认开启原 strict,任何旧上传都报错 | 在成功响应时提供更强的历史清单完整性要求 | 一条无法识别的旧记录就可能阻塞所有桶,必须排空或人工处理;丢失 key/ID 时难以操作 | 不采用。把临时历史数据变成整个列表服务的升级障碍 |
| 回填所有历史上传的身份 | 若有权威映射,可恢复完整历史清单 | 目录只有不可逆的桶/对象哈希;缓存或日志不保证齐全,不能凭空还原原名 | 不做通用回填。有可靠外部映射时的定向恢复另行处理 |
| 增加持久的桶级有序索引 | 可按桶、前缀与标记直接查找,减少重复全量扫描 | 创建、完成、取消要同步维护第二份状态;需定义原子性、修复、重建和混合版本行为 | 延后。规模超出现有扫描能力时,应单独设计并验收 |
只补缓存,或增加自有 prefix=true 参数 |
表面改动较少,部分单节点场景可能工作 | 不能从根本上解决重启、跨节点和持久分页;自有参数也无法修复标准 SDK 的请求 | 不采用。没有提供可信的持久清单 |
把索引和取消屏障同时加入本次修复,确实可能处理更多问题,但会改变创建与删除的存储协议,使一个列表改动扩展成新的数据生命周期设计。当前决策先复用已经存在的扫描实现,同时明确留下这些后续工作。直接提高扫描条目上限也不等于解决容量问题:每页的扫描量、内存分配与超时压力仍会增长。
元数据保存在哪里,为什么兼容
持久扫描先看到的是目录,不是用户的文件名。已有目录结构可以示意为:
哈希能用于验证“这个桶/对象是否应该放在这个目录”,却不能倒过来还原原始名称。upload ID 也不包含对象名。所以上文示意的两个身份字段保存原始桶名和完整对象名,位于 xl.meta 已有的内部元数据映射中,跟随原有写入一起保存;完成上传时移除上传专用字段。这两个字段在本次后续修复之前就已写入,之前的 legacy 和 strict 模式都会写。本次并未再加两个 key。
设计上选择已有映射,是因为它本来就用于保存可扩展的键值信息。没有改变目录名、二进制格式版本或必须识别的固定结构字段,也没有新增一个 JSON 文件。若另建旁路文件,还要多写一份文件,并处理“主元数据已写、身份文件未写”或取消后残留一份的问题;当前需求没有得到相应收益。
兼容性结论有具体边界。对 SILO 2026-08-06 历史提交 3be10fcc1a44f6620ded0bd303461f9d688cca23 的 xlMetaV2Object 及生成的 MessagePack 编解码器,已检查并抽取原实现做解码、重编码验证:内部 MetaSys 是 map[string][]byte,可以接受并保留这两个条目,不依赖字段名白名单。它不是遇到未知 key 就拒绝读取的固定 schema;当前响应头也会过滤内部保留前缀。
这项验证说明该历史编解码器不会因为这两个映射条目而报错,并能在重编码时保留它们。它不等于对所有历史服务端的恢复、修复、复制流程或第三方离线解析器完成了全量回退认证。旧代码能读文件,也不表示它会提供新的列表语义;较旧的完成路径还可能保留不认识的内部字段。受支持的发布验收范围仍是协调的 PGSTY 组件栈,上游兼容保持尽最大努力。
哪些可以跳过,哪些必须报错
“查不到身份就跳过”过于宽松:元数据损坏、读盘失败或者拿到不同版本的副本,都可能造成查不到。当前规则要求先区分历史格式与无法确认的状态。
- 在参与服务的 pool/set 中发现上传候选;每个 set 需要足够的目录扫描覆盖。这里 pool 是一组存储资源,set 是其中一起保存纠删码数据的磁盘组。
- 单盘上的合法身份若能证明属于其他桶,可提前排除。否则按原有元数据读取规则收集副本,达到读取 quorum,也就是有足够的有效副本让存储层确认这条记录可读。
- 对进入完整读取的候选,额外比较已经读回的有效副本身份。原有通用 quorum 选择并不比较这两个字段;若一个副本无身份、另一个有身份,或身份值相互矛盾,不能恰好选中“旧副本”就把新上传藏起来。
- 只有确认两个字段都不存在时才省略;字段存在但值为空、只存在一个字段、身份非法、目录哈希不匹配、带身份记录的原生 ID 不支持,均返回元数据错误。读取或扫描失败也保持失败。
- 汇总有身份的上传,按桶/对象/upload ID 去重,再全局排序、筛选、分组和截取页面。不能先在各盘或各 pool 分页再拼起来,否则排序与后续标记会不一致。
新增的副本身份比较使用已经读取的数据,只增加字段查找与比较,不额外读盘。其他桶的合法单来源身份可以提前排除,因此普通列表不是对全部桶全部副本做完整损坏审计。正常列举继续遵守已有的暂停 pool 排除规则;预检会检查暂停 pool,本次未重新定义其生命周期。
读取 quorum 与目录发现的门槛也不同。例如 16 盘、EC:8 的元数据可能只需八份就可读,但目录发现要求至少九盘扫描成功,以避免把没有发现的目录当成不存在。发生不足覆盖时拒绝返回成功,是这条扫描路径相对旧缓存列表的可用性代价之一。
成本:写入路径很小,列举路径并不便宜
必须区分比较基线。相对本次修改前已经有 strict 的代码,新上传仍写同一个 xl.meta,不增加磁盘写入次数、RPC、锁、索引或持久墓碑。相对更早的无身份实现,两条映射记录增加字段名、桶名和对象名的 UTF-8 字节以及编码开销,并随元数据副本保存;这是此前实现已经引入的成本。
对于之前使用默认 legacy 的部署,公开列表从查缓存或单键目录改成扫描持久状态,读成本会明显增加。一次扫描的工作量取决于存储中的上传总量,而不只取决于响应里的条目数。
| 操作 | 工作与代价 |
|---|---|
| 发起上传、上传分片、完成上传 | 本次不新增身份写入步骤或第二份状态;完成仍移除已有上传身份字段 |
| 列表发现 | 每页遍历相关 pool/set 的两层上传目录,其他桶和待省略旧记录也占预算 |
| 身份读取 | 通常先读一个来源副本;属于目标桶或身份不确定时,再走原有全盘元数据读取 |
| 校验、排序与分页 | 比较已读副本的身份;保存目标桶候选并全局排序,排序约为 O(M log M),M 为参与排序的目标桶上传数;另有发现候选集合的内存 |
下一页或缩小 prefix、max-uploads |
仍会重新发现并校验候选;只缩小前缀或页面不能相应减少前面的扫描工作 |
| 预检 | 也占用扫描名额;是按需诊断,不是每次上传或启动必做的迁移流程 |
为使数量直观,假设一个健康的 N 盘 set,每盘目录一致,有 H 个不同对象哈希目录、U 个上传目录:返回目录条目约为 N × (H + U),ListDir 调用约为 N × (1 + H)。这两者不是同一指标。目标桶或历史候选通常需要一次来源读取,加 N 次完整读取;其他桶候选的合法身份通常只需一次来源读取。
因此四盘、每个上传使用不同对象名、10,000 个目标桶上传时,一页也约需 80,000 个目录条目、40,004 次 ListDir 和 50,000 次 ReadVersion。这些是存储接口调用数,不应全部称作网络 RPC 或物理磁盘 I/O;本地盘、缓存与部署方式会影响实际代价。
现有保护保持不变:每进程两个扫描名额,每扫描 16 个身份 worker、四个完整读取 worker、30 秒 context 预算、100,000 个累计目录条目。并发已发出的调用可能继续完成,worker 退出前不会释放扫描名额,所以这些限制也不是精确的峰值内存或物理 I/O 截止保证。
100,000 个条目不是 100,000 个上传。 16 盘且每上传独占一个对象哈希目录时,约 3,125 个上传就用完条目预算;10,000 个需要约 320,000 项,会返回 SlowDown(503)。相同对象上的多个上传可共用上层目录,其他 pool 和残留目录也会改变数量。请求更小的页面或原样重试都不能解决确定性的预算超限。
2026-10-17 对最终候选运行真实文件系统基准,记录首个 1,000 项页面:
| 四盘上的 10,000 个上传 | 耗时 | ListDir 次数 |
ReadVersion 次数 |
Go 累计分配字节 |
|---|---|---|---|---|
| 全部为目标桶的新格式记录 | 约 2.40 秒 | 40,004 | 50,000 | 485,925,912 |
| 4,000 个目标桶新记录、1,000 个无身份旧记录、5,000 个其他桶新记录 | 约 2.42 秒 | 40,004 | 30,000 | 330,686,136 |
环境为本地 macOS arm64、Apple M5 Max、Go 1.27.2,每种情况只测一次,创建测试数据不计时,期间可能有其他本地验证。累计分配不是峰值驻留内存;更少的接口调用在这两次观察中也没有直接变成更短的耗时。它们不是生产分布式吞吐认证,也不能与 9 月 Docker 环境的数据相除后宣称性能提升。可在服务端仓库复现这两个最终候选样例:
为什么这次不修“取消后又出现”
列表与取消有关联,但不是同一个问题。可靠列表负责发现仍然存在的上传;“取消返回后,任何在途创建都不能再使它出现”需要创建与取消共同遵守一个持久的顺序规则。
例如,创建请求已向若干磁盘发起写入,其中一些调用超时但物理写入还未结束。取消随后删除此刻能看见的记录,迟到的写入再落盘,上传就可能重新具备可读副本。默认路径的故障测试保留了一个明确反例:16 盘/EC:8 下,九盘先完成创建、七盘写入滞后;已完成盘中的一盘离线,取消能读取并清理其余八盘;随后七份迟到写入落盘,离线盘恢复后,加上其中的一份旧记录,可以恢复八份可读副本。这里证明的是重新可读,不是已经达到九份的写入 quorum。
仅提高删除确认数量,不能阻止仍在进行的创建写入。真正的屏障可能需要持久墓碑或代际标记,让迟到写入知道“这个 upload ID 已被取消”,并定义屏障在哪些盘上生效、何时清理、离线盘如何恢复、旧节点如何协作。这会增加创建与取消热路径的存储状态和协调成本,必须单独设计、故障测试和验收。
本次保留此前默认的读取 quorum、尽力删除和按 pool 顺序返回的 Abort 行为,避免列表修复降低普通部署的取消可用性。原来与 strict 开关捆绑的“多数删除确认及残片重试”分支随开关退役;主动启用旧 strict 的用户会失去这部分更强的确认行为,不能笼统说所有用户的取消语义都未改变。HTTP 204 也可能是不存在上传的幂等响应,不能拿它证明所有物理副本已清空。
修改位置与影响范围
| 范围 | 本次修改 | 原因与影响 |
|---|---|---|
cmd/erasure-server-pool.go、cmd/erasure-sets.go、cmd/erasure-multipart.go |
公开列表统一进入持久扫描,移除模式分支和公开精确键/缓存捷径;Abort 保留默认路径 | 所有公开入口采用同一套列表算法;即使旧记录的完整对象名恰好等于 prefix,也不再额外捞回该记录 |
cmd/erasure-multipart-listing.go |
逐条省略确认无身份的旧记录,校验有效副本的身份一致性,诊断报告使用 persistent |
历史记录不阻断新记录;损坏或矛盾身份仍报错,预检不再决定功能是否开启 |
internal/config/api/api.go、cmd/common-main.go |
环境变量失效时提示,容忍退役共享配置,不再提供模式选择 | 无需配置新开关;部署配置里的旧变量可移除,其他 API 设置继续生效,不自动重写共享配置 |
| 回归测试与容量基准 | 覆盖新旧混合、HTTP 参数、重启、分页、元数据故障、默认取消与扫描预算 | 把已承诺的行为和仍存在的反例都变成可检查的证据 |
内部按完整对象名查找的 helper 仍用于发起上传时的放置逻辑,不能因为公开 API 不再使用它就一并删除。mpCache 的维护和节点间通知协议也保留,以避免额外扩大混合版本变更面;当前公开列表结果不再从这个缓存取数。这里有意保留少量兼容代码,后续清理不应混入功能修复。
主要影响未完成上传的发现、清理工具和运维盘点。标准 SDK 无需增加参数;调用方要遵守原有分页协议,不能假设一次拿齐,也要处理真实的扫描失败。已完成对象的列表与读取、上传数据格式、认证与授权规则不因此改变。空清单依然不能用于证明无身份历史上传已经排空。
已验证什么,仍缺什么证据
| 已完成的检查 | 支持的结论 | 不支持的更强结论 |
|---|---|---|
| 新旧混合 HTTP 列表、prefix、delimiter、全局分页、已删除 marker、多 set/pool、按已知 ID 继续和完成旧上传 | 已界定的列表与历史兼容行为有自动化回归覆盖 | 并发遍历具有快照一致性 |
| 存储层重新打开,以及真实服务进程重启后用 boto3 与 PGSTY mcli 检查 | 新格式发现不依赖进程上传缓存;标准客户端流程可用 | 所有历史或第三方客户端均完成认证 |
| 身份缺失/损坏/矛盾副本、读 quorum、目录覆盖、扫描限流、取消后名额保留测试 | 不能确认的状态返回错误,不以局部页面冒充成功;资源保护有回归覆盖 | 永不超时、精确峰值内存上限或任意规模都可列举 |
| 默认 Abort 故障测试及迟到创建反例 | 默认可用性得到保留;已知竞争仍可被测试观察 | 迟到创建已被修复或 204 意味着物理排空 |
定向测试与 race 检查、带 kqueue,dev 标签的全仓库测试(51 个有测试的包)、make verifiers、构建 |
候选通过本地功能、竞态与项目检查 | 所有生产拓扑、负载和长期故障恢复均已验收 |
| 四/十六盘、1,000/10,000 上传的初版容量矩阵;追加身份一致性检查后重跑上表两个最终样例 | 有真实目录成本与预算超限证据;最终样例的数据可复现 | 完整矩阵已在最终修订逐项重跑,或生产前台负载不受影响 |
这些证据来自候选实现的本地检查,文档只提取可复核的结论,原始执行日志不作为产品文档发布。无身份历史例外、容量上限与迟到写入都是有意记录的边界,不因测试显示通过而自动消失。
升级方式与重新评估条件
包含本次修复的版本发布后,按常规流程升级所有服务与写入节点,完成后重新开始分页遍历;不要求先排空历史上传。可以删除失效的 MINIO_API_MULTIPART_LISTING,不用设置替代变量。需要了解遗留情况时使用只读预检;ready=false 只是尚有历史记录或诊断不完整,不能理解为新列表未启用。
对已知身份的旧上传,继续、完成、取消和原有过期清理仍是处理路径。不能通过缩短过期时间来制造一个迁移截止日期,因为过期年龄按发起时间计算,会同时影响仍在进行的长上传。回退服务器会恢复旧版本的列表与开关行为;若目标构建拒绝曾持久化的 api multipart_listing 共享键,按历史章节的定向清理步骤核对,而不是重置整个 API 配置。
以下情况出现时,应重新评估本轮取舍:
- 产品需要列举所有历史上传、且不能容忍任何省略:需要权威身份恢复来源或明确的排空证明,不能靠重新命名开关来兑现。
- 支持规模超出扫描预算,单页延迟或前台上传影响不达标:用明确的磁盘数、桶分布、活跃上传数、并发分页与前台负载制定验收,再决定持久索引或其他发现结构。
- 产品要求取消成功后禁止任何迟到创建恢复上传:单独制定创建/取消屏障协议,包含离线副本、恢复和混合版本规则。
- 需要改变暂停 pool 的可见性或承诺完整回退到某个旧版本:分别补齐 pool 生命周期或指定版本的端到端兼容验收。
最终建议是保留这次明确的范围:让普通升级自动获得有身份上传的可靠列举,公开说明历史省略和扫描成本,把容量与取消屏障作为尚未完成的独立工作继续跟踪。不能把这份决策或候选提交当作 issue #79 已经全面收口的依据。
历史:9 月 16 日实现与升级契约
旧版分片列举设计已被覆盖 · Earlier multipart-listing designs superseded (2026-10-17)
中文: 本节的模式开关与排空启用方案已被最新设计决策(中文)覆盖,请参考新版。以下仅保留 Server 20260916 的历史契约;已安装旧版本仍按该契约运行。新设计已合入主干,尚未发布。
English: This mode-switch and drain-before-enabling design is superseded by the latest design decision (English); use that record for the new design. The text below preserves the Server 20260916 contract, which still applies to those installed binaries. The new code is merged into main and remains unreleased.
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 设置,让所有配置写入者升级到修复版,停止并发配置写入,再只删除这个历史键:
修复版会忽略历史键的模式值,但不会自动改写共享配置或删除历史记录。config get/export 会隐藏退役键,因此列表里看不到它不代表已经删除。应确认单项 reset 成功,重启后核对原有设置,并在受控回滚检查中验证旧版读取;不要重置整个 api 子系统。旧开发版本再次写入配置可能重新引入该键,带键的历史配置也不应直接回放。需要临时保护的 API 设置可按原值放入相应环境变量,但这不能替代持久配置清理。此步骤只处理本项 API 配置兼容性,其他变更的回滚条件仍需独立核对。
只读预检接口为 GET /minio/admin/v3/multipart-preflight,使用 SigV4 签名,要求 admin:StorageInfo 权限。例如,由操作者提供凭据与地址:
报告包含 mode、ready、complete、scannedEntries、legacyUploads,以及各 pool/set 的盘覆盖、未覆盖盘序号与最老旧上传的发起时间。它绕过上传缓存,检查包括暂停 pool 在内的持久状态,并识别只剩少数副本的旧上传。ready=true 要求全部盘可检查、候选元数据没有不可读状态、且未发现旧格式副本。它无法证明所有 writer 都已升级,也不能阻止并发旧 writer 再引入旧格式。离线盘、扫描错误、超时和预算耗尽均不能报告就绪;不完整计数不能当作零。磁盘恢复后以及切换严格模式前应重新预检。
丢失原始 key/ID 的旧上传由既有 stale-upload 清理器处理,各服务器扫描自己的本地盘。年龄按创建时间计算,不会被最近上传分片的活动刷新。保留现有清理策略,等待并验证实际排空;默认 24 小时过期、6 小时清理间隔不等于排空保证。不能据此缩短过期时间催促普通升级,因为这也会删除仍在进行的长上传。无法排空时继续使用默认 legacy 模式。本批不改变清理规则,也不新增按任意路径删除的管理接口。
两种模式共用的 maxUploadsList 上限由 10,000 降至 1,000,依赖一次返回全部结果的客户端必须分页。
旧格式上传缺少 bucket/key 身份,无法安全地排除为其他桶,因此在 strict 模式下可能阻断任何桶的列表;已验证属于其他桶的新格式身份可提前过滤。不要把这个限制理解为 legacy 默认模式也会返回全局 503。
扫描容量与证据边界
每个进程最多同时接受两个扫描,每扫描使用 16 个身份读取 worker 和四个全盘元数据读取 worker。目录读取传递有限 count 并检测溢出。请求合计预算为 100,000 个返回的目录条目,包含不同盘上的重复条目和哈希目录,不代表支持列出 100,000 个唯一上传。合计超限时,其他并发目录请求可能已经发出;超限返回 SlowDown(503),不能返回成功的局部页面。30 秒 context 预算停止后续调度,扫描 worker 退出前仍占用并发名额。这不是精确内存上限,也不保证已进入系统调用的物理 I/O 立即停止。
仅作预算示例:一个 N 盘 set,每个唯一 key 有一个上传,所有盘都有相同两级目录时,每上传约消耗 2 × N 个条目,100,000 条目约对应 100000 / (2 × N) 个上传;N=16 时约 3,125。相同 key 的多个上传会分摊哈希目录成本,其他 set/pool、旧目录与超时也影响容量,因此这不是统一的上传数上限。
每一页仍需重扫持久状态,遍历成本随存储候选数与页数共同增长。测试覆盖 marker 消失、多 pool 覆盖、部分删除重试、身份读取回退、RPC 目录边界、取消后并发名额,以及已知迟到写入反例。临时多节点与维护客户端测试只证明记录环境中的功能行为;生产规模延迟和前台负载影响仍需按部署验收,源码合并不等于性能认证。
9 月 16 日的临时 Docker Desktop arm64 测试使用两节点、四个 APFS 绑定卷,两个 bucket 共 11,000 个上传,期间还有其他本地验证负载。目标 bucket 有 10,000 个上传,单次返回 1,000 项耗时 18.7 秒;两个并发请求约 25–27 秒后返回可重试的 SlowDownRead。这组观察没有达到暂定的单页五秒目标,也不是隔离的 SSD 基准。容量和前台负载验收仍未完成,不能把有界扫描宣传为大规模性能修复。
历史:8 月 30 日分析与提案
旧版分片列举设计已被覆盖 · Earlier multipart-listing designs superseded (2026-10-17)
中文: 以下早期分析、推荐方案和迁移门槛已被最新设计决策(中文)覆盖,请参考新版。这里保留原始思考供追溯;其中的方案编号、能力广播、排空估算和启用建议均不是本次主干实现的要求。
English: The early proposals, recommendation and migration gates below are superseded by the latest design decision (English). They remain only as historical reasoning; their option numbers, capability advertisement, drain estimate and enablement advice do not define the implementation now on main.
归档分析(2026-08-30): 以下“当前”“建议”、方案编号与门禁描述当时的审查和提案。待发布候选以 10 月 17 日决策为准;Server 20260916 以其带日期的契约为准。旧提案中的 writer 升级/排空门槛不是本次候选的启用前提;能力广播、预计一天排空也只是当时提案,不是当前保证。
先用最简单的话说明问题
假设现在有四个大文件还没传完:
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 上传。
因此建议的方向是:
- 把 bucket 与 object key 写进上传现有的 quorum 元数据;
- 以有界的按需扫描作为持久化正确性路径;
- 缓存只能是可重建的优化,不能是真相来源;
- 只有所有 writer 都完成升级、无键 legacy 上传全部排空后,才能启用严格 S3 行为;
- 只有测量证明扫描达不到产品批准的服务目标时,才考虑持久化二级索引。
问题描述与证据来源
本文的问题判断与方案建立在五类证据之上。
S3 正式契约
AWS ListMultipartUploads API 定义了通用存储桶的公开契约:
prefix选择所有 key 以该字符串开头的上传;delimiter把匹配的 key 汇总成CommonPrefixes;max-uploads限制单页数量,文档规定上限为 1,000;key-marker与upload-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-uploads 和 upload-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 路径:
关键位置包括:
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-uploads、key-marker、upload-id-marker 或 delimiter,也不计算续页 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 不能单独展示 PutObjectPart、ListParts、CompleteMultipartUpload 和 AbortMultipartUpload 都无法操作的句柄。pool drain 或强制 abort 应当作为覆盖所有动词的独立设计。
为什么现有上传无法回填
Multipart 命名空间是平的:
哈希是单向的,路径里没有原始 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将匹配foo、foobar与foo/...,不再只匹配精确键foo;- bucket-wide 结果将按 key 与发起时间排序,而不是只按发起时间;
max-uploads会真正限制单页;- 默认值与最大值会从 SILO 当前的 10,000 常量向 AWS 的 1,000 收敛,具体边角以取证契约为准;
- 客户端必须跟随
NextKeyMarker和NextUploadIdMarker,不能再假设一个响应包含全部结果; - 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:
- 按配置的 list-quorum 策略,从所需的所有在线盘列出 hash 与 upload 候选目录;
- 对目录名做 union 和去重;
- 通过正常 erasure 元数据路径读取候选
xl.meta; - 只有元数据满足 quorum 且包含合法 bucket/key identity 时才纳入结果;
- 容忍候选在 abort、complete 或 stale cleanup 过程中消失;
- 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,例如 FindMultipartUploadPool 或 ListMultipartUploadsExact。它应继续使用现有对象哈希路径,不能共享公开 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 对未知内部元数据的处理。
交付关卡
- 批准 ADR,包括产品模式与性能 SLO;
- 提交兼容性 fixture;
- 完成并评审存储原型;
- 实现并通过定向、全量、race 与故障 QA;
- 更新 S3 兼容性参考与运维说明;
- 提交并合并源码变更;
- 构建并标识 release artifact 或容器镜像;
- 对滚动升级做 canary,观察 keyless-drain 遥测;
- 只有所有门槛满足时才启用 strict mode;
- 验证线上端点后再关闭 #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。
31 - 旧版元数据与扫描响应复用时的残留字段修复
发布更新(2026-10-15): 本修复首次随 Server 20261015 交付。下文源码验收记录保留此前发布前的验证结论。
2026-10-15 核验状态:已合入 main,尚未进入已发布的 Server 版本。
修复通过 PR #233 合入,合并提交为
d94b3b3c9251。
通过验证的 PR 头部是
86c5a92eef3d,
其源码树与合并提交完全一致。本次核验时,最新发布的 Server 仍为
20260916,
其源码仍包含两处缺陷。源码合并不会更新用户已经安装的二进制、软件包或容器镜像。
问题是什么
两条独立路径在复用 Go 对象时,没有完整替换其逻辑状态。新输入不包含的字段保留了上一次使用时的值。 修复应放在知道“这次输入允许包含哪些字段”的位置。
| 路径 | 触发条件 | 错误状态 | 应有状态 |
|---|---|---|---|
| 旧版 V2 版本头部 | 将旧头部解码到 EC 字段非零的结构体 | EcN 归零,EcM 残留 |
两个缺失的 EC 字段都归零 |
| 旧版 V1 版本头部 | 复用带有签名或 EC 字段的结构体 | V1 不包含的字段继续残留 | Signature、EcN、EcM 都归零 |
| 扫描进度响应 | 复用曾经携带最终缓存的响应对象 | 同时包含 Update 与旧 Final |
只包含 Update |
| 扫描最终响应 | 复用曾经携带进度更新的响应对象 | 同时包含旧 Update 与 Final |
只包含 Final |
这里的 stale metadata 指复用时带入的旧值。现有证据确认了正确性缺陷, 没有证明对象正文丢失、跨租户信息泄露或无界内存泄漏。仅凭一处用量统计偏差,不能断定触发了这个问题。
旧版 xl.meta 头部
xl.meta 内部紧凑头部的版本,与外层文件格式版本、SILO 发布版本号不同。
修复提交中的当前头部版本是 3,包含 EcN 和 EcM。旧 V2 头部只有五个字段,不包含这两个 EC 字段。
V1 解码器处理四字段布局,既没有签名,也没有 EC 字段;源码明确标注这个布局从未发布。
因此,V1 修复是对保留解码路径的防御性覆盖,不代表发现了受影响的已发布 V1 部署。
V2 的字节解码入口 UnmarshalMsg 与流式解码入口 DecodeMsg 原先都写成:
重复赋值使 EcM 没有被清空。使用新建的零值结构体时问题不显现;
复用一个 EcN=4, EcM=8 的结构体,V2 解码后就会得到 0,8。
修复将赋值改为 z.EcN, z.EcM = 0, 0,并在 V1 解码前额外清空缺失的 Signature。
当前 V3 头部仍然保留编码中实际存在的 EC 字段。
这不只是单独调用解码器时的问题:loadIndexed 会复用版本头部切片的底层空间。
只在新建结构体时清理,覆盖不到这条路径;提交中的回归测试直接验证了这种复用。
参见固定提交中的旧版解码器
与索引头部加载和比较逻辑。
已复现的结果是:同一份编码的解码结果受结构体先前内容影响。
头部相等判断和票数统计包含这些字段,不同残留值可以把本应相同的头部分成不同组。
这说明有必要恢复字段不变量,但不证明每个旧对象都会发生错误仲裁或错误修复。
尤其是 hasEC() 要求两个 EC 字段都非零,而原来的 V2 代码已经清空了 EcN;
不能声称仅凭残留 EcM 就一定会启用 EC 比较。本次审查没有复现生产数据丢失。
命名空间扫描响应
远程磁盘扫描通过 grid RPC 流传输 nsScannerResp。
每条消息应当二选一:Update 携带 dataUsageEntry 进度更新,或 Final 携带最终的 dataUsageCache。
处理器的对象池会复用响应对象,但不会自动清空字段。
原来的 NSScannerHandler 只赋值当前需要的字段。后续扫描复用对象时,两个指针可能同时被序列化。
接收端分别检查这两个字段:存在 Update 就转发更新,存在 Final 就记住最终缓存。
因此,旧的更新可能进入用量聚合,一条进度消息也可能夹带上一次扫描的缓存。
接收端不会在第一个 Final 处停止。后续合法的最终响应会替换已记录的缓存指针;
成功扫描也会在排空进度更新之后提交最终桶结果。因此,错误的中间响应不等于每次扫描完成后都会持久化错误总量。
确定性复现的是响应互斥约束被破坏、旧数据被传输了出去。修复在发送端消除这种歧义:
这项修复针对远程扫描响应。本地磁盘扫描不序列化这种响应对象,旧版头部问题也有独立的触发条件, 两个问题不需要同时发生。参见固定提交中的 服务端处理器、 客户端接收逻辑 与桶结果聚合。
为什么不修改通用对象池
PR 最初还在通用流式处理器的 PutResponse 中调用 Recycler.Recycle(),
随后又把同一个对象放回处理器自己的池。这把“交还资源所有权”的操作当成了普通清零钩子。
例如,grid.MSS.Recycle() 会把底层 map 还给另一个池,再将 map 设为 nil。
如果又把外层对象放回池,下次 NewResponse 就可能绕过构造器,返回一个不可写的 nil map。
补充复现用例在合并前的 main 上通过,在这个拟议改动上失败。
维护者补丁撤回了通用改动和新加的 nsScannerResp.Recycle 方法,只保留扫描器构造响应时的显式赋值。
最终合并的 internal/grid/handlers.go 与 cmd/storage-rest-common.go 相对合并前主线没有变化。
既有 Recycler 资源所有权约定见 grid 类型定义。
已有 Console 日志流和事件流确实使用 *grid.Bytes 的回收机制。
补充测试覆盖了它们的回收与缓冲区转交,并通过竞态检查。
本次否决的是通用接口约定的回归,没有观察到现有 Console 或事件流故障。
验证证据
已入库的回归测试
合并源码包含
TestXLMetaV2HeaderLegacyUnmarshalResetsEc
和
TestNSScannerHandlerClearsStaleResponses。
头部测试覆盖 V2 字节解码、流式解码、索引头部复用、V1 字段清理,以及当前格式 EC 字段保留。 扫描器测试连续三轮调用真实处理器,序列化后检查两种响应及其所属扫描轮次的数据。
测试中新分配的响应对象也预先填入旧 Update 和 Final。这是必要的:
sync.Pool 可以丢弃池中对象,假设“下次一定拿到刚放回的脏对象”的测试可能根本没触发问题就通过。
把新分配对象也设为脏状态,才能得到确定性的检查。
将受影响的生产文件替换成修复前 main
0d3ba5d4cbfd
中的版本时,这些测试会失败;使用修复文件时通过。
在包含修复的工作目录中运行:
相关元数据、扫描、用量、修复、目录遍历和响应序列化测试也通过了 -race。
本地 Go 1.27.2、darwin/arm64 环境中的 make verifiers、make build 均通过。
后续补跑的拼写检查通过了本 PR 的全部四个文件;全仓库检查报告 30 处已有诊断,
逐项确认均在本 PR 之前存在。不能将这项结果表述为全仓库拼写检查通过。
真实分布式扫描
后续验证在同一台 macOS 主机上启动两个服务进程、八个数据目录,仅监听回环地址。 创建十二个桶,每桶写入两个不同大小的对象,再对每个桶执行删除一个对象、覆盖另一个对象、新增一个对象。
| 检查点 | 对象数 | 逻辑总字节数 | 观察结果 |
|---|---|---|---|
| 首轮写入 | 24 | 64,740 | 每桶与集群总量连续三次刷新都符合预期 |
| 删除、覆盖与新增 | 24 | 111,960 | 每桶与集群总量又连续三次刷新都符合预期 |
| 回读校验 | 24 | 111,960 | 所有对象长度与 SHA-256 均匹配 |
两端都有实际桶扫描记录,其中非协调端记录了 119 次 ScanBucketDrive 操作。
因此验证确实覆盖了远程执行,不只是本地磁盘模拟。验证结束后已停止测试进程。
这是修复版本的功能集成检查,不是性能基准、物理磁盘故障测试、生产部署证明,
也不是对旧版本对象池复用的确定性复现。修复前后的确定性证据来自已入库的回归测试。
用量的 lastUpdate 时间戳代表结果刷新,不能据此认定生产桶的所有前缀都已完成一轮全量扫描。
远端 CI
PR 头部 86c5a92eef3d 的八项检查全部通过:DCO、
六项 Go CI
和 VulnCheck。
Go CI 覆盖格式、构建与 vet,lint 与生成文件,竞态与 S3 Select,交叉编译,以及 internal/、cmd/ 测试。
合并后的主线又独立通过了
六项 Go CI
与 VulnCheck。
兼容性与运维处置
修复只调整既有解码器和响应构造中的赋值,没有新增存储格式、RPC 字段、依赖版本、权限、配置开关或客户端要求。 不需要为此重写对象或删除用量缓存。正式支持的组合仍是协调维护的 SILO、SILO Console、mcli 与 silo-pkg; 与未修改的上游 MinIO/MC 兼容性维持“尽最大努力”。
- 确认实际运行的服务端版本。 Server 20260916 不含这项修复。 选择明确包含修复的后续发布版本,或已经验证包含修复代码的自构建制品。 仅查看仓库分支,不能确认正在运行的二进制身份。
- 升级所有提供扫描服务的节点。 线协议没有变化,但旧节点仍会生成原来的响应。 本补丁没有新增协同升级约束;所选完整版本中的其他改动可能有自己的要求,参见组件版本矩阵。
- 等待用量统计收敛。 用
mcli admin scanner status ALIAS -n 1检查扫描健康与活动。 对稳定桶使用独立清单核对数量与逻辑大小,统一版本、删除标记和统计时间口径。 不能凭一次更新时间变化,就认定所有受影响路径已经重新扫描。 - 持续偏差需要单独诊断。 保留运行版本、扫描错误和小规模可复现清单。
并发写入、版本控制、扫描未完成或磁盘离线,也可能造成差异。
不应仅因这个 PR 就手工修改
xl.meta、删除内部缓存、执行破坏性修复或重写全部对象。
补丁阻止后续复用时再次带入旧字段,不会还原历史用量观测,也不证明存量数据原本健康。 正常成功扫描可以替换派生的用量结果;源码合并和本地测试都不能替代对每个实际部署的恢复验收。
交付与贡献
Julien Laurenceau 提交了原始修复与初始测试。维护者补丁缩小了对象池改动范围并加强验证, 原作者提交及 DCO 签名均保留在合并历史中。最终 PR 修改四个文件,增加 194 行、删除两行,新增内容主要是测试。
代码修复、永久回归测试、PR 验收与合并后 CI 已完成。本文补齐中英文问题说明与运维边界。 截至上述核验日期,新 Server 版本发布和生产安装升级仍是独立交付项; 本文不宣称已完成软件包、镜像发布或生产部署。
32 - LIST 漏项修复:只在有疑问时确认,并把等待控制住
源码已合并,尚未发布新版本: PR #239 于 2026-10-17 合入主干,合并提交为
fbcb51197,#218 已关闭。修复保留原作者提交;主干合并不会自动更新已安装的软件。
这是 #218 / #239 的最新设计决策。它承接九月的 null 版本仲裁修复,也替代早期“直接调用 GET 元数据路径”的恢复方案。原作者 mr javad seydi 在 #221 中提出的修复及原始签署提交保留在 #239 中。源码合并、正式发布、生产升级是三个独立步骤;本页不是生产版本发布公告。
问题是“成功的清单少了东西”
假设桶里有 reports/a.csv。客户端可以 GET 下载它,LIST 却返回 HTTP 200,结果里没有这个名字。备份和同步程序通常把成功的清单当成可信输入,可能误报缺文件;如果开启“删除目标端多余文件”,还可能删掉目标端的正确副本。这不等于服务端已经丢失对象数据,但比明确报错更容易让下游作出错误决定。
LIST 会收集不同磁盘在不同时刻看到的目录和元数据。覆盖写、删除、滚动重启或跨池迁移期间,有的盘返回旧记录,有的返回新记录,有的暂时没有记录。“仲裁”就是要求足够多的盘提供相容的证据;不能把不同代、不同纠删码布局的记录随意相加。当目录记录无法形成结论时,旧路径可能直接跳过对象,最后仍然报告成功。
这里的 LIST 是 ListObjects / ListObjectsV2,例如 GET /bucket?list-type=2&prefix=reports/。它与 #79 的 ListMultipartUploads 是不同接口。本修复不增加 prefix=true、环境变量或客户端参数。
采用什么方案
先照原来的方式合并目录记录。已经能确定的条目直接返回。只有列举最新版本、至少存在一个可解码对象条目、且“可解码条目数加失败的读取流数”达到原列举阈值的未决条目,才进入恢复。失败的流只决定是否值得进一步检查,绝不作为对象存在或达到读仲裁的证据。低于入口阈值的输入、完整版本历史列举仍保留原行为;这是有边界的修复,不承诺消除所有可能的 LIST 漏项原因。
对需要恢复的名字,在汇总各存储池结果时统一确认。临时给这个名字加读锁,阻止同名写入打断确认,然后读取当前元数据。结果有三种:
- 确认存在: 按对象真实的纠删码布局和原有读仲裁选择当前版本;正常返回,删除标记留到跨池合并之后再过滤。
- 确认已删除: 来源存储组每一个配置盘位都在这次原始读取中返回
FileNotFound,还要核对每个存储池当前应存放这个名字的存储组,才能从清单中省略它。 - 仍然不能确认: 返回既有的失败响应,例如 HTTP 503
SlowDownRead,让客户端知道这次清单不能当成完整成功结果。离线盘、损坏元数据、缺卷和解码失败都不能冒充“已删除”。
完整的一页仍可先返回,不必为了第一页等待全桶扫描结束。客户端必须继续读取所有后续页;任一页失败,都不能把前面已收到的部分当成完整桶清单。这保留了流式分页的延迟和内存优势。
为什么删除要多查几个池?例如池 A 的目录读到 a.csv,池 B 的目录先前为空;随后迁移把文件复制到 B,并清掉 A。只看到 A 现在不存在就省略名字,会再次漏掉 B 中的当前对象。确认来源确实为空后,当前各池的查询结果必须替换整个同名旧结果组,不能再被先前的旧目录记录覆盖。同一次恢复中,每个存储组最多补读一次。
另一个真实反例是正常 DELETE:16 个目录输入中,6 份旧记录、6 份新记录、4 份缺失,目录本身无法仲裁;但 DELETE 已返回 204,GET 已返回 404,当前 16 个盘都明确不存在。早期 #239 把这个情况也当成不确定,返回 503。最终方案能够确认这种正常删除,同时保留故障情况下的报错。
元数据保存在哪里
不增加任何持久文件、xl.meta 字段、索引或迁移步骤。只在进程内的列表条目里增加一个指向来源存储组的指针,大致是:
“待确认”状态仅在内部通道中流转,由汇总层消耗。两个缓存编码入口都会拒绝尚未确认的条目;它不会写进分页缓存或发送给客户端。因而旧服务端不需要理解新字段,磁盘布局和网络编码保持不变。
补读使用只读元数据函数,复用已有的版本选择和读仲裁规则,不调用完整 GET 路径里的机会性修复或残留删除。另有一个窄的内存重计数:只在原选择失败、最新 null 版本和已知纠删码布局符合条件时,分别统计相容记录组;不降低阈值,不混加不同布局的票数,不扩展到严格解析或版本历史。
具体改哪些东西
| 位置 | 改动及必要性 |
|---|---|
| 目录解析与跨池汇总 | 将待确认名字带到同名结果组;在那里统一补读,避免每个池分别判断导致漏项或旧副本复活 |
| 元数据读取 | 区分原始“所有盘都不存在”和读取故障;使用原有版本、读仲裁规则,避免引入 GET 的写副作用 |
| 对象锁 | 多池沿用池级锁;单池多个存储组时,按固定顺序取得首组和目标组的读锁,覆盖现有 DELETE/COPY 与 PUT 的不同路由;相同锁域只取一次 |
| 分页缓存 | 先公布列举的最终错误,再结束缓存;HTTP 错误响应不等待失败的远端缓存更新。远端更新失败仍保留本地原缓存 ID 和错误,防止误标成功 |
| 读取尚未生成的缓存块 | 失败后及时检查生产端状态,每轮只探测一个在线盘并轮转;探测成功后仍进行原有仲裁读取。正常读取不多做状态 RPC |
| 取消与锁竞争 | 等待可被请求取消打断;仅对可识别的 LIST 恢复锁冲突保留一次短等待,降低短读锁触发长退避的概率 |
| 回归与 CI | 覆盖真实删除、迁移时序、锁路由、分页终止、失败 RPC、取消及迟到锁授予,并将这些测试加入 race 检查 |
底层目录回调仍使用原接口。因为恢复错误现在由汇总层直接传播,扫描、修复、再平衡和退役迁移调用方不需要额外接口改造。已有正常写入的锁路由不变。
单池需要覆盖两个不同锁域时,拿到第一把锁后等待第二把,可能暂时阻塞首组路由上的 DELETE/COPY;任一锁域无法形成仲裁,这个名字的恢复就会失败。这是确认过程的可用性与等待代价,只由实际进入恢复的名字承担。
锁在编码结果、通道发送和缓存 RPC 之前释放,避免慢客户端或缓存拖长持锁时间。分布式解锁通知可能异步完成,因此“调用了解锁”不等于远端锁记录在同一纳秒全部消失。单池两次加锁时,如果第二次失败或请求取消,必须释放第一次;这也有回归测试。
备选方案与取舍
| 方案 | 好处 | 为什么没有采用 |
|---|---|---|
| 保持现状 | 零新增开销 | 仍可能成功返回漏项,备份和同步不能可靠判断 |
| 遇到分歧一律返回 503 | 改动最少,不额外读取对象 | 一次补读即可确认的对象也不可用;正常删除也可能反复失败 |
| 每个对象都加锁并补读 | 规则直观 | 把磁盘和锁开销摊给全部 LIST,影响大桶列举和并发写入 |
| 只在来源池确认,或不加锁就确认 | 少一次锁或少查几个池 | 覆盖写、删除和迁移可以打断确认,已有反例证明仍会漏项 |
| 调低所有写请求的重试间隔 | 可能降低部分长尾 | 扩大到全局写路径,增加锁 RPC 和竞争,难以把代价限定在本问题 |
| 对符合条件的分歧按需确认,配合局部短等待 | 正常路径轻;可恢复时返回结果,未知时明确报错 | 分歧路径仍有实际读取与锁等待,代码多于“一律报错”,需要针对性验收 |
选择最后一项。最小化指的是每一处行为变化都对应已复现的错误或成本,并保留原接口和快路径;不能只比较补丁行数,把错误推给使用者。
开销具体有多大
| 情况 | 额外代价 |
|---|---|
| 正常列举 | 本次 64 位构建中,单个内存条目从 56 增至 64 字节,即多 8 字节和少量判断;没有额外对象读锁或磁盘补读 |
| 某个名字需要恢复 | 对待确认来源组补读元数据;每组内部并发读盘。单池不同锁域需要两次锁获取,多池使用一次池级锁 |
| 来源明确不存在 | 再查询每个池当前目标组,复用已读结果;上限按来源组与当前目标组的去重并集计算,而不是扫描整个桶 |
| 很多名字都需要恢复 | 同名组按目录顺序串行确认,整次扫描会变慢;不能把单个补读很快解释为整个扫描无代价 |
| 恢复读锁与写锁冲突 | 每次符合条件的锁 RPC 名义等待 2 毫秒后重新判断一次,每个本地锁服务共享最多 64 个等待名额,等待时不持内部互斥锁 |
| 锁获取失败后的普通退避 | 为支持取消,等待改为计时器与上下文选择,增加少量分配;重试间隔不变,一次获取成功的请求没有这笔开销 |
假设同时有 1,000 个条目留在内存,这个字段本身约多 8 KB;它不是给整个桶建立常驻索引。额外指针也参与垃圾回收扫描,整进程的内存和 CPU 不能只由结构体大小推算。
短等待不是优先级队列,也不是每个 HTTP 请求最多等 2 毫秒。调度、网络、后续重试和持续写锁仍可能造成长尾。来源标记复用既有诊断字段;未知或旧格式安全地回到原有拒绝与重试方式,不影响锁的正确性或兼容性。测得新进入短等待的 RPC 约多 264 字节、4 次分配,普通无竞争加锁分配不变。请求取消可中断普通退避等待,原退避间隔和仲裁阈值不变。
取消循环也由普通锁获取复用,所以这项计时器开销不限于 LIST:它发生在已经失败、需要退避的获取上。大量普通锁竞争也应纳入部署前的负载评估。
单个名字恢复使用五秒上下文预算,不把它当作整个 HTTP 响应的硬实时上限。取消后的部分或迟到锁授予仍需清理,测试同时检查没有剩余锁记录和等待名额。
验证证据与边界
最终源码包含当前主干的 IAM 和 Multipart 修复。回归测试既验证“该返回什么”,也验证正常路径不会额外读盘或取锁、锁不会跨越输出阻塞、缓存不能把未决状态编码出去。混合纠删码输入保留与原算法的差分比较,版本历史和严格解析维持原合同。
本地 make test 已通过,包含 verifiers、构建和全包测试:1,773 项顶层测试通过、12 项既有跳过。目录恢复、分页缓存、锁路由以及分布式/本地锁取消与迟到授予的针对性 race 测试也通过。
提交 87c1c6b3d 的 8 项 PR 检查全部通过,包括 Go CI、DCO 和漏洞检查。不发布制品的 snapshot 流水线也通过;它明确跳过发布、镜像发布和签名。
p95 表示约 95% 的请求耗时不超过该值;比较的是同一测试条件下候选与主干的比例,不是给生产环境承诺绝对延迟。
当前源码与主干 7e9454456 的独立对照共 10 轮:先做两轮相同主干,再做四组交替顺序的主干/候选比较。每轮预热 20 秒、测量 120 秒;八进程、两池、32 个逻辑盘目录共用一块 NVMe,使用回环网络和小对象固定速率负载。负载期间完成 22,410 次完整扫描,零漏项、重复、内容损坏或健康状态下的 HTTP 错误;没有环境或调度告警。
| 指标 | 候选 / 主干的配对 p95 变化 | 95% 区间 |
|---|---|---|
| 完整 LIST 页面 | +0.82% | −3.53% ~ +5.36% |
| 同名 DELETE | −0.38% | −1.80% ~ +1.05% |
| 不同名 DELETE | −0.55% | −4.74% ~ +3.84% |
严格的“退化不超过 5%”统计目标仍未确认。 LIST 区间上界为 5.36%,且两轮相同主干的不同名 DELETE p95 自身相差约 12%。因此整体结果记录为“不确定”,不能改称性能门禁通过。点估计支持主要常态指标变化较小,不能等同于保证所有场景都小于 5%。不修改门槛,不通过追加轮次挑选有利结果。
次要指标同样保留:完整分页扫描 p95 约 +0.47%,PUT p95 约 +2.82%,GET p95 约 −0.51%;每页最多 7 条的中间续页 p95 约 +12.60%,同名 DELETE p99 约 +18.95%。候选还出现一次 327.248 毫秒的同名 DELETE;四个配对基准轮次最大值为 24.153 毫秒,额外 A/A 基准中也记录过 148.186 毫秒。当前未给这个单次长尾作因果归因,不能声称长尾已消失。八进程 CPU 的配对变化约 −0.07%,RSS 合计约 +0.11%;RSS 是进程驻留内存;多进程合计会重复计算共享页,不等于实际物理内存。
首次尝试因种子目录的符号链接复制问题,在任何候选测量开始前被身份校验拦截。失败记录保留,种子从另一份完全匹配原始校验清单的副本恢复;上述结果来自更正后使用独立实体目录的完整固定对照,没有混入失败尝试的数据。
同一候选二进制完成了八进程逐一优雅重启的验收。负载期间有 5,235 次成功完整扫描,其中测量窗口内为 4,754 次;没有漏项、重复或内容损坏。139 次失败 HTTP 请求全部落在各自端点的停止至就绪窗口内;46 次失败扫描是失败分页请求的汇总,不能再重复计数。最终 80 个对象状态、8 个端点上每页上限为 1/7/1000 的分页检查、416 次成功 GET 与 512 次预期不存在的 GET,均与最后确认的写入或删除一致。这个结果通过了限定范围的滚动功能验收,但不意味着零中断,也不改变上面的性能统计结论。
源码合并的工程判断是接受这项有边界的正确性修复:主要常态指标的实测变化较小,正常条目没有额外对象 I/O 或锁,确定性测试覆盖了真实删除和锁域等缺口。严格性能统计仍不确定,个别长尾也需要保留,不能把这次合并写成生产性能验收已经完成。
前一轮冻结候选的 18 组实验必须保留其原结论:LIST、同名 DELETE、不同名 DELETE 的 p95 配对比例约为 1.0042、0.9963、0.9966,延迟指标的 95% 区间上界均低于 1.05;但一个测量区间出现持续的外部 CPU 活动,整轮性能结论仍是不确定,不能改写成通过。该候选的滚动测试记录了 124 次停机窗口内的失败 HTTP 请求,没有遗漏、重复或损坏内容;这证明了限定范围的功能与恢复行为,不证明滚动升级零中断。
强制制造恢复与同名写冲突的历史实验中,短等待改善了部分 DELETE 和 LIST 请求,但仍保留约 303 毫秒的 LIST 最大延迟,部分 PUT 长尾指标也变差。它降低短冲突被退避放大的概率,没有消除持续竞争;不能拿健康常态的结果承诺故障时的性能。
新的整合验收与上述历史数据分开,不挑掉慢样本,也不把旧二进制的结果冒充当前源码结果。所有单机多进程结果都不能替代真实跨主机网络、混合版本、大桶和持续故障的生产验收。实际生产中的恢复频率仍未测定;故障时明确返回错误是有意保留的可用性代价。
兼容性和交付决定
不变的是 S3 参数、分页令牌编码、存储格式、配置项、模块依赖和客户端调用方式。不需要重写旧对象,也不需要新开关。变化是符合入口条件、又无法确认的条目会使 LIST 失败;这是用现有错误类型纠正成功漏项,不是保证所有原本返回 200 的请求仍然返回 200。
修复不让整个桶获得某一时刻的快照,也不修复 #79 的取消竞态或容量问题。回退服务端无需转换数据,但会恢复旧版的漏项风险;正在执行的请求和缓存应按既有重试流程处理。
处理决定已执行:#239 合入主干,#218 关闭。#221 的原始签署提交也已随 #239 纳入主干,GitHub 将 #221 标记为已合并。严格性能统计未确认的部分保留到发布评估中,不改变成“全部通过”。生产版本发布、镜像推送和部署需单独执行;合并本身不授权这些动作。
33 - 对象锁保留期越权与版本覆写漏洞修复设计(SN-2026-015 / SN-2026-016)
2026-10-15 状态: PR #234(14042b5aa)与 PR #235(0d3ba5d4c)的完整修复首次随 RELEASE.2026-10-15T00-00-00Z 正式交付。此前发布的 RELEASE.2026-09-16T00-00-00Z 仍受影响。升级要求与制品验收见安全公告和发布说明。
本文档完整记录针对外部报告与内部关联排查发现的两处对象锁(Object Lock / WORM)安全缺陷——**SN-2026-015(PutObjectRetention 绕过请求头越权与条件策略失灵)**与 SN-2026-016(版本寻址写入覆写绕过及写路径锁缺口)——的事实核验、Git 历史溯源、修复设计、关键路径影响评估与候选方案的对抗验证取舍。
关联公开安全公告:
1. 问题确认与外部报告核验
1.1 两条独立外部报告来源
本次修复覆盖两条独立报告链及一次内部同源排查:
- 首报(F2 主路径):由外部安全研究者 szopi00 通过 GitHub Security Advisory 私密报告(GHSA-67mv-7hc9-wj88),指出持有
s3:PutObjectRetention但无s3:BypassGovernanceRetention的主体可通过发送原始x-amz-bypass-governance-retention: true请求头缩短未到期的GOVERNANCE保留期。 - 独立外部报告(F1 / F2 / F3):另一份外部报告独立复现了三类攻击向量:未经授权将
GOVERNANCE改为多年COMPLIANCE、绕过基于请求体条件键的s3:PutObjectRetentionDeny 策略,以及无 bypass 授权缩短未到期的GOVERNANCE保留期。 - 内部同源关联排查(
SN-2026-016/ F4 与目录对象null版本覆写):在收到上述报告后,我们对“拥有写权限但无 bypass 权限的调用者还能通过哪些入口破坏受锁版本”进行了全量写路径审计,定位到非可信复制请求携带?versionId=原地覆写历史版本、目录键dir/隐式null版本覆写以及已持久化目标版本的分段上传完成三条同源缺口。
1.2 独立报告逐项核验结论
以下核验与代码行号针对修复前的 bcb5fc956 快照:
| 邮件报告论断 | 核验结果 | 代码证据与定性说明 |
|---|---|---|
危害 1(未经授权设置 COMPLIANCE 保留期):无 s3:BypassGovernanceRetention(甚至无 s3:PutObjectRetention 或被条件 Deny)的攻击者可将 GOVERNANCE 改为 COMPLIANCE 或直接写入超长 COMPLIANCE |
完全属实 | cmd/bucket-object-lock.go 第 205 行仅检查原始请求头 !byPassSet;cmd/auth-handler.go 第 773–781 行在目标模式为 COMPLIANCE 时跳过 IAM 求值,保持 byPassSet == true,并于 if byPassSet || retSet 直接返回 ErrNone。一旦写成 COMPLIANCE,即使 S3 root 管理员也无法通过 S3 API 在到期前删除 |
危害 2(削弱保留期 / 勒索软件擦除前置):无 s3:BypassGovernanceRetention 的凭据可携带请求头缩短未到期 GOVERNANCE 保留期 |
完全属实 | 请求体模式为 GOVERNANCE 时,虽然 isPutRetentionAllowed 把 byPassSet 更新为 false,但紧接着求值 retSet(s3:PutObjectRetention)为 true,末尾 byPassSet || retSet 依然放行 |
SILO 与上游 MinIO 在 DeleteObjectVersion 上的爆炸半径差异:SILO 限制了无 s3:DeleteObjectVersion 时的历史版本删除;上游 MinIO 将 s3:DeleteObjectVersion 设为 DenyOnly: true,仅凭 s3:DeleteObject 即可等锁到期后删空历史版本 |
完全属实 | SILO 在提交 75a6734e4(2026-09-02)引入 deleteObjectAction 显式要求 s3:DeleteObjectVersion;而上游 MinIO cmd/auth-handler.go 至今对指定 versionId 的删除使用 DenyOnly: true |
受影响范围与代码行号:缺陷位于服务端 cmd/ 而非 silo-pkg;SILO RELEASE.2026-09-03T13-18-01Z(9b11dc94)、RELEASE.2026-09-16T00-00-00Z、修复前的 SILO main(bcb5fc956)及当时核验的上游 minio/minio 快照均受影响 |
完全属实 | silo-pkg 的 IAM 策略引擎对 Deny 求值完全正确;问题纯粹出在 cmd/object-handlers.go、cmd/bucket-object-lock.go 与 cmd/auth-handler.go 的调用编排与布尔短路逻辑 |
复现脚本中无法用无条件 Deny s3:PutObjectRetention 规避:若对应用账号无条件 Deny s3:PutObjectRetention,则连向配置了默认保留期(Default Retention)的桶执行普通 PutObject 也会失败 |
完全属实 | 普通 PutObject 在相关对象锁检查路径中依赖 s3:PutObjectRetention,无条件 Deny 会阻断正常业务写入,因此生产策略只能使用带条件键的 Deny——而这正是被 F3 绕过的场景 |
2. 缺陷全景与 Git 历史溯源
2.1 SN-2026-015:PutObjectRetention 鉴权短路与绕过漏洞(F1 / F2 / F3)
在修复前的实现中,PUT /{bucket}/{object}?retention 存在两处结构性错位:
- 处理器入口只做签名认证,不做策略鉴权:
PutObjectRetentionHandler调用authenticateRequest(ctx, r, policy.PutObjectRetentionAction),该函数只验证签名/令牌有效性,不查询 IAM 策略; - 存量锁绕过检查与 IAM 策略裁决混杂且布尔方向写反:在对象写锁回调
enforceRetentionBypassForPut及辅助函数isPutRetentionAllowed中,服务端直接信任了客户端请求头x-amz-bypass-governance-retention: true,并用byPassSet || retSet将“绕过权限”与“写保留期基础权限”做逻辑或(||)短路。
由此产生三类具体漏洞(F1–F3)及一处清除保留期时的剩余天数计算缺陷:
| 编号 | 攻击者拥有的权限 | 触发方式与安全后果 |
|---|---|---|
| F1 | 仅通过身份认证、没有任何对象锁权限(无 s3:PutObjectRetention、无 s3:BypassGovernanceRetention) |
携带 x-amz-bypass-governance-retention: true 发送:① 空 <Retention/> 直接清零未到期的 GOVERNANCE 保留期;② <Mode>COMPLIANCE</Mode> 将任意对象(含已有 GOVERNANCE 锁的对象)直接设为到期前不可解除的 COMPLIANCE 保留期 |
| F2 | 持有 s3:PutObjectRetention、但没有 s3:BypassGovernanceRetention(两份报告均覆盖) |
携带 x-amz-bypass-governance-retention: true 将未到期的 GOVERNANCE 保留期缩短至几秒后,或原地改为 COMPLIANCE |
| F3 | 持有 s3:PutObjectRetention(受条件 Deny 限制)或持有 s3:BypassGovernanceRetention |
① 目标模式为 COMPLIANCE 或空 <Retention/> 时,携带 bypass 请求头直接跳过 isPutRetentionAllowed 中的策略求值,完全绕过挂在 s3:PutObjectRetention 上的条件 Deny(独立报告症状 2);② 持有 s3:BypassGovernanceRetention 时,在 GOVERNANCE 模式下同样因 byPassSet || retSet 绕过 s3:PutObjectRetention 条件 Deny |
| 附带算术缺陷 | 持有受 s3:object-lock-remaining-retention-days 条件限制的 s3:PutObjectRetention |
发送空 <Retention/> 清除保留期时,RetainUntilDate 为零值 0001-01-01,旧代码 math.Abs(objRetention.RetainUntilDate.Sub(t).Hours()) 因 time.Duration 292 年上限截断后取绝对值算出 106,752 天,导致“禁止剩余保留期小于 N 天”的 NumericLessThan 策略被清除操作直接绕过 |
精确 Git 提交溯源
- 2019-11 至 2020-03 的正确初始设计:
- 2020-04 引入核心缺陷(上游 #9259,
43a3778b4,2020-04-06,Harshavardhana):- 为支持来自 XML 请求体的三个锁条件键(
s3:object-lock-mode、s3:object-lock-retain-until-date、s3:object-lock-remaining-retention-days),该提交删掉了 handler 入口的checkRequestAuthType,将鉴权下沉到enforceRetentionBypassForPut内部的新函数isPutRetentionAllowed,一举引入三处错误:enforceRetentionBypassForPut直接读取原始请求头byPassSet := IsObjectLockGovernanceBypassSet(r.Header),并用if !byPassSet包裹防缩短守卫;isPutRetentionAllowed仅在if retMode == objectlock.RetGovernance && byPassSet时才向 IAM 求值s3:BypassGovernanceRetention,导致retMode == COMPLIANCE时byPassSet保持原始请求头的true不变;- 函数末尾写成
if byPassSet || retSet { return ErrNone },造成||短路。
- 为支持来自 XML 请求体的三个锁条件键(
- 2020-05 扩大 F1 爆炸半径(上游 #9677,
bede525dc,2020-05-22,Ashish Kumar Sinha):- 允许发送空
<Retention/>(Mode == "")清除GOVERNANCE锁,同样落在retMode != RetGovernance分支,使零权限攻击者不但能锁死COMPLIANCE,还能直接清零GOVERNANCE锁,同时引入了零值日期math.Abs算出106,752天的算术缺陷。
- 允许发送空
- 2025-02 临时遮蔽 F1 与 2025-04 机械回归:
- 上游 #20929(
437dd4e32,2025-02-12,Ramon de Klein)发现零权限用户可调用PutObjectRetention,于是在 handler 入口加回不带条件键的checkRequestAuthType(ctx, r, policy.PutObjectRetentionAction, bucket, object),临时挡住了 F1(但破坏了带锁条件键的Allow策略,且完全未修 F2、F3)。 - 仅 50 天后,上游 #21103(
8c7097528,2025-04-03,Harshavardhana)在重构流式签名验证时,把入口的checkRequestAuthType机械替换为只验签名、不查策略的authenticateRequest,却保留了上方的旧注释// Check permissions to perform this object retention operation,导致 F1 重新彻底暴露。SILO 分叉自该提交之后,完整继承了 F1–F3。
- 上游 #20929(
2.2 SN-2026-016:版本寻址写入覆写绕过及写路径锁缺口(F4 与同源路径)
标准 S3 协议中,公开对象写入操作(PutObject、CopyObject、CreateMultipartUpload、PostPolicy)只允许服务端生成新版本号(或在未开启/暂停版本控制时写入 null 版本),绝不允许客户端指定目标 versionId 去原地改写既有历史版本。然而现有实现存在三处同源写路径缺口:
| 路径编号 | 触发方式 | 安全后果 |
|---|---|---|
F4(显式目标 versionId 写入) |
普通 s3:PutObject 主体在 PutObject、CopyObject、CreateMultipartUpload、PostPolicy 或 Snowball 归档解压(PAX minio.versionId)中传入既有版本的 versionId(UUID 或 null) |
存储层按指定 versionId 原地覆盖该版本的数据载荷与元数据。对于处于未到期 GOVERNANCE 保留期的版本,其数据与保留期被直接替换或抹除 |
同源缺口 A(目录键 dir/ 隐式 null 版本覆写与慢速请求体竞态) |
对启用了对象锁的桶中任意以 / 结尾的目录键发起普通 PUT /{bucket}/dir/(不带 ?versionId=) |
① putOpts 将目录键隐式映射为 opts.VersionID = "null" 原地覆写,而 checkPutObjectLockAllowed 通过 getOpts 读到的 opts.VersionID 为空,跳过存量锁检查,导致普通 PUT dir/ 可原地覆盖并抹除目录 null 版本的 GOVERNANCE、COMPLIANCE 保留期与 LegalHoldOn;② 即便修补了 HTTP 预检,由于预检发生在读取 HTTP 请求体之前,写入者仍可通过预检后暂停发送请求体字节流,待管理员对 dir/ 加上 COMPLIANCE 锁后再发完请求体,在存储层覆盖已加锁的 null 版本;③ 预检阶段 checkPutObjectLockAllowed 仅在 getObjectInfoFn 返回 err == nil 时检查锁状态,遇到读仲裁失败等错误时直接跳过检查放行写入 |
| 同源缺口 B(已持久化目标版本的分段上传完成) | 分段上传在 NewMultipartUpload 时将目标 fi.VersionID 持久化到 .minio.sys/multipart/.../uploadID/xl.meta,CompleteMultipartUpload 通过独立的 completeMultipartOpts 提交该版本 |
升级前已创建的遗留分段上传任务、由复制主体发起的任务、或旧上传状态恢复后与已复制落盘的同 UUID 版本发生碰撞的任务,其目标版本号保存在 uploadID/xl.meta 中且不经过 putOpts;若完成阶段不在对象写锁下对 fi.VersionID 执行存量版本查重,仅有 s3:PutObject 的调用者即可在目标版本被加上 COMPLIANCE 锁后完成上传并覆写该版本 |
精确 Git 提交溯源
- 2020-05 对象锁检查遗漏
GOVERNANCE(上游 #9513,9b3b04ece,2020-05-04):cmd/bucket-object-lock.go的checkPutObjectLockAllowed在if opts.VersionID != "" && !replicaTrusted分支中只拦截了未到期的COMPLIANCE与LegalHoldOn,漏掉了未到期的GOVERNANCE,且仅在getObjectInfoFn返回err == nil时才进入检查。 - 2020-09 为桶复制开放目标
versionId却未校验复制信任(上游 #10598,56d1b227c,2020-09-30,Poorna Krishnamoorthy):在cmd/object-api-options.go的putOpts中读取?versionId=并赋给opts.VersionID,但从未检查trustedReplication,导致任何拥有s3:PutObject的客户端都能直接寻址覆写既有版本。 - 2023-05 目录对象强制使用
null版本(上游 #17132,1d0211d39,2023-05-04,Harshavardhana):在putOpts和delOpts中对isDirObject(object)强制设置opts.VersionID = nullVersionID,但未同步更新checkPutObjectLockAllowed与存储提交阶段,留下目录对象隐式null版本锁覆写缺口。
3. 最终修复方案:要修改哪些代码、怎么改
最终修复方案由**核心方案 A(SN-2026-015)与入口 + 存储写锁双重防线 + 预检 Fail-Closed(SN-2026-016)**两部分组成,共涉及 cmd/ 下 11 个生产文件:
| 所属问题 | 修改文件 | 核心改动内容 |
|---|---|---|
| SN-2026-015 (核心方案 A,3 个文件) |
cmd/object-handlers.go |
在 PutObjectRetentionHandler 解析请求体 XML 后、读取任何桶/对象状态前,立即调用 isRetentionActionAllowed 无条件校验 s3:PutObjectRetention |
cmd/bucket-object-lock.go |
新增 isRetentionActionAllowed(修正零值日期为 "0" 天、超远日期用秒差向上取整);重写 enforceRetentionBypassForPut,仅在削弱未到期存量 GOVERNANCE 锁时同时要求 bypass 请求头与 s3:BypassGovernanceRetention 授权 |
|
cmd/auth-handler.go |
删除存在 byPassSet || retSet 短路漏洞的 isPutRetentionAllowed 函数(净删 45 行) |
|
| SN-2026-016 (入口与写锁双重防线,8 个文件) |
cmd/object-api-options.go |
在所有 HTTP 写入选项的唯一汇合点 putOpts 中,对 vid != "" && !trustedReplication 一律返回 errAuthentication(403 AccessDenied) |
cmd/batch-handlers.go |
在 BatchJobReplicateV1.writeAsArchive 中为外层 SnowballOptions 补齐 ReplicationRequest: true 标记,使服务端 batch replicate 归档复制正常通过外层复制信任校验 |
|
cmd/bucket-object-lock.go |
① 在预检 checkPutObjectLockAllowed 中补齐 isDirObject(object) 的 nullVersionID 解析,将未到期 GOVERNANCE 纳入不可覆写条件,并在 getObjectInfoFn 返回非“不存在”错误时立即 Fail-Closed;② 新增 checkObjectLockedForOverwrite 供存储写锁内复检 null 版本锁状态(同样 Fail-Closed) |
|
cmd/bucket-handlers.go |
在 PostPolicyBucketHandler 中对目录对象(isDirObject(object))补充调用 checkPutObjectLockAllowed |
|
cmd/object-multipart-handlers.go |
在 CompleteMultipartUploadHandler 调用 checkPutObjectLockAllowed 时传入实际的 trustedReplication 状态 |
|
cmd/erasure-object.go |
在 erasureObjects.putObject 与 erasureObjects.CopyObject 持有目标对象命名空间写锁(NewNSLock)期间,对目标版本为 "" 或 nullVersionID 的非复制写入调用 checkObjectLockedForOverwrite |
|
cmd/erasure-multipart.go |
在 CompleteMultipartUpload 持有目标对象写锁期间,对 null 版本调用 checkObjectLockedForOverwrite,对非空 UUID 版本执行 getObjectInfo(多池下为 replicaObjectInfo)存量版本查重 |
|
cmd/erasure-server-pool.gocmd/erasure-sets.go |
在多池模式下为公开写入与分段上传完成注入跨池 z.replicaObjectInfo,并在 CopyObject 中透传版本与复制协调选项 |
3.1 SN-2026-015 修复代码详解(核心方案 A)
(1)cmd/object-handlers.go:PutObjectRetentionHandler 入口无条件基础鉴权
在 authenticateRequest 验签并调用 ParseObjectRetention(r.Body) 解析完请求体后,在读取任何桶信息(GetBucketInfo)、桶锁配置(globalBucketObjectLockSys.Get)或对象版本(getOpts)之前,立即调用 isRetentionActionAllowed 校验 s3:PutObjectRetention:
(2)cmd/bucket-object-lock.go:isRetentionActionAllowed 与 enforceRetentionBypassForPut
抽出统一的 isRetentionActionAllowed 辅助函数,从解析后的请求体构造 object-lock-mode、object-lock-retain-until-date、object-lock-remaining-retention-days 三个条件键并调用 globalIAMSys.IsAllowed:
- 当请求为清除保留期(空
<Retention/>,RetainUntilDate为零值0001-01-01)或非未来时间时,将object-lock-remaining-retention-days精确求值为"0"(修复旧代码因time.Duration截断与math.Abs算出106,752天的漏洞,且始终保留该条件键以确保NumericLessThan的Deny策略能够命中清除请求); - 当日期为未来时间时,改用 Unix 秒数差与纳秒差向上取整计算天数,避免
Time.Sub在约 292 年后发生time.Duration饱和。
同时,将对象写锁内的 enforceRetentionBypassForPut 收敛为纯粹基于存量版本状态的规则:
(3)cmd/auth-handler.go:删除 isPutRetentionAllowed
直接删除 cmd/auth-handler.go 中 45 行的 isPutRetentionAllowed 函数,消除 byPassSet || retSet 短路逻辑。
3.2 SN-2026-016 修复代码详解(入口守卫 + 存储写锁复检 + 预检 Fail-Closed)
为什么 SN-2026-016 必须在 HTTP 入口守卫之外保留存储写锁内的复检?原因在于以下四类场景无法仅靠入口或来源标记拦截:
- 慢速请求体导致的
PUT dir/覆写竞态(TestPlanReviewSlowDirectoryPut):PutObjectHandler的预检checkPutObjectLockAllowed运行在流式读取 HTTP 请求体r.Body之前,而存储写锁er.NewNSLock在请求体写完临时文件后才获取。仅持有s3:PutObject的写入者可以先发起PUT /bucket/dir/通过预检,随后暂停发送请求体字节流,等管理员对dir/设置COMPLIANCE保留期并收到200 OK响应后,再继续发完请求体。因此必须在对象写锁内复检null版本锁状态。 - 升级遗留或复制发起的已持久化
uploadId完成请求(TestPlanReviewLegacyMPUCompliance):putOpts只在NewMultipartUpload创建上传任务时运行;而CompleteMultipartUpload使用completeMultipartOpts,目标版本号直接读取自.minio.sys/multipart/.../uploadID/xl.meta中持久化的fi.VersionID。对于升级前已创建的遗留分段上传任务(或由复制凭据初始化的任务),必须在完成阶段的对象写锁下校验目标版本。 - 旧上传状态恢复后与已复制落盘的
COMPLIANCE版本碰撞(TestRecoveredMultipartUploadAfterReplica):即便分段上传在初始化时由服务端生成了新 UUID(甚至带有服务端来源标记),在旧上传状态快照恢复且存活副本已将该完成版本及COMPLIANCE锁重新同步回本地后,暂存目录中的fi.VersionID与已落盘的受锁版本具有相同 UUID。来源标记只能证明 UUID 生成时的来源,不能证明完成上传时该版本仍不存在。因此必须在CompleteMultipartUpload的对象写锁内直接查重目标fi.VersionID是否已存在。 - 预检与写锁复检的读取失败必须 Fail-Closed(
TestPlanReviewPreflightReadFailure):只有明确确认目标版本不存在(ObjectNotFound、VersionNotFound或删除标记MethodNotAllowed)时才能按“无锁”放行;当桶锁配置读取失败或底层存储发生读仲裁错误(如InsufficientReadQuorum)时,预检与写锁复检都必须拒绝写入。
完整改动分为以下四步:
(1)在 putOpts 唯一汇合点拦截非可信复制的 versionId(cmd/object-api-options.go)
所有 HTTP 写入选项构造(PutObject、CopyObject、NewMultipartUpload、PostPolicy、PutObjectExtract)最终都汇聚到 putOpts。在解析 vid 前增加 3 行守卫:
(2)补齐 Batch Snowball 归档复制发送端的外层复制标记(cmd/batch-handlers.go)
SILO 服务端自身的 batch replicate 在源与目标均为 SILO/MinIO 时会通过 writeAsArchive 发送 Snowball 归档,并在每个 tar PAX 条目中携带 minio.versionId。接收端 PutObjectExtractHandler 要求外层 HTTP 请求头具备 X-Minio-Source-Replication-Request: true(markerExact)才会对条目授予 entryTrusted。因此在 writeAsArchive 中为外层 PutObjectOptions 补上复制请求标记:
(3)在 HTTP 预检与存储对象写锁内双重拦截 null 版本锁覆写,且读取失败时一律 Fail-Closed(cmd/bucket-object-lock.go、cmd/erasure-object.go、cmd/erasure-server-pool.go、cmd/erasure-sets.go)
- HTTP 预检阶段(
checkPutObjectLockAllowed):使目录对象(isDirObject(object))与putOpts/delOpts一致地将空版本解析为nullVersionID;在!replicaTrusted时将未到期的GOVERNANCE与COMPLIANCE、LegalHoldOn一并视为不可覆写;并且当getObjectInfoFn返回非ObjectNotFound/VersionNotFound/MethodNotAllowed的错误时立即返回错误(Fail-Closed):
- 存储提交写锁阶段(
checkObjectLockedForOverwrite):在er.putObject、er.CopyObject、er.CompleteMultipartUpload持有目标对象命名空间写锁(NewNSLock)期间,对所有目标版本为""或nullVersionID的非复制写入调用checkObjectLockedForOverwrite(多池模式下通过z.replicaObjectInfo汇总跨池锁状态)。桶锁配置读取失败或对象读取失败时同样拒绝写入,只有确认目标null版本不存在或未被锁定才放行:
(4)在 CompleteMultipartUpload 对象写锁下按持久化 fi.VersionID 精确区分 null 版本与 UUID 版本覆写(cmd/erasure-multipart.go)
分段上传在初始化与完成之间可能跨越桶版本控制状态的切换,或涉及升级遗留任务、跨主体完成及旧上传状态恢复。因此在对象写锁(NewNSLock)内直接依据 uploadid/xl.meta 中持久化的 fi.VersionID 裁决非复制完成请求(!opts.ReplicationRequest && !opts.ReplicaLockReconcile && !opts.DataMovement):
- 若
fi.VersionID == "" || fi.VersionID == nullVersionID(写入null版本,含版本控制开启前发起的正常在途上传及目录对象上传):调用checkObjectLockedForOverwrite,在未触发对象锁时正常放行完成; - 若
fi.VersionID为非空 UUID:无论当前桶处于Enabled还是Suspended,通过getObjectInfo(多池经replicaObjectInfo)检查该 UUID 版本是否已存在于磁盘(含普通版本与删除标记);若已存在则直接返回errAuthentication(403 AccessDenied),读取失败则按 Fail-Closed 返回对应错误,且不清理未提交的分片文件。
4. 关键路径与性能影响分析
| 操作路径 | 修改后的执行路径与开销说明 |
|---|---|
GetObject / HeadObject / ListObjects / DeleteObject |
完全不经过任何修改点,读路径与删除路径无任何变更 |
普通桶(未开启对象锁)的 PutObject / CopyObject |
putOpts 增加一次内存空串判断 vid != "";写锁内 checkObjectLockedForOverwrite 读取进程内 globalBucketObjectLockSys.Get(bucket) 缓存发现 !retentionCfg.LockEnabled 后立即返回,无额外磁盘 I/O 或跨池请求 |
对象锁桶中的常规对象 PutObject / CopyObject(非 / 结尾) |
对象锁桶强制开启版本控制,常规单次对象写入生成的 fi.VersionID 为非空 UUID,不满足 fi.VersionID == "" || fi.VersionID == nullVersionID,既不在预检也不在写锁内触发额外的 getObjectInfo 磁盘读取 |
对象锁桶中的目录对象写入(PUT dir/)或暂停版本控制下的 null 版本覆写 |
在预检与对象写锁内读取 null 版本元数据进行锁校验,并在读取失败时立即拒绝写入,防止慢速请求体竞态与读仲裁故障下的锁覆写 |
版本化桶中的分段上传完成(CompleteMultipartUpload) |
在 CompleteMultipartUpload 最终提交写锁内执行一次 getObjectInfo(多池下为 replicaObjectInfo)核验 fi.VersionID 是否已存在。该检查仅在分段上传最终合并提交时发生一次(NewMultipartUpload 与各 UploadPart 均不受影响);针对常规新建上传跳过该查询的优化需另行设计能抵御快照恢复重放的机制并独立验证,不在本次安全修复中强行并入 |
PutObjectRetention 合法延长保留期与未授权请求 |
基础权限 s3:PutObjectRetention 前移至读取桶/对象状态之前:未授权请求在入口即被内存态 IAM 求值拒绝(消除原有的磁盘读取与写锁争用);合法延长保留期请求在对象写锁内判定 !weakens 后立即返回,无需在写锁内重复执行 IAM 求值 |
5. 为什么这个方案有效,以及有哪些兼容性注意事项
5.1 三条核心安全不变量
- 不变量一:基础权限与锁条件键在读取任何状态前无条件成立(消除 F1、F3、清锁天数绕过与存在性探测)
PutObjectRetention支持的三个对象锁条件键全部派生自请求体 XML 与当前时间,不依赖桶或对象的任何存量元数据。因此在ParseObjectRetention之后立即调用isRetentionActionAllowed(policy.PutObjectRetentionAction, ...),既是最早可求值点,也是完全求值点。- 删除
byPassSet || retSet并将清除保留期(空<Retention/>)的剩余天数精确求值为"0"后,任何缺少s3:PutObjectRetention授权、或命中条件Deny/ 不满足条件Allow的请求,无论是否携带 bypass 请求头、也无论请求何种模式,一律在入口被403 AccessDenied拦截。
- 不变量二:Bypass 仅由“存量锁未到期且被削弱”触发,且意图与授权必须同时具备(消除 F2)
enforceRetentionBypassForPut在对象写锁保护下检查已存储的ret.Mode与ret.RetainUntilDate。若存量无锁、已到期、或请求未削弱存量锁(!weakens),无需 bypass;一旦weakens == true,存量COMPLIANCE无条件返回400 ObjectLocked,存量GOVERNANCE必须同时满足请求头(IsObjectLockGovernanceBypassSet)与 IAM 授权(isRetentionActionAllowed(BypassGovernanceRetentionAction, ...))。
- 不变量三:公开数据写入在入口与提交写锁下均不能替换既有 UUID 版本或受锁
null版本,且读取失败一律 Fail-Closed(消除 F4 与同源写路径缺口)- 入口层:
putOpts拒绝一切非可信复制指定的目标versionId;checkPutObjectLockAllowed拦截对受锁目录null版本的写入并在读取错误时立即拒绝放行。 - 提交写锁层:在与实际重命名提交相同的对象命名空间写锁(
NewNSLock)下,对null版本覆写再次执行 Fail-Closed 的checkObjectLockedForOverwrite(闭合慢速请求体 TOCTOU 窗口),对分段上传的非空fi.VersionID执行存量版本查重(闭合升级遗留任务、跨主体完成与旧上传状态恢复重放窗口)。
- 入口层:
PutObjectRetention 完整判定表
retOK 表示带请求中的锁条件键时 s3:PutObjectRetention 被允许,bypassOK 表示同一组条件键下 s3:BypassGovernanceRetention 被允许:
| 目标版本存量状态 | 请求变更类型 | Bypass 请求头 | retOK |
bypassOK |
修复前行为 | 修复后行为 |
|---|---|---|---|---|---|---|
| 任意 | 任意 | 任意 | 否 | 任意 | 常为 200(F1、F3)或 404/400 | 入口 403 AccessDenied |
未到期 GOVERNANCE |
延长保留期 | 任意 | 是 | 任意 | 200 | 200 OK |
未到期 GOVERNANCE |
缩短 / 清除 / 改模式 | 无 | 是 | 任意 | 400 InvalidRequest(ObjectLocked) |
400 InvalidRequest(ObjectLocked) |
未到期 GOVERNANCE |
缩短 / 清除 / 改模式 | 有 | 是 | 否 | 200(F2 漏洞) | 403 AccessDenied |
未到期 GOVERNANCE |
缩短 / 清除 / 改模式 | 有 | 是 | 是 | 200 | 200 OK |
未到期 COMPLIANCE |
Shorten / 清除 / 改模式 | 任意 | 是 | 任意 | 400 InvalidRequest(ObjectLocked) |
400 InvalidRequest(ObjectLocked) |
| 无保留期或已到期 | 任意 | 任意 | 是 | 任意 | 200 | 200 OK |
写路径不变量覆盖表
| 写入路径 | 选项汇合点与写锁防线 | 修复后的保证 |
|---|---|---|
PutObject(常规 PUT / 桶复制 / 站点复制 / 单对象 Batch 复制) |
验签后经 evaluateReplicationTrust 写入上下文,再调用 putOptsFromReq -> putOpts;null 版本在写锁下执行 checkObjectLockedForOverwrite |
公开客户端(含 root)带 ?versionId= 一律在 putOpts 返回 403;可信复制正常保留源版本 ID |
CopyObject(含元数据复制) |
源版本走 srcOpts,目标版本经 copyDstOpts -> putOpts;null 版本在写锁下执行 checkObjectLockedForOverwrite |
公开复制源版本正常生成新版本;公开指定目标 ?versionId= 返回 403;可信复制正常同步目标版本 |
PostPolicy(浏览器表单上传) |
经 putOptsFromReq -> putOpts;目录对象预检与写锁内双重执行锁检查 |
携带目标 versionId 一律返回 403;受锁目录对象不可被表单覆写 |
PutObjectExtract(Snowball / Batch 归档复制) |
外层校验 markerExact,逐条目校验 s3:ReplicateObject 得出 entryTrusted 再传入 putOpts;writeAsArchive 补齐外层标记 |
未获复制信任的归档条目若带 minio.versionId 一律返回 403;服务端 batch replicate 归档路径正常保留版本 ID |
NewMultipartUpload / UploadPart / UploadPartCopy |
初始化与加密分片走 putOptsFromReq -> putOpts;UploadPartCopy 强制清除复制信任 |
非可信复制初始化分段上传无法指定目标 versionId(返回 403) |
CompleteMultipartUpload |
走 completeMultipartOpts,并在对象写锁下按 fi.VersionID 执行 null 版本锁检查或 UUID 存量版本查重 |
版本控制开启前发起的正常在途上传可正常完成;针对既有 UUID 版本(含升级遗留任务、指定版本任务与恢复重放任务)的非复制完成请求一律返回 403 |
目录对象 PUT / Copy(/{bucket}/dir/) |
预检 checkPutObjectLockAllowed 与写锁内 checkObjectLockedForOverwrite 双重 Fail-Closed 校验 |
一旦目录 null 版本存在未到期的 GOVERNANCE、COMPLIANCE 或 LegalHoldOn(含预检后慢速传输期间并发提交的锁),非复制覆写返回 400 InvalidRequest(ObjectLocked);读取失败时在预检与写锁均拒绝写入 |
5.2 可能产生的业务与兼容性副作用(Upgrade Notes)
常规的新版本创建、普通对象读写与已获授权的保留期延长保持原有行为。以下收紧的边界行为需在升级与发布说明中提示运维人员:
- 仅授予
s3:BypassGovernanceRetention而无s3:PutObjectRetention的策略不再放行保留期写入(SN-2026-015)- 旧行为:因
byPassSet || retSet漏洞,仅有s3:BypassGovernanceRetention的主体带上请求头也能成功调用PutObjectRetention。 - 新行为:返回
403 AccessDenied。按 AWS S3 规范,s3:BypassGovernanceRetention是削弱治理锁时的附加权限,写保留期始终需要s3:PutObjectRetention。
- 旧行为:因
- 未授权
PutObjectRetention请求在入口统一返回403,不再返回404或400(SN-2026-015)- 旧行为:桶不存在时返回
404 NoSuchBucket,桶未开启对象锁时返回400 InvalidBucketObjectLockConfiguration。 - 新行为:在读取桶状态前完成鉴权,未授权主体一律收到
403 AccessDenied。
- 旧行为:桶不存在时返回
- 清除保留期(空
<Retention/>)时s3:object-lock-remaining-retention-days求值为"0"(SN-2026-015)- 旧行为:零值日期经
time.Duration截断与math.Abs计算后得到106,752天,导致基于s3:object-lock-remaining-retention-days的最短保留天数策略在清除保留期时失效。 - 新行为:清除请求将该条件键求值为
"0",受最短保留天数策略约束的主体无法通过直接清除保留期绕过最小天数限制。
- 旧行为:零值日期经
- 非复制请求(包括
root)不能再通过?versionId=原地覆写对象版本(SN-2026-016)- 旧行为:任何拥有
s3:PutObject的调用者(含root)带?versionId=<id>发送PUT、CopyObject或NewMultipartUpload会原地改写该版本。 - 新行为:不带可信复制标记的请求携带目标
versionId时一律在putOpts返回403 AccessDenied。标准 S3 协议本就不存在公开的按版本号覆写接口。
- 旧行为:任何拥有
- 向 SILO 目标执行
batch replicate时,目标端凭据必须持有s3:ReplicateObject(SN-2026-016)- 旧行为:若跨集群
batch replicate配置的目标端凭据只有s3:PutObject而无s3:ReplicateObject,目标端虽然剥离了内部复制头,但putOpts仍接受了 URL 中的?versionId=,从而静默保留了源版本号。 - 新行为:保留源版本号必须通过目标端的复制信任校验(即目标凭据需具备
s3:ReplicateObject),否则目标端返回403 AccessDenied,与桶级复制(Bucket Replication)的权限要求一致。
- 旧行为:若跨集群
- 对象锁桶中受锁目录对象(
dir/)在锁到期前不可被重复PUT覆写(SN-2026-016)- 旧行为:目录对象强制共用
null版本且跳过了存量锁检查,重复PUT dir/会静默覆盖并重置其保留期。 - 新行为:当对象锁桶设置了默认保留期、或有人对
dir/(versionId=null)设置了保留期/法务保留时,在该锁到期前再次写入dir/会返回400 InvalidRequest(ObjectLocked)。
- 旧行为:目录对象强制共用
- 独立跟踪的相邻议题(不并入本次补丁)
PostRestoreObjectHandler(归档对象恢复)先读快照再调用CopyObject回写完整元数据映射的并发竞态属于归档恢复路径的独立问题,另行立项处理。
6. 候选方案对抗评测与最终取舍
在设计评审期间,我们针对写路径防护评估并实际构造测试检验了以下四套候选方案:
| 评估维度 | 候选一:仅保留 HTTP 入口与预检守卫 (已否决) |
候选二:凭分段上传“服务端生成版本标记”跳过写锁 UUID 查重 (已否决) |
候选三:fe3935476 完整写锁复检(无预检 Fail-Closed) |
最终落地方案:fe3935476 完整写锁复检 + 预检 Fail-Closed |
|---|---|---|---|---|
SN-2026-015 保留期鉴权(F1 / F2 / F3 / 清锁 0 天计算) |
通过 | 通过 | 通过 | 通过 |
慢速请求体 PUT dir/ 竞态覆写 COMPLIANCE(TestPlanReviewSlowDirectoryPut) |
失败(P1):暂停请求体即可覆写已提交的 COMPLIANCE 锁 |
通过 | 通过 | 通过 |
升级遗留或复制发起的 uploadId 覆写(TestPlanReviewLegacyMPUCompliance) |
失败(P1):CompleteMultipartUpload 不经 putOpts,直接覆写 COMPLIANCE 版本 |
通过 | 通过 | 通过 |
旧上传状态恢复后与已复制落盘的同 UUID COMPLIANCE 版本碰撞(TestRecoveredMultipartUploadAfterReplica) |
失败(P1) | 失败(P1):生成标记只能证明初始化时的来源,不能证明完成时该版本仍不存在 | 通过(写锁内无条件按 fi.VersionID 查重) |
通过(写锁内无条件按 fi.VersionID 查重) |
预检阶段读仲裁失败时的行为(TestPlanReviewPreflightReadFailure) |
失败(P1):预检忽略读错误并覆写对象 | 通过 | 写锁内拦截,但预检忽略读错误并继续接收请求体 | 通过(预检即刻 Fail-Closed 拒绝,不进入存储写入) |
| 分段上传完成阶段查询开销 | 无额外查询 | 新上传跳过查询(但引入恢复重放漏洞) | 每次完成在写锁内执行一次 getObjectInfo |
保持写锁内 getObjectInfo 严格查重;性能优化留待后续单独设计验证 |
最终结论
采用**fe3935476 完整写锁防线 + 预检 Fail-Closed 加固**作为最终落地实现,并将慢速目录写入(TestPlanReviewSlowDirectoryPut)、预检读故障(TestPlanReviewPreflightReadFailure)、遗留分段上传(TestPlanReviewLegacyMPUCompliance)及恢复后分段上传碰撞(TestRecoveredMultipartUploadAfterReplica)四组对抗复现测试全部纳入 cmd/object-retention-overwrite_test.go 回归测试集。