fix(trash): retain metadata when permanent deletion fails

This commit is contained in:
eezd committed 2026-10-25 13:20:53 +08:00
1 parent 56145dba72
commit 9aaaf15278
3 files changed
+119 -5

No files matched your search

+32 -3
View File
@@ -1,12 +1,41 @@
package vault package vault
import ( import (
"encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"time" "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 { type TrashFailure struct {
ID string `json:"id"` ID string `json:"id"`
Error string `json:"error"` Error string `json:"error"`
@@ -35,12 +64,12 @@ func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error)
if !trashIDPattern.MatchString(id) { if !trashIDPattern.MatchString(id) {
continue // 不删除非本程序管理的文件。 continue // 不删除非本程序管理的文件。
} }
item, err := v.readTrashItem(id) metadata, err := v.readTrashMetadata(id)
if err == nil && days > 0 && (item.DeletedAt > now.UnixMilli() || (now.UnixMilli()-item.DeletedAt)/int64(24*time.Hour/time.Millisecond) < days) { if err == nil && days > 0 && (metadata.DeletedAt > now.UnixMilli() || (now.UnixMilli()-metadata.DeletedAt)/int64(24*time.Hour/time.Millisecond) < days) {
continue continue
} }
if err == nil { if err == nil {
err = os.RemoveAll(filepath.Join(v.trash, id)) err = v.purgeTrashItemLocked(metadata)
} }
if err != nil { if err != nil {
result.Failed = append(result.Failed, TrashFailure{ID: id, Error: err.Error()}) result.Failed = append(result.Failed, TrashFailure{ID: id, Error: err.Error()})
+84
View File
@@ -1,6 +1,7 @@
package vault package vault
import ( import (
"bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"os" "os"
@@ -10,6 +11,89 @@ import (
"time" "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 { func trashAt(t *testing.T, v *Vault, path string, when time.Time) TrashItem {
t.Helper() t.Helper()
if _, err := v.Write(path, []byte("content"), Precondition{}); err != nil { if _, err := v.Write(path, []byte("content"), Precondition{}); err != nil {
+3 -2
View File
@@ -748,10 +748,11 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) {
func (v *Vault) Purge(id string) error { func (v *Vault) Purge(id string) error {
v.mu.Lock() v.mu.Lock()
defer v.mu.Unlock() defer v.mu.Unlock()
if _, err := v.readTrashItem(id); err != nil { metadata, err := v.readTrashMetadata(id)
if err != nil {
return err return err
} }
return os.RemoveAll(filepath.Join(v.trash, id)) return v.purgeTrashItemLocked(metadata)
} }
// ---- 资源 ---- // ---- 资源 ----