storage.go

  1package plugin
  2
  3import (
  4	"encoding/json"
  5	"errors"
  6	"io/fs"
  7	"os"
  8	"path/filepath"
  9	"regexp"
 10	"sort"
 11	"sync"
 12
 13	"github.com/floatpane/matcha/config"
 14	lua "github.com/yuin/gopher-lua"
 15)
 16
 17var validPluginStoreName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
 18
 19// ErrNoActivePlugin is returned when a storage operation is attempted without
 20// an active plugin context.
 21var ErrNoActivePlugin = errors.New("plugin: no active plugin")
 22
 23type pluginStore struct {
 24	path string
 25	mu   sync.Mutex
 26	data map[string]string
 27}
 28
 29func newPluginStore(pluginName string) (*pluginStore, error) {
 30	if !validPluginStoreName.MatchString(pluginName) {
 31		return nil, errors.New("invalid plugin name for storage")
 32	}
 33
 34	cfgDir, err := config.GetConfigDir()
 35	if err != nil {
 36		return nil, err
 37	}
 38
 39	dir := filepath.Join(cfgDir, "plugins", pluginName)
 40	if err := os.MkdirAll(dir, 0o700); err != nil {
 41		return nil, err
 42	}
 43
 44	s := &pluginStore{
 45		path: filepath.Join(dir, "data.json"),
 46		data: map[string]string{},
 47	}
 48	if err := s.load(); err != nil {
 49		return nil, err
 50	}
 51	return s, nil
 52}
 53
 54func (s *pluginStore) load() error {
 55	raw, err := os.ReadFile(s.path)
 56	if errors.Is(err, fs.ErrNotExist) {
 57		return nil
 58	}
 59	if err != nil {
 60		return err
 61	}
 62	if err := json.Unmarshal(raw, &s.data); err != nil {
 63		return err
 64	}
 65	if s.data == nil {
 66		s.data = map[string]string{}
 67	}
 68	return nil
 69}
 70
 71func (s *pluginStore) flush() error {
 72	raw, err := json.MarshalIndent(s.data, "", "  ")
 73	if err != nil {
 74		return err
 75	}
 76
 77	tmp, err := os.CreateTemp(filepath.Dir(s.path), ".data-*.json")
 78	if err != nil {
 79		return err
 80	}
 81	tmpPath := tmp.Name()
 82	defer os.Remove(tmpPath) //nolint:errcheck
 83
 84	if _, err := tmp.Write(raw); err != nil {
 85		tmp.Close() //nolint:errcheck,gosec
 86		return err
 87	}
 88	if err := os.Chmod(tmpPath, 0o600); err != nil {
 89		tmp.Close() //nolint:errcheck,gosec
 90		return err
 91	}
 92	if err := tmp.Close(); err != nil {
 93		return err
 94	}
 95	return os.Rename(tmpPath, s.path)
 96}
 97
 98func (s *pluginStore) Get(k string) (string, bool) {
 99	s.mu.Lock()
100	defer s.mu.Unlock()
101
102	v, ok := s.data[k]
103	return v, ok
104}
105
106func (s *pluginStore) Set(k, v string) error {
107	s.mu.Lock()
108	defer s.mu.Unlock()
109
110	s.data[k] = v
111	return s.flush()
112}
113
114func (s *pluginStore) Delete(k string) error {
115	s.mu.Lock()
116	defer s.mu.Unlock()
117
118	delete(s.data, k)
119	return s.flush()
120}
121
122// Keys returns the keys currently stored, sorted lexicographically so plugin
123// authors can rely on a stable iteration order across calls.
124func (s *pluginStore) Keys() []string {
125	s.mu.Lock()
126	defer s.mu.Unlock()
127
128	out := make([]string, 0, len(s.data))
129	for k := range s.data {
130		out = append(out, k)
131	}
132	sort.Strings(out)
133	return out
134}
135
136func (m *Manager) currentStore() (*pluginStore, error) {
137	if m.currentPlugin == "" {
138		return nil, ErrNoActivePlugin
139	}
140	if m.stores == nil {
141		m.stores = make(map[string]*pluginStore)
142	}
143	if s, ok := m.stores[m.currentPlugin]; ok {
144		return s, nil
145	}
146
147	s, err := newPluginStore(m.currentPlugin)
148	if err != nil {
149		return nil, err
150	}
151	m.stores[m.currentPlugin] = s
152	return s, nil
153}
154
155func (m *Manager) luaStoreSet(L *lua.LState) int { //nolint:gocritic
156	key := L.CheckString(1)
157	val := L.CheckString(2)
158
159	s, err := m.currentStore()
160	if errors.Is(err, ErrNoActivePlugin) {
161		L.RaiseError("store_set: no plugin context")
162		return 0
163	}
164	if err != nil {
165		L.RaiseError("store_set: %v", err)
166		return 0
167	}
168	if err := s.Set(key, val); err != nil {
169		L.RaiseError("store_set: %v", err)
170	}
171	return 0
172}
173
174func (m *Manager) luaStoreGet(L *lua.LState) int { //nolint:gocritic
175	key := L.CheckString(1)
176
177	s, err := m.currentStore()
178	if errors.Is(err, ErrNoActivePlugin) {
179		L.Push(lua.LNil)
180		return 1
181	}
182	if err != nil {
183		L.RaiseError("store_get: %v", err)
184		return 0
185	}
186	if v, ok := s.Get(key); ok {
187		L.Push(lua.LString(v))
188	} else {
189		L.Push(lua.LNil)
190	}
191	return 1
192}
193
194func (m *Manager) luaStoreDelete(L *lua.LState) int { //nolint:gocritic
195	key := L.CheckString(1)
196
197	s, err := m.currentStore()
198	if errors.Is(err, ErrNoActivePlugin) {
199		return 0 // silent no-op outside plugin context, matching store_get behavior
200	}
201	if err != nil {
202		L.RaiseError("store_delete: %v", err)
203		return 0
204	}
205	if err := s.Delete(key); err != nil {
206		L.RaiseError("store_delete: %v", err)
207	}
208	return 0
209}
210
211func (m *Manager) luaStoreKeys(L *lua.LState) int { //nolint:gocritic
212	s, err := m.currentStore()
213	if errors.Is(err, ErrNoActivePlugin) {
214		L.Push(L.NewTable())
215		return 1
216	}
217	if err != nil {
218		L.RaiseError("store_keys: %v", err)
219		return 0
220	}
221
222	t := L.NewTable()
223	for i, key := range s.Keys() {
224		t.RawSetInt(i+1, lua.LString(key))
225	}
226	L.Push(t)
227	return 1
228}