From df064155c7f469f5fe0c3b5dafef1c56d6a2c98c Mon Sep 17 00:00:00 2001 From: eezd Date: Tue, 20 Oct 2026 12:46:55 +0800 Subject: [PATCH] Preserve vault state on failed file operations --- internal/api/api.go | 4 ++ internal/api/api_test.go | 30 ++++++++++ internal/vault/vault.go | 109 +++++++++++++++++++++++++++++------ internal/vault/vault_test.go | 87 ++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 18 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 27cc0ef..794c789 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -4,6 +4,7 @@ package api import ( "encoding/json" "errors" + "fmt" "io" "io/fs" "log/slog" @@ -359,6 +360,9 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) { return } if err := s.store.MoveShares(body.From, body.To); err != nil { + if rollback := s.vault.Move(body.To, body.From); rollback != nil { + err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback)) + } internalError(w, r, err) return } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 4a214e3..d91be32 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -31,6 +31,36 @@ type fixture struct { admin *http.Client } +func TestMoveRollsBackWhenShareUpdateFails(t *testing.T) { + dir := t.TempDir() + v, err := vault.Open(filepath.Join(dir, "vault")) + if err != nil { + t.Fatal(err) + } + if _, err := v.Write("from.md", []byte("原文"), vault.Precondition{}); err != nil { + t.Fatal(err) + } + st, err := store.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + s := New(v, nil, st, nil, nil) + res := httptest.NewRecorder() + s.move(res, httptest.NewRequest("POST", "/api/v1/move", strings.NewReader(`{"from":"from.md","to":"to.md"}`))) + if res.Code != 500 { + t.Fatalf("数据库失败应返回错误: %d", res.Code) + } + if content, _, err := v.Read("from.md"); err != nil || string(content) != "原文" { + t.Fatalf("原笔记未恢复: %q %v", content, err) + } + if _, err := os.Stat(filepath.Join(v.Root(), "to.md")); !os.IsNotExist(err) { + t.Fatalf("失败后目标仍存在: %v", err) + } +} + func newFixture(t *testing.T) *fixture { t.Helper() dir := t.TempDir() diff --git a/internal/vault/vault.go b/internal/vault/vault.go index a827840..45e3a1b 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -338,7 +338,7 @@ func (v *Vault) Tree() ([]Entry, error) { // ---- 移动 / 重命名 ---- // Move 移动或重命名笔记/文件夹。笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接 -func (v *Vault) Move(from, to string) error { +func (v *Vault) Move(from, to string) (err error) { src, err := v.abs(from) if err != nil { return err @@ -381,10 +381,25 @@ func (v *Vault) Move(from, to string) error { if err := os.Rename(src, dst); err != nil { return err } + assetsMoved := false + defer func() { + if err == nil { + return + } + if assetsMoved { + if rollback := os.Rename(dstAssets, srcAssets); rollback != nil { + err = errors.Join(err, fmt.Errorf("回滚图片移动失败: %w", rollback)) + } + } + if rollback := os.Rename(dst, src); rollback != nil { + err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback)) + } + }() if srcAssets != "" { if err := os.Rename(srcAssets, dstAssets); err != nil { return fmt.Errorf("移动图片目录失败: %w", err) } + assetsMoved = true oldBase := strings.TrimSuffix(path.Base(from), NoteExt) newBase := strings.TrimSuffix(path.Base(to), NoteExt) if oldBase != newBase { @@ -411,7 +426,7 @@ func newTrashID() string { var trashIDPattern = regexp.MustCompile(`^\d{8}T\d{6}\.\d{3}-[0-9a-f]{8}$`) // Delete 把笔记(连同 .assets)或文件夹移入回收站 -func (v *Vault) Delete(rel string) (TrashItem, error) { +func (v *Vault) Delete(rel string) (item TrashItem, err error) { p, err := v.abs(rel) if err != nil { return TrashItem{}, err @@ -428,25 +443,53 @@ func (v *Vault) Delete(rel string) (TrashItem, error) { if !info.IsDir() && !IsNote(rel) { return TrashItem{}, ErrInvalidPath } - item := TrashItem{ID: newTrashID(), Path: rel, Dir: info.IsDir(), DeletedAt: time.Now().UnixMilli()} + item = TrashItem{ID: newTrashID(), Path: rel, Dir: info.IsDir(), DeletedAt: time.Now().UnixMilli()} + if !item.Dir { + st, statErr := os.Stat(AssetsDir(p)) + if statErr != nil && !errors.Is(statErr, fs.ErrNotExist) { + return TrashItem{}, statErr + } + if statErr == nil && st.IsDir() { + item.Assets = filepath.Base(AssetsDir(p)) + } + } dir := filepath.Join(v.trash, item.ID) if err := os.MkdirAll(dir, 0o755); err != nil { return TrashItem{}, err } - if err := os.Rename(p, filepath.Join(dir, filepath.Base(p))); err != nil { - os.Remove(dir) - return TrashItem{}, err - } - if !item.Dir { - if st, err := os.Stat(AssetsDir(p)); err == nil && st.IsDir() { - item.Assets = filepath.Base(AssetsDir(p)) - if err := os.Rename(AssetsDir(p), filepath.Join(dir, item.Assets)); err != nil { - return TrashItem{}, fmt.Errorf("移动图片目录到回收站失败: %w", err) + noteMoved := false + defer func() { + if err == nil { + return + } + rollbackFailed := false + if noteMoved { + if rollback := os.Rename(filepath.Join(dir, filepath.Base(p)), p); rollback != nil { + err = errors.Join(err, fmt.Errorf("回滚回收站笔记失败: %w", rollback)) + rollbackFailed = true } } - } + // 回滚失败时保留目录和元数据,避免继续删除仅存的恢复依据。 + if !rollbackFailed { + if cleanup := os.RemoveAll(dir); cleanup != nil { + err = errors.Join(err, cleanup) + } + } + }() meta, _ := json.Marshal(item) - return item, atomicWrite(filepath.Join(dir, "meta.json"), meta) + if err = atomicWrite(filepath.Join(dir, "meta.json"), meta); err != nil { + return item, err + } + if err = os.Rename(p, filepath.Join(dir, filepath.Base(p))); err != nil { + return item, err + } + noteMoved = true + if item.Assets != "" { + if err = os.Rename(AssetsDir(p), filepath.Join(dir, item.Assets)); err != nil { + return item, fmt.Errorf("移动图片目录到回收站失败: %w", err) + } + } + return item, nil } func (v *Vault) readTrashItem(id string) (TrashItem, error) { @@ -487,8 +530,8 @@ func (v *Vault) TrashList() ([]TrashItem, error) { return items, nil } -func (v *Vault) Restore(id string) (TrashItem, error) { - item, err := v.readTrashItem(id) +func (v *Vault) Restore(id string) (item TrashItem, err error) { + item, err = v.readTrashItem(id) if err != nil { return TrashItem{}, err } @@ -513,11 +556,38 @@ func (v *Vault) Restore(id string) (TrashItem, error) { if err := os.Rename(filepath.Join(dir, filepath.Base(dst)), dst); err != nil { return TrashItem{}, err } + assetsMoved, cleaning := false, false + defer func() { + if err == nil { + return + } + if cleaning { + // 清理可能已删除元数据;先重建恢复依据,再撤回本次移动。 + if rollback := os.MkdirAll(dir, 0o755); rollback != nil { + err = errors.Join(err, rollback) + return + } + meta, _ := json.Marshal(item) + if rollback := atomicWrite(filepath.Join(dir, "meta.json"), meta); rollback != nil { + err = errors.Join(err, rollback) + } + } + if assetsMoved { + if rollback := os.Rename(AssetsDir(dst), filepath.Join(dir, item.Assets)); rollback != nil { + err = errors.Join(err, fmt.Errorf("回滚恢复图片失败: %w", rollback)) + } + } + if rollback := os.Rename(dst, filepath.Join(dir, filepath.Base(dst))); rollback != nil { + err = errors.Join(err, fmt.Errorf("回滚恢复笔记失败: %w", rollback)) + } + }() if item.Assets != "" { if err := os.Rename(filepath.Join(dir, item.Assets), AssetsDir(dst)); err != nil { - return TrashItem{}, fmt.Errorf("恢复图片目录失败: %w", err) + return item, fmt.Errorf("恢复图片目录失败: %w", err) } + assetsMoved = true } + cleaning = true return item, os.RemoveAll(dir) } @@ -558,6 +628,9 @@ func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (stri dir := AssetsDir(p) v.mu.Lock() defer v.mu.Unlock() + if err := v.checkInside(filepath.Join(dir, name)); err != nil { + return "", err + } if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } @@ -592,4 +665,4 @@ func EscapeLinkPath(s string) string { return linkEscaper.Replace(s) } -var linkEscaper = strings.NewReplacer(" ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E") +var linkEscaper = strings.NewReplacer("%", "%25", "#", "%23", "?", "%3F", " ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E") diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go index ae41a75..6be1ed0 100644 --- a/internal/vault/vault_test.go +++ b/internal/vault/vault_test.go @@ -2,6 +2,7 @@ package vault import ( "errors" + "net/url" "os" "path/filepath" "strings" @@ -44,6 +45,92 @@ func TestSymlinkEscapeRejected(t *testing.T) { } } +func TestAssetSymlinkEscapeRejected(t *testing.T) { + v := newVault(t) + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(v.Root(), "n.assets")); err != nil { + t.Fatal(err) + } + if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); !errors.Is(err, ErrInvalidPath) { + t.Fatalf("资源目录越界应被拒绝: %v", err) + } + files, err := os.ReadDir(outside) + if err != nil || len(files) != 0 { + t.Fatalf("仓库外被写入: %v %v", files, err) + } +} + +func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) { + v := newVault(t) + if _, err := v.Write("n.md", []byte("原文"), Precondition{}); err != nil { + t.Fatal(err) + } + if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); err != nil { + t.Fatal(err) + } + // 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。 + destination := strings.Repeat("a", 250) + ".md" + if err := v.Move("n.md", destination); err == nil { + t.Fatal("图片目录移动应失败") + } + if content, _, err := v.Read("n.md"); err != nil || string(content) != "原文" { + t.Fatalf("原笔记未恢复: %q %v", content, err) + } + if _, err := os.Stat(filepath.Join(v.Root(), "n.assets")); err != nil { + t.Fatal(err) + } + if _, _, err := v.Read(destination); !errors.Is(err, ErrNotFound) { + t.Fatalf("目标笔记应撤回: %v", err) + } +} + +func TestRestoreRollsBackWhenAssetsAreMissing(t *testing.T) { + v := newVault(t) + if _, err := v.Write("n.md", []byte("原文"), Precondition{}); err != nil { + t.Fatal(err) + } + if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); err != nil { + t.Fatal(err) + } + item, err := v.Delete("n.md") + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(filepath.Join(v.trash, item.ID, item.Assets)); err != nil { + t.Fatal(err) + } + if _, err := v.Restore(item.ID); err == nil { + t.Fatal("丢失图片目录时恢复应失败") + } + if _, _, err := v.Read("n.md"); !errors.Is(err, ErrNotFound) { + t.Fatalf("不应留下部分恢复的笔记: %v", err) + } + items, err := v.TrashList() + if err != nil || len(items) != 1 { + t.Fatalf("回收站恢复依据未保留: %v %v", items, err) + } +} + +func TestAssetLinksEncodeReservedURLCharacters(t *testing.T) { + v := newVault(t) + link, err := v.SaveAsset("n#?%20.md", "x.png", strings.NewReader("image"), 100) + if err != nil { + t.Fatal(err) + } + decoded, err := url.PathUnescape(link) + if err != nil { + t.Fatal(err) + } + file, _, err := v.OpenRaw(decoded) + if err != nil { + t.Fatalf("资源链接未指向实际文件: %s %v", link, err) + } + file.Close() + if strings.ContainsAny(link, "#?") || !strings.HasPrefix(link, "n%23%3F%2520.assets/") { + t.Fatalf("链接未正确转义: %s", link) + } +} + func TestWritePreconditions(t *testing.T) { v := newVault(t) rev, err := v.Write("a/b.md", []byte("one"), Precondition{IfNoneMatch: true})