Implement milestone 6b workbench routing skeleton

This commit is contained in:
2026-06-19 07:51:57 +08:00
parent a100f5a441
commit 6ed6df311a
53 changed files with 7592 additions and 335 deletions
+26 -8
View File
@@ -14,14 +14,21 @@ import (
// Config represents the application settings stored in ~/.config/verstak/config.json.
type Config struct {
SchemaVersion int `json:"schemaVersion"`
CurrentVaultPath string `json:"currentVaultPath"`
RecentVaults []string `json:"recentVaults"`
Theme string `json:"theme"`
DevMode bool `json:"devMode"`
UserPluginsDir string `json:"userPluginsDir"`
WindowState *WindowState `json:"windowState,omitempty"`
LastOpenedAt string `json:"lastOpenedAt"`
SchemaVersion int `json:"schemaVersion"`
CurrentVaultPath string `json:"currentVaultPath"`
RecentVaults []string `json:"recentVaults"`
Theme string `json:"theme"`
DevMode bool `json:"devMode"`
UserPluginsDir string `json:"userPluginsDir"`
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
WindowState *WindowState `json:"windowState,omitempty"`
LastOpenedAt string `json:"lastOpenedAt"`
}
type WorkbenchPreferences struct {
DefaultTextEditorProvider string `json:"defaultTextEditorProvider,omitempty"`
DefaultMarkdownEditorProvider string `json:"defaultMarkdownEditorProvider,omitempty"`
DefaultNotesMarkdownEditorProvider string `json:"defaultNotesMarkdownEditorProvider,omitempty"`
}
// WindowState stores the last window position and size.
@@ -156,6 +163,15 @@ func (m *Manager) Update(patch *Config) error {
if patch.WindowState != nil {
m.config.WindowState = patch.WindowState
}
if patch.Workbench.DefaultTextEditorProvider != "" {
m.config.Workbench.DefaultTextEditorProvider = patch.Workbench.DefaultTextEditorProvider
}
if patch.Workbench.DefaultMarkdownEditorProvider != "" {
m.config.Workbench.DefaultMarkdownEditorProvider = patch.Workbench.DefaultMarkdownEditorProvider
}
if patch.Workbench.DefaultNotesMarkdownEditorProvider != "" {
m.config.Workbench.DefaultNotesMarkdownEditorProvider = patch.Workbench.DefaultNotesMarkdownEditorProvider
}
m.config.DevMode = patch.DevMode
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
@@ -201,6 +217,7 @@ func defaultConfig() *Config {
Theme: "dark",
DevMode: false,
UserPluginsDir: filepath.Join(os.Getenv("HOME"), ".config", "verstak", "plugins"),
Workbench: WorkbenchPreferences{},
WindowState: &WindowState{Width: 1200, Height: 800},
LastOpenedAt: time.Now().UTC().Format(time.RFC3339),
}
@@ -216,6 +233,7 @@ func copyConfig(c *Config) *Config {
Theme: c.Theme,
DevMode: c.DevMode,
UserPluginsDir: c.UserPluginsDir,
Workbench: c.Workbench,
LastOpenedAt: c.LastOpenedAt,
}
if c.WindowState != nil {
+31
View File
@@ -124,6 +124,37 @@ func TestUpdate_Patch(t *testing.T) {
}
}
func TestUpdate_WorkbenchPreferences(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
m := NewManager(path)
if err := m.Load(); err != nil {
t.Fatal(err)
}
if err := m.Update(&Config{
Workbench: WorkbenchPreferences{
DefaultTextEditorProvider: "editor.text",
DefaultMarkdownEditorProvider: "editor.markdown",
DefaultNotesMarkdownEditorProvider: "editor.notes",
},
}); err != nil {
t.Fatalf("Update: %v", err)
}
reloaded := NewManager(path)
if err := reloaded.Load(); err != nil {
t.Fatal(err)
}
cfg := reloaded.Get()
if cfg.Workbench.DefaultTextEditorProvider != "editor.text" ||
cfg.Workbench.DefaultMarkdownEditorProvider != "editor.markdown" ||
cfg.Workbench.DefaultNotesMarkdownEditorProvider != "editor.notes" {
t.Fatalf("workbench preferences = %+v", cfg.Workbench)
}
}
func TestAppSettings_NotInsideVault(t *testing.T) {
// App settings path should be under ~/.config/verstak/, not inside vault
path := DefaultConfigPath()
+40
View File
@@ -22,6 +22,7 @@ type Registry struct {
searchProviders []ContributionSearchProvider
activityProviders []ContributionActivityProvider
statusBarItems []ContributionStatusBarItem
openProviders []ContributionOpenProvider
}
// ContributionPointType defines the type of contribution point.
@@ -38,6 +39,7 @@ const (
PointSearchProviders ContributionPointType = "searchProviders"
PointActivity ContributionPointType = "activityProviders"
PointStatusBar ContributionPointType = "statusBarItems"
PointOpenProviders ContributionPointType = "openProviders"
)
// ListByPoint returns all contributions for a given point type.
@@ -87,6 +89,10 @@ func (r *Registry) ListByPoint(point ContributionPointType) []interface{} {
for _, v := range r.statusBarItems {
result = append(result, v)
}
case PointOpenProviders:
for _, v := range r.openProviders {
result = append(result, v)
}
}
return result
}
@@ -136,6 +142,11 @@ type ContributionStatusBarItem struct {
Item plugin.ContributionStatusBarItem `json:"item"`
}
type ContributionOpenProvider struct {
PluginID string `json:"pluginId"`
Item plugin.ContributionOpenProvider `json:"item"`
}
// NewRegistry creates a new contribution registry.
func NewRegistry() *Registry {
return &Registry{}
@@ -159,6 +170,7 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
r.searchProviders = removeSearchProviders(r.searchProviders, pluginID)
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
for _, item := range c.Views {
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
@@ -190,6 +202,9 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
for _, item := range c.StatusBarItems {
r.statusBarItems = append(r.statusBarItems, ContributionStatusBarItem{PluginID: pluginID, Item: item})
}
for _, item := range c.OpenProviders {
r.openProviders = append(r.openProviders, ContributionOpenProvider{PluginID: pluginID, Item: item})
}
}
// Unregister removes all contributions from a plugin.
@@ -207,6 +222,7 @@ func (r *Registry) Unregister(pluginID string) {
r.searchProviders = removeSearchProviders(r.searchProviders, pluginID)
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
}
// Getters — sorted for deterministic display.
@@ -274,6 +290,20 @@ func (r *Registry) SearchProviders() []ContributionSearchProvider {
return result
}
func (r *Registry) OpenProviders() []ContributionOpenProvider {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ContributionOpenProvider, len(r.openProviders))
copy(result, r.openProviders)
sort.Slice(result, func(i, j int) bool {
if result[i].PluginID != result[j].PluginID {
return result[i].PluginID < result[j].PluginID
}
return result[i].Item.ID < result[j].Item.ID
})
return result
}
// ─── Remove helpers ─────────────────────────────────────────
func removeViews(items []ContributionView, pluginID string) []ContributionView {
@@ -365,3 +395,13 @@ func removeStatusBarItems(items []ContributionStatusBarItem, pluginID string) []
}
return result
}
func removeOpenProviders(items []ContributionOpenProvider, pluginID string) []ContributionOpenProvider {
var result []ContributionOpenProvider
for _, item := range items {
if item.PluginID != pluginID {
result = append(result, item)
}
}
return result
}
@@ -184,6 +184,17 @@ func TestListByPoint(t *testing.T) {
SearchProviders: []plugin.ContributionSearchProvider{{ID: "sp1", Label: "SP1", Handler: "h"}},
ActivityProviders: []plugin.ContributionActivityProvider{{ID: "ap1", Events: []string{"test"}, Handler: "h"}},
StatusBarItems: []plugin.ContributionStatusBarItem{{ID: "sb1", Label: "SB1"}},
OpenProviders: []plugin.ContributionOpenProvider{{
ID: "op1",
Title: "Open Provider 1",
Priority: 100,
Component: "OpenProvider",
Supports: []plugin.OpenProviderSupport{{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{"generic-markdown", "notes-markdown"},
}},
}},
}
r.Register("test.plugin", contrib)
@@ -202,6 +213,7 @@ func TestListByPoint(t *testing.T) {
{PointSearchProviders, 1},
{PointActivity, 1},
{PointStatusBar, 1},
{PointOpenProviders, 1},
}
for _, tt := range tests {
@@ -212,6 +224,55 @@ func TestListByPoint(t *testing.T) {
}
}
func TestOpenProviders_RegisterReplaceUnregister(t *testing.T) {
r := NewRegistry()
r.Register("editor.plugin", &plugin.Contributions{
OpenProviders: []plugin.ContributionOpenProvider{{
ID: "editor.markdown",
Title: "Markdown",
Priority: 50,
Component: "MarkdownEditor",
Supports: []plugin.OpenProviderSupport{{
Kind: "vault-file",
Extensions: []string{".md", ".markdown"},
Contexts: []string{"generic-markdown", "notes-markdown"},
}},
}},
})
providers := r.OpenProviders()
if len(providers) != 1 {
t.Fatalf("OpenProviders count = %d, want 1", len(providers))
}
if providers[0].PluginID != "editor.plugin" || providers[0].Item.Component != "MarkdownEditor" {
t.Fatalf("provider = %+v", providers[0])
}
r.Register("editor.plugin", &plugin.Contributions{
OpenProviders: []plugin.ContributionOpenProvider{{
ID: "editor.text",
Title: "Text",
Priority: 10,
Component: "TextEditor",
Supports: []plugin.OpenProviderSupport{{
Kind: "vault-file",
Extensions: []string{".txt"},
}},
}},
})
providers = r.OpenProviders()
if len(providers) != 1 || providers[0].Item.ID != "editor.text" {
t.Fatalf("providers after replace = %+v", providers)
}
r.Unregister("editor.plugin")
if got := len(r.OpenProviders()); got != 0 {
t.Fatalf("OpenProviders after unregister = %d, want 0", got)
}
}
// TestRegister_DuplicatePrevention calls Register twice for the same plugin
// (simulating reload) and checks contributions appear only once (no duplicates).
// This is the KEY TEST for idempotent re-registration.
+85
View File
@@ -0,0 +1,85 @@
package files
import (
"fmt"
"path"
"path/filepath"
"strings"
"unicode"
)
func NormalizeRelativeDir(relativeDir string) (string, error) {
return normalizeRelativePath(relativeDir, true)
}
func NormalizeRelativeFile(relativePath string) (string, error) {
return normalizeRelativePath(relativePath, false)
}
func IsReservedPath(relativePath string) bool {
normalized := strings.ReplaceAll(relativePath, "\\", "/")
cleaned := path.Clean(normalized)
if cleaned == "." {
cleaned = ""
}
if cleaned == "" {
return false
}
first := strings.Split(cleaned, "/")[0]
return strings.EqualFold(first, ".verstak")
}
func normalizeRelativePath(input string, allowRoot bool) (string, error) {
if strings.Contains(input, "\x00") {
return "", fmt.Errorf("invalid-path: null-byte")
}
if strings.Contains(input, "\\") {
return "", fmt.Errorf("invalid-path: backslash not allowed")
}
if looksAbsolute(input) {
return "", fmt.Errorf("invalid-path: absolute path rejected")
}
normalized := input
for _, part := range strings.Split(normalized, "/") {
if part == ".." {
return "", fmt.Errorf("invalid-path: path-traversal")
}
}
cleaned := path.Clean(normalized)
if cleaned == "." {
cleaned = ""
}
if cleaned == "" && !allowRoot {
return "", fmt.Errorf("invalid-path: empty path")
}
if cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", fmt.Errorf("invalid-path: path-traversal")
}
if IsReservedPathNoNormalize(cleaned) {
return "", fmt.Errorf("reserved-path: .verstak is internal")
}
return cleaned, nil
}
func IsReservedPathNoNormalize(cleaned string) bool {
if cleaned == "" {
return false
}
first := strings.Split(cleaned, "/")[0]
return strings.EqualFold(first, ".verstak")
}
func looksAbsolute(input string) bool {
if input == "" {
return false
}
if filepath.IsAbs(input) || strings.HasPrefix(input, "/") || strings.HasPrefix(input, "\\\\") || strings.HasPrefix(input, "\\") {
return true
}
if len(input) >= 2 && input[1] == ':' && unicode.IsLetter(rune(input[0])) {
return true
}
return false
}
+93
View File
@@ -0,0 +1,93 @@
package files
import (
"strings"
"testing"
)
func TestNormalizeRelativeDirAllowsRootAndPreservesCase(t *testing.T) {
got, err := NormalizeRelativeDir("")
if err != nil {
t.Fatalf("NormalizeRelativeDir root: %v", err)
}
if got != "" {
t.Fatalf("root dir = %q, want empty", got)
}
got, err = NormalizeRelativeDir("Notes/Overview.md")
if err != nil {
t.Fatalf("NormalizeRelativeDir preserves case: %v", err)
}
if got != "Notes/Overview.md" {
t.Fatalf("path = %q, want Notes/Overview.md", got)
}
}
func TestNormalizeRelativeFileRejectsUnsafePaths(t *testing.T) {
cases := []string{
"",
"/etc/passwd",
"C:\\Users\\file.txt",
"C:/Windows/system.ini",
`\\server\share`,
"//server/share",
`..\secret`,
`folder\..\secret`,
"../outside.txt",
"folder/../../outside.txt",
`folder\sub/../../secret`,
`folder\sub`,
"bad\x00name.txt",
".verstak",
".verstak/",
".verstak/vault.json",
"./.verstak",
".verstak/trash",
"folder/../.verstak",
".Verstak",
}
for _, input := range cases {
t.Run(input, func(t *testing.T) {
_, err := NormalizeRelativeFile(input)
if err == nil {
t.Fatalf("NormalizeRelativeFile(%q): expected error", input)
}
})
}
}
func TestReservedPathPolicy(t *testing.T) {
if !IsReservedPath(".verstak") {
t.Fatal(".verstak should be reserved")
}
if !IsReservedPath(".verstak/trash/file.txt") {
t.Fatal(".verstak/trash/file.txt should be reserved")
}
if !IsReservedPath(".Verstak/trash/file.txt") {
t.Fatal(".Verstak/trash/file.txt should be reserved by case-insensitive policy")
}
if IsReservedPath("Notes/.verstak.md") {
t.Fatal("Notes/.verstak.md should not be reserved")
}
}
func TestNormalizeRelativeFileAcceptsOnlySlashSeparatedRelativePaths(t *testing.T) {
got, err := NormalizeRelativeFile("Notes/Overview.md")
if err != nil {
t.Fatalf("NormalizeRelativeFile valid slash path: %v", err)
}
if got != "Notes/Overview.md" {
t.Fatalf("path = %q, want Notes/Overview.md", got)
}
}
func TestPathPolicyErrorsAreReadable(t *testing.T) {
_, err := NormalizeRelativeFile("../outside.txt")
if err == nil {
t.Fatal("expected traversal error")
}
if !strings.Contains(err.Error(), "path-traversal") {
t.Fatalf("error = %q, want path-traversal", err.Error())
}
}
+472
View File
@@ -0,0 +1,472 @@
package files
import (
"encoding/json"
"fmt"
"io/fs"
"mime"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"github.com/verstak/verstak-desktop/internal/core/vault"
)
type Service struct {
vault *vault.Vault
}
func NewService(v *vault.Vault) *Service {
return &Service{vault: v}
}
func (s *Service) ListVaultFiles(relativeDir string) ([]FileEntry, error) {
root, err := s.vaultRoot()
if err != nil {
return nil, err
}
rel, err := NormalizeRelativeDir(relativeDir)
if err != nil {
return nil, err
}
full, err := s.resolve(root, rel)
if err != nil {
return nil, err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return nil, err
}
info, err := os.Stat(full)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("not-found: %s", rel)
}
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("not-directory: %s", rel)
}
dirEntries, err := os.ReadDir(full)
if err != nil {
return nil, err
}
entries := make([]FileEntry, 0, len(dirEntries))
for _, dirEntry := range dirEntries {
childRel := joinRel(rel, dirEntry.Name())
if IsReservedPathNoNormalize(childRel) {
continue
}
info, err := dirEntry.Info()
if err != nil {
continue
}
entries = append(entries, makeEntry(childRel, info))
}
return entries, nil
}
func (s *Service) GetVaultFileMetadata(relativePath string) (FileMetadata, error) {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return FileMetadata{}, err
}
if err := rejectSymlinkPath(root, rel, false); err != nil {
return FileMetadata{}, err
}
info, err := os.Lstat(full)
if err != nil {
if os.IsNotExist(err) {
return FileMetadata{}, fmt.Errorf("not-found: %s", rel)
}
return FileMetadata{}, err
}
return makeMetadata(rel, info), nil
}
func (s *Service) ReadVaultTextFile(relativePath string) (string, error) {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return "", err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return "", err
}
info, err := os.Lstat(full)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("not-found: %s", rel)
}
return "", err
}
if info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("symlink-not-allowed: %s", rel)
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("not-regular-file: %s", rel)
}
if info.Size() > MaxTextFileBytes {
return "", fmt.Errorf("file-too-large: %s", rel)
}
data, err := os.ReadFile(full)
if err != nil {
return "", err
}
if !utf8.Valid(data) {
return "", fmt.Errorf("not-text-file: %s", rel)
}
return string(data), nil
}
func (s *Service) WriteVaultTextFile(relativePath string, content string, options WriteOptions) error {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return err
}
parent := filepath.Dir(full)
if info, err := os.Stat(parent); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("parent-not-found: %s", pathDir(rel))
}
return err
} else if !info.IsDir() {
return fmt.Errorf("parent-not-directory: %s", pathDir(rel))
}
existing, err := os.Lstat(full)
if err == nil {
if existing.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink-not-allowed: %s", rel)
}
if !existing.Mode().IsRegular() {
return fmt.Errorf("not-regular-file: %s", rel)
}
if !options.Overwrite {
return fmt.Errorf("conflict: %s", rel)
}
} else if os.IsNotExist(err) {
if !options.CreateIfMissing {
return fmt.Errorf("not-found: %s", rel)
}
} else {
return err
}
tmp, err := os.CreateTemp(parent, ".verstak-write-*")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.WriteString(content); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpName, full); err != nil {
return err
}
cleanup = false
return nil
}
func (s *Service) CreateVaultFolder(relativePath string) error {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return err
}
if _, err := os.Lstat(full); err == nil {
return fmt.Errorf("conflict: %s", rel)
} else if !os.IsNotExist(err) {
return err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return err
}
parent := filepath.Dir(full)
if info, err := os.Stat(parent); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("parent-not-found: %s", pathDir(rel))
}
return err
} else if !info.IsDir() {
return fmt.Errorf("parent-not-directory: %s", pathDir(rel))
}
return os.Mkdir(full, 0o755)
}
func (s *Service) MoveVaultPath(fromRelativePath string, toRelativePath string, options MoveOptions) error {
root, fromRel, fromFull, err := s.resolveFile(fromRelativePath)
if err != nil {
return err
}
_, toRel, toFull, err := s.resolveFile(toRelativePath)
if err != nil {
return err
}
if fromRel == "" || toRel == "" {
return fmt.Errorf("invalid-path: cannot move root")
}
if err := rejectSymlinkPath(root, fromRel, true); err != nil {
return err
}
if err := rejectSymlinkPath(root, toRel, false); err != nil {
return err
}
fromInfo, err := os.Lstat(fromFull)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("not-found: %s", fromRel)
}
return err
}
if fromInfo.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink-not-allowed: %s", fromRel)
}
if fromInfo.IsDir() && (toRel == fromRel || strings.HasPrefix(toRel, fromRel+"/")) {
return fmt.Errorf("move-into-self: %s -> %s", fromRel, toRel)
}
parent := filepath.Dir(toFull)
if info, err := os.Stat(parent); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("parent-not-found: %s", pathDir(toRel))
}
return err
} else if !info.IsDir() {
return fmt.Errorf("parent-not-directory: %s", pathDir(toRel))
}
if _, err := os.Lstat(toFull); err == nil && !options.Overwrite {
return fmt.Errorf("conflict: %s", toRel)
} else if err != nil && !os.IsNotExist(err) {
return err
}
return os.Rename(fromFull, toFull)
}
func (s *Service) TrashVaultPath(relativePath string) (TrashResult, error) {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return TrashResult{}, err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return TrashResult{}, err
}
info, err := os.Lstat(full)
if err != nil {
if os.IsNotExist(err) {
return TrashResult{}, fmt.Errorf("not-found: %s", rel)
}
return TrashResult{}, err
}
if info.Mode()&os.ModeSymlink != 0 {
return TrashResult{}, fmt.Errorf("symlink-not-allowed: %s", rel)
}
deletedAt := time.Now().UTC().Format(time.RFC3339Nano)
trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString()
trashRel := filepath.ToSlash(filepath.Join(".verstak", "trash", "files", trashID, filepath.Base(rel)))
trashFull := filepath.Join(root, filepath.FromSlash(trashRel))
if err := os.MkdirAll(filepath.Dir(trashFull), 0o755); err != nil {
return TrashResult{}, err
}
if err := os.Rename(full, trashFull); err != nil {
return TrashResult{}, err
}
result := TrashResult{
OriginalPath: rel,
TrashPath: trashRel,
TrashID: trashID,
DeletedAt: deletedAt,
}
meta := map[string]string{
"originalPath": rel,
"trashPath": trashRel,
"trashId": trashID,
"deletedAt": deletedAt,
"originalType": string(fileTypeFromInfo(info)),
"basename": filepath.Base(rel),
"type": string(fileTypeFromInfo(info)),
}
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return TrashResult{}, err
}
if err := os.WriteFile(filepath.Join(root, ".verstak", "trash", "files", trashID, "metadata.json"), data, 0o644); err != nil {
return TrashResult{}, err
}
return result, nil
}
func (s *Service) vaultRoot() (string, error) {
if s == nil || s.vault == nil {
return "", fmt.Errorf("vault-not-initialized")
}
if s.vault.GetVaultStatus() != vault.StatusOpen {
return "", fmt.Errorf("vault-not-open")
}
root := s.vault.GetVaultPath()
if root == "" {
return "", fmt.Errorf("vault-not-open")
}
return root, nil
}
func (s *Service) resolveFile(relativePath string) (string, string, string, error) {
root, err := s.vaultRoot()
if err != nil {
return "", "", "", err
}
rel, err := NormalizeRelativeFile(relativePath)
if err != nil {
return "", "", "", err
}
full, err := s.resolve(root, rel)
return root, rel, full, err
}
func (s *Service) resolve(root, rel string) (string, error) {
full := filepath.Join(root, filepath.FromSlash(rel))
absRoot, err := filepath.Abs(root)
if err != nil {
return "", err
}
absFull, err := filepath.Abs(full)
if err != nil {
return "", err
}
relToRoot, err := filepath.Rel(absRoot, absFull)
if err != nil {
return "", err
}
if relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(os.PathSeparator)) || filepath.IsAbs(relToRoot) {
return "", fmt.Errorf("invalid-path: path-traversal")
}
return absFull, nil
}
func rejectSymlinkPath(root, rel string, includeFinal bool) error {
if rel == "" {
return nil
}
parts := strings.Split(rel, "/")
limit := len(parts)
if !includeFinal {
limit--
}
current := root
for i := 0; i < limit; i++ {
current = filepath.Join(current, filepath.FromSlash(parts[i]))
info, err := os.Lstat(current)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink-not-allowed: %s", strings.Join(parts[:i+1], "/"))
}
}
return nil
}
func makeEntry(rel string, info fs.FileInfo) FileEntry {
t := fileTypeFromInfo(info)
return FileEntry{
Name: info.Name(),
RelativePath: rel,
Type: t,
Size: sizeForType(t, info),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
Extension: strings.TrimPrefix(filepath.Ext(info.Name()), "."),
IsHidden: strings.HasPrefix(info.Name(), "."),
IsReserved: IsReservedPathNoNormalize(rel),
CanRead: t == FileTypeFile || t == FileTypeFolder,
CanWrite: t == FileTypeFile || t == FileTypeFolder,
}
}
func makeMetadata(rel string, info fs.FileInfo) FileMetadata {
t := fileTypeFromInfo(info)
ext := strings.TrimPrefix(filepath.Ext(info.Name()), ".")
return FileMetadata{
RelativePath: rel,
Type: t,
Size: sizeForType(t, info),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
Extension: ext,
MimeHint: mime.TypeByExtension(filepath.Ext(info.Name())),
IsText: isTextExtension(ext),
IsHidden: strings.HasPrefix(info.Name(), "."),
IsReserved: IsReservedPathNoNormalize(rel),
CanRead: t == FileTypeFile || t == FileTypeFolder,
CanWrite: t == FileTypeFile || t == FileTypeFolder,
}
}
func fileTypeFromInfo(info fs.FileInfo) FileType {
if info.Mode()&os.ModeSymlink != 0 {
return FileTypeSymlink
}
if info.IsDir() {
return FileTypeFolder
}
if info.Mode().IsRegular() {
return FileTypeFile
}
return FileTypeUnknown
}
func sizeForType(t FileType, info fs.FileInfo) int64 {
if t == FileTypeFolder {
return 0
}
return info.Size()
}
func isTextExtension(ext string) bool {
switch strings.ToLower(ext) {
case "txt", "md", "markdown", "json", "yaml", "yml", "toml", "csv", "log", "xml", "html", "css", "js", "ts", "svelte", "go":
return true
default:
return false
}
}
func joinRel(parent, name string) string {
if parent == "" {
return name
}
return parent + "/" + name
}
func pathDir(rel string) string {
dir := pathDirSlash(rel)
if dir == "." {
return ""
}
return dir
}
func pathDirSlash(rel string) string {
idx := strings.LastIndex(rel, "/")
if idx < 0 {
return "."
}
return rel[:idx]
}
+436
View File
@@ -0,0 +1,436 @@
package files
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/verstak/verstak-desktop/internal/core/vault"
)
func newTestService(t *testing.T) (*Service, string) {
t.Helper()
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
return NewService(v), v.GetVaultPath()
}
func TestServiceRequiresOpenVault(t *testing.T) {
v := vault.NewVault(nil)
s := NewService(v)
if _, err := s.ListVaultFiles(""); err == nil {
t.Fatal("ListVaultFiles with closed vault: expected error")
}
}
func TestListVaultFilesExcludesReservedAndReturnsEntries(t *testing.T) {
s, root := newTestService(t)
if err := os.WriteFile(filepath.Join(root, "readme.md"), []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "Docs"), 0o755); err != nil {
t.Fatal(err)
}
entries, err := s.ListVaultFiles("")
if err != nil {
t.Fatalf("ListVaultFiles: %v", err)
}
names := map[string]FileEntry{}
for _, entry := range entries {
names[entry.Name] = entry
if strings.HasPrefix(entry.RelativePath, ".verstak") {
t.Fatalf("reserved entry leaked into list: %+v", entry)
}
}
if names["readme.md"].Type != FileTypeFile {
t.Fatalf("readme.md type = %q", names["readme.md"].Type)
}
if names["Docs"].Type != FileTypeFolder {
t.Fatalf("Docs type = %q", names["Docs"].Type)
}
}
func TestPathPolicyRejectsUnsafeOperations(t *testing.T) {
s, _ := newTestService(t)
cases := []string{
"/etc/passwd",
"C:\\Windows\\system.ini",
"C:/Windows/system.ini",
`\\server\share`,
"//server/share",
`..\outside`,
`folder\..\outside`,
"../outside",
"folder/../../outside",
`folder\sub/../../outside`,
"bad\x00path",
".verstak",
".verstak/",
".verstak/vault.json",
"./.verstak",
".Verstak/trash",
}
for _, input := range cases {
t.Run(input, func(t *testing.T) {
if _, err := s.GetVaultFileMetadata(input); err == nil {
t.Fatal("metadata: expected error")
}
if _, err := s.ReadVaultTextFile(input); err == nil {
t.Fatal("read: expected error")
}
if err := s.WriteVaultTextFile(input, "x", WriteOptions{CreateIfMissing: true}); err == nil {
t.Fatal("write: expected error")
}
if err := s.MoveVaultPath(input, "safe.txt", MoveOptions{}); err == nil {
t.Fatal("move: expected error")
}
if _, err := s.TrashVaultPath(input); err == nil {
t.Fatal("trash: expected error")
}
})
}
}
func TestReadVaultTextFileRules(t *testing.T) {
s, root := newTestService(t)
if err := os.WriteFile(filepath.Join(root, "note.md"), []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "Folder"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "binary.bin"), []byte{0xff, 0xfe, 0xfd}, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "huge.txt"), []byte(strings.Repeat("a", int(MaxTextFileBytes)+1)), 0o644); err != nil {
t.Fatal(err)
}
text, err := s.ReadVaultTextFile("note.md")
if err != nil {
t.Fatalf("ReadVaultTextFile note: %v", err)
}
if text != "hello\n" {
t.Fatalf("text = %q", text)
}
if _, err := s.ReadVaultTextFile("Folder"); err == nil || !strings.Contains(err.Error(), "not-regular-file") {
t.Fatalf("read folder error = %v, want not-regular-file", err)
}
if _, err := s.ReadVaultTextFile("missing.md"); err == nil || !strings.Contains(err.Error(), "not-found") {
t.Fatalf("read missing error = %v, want not-found", err)
}
if _, err := s.ReadVaultTextFile("huge.txt"); err == nil || !strings.Contains(err.Error(), "file-too-large") {
t.Fatalf("read huge error = %v, want file-too-large", err)
}
if _, err := s.ReadVaultTextFile("binary.bin"); err == nil || !strings.Contains(err.Error(), "not-text-file") {
t.Fatalf("read binary error = %v, want not-text-file", err)
}
}
func TestWriteVaultTextFileAtomicAndConflictBehavior(t *testing.T) {
s, root := newTestService(t)
if err := s.WriteVaultTextFile("Notes/one.md", "first", WriteOptions{CreateIfMissing: true}); err == nil {
t.Fatal("write should fail when parent folder is missing")
}
if err := s.CreateVaultFolder("Notes"); err != nil {
t.Fatalf("CreateVaultFolder: %v", err)
}
if err := s.WriteVaultTextFile("Notes/one.md", "first", WriteOptions{CreateIfMissing: true}); err != nil {
t.Fatalf("write create: %v", err)
}
if err := s.WriteVaultTextFile("Notes/one.md", "second", WriteOptions{CreateIfMissing: true}); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("write conflict error = %v, want conflict", err)
}
if err := s.WriteVaultTextFile("Notes/one.md", "second", WriteOptions{CreateIfMissing: true, Overwrite: true}); err != nil {
t.Fatalf("write overwrite: %v", err)
}
data, err := os.ReadFile(filepath.Join(root, "Notes", "one.md"))
if err != nil {
t.Fatal(err)
}
if string(data) != "second" {
t.Fatalf("file content = %q", string(data))
}
matches, err := filepath.Glob(filepath.Join(root, "Notes", ".verstak-write-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("atomic write left temp files: %v", matches)
}
if err := s.WriteVaultTextFile("", "root", WriteOptions{CreateIfMissing: true}); err == nil || !strings.Contains(err.Error(), "empty path") {
t.Fatalf("write root error = %v, want empty path", err)
}
}
func TestCreateVaultFolderConflict(t *testing.T) {
s, _ := newTestService(t)
if err := s.CreateVaultFolder("Folder"); err != nil {
t.Fatalf("CreateVaultFolder first: %v", err)
}
if err := s.CreateVaultFolder("Folder"); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("CreateVaultFolder conflict error = %v, want conflict", err)
}
}
func TestMoveVaultPathRules(t *testing.T) {
s, root := newTestService(t)
if err := os.Mkdir(filepath.Join(root, "A"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "A", "one.txt"), []byte("one"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "target.txt"), []byte("target"), 0o644); err != nil {
t.Fatal(err)
}
if err := s.MoveVaultPath("A/one.txt", "moved.txt", MoveOptions{}); err != nil {
t.Fatalf("move file: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "moved.txt")); err != nil {
t.Fatalf("moved file missing: %v", err)
}
if err := s.MoveVaultPath("A", "B", MoveOptions{}); err != nil {
t.Fatalf("move folder: %v", err)
}
if err := os.Mkdir(filepath.Join(root, "C"), 0o755); err != nil {
t.Fatal(err)
}
if err := s.MoveVaultPath("C", "C/Child", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "move-into-self") {
t.Fatalf("move into self error = %v, want move-into-self", err)
}
if err := s.MoveVaultPath("moved.txt", "target.txt", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("move conflict error = %v, want conflict", err)
}
if err := s.MoveVaultPath("", "root-move", MoveOptions{}); err == nil {
t.Fatal("move root should fail")
}
}
func TestTrashVaultPathMovesToReservedTrashAndHidesFromList(t *testing.T) {
s, root := newTestService(t)
if err := os.WriteFile(filepath.Join(root, "delete-me.txt"), []byte("bye"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "delete-folder"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "same.txt"), []byte("one"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "Other"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Other", "same.txt"), []byte("two"), 0o644); err != nil {
t.Fatal(err)
}
fileResult, err := s.TrashVaultPath("delete-me.txt")
if err != nil {
t.Fatalf("trash file: %v", err)
}
if fileResult.OriginalPath != "delete-me.txt" || fileResult.TrashID == "" || fileResult.DeletedAt == "" {
t.Fatalf("unexpected trash result: %+v", fileResult)
}
if _, err := os.Stat(filepath.Join(root, fileResult.TrashPath)); err != nil {
t.Fatalf("trashed file missing: %v", err)
}
metaPath := filepath.Join(root, ".verstak", "trash", "files", fileResult.TrashID, "metadata.json")
metaData, err := os.ReadFile(metaPath)
if err != nil {
t.Fatalf("trash metadata missing: %v", err)
}
var meta map[string]string
if err := json.Unmarshal(metaData, &meta); err != nil {
t.Fatalf("trash metadata invalid JSON: %v", err)
}
for _, key := range []string{"originalPath", "deletedAt", "originalType", "trashId", "basename"} {
if meta[key] == "" {
t.Fatalf("trash metadata missing %s: %s", key, string(metaData))
}
}
if meta["basename"] != "delete-me.txt" || meta["originalType"] != string(FileTypeFile) {
t.Fatalf("trash metadata = %+v", meta)
}
if _, err := s.TrashVaultPath("delete-folder"); err != nil {
t.Fatalf("trash folder: %v", err)
}
firstSame, err := s.TrashVaultPath("same.txt")
if err != nil {
t.Fatalf("trash same root: %v", err)
}
secondSame, err := s.TrashVaultPath("Other/same.txt")
if err != nil {
t.Fatalf("trash same nested: %v", err)
}
if firstSame.TrashID == secondSame.TrashID || firstSame.TrashPath == secondSame.TrashPath {
t.Fatalf("repeated trash basename collided: first=%+v second=%+v", firstSame, secondSame)
}
if _, err := s.TrashVaultPath(""); err == nil {
t.Fatal("trash root should fail")
}
if _, err := s.TrashVaultPath("missing.txt"); err == nil || !strings.Contains(err.Error(), "not-found") {
t.Fatalf("trash missing error = %v, want not-found", err)
}
entries, err := s.ListVaultFiles("")
if err != nil {
t.Fatalf("ListVaultFiles: %v", err)
}
for _, entry := range entries {
if entry.Name == "delete-me.txt" || entry.Name == "delete-folder" || entry.Name == ".verstak" {
t.Fatalf("unexpected entry after trash: %+v", entry)
}
}
}
func TestSymlinkEscapeRejected(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on many Windows test environments")
}
s, root := newTestService(t)
outside := t.TempDir()
outsideFile := filepath.Join(outside, "outside.txt")
if err := os.WriteFile(outsideFile, []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideFile, filepath.Join(root, "escape.txt")); err != nil {
t.Skipf("symlink not supported in this environment: %v", err)
}
meta, err := s.GetVaultFileMetadata("escape.txt")
if err != nil {
t.Fatalf("metadata symlink: %v", err)
}
if meta.Type != FileTypeSymlink {
t.Fatalf("symlink type = %q", meta.Type)
}
if _, err := s.ReadVaultTextFile("escape.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("read symlink error = %v, want symlink-not-allowed", err)
}
if err := s.WriteVaultTextFile("escape.txt", "x", WriteOptions{Overwrite: true}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("write symlink error = %v, want symlink-not-allowed", err)
}
if err := s.MoveVaultPath("escape.txt", "moved-link.txt", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("move symlink error = %v, want symlink-not-allowed", err)
}
if _, err := s.TrashVaultPath("escape.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("trash symlink error = %v, want symlink-not-allowed", err)
}
}
func TestSymlinkInsideVaultRejectedForMutatingAndReadOperations(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on many Windows test environments")
}
s, root := newTestService(t)
if err := os.WriteFile(filepath.Join(root, "target.txt"), []byte("inside"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(root, "target.txt"), filepath.Join(root, "inside-link.txt")); err != nil {
t.Skipf("symlink not supported in this environment: %v", err)
}
meta, err := s.GetVaultFileMetadata("inside-link.txt")
if err != nil {
t.Fatalf("metadata inside symlink: %v", err)
}
if meta.Type != FileTypeSymlink {
t.Fatalf("symlink type = %q", meta.Type)
}
if _, err := s.ReadVaultTextFile("inside-link.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("read inside symlink error = %v, want symlink-not-allowed", err)
}
if err := s.WriteVaultTextFile("inside-link.txt", "x", WriteOptions{Overwrite: true}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("write inside symlink error = %v, want symlink-not-allowed", err)
}
if err := s.MoveVaultPath("inside-link.txt", "moved-link.txt", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("move inside symlink error = %v, want symlink-not-allowed", err)
}
if _, err := s.TrashVaultPath("inside-link.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("trash inside symlink error = %v, want symlink-not-allowed", err)
}
matches, err := filepath.Glob(filepath.Join(root, ".verstak-write-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("write symlink left root temp files: %v", matches)
}
}
func TestListVaultFilesRejectsSymlinkDirectoryEscape(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on many Windows test environments")
}
s, root := newTestService(t)
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "outside.txt"), []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "outside-dir")); err != nil {
t.Skipf("symlink not supported in this environment: %v", err)
}
if _, err := s.ListVaultFiles("outside-dir"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("list symlink dir error = %v, want symlink-not-allowed", err)
}
entries, err := s.ListVaultFiles("")
if err != nil {
t.Fatalf("list root: %v", err)
}
var foundSymlink bool
for _, entry := range entries {
if entry.RelativePath == "outside-dir" {
foundSymlink = true
if entry.Type != FileTypeSymlink {
t.Fatalf("root symlink entry type = %q, want symlink", entry.Type)
}
}
}
if !foundSymlink {
t.Fatal("root list should expose the symlink as metadata without following it")
}
}
func TestCreateVaultFolderRejectsSymlinkParentEscape(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on many Windows test environments")
}
s, root := newTestService(t)
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, "outside-dir")); err != nil {
t.Skipf("symlink not supported in this environment: %v", err)
}
if err := s.CreateVaultFolder("outside-dir/new-folder"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("create folder through symlink parent error = %v, want symlink-not-allowed", err)
}
if _, err := os.Stat(filepath.Join(outside, "new-folder")); !os.IsNotExist(err) {
t.Fatalf("folder should not be created outside vault, stat err=%v", err)
}
}
+56
View File
@@ -0,0 +1,56 @@
package files
const MaxTextFileBytes int64 = 2 * 1024 * 1024
type FileType string
const (
FileTypeFile FileType = "file"
FileTypeFolder FileType = "folder"
FileTypeSymlink FileType = "symlink"
FileTypeUnknown FileType = "unknown"
)
type FileEntry struct {
Name string `json:"name"`
RelativePath string `json:"relativePath"`
Type FileType `json:"type"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
Extension string `json:"extension"`
IsHidden bool `json:"isHidden"`
IsReserved bool `json:"isReserved"`
CanRead bool `json:"canRead"`
CanWrite bool `json:"canWrite"`
}
type FileMetadata struct {
RelativePath string `json:"relativePath"`
Type FileType `json:"type"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
CreatedAt string `json:"createdAt,omitempty"`
Extension string `json:"extension"`
MimeHint string `json:"mimeHint"`
IsText bool `json:"isText"`
IsHidden bool `json:"isHidden"`
IsReserved bool `json:"isReserved"`
CanRead bool `json:"canRead"`
CanWrite bool `json:"canWrite"`
}
type WriteOptions struct {
CreateIfMissing bool `json:"createIfMissing"`
Overwrite bool `json:"overwrite"`
}
type MoveOptions struct {
Overwrite bool `json:"overwrite"`
}
type TrashResult struct {
OriginalPath string `json:"originalPath"`
TrashPath string `json:"trashPath"`
TrashID string `json:"trashId"`
DeletedAt string `json:"deletedAt"`
}
+4
View File
@@ -33,12 +33,16 @@ func (r *Registry) registerDefaults() {
{Name: "vault.read", Description: "Read vault files and metadata", Dangerous: false},
{Name: "vault.write", Description: "Write vault files and metadata", Dangerous: true},
{Name: "vault.watch", Description: "Watch vault file changes", Dangerous: false},
{Name: "files.read", Description: "List files and read text files through the vault Files API", Dangerous: false},
{Name: "files.write", Description: "Create folders, write text files, and move paths through the vault Files API", Dangerous: true},
{Name: "files.delete", Description: "Trash vault files and folders through the vault Files API", Dangerous: true},
{Name: "storage.namespace", Description: "Read/write plugin's own storage namespace", Dangerous: false},
{Name: "storage.migrations", Description: "Run database migrations in plugin namespace", Dangerous: false},
{Name: "events.publish", Description: "Publish events to the event bus", Dangerous: false},
{Name: "events.subscribe", Description: "Subscribe to events on the event bus", Dangerous: false},
{Name: "ui.register", Description: "Register UI components and contributions", Dangerous: false},
{Name: "commands.register", Description: "Register command palette commands", Dangerous: false},
{Name: "workbench.open", Description: "Request Workbench open/edit routing for vault resources", Dangerous: false},
{Name: "network.local", Description: "Connect to localhost network services", Dangerous: false},
{Name: "network.remote", Description: "Connect to remote network services", Dangerous: true},
{Name: "process.spawn", Description: "Spawn external processes", Dangerous: true},
+71
View File
@@ -0,0 +1,71 @@
package plugin
import (
"os"
"path/filepath"
"strings"
)
// DirResolveOptions makes plugin directory resolution testable.
type DirResolveOptions struct {
EnvPluginDir string
CWD string
ExecutablePath string
UserConfigDir string
HomeDir string
}
// ResolveDiscoveryDirs returns plugin discovery directories in priority order:
// explicit env override, dev ./plugins, packaged binary-adjacent plugins, user plugins.
func ResolveDiscoveryDirs(opts DirResolveOptions) []string {
var dirs []string
add := func(path string) {
if path == "" {
return
}
cleaned := filepath.Clean(path)
for _, existing := range dirs {
if existing == cleaned {
return
}
}
dirs = append(dirs, cleaned)
}
if opts.EnvPluginDir != "" {
for _, path := range filepath.SplitList(opts.EnvPluginDir) {
add(path)
}
}
if opts.CWD != "" {
add(filepath.Join(opts.CWD, "plugins"))
}
if opts.ExecutablePath != "" {
add(filepath.Join(filepath.Dir(opts.ExecutablePath), "plugins"))
}
if opts.UserConfigDir != "" {
add(filepath.Join(opts.UserConfigDir, "verstak", "plugins"))
} else if opts.HomeDir != "" {
add(filepath.Join(opts.HomeDir, ".config", "verstak", "plugins"))
}
return dirs
}
// DefaultDiscoveryDirs resolves discovery directories from the current process.
func DefaultDiscoveryDirs() []string {
cwd, _ := os.Getwd()
exe, _ := os.Executable()
userConfig, _ := os.UserConfigDir()
home, _ := os.UserHomeDir()
return ResolveDiscoveryDirs(DirResolveOptions{
EnvPluginDir: strings.TrimSpace(os.Getenv("VERSTAK_PLUGIN_DIR")),
CWD: cwd,
ExecutablePath: exe,
UserConfigDir: userConfig,
HomeDir: home,
})
}
+71
View File
@@ -0,0 +1,71 @@
package plugin
import (
"path/filepath"
"reflect"
"testing"
)
func TestResolveDiscoveryDirs_EnvCwdBinaryUserDedup(t *testing.T) {
root := t.TempDir()
envDir := filepath.Join(root, "env-plugins")
cwdDir := filepath.Join(root, "repo", "plugins")
binaryDir := filepath.Join(root, "app", "plugins")
userConfigDir := filepath.Join(root, "config")
got := ResolveDiscoveryDirs(DirResolveOptions{
EnvPluginDir: envDir + string(filepath.ListSeparator) + cwdDir,
CWD: filepath.Join(root, "repo"),
ExecutablePath: filepath.Join(root, "app", "verstak-desktop"),
UserConfigDir: userConfigDir,
})
want := []string{
envDir,
cwdDir,
binaryDir,
filepath.Join(userConfigDir, "verstak", "plugins"),
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ResolveDiscoveryDirs() = %#v, want %#v", got, want)
}
}
func TestResolveDiscoveryDirs_UsesCwdWhenExecutablePathMissing(t *testing.T) {
root := t.TempDir()
got := ResolveDiscoveryDirs(DirResolveOptions{
CWD: root,
HomeDir: filepath.Join(root, "home"),
})
wantFirst := filepath.Join(root, "plugins")
if got[0] != wantFirst {
t.Fatalf("first plugin dir = %q, want cwd plugins %q", got[0], wantFirst)
}
}
func TestResolveDiscoveryDirs_FallsBackToHomeConfigDir(t *testing.T) {
root := t.TempDir()
got := ResolveDiscoveryDirs(DirResolveOptions{
HomeDir: filepath.Join(root, "home"),
})
want := []string{filepath.Join(root, "home", ".config", "verstak", "plugins")}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ResolveDiscoveryDirs() = %#v, want %#v", got, want)
}
}
func TestResolveDiscoveryDirs_NormalizesAndDeduplicatesPaths(t *testing.T) {
root := t.TempDir()
cwd := filepath.Join(root, "repo")
got := ResolveDiscoveryDirs(DirResolveOptions{
EnvPluginDir: filepath.Join(cwd, ".", "plugins") + string(filepath.ListSeparator) + filepath.Join(cwd, "plugins"),
CWD: cwd,
})
want := []string{filepath.Join(cwd, "plugins")}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ResolveDiscoveryDirs() = %#v, want %#v", got, want)
}
}
+44 -5
View File
@@ -67,6 +67,7 @@ type Contributions struct {
SearchProviders []ContributionSearchProvider `json:"searchProviders,omitempty"`
ActivityProviders []ContributionActivityProvider `json:"activityProviders,omitempty"`
StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"`
OpenProviders []ContributionOpenProvider `json:"openProviders,omitempty"`
}
// ContributionView represents a view contribution.
@@ -144,6 +145,23 @@ type ContributionStatusBarItem struct {
Handler string `json:"handler,omitempty"`
}
// OpenProviderSupport describes a resource shape an open provider can handle.
type OpenProviderSupport struct {
Kind string `json:"kind"`
Mime []string `json:"mime,omitempty"`
Extensions []string `json:"extensions,omitempty"`
Contexts []string `json:"contexts,omitempty"`
}
// ContributionOpenProvider represents an editor/viewer provider contribution.
type ContributionOpenProvider struct {
ID string `json:"id"`
Title string `json:"title"`
Priority int `json:"priority,omitempty"`
Component string `json:"component"`
Supports []OpenProviderSupport `json:"supports"`
}
// SyncConfig describes plugin sync configuration.
type SyncConfig struct {
Namespaces []string `json:"namespaces,omitempty"`
@@ -209,6 +227,27 @@ func ValidateManifest(m *Manifest) []string {
if len(m.Permissions) == 0 {
errs.add("permissions must have at least one permission")
}
if m.Contributes != nil {
for i, provider := range m.Contributes.OpenProviders {
if provider.ID == "" {
errs.add("contributes.openProviders[%d].id is required", i)
}
if provider.Title == "" {
errs.add("contributes.openProviders[%d].title is required", i)
}
if provider.Component == "" {
errs.add("contributes.openProviders[%d].component is required", i)
}
if len(provider.Supports) == 0 {
errs.add("contributes.openProviders[%d].supports must have at least one entry", i)
}
for j, support := range provider.Supports {
if support.Kind == "" {
errs.add("contributes.openProviders[%d].supports[%d].kind is required", i, j)
}
}
}
}
return errs.errors
}
@@ -249,7 +288,7 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
var plugins []Plugin
var errs []error
seen := make(map[string]bool)
seen := make(map[string]string)
log.Printf("[discovery] start: %d dir(s): %v", len(dirs), dirs)
@@ -287,12 +326,12 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
continue
}
if seen[plugin.Manifest.ID] {
errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s", plugin.Manifest.ID, pluginDir))
log.Printf("[discovery] %s: duplicate ID %q (skip)", entry.Name(), plugin.Manifest.ID)
if existingPath, ok := seen[plugin.Manifest.ID]; ok {
errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s (already loaded from %s); first plugin wins", plugin.Manifest.ID, pluginDir, existingPath))
log.Printf("[discovery] %s: duplicate ID %q in %s (already loaded from %s; skip)", entry.Name(), plugin.Manifest.ID, pluginDir, existingPath)
continue
}
seen[plugin.Manifest.ID] = true
seen[plugin.Manifest.ID] = pluginDir
plugins = append(plugins, plugin)
log.Printf("[discovery] %s: ✅ %s@%s", entry.Name(), plugin.Manifest.ID, plugin.Manifest.Version)
}
+84
View File
@@ -149,6 +149,90 @@ func TestDiscoverPlugins_DuplicateID(t *testing.T) {
}
}
func TestDiscoverPlugins_DuplicateIDAcrossDirs_FirstWinsAndReportsBothPaths(t *testing.T) {
dir1 := t.TempDir()
dir2 := t.TempDir()
firstPath := createTempPlugin(t, dir1, "shared.plugin", "First")
secondPath := filepath.Join(dir2, "other-name")
if err := os.MkdirAll(secondPath, 0755); err != nil {
t.Fatal(err)
}
manifest := `{
"schemaVersion": 1,
"id": "shared.plugin",
"name": "Second",
"version": "2.0.0",
"apiVersion": "1.0",
"provides": ["shared.plugin.second.cap"],
"permissions": ["vault.read"]
}`
if err := os.WriteFile(filepath.Join(secondPath, "plugin.json"), []byte(manifest), 0644); err != nil {
t.Fatal(err)
}
plugins, errs := DiscoverPlugins([]string{dir1, dir2})
if len(plugins) != 1 {
t.Fatalf("expected first plugin only, got %d", len(plugins))
}
if plugins[0].RootPath != firstPath {
t.Fatalf("winner path = %q, want %q", plugins[0].RootPath, firstPath)
}
combined := ""
for _, err := range errs {
combined += err.Error()
}
if !strings.Contains(combined, "duplicate plugin ID") {
t.Fatalf("expected duplicate error, got %v", errs)
}
if !strings.Contains(combined, firstPath) || !strings.Contains(combined, secondPath) {
t.Fatalf("duplicate error should include both paths; got %q", combined)
}
}
func TestValidateManifest_OpenProviders(t *testing.T) {
valid := &Manifest{
SchemaVersion: 1,
ID: "editor.plugin",
Name: "Editor",
Version: "1.0.0",
APIVersion: "1.0",
Provides: []string{"editor.text"},
Permissions: []string{"workbench.open"},
Contributes: &Contributions{
OpenProviders: []ContributionOpenProvider{{
ID: "editor.text",
Title: "Text Editor",
Component: "TextEditor",
Supports: []OpenProviderSupport{{
Kind: "vault-file",
Extensions: []string{".txt"},
Contexts: []string{"generic-text"},
}},
}},
},
}
if errs := ValidateManifest(valid); len(errs) != 0 {
t.Fatalf("valid manifest errors = %v", errs)
}
invalid := *valid
invalid.Contributes = &Contributions{
OpenProviders: []ContributionOpenProvider{{
ID: "broken",
Title: "Broken",
Component: "",
Supports: []OpenProviderSupport{{}},
}},
}
errs := ValidateManifest(&invalid)
combined := strings.Join(errs, "\n")
if !strings.Contains(combined, "component is required") || !strings.Contains(combined, "kind is required") {
t.Fatalf("expected open provider validation errors, got %v", errs)
}
}
// TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories.
func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
dir1 := t.TempDir()
+257
View File
@@ -0,0 +1,257 @@
// Package workbench routes open/edit resource requests to contributed providers.
package workbench
import (
"fmt"
"path"
"sort"
"strings"
"time"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/plugin"
)
type Preferences struct {
DefaultTextEditorProvider string `json:"defaultTextEditorProvider,omitempty"`
DefaultMarkdownEditorProvider string `json:"defaultMarkdownEditorProvider,omitempty"`
DefaultNotesMarkdownEditorProvider string `json:"defaultNotesMarkdownEditorProvider,omitempty"`
}
const (
ContextGenericText = "generic-text"
ContextGenericMarkdown = "generic-markdown"
ContextNotesMarkdown = "notes-markdown"
)
type OpenResourceContext struct {
SourcePluginID string `json:"sourcePluginId,omitempty"`
SourceView string `json:"sourceView,omitempty"`
IsInsideNotesFolder bool `json:"isInsideNotesFolder,omitempty"`
NotesScopePath string `json:"notesScopePath,omitempty"`
NotesMode bool `json:"notesMode,omitempty"`
}
type OpenResourceRequest struct {
Kind string `json:"kind"`
Path string `json:"path"`
Mode string `json:"mode,omitempty"`
Mime string `json:"mime,omitempty"`
Extension string `json:"extension,omitempty"`
Context OpenResourceContext `json:"context,omitempty"`
}
type OpenResourceResult struct {
Status string `json:"status"`
ProviderID string `json:"providerId,omitempty"`
ProviderPluginID string `json:"providerPluginId,omitempty"`
ProviderComponent string `json:"providerComponent,omitempty"`
Request OpenResourceRequest `json:"request"`
Message string `json:"message,omitempty"`
}
type OpenedResource struct {
ID string `json:"id"`
ProviderID string `json:"providerId"`
ProviderPluginID string `json:"providerPluginId"`
ProviderComponent string `json:"providerComponent"`
Request OpenResourceRequest `json:"request"`
OpenedAt string `json:"openedAt"`
}
type Router struct {
preferences Preferences
opened []OpenedResource
}
func NewRouter(preferences Preferences) *Router {
return &Router{preferences: preferences}
}
func (r *Router) Preferences() Preferences {
return r.preferences
}
func (r *Router) SetPreferences(preferences Preferences) {
r.preferences = preferences
}
func (r *Router) SelectProvider(request OpenResourceRequest, providers []contribution.ContributionOpenProvider) (contribution.ContributionOpenProvider, error) {
request = normalizeRequest(request)
var matches []contribution.ContributionOpenProvider
for _, provider := range providers {
if providerMatches(request, provider.Item) {
matches = append(matches, provider)
}
}
if len(matches) == 0 {
return contribution.ContributionOpenProvider{}, fmt.Errorf("no open provider supports %s %q", request.Kind, request.Path)
}
preferred := r.preferenceFor(request)
if preferred != "" {
for _, provider := range matches {
if provider.Item.ID == preferred {
return provider, nil
}
}
}
sort.Slice(matches, func(i, j int) bool {
if matches[i].Item.Priority != matches[j].Item.Priority {
return matches[i].Item.Priority > matches[j].Item.Priority
}
if matches[i].PluginID != matches[j].PluginID {
return matches[i].PluginID < matches[j].PluginID
}
return matches[i].Item.ID < matches[j].Item.ID
})
return matches[0], nil
}
func (r *Router) OpenResource(request OpenResourceRequest, providers []contribution.ContributionOpenProvider) (OpenResourceResult, error) {
request = normalizeRequest(request)
provider, err := r.SelectProvider(request, providers)
if err != nil {
return OpenResourceResult{
Status: "no-provider",
Request: request,
Message: err.Error(),
}, nil
}
result := OpenResourceResult{
Status: "opened",
ProviderID: provider.Item.ID,
ProviderPluginID: provider.PluginID,
ProviderComponent: provider.Item.Component,
Request: request,
}
r.opened = append(r.opened, OpenedResource{
ID: fmt.Sprintf("%s:%d", provider.Item.ID, len(r.opened)+1),
ProviderID: result.ProviderID,
ProviderPluginID: result.ProviderPluginID,
ProviderComponent: result.ProviderComponent,
Request: result.Request,
OpenedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
return result, nil
}
func (r *Router) OpenedResources() []OpenedResource {
result := make([]OpenedResource, len(r.opened))
copy(result, r.opened)
return result
}
func normalizeRequest(request OpenResourceRequest) OpenResourceRequest {
if request.Mode == "" {
request.Mode = "view"
}
if request.Extension == "" {
request.Extension = path.Ext(request.Path)
}
request.Extension = strings.ToLower(request.Extension)
request.Mime = strings.ToLower(request.Mime)
return request
}
// DetermineContextName derives the current routing context from a request.
// Future Files/Notes callers can move canonical Notes folder auto-detection here.
func DetermineContextName(request OpenResourceRequest) string {
request = normalizeRequest(request)
return resourceContextName(request)
}
func providerMatches(request OpenResourceRequest, provider plugin.ContributionOpenProvider) bool {
for _, support := range provider.Supports {
if support.Kind != request.Kind {
continue
}
if !supportMatchesExtensionOrMime(request, support) {
continue
}
if !supportMatchesContext(request, support) {
continue
}
return true
}
return false
}
func supportMatchesExtensionOrMime(request OpenResourceRequest, support plugin.OpenProviderSupport) bool {
hasExtensionRules := len(support.Extensions) > 0
hasMimeRules := len(support.Mime) > 0
if !hasExtensionRules && !hasMimeRules {
return true
}
if hasExtensionRules {
for _, ext := range support.Extensions {
if strings.ToLower(ext) == request.Extension {
return true
}
}
}
if hasMimeRules && request.Mime != "" {
for _, mime := range support.Mime {
if strings.ToLower(mime) == request.Mime {
return true
}
}
}
return false
}
func supportMatchesContext(request OpenResourceRequest, support plugin.OpenProviderSupport) bool {
if len(support.Contexts) == 0 {
return true
}
context := resourceContextName(request)
for _, supported := range support.Contexts {
if supported == context {
return true
}
}
return false
}
func (r *Router) preferenceFor(request OpenResourceRequest) string {
context := resourceContextName(request)
switch {
case context == ContextNotesMarkdown:
return r.preferences.DefaultNotesMarkdownEditorProvider
case context == ContextGenericMarkdown:
return r.preferences.DefaultMarkdownEditorProvider
case context == ContextGenericText:
return r.preferences.DefaultTextEditorProvider
default:
return ""
}
}
func resourceContextName(request OpenResourceRequest) string {
ext := strings.ToLower(request.Extension)
if ext == ".md" || ext == ".markdown" {
if request.Context.NotesMode || request.Context.IsInsideNotesFolder {
return ContextNotesMarkdown
}
return ContextGenericMarkdown
}
if isTextResource(request) {
return ContextGenericText
}
return ""
}
func isTextResource(request OpenResourceRequest) bool {
if strings.HasPrefix(request.Mime, "text/") {
return true
}
switch strings.ToLower(request.Extension) {
case ".txt", ".log", ".json", ".yaml", ".yml", ".toml", ".ini", ".conf":
return true
default:
return false
}
}
+244
View File
@@ -0,0 +1,244 @@
package workbench
import (
"testing"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/plugin"
)
func provider(pluginID, id string, priority int, component string, supports ...plugin.OpenProviderSupport) contribution.ContributionOpenProvider {
return contribution.ContributionOpenProvider{
PluginID: pluginID,
Item: plugin.ContributionOpenProvider{
ID: id,
Title: id,
Priority: priority,
Component: component,
Supports: supports,
},
}
}
func TestSelectProviderUsesNotesMarkdownPreference(t *testing.T) {
r := NewRouter(Preferences{
DefaultNotesMarkdownEditorProvider: "community.notes-editor",
})
providers := []contribution.ContributionOpenProvider{
provider("official.editor", "official.markdown", 100, "OfficialMarkdown", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md", ".markdown"},
Contexts: []string{"generic-markdown", "notes-markdown"},
}),
provider("community.editor", "community.notes-editor", 10, "CommunityNotes", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{"notes-markdown"},
}),
}
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Clients/Acme/Notes/Overview.md",
Extension: ".md",
Mode: "edit",
Context: OpenResourceContext{
SourceView: "notes",
IsInsideNotesFolder: true,
NotesMode: true,
},
}, providers)
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.Item.ID != "community.notes-editor" {
t.Fatalf("provider = %q, want community.notes-editor", selected.Item.ID)
}
}
func TestSelectProviderFallsBackByPriorityThenID(t *testing.T) {
r := NewRouter(Preferences{
DefaultMarkdownEditorProvider: "disabled.or.missing",
})
providers := []contribution.ContributionOpenProvider{
provider("b.plugin", "b.provider", 100, "B", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{"generic-markdown"},
}),
provider("a.plugin", "a.provider", 100, "A", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{"generic-markdown"},
}),
provider("high.plugin", "high.provider", 200, "High", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".txt"},
}),
}
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/readme.md",
Extension: ".md",
Mode: "view",
}, providers)
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.Item.ID != "a.provider" {
t.Fatalf("provider = %q, want deterministic tie winner a.provider", selected.Item.ID)
}
}
func TestSelectProviderTieBreaksByPluginIDThenProviderID(t *testing.T) {
r := NewRouter(Preferences{})
providers := []contribution.ContributionOpenProvider{
provider("b.plugin", "a.provider", 100, "B", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{ContextGenericMarkdown},
}),
provider("a.plugin", "z.provider", 100, "A", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{ContextGenericMarkdown},
}),
}
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/readme.md",
Mode: "view",
}, providers)
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.PluginID != "a.plugin" || selected.Item.ID != "z.provider" {
t.Fatalf("provider = %+v, want a.plugin/z.provider", selected)
}
}
func TestSelectProviderMatchesGenericTextContext(t *testing.T) {
r := NewRouter(Preferences{})
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/readme.txt",
Mode: "view",
}, []contribution.ContributionOpenProvider{
provider("text.plugin", "text.provider", 10, "Text", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".txt"},
Contexts: []string{ContextGenericText},
}),
})
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.Item.ID != "text.provider" {
t.Fatalf("provider = %q, want text.provider", selected.Item.ID)
}
}
func TestGenericMarkdownDoesNotSelectNotesOnlyProvider(t *testing.T) {
r := NewRouter(Preferences{})
_, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/readme.md",
Mode: "view",
}, []contribution.ContributionOpenProvider{
provider("notes.plugin", "notes.provider", 10, "Notes", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{ContextNotesMarkdown},
}),
})
if err == nil {
t.Fatal("expected no provider for generic markdown with notes-only provider")
}
}
func TestOpenResourceStoresSelectedProviderAndRequest(t *testing.T) {
r := NewRouter(Preferences{})
result, err := r.OpenResource(OpenResourceRequest{
Kind: "vault-file",
Path: "Notes/Overview.md",
Extension: ".md",
Mode: "edit",
Context: OpenResourceContext{
IsInsideNotesFolder: true,
NotesMode: true,
},
}, []contribution.ContributionOpenProvider{
provider("official.editor", "official.markdown", 100, "MarkdownEditor", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{"notes-markdown"},
}),
})
if err != nil {
t.Fatalf("OpenResource: %v", err)
}
if result.Status != "opened" || result.ProviderID != "official.markdown" || result.ProviderComponent != "MarkdownEditor" {
t.Fatalf("result = %+v", result)
}
opened := r.OpenedResources()
if len(opened) != 1 || opened[0].Request.Path != "Notes/Overview.md" {
t.Fatalf("opened = %+v", opened)
}
}
func TestOpenResourceReturnsNoProviderFallback(t *testing.T) {
r := NewRouter(Preferences{})
result, err := r.OpenResource(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/unknown.bin",
}, nil)
if err != nil {
t.Fatalf("OpenResource: %v", err)
}
if result.Status != "no-provider" || result.Request.Path != "Docs/unknown.bin" || result.Message == "" {
t.Fatalf("result = %+v", result)
}
if len(r.OpenedResources()) != 0 {
t.Fatalf("no-provider result should not store opened resource: %+v", r.OpenedResources())
}
}
func TestDetermineContextName(t *testing.T) {
tests := []struct {
name string
request OpenResourceRequest
want string
}{
{
name: "text",
request: OpenResourceRequest{Kind: "vault-file", Path: "Docs/readme.txt"},
want: ContextGenericText,
},
{
name: "markdown",
request: OpenResourceRequest{Kind: "vault-file", Path: "Docs/readme.md"},
want: ContextGenericMarkdown,
},
{
name: "notes markdown",
request: OpenResourceRequest{
Kind: "vault-file",
Path: "Notes/Overview.md",
Context: OpenResourceContext{
IsInsideNotesFolder: true,
NotesMode: true,
},
},
want: ContextNotesMarkdown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := DetermineContextName(tt.request); got != tt.want {
t.Fatalf("DetermineContextName = %q, want %q", got, tt.want)
}
})
}
}