这是本节的多页打印视图。 .
设计归档
- 1: CopyObject Checksum 必须覆盖逻辑对象字节
- 2: 只预览文本,绝不执行:SILO Console 文本预览 PRD
- 3: 数据库通知统一连接串:#53 的兼容性边界
- 4: 总量未知时,进度条应该说什么
这里归档 SILO 维护决策背后的完整思考:要解决的问题、兼容性边界、被否决的方案、实现要求,以及进入发布版本前必须取得的验证证据。
1 - CopyObject Checksum 必须覆盖逻辑对象字节
本文是 SILO #63 的最终设计与验证归档。
最终决策: 所有由服务器生成的 CopyObject checksum 都必须在压缩和加密之前,基于目标对象的逻辑字节计算;逻辑 reader 与存储 reader 分离保存;如果读到 EOF 后仍拿不到预期 checksum,则拒绝发布对象。
实现: PR #66,合并提交为 c0e715977。
相关修复: transform state 保真 #67 / PR #69,CopyObjectResult checksum 字段 #68 / PR #70。
上游客户端: minio-go PR #2295。
发布边界: 这些修改已经合并到源码,但本文不声称任何具体 release tag、RPM、DEB、APK、归档或容器镜像已经包含它们。
一句话决策
checksum 不是“在写入链路上随便找个位置算出的 digest”。它一定是某段明确定义字节序列的函数。对 S3 CopyObject 而言,这段字节必须是客户端最终读到的逻辑对象,而不是 SILO 私有的压缩或加密表示。
因此最终采用的流水线是:
整份设计都可以从这个顺序推出。
背景:一个对象存在多个完整性域
SILO 中有若干值都会被口语化地称作 checksum,但它们保护的契约并不相同。
| 值 | 字节域 | 用途 |
|---|---|---|
| S3 additional checksum | 逻辑对象字节 | 通过 HEAD、GET、attributes 和复制响应提供客户端可见的端到端完整性 |
| ETag | 普通单 part、兼容未加密场景下代表逻辑内容;其他场景遵循各自协议语义 | 对象身份与条件请求兼容性 |
| 存储 reader 记账 | 压缩或加密后的写入流 | 在写入路径中传递 size、stream 与 ETag 委托 |
| 纠删码 bitrot checksum | 盘上纠删码 shard | 检测 SILO 物理表示损坏 |
| 加密认证 | 密文 framing 与密钥 | 检测篡改并认证加密存储 |
| 压缩 index | S2 存储流偏移 | 支持大压缩对象的高效读取 |
它们可以在同一遍流式写入中计算,却绝不能相互替代。存储流 checksum 完全可能在数学上正确,同时作为 S3 对象 checksum 完全错误。
Amazon S3 明确规定 CopyObject 会产生目标 checksum;multipart 来源在一次 CopyObject 后会成为 full-object checksum。算法可能由请求显式选择、从来源继承,或在来源没有 checksum 时使用默认算法。无论哪一种,结果描述的都是复制后的对象,而不是供应商私有的存储编码。
#46 如何暴露 #63
这个缺陷是在修复 multipart checksum 兼容性 #46 时发现的。
#46 为 UploadPart 与 UploadPartCopy 建立了三条内部规则:
- 为逻辑明文 checksum 保留专用 reader。
- fallback server hasher 必须由 handler 在压缩或加密消费数据前安装。
- 对象层负责验证并持久化完成后的结果,但不在对象层决定字节域。
由此引入的私有字段 checksumReader 刻意与活动 Reader、历史 rawReader 分开。WithEncryption 可以替换活动存储 reader,却不能替换逻辑 checksum reader。
沿着普通 CopyObject 检查后,我们发现同一个概念风险出现在另一条 handler 中:代码先创建 newS2CompressReader,把它的输出包装成 srcInfo.Reader,之后才对这个 reader 调用 AddServerSideChecksumHasher。变量名掩盖了关键事实:此时的 srcInfo.Reader 已经代表存储流,不一定代表 S3 对象流。
#46 刻意不修改 CopyObject。把 #63 单独拆出,意味着 P0 multipart 修复可以独立审查、发布或回滚,不必绑定另一套 API 与测试矩阵。
故障模型
旧顺序
旧流程的关键部分是:
从存储 writer 的角度看,这个 checksum 并没有漏数据,也不一定损坏;它只是覆盖了错误但完整的流。
静态假设与动态结果
最初 Issue 提出了两种可能:
- hasher 覆盖压缩数据;
- 压缩 goroutine 在 hasher 安装前已经消费部分逻辑输入,导致漏掉前缀。
API 复现确认了第一种,没有确认第二种。hasher 实际安装在压缩器输出侧 reader 上,因此会从该输出 reader 的起点观察完整 transformed stream。压缩输入侧提前消费的字节,并不是输出 hash reader 已经消费的字节。
这个区别很重要:根因不是“偶发 race,因此加一把锁即可”,而是确定性的数据域错误。
可重复复现
对于永久测试使用的同一份 payload,未修复树保存的是:
前者是合法 CRC32,所以普通 metadata 合法性检查无法发现。只有对下载后的逻辑对象独立计算,才会暴露不一致。
CRC32、CRC32C、CRC64NVME、SHA1、SHA256 在压缩目标上都会失败。当压缩与目标加密组合时,S2 会为加密流加入随机 padding,错误 checksum 不但错误,而且同一份逻辑复制多次可能得到不同结果。
需求与非目标
修复必须同时满足:
- 字节域正确。 服务器生成的 checksum 精确覆盖目标逻辑字节。
- 单遍流式处理。 CopyObject 不能增加第二遍对象读取。
- 与变换无关。 压缩和加密不能改变逻辑 checksum。
- 客户端兼容。 既有客户端 checksum 校验与算法选择语义不变。
- multipart 来源正确。 multipart composite 来源复制为单对象后,必须按 base algorithm 重算 full-object checksum。
- 默认行为正确。 来源没有 checksum 时,目标仍获得当前基线的 S3 兼容默认 CRC64NVME。
- ETag 不回归。 移动 checksum reader 不能悄悄改变 CopyObject ETag 契约。
- fail closed。 内部调用者声明要生成 checksum 却没有生成时,不能返回成功并缺少完整性 metadata。
- 格式兼容。 继续使用既有 checksum metadata 表示。
- 回滚边界小。 不把响应 schema、federation 或 metadata-only transform 缺陷混入核心放置修复。
#63 明确不负责:
- 新增 checksum 算法;
- 修改盘上 checksum 编码;
- 扫描或回填旧对象;
- 修复 legacy federated UploadPartCopy;
- 增加 CopyObjectResult XML 字段;
- 修改 MCLI 或 Console 行为。
备选方案与权衡
| 方案 | 吸引力 | 否决原因 |
|---|---|---|
| 在对象层安装 hasher | 所有调用者集中 fallback | 对象层拿到的是 handler 变换后的存储 reader,无法可靠重建逻辑字节域,而且安装时点可能过晚 |
| 对 S2 输出做 hash | 代码移动最少 | 这就是已经复现的缺陷:保护存储字节,不保护 S3 对象字节 |
| 对密文做 hash | 加密设置完成后最方便 | IV、framing、认证与 padding 让结果变成供应商私有值,且往往非确定 |
| 写完后重新读取对象 | 推理简单 | I/O 翻倍,破坏单遍流式目标,增加大对象和分层对象延迟 |
| 变换前缓存整个对象 | 顺序直观 | CopyObject 可处理大对象;整对象缓冲带来不可接受的内存与延迟 |
| 永远复制来源 checksum 值 | 避免计算 | 请求可能指定不同算法;来源可能没有 checksum;multipart composite 来源必须转为 full-object |
| 新建 CopyObject 专用 checksum 抽象 | 改动局部 | 重复 #46 已建立的不变量,未来形成两套略有差异的内部契约 |
| 在变换前复用逻辑 checksumReader | 单遍、复用既有格式、共享内部不变量 | 采用 |
选择方案不只是“改动行数最少”,而是它用最少机制把字节域契约显式化,并且能够复用。
最终设计
1. 先构造逻辑 reader
CopyObject 取得的源 GetObjectReader 已经输出逻辑源对象:盘上压缩已经解码,来源加密也已经通过授权的 source options 解开。
SILO 用已知逻辑对象大小把它包装成逻辑 hash.Reader。对压缩目标而言,这还把过去的无限长度收紧为压缩前的逻辑长度硬上限。
此时压缩 goroutine 尚未启动。
2. 决定目标 checksum 策略
既有策略保持不变:
- 请求包含
x-amz-checksum-algorithm时,计算对应 base algorithm。 - 否则检查来源 checksum。
- 来源是 full-object checksum 时,因为逻辑字节不变,可以保留该值。
- 来源是 multipart composite 时,因为 CopyObject 产生单次 full object,必须用 base algorithm 重算。
- 来源没有 checksum metadata 时,目标获得默认 CRC64NVME。
只有真正需要计算的分支才调用 AddServerSideChecksumHasher。
3. hasher 安装后再启动压缩
目标需要压缩时,逻辑 reader 被捕获为 checksumReader,并作为 newS2CompressReader 的输入。压缩器无法得到任何一个字节,除非该字节先通过逻辑 hasher。
压缩输出拥有独立的存储 hash.Reader。新的 PutObjReader 以存储 reader 为活动 reader,再通过 setChecksumReader 保存逻辑 reader 引用。
不需要新增导出方法或 package 级抽象。
4. 加密阶段继续保持分离
目标加密会包装压缩存储 reader,并可能通过 WithEncryption 替换 PutObjReader.Reader,但不会修改 checksumReader。
因此以下四种存储方式必须得到同一逻辑 checksum:
- 明文;
- 压缩;
- 加密;
- 压缩加密。
checksum metadata 本身需要保护时,既有 metadata encryption function 会在计算完成后加密序列化结果。这保护的是 metadata at rest,不会改变被 hash 的字节。
5. EOF 后完成并 fail closed
内部 hash reader 只在来源返回 EOF 后设置 ServerSideChecksumResult。压缩路径中,io.Copy 必须先排空逻辑 checksum reader,随后压缩器才能关闭 pipe。对象 writer 不可能在逻辑 reader 完成 checksum 前观察到压缩流 EOF。
对象层随后验证:
- 结果存在;
- 结果结构有效;
- base algorithm 与
WantServerSideChecksumType一致。
失败时记录内部不变量错误并中止写入。延迟纠删码清理会在唯一 metadata 发布前删除临时 shard。显式或默认要求 checksum 的操作若返回 HTTP 200 却没有 checksum,是静默正确性损失,不能作为 fallback。
6. 不改变盘上格式
验证后的 checksum 继续追加到既有 FileInfo.Checksum 表示。加密目标复用现有 metadata encrypter。HEAD、GET、GetObjectAttributes、复制 metadata 与后续 reader 仍消费同一种表示。
为什么这个设计一定成立
字节域证明
压缩器接受的每一个字节都来自 checksumReader。hasher 在压缩器构造前安装,所以 digest 输入精确等于压缩器的逻辑输入,而不是输出。
完整性证明
压缩器只有在排空逻辑输入并关闭 S2 writer 后,才能关闭输出。对象 writer 必须把输出读到 EOF 才能完成写入。checksum 在逻辑输入 EOF 时完成,而这一时刻先于存储侧可见 EOF。
pipe 自然建立 happens-before 关系,不需要额外 mutex 或旁路信号。定向 race 与随机顺序重复执行验证了实现。
ETag 证明
压缩 reader 把逻辑 reader 作为 ETag delegate。移动 S3 checksum hasher 不会把 ETag 计算迁到 S2 字节上。永久测试在兼容的未加密场景中,把最终 ETag 与逻辑对象 MD5 独立比较。
存储完整性证明
压缩后仍保留存储侧 reader,用于物理流记账与 ETag 委托;纠删码层继续为盘上 shard 写入自己的 bitrot protection。二者都没有被 S3 逻辑 checksum 取代,S3 checksum 也不会被冒充成 shard 完整性机制。
加密证明
加密 reader 在逻辑 checksum 之后消费存储流。随机 IV、framing 或 padding 无法影响 checksum。SSE-C 与 SSE-S3 测试同时覆盖仅加密、压缩加密目标;加密来源测试证明 source decryption 也发生在 hash 之前。
兼容性证明
对不压缩、不加密的目标,NewPutObjReader 会把 checksumReader 与 rawReader 初始化为同一个 reader,因此 accessor 调整在行为上不变。
补丁没有新增服务端 API,也没有新增存储 marker。生产修改只涉及三个文件,约 30 行新增、15 行删除。较大的测试文件反映兼容性矩阵,不是运行时复杂度。
对抗审查拆出的独立修复
审查刻意尝试从边界击穿方案,发现了两个真实继承缺陷,但都不属于 checksum 放置本身。
Metadata-only transform state:#67
CopyObject 在确认是否重写对象字节前,就按当前目标配置推导压缩 metadata。metadata/reference-only self-copy 因而可能给未压缩数据增加压缩标记,或从压缩数据删除标记。
版本化路径还暴露了更深边界:来源 VersionID 未解析时,metadata-only 操作可能落入 PutObject。版本化 SSE-C 密钥轮换随即会写入明文却保留加密 metadata,后续 GET 报 sio: unsupported version。
PR #69 单独修复:
- metadata/reference-only 更新保留来源 transform metadata;
- 只有真实重写字节时才改变压缩标记;
- 版本化 reference copy 使用已经解析的来源版本。
这个问题保持独立,既保护 #63 回滚边界,也避免用一个表面上的三行 guard 掩盖版本化损坏。
CopyObjectResult checksum 响应:#68
#63 完成后,对象通过 HEAD 与 GET 返回正确 checksum,但成功 CopyObject XML 仍只有 LastModified 与 ETag。
PR #70 增加当前服务端支持的五种 checksum 字段与 ChecksumType,从已提交的目标 ObjectInfo 填充,并把新增导出字段登记到 compatibility baseline。
活跃的 minio-go 已经在 UploadInfo 上拥有 checksum 字段,却会丢弃 CopyObjectResult 值。上游 PR #2295 连接这些已有字段,不新增公共 API。
验证证据
永久测试覆盖:
- CRC32、CRC32C、CRC64NVME、SHA1、SHA256;
- 显式算法与默认 CRC64NVME;
- 明文、压缩、仅加密、压缩加密目标;
- SSE-C 与 SSE-S3;
- 加密来源与压缩来源;
- 未版本化与版本化桶;
- 来源 full-object checksum 保留;
- multipart composite 来源转换为 full-object checksum;
- 原地 self-copy;
- 空数据、精确 4096 字节、4097 字节;
- 超过 8 MiB 的 S2 compression-index 路径;
- 逻辑 ETag 与逐字节正文 round trip;
- 启用 checksum mode 的 HEAD 与 GET;
- 内部 checksum 缺失与算法失配;
- 不变量失败后对象没有发布。
验证门禁包括:
同一条回归在未修复基线上为红,在修复树上为绿。
运维与发布考虑
混合服务器版本
metadata 表示没有变化,所以旧节点能够读取新节点写入的正确 checksum。但滚动升级期间,CopyObject 行为取决于实际处理请求的节点:旧节点仍可能写入错误值,新节点写入正确值。
所有承载 API 的节点都升级后,才能把 CopyObject checksum 行为视为稳定。一次本地构建成功或只升级一个节点,都不构成发布证据。
已存在对象
修复只影响此后的复制。SILO 不自动扫描或重写历史 checksum metadata,因为那意味着在没有显式 S3 操作的情况下读取并重写用户数据。
同时满足以下条件的对象值得核验:
- 由受影响版本的 CopyObject 创建;
- 当时目标 key 或内容类型命中压缩配置;
- 对象带有额外 S3 checksum。
使用 checksum mode 取回 checksum,下载逻辑对象,用同一算法独立计算,再比较 Base64 值。
修复时优先把对象复制到新 key,显式指定目标 checksum algorithm,验证完成后再替换原对象。也可以带 x-amz-metadata-directive: REPLACE 做原地复制,但未版本化桶会替换当前值,版本化桶会创建新版本。批量重写前必须确认 retention、legal hold、tag、用户 metadata、加密密钥、容量、复制与回滚要求。
合并不等于发布
服务端修复与本文已经合并,文档也已部署,但这仍不能回答“第一个包含修复的二进制版本是什么”。最终 release note 必须明确具体 tag,并独立验证归档、软件包、容器 manifest、checksum、签名、SBOM 与 provenance。
跨仓库影响
| 仓库 | 决策 |
|---|---|
| pgsty/silo | 拥有 handler、reader 链、对象层不变量、响应 schema 与测试 |
| minio/minio | 已归档上游仍保留原缺陷,无法正常提交服务端上游 PR |
| minio/minio-go | PR #2295 通过已有 UploadInfo 字段返回 CopyObject checksum;等待 maintainer 合并 |
| pgsty/silo-pkg | 无需修改:不拥有 ObjectInfo、PutObjReader 或 CopyObjectHandler |
| pgsty/mc | 无需修改:指定 –checksum 时会主动禁用 server-side copy,改用下载再上传 |
| pgsty/silo-console | 无需直接修改:通过 minio-go 透传 CopyObject,且不解释 checksum 结果 |
| silo.pgsty.com | 负责本文双语设计、发布边界与历史对象指导 |
legacy federated UploadPartCopy checksum 恢复仍由 #64 跟踪。它属于不同 API、响应契约与部署拓扑,绝不能声称已由本次工作解决。
最终结果
修复之所以小,不是因为它省略了问题,而是因为它没有发明新的 checksum 系统,只把原本隐含的职责差异显式化:
一旦二者分离,压缩、加密、ETag、纠删码与 metadata 持久化都可以继续保持流式、独立验证。这就是该方案能够解决已复现缺陷,同时不换来额外 I/O、无界缓冲、新盘上格式或第二套内部抽象的原因。
2 - 只预览文本,绝不执行:SILO Console 文本预览 PRD
状态: 设计已接受,实现待完成 · 归属: pgsty/silo-console · 跟踪: pgsty/silo#17 · 审阅: 产品、安全与前端架构三方共识
SILO Console 可以预览图片、PDF、音频和视频,却不能直接查看运维中最常见的小型日志、纯文本、JSON 与 XML。即使对象保存了完全正确的 Content-Type,前端也会在选择渲染器之前把它判为不支持。
恢复旧版浏览器原生预览很容易,却不是正确修复。对象内容由上传者控制;如果把它作为同源 HTML/XML 文档加载,一个便利功能就会变成代码执行边界。
因此最终设计给出一个更强的承诺:
SILO 只把符合条件的对象作为有界 UTF-8 文本预览,绝不让浏览器把其中的标记、MIME 或内容解释成文档。
本文固定产品边界、资源上限、安全不变量、实现形态,以及功能进入发布版本前必须取得的证据。
最终决策
第一版增加独立的 text 预览类型和 PreviewText 组件。
契约如下:
- 完整保留现有 image、PDF、audio、video 判定。
- 只有旧分类器返回
none时,才考虑文本 fallback。 - 由四种目标扩展名或四种精确被动文本 MIME 触发。
- 通过普通鉴权下载路径获取字节,不传
preview=true。 - 在应用层强制执行 1 MiB 读取硬上限。
- 只做严格 UTF-8 解码,并拒绝疑似二进制内容。
- 在可滚动
<pre>中只渲染一个 React 文本节点。 - 永不使用 iframe、HTML/XML 解析器或 HTML 注入接口。
- 要么显示完整对象,要么完全不显示;不展示截断 JSON/XML。
- 文件超限、编码非法或加载失败时,始终保留 Download。
不新增 Console API 或 S3 API,也不扩大后端 inline MIME 白名单。
当前状况
撰写本文时,SILO 当前锁定的 SILO Console v2.1.1 仍存在这个问题。
前端预览联合类型只有:
扩展名表包含媒体格式,却没有 .log、.txt、.json、.xml;MIME 分类器也不识别 text/plain、application/json、application/xml、text/xml。
运行时验证得到的分裂状态如下:
| 对象 | 前端结果 | Console 下载响应 |
|---|---|---|
.log / text/plain |
none |
inline,SAMEORIGIN |
.txt / text/plain |
none |
inline,SAMEORIGIN |
.json / application/json |
“Preview unavailable” | inline,SAMEORIGIN |
.xml / application/xml |
none |
attachment,DENY |
对象详情页判断 Preview 是否禁用时还使用了错误的与条件:有权限用户可以点开一个不支持对象,最后只看到 unavailable;另一些组合则会先提供按钮,再由服务端拒绝。
预览组件中仍残留一个通用同源 iframe fallback。按当前类型联合,这条分支实际上不可达,所以当前缺陷本身不是可利用的文本预览 XSS。但它很危险:如果只把 text 加入联合类型并让它落入旧 fallback,就会重新激活本文明确否决的同源文档加载。
根因
这是三个独立演进层之间的契约漂移。
分类契约漂移
浏览器端根据文件名和对象元数据决定资格,但封闭类型联合中根本没有文本。再正确的元数据也无法选择一个不存在的渲染器。
响应策略漂移
Console 服务端又独立判断响应能否 inline:它仍把纯文本与 JSON 视为被动安全 MIME,而 XML/HTML 保持 attachment。这个服务端决定没有映射到前端分类。
渲染器漂移
当可达预览类型已经只剩媒体时,旧通用 iframe 仍留在组件里。代码看起来保留了一项能力,类型系统却不可能再调用它。
修复必须重新对齐三层契约,同时绝不能把 MIME 元数据提升成安全边界。
为什么拒绝同源 iframe
X-Frame-Options: SAMEORIGIN 不是 sandbox。它只控制谁能嵌入响应,不限制同源 frame 中的代码能做什么。
一旦上传者控制的 HTML、XHTML、SVG 或主动 XML 被作为同源 inline 文档加载,它就可能获得 Console origin。HttpOnly Cookie 可以阻止脚本直接读取 Cookie,却不能阻止浏览器携带 Cookie 发出鉴权同源请求。只要 MIME 规则被错误放宽,存储对象就可能变成存储型应用代码。
nosniff、CSP 与 Content-Disposition 仍然是有价值的纵深防御,但都不能替代核心不变量:
产品契约
这是一个只读文本查看器,不是网页预览器,也不是在线编辑器。
用户应该能够:
- 从列表或对象详情打开小型、符合条件的对象;
- 在现有预览弹窗里阅读保留空白的源码文本;
- 使用浏览器原生选择和复制;
- 分清失败来自大小、编码、权限、对象被替换还是网络错误;
- 随时下载原始字节。
系统绝不能让用户误以为:
- 格式化后的 JSON 就是存储原文;
- 截断 XML 是完整文档;
- 替换字符本来就存在于对象;
- 不支持的编码已经被忠实解码;
- 主动 HTML/XML 经“消毒”后可以安全执行。
目标与非目标
目标
- 无需本地下载即可查看小型日志、纯文本、JSON 与 XML。
- 无论扩展名、MIME 与载荷如何,对象内容始终保持惰性。
- 把保留的响应字节与渲染文本限制在 1 MiB。
- 忠实显示存储文本,不做静默格式化。
- 列表与详情页按照相同权限和类型契约提供 Preview。
- 支持当前对象版本和显式选择的历史版本。
- 保持匿名访问和子路径部署行为。
- 先独立发布 Console,再由 SILO 精确消费该 Console 修订。
非目标
- HTML/XHTML 渲染。
- XML 解析、XSLT、外部实体与 Schema 校验。
- Markdown 渲染。
- JSON 自动格式化。
- YAML/CSV 专用行为。
- 编辑与保存。
- 语法高亮、行号、搜索、折叠、ANSI 渲染与自动链接。
- 大对象 head、tail 或截断预览。
- 有损解码,以及 GBK、UTF-16、Latin-1 等编码自动探测。
- 新增后端文本预览接口。
- 修改现有 SVG、媒体、PDF、下载、分享或存储契约。
类似 notes.md 的对象如果精确 MIME 为 text/plain,仍可能作为原始文本显示,但不会获得 Markdown 语义。
资格判定契约
资格判定刻意分为两阶段。
第一阶段:保留旧媒体结论
完全不变地运行当前 image、PDF、audio、video 分类器。只要结果不是 none,直接返回。
这样可以保留文件名与 MIME 冲突时的历史行为。
第二阶段:文本 fallback
只有旧结果为 none 时:
-
最终扩展名为
.html、.htm、.xhtml时明确拒绝; -
按大小写不敏感方式匹配最终扩展名:
.log.txt.json.xml
-
去掉参数、裁剪空白并转成小写,规范化 Content-Type;
-
精确匹配:
text/plainapplication/jsonapplication/xmltext/xml
允许扩展名或精确 MIME 任意一项命中。本版禁止 text/、子串匹配与 application/+json 等宽泛规则。
以下矩阵是强制契约:
| 文件名与 MIME | 结果 | 原因 |
|---|---|---|
report.txt + image/png |
image | 现有媒体结论优先。 |
report.json + application/pdf |
现有媒体结论优先。 | |
server.LOG + application/octet-stream |
text | 允许的扩展名,忽略大小写。 |
无扩展名 + application/json; charset=utf-8 |
text | 规范化后精确 MIME 命中。 |
page.html + text/plain |
none | 主动扩展名显式排除。 |
page.txt + text/html |
text | 扩展名命中,但 HTML 源码保持惰性文本。 |
notes.md + text/plain |
text | MIME 命中原始文本,不渲染 Markdown。 |
image.svg + image/svg+xml |
现有 image 路径 | 不进入新 text/iframe 路径。 |
文件名和 MIME 只影响产品资格,永远不能选择可执行渲染模式。
资源契约
二进制上限定义为:
正好 1 MiB 可以预览,多一个字节就不可以。
已知大小
- 选中版本的已知大小超过上限时,不请求正文;
- 已知大小为零时,显示空文件状态;
- 已知大小不超过上限时,开始有界请求;
- 缺失大小不等于零,必须进入有界未知大小路径。
因此当前从列表向弹窗传值时,不能再用 truthy fallback 把 undefined 强制变成零。
有界请求
对于小型或未知大小对象,请求:
额外一字节用于探测超限。
客户端必须:
- 在存在时检查
Content-Range与Content-Length; - 以 stream 读取响应,禁止调用
response.text()或先构造完整 Blob; - 最多保留上限加一字节;
- 观察到探测字节后立即取消;
- 服务端忽略 Range、返回 200 时仍执行同一限制;
- 只有 EOF 证明完整对象未超限后才开始渲染。
超限对象进入说明状态:显示已知大小、1 MiB 策略和 Download,不展示任何前缀片段。
请求身份与取消
预览请求身份是:
请求必须复用现有生成 API 客户端或等价的 base-path-safe helper,从而保持:
- same-origin credentials;
- 当前 Console 子路径;
version_id;- 匿名模式
X-Anonymous: 1; - 当前错误处理和权限边界。
关闭、对象变化、版本变化、bucket 变化和组件卸载都必须中止活动请求并清空旧内容。
仅依靠 abort 不够。还要使用 generation token 或失效标记,防止已经读完或解码完成的旧响应更新新的预览。
被取消的请求不是错误,不应产生错误 Toast。
编码与内容保真
第一版只支持严格 UTF-8:
要求:
- 正确处理 UTF-8 BOM,不显示 BOM;
- 保留 Unicode、emoji、TAB、LF、CRLF;
- 非法 UTF-8 直接拒绝,不插入替换字符;
- 解码后存在 NUL 时,按二进制或不支持内容拒绝;
- 不猜测其他编码;
- 不把对象正文写入日志或持久化;
- 永远保留下载原始字节的出口。
不支持编码状态应解释:
该对象不是有效的 UTF-8 文本,或包含二进制内容。请下载后检查原始字节。
JSON 与 XML 都按解码后的原始源码显示。第一版不得执行 JSON.parse 再 JSON.stringify:这会改变不安全整数、重复 key、空白、字面形式以及用户复制的文本。
安全渲染器
成功状态只渲染一个文本节点:
禁止:
- iframe、object、embed;
dangerouslySetInnerHTML、innerHTML;DOMParser或 XML parser;- Markdown/HTML 渲染;
- HTML data/blob URL;
- 按行或 token 生成大量 span;
- 自动链接、ANSI escape 与语法标记。
单个有界文本节点让 DOM 成本可预测,也让安全性质容易审计。
预格式化区域使用等宽字体、保留空白、默认不换行、独立承担横纵滚动、可键盘聚焦,并支持原生选择和复制。不换行是刻意选择:它能保留日志列对齐,也能避免一条 1 MiB 长行触发昂贵折行布局。
UI 状态与权限
只有同时满足以下条件时,Preview 才可用:
对象详情页当前的与条件错误必须修复;列表与详情页必须共享同一资格函数。
符合格式但超限的对象仍然提供 Preview。弹窗负责解释正文为何没有加载;如果直接禁用按钮,用户无法区分大小、权限和类型问题。
弹窗必须区分:
| 状态 | 必要表现 |
|---|---|
| Loading | 可访问 busy 状态,不显示旧文本。 |
| Success | 可滚动原文和 Download。 |
| Empty | 明确“文件为空”。 |
| Too large | 对象大小、1 MiB 上限、Download;已知超限时正文请求数为零。 |
| Invalid UTF-8 / binary | 独立解释和 Download。 |
| Forbidden | 权限专属提示,不保留正文。 |
| Not found / replaced | 对象变化提示,不保留正文。 |
| Network / server error | 可操作的重试/下载状态。 |
| Aborted / closed | 静默清理。 |
HTTP 错误响应正文绝不能被解码后当作对象内容展示。
所有新增用户文案都必须走现有翻译层,并同时提供中英文。内容区和控制项必须在明暗主题、窄屏宽屏下保持可用。
功能与安全要求
功能要求
- FR1: 现有媒体与 PDF 分类不变。
- FR2: 文本 fallback 严格遵守规范扩展名/MIME 矩阵。
- FR3: 不超过 1 MiB 的完整合格对象按严格 UTF-8 源码显示。
- FR4: 超限对象不显示部分内容。
- FR5: 空对象具有独立成功空状态。
- FR6: 当前版本与选定历史版本的元数据、大小和正文使用同一 version ID。
- FR7: 匿名访问与子路径部署保持当前请求行为。
- FR8: 列表与详情页采用相同类型/权限结论。
- FR9: 下载、分享、媒体、PDF 与存储行为不变。
安全要求
- SR1: 对象字节只能通过文本内容进入 DOM。
- SR2: Text Preview 不得包含文档渲染器或解析器。
- SR3: 最多保留 1 MiB 加一个探测字节。
- SR4: 关闭或身份变化后,全部旧响应失效。
- SR5: 非法 UTF-8 与 NUL 内容不得冒充忠实文本。
- SR6: 错误、Redux、local storage、日志和遥测不得保存预览正文。
- SR7: 直接请求仍以服务端鉴权为最终权威。
- SR8: 不放宽 CSP 或后端 inline MIME。
实现范围
预计 Console 改动:
- 重构预览分类:完整保留当前媒体结论,显式增加文本 fallback;
- 在预览类型联合中加入
text; - 新增
PreviewText:流式上限、严格解码、请求取消和明确状态; - 把文本对象显式路由到该组件;
- 删除不可达的通用 iframe fallback;
- 修复对象详情页 Preview 禁用表达式,并与列表共享资格逻辑;
- 保留 unknown size,不再把它强制变成零;
- 增加中英文文案;
- 增加分类、组件、资源、安全、权限、版本与浏览器测试。
预计保持不变:
- Console 与 S3 API 路径;
- 后端
safeMimeTypes; - CSP;
- 对象存储与元数据格式;
- 图片、PDF、音频、视频、下载和分享 handler;
- 外部前端依赖。
如果未来需要 tail、服务端转码、组织级策略,或者必须穿过不支持 Range 的代理链稳定工作,可另行设计专用服务端接口。
被否决的方案
继续禁用文本预览
优点: 没有新代码和浏览器内存成本。
拒绝原因: 日志与配置对象是日常对象存储工作流,强制下载查看是可以避免的 Console 能力退化。
复用同源 iframe
优点: 代码最少,浏览器原生展示。
拒绝原因: 它把上传者控制内容与可变 MIME 元数据变成同源文档边界,同时也不限制资源使用。
现在新增后端预览 API
优点: 服务端统一上限与文本响应。
第一版拒绝原因: 用户本来就有对象读取权限,现有下载端点已经提供版本、鉴权与 Range;新 API 会重复契约,却没有建立新的数据访问边界。
显示大对象前 1 MiB
优点: 大日志更方便。
拒绝原因: 部分 JSON/XML 在结构上会误导,UTF-8 边界还需要额外处理,而且同一个 Preview 动作不再意味着完整内容。
用替换字符解码非法 UTF-8
优点: 损坏或旧日志仍可能部分可读。
拒绝原因: 用户复制的文本不再忠实对应存储对象。有损查看和其他编码应建立独立、显式产品模式。
自动格式化 JSON
优点: 缩进更易读。
拒绝原因: parse/stringify 会改变数字、重复 key、字面形式和复制内容。未来可以增加可选格式化视图,但绝不能替代原文默认。
引入 Monaco 或其他代码编辑器
优点: 行号、搜索、高亮与折叠。
拒绝原因: Bundle、Worker、CSP 与维护成本超过有界只读预览所需;原生 <pre> 更小、更容易审计。
验收与测试计划
分类矩阵
自动化测试必须锁定规范矩阵全部行、扩展名大小写、MIME 参数剥离、HTML/XHTML 显式拒绝,以及媒体冲突行为不变。
资源测试
覆盖:
- 0 字节;
- 1 字节;
- 正好 1,048,576 字节;
- 1,048,577 字节;
- 已知超限且正文请求数为零;
- 未知大小;
- 206 且
Content-Range已暴露总大小; - 服务端忽略 Range 并返回 200;
Content-Length缺失或错误;- 流式读取期间关闭和切换身份。
任何情况都不得保留或渲染超过允许的完整对象。
编码与保真测试
覆盖 UTF-8 中文、emoji、TAB、LF、CRLF、BOM、非法字节序列、NUL、JSON 不安全整数、重复 key、原始空白、XML 声明、DOCTYPE、CDATA 与 stylesheet 指令。
成功视图必须保留解码原文;非法与二进制情况必须进入独立状态。
安全测试
包含 <script>、事件属性、iframe 标签、SVG handler、XML stylesheet、外部实体与可疑 URL 的载荷必须:
- 逐字出现在
<pre>.textContent; - 不创建对应 DOM 元素;
- 不执行脚本或弹窗;
- 不发出由对象正文触发的请求;
- 在 Text Preview 中接触不到 iframe、object、embed、HTML parser 或 XML parser。
权限与竞态测试
验证:
- 没有
GetObject时没有可用动作,也不保留正文; - 历史版本遵守对应权限;
- 元数据与正文使用同一 version ID;
- 迟到旧响应不能覆盖新对象;
- 401、403、404、416、5xx 正文不成为预览内容;
- 匿名访问和 Console 子路径不回归。
浏览器回归
使用真实 SILO/Console 测试实例检查中英文路由、明暗主题、窄屏与桌面宽度;新文本状态之外,还要对媒体、PDF、下载、分享与版本工作流进行冒烟验证。
交付与完成门槛
虽然用户报告记录在 SILO 服务端仓库,修复本身归属 pgsty/silo-console。
交付分阶段进行:
- 合入边界明确的 Console 源码与测试;
- 通过 TypeScript 检查、生产构建、自动矩阵与真实浏览器安全回归;
- 更新 Console 发布说明并重新生成实际嵌入的 Web 资产;
- 发布 Console 版本;这项新增可见能力适合 minor 版本;
- 更新 SILO 中
github.com/minio/console => github.com/pgsty/silo-consolereplacement 到精确新 pseudo-version; - 用精确依赖构建 SILO 候选版本并重复集成验证;
- 发布 SILO 二进制与镜像,注明第一个包含此功能的版本。
这些是不同状态:
| 门槛 | 含义 |
|---|---|
| Console PR 合入 | 实现存在于源码。 |
| Console 资产/tag 发布 | Console 可以被独立消费。 |
| SILO 更新依赖 | SILO 主线已集成。 |
| SILO 正式发布 | 用户可以获得功能。 |
不能因为本地预览或 Console 源码 PR 已存在,就对用户宣称 issue #17 已经修复。
利弊取舍
最终方案选择:
- 明确范围,而不是通用浏览器查看器;
- 完整小文件,而不是部分大文件;
- 原文保真,而不是自动格式化;
- 严格 UTF-8,而不是静默有损解码;
- 单个惰性文本节点,而不是完整编辑器;
- 复用下载 API,而不是新增后端契约;
- 可验证安全不变量,而不是便利的同源渲染。
代价真实存在:大型日志和旧编码仍需下载,第一版也没有搜索、行号、换行开关和高亮。这些缺失是刻意的,它们让功能足够小,可以审计;也足够强,可以信任。
审阅记录
本设计从三个视角进行独立审阅:
- 产品范围、交付与验收;
- 安全与前端架构;
- 兼容性与当前源码验证。
评审者最初在“仅 MIME 是否可触发”和“非法 UTF-8 是否有损回退”上存在不同意见。交叉审阅后,三方达成唯一契约:
- 现有媒体分类优先;
- 文本 fallback 接受四种目标扩展名或四种精确规范化 MIME;
- HTML/XHTML 扩展名显式排除;
- 必须严格 UTF-8 并拒绝 NUL;
- 有损查看另立独立方案。
当前没有待裁决设计项,可以依照本文进入实现。
3 - 数据库通知统一连接串:#53 的兼容性边界
本文是 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 名称,但绝不能打印凭据。 |
这是配置边界决策,不是删除数据库通知功能。
状态: 设计已接受,实现待完成。
归属: SILO 服务端仓库。
跟踪: pgsty/silo#53。
目标: 实现并验证后进入下一个 SILO 补丁版本。
背景
SILO 从 MinIO 继承了两代数据库通知配置。
KV 时代之前的 JSON 配置既可以保存完整连接串,也可以使用五个离散字段:
当前 KV 配置只暴露驱动原生形式:
这不是新方向。MinIO 在 RELEASE.2020-04-10T03-34-42Z 就废弃了五个离散字段,并要求迁移到 connection_string 或 dsn_string。SILO 当前的帮助表、环境变量文档与示例也已经把完整连接串作为正式接口。
SILO 是一个迁移步骤显式的新社区分支。它优先保证 S3/Admin API、当前 MINIO_* 设置、盘上数据格式和当前 KV 配置的兼容性;当一个规范形式已经存在多年时,没有必要永久保留 2020 年以前的每一种配置拼法。
问题本质
当前旧配置迁移器 SetNotifyPostgres 与 SetNotifyMySQL 会把两种形式一起写入新 KV 配置。即使旧 target 已经有完整连接串,迁移器仍会附带五个离散 key,通常只是写入空值。
新解析器会拒绝这些 key,因为 DefaultPostgresKVS 与 DefaultMySQLKVS 都没有注册它们。合法性检查只看 key 是否存在,不看值是不是空。因此两种旧来源都会失败:
通知初始化又放大了这个错误。FetchEnabledTargets 对所有通知子系统采用 fail-fast:第一个非法子系统会返回错误和空 target list。上层只记录错误并继续启动对象存储服务,于是健康的 Webhook、Kafka、NATS 等 target 也全部不可用。
仅仅让两个迁移 helper 返回错误还不能修复这个行为。错误会经过 readConfigWithoutMigrate 与 initConfig 向上传播,但 initConfigSubsystem 当前会把不可重试的配置错误降级成 “some features may be missing” 日志并返回成功。服务器随后在没有设置 globalServerConfig 的情况下继续启动;通知失败只是其中一个后果,区域、存储类、压缩、身份与其他持久化设置也可能全部缺失。因此实现必须把类型化数据库迁移错误传到启动边界,并在那里按致命错误处理。把它标记为可重试同样不对,因为在没有外部状态变化时,服务器只会无限重试,配置永远不会自行修复。
这个行为格外危险,因为对象读写仍然正常。操作者看到的是健康的 S3 服务,但全部事件管道已经停止。target 根本没有建立,所以不能假定故障期间产生的事件日后还能投递或补放。
此外还有诊断信息暴露问题。未注册的 password 没有敏感字段元数据,可能被原样复制到健康检查或诊断材料中;正式注册的 connection_string 与 dsn_string 已经按敏感值处理。
为什么第一版修复被回滚
第一版修复注册了五个离散 key,并让解析器读取它们。这样迁移结果确实能通过 CheckValidKeys,而且 target 参数结构和构造器中也仍然保留着旧字段,看起来是很自然的接线方式。
但它破坏了文档明确支持的完整连接串路径。
共享的 mc admin config set 分词器通过查找已注册 key 来识别字段边界,并不能完整理解引号。一旦 port 成为已注册 key,下面这条合法输入中就出现了一个看似新的顶层字段:
分词器会在引号内部的 port= 处切开,把 connection_string 截断,再把剩余部分交给 port 解析器,最终报出 invalid port。
在当前分词器下,注册 host、port、password 这类常见词,会让连接串语法与顶层 KV 语法发生直接冲突。因此第一版注册方案被回滚;重新注册这些字段不是可接受的修复。
产品判断
数据库 notification target 是一个专业但有价值的能力。它可以直接提供数据库中的对象命名空间视图或访问流水,不要求用户额外部署事件总线;对于小型部署以及本来就在运行 PostgreSQL/MySQL 的用户仍然有意义。
旧连接参数写法的价值则低得多。五字段模型无法表达常见驱动能力:TLS 模式与证书、连接超时、应用名、Unix socket、PostgreSQL 多主机配置、MySQL 驱动参数,以及未来新增的驱动选项。同时支持两种形式还会制造优先级、合并、脱敏与测试问题;单一规范值不存在这些歧义。
完整连接串才是正确的抽象边界:SILO 负责通知语义,数据库驱动负责连接语法。
因此产品决策是保留能力、删除兼容假象。不支持的旧 target 必须被明确拒绝,不能再被“接受”后转换成一个随后拖垮无关 target 的非法配置。
目标
- 把
connection_string与dsn_string固定为数据库通知唯一受支持的在线配置接口。 - 允许已经含有规范连接串的旧 JSON target 跨过迁移边界,不改变其连接语义。
- 在离散字段旧 target 产生半成品或非法 KV 配置之前明确拒绝。
- 把 #53 当前“服务看似健康、全部通知静默失效”的运行时故障模式,替换为操作者必须先解决才能启动的显式启动期失败。
- 确保迁移错误、日志、健康报告与诊断包都不会暴露数据库密码。
- 从未注册写入源代码审计中删除 Postgres/MySQL 的十条例外。
- 在发布与迁移文档中明确兼容性边界和操作者修复路径。
非目标
- 在当前 KV 接口中同时支持 DSN 与数据库离散字段;
- 自动从旧离散字段生成 DSN;
- 重写共享 KV 分词器;
- 在本补丁中改变
FetchEnabledTargets的 fail-fast 语义; - 静默跳过已启用的数据库 target,再以残缺通知覆盖继续运行;
- 删除 PostgreSQL 或 MySQL notification target;
- 删除为解码和识别不受支持输入所需的旧结构体字段。这些字段仍位于在线构造器共用的 target 参数结构上;构造器中的离散字段连接串合成代码无法从当前 KV 配置到达,但这些字段不能重新成为受支持的配置 key。
- 修复其他八个旧通知 setter 被忽略的错误。它们原有的静默跳过行为在这次狭窄的数据库迁移补丁中保持不变,必须另做审计和设计决策。
功能需求
当前配置
notify_postgres接受connection_string;notify_mysql接受dsn_string。- 五个离散 key 继续保持未注册,并被当前配置命令拒绝。
- 现有完整连接串必须继续支持数据库驱动语法,包括值内部出现
host、port、user、password、database等词的情况。 - 不增加新的公共环境变量或 KV key。
- 已声明的旧变量
MINIO_NOTIFY_POSTGRES_HOST/PORT/USERNAME/PASSWORD/DATABASE及其 MySQL 对应形式没有接入当前解析,继续作为不受支持的形式,也不得在文档中被描述成完整连接串变量的可用替代。
旧配置迁移
- 旧 target 未启用时,
SetNotifyPostgres必须直接返回,不生成 target。 - 对已启用 target,
SetNotifyPostgres必须要求非空ConnectionString,并且只写已注册的 Postgres key。如果规范连接串与离散字段同时存在,以规范连接串为准,所有离散值都被丢弃。 SetNotifyMySQL对DSN执行同样规则。- 两个 helper 都不得写出
host、port、username、password、database。 - 缺少规范连接串时,必须返回带类型或包装上下文的迁移错误,指出子系统与 target 名称。
cmd/config-migrate.go必须检查并传播两个 helper 的错误,禁止忽略。- 任一 helper 失败后,都不得启用或持久化半迁移配置。
- 错误可以指出所需 key 和修复动作,但不得包含任何连接字段值。
- 传播的类型化迁移错误必须中止服务器启动,尤其不得落入
initConfigSubsystem中 “some features may be missing” 的非致命日志路径,也不得进入可重试错误循环。 - 已提供规范连接串的校验错误同样遵守启动致命和保密规则;包装错误只能增加 target 上下文,不能重复 DSN 或其组成部分。
推荐错误形式:
操作者修复路径
遇到错误的操作者必须选择一条明确修复路径。这既适用于首次切换到 SILO,也适用于升级已经运行 SILO 的部署:旧配置迁移结果不会持久化,因此同一份旧 JSON 来源可能在每次启动时重新进入迁移。一个当前仍能启动、但通知已经静默失效的部署,在升级到修复版本后会直接启动失败,直到来源配置被修正。
- 使用兼容的中间 MinIO 版本,把旧字段替换成
connection_string或dsn_string,验证 target 后再迁移到 SILO; - 禁用或删除旧数据库 target,迁移服务器,再用规范连接串重建 target;
- 对全新 SILO 安装,直接使用规范连接串创建 target,不经过旧配置迁移。
- 对仍在读取旧 JSON 文件的现有 SILO 部署,先停留在上一个可运行版本,备份来源配置,再转换、禁用或删除数据库 target,然后启动修复版本;不要删除或改写无关配置。
文档不得暗示离散字段 target 会被自动转换。
可用性权衡
这个决策有意把一种不受支持配置的“降级启动”变成“启动硬失败”。可用性代价是真实的:一台此前仍能提供对象读写、但全部通知已经静默死亡的服务器,在修复后可能拒绝启动。
我们接受这个代价,因为对象服务表面健康、已配置事件出口却全部消失,会造成静默且可能无法补救的下游数据丢失。SILO 是一个迁移边界显式的新 fork,而离散形式从 2020 年起就已废弃。一个致命、可操作的迁移前置条件,比一次看似成功却缩减通知覆盖的升级更安全。发布注记必须突出这个启动行为,不能把它藏在内部迁移清理里。
安全要求
- 不支持输入的错误不得格式化输出旧参数结构或其中任何值。
- 测试必须使用哨兵密码,并断言返回错误和捕获日志中都不存在它。
- 迁移输出只能包含已注册的敏感连接串 key,不能出现独立
passwordkey。 - 如果受影响部署曾在修复前导出并分享诊断包,应将数据库密码视为可能泄露并进行轮换。
备选方案
注册并解析离散字段
优点: 保留旧来源形式,并复用现存参数字段。
拒绝原因: 注册会把常见字段名暴露给共享分词器,破坏引号内的完整连接串;而且这些字段早在 2020 年就已废弃,重新注册等于反向扩大公共配置面。
迁移时自动生成规范连接串
优点: 兼容仅使用离散字段的旧安装。
拒绝原因: 这会为过时输入建立永久代码与测试责任,包括 PostgreSQL 引用、MySQL DSN 格式、socket/IPv6 行为、默认值与未来驱动漂移。对于迁移边界显式的新 fork,这个收益不足以覆盖长期维护面。
只跳过不支持的 target
优点: 对象存储服务与其他通知 target 可以继续运行。
拒绝原因: 静默丢弃已经配置的事件出口可能造成不可见、不可恢复的事件丢失。清晰的迁移失败,比一次通知覆盖缩水却看似成功的升级更安全。
修改全局通知 fail-fast 行为
优点: 限制未来非法 target 的故障半径。
本次拒绝原因: 它既不能修复数据库 target,也不能关闭凭据暴露路径,还会改变全系统错误语义。可另立独立设计和运维契约评估。
删除数据库通知 target
优点: 删除全部数据库专用维护面。
拒绝原因: 这些 target 仍然有用且相对自洽。缺陷属于过时配置形式,不属于通知能力本身。
实现范围
服务端改动应保持狭窄:
- 修改
internal/config/notify/legacy.go:两个数据库 setter 只输出规范已注册 key;已启用但没有规范连接串时明确拒绝。 - 修改
cmd/config-migrate.go:传播两个数据库 helper 的错误,并补充子系统与 target 上下文。 - 定义类型化数据库迁移错误,修改
cmd/server-main.go,让initConfigSubsystem将其作为致命错误返回,而不是记录后忽略;该错误必须保持不可重试。 - 本补丁不改变其他八个旧通知 setter 错误被忽略的现状;将其留给独立审计,不能暗中扩大 #53。
- 从
knownUnregisteredWrites删除 Postgres/MySQL 十项;除非存在另一个独立且有充分理由的旧例外,否则这个棘轮应当归零。 - 增加聚焦的迁移、启动、校验、保密和共存测试。
- 更新
silo.pgsty.com的数据库通知与迁移文档。
补丁不得注册旧 key、修改通用分词器,也不得重构无关通知 target。
验收标准
只有以下证据全部成立,才算实现完成:
-
含完整连接串的旧 PostgreSQL target 可以迁移,通过
CheckValidKeys,并由GetNotifyPostgres原样返回连接串。 -
含完整 DSN 的旧 MySQL target 完成同等验证。
-
两类已启用离散字段 target 都在 target 初始化前失败;错误包含子系统与 target 名称,给出可操作修复建议,且服务器启动中止。
-
缺少连接串和畸形连接串的错误都不包含哨兵 host、用户名、密码、数据库或 DSN 值。
-
未启用的离散旧 target 不生成配置项,也不阻塞迁移。
-
迁移后的 KVS 不含十个离散 key,包括空值形式。
-
旧 target 同时包含规范连接串与冲突离散值时,只迁移规范连接串,所有输出 KVS 值中都不存在离散哨兵值。
-
使用真实
DefaultPostgresKVS和DefaultMySQLKVSkey 集的SetKVS回归测试,能够接受引号内包含port=、host=、password=的完整连接串。 -
包含健康 Webhook、Kafka、NATS target 的配置不能再带着非法迁移数据库 target 进入
FetchEnabledTargets:readConfigWithoutMigrate返回错误,不返回、不持久化、也不启用任何半成品配置,启动路径随后因该类型化错误中止。 -
initConfigSubsystem返回类型化迁移错误,既不能记录后继续,也不能进入可重试循环。 -
knownUnregisteredWrites不再包含 Postgres/MySQL 例外。 -
以下验证全部通过:
cmd的详细输出必须显示两个前缀的测试确实执行;零匹配警告视为验收失败。服务端常规 CI 测试也必须通过;文档仓库执行make check。
发布与兼容性声明
发布注记必须把它描述为一个被正式执行的兼容性边界:
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。复核确认中英文记录语义对齐,需求可以在当前服务端代码树上实现,验收标准覆盖启动、迁移、解析器回归和凭据保密边界。
4 - 总量未知时,进度条应该说什么
状态:已在本地实现并验证;提交、Console 发布与 Silo 依赖更新待办 · 优先级:P1 · 归属:
pgsty/silo-console· 关联问题:pgsty/silo#62· PRD 复核:Claude Fable 5(xhigh)— APPROVE · 实现复核:Claude Fable 5(xhigh),2026-08-23 — APPROVE,无 P0/P1/P2 发现
SILO Console 下载文件夹时,Downloads / Uploads 面板会显示 NaN%。ZIP 通常仍在正常传输,存储对象也完好无损,但进度条已经从“总量未知”错误地跨进了一个非法的确定进度状态。用户看到一条近乎满格的进度条,以为下载失败或已经完成,于是重复点击。
建议的修复刻意保持狭窄:
只有当下载拥有一个有限、正数、并且适用于当前响应字节的总量时,才能进入 determinate 状态;否则必须保持 indeterminate,直到完成、失败或取消。
服务端继续流式生成 ZIP,普通文件继续显示百分比。前端只增加一道安全计算边界,复用已经存在的 indeterminate 渲染,再补齐一条缺失的取消状态转换。本文说明为什么这套方案既充分,又是最小且诚实的修复。
已观察到的故障
这个缺陷存在于当前 silo-console v2.1.1,Silo RELEASE.2026-08-06T00-00-00Z 内嵌的正是这一版本。
复现步骤:
- 在某个 prefix 下放入若干对象,例如
folder/。 - 停留在父目录,选择
folder/并点击 Download。 - 在传输完成前打开 Downloads / Uploads。
- 任务行显示
NaN%,而 ZIP 请求仍在继续。
运行时验证使用了一个约 88.7 MiB 的 prefix,并对 Chromium 限速以保留观察窗口。两次独立下载都进入了相同的 NaN% 状态。
这是前端正确性问题,不代表对象损坏、磁盘格式变化或 S3 GET 失败。
实际发生了什么
可见的 NaN% 是三层契约错位的最终结果。
Prefix 没有对象大小
S3 的文件夹是 common prefix,不是实际存储的目录对象。在列表模型里,prefix 以 / 结尾并携带 size=0。Console 已经把这种大小显示为 -,正确地表达了“不适用”。
生成的 API 模型为 size 标记了 omitempty,所以逻辑上的零不会出现在列表 JSON 中。单选下载 thunk 却把 object.size 原样传给辅助函数:prefix 与零字节对象在运行时提供的是 undefined(人工构造的 prefix 记录也可能提供 0)。两者都不是有效分母。
流式 ZIP 没有事先可知的网络长度
服务端通过末尾的 / 识别文件夹,递归列出对象,再把 zip.Writer 接到 io.Pipe 上。对象一边读取、一边 Deflate、一边复制进 HTTP 响应,档案生成多少就发送多少。
这是一项有价值的行为:服务端不用把完整 ZIP 全部放进内存或临时磁盘,就能尽早发出首字节。它也带来一个同样刻意的结果:发送响应头时,最终压缩字节数尚不存在,因此响应只有 Content-Type: application/zip 和文件名,没有 Content-Length。
源对象大小之和不能替代这个总量。对象大小是压缩前字节;ProgressEvent.loaded 统计的是 ZIP 压缩与封装后的响应字节。它们不是同一个单位。
收到 progress 事件,不代表百分比可计算
客户端当前对每个事件都执行:
Prefix 的分母为零或缺失。根据实际值与事件,JavaScript 会产生 NaN(loaded / undefined 或 0 / 0)或 Infinity(正数字节除以零)。
progress callback 随后把非有限值写入 Redux,同时设置 waitingForFile=false。第二个操作才是决定性的状态错误:任务仅仅因为“来了一个事件”就离开了现有 indeterminate 分支,而不是因为事件真的提供了可用总量。确定进度组件拿到非法值,最终渲染出非法标签。
完整链路如下:
普通非空文件之所以不出问题,是因为服务端可以 stat 对象、设置 Content-Length,列表中的大小也为正数。如果浏览器为空响应触发 progress 事件,零字节文件虽然是真对象,却会抵达与 prefix 相同的算术边界,因此必须纳入回归契约。
产品契约
UI 只需要诚实地区分两种情况:
- Determinate:已传输字节与总字节都已知,而且单位相同。
- Indeterminate:请求正在进行,但总量未知。
由此得到四条承重不变量:
这些不变量比 objectPath.endsWith("/") 更一般:无需发明对象类型特例,就能同时覆盖 prefix、零字节文件、异常元数据和未来任何未知长度响应。
目标与非目标
目标
- 文件夹下载不再显示
NaN%、Infinity%或伪造的确定百分比。 - 总长度未知的传输使用现有 indeterminate 动画。
- 总长度已知的普通文件保留当前百分比体验。
- 完成、失败与取消都必须离开 indeterminate。
- 零字节文件不得产生非有限百分比,并且仍能成功完成。
- 非有限或越界下载百分比不得进入 Redux。
- 修复可以先在 Console 独立发布,再由 Silo 更新依赖。
非目标
- 不在服务端预生成或缓存完整 ZIP。
- 不把文件夹内对象的未压缩大小之和冒充网络传输总量。
- 不重构整个 Object Manager 状态模型。
- 不把文件夹切换到当前“点击即完成”的
BrowserDownload路径。 - 不在这里解决
XMLHttpRequest.responseType="blob"的浏览器内存占用。 - 不改变取消记录是否保留到用户手动清理的现有产品行为。
- 不重新设计 HTTP 响应头发出之后,流式 ZIP 中途失败的错误表达。
- 不改变 S3 API、Console API、对象布局或 ZIP 内容。
这些都是合理的后续工作,但把它们绑进当前缺陷会扩大风险,却不是恢复诚实进度所必需的。
最终决策
最小生产修复由四部分组成。
D1. 只使用有效总量计算
增加一个不依赖 DOM 和 Redux 副作用的小型纯函数:
总量来源的优先级用于保持兼容:
- 有限且为正的
objectSize保留普通文件当前算法。 - 当对象大小不可用,但浏览器声明响应长度可计算,且
event.total有限为正时,使用响应总量。 - 其余情况返回
null:此时还不存在诚实的百分比。
辅助函数的输出契约是闭合的:要么是 null,要么是 [0,100] 内的有限数。
D2. 未知总量保持 indeterminate
XHR handler 只 dispatch 真实百分比:
下载任务本来就以 waitingForFile=true 创建,ObjectHandled 也已经把这个状态渲染成 variant="indeterminate"。没有必要把 Redux 扩成 number | null,也不用再加一个布尔值或修改 MDS。
首次获得有效百分比时,现有 updateProgress 会写入数值并设置 waitingForFile=false。如果整个请求始终没有有效总量,任务就保持 indeterminate,直到终态 action 到来。
D3. 让取消成为真正的终态
完成和失败路径已经会清除 waitingForFile,取消路径没有。需要在 cancelObjectInList 中补上:
没有这一行,修复后的 prefix 下载会在 abort 后继续进入 indeterminate 渲染分支,遮住 Cancelled 状态。任务行继续遵循现有产品行为:保留一条已取消记录,由用户手动移除。本次不要求自动清理。
XHR 边界还需要一条事件顺序守卫。abort() 会先触发 readystatechange(DONE, status=0),随后才触发 abort 事件;如果不提前返回,通用 DONE 分支会先把请求标成失败,onabort 再把它标成取消。DONE/status zero 因此交给专用的 onerror 或 onabort handler 处理,onabort 同时删除已存储的请求引用。
D4. 还原被省略的零字节大小
单选下载 thunk 改为传递 object.size || 0,与另一个下载入口保持一致。这样会在 Blob.size === fileSize 完成校验之前,还原 API 模型省略的逻辑零,使 HTTP 200 的零字节对象以 100% 完成,而不是被误报为 incomplete。
D5. 服务端流式行为保持不变
文件夹 handler 继续通过 io.Pipe 生成 Deflate ZIP,并且不设置 Content-Length。API、档案、存储和资源管理契约均不变化。
状态机
| 状态 | waitingForFile |
percentage |
终态标志 | 表现 |
|---|---|---|---|---|
| 排队 / 尚无有效进度 | true |
0 |
无 | indeterminate |
| 未知总量传输中 | true |
0 |
无 | indeterminate |
| 已知总量传输中 | false |
0..100 |
无 | 确定百分比 |
| 完成 | false |
100 |
done=true |
成功 |
| 失败 | false |
最后有效值 | failed=true, done=true |
错误 |
| 取消 | false |
0 |
cancelled=true, done=true |
已取消 |
状态不从 determinate 回退到 indeterminate。如果取得过有效百分比,之后某个事件又没有有效总量,handler 保留最后一个有效值即可。
现有 reducer 会在 Failed 与 Cancelled 时同时设置 done=true。ObjectHandled 依据 done 把关闭按钮从“中止请求”切换为“移除记录”;本次保持这一行为。取消后的 Redux 数值仍为 0,但现有 ProgressBarWrapper 会因为 ready=true 渲染一条满格橙色终态进度条并显示 Cancelled 标签;这种既有表现不属于本次修复范围。
waitingForFile 并不是“没有可计算进度”的理想长期命名。重命名它,或用 discriminated union 替代当前多个布尔值,都能改善模型,但那属于独立重构。本次所需的状态和渲染已经存在,复用它的兼容风险最低。
为什么这套方案充分
可以按情况验证修复的闭合性。
普通非空文件
objectSize > 0,辅助函数继续使用当前分母。结果有限且经过边界限制,updateProgress 进入 determinate,完成时仍为 100%。
当前流式文件夹
objectSize 被归一化为 0,同时 lengthComputable=false、event.total=0。辅助函数返回 null;没有非法 action 被 dispatch,因此任务保持 indeterminate。完成时现有 reducer 设置 waitingForFile=false、percentage=100、done=true。
未来提供真实长度的响应
如果代理或未来服务端实现提供了可信响应总量,lengthComputable=true 且 event.total>0。同一份代码会自动给出真实百分比,不需要再次修改产品逻辑。
零字节文件
列表中被省略的大小先还原为零,此后两个总量都为零,中间百分比在数学上未定义。任务在通常极短的生命周期里保持 indeterminate;零字节 Blob 与归一化后的预期大小相等,成功响应随即切换到 100%。整个过程不会计算 0/0。
失败与取消
失败路径本来就会离开 indeterminate;新增的取消转换让 abort 也同样进入终态。终态任务不会仅仅因为总量未知而继续表现得像正在运行。
从数学上说,只有当 total 属于 (0, +infinity) 才会执行除法,结果随后被限制到 [0,100]。因此 NaN 和 Infinity 都不可能穿过计算边界进入 Redux 或确定进度组件。
被否决的替代方案
缓存 ZIP 以获得 Content-Length
服务端可以先在内存或临时文件中生成完整档案,测量以后再发送。这样能得到精确网络总量,但代价是内存或磁盘压力、首字节延迟、清理复杂度与更差的并发下载表现。一个可观测性缺陷不足以成为放弃流式行为的理由。
对 prefix 下对象大小求和
这个和是未压缩逻辑数据;event.loaded 是压缩响应加 ZIP 封装后的字节。单位不同,进度条可能停在 100% 以下、提前超过 100%,或随着压缩率而不是传输完成度移动。否决。
把非法进度变成 0%
这只会隐藏字符串,却会撒另一个谎:determinate 0% 表示总量已知,只是还没有传输。用户仍然会把它理解为下载卡死。未知就应该保持未知。
只特判以 / 结尾的路径
它能修报告中的 prefix,却会漏掉真实零字节对象、非法元数据与其他未知长度响应。正确边界是 denominator 是否可用,而不是对象类型。
把文件夹交给 BrowserDownload
当前大文件路径创建 <a> 并在点击后立刻调用完成回调。它无法报告真实完成、Console 内取消或后续 HTTP 失败。它可以成为未来流式下载设计的基础,但今天使用它只会用另一个谎替换当前的谎。
在 ProgressBar 内部吞掉非法值
通用组件守卫可以作为第二道防线,但它会把非法数据留在 Redux,并向所有其他消费者隐藏错误状态转换。主要修复应该位于“进度成为应用状态”的边界。
现在引入 percentage: number | null
如果要重新设计 Object Manager,discriminated progress state 会比当前布尔值组合更干净。但在保留 waitingForFile、done、failed、cancelled 的同时再加入 null,只会制造更多矛盾组合。彻底移除旧字段又超过当前缺陷所需范围。现在复用已经能渲染的 indeterminate,状态重构另立任务。
需求与验收
功能需求
- FR1: 总量未知时,任务保持 indeterminate。
- FR2: 对象大小有限为正时,普通文件保留确定百分比。
- FR3: 只有
lengthComputable=true时,有限为正的event.total才能作为回退。 - FR4: 所有 dispatch 的百分比都必须有限且位于
[0,100]。 - FR5: 零字节文件不显示非有限进度,并且最终成功。
- FR6: 完成、失败与取消都必须离开 indeterminate。
- FR7: 版本化对象、匿名下载、预览与长文件名入口保持现有调用契约。
非功能需求
- 不增加服务端 CPU、内存、磁盘缓存或请求成本。
- 不增加前端依赖或构建步骤。
- 不改变 S3 API、Console API、ZIP 内容或存储对象。
- 计算函数必须能在没有 DOM 与真实 store 的环境中测试。
- TypeScript typecheck 与生产前端构建必须通过。
验收标准
- 没有
Content-Length的文件夹 ZIP 传输期间,任务行显示 indeterminate 动画且没有百分比文本。 - 成功完成后,任务显示成功/100%,ZIP 可以正常打开。
- 普通非空文件继续显示有限的确定进度,并以 100% 完成。
- 零字节文件不显示
NaN%或Infinity%,并且成功完成。 - 取消未知总量下载会 abort 请求并显示 Cancelled,而不是继续播放活动动画。
- 任何下载路径都不能把非有限或越界百分比放进 Redux。
测试计划
纯计算矩阵
使用现有 @playwright/test runner 测试纯模块,不增加测试框架。这需要在 web-app/playwright.config.ts 中新增一个无依赖的 unit project,例如使用 testMatch: /.*\.unit\.ts/。现有 chromium project 依赖针对 localhost:9090 真实实例的登录 setup,纯计算与 reducer 测试不应被该环境门控。此为纯配置变更,不引入新依赖。
| 场景 | loaded |
objectSize |
lengthComputable |
event.total |
期望 |
|---|---|---|---|---|---|
| 普通文件一半 | 50 | 100 | false | 0 | 50 |
| Common prefix | 1024 | 0 | false | 0 | null |
| 初始零除零 | 0 | 0 | false | 0 | null |
| 响应总量回退 | 50 | 0 | true | 200 | 25 |
| 零总量不可用 | 0 | 0 | true | 0 | null |
| loaded 超过总量 | 150 | 100 | true | 100 | 100 |
| 非法对象大小 | 10 | NaN |
false | 0 | null |
| 被省略的零大小 | 10 | undefined |
false | 0 | null |
| 非法响应总量 | 10 | 0 | true | Infinity |
null |
| 负 loaded | -1 | 100 | true | 100 | null |
状态测试
直接覆盖状态转换契约:
- 新下载以
waitingForFile=true开始。 - 没有有效 progress action 时保持 indeterminate。
- 有效 progress 产生有限值并设置
waitingForFile=false。 - complete 产生
done=true、waitingForFile=false、percentage=100。 - failure 产生
failed=true、done=true、waitingForFile=false。 - cancel 产生
cancelled=true、done=true、waitingForFile=false、percentage=0。
浏览器回归
使用真实 Console 测试实例与 Chromium:
- 创建临时桶,在
folder/下放入多个对象。 - 从父目录选择 prefix 并开始下载。
- 使用 CDP 限制下载速度,保证中间状态可观察。
限速用例需用
test.setTimeout放宽默认 30 秒超时。 - 打开 Downloads / Uploads,确认任务存在、没有百分比标签,也不存在
NaN%或Infinity%。 - 取消下载并验证 Cancelled 终态。
- 在
finally中恢复网络条件。 - 不限速再次下载,等待浏览器下载事件并验证 ZIP。
- 对普通非空文件与零字节文件重复相应断言。
- teardown 删除桶、对象、下载与临时文件。
当前 Playwright 项目只启用了 Chromium,因此 CDP 是可接受的测试机制。如果以后启用 Firefox 或 WebKit,纯函数和状态测试保持跨浏览器,只让限速观察测试受 Chromium project 门控。
实现边界
预计 Console 变更:
- 新增
downloadProgress.ts,承载纯计算逻辑。 - 修改
Objects/utils.ts:只 dispatch 非 null 百分比,把 status-zero 终态交给专用 handler,并清理已取消请求。 - 在单选下载 thunk 中还原被省略的零大小。
- 修改
cancelObjectInList,清除waitingForFile。 - 使用现有依赖补充计算、状态与浏览器回归,并在
playwright.config.ts中新增无依赖的unitproject。
预计保持不变:
- Go 文件夹下载 handler 与流式 ZIP。
ObjectHandled、ProgressBarWrapper与 MDS。IFileItem.percentage: number及现有 thunk callback 类型。- S3 与 Console API 路径。
- 存储对象与档案格式。
交付与回滚
修复归属于 pgsty/silo-console,而不是当前收到报告的 Silo 服务端仓库。
交付顺序:
- 把 #62 转移或交叉关联到
pgsty/silo-console。 - 实现边界明确的 Console 修改。
- 通过 typecheck、生产构建、纯函数/状态测试与真实浏览器回归。
- 发布新的 Console 版本。
- 更新 Silo 固定的 Console pseudo-version 或发布依赖。
- 构建 Silo 候选版本,重复文件夹、普通文件、零字节、取消与 ZIP 完整性验证。
- 发布 Silo,并在 Issue 中记录受影响与已修复版本。
没有数据迁移。如果前端修改出现回归,Silo 只需回退 Console 依赖;服务端数据与 API 行为保持兼容。
完成定义
- 计算函数只返回
null或有限的[0,100]数字。 - 活跃的未知总量文件夹下载渲染 indeterminate。
- 普通文件保留确定进度。
- 零字节文件不渲染非法进度。
- 完成、失败与取消任务都离开 indeterminate。
- 流式 ZIP 与服务端响应契约保持不变。
- typecheck、生产构建与自动化回归已在本地通过。
- Console 发布完成。
- Silo 更新 Console 依赖并通过候选版本验证。
后续工作
四项相邻改进应该分别建立设计档案:
- 把大文件夹直接流式写入浏览器或文件系统,避免在内存中持有完整 Blob。
- 用 discriminated progress/terminal state 替代 Object Manager 的布尔值组合。
- 改进响应头已经发出后,ZIP 失败的端到端完整性与错误表达。
- 为共享进度组件增加通用非有限值守卫,作为第二道防线。
- 修复既有的 Blob JSON 错误解码与 HTTP 失败路径请求引用清理问题。
它们都不是停止当前 UI 撒谎所必需的。下一阶段维护迭代应先恢复最小而诚实的契约:已知总量才显示百分比,未知总量就保持未知。