fix(trash): retain metadata when permanent deletion fails
This commit is contained in:
1 parent
56145dba72
commit
9aaaf15278
3 files changed
+119
-5
No files matched your search
+32
-3
@@ -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()})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ---- 资源 ----
|
||||
|
||||
Reference in new issue
Block a user