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}