跳转到主要内容

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

返回本页常规视图.

设计归档

SILO 的产品需求、实现决策、兼容性评审与发布准备记录。

这里归档 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 个字节就来自这里:

// crypto/tls/defaults.go,Go 1.27.1
func defaultSupportedSignatureAlgorithms() []SignatureScheme {
    return []SignatureScheme{
        MLDSA44,        // 0x0904
        MLDSA65,        // 0x0905
        MLDSA87,        // 0x0906
        PSSWithSHA256,
        ECDSAWithP256AndSHA256,
        ...

这些编号只在 TLS 1.3 下定义。isDisabledSignatureAlgorithm 会在配置无法协商到 TLS 1.3 时将其剔除——如下文所述,这也是应用层对它唯一的杠杆。

这个开关本来就不管显式清单

真正把我们坑到的那处变更范围很窄,而且放在上下文里看是讲得通的。Go 1.27 发布说明原文:

Post-quantum hybrid key exchanges can now be explicitly enabled in Config.CurvePreferences even if the tlsmlkem=0 or tlssecpmlkem=0 GODEBUG options are used. Those options were always meant to only apply to the default set used when Config.CurvePreferences is nil.

(后量子混合密钥交换现在可以在 Config.CurvePreferences 中显式启用, 即使设置了 tlsmlkem=0 或 tlssecpmlkem=0。这些选项本来就只适用于 Config.CurvePreferences 为 nil 时所用的默认集合。)

标准库自 Go 1.24 起就把两条路径写成二选一:

// CurvePreferences ... If empty, the default will be used.
//
// From Go 1.24, the default includes the [X25519MLKEM768] hybrid
// post-quantum key exchange. To disable it, set CurvePreferences
// explicitly or use the GODEBUG=tlsmlkem=0 environment variable.

代码里就是一个分支:给了显式清单就原样使用,只有为空时才查默认集合,而 GODEBUG 住在默认那条路上。

// crypto/tls/common.go,Go 1.27.1
func (c *Config) supportsCurve(version uint16, x CurveID) bool {
    if c != nil && len(c.CurvePreferences) != 0 {
        if !slices.Contains(c.CurvePreferences, x) { return false }   // 只看你的清单
        ...
    } else {
        if !defaultCurveEnabled(x) { return false }                   // 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 重新编译确实能恢复到正常版本的那份握手,上游的报告也确认降级有效。 但它仍然是错误的手段,有五条理由。

  1. 用整个产品的工具链,去替别人的缺陷买单。 而那台设备的厂商已有修复。
  2. 它有保质期。 Go 只维护最近两个版本。Go 1.28 一发布, 1.26 构建就运行在不再收安全修复的标准库上,同一个决定会带着更差的选项回来。
  3. 上游不会回头。 Go 已明确拒绝加开关,Chrome 还准备对同一个扩展做 GREASE。 回退是拖延,不是解决。
  4. 这不是换个编译器那么简单。 Server、Console、mcli 和 silo-pkg 都声明 go 1.27.1;用 Go 1.26.7 编译当前源码会被模块要求直接拒绝, 因此回退是跨四个仓库的协同变更,还要处理已经要求 1.27 的传递依赖。
  5. 它只覆盖一条分支。 如果重置来自分支 C 的 HTTP 层规则,工具链根本不相关。

还有一个相邻的想法同样无效:保持用 1.27 编译,只把 go.mod 的 go 指令降下去。 该机制只管有 GODEBUG 的行为,而 ML-DSA 的广告没有开关—— 这样做只会改掉 macOS 根证书之类的无关默认值,握手一个字节都不会变。

回滚发布镜像是另一件事,而且是合理的。 受影响的运维在等待设备补丁期间 停留在 20260804,是稳妥的应急处置;产品为同样的理由冻住编译器,则不是。

由此确立的需求

  1. 为 discovery 与 JWKS 抓取加上分阶段诊断。 通过 httptrace 记录 连接建立 → 握手开始 → 握手完成(附协商到的版本、套件、group)→ 请求写出 → 首个响应字节, 并在返回的错误中标明最后到达的阶段。无需配置、不改协议行为, 且可以用一条日志代替抓包来区分 A、B、C 三条分支。这是价值最高的一项。
  2. 显式的出站 TLS 兼容档位,至少覆盖身份提供方:经典曲线,以及可选的 TLS 1.2 上限。 必须 opt-in、启动时打出醒目警告,并在 mc admin config 与支持包中可见。 MINIO_API_SECURE_CIPHERS 是既有先例。这是分支 B 唯一的进程内解法; 既然 Go 已拒绝提供签名算法开关,它就是必须项,而不是备选项。
  3. 启动健壮性:为 discovery 与 JWKS 抓取加总期限与 context 取消; 并就就绪检查是否应当反映身份状态做出决定——目前它并不反映。
  4. 受支持的诊断子命令,由调查用探针演化而来:握手分阶段、协商参数, 以及 classical / TLS 1.2 / User-Agent 对照,使运维无需我们介入即可定位这类故障。
  5. 清理剩余的写死密码套件,至少让出站路径拥有监听器已有的那个开关。

有两个选项被明确否决。发布一个降低了的默认值——在 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 以补丁合并为依据关闭,而不是以报告人复测为准。

根子上的定性错误才是教训:这次改动被当作正确性修复(恢复标准库默认值), 而它同时是一次兼容契约变更——其效果取决于运维的动作。 定性正确的话,它会走发布门禁与用户沟通流程,而不是只走文档流程。

由此确立三条门槛,适用于今后所有同类改动:

  1. 任何「效果取决于运维动作」的修复,都要在发布说明和升级检查清单中列为 required action,与协调升级类条目同等待遇。
  2. 外部用户报告的 issue,只有在报告人确认复测通过后才能关闭。 补丁合并只改变状态标签,不改变 issue 状态。
  3. 修复无法完整解决所报症状时,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 内:先锁外比较、再锁内写入,仍会使用过期状态;改成按字段加锁,也会让整条记录的读改写互相覆盖。

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

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

这使一次本地纠正能超过已保存的未来字段时间。带来源时间的 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 级别和按原因去重的检查通过,不能把二者误写成已经分别演示过旧码失败。

仍需保留的运行边界

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

相关记录:tags · metadata · HTTP · audit

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 头路径也是同样的行为。

修复只确立一条不变式:

普通签名与预签名 SigV4 中,x-amz-* 操作头必须被签名覆盖。
唯一豁免 X-Amz-Content-Sha256,其有效值另行绑定和校验;streaming seed 不在此覆盖内。

这与 AWS S3 一致——AWS 对同一请求返回 AccessDenied(“There were headers present in the request which were not signed”)。受影响的签名代码继承自上游 minio/minio,已发布的 SILO 基线尚未包含本修复;源码谱系本身不能证明所有上游构建或其他分叉的状态。

缺陷:覆盖缺口

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

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

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

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

溯源

这个缺口继承自上游 MinIO,并非 SILO 引入。cmd/signature-v4-utils.go 里的 SigV4 验签器、以及 cmd/signature-v4.go 里的 Authorization 头路径 doesSignatureMatch,都是可追溯到 2016 年的原始 MinIO 代码;cmd/api-router.go 里由头驱动的 CopyObject 派发可追溯到 2019 年。唯一遍历到达头的例程 checkMetaHeaders,是上游在 2023-07-27 通过 minio/minio#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-go CopyObject、带用户元数据与标签的 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 和签名父边界。请安排维护窗口:

  1. 暂停 IAM 修改,隔离状态未知的离线站点和备份。
  2. 备份各站点完整 IAM 存储及所需加密材料;活记录 admin 导出不包含删除历史,不能替代完整备份。
  3. 停止每个后端的全部节点,更换二进制并用新版本启动。所有参与站点升级完成后才能依赖新撤销语义。
  4. 检查 IAM 加载、站点复制错误和撤销收敛,验证代表性的旧凭证被拒绝、新签发凭证有效。
  5. 显式处理升级前已丢失的撤销历史:在仍有旧记录的站点删除它们,按批准状态重建旧离线 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,或者仅恢复活记录导出,都会丢掉阻止重放的历史。

契约以版本化源码和指标定义为依据。本文取代原服务端仓库的 IAM 设计记录,不是生产恢复验收证书。

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 状态。最终条件是:

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

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

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

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

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

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

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

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

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

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

选定的最小改动

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

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

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

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

一个 token 无法取消一组任务

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

与 Fable 的评审和复杂度取舍

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

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

终审还接受了两个实现边界:罕见的 resync start 管理操作在状态锁内完成配置读写,与现有终态保存方式一致;活动注册表复用包含 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 下可复跑:

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

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

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

后续维护与发布判断

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

本次代码结论是 GO,交付事实是主干合并及 CI 通过。正式发布仍需另行选定 tag,验证软件包与镜像,并确认实际部署包含修复。既有 MRF scanner 恢复时延、仍未进入 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):

  1. 每次标签变更生成一个修订。 PUT/DELETE tagging handler 无条件打上单一 UTC RFC3339Nano 修订,与复制是否选中该对象无关。存储层在写锁内强制单调:不严格更新的本地修订被推进到 stored+1 ns;多池后端计算一个严格超过所有池副本的统一值。
  2. 发送端传输墓碑。 空值携带其已记录修订;空且无修订不伪造;畸形的已记录时间戳 fail-closed,不做静默修复。
  3. 接收端接受墓碑。 复制 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 项检查全部通过。这些确立了受测场景的排序行为,不确立真实时钟偏差下的多站点调度、跨区域故障切换,或只升级一对复制端中一端的部署行为。

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

相关记录:tags · metadata · HTTP · audit

11 - SILO Server 20260903 发布前复审

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

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

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

为什么需要第二轮审查

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

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

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

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

分领域复审结果

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

定义候选的四条不变量

信任只在鉴权后推导一次

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

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

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

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

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

共享记录只有一个写边界

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

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

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

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

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

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

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

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

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

Object Lock 看语义,不看文本

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

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

复杂度审计

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

因保护已复现故障而保留

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

删除或收窄的复杂度

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

刻意没有引入

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

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

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

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

证据

完整验收树

完整本地验收对应 ebac0ca73bbf251b070bb6df4d8005015841f901:

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

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

验收后的候选

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

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

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

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

代码结论:GO

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

生产结论:有条件 GO

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

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

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

部署特定停止条件

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

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

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

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

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

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

值得记住的运维结论:admin 端点就绪不等于数据就绪。 原始固定提交记录测了三个二进制: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 个对照通过;这不是本次文档修改重新运行的结果。升级摘要见组件版本矩阵;姊妹修复见复制标签排序。

相关记录:tags · metadata · HTTP · audit

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 设置的任何绝对截止时间——慢速读取者只要每个空闲窗口发一个字节,就能无限期占住连接。

两个独立缺陷

  1. 连接层中和了绝对截止时间。 DeadlineConn 包装器的读路径在每次部分读取前重置 socket 截止时间,使 Go 服务器设置的读头截止时间失效。直接 TCP 基线单独证明了这一点:头部限 100 ms、空闲窗 2 s 时,400 ms 才完成的头部被接受。
  2. 配置从未到达服务器。 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 握手窗口;它不是上传/下载的总时长限制)见组件版本矩阵。

相关记录:tags · metadata · HTTP · audit

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 组件。

契约如下:

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

不新增 Console 或 S3 路由,也不扩大后端 inline MIME 白名单。但交付修改了 Console 响应语义:零字节对象的 Range 请求返回空 200;不可满足的范围返回 416 与 Content-Range: bytes */N;对象 JSON 总是输出 size。见 Console 2.2.0。

当前状况

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

前端预览联合类型只有:

image | pdf | audio | video | none

扩展名表包含媒体格式,却没有 .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 仍然是有价值的纵深防御,但都不能替代核心不变量:

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

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

产品契约

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

用户应该能够:

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

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

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

目标与非目标

目标

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

非目标

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

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

资格判定契约

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

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

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

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

第二阶段:文本 fallback

只有旧结果为 none 时:

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

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

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

  4. 精确匹配:

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

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

以下矩阵是强制契约:

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

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

资源契约

二进制上限定义为:

MAX_TEXT_PREVIEW_BYTES = 1,048,576

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

已知大小

  • 选中版本的已知大小超过上限时,首次不请求正文;显式 Retry 可绕过可能过期的列表大小,但仍保持有界 Range 和字节上限;
  • 已知大小为零仍走有界请求,空响应显示空文件状态;
  • 已知大小不超过上限时,开始有界请求;
  • 缺失大小不等于零,必须进入有界未知大小路径。

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

有界请求

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

Range: bytes=0-1048576

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

客户端必须:

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

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

请求身份与取消

预览请求身份是:

bucket + object name + version ID

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

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

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

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

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

编码与内容保真

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

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

要求:

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

不支持编码状态应解释:

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

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

安全渲染器

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

<pre>{content}</pre>

禁止:

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

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

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

UI 状态与权限

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

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

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

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

弹窗必须区分:

状态 必要表现
Loading 可访问 busy 状态,不显示旧文本。
Success 可滚动原文和 Download。
Empty 明确“文件为空”。
Too large 对象大小、1 MiB 上限、Download;已知超限时首次正文请求数为零;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 改动:

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

预计保持不变:

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

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

被否决的方案

继续禁用文本预览

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

复用同源 iframe

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

现在新增后端预览 API

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

显示大对象前 1 MiB

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

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

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

自动格式化 JSON

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

引入 Monaco 或其他代码编辑器

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

验收与测试计划

分类矩阵

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

资源测试

覆盖:

  • 0 字节;
  • 1 字节;
  • 正好 1,048,576 字节;
  • 1,048,577 字节;
  • 已知超限时首次正文请求数为零,以及有界 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。

交付分阶段进行:

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

这些是不同状态:

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

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

利弊取舍

最终方案选择:

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

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

审阅记录

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

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

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

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

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

当前 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 配置既可以保存完整连接串,也可以使用五个离散字段:

host
port
username
password
database

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

notify_postgres  -> connection_string
notify_mysql     -> dsn_string

这不是新方向。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 是否存在,不看值是不是空。因此两种旧来源都会失败:

旧完整连接串 -> 规范连接串 + 五个空的未知 key -> 拒绝
旧离散字段   -> 空规范连接串 + 五个有值的未知 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,下面这条合法输入中就出现了一个看似新的顶层字段:

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

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

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

产品判断

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

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

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

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

目标

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

非目标

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

功能需求

当前配置

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

旧配置迁移

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

推荐错误形式:

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

操作者修复路径

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

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

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

可用性权衡

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

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

安全要求

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

备选方案

注册并解析离散字段

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

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

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

只跳过不支持的 target

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

修改全局通知 fail-fast 行为

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

删除数据库通知 target

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

实现范围

服务端改动应保持狭窄:

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

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

验收标准

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

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

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

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

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

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

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

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

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

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

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

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

  12. 以下验证全部通过:

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

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

实现结果

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

  • 两个旧数据库 setter 只输出 connection_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,由 x509sslcertoverrideplatform GODEBUG 默认值决定,而它跟随主模块的 go 指令:go 1.26 模块在 macOS 上忽略这两个变量(平台库优先),go 1.27 模块遵循——且以消费应用的指令为准,库模块更旧也不妨碍新行为。macOS 上的运维者应知道:设置任一变量都会用给定文件/目录整体替换 Keychain 信任;过期或不完整的路径会破坏 Keychain 本可接受的链,取消设置即可恢复。
  • 并非一切都变了。 TLS 版本、密码套件、证书与主机名校验、代理处理、HTTP/2 选择均不受影响。标准库 drain 上限(256 KiB / 50 ms)等已审计的 Go 1.27 变更无 SILO 依赖。Go 1.27 二进制要求 macOS 13 或更新。降级不受支持:模块图在 Server、Console、mc 三处都要求 Go ≥ 1.27.1。

为什么撤回了 OIDC 专用补丁

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

按阶段诊断 discovery 故障

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

按连接死在哪一层区分:

  • TLS ClientHello 之后立即 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)

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

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

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

修复建立了两个不变量:

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

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

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

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

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

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

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

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

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

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

故障 B:marker header 变成了权威

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

X-Minio-Source-Replication-Request: true

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

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

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

最终设计

一个精确 marker,两级信任

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

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

决定 必须满足 可以启用的语义
trusted 原始请求完成认证;非匿名主体;精确 marker;目标资源上具有 s3: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。

因此顺序是硬约束:

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

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

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

Replica status 不是普通用户 metadata

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

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

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

对象锁接收显式决定

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

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

真实复制 wire 矩阵

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

操作 Marker 本请求携带 REPLICA 接收端决定
普通对象复制 PutObject 是 是 replicaTrusted
复制 NewMultipartUpload 是 是 保存可信 MPU replica provenance
复制 PutObjectPart 是 否 trusted;只有已存 MPU 状态为 REPLICA 才获得 replicaTrusted
复制 CompleteMultipartUpload 是 否 trusted;保留 source ETag/MTime、actual size 与加密 checksum
CopyObject metadata replication 是 是 replicaTrusted
复制 RemoveObject 是 是 具有 s3: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 产生两个可能互相矛盾的权威,原漏洞类别仍然存在

实现边界

最终改动按层组织:

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

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

验证与对抗审查

回归覆盖包括:

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

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

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

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

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

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

兼容性与运维影响

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

残余风险与后续

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

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

  • Replication validity probe 现在会校验目标凭据所需的复制权限,并把合成的校验 key 放在规则前缀之下(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 用同一个路由处理两个方向的状态变化:

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

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

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

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

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

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

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

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

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

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

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

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

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

设计目标与非目标

设计目标

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

非目标

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

讨论过但没有采用的方案

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

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

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

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

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

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

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

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

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

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

先校验 status,再进行鉴权

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

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

最终实现

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

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

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

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

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

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

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

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

为什么这个修复是安全的

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

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

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

测试设计

纯 action 映射

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

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

四向 IAM 鉴权矩阵

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

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

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

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

修复与验证记录

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

本地验证通过:

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

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

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

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

最小权限策略示例

只能禁用的操作员

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

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

只能启用的操作员

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

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

组状态权限善后

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

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

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

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

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

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

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

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

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

兼容性与迁移

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

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

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

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

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

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

上游处置

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

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

交付状态

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

结论

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

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

本页的 enable/disable 权限修复与后续 admin:ChangeMyPassword 拆分是不同改动。升级九月候选前须按密码迁移指南为原有自助改密限制保留配对 Deny。

20 - 配置环境文件不是 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 可以从文件加载启动环境变量:

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

解析器接受如下写法:

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

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

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

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

为什么这是真实兼容回归

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

SILO 命名配置 target 的结构是:

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

例如:

MINIO_NOTIFY_WEBHOOK_ENABLE_my-hook
MINIO_NOTIFY_WEBHOOK_ENDPOINT_my-hook

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

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

文件语法

行与注释

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

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

键名

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

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

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

值与引号

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

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

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

失败与保密契约

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

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

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

回归矩阵

提交中的测试覆盖:

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

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

兼容性与交付

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

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

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

解析契约已进入 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 等敏感配置的预期值。

21 - 两把 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 时,旧流程是:

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

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

可观察故障

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

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

修复前:

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

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

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

为什么不能修改全局 decrypter

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

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

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

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

最终实现

目标响应视图

Handler clone 请求头并精确删除:

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

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

解密一次,投影两次

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

修复后:

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

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

回归矩阵

测试覆盖:

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

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

兼容性与运维影响

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

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

结论

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

22 - 为什么 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 会被拒绝,不再静默规范化。

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

历史范围: 本页记录 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 内嵌的正是这一版本。

复现步骤:

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

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

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

实际发生了什么

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

Prefix 没有对象大小

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

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

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

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

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

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

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

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

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

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

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

完整链路如下:

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

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

产品契约

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

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

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

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

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

目标与非目标

目标

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

非目标

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

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

最终决策

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

D1. 只使用有效总量计算

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

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

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

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

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

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

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

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

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

D2. 未知总量保持 indeterminate

XHR handler 只 dispatch 真实百分比:

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

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

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

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

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

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

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

item.waitingForFile = false;

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

XHR 边界还需要一条事件顺序守卫。abort() 会先触发 readystatechange(DONE, status=0),随后才触发 abort 事件;如果不提前返回,通用 DONE 分支会先把请求标成失败,onabort 再把它标成取消。DONE/status zero 因此交给专用的 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 与生产前端构建必须通过。

验收标准

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

测试计划

纯计算矩阵

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

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

状态测试

直接覆盖状态转换契约:

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

浏览器回归

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

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

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

实现边界

预计 Console 变更:

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

预计保持不变:

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

交付与回滚

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

交付顺序:

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

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

完成定义

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

后续工作

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

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

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

后续实现与原设计的差异

Console 2.2.0 的整合还把 size 改为必输出的 JSON 字段,并修复 ZIP 错误传播:读取失败不再静默跳过条目;响应尚未开始可返回 500,开始后以流中断暴露失败。因此上文“不改 API/资源管理”的约束仅适用于最初进度计算补丁,不能概括整个发布。

当前单目录下载会交给浏览器,Console 的完成行表示交接,不证明字节已经全部下载;跟踪和取消应在浏览器下载管理器中进行。多选 ZIP 的文件 writer 与原生交接路径见 Console 2.4.1。大小归一化仍可兼容旧响应,但不再说明当前模型缺少零值字段。

24 - 列举不能丢掉仍有仲裁的 null 版本

发布更新,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 做有界的诊断采集,再据此构造确定性回归,然后才裁决契约是否改成让请求失败或返回三态结果。在此之前,滚动重启期间不要运行会删除源端列表中缺失对象的同步工具,集群稳定后重新列举。

25 - 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 给出的最小复现是在不存在的桶上调用:

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

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

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

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

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

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

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

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

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

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

为什么要修复

S3 契约明确要求 NoSuchBucket

AWS 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 原来都执行:

return entries, io.EOF

改为:

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

helper 的契约只有两类结果:

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

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

为什么 helper 放在这里

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

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

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

不引入缓存

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

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

测试与评审证据

对象层契约

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

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

每组都覆盖 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 中重复三遍。

本地质量门槛

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

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

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

带 DCO sign-off 的 PR head e9c5340be 随后通过八项远端检查: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 边界:

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

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

讨论过的替代方案

保持上游现状

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

恢复通用 checkBucketExist

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

只修 Prefix="/"

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

增加 bucket-existence cache

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

复杂度与成本收益

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

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

接受决策与后续门槛

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

实际执行顺序是:

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

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

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

结论

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

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

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

26 - 只读 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 中重新打开进度条。

命令与范围

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

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

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

只读数据路径

对每个候选对象,MCLI:

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

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

结果与退出码契约

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

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

summary 携带 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 上限。

27 - 可选校验和,强制失败:修复 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 采用以下契约:

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

一句话概括:

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

我们如何发现问题

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

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

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

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

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

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

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

AWS 契约到底是什么

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

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

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

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

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

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

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

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

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

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

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

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

因此内部不变量是:

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

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

我们研究过的方案

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

商业版给了什么线索

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

静态分析显示,AIStor 已经:

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

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

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

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

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

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

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

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

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

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

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

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

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

最终实现

独立的逻辑 checksum reader

PutObjReader 原本有两个概念:

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

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

checksumReader *hash.Reader

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

PutObjReader 同时提供未导出的 accessor:

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

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

在 transform 之前准备 hasher

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

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

普通 UploadPart:

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

UploadPartCopy:

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

对象层仍然是最终权威

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

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

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

CopyPart 响应

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

ChecksumCRC32
ChecksumCRC32C
ChecksumCRC64NVME
ChecksumSHA1
ChecksumSHA256

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

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

修复后数据流变成:

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

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

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

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

兼容基线 blocker

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

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

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

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

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

exported=9021
Silo rebrand compatibility baseline is unchanged

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

golangci-lint 尚未在本地执行;它仍是远端 go.yml 的发布前检查之一。go 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。

本地验证包括:

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

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

成本、风险与发布边界

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

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

原先的本地实现后来已合并并随 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 之前安装,并由拥有存储不变量的对象层复核和持久化。

沉淀下来的经验

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

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

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

28 - 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 一致性。

29 - ListMultipartUploads:实现、升级契约与历史设计

2026-09-17 发布更新: 本文记录的九月源码修复已随 Server 20260916 发布;协调升级、可选功能启用条件和剩余限制仍按各节执行。下方带日期的源码状态与验证记录保留当时的范围。

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

9 月 16 日实现与升级契约

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

列表与取消语义

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

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

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

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

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

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

协调升级

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

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

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

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

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

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

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

报告包含 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 基准。容量和前台负载验收仍未完成,不能把有界扫描宣传为大规模性能修复。

历史分析(2026-08-30): 以下“当前”“建议”“门禁”等用语描述当时的代码与提案;现行实现和未完成边界以上方 9 月 16 日章节为准。旧提案中的能力广播、预计一天排空等不属于当前保证。

先用最简单的话说明问题

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

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

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

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

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

决策摘要

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

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

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

因此建议的方向是:

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

问题描述与证据来源

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

S3 正式契约

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

  • prefix 选择所有 key 以该字符串开头的上传;
  • delimiter 把匹配的 key 汇总成 CommonPrefixes;
  • max-uploads 限制单页数量,文档规定上限为 1,000;
  • key-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 路径:

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

关键位置包括:

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

独立复现与对抗性审查

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

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

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

当前代码到底做了什么

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

没有 prefix 时,pool 层从 mpCache 返回该 bucket 的全部 MultipartInfo,并且只按发起时间排序。它不应用 max-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 命名空间是平的:

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

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

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

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

复杂度评估

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

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

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

兼容性与运维影响

Wire 行为变化

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

  • prefix=foo 将匹配 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:

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

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

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

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

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

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

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

增加一个命名收敛的内部 helper,例如 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 对未知内部元数据的处理。

交付关卡

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

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

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

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

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

平衡后的决策是:

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

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