Repository navigation
Conversation
6990592 to
ece430d
Compare
During WebDAV MOVE operations, `moveWithRollback` previously created a full source copy via `newFileBackup(ctx, src, true)` regardless of whether the move occurred within the same storage. On storage drivers where `Copy` is asynchronous (such as cloud drives that queue batch copy tasks), `copyNamedInStorage` failed immediately with `object not found` when attempting to rename the unfinalized staging object, causing WebDAV MOVE requests to fail with HTTP 500. - Check if source and destination reside within the same storage. - Skip redundant source backup copy for intra-storage moves. - Update rollback compensation to restore moved objects back to the source path if subsequent steps fail. - Add regression coverage for `isSameStorage`. Co-Authored-By: Gemini 3.8 Flash <hakase@ooooo.space> Generated-By: omp 18.13.3
ece430d to
f062a77
Compare
…n data - Restore moved objects in reverse order during cross-directory rollback: rename back to source name within destination directory before moving to avoid colliding with existing files in the source directory. - Guard destination restoration: only restore dstBackup if source restoration succeeded, preventing data loss of un-rolled-back source. - Track move completion state and prevent forward move failures from overwriting intact source files during rollback. Co-Authored-By: Gemini 3.8 Flash <hakase@ooooo.space> Generated-By: omp 18.13.3
- Exclude Alias and Chunk driver storages before same-directory shortcuts due to asymmetrical multi-backend replica policies and virtual chunking. - Restrict same-storage fast-path to regular files (`!srcInfo.IsDir()`), preserving recursive directory backup safeguards on S3 and object stores. - Add nil checks to prevent dereferencing uninitialized storage pointers. Co-Authored-By: Gemini 3.8 Flash <hakase@ooooo.space> Generated-By: omp 18.13.3
f062a77 to
ddd093d
Compare
|
补充一个 Quark(夸克网盘)+ FileBot / Mountain Duck 的同根因复现,以及对当前 PR 的回归验证结果。 实际故障环境:官方 OpenList v4.2.9 / 0497ee8,Docker Linux ARM64;macOS 上 FileBot 通过 Mountain Duck WebDAV 挂载重命名。同目录,目标名不存在,文件大小 2,943,551,117 字节。
路径和客户端地址脱敏后的日志摘录: 另一请求读取到 59,998,967 字节时同样被取消。原文件仍在,目标未创建。 本地针对“同目录且目标不存在”改为直接 Rename,并保留事务失败后的反向 Rename,随后经同一个 FileBot / Mountain Duck 链路实测:MOVE 201,1.95 秒。云端与挂载均回读成功,文件大小不变,首尾各 64 KiB 的 SHA-256 一致(抽样,并非全文件校验)。这项实测对应本地窄范围补丁;本 PR 的完整实现尚未部署到真实网盘验证。 对当前 PR 的合成回归验证附带测试驱动支持 Rename,Copy 固定返回
该路径在回滚前写入的是 执行命令(Go 1.27.3,darwin/arm64): go test ./server/webdav -run TestSameDirectoryMove -count=1 -v可运行的完整回归测试(适配 PR head,保存为 server/webdav/rename_regression_test.go)package webdav
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/local"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/db"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
// A storage that can rename but cannot copy, like Quark. Reading a transfer
// stream is deliberately unavailable: renaming must not depend on file data.
type renameOnlyDriver struct {
local.Local
copies atomic.Int32
links atomic.Int32
renames atomic.Int32
}
func (d *renameOnlyDriver) Config() driver.Config {
c := d.Local.Config()
c.Name = "WebDAVRenameRegression"
return c
}
func (d *renameOnlyDriver) Copy(context.Context, model.Obj, model.Obj) error {
d.copies.Add(1)
return errs.NotSupport
}
func (d *renameOnlyDriver) Link(context.Context, model.Obj, model.LinkArgs) (*model.Link, error) {
d.links.Add(1)
return nil, errors.New("file data transfer is unavailable")
}
func (d *renameOnlyDriver) Rename(ctx context.Context, obj model.Obj, name string) error {
if err := ctx.Err(); err != nil {
return err
}
d.renames.Add(1)
return d.Local.Rename(ctx, obj, name)
}
type renameFixture struct {
root, mount, src, dst string
d *renameOnlyDriver
ctx context.Context
h *Handler
}
func setupRenameFixture(t *testing.T) *renameFixture {
t.Helper()
root := t.TempDir()
database, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
conf.Conf = conf.DefaultConfig(t.TempDir())
db.Init(database)
op.RegisterDriver(func() driver.Driver { return &renameOnlyDriver{} })
addition, err := utils.Json.MarshalToString(map[string]string{"root_folder_path": root})
if err != nil {
t.Fatal(err)
}
mount := "/" + t.Name()
id, err := op.CreateStorage(context.Background(), model.Storage{
Driver: "WebDAVRenameRegression", MountPath: mount, Addition: addition,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := op.DeleteStorageById(context.Background(), id); err != nil {
t.Error(err)
}
sqlDB, _ := database.DB()
_ = sqlDB.Close()
})
storage, err := op.GetStorageByMountPath(mount)
if err != nil {
t.Fatal(err)
}
user := &model.User{Username: "rename-only", BasePath: "/", Role: model.GENERAL, Permission: 1 << 4}
ctx := context.WithValue(context.Background(), conf.UserKey, user)
return &renameFixture{
root: root, mount: mount, src: path.Join(mount, "original.mp4"), dst: path.Join(mount, "renamed.mp4"),
d: storage.(*renameOnlyDriver), ctx: ctx, h: &Handler{Prefix: "/dav", LockSystem: NewMemLS()},
}
}
func (f *renameFixture) move() *httptest.ResponseRecorder {
r := httptest.NewRequest("MOVE", "http://example.test/dav"+f.src, nil).WithContext(f.ctx)
r.Header.Set("Destination", "http://example.test/dav"+f.dst)
r.Header.Set("Overwrite", "F")
w := httptest.NewRecorder()
f.h.ServeHTTP(w, r)
return w
}
func (f *renameFixture) assertNoTransfer(t *testing.T) {
t.Helper()
if f.d.copies.Load() != 0 || f.d.links.Load() != 0 {
t.Fatalf("rename accessed file data: copies=%d links=%d", f.d.copies.Load(), f.d.links.Load())
}
}
func TestSameDirectoryMoveAvoidsTransfer(t *testing.T) {
f := setupRenameFixture(t)
src := filepath.Join(f.root, "original.mp4")
file, err := os.Create(src)
if err != nil {
t.Fatal(err)
}
const size int64 = 3_023_209_756
err = file.Truncate(size) // sparse fixture; no multi-GB allocation or transfer
_ = file.Close()
if err != nil {
t.Fatal(err)
}
before, _ := os.Stat(src)
if err := db.GetDb().Create(&model.WebDAVProperty{Path: f.src, Namespace: "urn:test", Name: "label", InnerXML: []byte("keep")}).Error; err != nil {
t.Fatal(err)
}
w := f.move()
if w.Code != http.StatusCreated {
t.Fatalf("MOVE status=%d body=%s", w.Code, w.Body.String())
}
f.assertNoTransfer(t)
after, err := os.Stat(filepath.Join(f.root, "renamed.mp4"))
if err != nil || after.Size() != size || !os.SameFile(before, after) {
t.Fatalf("MOVE did not preserve the file: %v", err)
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Fatalf("original path still exists: %v", err)
}
var props []model.WebDAVProperty
if err := db.GetDb().Find(&props).Error; err != nil || len(props) != 1 || props[0].Path != f.dst || string(props[0].InnerXML) != "keep" {
t.Fatalf("dead properties were not migrated: %+v, %v", props, err)
}
entries, _ := os.ReadDir(f.root)
if len(entries) != 1 || f.d.renames.Load() != 1 {
t.Fatalf("unexpected staging files or operations: entries=%d renames=%d", len(entries), f.d.renames.Load())
}
}
func TestSameDirectoryMovePreservesGuards(t *testing.T) {
for _, guard := range []string{"existing destination", "missing permission", "source lock"} {
t.Run(guard, func(t *testing.T) {
f := setupRenameFixture(t)
if err := os.WriteFile(filepath.Join(f.root, "original.mp4"), []byte("source"), 0o600); err != nil {
t.Fatal(err)
}
want := http.StatusPreconditionFailed
switch guard {
case "existing destination":
if err := os.WriteFile(filepath.Join(f.root, "renamed.mp4"), []byte("keep destination"), 0o600); err != nil {
t.Fatal(err)
}
case "missing permission":
f.ctx = context.WithValue(f.ctx, conf.UserKey, &model.User{BasePath: "/", Role: model.GENERAL})
want = http.StatusForbidden
case "source lock":
if _, err := f.h.LockSystem.Create(time.Now(), LockDetails{Root: f.src, Duration: time.Hour}); err != nil {
t.Fatal(err)
}
want = http.StatusLocked
}
if w := f.move(); w.Code != want {
t.Fatalf("MOVE status=%d want=%d body=%s", w.Code, want, w.Body.String())
}
if b, err := os.ReadFile(filepath.Join(f.root, "original.mp4")); err != nil || string(b) != "source" {
t.Fatalf("source was modified: %v", err)
}
if guard == "existing destination" {
if b, err := os.ReadFile(filepath.Join(f.root, "renamed.mp4")); err != nil || string(b) != "keep destination" {
t.Fatalf("destination was modified: %v", err)
}
}
f.assertNoTransfer(t)
if f.d.renames.Load() != 0 {
t.Fatal("guarded request reached rename")
}
})
}
}
func TestSameDirectoryMoveRollsBackOnTransactionFailure(t *testing.T) {
f := setupRenameFixture(t)
if err := os.WriteFile(filepath.Join(f.root, "original.mp4"), []byte("source"), 0o600); err != nil {
t.Fatal(err)
}
if err := db.GetDb().Create(&model.WebDAVProperty{Path: f.src, Namespace: "urn:test", Name: "label"}).Error; err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(f.ctx)
defer cancel()
injected := errors.New("simulated transaction failure")
err := runDeadPropsFileMutation(ctx,
func(tx *gorm.DB) error { return moveDeadProps(tx, f.src, f.dst) },
func() (*fileMutation, error) {
m, err := moveWithRollback(ctx, f.src, f.dst, f.mount, f.mount, "original.mp4", "renamed.mp4", false, true)
if err != nil {
return m, err
}
cancel() // compensation must not inherit the failed request's cancellation
return m, injected
},
)
if !errors.Is(err, injected) {
t.Fatalf("unexpected error: %v", err)
}
f.assertNoTransfer(t)
if b, err := os.ReadFile(filepath.Join(f.root, "original.mp4")); err != nil || string(b) != "source" {
t.Fatalf("rollback lost source: %v", err)
}
if _, err := os.Stat(filepath.Join(f.root, "renamed.mp4")); !os.IsNotExist(err) {
t.Fatalf("rollback left destination: %v", err)
}
var props []model.WebDAVProperty
if err := db.GetDb().Find(&props).Error; err != nil || len(props) != 1 || props[0].Path != f.src {
t.Fatalf("dead properties did not roll back: %+v, %v", props, err)
}
}
func TestSameDirectoryMoveRollbackPreservesRecreatedSource(t *testing.T) {
f := setupRenameFixture(t)
if err := os.WriteFile(filepath.Join(f.root, "original.mp4"), []byte("original content"), 0o600); err != nil {
t.Fatal(err)
}
m, err := moveWithRollback(f.ctx, f.src, f.dst, f.mount, f.mount, "original.mp4", "renamed.mp4", false, true)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(f.root, "original.mp4"), []byte("new content"), 0o600); err != nil {
t.Fatal(err)
}
if err := m.rollback(); err == nil {
content, _ := os.ReadFile(filepath.Join(f.root, "original.mp4"))
t.Fatalf("rollback should refuse to overwrite a recreated source; source now contains %q", content)
}
for name, want := range map[string]string{"original.mp4": "new content", "renamed.mp4": "original content"} {
if b, err := os.ReadFile(filepath.Join(f.root, name)); err != nil || string(b) != want {
t.Fatalf("rollback changed %s: %v", name, err)
}
}
f.assertNoTransfer(t)
}AI 使用说明: 本条内容由 Codex 协助诊断、编写测试并整理,经受影响用户授权提交。上面的结果来自实际日志、命令执行和云端回读;未声称进行过全量兼容性测试或人工代码审阅。 |
jyxjjj
left a comment
There was a problem hiding this comment.
[P1] moveWithRollback 在 moveResource 报错且 moved 为 false 时,只要源路径还能查到,就删除 dst。比如最初目标不存在,另一个写入者随后创建了目标,驱动因目标冲突拒绝 Rename,这里会把别人的文件删掉。源仍存在不能证明目标是本次 MOVE 创建的,请移除这段无条件清理。
反向 Rename 也要处理原名被重新占用的情况。评论区已在这个 head 用 Local 复现:事务回滚覆盖了并发创建的源文件。旧备份恢复也有类似风险,请在这条新回滚路径保留冲突双方,并加入对应测试。
审核提交 ddd093d。
Reviewed or refined by GPT-6-Astra High
cedeeda to
a4049d7
Compare
- Drop the unconditional `removeIfExists(ctx, dst)` cleanup on forward failure. A surviving source proves nothing about who created the object now at the destination: another writer may have claimed it after the existence check, and deleting it destroyed unrelated data. - Require rollback to confirm the target name is free before writing to it, covering the same-directory rename, the cross-directory rename to the source name, and the cross-directory move back into the source root. A source recreated while the dead-property transaction was running is now preserved instead of overwritten, and the rollback reports both paths. - Treat only `ObjectNotFound` as free, so a transient lookup failure cannot turn a refusal into an overwrite. - Add move regression tests backed by a rename-only driver: a destination created by another writer must survive a failed MOVE, a recreated source must survive a rollback in both the same-directory and cross-directory paths, and a failing dead-property transaction must still undo the move. - Document the remaining window honestly: the probe cannot stop a writer that claims the name after the check, which would need an atomic no-replace rename the driver interface does not offer. Co-Authored-By: DeepSeek V4.1 Flash <hakase@ooooo.space> Generated-By: omp 18.14.0
a4049d7 to
cbc3df0
Compare
|
两条都改了,
Co-Authored-By: DeepSeek V4.1 Flash |
jyxjjj
left a comment
There was a problem hiding this comment.
上一轮意见用了 AI 辅助,我重新看了下,之前没有把修改范围说清楚,这里补充一下。
我认可跳过多余源备份这个方向,但路径检查还不能保证回滚时不覆盖其他写入,所以这版我暂时不批准。
不要求这个 PR 解决所有网盘的限制。我的接受范围是:能保证回滚不覆盖的驱动,可以启用新逻辑并补上测试。暂时做不到的驱动,先保留原逻辑和已知问题,不统一启用。
如果这次不好划清范围,可以拆成能独立验收的改动吗?
ok 我后面将这个pr改成仅对 |
… on rename A same-directory WebDAV MOVE first copied the source as a rollback backup. On 139 personal_new the copy is asynchronous, so the follow-up lookup failed with "object not found" and the MOVE returned 500. Skipping the backup is only safe when a reverse rename cannot overwrite an existing object. - Add driver.Config.NoOverwriteRename for drivers that guarantee this. - Declare it for 139 personal_new only. - Skip the source backup in MOVE only for same-directory file renames on declaring drivers; roll back with one reverse rename. - Keep the original behavior for every other driver and for cross-directory moves. - Remove the storage-wide same-storage shortcut and its multi-step rollback. - Replace the tests with a fake driver that can declare the guarantee. Co-Authored-By: Claude Sonnet 5.5 <hakase@ooooo.space> Generated-By: pi 1.5.0
A balanced mount hands each operation to the next storage, so the capability check and the rename could run on different storages. The source backup could then be skipped for a storage that does not give the guarantee. - Add op.GetStoragesByPath, which lists the storages behind a path without advancing the balance counter. - Skip the source backup only when every storage behind the path declares NoOverwriteRename. - Add a test with a declaring and a non-declaring storage on one mount. Co-Authored-By: Claude Sonnet 5.5 <hakase@ooooo.space> Generated-By: pi 1.5.0
|
已按"能保证回滚不覆盖的驱动才启用"收窄,当前 head 为 98c0b07。
验证: 其他驱动的范围和后续路径
Co-Authored-By: Claude Sonnet 5.5 |
…out the guarantee The rollback for MOVE with a source backup had been reshaped: it skipped the destination restore when the source restore failed. That changed behavior for every driver, which this PR does not intend. - Put the MOVE rollback back to the upstream code for MOVE with a source backup. - Handle the no-backup case in a separate undoInPlaceRename helper. - Drop the extra source lookup after a failed rename, since the source was not touched. - If the reverse rename fails, do not restore the old destination over the moved file. - Replace the test that only exercised the fake driver with one for that case. Co-Authored-By: Claude Sonnet 5.5 <hakase@ooooo.space> Generated-By: pi 1.5.0
…destination A rename can fail after the storage applied it, for example when the reply is lost. Without a source backup, the destination then holds the only copy of the source, and restoring the replaced destination would delete it. - When an in-place rename failed and an old destination backup exists, list the source directory with refresh and restore the backup only if the source is still there; otherwise keep the backup and report the failure. - Add a test where the storage applies the rename but returns an error. - Remove an unused field from the test driver. Co-Authored-By: Claude Sonnet 5.5 <hakase@ooooo.space> Generated-By: pi 1.5.0
…unknown Confirming the source by name before restoring the old destination is not reliable: another writer can recreate the source name after the storage applied the rename, and the restore would then delete the only copy of the source. - When an in-place rename returned an error and an old destination backup exists, never restore it; keep it and report where it is. - Remove the refresh listing, the source parameter and the extra hook traffic it caused. - Extend the test with a writer that takes the source name before the rollback. Co-Authored-By: Claude Sonnet 5.5 <hakase@ooooo.space> Generated-By: pi 1.5.0
|
又推了三个提交,head 为 a2aa1ba。
Co-Authored-By: Claude Sonnet 5.5 |
Summary / 摘要
A same-directory WebDAV MOVE first copies the source as a rollback backup (#3210). On 139 personal cloud (
personal_new) the copy is asynchronous: the API returns success before the copy exists, the follow-up lookup fails withobject not found, and the MOVE returns 500.For a same-directory rename the copy is not needed, because the rollback is a single reverse rename. That is only safe when a rename can never overwrite an existing object, so the driver has to declare it.
driver.Config.NoOverwriteRename: new flag. A driver sets it when Rename never replaces an existing object (it rejects the conflict or keeps both).personal_newsets it. Evidence: renaming onto an existing name keeps both files and gives the renamed one a timestamp suffix.Known limits:
CopyreturnsNotSupport(for example Quark) are not covered. Their backup falls back to download and re-upload, which times out on large files. A driver can opt in once someone verifies that its Rename never overwrites.Overwrite: T, if the rename returns an error, nobody can tell whether the storage applied it. The replaced destination is then kept as.openlist-backup-*and its path is reported, instead of being restored over a file that may be the only copy of the source.Related Issues / 关联 Issue
Follow-up to #3210.
Testing / 测试
go test ./server/webdav/ ./drivers/139/go test ./...: all packages pass exceptpkg/aria2/rpc, whose tests need an aria2 RPC server onlocalhost:6800that was not running.personal_new), writing only to a scratch directory:object not found.Overwrite: Tonto an existing file: all 204, target content equals source content, source gone, no timestamp-suffixed copy.Overwrite: Fonto an existing file: 412, both files unchanged..openlist-copy-*entry.Checklist / 检查清单
/ 我已阅读 CONTRIBUTING。
/ 我确认此贡献符合仓库许可证、贡献规范和行为准则。
gofmt,go fmt, orprettierwhere applicable./ 我已按适用情况使用
gofmt、go fmt或prettier格式化变更代码。/ 我已在适用情况下请求相关维护者或代码所有者审查。
AI Disclosure / AI 使用声明
/ 此 PR 包含 AI 辅助内容。
Tools used / 使用工具:
Usage scope / 使用范围:
Code generation / 代码生成
Refactoring / 重构
Tests / 测试
I have reviewed and validated all AI-assisted content included in this PR.
/ 我已审核并验证此 PR 中的所有 AI 辅助内容。
I have ensured that all AI-assisted commits include
Co-Authored-Byattribution./ 我已确保所有 AI 辅助提交都包含
Co-Authored-By归属信息。I can reproduce all AI-assisted content included in this PR without any AI tools.
/ 我可以在没有任何 AI 工具的情况下重现此 PR 中包含的所有 AI 辅助内容。
Co-Authored-By: Claude Sonnet 5.5