Skip to content

fix(webdav): skip source backup only for drivers that never overwrite on rename - #3325

Open
shelken wants to merge 9 commits into
OpenListTeam:mainfrom
shelken:fix/webdav-same-storage-move
Open

shelken wants to merge 9 commits into
OpenListTeam:mainfrom
shelken:fix/webdav-same-storage-move

Conversation

@shelken

@shelken shelken commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

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 with object 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.

moveFiles
  renameInPlace = same directory && file && driver.Config().NoOverwriteRename
  moveWithRollback
-   copy source into .openlist-backup-<uuid>      (always)
+   copy source into .openlist-backup-<uuid>      (unless renameInPlace)
    rename src -> dst
    on failure: restore from backup
+   on failure, renameInPlace: rename dst -> src
  • driver.Config.NoOverwriteRename: new flag. A driver sets it when Rename never replaces an existing object (it rejects the conflict or keeps both).
  • Only 139 personal_new sets it. Evidence: renaming onto an existing name keeps both files and gives the renamed one a timestamp suffix.
  • Every other driver, cross-directory MOVE, and directories keep the existing behavior, including the source backup.
  • Earlier revisions of this PR used a storage-wide shortcut with multi-step rollback. That is removed.

Known limits:

  • Cross-directory MOVE on 139 still takes the old path and still fails.
  • Drivers whose Copy returns NotSupport (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.
  • If the source name is taken again before the reverse rename, 139 keeps both objects and suffixes the renamed one, so nothing is overwritten, but the rollback reports success without restoring the original name.
  • With 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.
  • On a balanced mount, the backup is skipped only when every storage behind the path declares the flag.
  • 139 can return success from Rename without applying it (rare, about 0.2% to 0.7% in my runs). The reverse rename has the same property.

Related Issues / 关联 Issue

Follow-up to #3210.

Testing / 测试

  • go test ./server/webdav/ ./drivers/139/
  • go test ./...: all packages pass except pkg/aria2/rpc, whose tests need an aria2 RPC server on localhost:6800 that was not running.
  • Manual test / 手动测试: built this change and ran it against a real 139 personal cloud (personal_new), writing only to a scratch directory:
    • 60 same-directory MOVE (temp name to final name): all 201, final files present on the drive, no object not found.
    • 10 MOVE with Overwrite: T onto an existing file: all 204, target content equals source content, source gone, no timestamp-suffixed copy.
    • 1 MOVE with Overwrite: F onto an existing file: 412, both files unchanged.
    • Server log shows no backup copy on these paths.
    • Cross-directory MOVE (unchanged path): 2 of 2 returned 500 and left a .openlist-copy-* entry.
  • Unit tests use a fake driver that can declare the guarantee. With the skip forced off, the new case fails.
  • Not tested: other 139 account types, other drivers.

Checklist / 检查清单

  • I have read CONTRIBUTING.
    / 我已阅读 CONTRIBUTING。
  • I confirm this contribution follows the repository license, contribution policy, and code of conduct.
    / 我确认此贡献符合仓库许可证、贡献规范和行为准则。
  • I have formatted the changed code with gofmt, go fmt, or prettier where applicable.
    / 我已按适用情况使用 gofmt、go fmt 或 prettier 格式化变更代码。
  • I have requested review from relevant maintainers or code owners where applicable.
    / 我已在适用情况下请求相关维护者或代码所有者审查。

AI Disclosure / AI 使用声明

  • This PR includes AI-assisted content.
    / 此 PR 包含 AI 辅助内容。

Tools used / 使用工具:

  • Claude
  • Gemini

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-By attribution.
    / 我已确保所有 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

@shelken
shelken force-pushed the fix/webdav-same-storage-move branch 4 times, most recently from 6990592 to ece430d Compare October 30, 2026 03:09
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
@shelken
shelken force-pushed the fix/webdav-same-storage-move branch from ece430d to f062a77 Compare October 30, 2026 03:23
shelken and others added 2 commits October 30, 2026 11:32
…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
@thirteenkai

Copy link
Copy Markdown

补充一个 Quark(夸克网盘)+ FileBot / Mountain Duck 的同根因复现,以及对当前 PR 的回归验证结果。

实际故障

环境:官方 OpenList v4.2.9 / 0497ee8,Docker Linux ARM64;macOS 上 FileBot 通过 Mountain Duck WebDAV 挂载重命名。同目录,目标名不存在,文件大小 2,943,551,117 字节。

moveWithRollback 先调用 newFileBackup(ctx, src, true)。Quark 驱动的 Copy 返回 NotSupport,备份转入 CopyTo → FileTransferTask → Put,开始下载并重新上传整个视频。客户端约 15 秒后取消请求,MOVE 返回 500。

路径和客户端地址脱敏后的日志摘录:

failed copy /quark/media/original.mkv to /quark/media as .openlist-backup-<uuid>: failed to read all data: (expect =2943551117, actual =6749298) context canceled
[GIN] 2026/10/31 - 17:39:13 | 500 | 15.52s | <client> | MOVE "/dav/quark/media/original.mkv"

另一请求读取到 59,998,967 字节时同样被取消。原文件仍在,目标未创建。

本地针对“同目录且目标不存在”改为直接 Rename,并保留事务失败后的反向 Rename,随后经同一个 FileBot / Mountain Duck 链路实测:MOVE 201,1.95 秒。云端与挂载均回读成功,文件大小不变,首尾各 64 KiB 的 SHA-256 一致(抽样,并非全文件校验)。这项实测对应本地窄范围补丁;本 PR 的完整实现尚未部署到真实网盘验证。

对当前 PR 的合成回归验证

附带测试驱动支持 Rename,Copy 固定返回 NotSupport,Link 固定失败,以确保改名不依赖文件内容传输。文件使用稀疏文件,无需真实网盘或大文件下载。

  • 获取时的 main e72822f:TestSameDirectoryMoveAvoidsTransfer 失败,MOVE 返回 500。
  • 本 PR head ddd093d:无需复制的改名、死属性迁移、目标冲突、权限拒绝、锁拒绝、请求取消后的事务回滚均通过。
  • 同一 PR head:TestSameDirectoryMoveRollbackPreservesRecreatedSource 失败。改名完成后若其他操作重新创建原路径,回滚中的 fs.Rename(dst, srcName) 会覆盖这份新内容。Local 驱动合成复现得到:
rollback should refuse to overwrite a recreated source; source now contains "original content"

该路径在回滚前写入的是 "new content"。建议回滚时先确认原路径未被重建;发生冲突时保留两份内容并返回错误,避免覆盖。附带测试覆盖了这个边界。

执行命令(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 jyxjjj left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] moveWithRollback 在 moveResource 报错且 moved 为 false 时,只要源路径还能查到,就删除 dst。比如最初目标不存在,另一个写入者随后创建了目标,驱动因目标冲突拒绝 Rename,这里会把别人的文件删掉。源仍存在不能证明目标是本次 MOVE 创建的,请移除这段无条件清理。

反向 Rename 也要处理原名被重新占用的情况。评论区已在这个 head 用 Local 复现:事务回滚覆盖了并发创建的源文件。旧备份恢复也有类似风险,请在这条新回滚路径保留冲突双方,并加入对应测试。

审核提交 ddd093d。


Reviewed or refined by GPT-6-Astra High

@shelken
shelken force-pushed the fix/webdav-same-storage-move branch from cedeeda to a4049d7 Compare November 4, 2026 14:38
- 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
@shelken
shelken force-pushed the fix/webdav-same-storage-move branch from a4049d7 to cbc3df0 Compare November 4, 2026 14:57
@shelken

shelken commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

两条都改了,ddd093d → cbc3df02。

  1. 失败时不再删 dst。源还在不能证明 dst 上的对象是本次 MOVE 放置的。
  2. 回滚写入前用 ensurePathFree 检查目标名字。已占用的会被拒绝,两份都保留,不再覆盖。这不是原子操作:探测通过后、写入前出现的写入者仍会被覆盖,关掉这个窗口需要驱动层提供不覆盖的原子改名。探测本身也不完全可靠,目录缓存、list 错误、符号链接都会影响结果。

go test ./server/webdav/ -count=1 通过。新增 4 条测试,其中 3 条在改前失败:目标被并发写入者创建后被删,以及同目录、跨目录两种回滚覆盖重建的源文件。

fileBackup.restore 和源备份回滚分支存在同类问题,属于 #3210,改它会影响 COPY 语义,不在本次范围。

Co-Authored-By: DeepSeek V4.1 Flash

@jyxjjj jyxjjj left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

上一轮意见用了 AI 辅助,我重新看了下,之前没有把修改范围说清楚,这里补充一下。

我认可跳过多余源备份这个方向,但路径检查还不能保证回滚时不覆盖其他写入,所以这版我暂时不批准。

不要求这个 PR 解决所有网盘的限制。我的接受范围是:能保证回滚不覆盖的驱动,可以启用新逻辑并补上测试。暂时做不到的驱动,先保留原逻辑和已知问题,不统一启用。

如果这次不好划清范围,可以拆成能独立验收的改动吗?

@shelken

shelken commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

上一轮意见用了 AI 辅助,我重新看了下,之前没有把修改范围说清楚,这里补充一下。

我认可跳过多余源备份这个方向,但路径检查还不能保证回滚时不覆盖其他写入,所以这版我暂时不批准。

不要求这个 PR 解决所有网盘的限制。我的接受范围是:能保证回滚不覆盖的驱动,可以启用新逻辑并补上测试。暂时做不到的驱动,先保留原逻辑和已知问题,不统一启用。

如果这次不好划清范围,可以拆成能独立验收的改动吗?

ok 我后面将这个pr改成仅对能保证回滚不覆盖的驱动的处理

shelken and others added 2 commits November 8, 2026 21:56
… 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
@shelken shelken changed the title fix(webdav): skip redundant source backup on intra-storage MOVE fix(webdav): skip source backup only for drivers that never overwrite on rename Nov 8, 2026
@shelken

shelken commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

已按"能保证回滚不覆盖的驱动才启用"收窄,当前 head 为 98c0b07。

  • 新增 driver.Config.NoOverwriteRename,驱动声明"Rename 不会替换已存在的对象"。
  • 只有 139 personal_new 声明。依据:同名改名时两个文件都保留,被改名的加时间戳后缀。
  • webdav 只对"声明了该字段、同目录、是文件"的改名跳过源备份,回滚是一次反向 Rename。其余驱动、跨目录、目录全部保留原逻辑。
  • 负载均衡挂载下,要该路径背后所有存储都声明了才跳过备份,因为检查和实际改名可能落在不同存储上。
  • 删除了之前的 isSameStorage、ensurePathFree 和多步回滚。

验证:go test ./server/webdav/ ./drivers/139/ 通过。另在真实 139 个人云上跑过:同目录改名 60 次均 201,覆盖改名 10 次均 204(内容正确,无副本),Overwrite: F 返回 412,这些路径没有备份复制。

其他驱动的范围和后续路径

  • 本 PR 不处理其他驱动,没有证据的不启用。
  • Copy 返回 NotSupport 的驱动(例如 Quark,见上面 thirteenkai 的日志)备份会退化成下载再重传,大文件会超时。它们需要有人验证 Rename 遇到同名目标不会覆盖,再各加一行 NoOverwriteRename,机制已就位。
  • 139 的跨目录 MOVE 没有证据(/file/batchMove 遇重名的行为未测),仍走原逻辑,仍会失败。
  • Rename 返回成功但没生效的情况,反向 Rename 同样可能发生,这里不额外处理。
  • 回滚时原名若又被占用,139 会保留两个对象并给被改名的加后缀,不会覆盖,但回滚会返回成功而没有恢复原名。

Co-Authored-By: Claude Sonnet 5.5

shelken and others added 3 commits November 8, 2026 22:19
…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
@shelken

shelken commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

又推了三个提交,head 为 a2aa1ba。

  • 此前回滚代码被改过:源备份恢复失败时会跳过目标备份恢复。这会影响所有驱动,已还原成上游原样。现在有源备份的路径与基点逐字一致,新逻辑只在 renameInPlace 时执行。
  • 补了一个边界:改名在服务端已生效但回复丢失(返回错误)时,无法分辨"没改"与"已改",此时恢复旧目标会删掉源文件的唯一副本。现在一律保留旧目标备份并报告其位置,不自动恢复。新增了对应测试,含"源名被别的写入者重建"的情形。
  • 负载均衡挂载下,要该路径背后所有存储都声明 NoOverwriteRename 才跳过备份。

go test ./... 除 pkg/aria2/rpc(需要本机 aria2)外通过。

Co-Authored-By: Claude Sonnet 5.5

@shelken
shelken requested a review from jyxjjj November 8, 2026 14:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants