From 9aaaf1527815203f28e8689bd1202e36ffe533f1 Mon Sep 17 00:00:00 2001 From: eezd Date: Sun, 25 Oct 2026 13:20:53 +0800 Subject: [PATCH] fix(trash): retain metadata when permanent deletion fails --- internal/vault/trash.go | 35 +++++++++++++-- internal/vault/trash_test.go | 84 ++++++++++++++++++++++++++++++++++++ internal/vault/vault.go | 5 ++- 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/internal/vault/trash.go b/internal/vault/trash.go index da5b221..430a0ed 100644 --- a/internal/vault/trash.go +++ b/internal/vault/trash.go @@ -1,12 +1,41 @@ package vault import ( + "encoding/json" + "errors" "fmt" "os" "path/filepath" "time" ) +// 调用方持有写锁并已读取验证元数据。先删除内容,失败时保留条目的管理依据。 +func (v *Vault) purgeTrashItemLocked(metadata trashMetadata) error { + dir := filepath.Join(v.trash, metadata.ID) + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, entry := range entries { + if entry.Name() == "meta.json" { + continue + } + if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil { + return err + } + } + metaPath := filepath.Join(dir, "meta.json") + if err := os.Remove(metaPath); err != nil { + return err + } + if err := os.Remove(dir); err != nil { + // 父目录不可写等原因仍可能阻止最后的 rmdir,恢复元数据以便继续清理。 + data, _ := json.Marshal(metadata) + return errors.Join(err, atomicWrite(metaPath, data)) + } + return nil +} + type TrashFailure struct { ID string `json:"id"` Error string `json:"error"` @@ -35,12 +64,12 @@ func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error) if !trashIDPattern.MatchString(id) { continue // 不删除非本程序管理的文件。 } - item, err := v.readTrashItem(id) - if err == nil && days > 0 && (item.DeletedAt > now.UnixMilli() || (now.UnixMilli()-item.DeletedAt)/int64(24*time.Hour/time.Millisecond) < days) { + metadata, err := v.readTrashMetadata(id) + if err == nil && days > 0 && (metadata.DeletedAt > now.UnixMilli() || (now.UnixMilli()-metadata.DeletedAt)/int64(24*time.Hour/time.Millisecond) < days) { continue } if err == nil { - err = os.RemoveAll(filepath.Join(v.trash, id)) + err = v.purgeTrashItemLocked(metadata) } if err != nil { result.Failed = append(result.Failed, TrashFailure{ID: id, Error: err.Error()}) diff --git a/internal/vault/trash_test.go b/internal/vault/trash_test.go index 145aaee..e40a82e 100644 --- a/internal/vault/trash_test.go +++ b/internal/vault/trash_test.go @@ -1,6 +1,7 @@ package vault import ( + "bytes" "encoding/json" "errors" "os" @@ -10,6 +11,89 @@ import ( "time" ) +func TestTrashPurgeFailurePreservesMetadata(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("目录写权限测试需以普通用户运行;root 可绕过文件权限") + } + for _, operation := range []string{"single", "all", "expired"} { + for _, blocked := range []string{"payload", "parent"} { + t.Run(operation+"/"+blocked, func(t *testing.T) { + v := newVault(t) + if _, err := v.Write("folder/locked/note.md", []byte("content"), Precondition{}); err != nil { + t.Fatal(err) + } + setIcon(t, v, "folder", &TreeIcon{Kind: "emoji", Value: "📚"}) + item, err := v.Delete("folder") + if err != nil { + t.Fatal(err) + } + metaPath := filepath.Join(v.trash, item.ID, "meta.json") + data, err := os.ReadFile(metaPath) + if err != nil { + t.Fatal(err) + } + var metadata trashMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + t.Fatal(err) + } + metadata.DeletedAt = time.Now().Add(-40 * 24 * time.Hour).UnixMilli() + data, err = json.Marshal(metadata) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(metaPath, data, 0600); err != nil { + t.Fatal(err) + } + locked := filepath.Join(v.trash, item.ID, "folder", "locked") + if blocked == "parent" { + locked = v.trash + } + if err := os.Chmod(locked, 0500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(locked, 0755) }) + purge := func() error { + if operation == "single" { + return v.Purge(item.ID) + } + days := int64(0) + if operation == "expired" { + days = 30 + } + result, err := v.PurgeTrash(days, time.Now()) + if err == nil && len(result.Failed) > 0 { + if len(result.Deleted) != 0 || len(result.Failed) != 1 || result.Failed[0].ID != item.ID { + t.Fatalf("部分失败结果错误: %+v", result) + } + return errors.New(result.Failed[0].Error) + } + return err + } + if err := purge(); err == nil { + t.Fatal("权限错误应报告失败") + } + after, err := os.ReadFile(metaPath) + if err != nil || !bytes.Equal(data, after) { + t.Fatalf("删除失败必须保留完整元数据: %v", err) + } + items, err := v.TrashList() + if err != nil || len(items) != 1 || items[0].ID != item.ID { + t.Fatalf("删除失败的条目必须仍可列出: %+v %v", items, err) + } + if err := os.Chmod(locked, 0755); err != nil { + t.Fatal(err) + } + if err := purge(); err != nil { + t.Fatalf("修复权限后应可继续清理: %v", err) + } + if _, err := os.Stat(filepath.Dir(metaPath)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("成功清理后不应残留目录: %v", err) + } + }) + } + } +} + func trashAt(t *testing.T, v *Vault, path string, when time.Time) TrashItem { t.Helper() if _, err := v.Write(path, []byte("content"), Precondition{}); err != nil { diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 122cf42..f6ce1b9 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -748,10 +748,11 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) { func (v *Vault) Purge(id string) error { v.mu.Lock() defer v.mu.Unlock() - if _, err := v.readTrashItem(id); err != nil { + metadata, err := v.readTrashMetadata(id) + if err != nil { return err } - return os.RemoveAll(filepath.Join(v.trash, id)) + return v.purgeTrashItemLocked(metadata) } // ---- 资源 ----