diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js index dd42c93..62f3821 100644 --- a/frontend/src/lib/plugin-host/VerstakPluginAPI.js +++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js @@ -272,6 +272,12 @@ export function createPluginAPI(pluginId) { return App.ListVaultTrash(pluginId); }); }, + restoreTrash: function(trashId, options) { + assertActive('files.restoreTrash(' + trashId + ')'); + return callBackend(pluginId, 'files.restoreTrash(' + trashId + ')', function() { + return App.RestoreVaultTrash(pluginId, trashId, options || {}); + }); + }, openExternal: function(relativePath) { assertActive('files.openExternal(' + relativePath + ')'); return callBackendErrorString(pluginId, 'files.openExternal(' + relativePath + ')', function() { diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js index 183298b..6877e06 100644 --- a/frontend/src/lib/test/wails-mock.js +++ b/frontend/src/lib/test/wails-mock.js @@ -219,6 +219,7 @@ var vaultFiles = makeDefaultVaultFiles(); var externalOpens = []; var trashEntries = []; + var trashPayloads = {}; window.__wailsMockExternalOpens = []; var workspaceTree = makeDefaultWorkspaceTree(); var reloadResponseMode = 'tuple'; @@ -1345,6 +1346,9 @@ var trashPath = '.verstak/trash/files/' + trashId + '/' + baseName(norm.path); var originalType = vaultFiles[norm.path].type || 'file'; var moving = Object.keys(vaultFiles).filter(function (path) { return path === norm.path || path.indexOf(norm.path + '/') === 0; }); + trashPayloads[trashId] = moving.map(function (path) { + return { suffix: path.slice(norm.path.length), entry: Object.assign({}, vaultFiles[path]) }; + }); moving.forEach(function (path) { delete vaultFiles[path]; }); var entry = { originalPath: norm.path, trashPath: trashPath, trashId: trashId, deletedAt: new Date().toISOString(), originalType: originalType, basename: baseName(norm.path) }; trashEntries.unshift(entry); @@ -1355,6 +1359,29 @@ if (err) return Promise.resolve([[], err]); return Promise.resolve([trashEntries.slice(), '']); }, + RestoreVaultTrash: function (pluginId, trashId, options) { + var deleteErr = requirePluginPermission(pluginId, 'files.delete'); + if (deleteErr) return Promise.resolve(['', deleteErr]); + var writeErr = requirePluginPermission(pluginId, 'files.write'); + if (writeErr) return Promise.resolve(['', writeErr]); + options = options || {}; + var entry = trashEntries.find(function (item) { return item.trashId === trashId; }); + if (!entry) return Promise.resolve(['', 'not-found: trash entry ' + trashId]); + var target = normalizeVaultPath(options.targetPath || entry.originalPath, false); + if (target.error) return Promise.resolve(['', target.error]); + if (vaultFiles[target.path] && !options.overwrite) return Promise.resolve(['', 'conflict: ' + target.path]); + var parent = parentPath(target.path); + if (!vaultFiles[parent] || vaultFiles[parent].type !== 'folder') return Promise.resolve(['', 'parent-not-found: ' + parent]); + if (options.overwrite) { + Object.keys(vaultFiles).filter(function (path) { return path === target.path || path.indexOf(target.path + '/') === 0; }).forEach(function (path) { delete vaultFiles[path]; }); + } + (trashPayloads[trashId] || []).forEach(function (item) { + vaultFiles[target.path + item.suffix] = Object.assign({}, item.entry, { modifiedAt: new Date().toISOString() }); + }); + delete trashPayloads[trashId]; + trashEntries = trashEntries.filter(function (item) { return item.trashId !== trashId; }); + return Promise.resolve([target.path, '']); + }, OpenVaultPathExternal: function (pluginId, relativePath) { var err = requirePluginPermission(pluginId, 'files.openExternal'); if (err) return Promise.resolve(err); @@ -1722,6 +1749,8 @@ pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } }; vaultFiles = makeDefaultVaultFiles(); externalOpens = []; + trashEntries = []; + trashPayloads = {}; window.__wailsMockExternalOpens = []; workspaceTree = makeDefaultWorkspaceTree(); reloadResponseMode = 'tuple'; diff --git a/frontend/tests/plugin-api-files-test.mjs b/frontend/tests/plugin-api-files-test.mjs new file mode 100644 index 0000000..17c2649 --- /dev/null +++ b/frontend/tests/plugin-api-files-test.mjs @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const calls = []; + +globalThis.window = { + __VERSTAK_PLUGIN_REGISTRY__: {}, + __VERSTAK_EVENT_HANDLERS__: {}, + __VERSTAK_COMMAND_HANDLERS__: {}, + go: { + api: { + App: { + RestoreVaultTrash: (pluginId, trashId, options) => { + calls.push({ pluginId, trashId, options }); + return Promise.resolve(['Docs/restored.txt', '']); + }, + }, + }, + }, +}; +globalThis.__mockApp = window.go.api.App; + +const sourcePath = path.resolve('frontend/src/lib/plugin-host/VerstakPluginAPI.js'); +const source = fs.readFileSync(sourcePath, 'utf8') + .replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;'); +const tempPath = path.resolve('/tmp/verstak-plugin-api-files-test.mjs'); +fs.writeFileSync(tempPath, source); + +const apiModule = await import(pathToFileURL(tempPath).href + '?t=' + Date.now()); +const api = apiModule.createPluginAPI('verstak.files'); + +if (!api.files || typeof api.files.restoreTrash !== 'function') { + throw new Error('api.files.restoreTrash is missing'); +} + +const restored = await api.files.restoreTrash('trash-1', { overwrite: true }); +if (restored !== 'Docs/restored.txt') { + throw new Error(`unexpected restore result: ${JSON.stringify(restored)}`); +} +if (calls.length !== 1 || calls[0].pluginId !== 'verstak.files' || calls[0].trashId !== 'trash-1' || calls[0].options.overwrite !== true) { + throw new Error(`unexpected RestoreVaultTrash call: ${JSON.stringify(calls)}`); +} + +console.log('plugin api files smoke passed'); diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index 8e29cb9..d47e0b5 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -66,6 +66,8 @@ export function ListPluginCapabilities(arg1:string):Promise|string>; +export function ListVaultTrash(arg1:string):Promise|string>; + export function ListWorkspaces():Promise|string>; export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise; @@ -106,6 +108,8 @@ export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise export function ReloadPlugins():Promise; +export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise; + export function RenameWorkspace(arg1:string,arg2:string):Promise; export function RenameWorkspaceNode(arg1:string,arg2:string):Promise; @@ -126,8 +130,6 @@ export function SubscribePluginEvent(arg1:string,arg2:string):Promise; export function TrashVaultPath(arg1:string,arg2:string):Promise; -export function ListVaultTrash(arg1:string):Promise; - export function TrashWorkspace(arg1:string):Promise; export function UpdateAppSettings(arg1:Record):Promise; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index 1ec8637..cdb7d2a 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -118,6 +118,10 @@ export function ListVaultFiles(arg1, arg2) { return window['go']['api']['App']['ListVaultFiles'](arg1, arg2); } +export function ListVaultTrash(arg1) { + return window['go']['api']['App']['ListVaultTrash'](arg1); +} + export function ListWorkspaces() { return window['go']['api']['App']['ListWorkspaces'](); } @@ -198,6 +202,10 @@ export function ReloadPlugins() { return window['go']['api']['App']['ReloadPlugins'](); } +export function RestoreVaultTrash(arg1, arg2, arg3) { + return window['go']['api']['App']['RestoreVaultTrash'](arg1, arg2, arg3); +} + export function RenameWorkspace(arg1, arg2) { return window['go']['api']['App']['RenameWorkspace'](arg1, arg2); } @@ -238,10 +246,6 @@ export function TrashVaultPath(arg1, arg2) { return window['go']['api']['App']['TrashVaultPath'](arg1, arg2); } -export function ListVaultTrash(arg1) { - return window['go']['api']['App']['ListVaultTrash'](arg1); -} - export function TrashWorkspace(arg1) { return window['go']['api']['App']['TrashWorkspace'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 83c97fe..74e8403 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,5 +1,51 @@ export namespace api { + export class FlatContextMenuEntry { + pluginId: string; + id: string; + label: string; + context: string; + group?: string; + capability?: string; + handler?: string; + + static createFrom(source: any = {}) { + return new FlatContextMenuEntry(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.pluginId = source["pluginId"]; + this.id = source["id"]; + this.label = source["label"]; + this.context = source["context"]; + this.group = source["group"]; + this.capability = source["capability"]; + this.handler = source["handler"]; + } + } + export class FlatAction { + pluginId: string; + id: string; + label: string; + icon?: string; + capability?: string; + handler?: string; + + static createFrom(source: any = {}) { + return new FlatAction(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.pluginId = source["pluginId"]; + this.id = source["id"]; + this.label = source["label"]; + this.icon = source["icon"]; + this.capability = source["capability"]; + this.handler = source["handler"]; + } + } export class FlatWorkspaceItem { pluginId: string; id: string; @@ -80,24 +126,6 @@ export namespace api { return a; } } - export class FlatSearchProvider { - pluginId: string; - id: string; - label: string; - handler: string; - - static createFrom(source: any = {}) { - return new FlatSearchProvider(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.pluginId = source["pluginId"]; - this.id = source["id"]; - this.label = source["label"]; - this.handler = source["handler"]; - } - } export class FlatStatusBarItem { pluginId: string; id: string; @@ -160,6 +188,24 @@ export namespace api { this.component = source["component"]; } } + export class FlatSearchProvider { + pluginId: string; + id: string; + label: string; + handler: string; + + static createFrom(source: any = {}) { + return new FlatSearchProvider(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.pluginId = source["pluginId"]; + this.id = source["id"]; + this.label = source["label"]; + this.handler = source["handler"]; + } + } export class FlatCommand { pluginId: string; id: string; @@ -209,6 +255,9 @@ export namespace api { statusBarItems: FlatStatusBarItem[]; openProviders: FlatOpenProvider[]; workspaceItems: FlatWorkspaceItem[]; + fileActions: FlatAction[]; + noteActions: FlatAction[]; + contextMenuEntries: FlatContextMenuEntry[]; static createFrom(source: any = {}) { return new ContributionSummary(source); @@ -224,6 +273,9 @@ export namespace api { this.statusBarItems = this.convertValues(source["statusBarItems"], FlatStatusBarItem); this.openProviders = this.convertValues(source["openProviders"], FlatOpenProvider); this.workspaceItems = this.convertValues(source["workspaceItems"], FlatWorkspaceItem); + this.fileActions = this.convertValues(source["fileActions"], FlatAction); + this.noteActions = this.convertValues(source["noteActions"], FlatAction); + this.contextMenuEntries = this.convertValues(source["contextMenuEntries"], FlatContextMenuEntry); } convertValues(a: any, classs: any, asMap: boolean = false): any { @@ -252,6 +304,9 @@ export namespace api { + + + export class SyncStatusDTO { configured: boolean; serverUrl: string; @@ -390,6 +445,42 @@ export namespace files { this.overwrite = source["overwrite"]; } } + export class RestoreOptions { + targetPath?: string; + overwrite: boolean; + + static createFrom(source: any = {}) { + return new RestoreOptions(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.targetPath = source["targetPath"]; + this.overwrite = source["overwrite"]; + } + } + export class TrashEntry { + originalPath: string; + trashPath: string; + trashId: string; + deletedAt: string; + originalType: string; + basename: string; + + static createFrom(source: any = {}) { + return new TrashEntry(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.originalPath = source["originalPath"]; + this.trashPath = source["trashPath"]; + this.trashId = source["trashId"]; + this.deletedAt = source["deletedAt"]; + this.originalType = source["originalType"]; + this.basename = source["basename"]; + } + } export class TrashResult { originalPath: string; trashPath: string; diff --git a/internal/api/app.go b/internal/api/app.go index 9130bdd..82f5043 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -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 { diff --git a/internal/api/app_test.go b/internal/api/app_test.go index ab6a801..bb3b57d 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -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"}, diff --git a/internal/core/files/service.go b/internal/core/files/service.go index b4f8516..98f5378 100644 --- a/internal/core/files/service.go +++ b/internal/core/files/service.go @@ -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") diff --git a/internal/core/files/service_test.go b/internal/core/files/service_test.go index 2aa8e39..e291dc3 100644 --- a/internal/core/files/service_test.go +++ b/internal/core/files/service_test.go @@ -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") diff --git a/internal/core/files/types.go b/internal/core/files/types.go index d19b8e5..fa55427 100644 --- a/internal/core/files/types.go +++ b/internal/core/files/types.go @@ -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"`