226 lines
6.9 KiB
Go
226 lines
6.9 KiB
Go
// Package storage provides a safe, namespace-isolated JSON storage API for plugins.
|
|
// All data is stored within the vault's .verstak directory, scoped per plugin.
|
|
package storage
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/verstak/verstak-desktop/internal/core/vault"
|
|
)
|
|
|
|
// Storage provides plugin-scoped JSON storage (settings, data, cache).
|
|
type Storage struct {
|
|
mu sync.RWMutex
|
|
vault *vault.Vault
|
|
}
|
|
|
|
// New creates a new Storage instance backed by the given vault.
|
|
func New(v *vault.Vault) *Storage {
|
|
return &Storage{vault: v}
|
|
}
|
|
|
|
// ─── Plugin ID validation ─────────────────────────────────
|
|
|
|
func validatePluginID(pluginID string) error {
|
|
if pluginID == "" {
|
|
return fmt.Errorf("plugin ID is empty")
|
|
}
|
|
if strings.ContainsAny(pluginID, `/\`) {
|
|
return fmt.Errorf("plugin ID %q contains path separators", pluginID)
|
|
}
|
|
if pluginID == "." || pluginID == ".." {
|
|
return fmt.Errorf("plugin ID %q is a path traversal reference", pluginID)
|
|
}
|
|
cleaned := filepath.Clean(pluginID)
|
|
if cleaned != pluginID {
|
|
return fmt.Errorf("plugin ID %q contains path traversal", pluginID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ─── Atomic write helper ──────────────────────────────────
|
|
|
|
func atomicWrite(path string, data []byte) error {
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("failed to create dir %s: %w", dir, err)
|
|
}
|
|
tmpFile := filepath.Join(dir, fmt.Sprintf(".tmp.%d", time.Now().UnixNano()))
|
|
if err := os.WriteFile(tmpFile, data, 0o644); err != nil {
|
|
return fmt.Errorf("failed to write temp file: %w", err)
|
|
}
|
|
if err := os.Rename(tmpFile, path); err != nil {
|
|
os.Remove(tmpFile) // best-effort cleanup
|
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ─── Settings API ─────────────────────────────────────────
|
|
|
|
// ReadPluginSettings reads all settings for a plugin.
|
|
// Returns empty map if settings.json does not exist.
|
|
func (s *Storage) ReadPluginSettings(pluginID string) (map[string]interface{}, error) {
|
|
if err := validatePluginID(pluginID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dir := s.vault.GetPluginSettingsPath(pluginID)
|
|
path := filepath.Join(dir, "settings.json")
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return make(map[string]interface{}), nil
|
|
}
|
|
return nil, fmt.Errorf("failed to read settings for plugin %s: %w", pluginID, err)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, fmt.Errorf("corrupt settings.json for plugin %s: %w", pluginID, err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// WritePluginSettings writes all settings for a plugin atomically.
|
|
func (s *Storage) WritePluginSettings(pluginID string, data map[string]interface{}) error {
|
|
if err := validatePluginID(pluginID); err != nil {
|
|
return err
|
|
}
|
|
|
|
dir := s.vault.GetPluginSettingsPath(pluginID)
|
|
path := filepath.Join(dir, "settings.json")
|
|
|
|
encoded, err := json.MarshalIndent(data, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal settings for plugin %s: %w", pluginID, err)
|
|
}
|
|
return atomicWrite(path, encoded)
|
|
}
|
|
|
|
// ReadPluginSetting reads a single setting key.
|
|
func (s *Storage) ReadPluginSetting(pluginID, key string) (interface{}, error) {
|
|
settings, err := s.ReadPluginSettings(pluginID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
val, ok := settings[key]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return val, nil
|
|
}
|
|
|
|
// WritePluginSetting writes a single setting key.
|
|
func (s *Storage) WritePluginSetting(pluginID, key string, value interface{}) error {
|
|
settings, err := s.ReadPluginSettings(pluginID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
settings[key] = value
|
|
return s.WritePluginSettings(pluginID, settings)
|
|
}
|
|
|
|
// ─── Data JSON API ────────────────────────────────────────
|
|
|
|
// ReadPluginDataJSON reads a named JSON data file for a plugin.
|
|
func (s *Storage) ReadPluginDataJSON(pluginID, name string) (map[string]interface{}, error) {
|
|
if err := validatePluginID(pluginID); err != nil {
|
|
return nil, err
|
|
}
|
|
if name == "" {
|
|
return nil, fmt.Errorf("data name is empty")
|
|
}
|
|
|
|
dir := s.vault.GetPluginDataPath(pluginID)
|
|
path := filepath.Join(dir, name+".json")
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return make(map[string]interface{}), nil
|
|
}
|
|
return nil, fmt.Errorf("failed to read data %s for plugin %s: %w", name, pluginID, err)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, fmt.Errorf("corrupt data file %s.json for plugin %s: %w", name, pluginID, err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// WritePluginDataJSON writes a named JSON data file for a plugin atomically.
|
|
func (s *Storage) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) error {
|
|
if err := validatePluginID(pluginID); err != nil {
|
|
return err
|
|
}
|
|
if name == "" {
|
|
return fmt.Errorf("data name is empty")
|
|
}
|
|
|
|
dir := s.vault.GetPluginDataPath(pluginID)
|
|
path := filepath.Join(dir, name+".json")
|
|
|
|
encoded, err := json.MarshalIndent(data, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal data %s for plugin %s: %w", name, pluginID, err)
|
|
}
|
|
return atomicWrite(path, encoded)
|
|
}
|
|
|
|
// ─── Cache JSON API ───────────────────────────────────────
|
|
|
|
// ReadPluginCacheJSON reads a named JSON cache file for a plugin.
|
|
func (s *Storage) ReadPluginCacheJSON(pluginID, name string) (map[string]interface{}, error) {
|
|
if err := validatePluginID(pluginID); err != nil {
|
|
return nil, err
|
|
}
|
|
if name == "" {
|
|
return nil, fmt.Errorf("cache name is empty")
|
|
}
|
|
|
|
dir := s.vault.GetPluginCachePath(pluginID)
|
|
path := filepath.Join(dir, name+".json")
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return make(map[string]interface{}), nil
|
|
}
|
|
return nil, fmt.Errorf("failed to read cache %s for plugin %s: %w", name, pluginID, err)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, fmt.Errorf("corrupt cache file %s.json for plugin %s: %w", name, pluginID, err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// WritePluginCacheJSON writes a named JSON cache file for a plugin atomically.
|
|
func (s *Storage) WritePluginCacheJSON(pluginID, name string, data map[string]interface{}) error {
|
|
if err := validatePluginID(pluginID); err != nil {
|
|
return err
|
|
}
|
|
if name == "" {
|
|
return fmt.Errorf("cache name is empty")
|
|
}
|
|
|
|
dir := s.vault.GetPluginCachePath(pluginID)
|
|
path := filepath.Join(dir, name+".json")
|
|
|
|
encoded, err := json.MarshalIndent(data, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal cache %s for plugin %s: %w", name, pluginID, err)
|
|
}
|
|
return atomicWrite(path, encoded)
|
|
}
|