feat: restore files from trash
This commit is contained in:
@@ -987,6 +987,49 @@ func (a *App) ListVaultTrash(pluginID string) ([]corefiles.TrashEntry, string) {
|
||||
return entries, ""
|
||||
}
|
||||
|
||||
// RestoreVaultTrash restores a file or folder from internal trash for a plugin with files.delete and files.write.
|
||||
func (a *App) RestoreVaultTrash(pluginID, trashID string, options corefiles.RestoreOptions) (string, string) {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.delete"); err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
if a.files == nil {
|
||||
return "", "files service not initialized"
|
||||
}
|
||||
entries, err := a.files.ListTrashEntries()
|
||||
if err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
var entry corefiles.TrashEntry
|
||||
for _, candidate := range entries {
|
||||
if candidate.TrashID == trashID {
|
||||
entry = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if entry.TrashID == "" {
|
||||
return "", "not-found: trash entry " + trashID
|
||||
}
|
||||
restoredPath, err := a.files.RestoreTrashEntry(trashID, options)
|
||||
if err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
if err := a.recordFileSyncOp(syncEntityTypeForFileType(entry.OriginalType), restoredPath, syncsvc.OpCreate, map[string]string{
|
||||
"path": restoredPath,
|
||||
}); err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
a.publishFileActivity("file.changed", pluginID, restoredPath, map[string]interface{}{
|
||||
"operation": syncsvc.OpCreate,
|
||||
"type": string(entry.OriginalType),
|
||||
"restored": true,
|
||||
"trashId": trashID,
|
||||
})
|
||||
return restoredPath, ""
|
||||
}
|
||||
|
||||
// OpenVaultPathExternal opens a vault-relative file or folder in the OS default app.
|
||||
func (a *App) OpenVaultPathExternal(pluginID, relativePath string) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.openExternal"); err != nil {
|
||||
|
||||
@@ -395,6 +395,17 @@ func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) {
|
||||
if len(trashEntries) != 1 || trashEntries[0].OriginalPath != "Docs/two.txt" || trashEntries[0].TrashID != trash.TrashID {
|
||||
t.Fatalf("trash entries = %+v, want Docs/two.txt", trashEntries)
|
||||
}
|
||||
|
||||
restored, errStr := app.RestoreVaultTrash("files.plugin", trash.TrashID, corefiles.RestoreOptions{})
|
||||
if errStr != "" {
|
||||
t.Fatalf("RestoreVaultTrash: %s", errStr)
|
||||
}
|
||||
if restored != "Docs/two.txt" {
|
||||
t.Fatalf("restored path = %q, want Docs/two.txt", restored)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "Docs", "two.txt")); err != nil {
|
||||
t.Fatalf("restored file missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesBridgeWritePublishesFileChangedActivityEvent(t *testing.T) {
|
||||
@@ -610,6 +621,24 @@ func TestFilesBridgePermissions(t *testing.T) {
|
||||
call: func(app *App) string { _, errStr := app.TrashVaultPath("files.plugin", "one.txt"); return errStr },
|
||||
wantPhrase: "files.delete",
|
||||
},
|
||||
{
|
||||
name: "restore requires delete",
|
||||
perms: []string{"files.read", "files.write"},
|
||||
call: func(app *App) string {
|
||||
_, errStr := app.RestoreVaultTrash("files.plugin", "missing", corefiles.RestoreOptions{})
|
||||
return errStr
|
||||
},
|
||||
wantPhrase: "files.delete",
|
||||
},
|
||||
{
|
||||
name: "restore requires write",
|
||||
perms: []string{"files.read", "files.delete"},
|
||||
call: func(app *App) string {
|
||||
_, errStr := app.RestoreVaultTrash("files.plugin", "missing", corefiles.RestoreOptions{})
|
||||
return errStr
|
||||
},
|
||||
wantPhrase: "files.write",
|
||||
},
|
||||
{
|
||||
name: "open external requires openExternal",
|
||||
perms: []string{"files.read", "files.write", "files.delete"},
|
||||
|
||||
@@ -396,6 +396,143 @@ func (s *Service) ListTrashEntries() ([]TrashEntry, error) {
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *Service) RestoreTrashEntry(trashID string, options RestoreOptions) (string, error) {
|
||||
root, err := s.vaultRoot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateTrashID(trashID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
entry, err := readTrashEntry(root, trashID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
targetRel := entry.OriginalPath
|
||||
if options.TargetPath != "" {
|
||||
targetRel = options.TargetPath
|
||||
}
|
||||
targetRel, err = NormalizeRelativeFile(targetRel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
targetFull, err := s.resolve(root, targetRel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := rejectSymlinkPath(root, targetRel, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
trashDir := filepath.Join(root, ".verstak", "trash", "files", trashID)
|
||||
payloadFull := filepath.Join(root, filepath.FromSlash(entry.TrashPath))
|
||||
if !isInsideDir(trashDir, payloadFull) {
|
||||
return "", fmt.Errorf("invalid-trash-entry: payload outside trash")
|
||||
}
|
||||
if info, err := os.Lstat(payloadFull); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("not-found: trash payload %s", trashID)
|
||||
}
|
||||
return "", err
|
||||
} else if info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("symlink-not-allowed: %s", entry.TrashPath)
|
||||
}
|
||||
|
||||
parent := filepath.Dir(targetFull)
|
||||
if info, err := os.Stat(parent); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("parent-not-found: %s", pathDir(targetRel))
|
||||
}
|
||||
return "", err
|
||||
} else if !info.IsDir() {
|
||||
return "", fmt.Errorf("parent-not-directory: %s", pathDir(targetRel))
|
||||
}
|
||||
|
||||
if existing, err := os.Lstat(targetFull); err == nil {
|
||||
if existing.Mode()&os.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("symlink-not-allowed: %s", targetRel)
|
||||
}
|
||||
if !options.Overwrite {
|
||||
return "", fmt.Errorf("conflict: %s", targetRel)
|
||||
}
|
||||
if err := os.RemoveAll(targetFull); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.Rename(payloadFull, targetFull); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.RemoveAll(trashDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return targetRel, nil
|
||||
}
|
||||
|
||||
func readTrashEntry(root, trashID string) (TrashEntry, error) {
|
||||
if err := validateTrashID(trashID); err != nil {
|
||||
return TrashEntry{}, err
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, ".verstak", "trash", "files", trashID, "metadata.json"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return TrashEntry{}, fmt.Errorf("not-found: trash entry %s", trashID)
|
||||
}
|
||||
return TrashEntry{}, err
|
||||
}
|
||||
var raw struct {
|
||||
OriginalPath string `json:"originalPath"`
|
||||
TrashPath string `json:"trashPath"`
|
||||
TrashID string `json:"trashId"`
|
||||
DeletedAt string `json:"deletedAt"`
|
||||
OriginalType string `json:"originalType"`
|
||||
Basename string `json:"basename"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return TrashEntry{}, err
|
||||
}
|
||||
if raw.OriginalPath == "" || raw.TrashPath == "" || raw.TrashID == "" || raw.DeletedAt == "" {
|
||||
return TrashEntry{}, fmt.Errorf("invalid-trash-entry: missing metadata")
|
||||
}
|
||||
if raw.TrashID != trashID {
|
||||
return TrashEntry{}, fmt.Errorf("invalid-trash-entry: mismatched trash id")
|
||||
}
|
||||
return TrashEntry{
|
||||
OriginalPath: raw.OriginalPath,
|
||||
TrashPath: raw.TrashPath,
|
||||
TrashID: raw.TrashID,
|
||||
DeletedAt: raw.DeletedAt,
|
||||
OriginalType: FileType(raw.OriginalType),
|
||||
Basename: raw.Basename,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateTrashID(trashID string) error {
|
||||
if trashID == "" || trashID == "." || trashID == ".." || strings.ContainsAny(trashID, "/\\\x00") {
|
||||
return fmt.Errorf("invalid-trash-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isInsideDir(parent, child string) bool {
|
||||
absParent, err := filepath.Abs(parent)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
absChild, err := filepath.Abs(child)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(absParent, absChild)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && !filepath.IsAbs(rel)
|
||||
}
|
||||
|
||||
func (s *Service) vaultRoot() (string, error) {
|
||||
if s == nil || s.vault == nil {
|
||||
return "", fmt.Errorf("vault-not-initialized")
|
||||
|
||||
@@ -334,6 +334,96 @@ func TestListTrashEntriesReturnsMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreTrashEntryRestoresOriginalPathAndRemovesTrashMetadata(t *testing.T) {
|
||||
s, root := newTestService(t)
|
||||
if err := os.Mkdir(filepath.Join(root, "Docs"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "Docs", "restore.txt"), []byte("restore me"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trash, err := s.TrashVaultPath("Docs/restore.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("TrashVaultPath: %v", err)
|
||||
}
|
||||
|
||||
restored, err := s.RestoreTrashEntry(trash.TrashID, RestoreOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("RestoreTrashEntry: %v", err)
|
||||
}
|
||||
if restored != "Docs/restore.txt" {
|
||||
t.Fatalf("restored path = %q, want Docs/restore.txt", restored)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, "Docs", "restore.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("restored file missing: %v", err)
|
||||
}
|
||||
if string(data) != "restore me" {
|
||||
t.Fatalf("restored content = %q", string(data))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, trash.TrashPath)); !os.IsNotExist(err) {
|
||||
t.Fatalf("trash payload should be removed, stat err = %v", err)
|
||||
}
|
||||
entries, err := s.ListTrashEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("ListTrashEntries: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("trash entries after restore = %+v, want none", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreTrashEntryConflictAndOverwrite(t *testing.T) {
|
||||
s, root := newTestService(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "conflict.txt"), []byte("trashed"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trash, err := s.TrashVaultPath("conflict.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("TrashVaultPath: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "conflict.txt"), []byte("existing"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := s.RestoreTrashEntry(trash.TrashID, RestoreOptions{}); err == nil || !strings.Contains(err.Error(), "conflict: conflict.txt") {
|
||||
t.Fatalf("restore conflict error = %v, want conflict", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, "conflict.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "existing" {
|
||||
t.Fatalf("conflicting file content = %q, want existing", string(data))
|
||||
}
|
||||
|
||||
restored, err := s.RestoreTrashEntry(trash.TrashID, RestoreOptions{Overwrite: true})
|
||||
if err != nil {
|
||||
t.Fatalf("RestoreTrashEntry overwrite: %v", err)
|
||||
}
|
||||
if restored != "conflict.txt" {
|
||||
t.Fatalf("restored path = %q, want conflict.txt", restored)
|
||||
}
|
||||
data, err = os.ReadFile(filepath.Join(root, "conflict.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "trashed" {
|
||||
t.Fatalf("overwritten content = %q, want trashed", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreTrashEntryRejectsInvalidTrashID(t *testing.T) {
|
||||
s, _ := newTestService(t)
|
||||
for _, trashID := range []string{"", "../escape", "bad/slash"} {
|
||||
t.Run(trashID, func(t *testing.T) {
|
||||
if _, err := s.RestoreTrashEntry(trashID, RestoreOptions{}); err == nil {
|
||||
t.Fatal("expected invalid trash id error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSymlinkEscapeRejected(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation requires privileges on many Windows test environments")
|
||||
|
||||
@@ -54,6 +54,11 @@ type MoveOptions struct {
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
type RestoreOptions struct {
|
||||
TargetPath string `json:"targetPath,omitempty"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
type TrashResult struct {
|
||||
OriginalPath string `json:"originalPath"`
|
||||
TrashPath string `json:"trashPath"`
|
||||
|
||||
Reference in New Issue
Block a user