diff --git a/cmd/smoke-platform/main.go b/cmd/smoke-platform/main.go index a4c1f01..f2fb171 100644 --- a/cmd/smoke-platform/main.go +++ b/cmd/smoke-platform/main.go @@ -167,6 +167,14 @@ func main() { fmt.Printf(" ✅ registered %d core capabilities\n", len(coreCaps)) } + // Register vault capability (core service) + if err := reg.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil { + fmt.Printf(" ❌ register vault capability: %v\n", err) + allGood = false + } else { + fmt.Printf(" ✅ registered vault capability\n") + } + // Register plugin capabilities for _, p := range m.Provides { if err := reg.Register(m.ID, []string{p}); err != nil { @@ -232,10 +240,10 @@ func main() { fmt.Printf("\n[capability count]\n") totalCaps := len(reg.List()) fmt.Printf(" total capabilities: %d\n", totalCaps) - if totalCaps >= 7 { - fmt.Printf(" ✅ total capabilities >= 7 (%d)\n", totalCaps) + if totalCaps >= 8 { + fmt.Printf(" ✅ total capabilities >= 8 (%d)\n", totalCaps) } else { - fmt.Printf(" ❌ total capabilities < 7 (got %d, expected >= 7)\n", totalCaps) + fmt.Printf(" ❌ total capabilities < 8 (got %d, expected >= 8)\n", totalCaps) allGood = false } diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index 5b688b2..d598e17 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -5,6 +5,10 @@ import {api} from '../models'; import {permissions} from '../models'; import {plugin} from '../models'; +export function CloseVault():Promise; + +export function CreateVault(arg1:string):Promise; + export function GetCapabilities():Promise>; export function GetContributions():Promise; @@ -13,6 +17,10 @@ export function GetPermissions():Promise>; export function GetPlugins():Promise>; +export function GetVaultStatus():Promise>; + +export function OpenVault(arg1:string):Promise; + export function ReloadPlugins():Promise; export function Startup():Promise; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index f793603..9ced9de 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -2,6 +2,14 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function CloseVault() { + return window['go']['api']['App']['CloseVault'](); +} + +export function CreateVault(arg1) { + return window['go']['api']['App']['CreateVault'](arg1); +} + export function GetCapabilities() { return window['go']['api']['App']['GetCapabilities'](); } @@ -18,6 +26,14 @@ export function GetPlugins() { return window['go']['api']['App']['GetPlugins'](); } +export function GetVaultStatus() { + return window['go']['api']['App']['GetVaultStatus'](); +} + +export function OpenVault(arg1) { + return window['go']['api']['App']['OpenVault'](arg1); +} + export function ReloadPlugins() { return window['go']['api']['App']['ReloadPlugins'](); } diff --git a/go.mod b/go.mod index 02b1816..8dd2d1a 100644 --- a/go.mod +++ b/go.mod @@ -2,14 +2,16 @@ module github.com/verstak/verstak-desktop go 1.24.4 -require github.com/wailsapp/wails/v2 v2.12.0 +require ( + github.com/google/uuid v1.6.0 + github.com/wailsapp/wails/v2 v2.12.0 +) require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/labstack/echo/v4 v4.13.3 // indirect diff --git a/internal/api/app.go b/internal/api/app.go index 3777abc..3e2c6b4 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -13,6 +13,7 @@ import ( "github.com/verstak/verstak-desktop/internal/core/events" "github.com/verstak/verstak-desktop/internal/core/permissions" "github.com/verstak/verstak-desktop/internal/core/plugin" + "github.com/verstak/verstak-desktop/internal/core/vault" ) // App is the main application struct exposed to the Wails frontend. @@ -22,6 +23,7 @@ type App struct { permRegistry *permissions.Registry eventBus *events.Bus plugins []plugin.Plugin + vault *vault.Vault } // NewApp creates a new App instance. @@ -31,6 +33,7 @@ func NewApp( permReg *permissions.Registry, bus *events.Bus, plugins []plugin.Plugin, + vaultService *vault.Vault, ) *App { return &App{ capRegistry: capReg, @@ -38,6 +41,7 @@ func NewApp( permRegistry: permReg, eventBus: bus, plugins: plugins, + vault: vaultService, } } @@ -124,6 +128,13 @@ func (a *App) ReloadPlugins() (int, string) { log.Printf("[api] ReloadPlugins: failed to re-register core capabilities: %v", err) } + // Re-register vault capability if vault is open + if a.vault != nil && a.vault.GetVaultStatus() == vault.StatusOpen { + if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil { + log.Printf("[api] ReloadPlugins: failed to re-register vault capability: %v", err) + } + } + plugins, errs := plugin.DiscoverPlugins(discoveryDirs) // Plugin lifecycle: register capabilities + contributions @@ -184,6 +195,55 @@ func (a *App) ReloadPlugins() (int, string) { return len(plugins), summary } +// ─── Vault API ────────────────────────────────────────────── + +// GetVaultStatus returns the current vault status, path, and vault ID. +func (a *App) GetVaultStatus() map[string]string { + status := "not-created" + path := "" + vaultID := "" + + if a.vault != nil { + status = string(a.vault.GetVaultStatus()) + path = a.vault.GetVaultPath() + meta := a.vault.GetVaultMeta() + if meta != nil { + vaultID = meta.VaultID + } + } + + return map[string]string{ + "status": status, + "path": path, + "vaultId": vaultID, + } +} + +// CreateVault creates a new vault at the given path. +func (a *App) CreateVault(path string) error { + if a.vault == nil { + return fmt.Errorf("vault service not initialized") + } + return a.vault.CreateVault(path) +} + +// OpenVault opens an existing vault at the given path. +func (a *App) OpenVault(path string) error { + if a.vault == nil { + return fmt.Errorf("vault service not initialized") + } + return a.vault.OpenVault(path) +} + +// CloseVault closes the current vault. +func (a *App) CloseVault() error { + if a.vault == nil { + return fmt.Errorf("vault service not initialized") + } + a.vault.CloseVault() + return nil +} + // ContributionSummary aggregates all contribution types for the frontend. type ContributionSummary struct { Views []contribution.ContributionView `json:"views"` diff --git a/internal/core/vault/vault.go b/internal/core/vault/vault.go new file mode 100644 index 0000000..e0bfd23 --- /dev/null +++ b/internal/core/vault/vault.go @@ -0,0 +1,297 @@ +// Package vault provides the core vault service for managing Verstak vaults. +// A vault is a directory that stores plugin data, settings, cache, and metadata. +package vault + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/verstak/verstak-desktop/internal/core/events" +) + +// VaultStatus represents the current state of a vault. +type VaultStatus string + +const ( + StatusNotCreated VaultStatus = "not-created" + StatusClosed VaultStatus = "closed" + StatusOpen VaultStatus = "open" + StatusError VaultStatus = "error" +) + +// VaultMeta stores metadata about a vault, persisted in .verstak/vault.json. +type VaultMeta struct { + SchemaVersion int `json:"schemaVersion"` + VaultID string `json:"vaultId"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + App string `json:"app"` +} + +// Vault manages a Verstak vault directory and its layout. +type Vault struct { + mu sync.RWMutex + status VaultStatus + path string + meta *VaultMeta + eventBus *events.Bus +} + +// NewVault creates a new Vault instance with the given event bus. +func NewVault(bus *events.Bus) *Vault { + return &Vault{ + status: StatusNotCreated, + path: "", + meta: nil, + eventBus: bus, + } +} + +// GetVaultStatus returns the current vault status. +func (v *Vault) GetVaultStatus() VaultStatus { + v.mu.RLock() + defer v.mu.RUnlock() + return v.status +} + +// GetVaultPath returns the current vault path. +func (v *Vault) GetVaultPath() string { + v.mu.RLock() + defer v.mu.RUnlock() + return v.path +} + +// GetVaultMeta returns the current vault metadata. +func (v *Vault) GetVaultMeta() *VaultMeta { + v.mu.RLock() + defer v.mu.RUnlock() + return v.meta +} + +// CreateVault creates a new vault at the given path. +func (v *Vault) CreateVault(path string) error { + if err := ValidateVaultPath(path); err != nil { + return fmt.Errorf("invalid vault path: %w", err) + } + + vaultDir := filepath.Join(path, "VerstakVault") + + // Create VerstakVault directory + if err := os.MkdirAll(vaultDir, 0o755); err != nil { + return fmt.Errorf("failed to create vault directory: %w", err) + } + + // Ensure .verstak layout + if err := EnsureVaultLayout(vaultDir); err != nil { + return fmt.Errorf("failed to create vault layout: %w", err) + } + + // Generate metadata + now := time.Now().UTC().Format(time.RFC3339) + meta := &VaultMeta{ + SchemaVersion: 1, + VaultID: uuid.New().String(), + CreatedAt: now, + UpdatedAt: now, + App: "verstak", + } + + // Write vault.json + metaPath := filepath.Join(vaultDir, ".verstak", "vault.json") + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal vault meta: %w", err) + } + if err := os.WriteFile(metaPath, data, 0o644); err != nil { + return fmt.Errorf("failed to write vault.json: %w", err) + } + + v.mu.Lock() + v.status = StatusOpen + v.path = vaultDir + v.meta = meta + v.mu.Unlock() + + // Publish event + if v.eventBus != nil { + v.eventBus.Publish(events.Event{ + Name: "vault.created", + Payload: map[string]string{"path": v.path, "vaultId": v.meta.VaultID}, + }) + } + + return nil +} + +// OpenVault opens an existing vault at the given path. +func (v *Vault) OpenVault(path string) error { + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("failed to resolve path: %w", err) + } + + metaPath := filepath.Join(absPath, ".verstak", "vault.json") + data, err := os.ReadFile(metaPath) + if err != nil { + return fmt.Errorf("failed to read vault.json: %w", err) + } + + var meta VaultMeta + if err := json.Unmarshal(data, &meta); err != nil { + return fmt.Errorf("failed to parse vault.json: %w", err) + } + + // Validate metadata + if meta.SchemaVersion != 1 { + return fmt.Errorf("unsupported schema version: %d", meta.SchemaVersion) + } + if meta.VaultID == "" { + return errors.New("vault ID is empty") + } + + // Ensure layout exists + if err := EnsureVaultLayout(absPath); err != nil { + return fmt.Errorf("failed to ensure vault layout: %w", err) + } + + v.mu.Lock() + v.status = StatusOpen + v.path = absPath + v.meta = &meta + v.mu.Unlock() + + // Publish event + if v.eventBus != nil { + v.eventBus.Publish(events.Event{ + Name: "vault.opened", + Payload: map[string]string{"path": v.path, "vaultId": v.meta.VaultID}, + }) + } + + return nil +} + +// CloseVault closes the current vault. +func (v *Vault) CloseVault() { + v.mu.Lock() + defer v.mu.Unlock() + + if v.status == StatusClosed { + return + } + + vaultID := "" + if v.meta != nil { + vaultID = v.meta.VaultID + } + + v.status = StatusClosed + v.path = "" + v.meta = nil + + if v.eventBus != nil { + v.eventBus.Publish(events.Event{ + Name: "vault.closed", + Payload: map[string]string{"vaultId": vaultID}, + }) + } +} + +// EnsureVaultLayout creates the .verstak directory and standard subdirectories +// if they do not already exist. +func EnsureVaultLayout(basePath string) error { + subdirs := []string{ + ".verstak/plugin-data", + ".verstak/plugin-settings", + ".verstak/plugin-cache", + ".verstak/trash", + ".verstak/logs", + } + + for _, sub := range subdirs { + dir := filepath.Join(basePath, sub) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create %s: %w", sub, err) + } + } + + return nil +} + +// ValidateVaultPath checks that the given path is a valid, safe vault path. +func ValidateVaultPath(path string) error { + if path == "" { + return errors.New("path is empty") + } + + cleaned := filepath.Clean(path) + + if !filepath.IsAbs(cleaned) { + return errors.New("path must be absolute") + } + + // Check for null bytes + if strings.Contains(cleaned, "\x00") { + return errors.New("path contains null bytes") + } + + return nil +} + +// ResolveSafePath resolves a relative path within the vault, preventing +// path traversal attacks. +func (v *Vault) ResolveSafePath(relative string) (string, error) { + v.mu.RLock() + defer v.mu.RUnlock() + + if v.status != StatusOpen || v.path == "" { + return "", errors.New("vault is not open") + } + + result := filepath.Join(v.path, relative) + result = filepath.Clean(result) + + if !strings.HasPrefix(result, v.path) { + return "", errors.New("path traversal detected") + } + + return result, nil +} + +// GetPluginDataPath returns the data directory for a plugin, creating it if needed. +func (v *Vault) GetPluginDataPath(pluginID string) string { + v.mu.RLock() + defer v.mu.RUnlock() + + dir := filepath.Join(v.path, ".verstak", "plugin-data", pluginID) + os.MkdirAll(dir, 0o755) + return dir +} + +// GetPluginSettingsPath returns the settings directory for a plugin, creating it if needed. +func (v *Vault) GetPluginSettingsPath(pluginID string) string { + v.mu.RLock() + defer v.mu.RUnlock() + + dir := filepath.Join(v.path, ".verstak", "plugin-settings", pluginID) + os.MkdirAll(dir, 0o755) + return dir +} + +// GetPluginCachePath returns the cache directory for a plugin, creating it if needed. +func (v *Vault) GetPluginCachePath(pluginID string) string { + v.mu.RLock() + defer v.mu.RUnlock() + + dir := filepath.Join(v.path, ".verstak", "plugin-cache", pluginID) + os.MkdirAll(dir, 0o755) + return dir +} diff --git a/internal/core/vault/vault_test.go b/internal/core/vault/vault_test.go new file mode 100644 index 0000000..adde941 --- /dev/null +++ b/internal/core/vault/vault_test.go @@ -0,0 +1,249 @@ +package vault + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/verstak/verstak-desktop/internal/core/events" +) + +func TestCreateVault_CreatesLayoutAndMeta(t *testing.T) { + base := t.TempDir() + bus := events.NewBus() + v := NewVault(bus) + + err := v.CreateVault(base) + if err != nil { + t.Fatalf("CreateVault failed: %v", err) + } + + vaultDir := filepath.Join(base, "VerstakVault") + + // Check vault.json exists + metaPath := filepath.Join(vaultDir, ".verstak", "vault.json") + data, err := os.ReadFile(metaPath) + if err != nil { + t.Fatalf("vault.json not found: %v", err) + } + + var meta VaultMeta + if err := json.Unmarshal(data, &meta); err != nil { + t.Fatalf("failed to parse vault.json: %v", err) + } + + if meta.SchemaVersion != 1 { + t.Errorf("schemaVersion: got %d, want 1", meta.SchemaVersion) + } + if meta.VaultID == "" { + t.Error("vaultId is empty") + } + if meta.App != "verstak" { + t.Errorf("app: got %q, want %q", meta.App, "verstak") + } + + // Check subdirectories + expectedDirs := []string{ + ".verstak/plugin-data", + ".verstak/plugin-settings", + ".verstak/plugin-cache", + ".verstak/trash", + ".verstak/logs", + } + for _, dir := range expectedDirs { + full := filepath.Join(vaultDir, dir) + info, err := os.Stat(full) + if err != nil { + t.Errorf("directory %s not found: %v", dir, err) + continue + } + if !info.IsDir() { + t.Errorf("%s is not a directory", dir) + } + } +} + +func TestOpenVault_ReadsExistingVaultId(t *testing.T) { + base := t.TempDir() + bus := events.NewBus() + v := NewVault(bus) + + // Create vault + if err := v.CreateVault(base); err != nil { + t.Fatalf("CreateVault failed: %v", err) + } + + // Remember the vault ID + meta := v.GetVaultMeta() + if meta == nil { + t.Fatal("meta is nil after CreateVault") + } + originalID := meta.VaultID + + // Close vault + v.CloseVault() + + // Open vault + vaultDir := filepath.Join(base, "VerstakVault") + if err := v.OpenVault(vaultDir); err != nil { + t.Fatalf("OpenVault failed: %v", err) + } + + // Verify vault ID matches + newMeta := v.GetVaultMeta() + if newMeta == nil { + t.Fatal("meta is nil after OpenVault") + } + if newMeta.VaultID != originalID { + t.Errorf("vault ID mismatch: got %q, want %q", newMeta.VaultID, originalID) + } +} + +func TestOpenVault_CorruptJSON_Error(t *testing.T) { + base := t.TempDir() + vaultDir := filepath.Join(base, "VerstakVault") + + // Create vault directory with corrupt vault.json + if err := os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755); err != nil { + t.Fatalf("failed to create .verstak dir: %v", err) + } + if err := os.WriteFile(filepath.Join(vaultDir, ".verstak", "vault.json"), []byte("{corrupt"), 0o644); err != nil { + t.Fatalf("failed to write corrupt vault.json: %v", err) + } + + bus := events.NewBus() + v := NewVault(bus) + + err := v.OpenVault(vaultDir) + if err == nil { + t.Fatal("expected error for corrupt vault.json, got nil") + } + if !strings.Contains(err.Error(), "failed to parse vault.json") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestResolveSafePath_BlocksTraversal(t *testing.T) { + base := t.TempDir() + bus := events.NewBus() + v := NewVault(bus) + + if err := v.CreateVault(base); err != nil { + t.Fatalf("CreateVault failed: %v", err) + } + + // Path traversal should be blocked + _, err := v.ResolveSafePath("../../etc/passwd") + if err == nil { + t.Fatal("expected error for path traversal, got nil") + } + if !strings.Contains(err.Error(), "path traversal detected") { + t.Errorf("unexpected error: %v", err) + } + + // Normal path should work + result, err := v.ResolveSafePath("normal/path") + if err != nil { + t.Fatalf("ResolveSafePath failed for normal path: %v", err) + } + if !strings.Contains(result, "normal") { + t.Errorf("unexpected result path: %s", result) + } +} + +func TestGetPluginDataPath_CreatesNamespace(t *testing.T) { + base := t.TempDir() + bus := events.NewBus() + v := NewVault(bus) + + if err := v.CreateVault(base); err != nil { + t.Fatalf("CreateVault failed: %v", err) + } + + path := v.GetPluginDataPath("test-plugin") + if !strings.Contains(path, "plugin-data") { + t.Errorf("path does not contain plugin-data: %s", path) + } + if !strings.Contains(path, "test-plugin") { + t.Errorf("path does not contain test-plugin: %s", path) + } + + // Verify directory was created + info, err := os.Stat(path) + if err != nil { + t.Fatalf("plugin data directory not created: %v", err) + } + if !info.IsDir() { + t.Error("plugin data path is not a directory") + } +} + +func TestVaultStatus_Transitions(t *testing.T) { + base := t.TempDir() + bus := events.NewBus() + v := NewVault(bus) + + // New vault → not-created + if status := v.GetVaultStatus(); status != StatusNotCreated { + t.Errorf("initial status: got %q, want %q", status, StatusNotCreated) + } + + // Create → open + if err := v.CreateVault(base); err != nil { + t.Fatalf("CreateVault failed: %v", err) + } + if status := v.GetVaultStatus(); status != StatusOpen { + t.Errorf("after create: got %q, want %q", status, StatusOpen) + } + + // Close → closed + v.CloseVault() + if status := v.GetVaultStatus(); status != StatusClosed { + t.Errorf("after close: got %q, want %q", status, StatusClosed) + } +} + +func TestVaultEvents_Published(t *testing.T) { + base := t.TempDir() + bus := events.NewBus() + v := NewVault(bus) + + // Collect events + var published []string + bus.Subscribe("vault.created", func(e events.Event) { + published = append(published, e.Name) + }) + bus.Subscribe("vault.opened", func(e events.Event) { + published = append(published, e.Name) + }) + bus.Subscribe("vault.closed", func(e events.Event) { + published = append(published, e.Name) + }) + + // Create + if err := v.CreateVault(base); err != nil { + t.Fatalf("CreateVault failed: %v", err) + } + + // Close + v.CloseVault() + + // Open + vaultDir := filepath.Join(base, "VerstakVault") + if err := v.OpenVault(vaultDir); err != nil { + t.Fatalf("OpenVault failed: %v", err) + } + + // Verify events + expected := []string{"vault.created", "vault.closed", "vault.opened"} + if len(published) != len(expected) { + t.Fatalf("expected %d events, got %d: %v", len(expected), len(published), published) + } + for i, name := range expected { + if published[i] != name { + t.Errorf("event %d: got %q, want %q", i, published[i], name) + } + } +} diff --git a/main.go b/main.go index 1f7602a..8fb5b5c 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "github.com/verstak/verstak-desktop/internal/core/events" "github.com/verstak/verstak-desktop/internal/core/permissions" "github.com/verstak/verstak-desktop/internal/core/plugin" + "github.com/verstak/verstak-desktop/internal/core/vault" ) //go:embed frontend/dist @@ -43,6 +44,9 @@ func main() { permRegistry := permissions.NewRegistry() eventBus := events.NewBus() + // ─── Initialize Vault ──────────────────────────────────── + vaultService := vault.NewVault(eventBus) + // ─── Register Core Capabilities ───────────────────────── // These are provided by the desktop core itself, not by plugins. // Registered before plugin discovery so that plugins can resolve @@ -60,6 +64,12 @@ func main() { } log.Printf("[main] registered %d core capabilities", len(coreCaps)) + // Register vault capability (vault is available as a core service) + if err := capRegistry.Register(corePluginID, []string{"verstak/core/vault/v1"}); err != nil { + log.Fatalf("[main] failed to register vault capability: %v", err) + } + log.Printf("[main] registered vault capability") + // ─── Plugin Discovery ─────────────────────────────────── discoveryDirs := []string{ "~/.config/verstak/plugins", @@ -136,10 +146,11 @@ func main() { failed++ } } - log.Printf("[main] lifecycle summary: loaded=%d degraded=%d failed=%d", loaded, degraded, failed) + log.Printf("[main] lifecycle summary: loaded=%d degraded=%d failed=%d vault=%s", + loaded, degraded, failed, vaultService.GetVaultStatus()) // Create the App struct - app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins) + app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService) // ─── Wails App ─────────────────────────────────────────── err := wails.Run(&options.App{