Nebula 已经能流式下载、跨云迁移、并发分片上传。但这些功能都默默假设了一件事:传过去的字节和源头一模一样。真是这样吗?网络抖动、磁盘写坏、代理截断——任何一环出问题,你拿到的都是一个"看起来正常、其实缺了几个字节"的文件。而对象存储不会主动告诉你这件事。

所以这次加的是内容完整性校验:右键任意文件 → 校验完整性 → 立刻知道它和远端是否一致。关键是,这个功能不需要额外存任何校验信息,也不需要为每家云单独写代码。原因藏在一个你天天见、却很少细看的字段里:ETag。

ETag 其实就是 MD5

列目录、stat 一个对象,响应里都会带一个 ETag。多数人只把它当成"缓存用的版本标识",但对整对象上传(一次 PUT 传完、没有分片)的文件,S3 及所有兼容实现的约定是:

ETag = 对象内容的 MD5,十六进制,通常带一对引号。

也就是说,远端已经免费替我们存了一份内容指纹。校验一个文件是否完整,根本不用预先算好 checksum 存到某个数据库里——把内容下下来,本地算个 MD5,和 ETag 比一下就行:

pub fn verify_bytes(data: &[u8], etag: Option<&str>) -> Integrity {
    let Some(etag) = etag else {
        return Integrity::Unverifiable { reason: "对象未返回 ETag".into() };
    };
    let Some(expected) = etag_as_md5(etag) else {
        return Integrity::Unverifiable { reason: "分片对象的 ETag 非整体 MD5".into() };
    };
    let actual = md5_hex(data);
    if actual == expected {
        Integrity::Verified
    } else {
        Integrity::Mismatch { expected, actual }
    }
}

三种结果:通过不一致(文件可能损坏)无法校验。第三种不是偷懒——它对应一个绕不过的坑。

坑:分片上传的 ETag 不是 MD5

大文件走的是分片上传(multipart)。这种对象的 ETag 不是整个文件的 MD5,而是"每个分片 MD5 再拼起来取一次 MD5",后面还跟一个 -N 表示分片数,长这样:

"d41d8cd98f00b204e9800998ecf8427e-12"

你要是老老实实对整个文件算 MD5 去比,永远对不上——不是文件坏了,是算法根本不同。更麻烦的是,分片 ETag 依赖上传时的分片大小,而这个值各家客户端、各家 SDK 都不一样,没法在客户端稳定复现。

所以判定逻辑很简单但必须严格:只有 ETag 去掉引号后是纯 32 位十六进制、且没有 - 后缀,才当它是可信的整体 MD5;否则一律返回 Unverifiable,并如实告诉用户"这是分片对象,MD5 校验不适用",而不是谎报一个"失败"。诚实的"我不知道"比错误的"它坏了"有用得多。

fn etag_as_md5(etag: &str) -> Option<String> {
    let e = etag.trim().trim_matches('"');
    (e.len() == 32 && e.bytes().all(|b| b.is_ascii_hexdigit()))
        .then(|| e.to_ascii_lowercase())
}

为什么这个功能对"每一家云"自动生效

Nebula 的一条设计原则是:新增一家厂商,不应该逼着你回头去改一堆已有功能。完整性校验天然符合这一点——因为它只依赖 ETag 语义,不碰任何厂商的签名、endpoint 或私有字段

无论是国内的阿里云 OSS / 腾讯云 COS / 华为云 OBS,国际的 AWS S3 / Cloudflare R2,还是自建的 MinIO,它们的 stat 都会把 ETag 填进统一的 Entry.etag。校验逻辑活在 App 层,拿到的是这个统一字段,根本不知道背后是哪家云:

pub async fn verify(&self, account: &str, path: &str) -> Result<Integrity> {
    let provider = self.provider(account)?;
    let meta = provider.stat(path).await?;   // 任意厂商,统一 Entry
    let data = provider.read(path).await?;
    Ok(integrity::verify_bytes(&data, meta.etag.as_deref()))
}

结果是:以后新接入的任意一家云,不写一行校验相关的代码,就自动拥有完整性校验。这正是"统一抽象 + 只依赖公共语义"想要的复利——功能一次写好,厂商越加越多,它覆盖得越广。

小结

完整性校验没有引入新的存储、新的协议、新的每厂商适配,只是把对象存储早就给你的 ETag 用起来了:

  • 整对象文件:本地 MD5 对比 ETag,一键判断是否损坏;
  • 分片对象:识别 -N 形态,如实标为"不可校验",绝不谎报;
  • 逻辑只吃统一的 Entry.etag,对现有与未来的每一家云一视同仁。

下次下载或迁移完一个重要文件,右键校验一下——花一次下载的钱,买一个"它确实完好"的确定性。