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
|
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()})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 资源 ----
|
// ---- 资源 ----
|
||||||
|
|||||||
Reference in new issue
Block a user