单个对象改名早就有了。这次补上整个文件夹的移动 / 重命名——右键目录、改个名,a/photos/ 下的东西整体变成 a/archive/

听着理所当然,但对象存储里有个反直觉的前提:根本没有文件夹

文件夹是个错觉

对象存储是一个扁平的 key→内容映射。a/photos/cat.jpg 不是"a 目录下的 photos 目录下的 cat.jpg",它就是一个完整的 key,那些 / 只是 key 里的普通字符。所谓"文件夹",是客户端按 / 分段假装出来的层级。

所以「重命名文件夹」在底层没有对应的原子操作——存储引擎不认识"文件夹"。它真正的含义是:把一批 key 的公共前缀从旧的换成新的。而 key 是不可变的,换前缀 = 对每个对象复制到新 key、删除旧 key:

for file in files {                          // a/photos/ 下的每个对象
    let rel = file.path.strip_prefix(src_base);   // "cat.jpg"
    let dst = format!("{dst_base}{rel}");          // "a/archive/cat.jpg"
    provider.copy(&file.path, &dst).await?;        // 同账号 → 服务端复制,不过本机
    provider.delete(&file.path).await?;
}

好在同账号的复制走服务端复制(x-amz-copy-source 那套),字节不经过本机,所以即便目录很大也只是很多次轻量 API 调用,而非重新上传。这一点和跨账号迁移复用同一条 copy 路径——只是这里源和目标是同一个账号。做完再把源目录的占位对象(「新建文件夹」留下的零字节 / 对象)清掉。

因为是「逐对象复制 + 删除」,它天然是可取消按文件数报进度的,于是直接接进传输面板,和文件夹下载 / 迁移 / 删除长一个样。

那个会自我吞噬的坑

a/photos/ 重命名成 a/archive/,没问题。但如果目标是源目录自己的子目录呢——把 a/photos/ 移动到 a/photos/sub/?

逐个想一遍:a/photos/cat.jpg 要复制成 a/photos/sub/cat.jpg。可 a/photos/sub/cat.jpg 也在 a/photos/ 之下——它是不是又该被移动一次?这在扁平 key 空间里会变成一个语义上塌缩的操作:目标不断落回源的扫描范围。必须直接拒绝。

判据很简单:目标前缀落在源前缀之内就拒绝。关键是用带结尾斜杠的前缀来比,否则会误伤:

/// 两参数都以 `/` 结尾,避免 a/b/ 与 a/bc/ 之间的前缀误判。
fn is_within(src_base: &str, dst_base: &str) -> bool {
    dst_base.starts_with(src_base)
}

没有结尾斜杠的话,"a/bc/".starts_with("a/b") 会是 true——把毫不相干的 a/bc/ 误判成 a/b 的子目录。补上斜杠,"a/bc/".starts_with("a/b/") 就是 false 了,而 "a/photos/sub/".starts_with("a/photos/") 仍是 true。这个 is_within 是纯函数,单测把「自身 / 子目录 / 相似前缀 / 无关」四种情形钉死。

小结

「重命名文件夹」在对象存储里不是一个操作,而是对一批 key 批量改前缀——复制到新前缀、删旧的,同账号靠服务端复制免中转。真正需要小心的是那个前缀自包含的坑:目标不能落在源之内,而判断它的前提是别忘了给前缀补上结尾斜杠。文件夹是客户端的错觉,但正因如此,操作它时得比操作真文件系统更清醒一点。