diff options
| author | spiperac <[email protected]> | 2026-06-15 06:42:13 +0200 |
|---|---|---|
| committer | spiperac <[email protected]> | 2026-06-15 06:42:13 +0200 |
| commit | 0a477fd288d064d265395c88dbd0f261b723804e (patch) | |
| tree | 9b129f8f41c907285b89ed8385679934bb59c14f /internal/plugin | |
Initial release
Diffstat (limited to 'internal/plugin')
| -rw-r--r-- | internal/plugin/api.go | 313 | ||||
| -rw-r--r-- | internal/plugin/engine.go | 347 | ||||
| -rw-r--r-- | internal/plugin/engine_test.go | 1003 | ||||
| -rw-r--r-- | internal/plugin/plugin.go | 197 | ||||
| -rw-r--r-- | internal/plugin/plugin_test.go | 391 |
5 files changed, 2251 insertions, 0 deletions
diff --git a/internal/plugin/api.go b/internal/plugin/api.go new file mode 100644 index 0000000..986c5d2 --- /dev/null +++ b/internal/plugin/api.go @@ -0,0 +1,313 @@ +package plugin + +import ( + "io" + "net/http" + "strings" + "time" + + lua "github.com/yuin/gopher-lua" + + "github.com/shiv/internal/store" +) + +// registerAPI registers all Lua API namespaces on the given VM. +// logFn is called when the Lua script calls log() — the engine routes +// these lines onto the bus after each hook call. +// Called once per plugin at load time. +func registerAPI(L *lua.LState, st *store.Store, logFn func(string)) { + registerLog(L, logFn) + registerHTTPClient(L) + registerDB(L, st) +} + +// ── log ─────────────────────────────────────────────────────────────────────── + +func registerLog(L *lua.LState, logFn func(string)) { + L.SetGlobal("log", L.NewFunction(func(L *lua.LState) int { + msg := L.CheckString(1) + logFn(msg) + return 0 + })) +} + +// ── http client ─────────────────────────────────────────────────────────────── + +var httpClient = &http.Client{Timeout: 15 * time.Second} + +func registerHTTPClient(L *lua.LState) { + tbl := L.NewTable() + + // http.get(url) → {status, headers, body} or nil, err + L.SetField(tbl, "get", L.NewFunction(func(L *lua.LState) int { + url := L.CheckString(1) + resp, err := httpClient.Get(url) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + defer resp.Body.Close() + return pushResponse(L, resp) + })) + + // http.post(url, body, content_type) → {status, headers, body} or nil, err + L.SetField(tbl, "post", L.NewFunction(func(L *lua.LState) int { + url := L.CheckString(1) + body := L.CheckString(2) + ct := L.OptString(3, "application/json") + resp, err := httpClient.Post(url, ct, strings.NewReader(body)) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + defer resp.Body.Close() + return pushResponse(L, resp) + })) + + // http.request(method, url, body, headers_table) → {status, headers, body} or nil, err + L.SetField(tbl, "request", L.NewFunction(func(L *lua.LState) int { + method := L.CheckString(1) + url := L.CheckString(2) + body := L.OptString(3, "") + headersTbl := L.OptTable(4, nil) + + var bodyReader io.Reader + if body != "" { + bodyReader = strings.NewReader(body) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + + if headersTbl != nil { + headersTbl.ForEach(func(k, v lua.LValue) { + req.Header.Set(k.String(), v.String()) + }) + } + + resp, err := httpClient.Do(req) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + defer resp.Body.Close() + return pushResponse(L, resp) + })) + + L.SetGlobal("http", tbl) +} + +func pushResponse(L *lua.LState, resp *http.Response) int { + body, _ := io.ReadAll(resp.Body) + + tbl := L.NewTable() + L.SetField(tbl, "status", lua.LNumber(resp.StatusCode)) + L.SetField(tbl, "body", lua.LString(body)) + + headers := L.NewTable() + for k, vals := range resp.Header { + L.SetField(headers, k, lua.LString(strings.Join(vals, ", "))) + } + L.SetField(tbl, "headers", headers) + + L.Push(tbl) + return 1 +} + +// ── db ──────────────────────────────────────────────────────────────────────── + +func registerDB(L *lua.LState, st *store.Store) { + tbl := L.NewTable() + + // db.history(limit) → array of transaction tables + L.SetField(tbl, "history", L.NewFunction(func(L *lua.LState) int { + limit := L.OptInt(1, 100) + txs, err := st.TransactionsPage(0, store.TransactionFilter{ShowOutScope: true}) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + if limit > 0 && len(txs) > limit { + txs = txs[:limit] + } + arr := L.NewTable() + for i, tx := range txs { + L.RawSetInt(arr, i+1, transactionToTable(L, tx)) + } + L.Push(arr) + return 1 + })) + + // db.history_filter(host, method, status) → array of transaction tables + L.SetField(tbl, "history_filter", L.NewFunction(func(L *lua.LState) int { + host := L.OptString(1, "") + method := L.OptString(2, "") + _ = L.OptInt(3, 0) // status filtering done client-side below + + filter := store.TransactionFilter{ + Host: host, + ShowOutScope: true, + } + if method != "" { + filter.Search = method + } + + txs, err := st.TransactionsPage(0, filter) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + arr := L.NewTable() + idx := 1 + for _, tx := range txs { + L.RawSetInt(arr, idx, transactionToTable(L, tx)) + idx++ + } + L.Push(arr) + return 1 + })) + + // db.scope_get() → array of {id, host} + L.SetField(tbl, "scope_get", L.NewFunction(func(L *lua.LState) int { + entries, err := st.AllScopeEntries() + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + arr := L.NewTable() + for i, e := range entries { + row := L.NewTable() + L.SetField(row, "id", lua.LNumber(e.ID)) + L.SetField(row, "host", lua.LString(e.Host)) + L.RawSetInt(arr, i+1, row) + } + L.Push(arr) + return 1 + })) + + // db.scope_add(host) → true or nil, err + L.SetField(tbl, "scope_add", L.NewFunction(func(L *lua.LState) int { + host := L.CheckString(1) + if err := st.AddScopeEntry(host); err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + L.Push(lua.LTrue) + return 1 + })) + + // db.scope_remove(id) → true or nil, err + L.SetField(tbl, "scope_remove", L.NewFunction(func(L *lua.LState) int { + id := L.CheckInt64(1) + if err := st.DeleteScopeEntry(id); err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + L.Push(lua.LTrue) + return 1 + })) + + // db.loot_get() → array of loot tables + L.SetField(tbl, "loot_get", L.NewFunction(func(L *lua.LState) int { + entries, err := st.AllLoot() + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + arr := L.NewTable() + for i, e := range entries { + row := L.NewTable() + L.SetField(row, "id", lua.LNumber(e.ID)) + L.SetField(row, "title", lua.LString(e.Title)) + L.SetField(row, "severity", lua.LString(e.Severity)) + L.SetField(row, "notes", lua.LString(e.Notes)) + L.SetField(row, "raw_request", lua.LString(e.RawRequest)) + L.SetField(row, "raw_response", lua.LString(e.RawResponse)) + L.RawSetInt(arr, i+1, row) + } + L.Push(arr) + return 1 + })) + + // db.loot_add(title, severity, notes, raw_request, raw_response) → id or nil, err + L.SetField(tbl, "loot_add", L.NewFunction(func(L *lua.LState) int { + title := L.CheckString(1) + severity := L.CheckString(2) + notes := L.OptString(3, "") + rawReq := L.OptString(4, "") + rawResp := L.OptString(5, "") + id, err := st.AddLoot(store.LootEntry{ + Title: title, + Severity: severity, + Notes: notes, + RawRequest: rawReq, + RawResponse: rawResp, + }) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + L.Push(lua.LNumber(id)) + return 1 + })) + + // db.loot_delete(id) → true or nil, err + L.SetField(tbl, "loot_delete", L.NewFunction(func(L *lua.LState) int { + id := L.CheckInt64(1) + if err := st.DeleteLoot(id); err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + L.Push(lua.LTrue) + return 1 + })) + + L.SetGlobal("db", tbl) +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func transactionToTable(L *lua.LState, tx store.Transaction) *lua.LTable { + tbl := L.NewTable() + L.SetField(tbl, "id", lua.LNumber(tx.ID)) + L.SetField(tbl, "host", lua.LString(tx.Host)) + L.SetField(tbl, "method", lua.LString(tx.Method)) + L.SetField(tbl, "url", lua.LString(tx.URL)) + L.SetField(tbl, "proto", lua.LString(tx.Proto)) + L.SetField(tbl, "status", lua.LNumber(tx.StatusCode)) + L.SetField(tbl, "duration_ms", lua.LNumber(tx.DurationMs)) + L.SetField(tbl, "tls", lua.LBool(tx.TLS)) + L.SetField(tbl, "in_scope", lua.LBool(tx.InScope)) + L.SetField(tbl, "req_body", lua.LString(tx.ReqBody)) + L.SetField(tbl, "resp_body", lua.LString(tx.RespBody)) + L.SetField(tbl, "timestamp", lua.LString(tx.Timestamp.UTC().Format(time.RFC3339))) + + reqH := L.NewTable() + for k, vals := range tx.ReqHeaders { + L.SetField(reqH, k, lua.LString(strings.Join(vals, ", "))) + } + L.SetField(tbl, "req_headers", reqH) + + respH := L.NewTable() + for k, vals := range tx.RespHeaders { + L.SetField(respH, k, lua.LString(strings.Join(vals, ", "))) + } + L.SetField(tbl, "resp_headers", respH) + + return tbl +} diff --git a/internal/plugin/engine.go b/internal/plugin/engine.go new file mode 100644 index 0000000..0623d36 --- /dev/null +++ b/internal/plugin/engine.go @@ -0,0 +1,347 @@ +package plugin + +import ( + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + + lua "github.com/yuin/gopher-lua" + + "github.com/shiv/internal/events" + "github.com/shiv/internal/logger" + "github.com/shiv/internal/store" +) + +// Engine loads all Lua plugins from a directory and implements +// events.RequestMiddleware, events.ResponseObserver, +// events.WebSocketFrameObserver, and events.PluginEnabledObserver +// so it can be registered directly on the bus. +type Engine struct { + plugins []*Plugin + bus *events.Bus + dir string + st *store.Store +} + +// NewEngine loads all *.lua files from dir, registers each in the store, +// restores persisted enabled state, and returns a ready Engine. +// Plugins that fail to load are skipped. +func NewEngine(dir string, st *store.Store, bus *events.Bus) (*Engine, error) { + plugins, err := loadDir(dir, st) + if err != nil { + return nil, err + } + + e := &Engine{plugins: plugins, bus: bus, dir: dir, st: st} + + for _, p := range plugins { + // Persist plugin record; preserves existing enabled state on conflict. + if err := st.RegisterPlugin(p.name, p.path); err != nil { + logger.Error("plugin: register %s: %v", p.name, err) + } + // Restore enabled state from previous session. + if enabled, err := st.PluginEnabled(p.name); err == nil { + p.enabled.Store(enabled) + } + // Drain any log() calls made during on_load so they reach the UI. + e.emitLogs(p) + } + + active := make([]string, 0, len(plugins)) + for _, p := range plugins { + active = append(active, p.name) + } + if err := st.ScanPlugins(active); err != nil { + logger.Error("plugin: scan: %v", err) + } + + return e, nil +} + +// emitLogs drains a plugin's log buffer and emits each line onto the bus. +// Called after every hook invocation, including on error, so no log line +// is ever lost. +func (e *Engine) emitLogs(p *Plugin) { + for _, line := range p.drainLogs() { + e.bus.EmitPluginLog(events.PluginLogEvent{Name: p.name, Message: line}) + } +} + +// HandleRequest implements events.RequestMiddleware. +// Each plugin's on_request(req) hook is called in load order. Table +// construction and the Lua call happen in a single locked section via +// callWithBuilder — no VM access escapes the lock at any point. +// A plugin may modify method, url, headers, body, or set drop=true. +// The first drop short-circuits all remaining plugins. +func (e *Engine) HandleRequest(ev events.RequestEvent) events.RequestResult { + req := ev.Request + body := ev.Body + + if req == nil || req.URL == nil { + return events.RequestResult{Drop: false, Request: req, Body: body} + } + + for _, p := range e.plugins { + if !p.enabled.Load() { + continue + } + if !p.has("on_request") { + continue + } + + // Snapshot Go values before entering the lock so the builder closure + // captures plain Go strings, not live request fields that could race. + method := req.Method + rawURL := req.URL.String() + bodySnap := string(body) + headers := req.Header + + ret, err := p.callWithBuilder("on_request", func(L *lua.LState) *lua.LTable { + tbl := L.NewTable() + L.SetField(tbl, "method", lua.LString(method)) + L.SetField(tbl, "url", lua.LString(rawURL)) + L.SetField(tbl, "body", lua.LString(bodySnap)) + L.SetField(tbl, "drop", lua.LFalse) + + hTbl := L.NewTable() + for k, vals := range headers { + L.SetField(hTbl, k, lua.LString(strings.Join(vals, ", "))) + } + L.SetField(tbl, "headers", hTbl) + return tbl + }) + e.emitLogs(p) + if err != nil { + logger.Error("plugin %s: on_request: %v", p.name, err) + continue + } + if ret == nil { + continue + } + + // Read back modifications. ret is a Lua table owned by p.L — + // mu must be held while reading it. + p.mu.Lock() + drop := false + if d, ok := ret.RawGetString("drop").(lua.LBool); ok { + drop = bool(d) + } + if !drop { + if m, ok := ret.RawGetString("method").(lua.LString); ok && string(m) != "" { + req.Method = string(m) + } + if u, ok := ret.RawGetString("url").(lua.LString); ok && string(u) != "" { + if parsed, err := req.URL.Parse(string(u)); err == nil { + req.URL = parsed + } + } + if b, ok := ret.RawGetString("body").(lua.LString); ok { + body = []byte(b) + } + if hTbl, ok := ret.RawGetString("headers").(*lua.LTable); ok { + newHeaders := make(http.Header) + hTbl.ForEach(func(k, v lua.LValue) { + newHeaders.Set(k.String(), v.String()) + }) + req.Header = newHeaders + } + } + p.mu.Unlock() + + if drop { + return events.RequestResult{Drop: true, Request: req, Body: body} + } + } + + return events.RequestResult{Drop: false, Request: req, Body: body} +} + +// ObserveResponse implements events.ResponseObserver. +// Each plugin's on_response(resp) hook is called in load order. Table +// construction and the Lua call happen in a single locked section via +// callWithBuilder — no VM access escapes the lock at any point. +// The hook is read-only — return value is ignored. +func (e *Engine) ObserveResponse(ev events.ResponseEvent) { + // Snapshot all event fields once before the plugin loop. Each plugin + // call captures these plain Go values in its builder closure — no live + // event fields are accessed inside the lock. + host := ev.Host + // ev.Host arrives as host:port from the proxy; expose bare hostname to plugins. + if h, _, err := net.SplitHostPort(ev.Host); err == nil { + host = h + } + method := ev.Method + url := ev.URL + proto := ev.Proto + status := ev.StatusCode + durationMs := ev.DurationMs + tls := ev.TLS + reqBody := string(ev.ReqBody) + respBody := string(ev.RespBody) + reqHeaders := ev.ReqHeaders + respHeaders := ev.RespHeaders + + for _, p := range e.plugins { + if !p.enabled.Load() { + continue + } + if !p.has("on_response") { + continue + } + + _, err := p.callWithBuilder("on_response", func(L *lua.LState) *lua.LTable { + tbl := L.NewTable() + L.SetField(tbl, "host", lua.LString(host)) + L.SetField(tbl, "method", lua.LString(method)) + L.SetField(tbl, "url", lua.LString(url)) + L.SetField(tbl, "proto", lua.LString(proto)) + L.SetField(tbl, "status", lua.LNumber(status)) + L.SetField(tbl, "duration_ms", lua.LNumber(durationMs)) + L.SetField(tbl, "tls", lua.LBool(tls)) + L.SetField(tbl, "req_body", lua.LString(reqBody)) + L.SetField(tbl, "resp_body", lua.LString(respBody)) + + rqH := L.NewTable() + for k, vals := range reqHeaders { + L.SetField(rqH, k, lua.LString(strings.Join(vals, ", "))) + } + L.SetField(tbl, "req_headers", rqH) + + rpH := L.NewTable() + for k, vals := range respHeaders { + L.SetField(rpH, k, lua.LString(strings.Join(vals, ", "))) + } + L.SetField(tbl, "resp_headers", rpH) + return tbl + }) + e.emitLogs(p) + if err != nil { + logger.Error("plugin %s: on_response: %v", p.name, err) + } + } +} + +// ObserveWebSocketFrame implements events.WebSocketFrameObserver. +// Each plugin's on_websocket_frame(frame) hook is called in load order. +// The hook may modify frame.payload — the last modification wins. +// Plugins cannot drop frames. +func (e *Engine) ObserveWebSocketFrame(ev events.WebSocketFrameEvent) events.WebSocketFrameResult { + payload := ev.Payload + + // Snapshot Go values before the plugin loop. + connID := ev.ConnectionID + direction := int(ev.Direction) + opcode := int(ev.Opcode) + payloadSnap := string(ev.Payload) + + for _, p := range e.plugins { + if !p.enabled.Load() { + continue + } + if !p.has("on_websocket_frame") { + continue + } + + ret, err := p.callWithBuilder("on_websocket_frame", func(L *lua.LState) *lua.LTable { + tbl := L.NewTable() + L.SetField(tbl, "connection_id", lua.LNumber(connID)) + L.SetField(tbl, "direction", lua.LNumber(direction)) + L.SetField(tbl, "opcode", lua.LNumber(opcode)) + L.SetField(tbl, "payload", lua.LString(payloadSnap)) + return tbl + }) + e.emitLogs(p) + if err != nil { + logger.Error("plugin %s: on_websocket_frame: %v", p.name, err) + continue + } + if ret == nil { + continue + } + + p.mu.Lock() + if b, ok := ret.RawGetString("payload").(lua.LString); ok { + payload = []byte(b) + payloadSnap = string(payload) + } + p.mu.Unlock() + } + + return events.WebSocketFrameResult{Payload: payload} +} + +// ObservePluginEnabled implements events.PluginEnabledObserver. +// Toggles the in-memory enabled flag for the named plugin so the change +// takes effect immediately for all subsequent hook calls. +func (e *Engine) ObservePluginEnabled(ev events.SetPluginEnabledEvent) { + for _, p := range e.plugins { + if p.name == ev.Name { + p.enabled.Store(ev.Enabled) + return + } + } +} + +// ObserveLoadPlugin implements events.LoadPluginObserver. +// Copies the selected file into the plugins directory, loads it, registers +// it in the store, and appends it to the engine's plugin slice so it +// participates in all subsequent hook calls. +// Safe without a mutex — the bus is synchronous so this and all hook +// iteration methods are always called sequentially. +func (e *Engine) ObserveLoadPlugin(ev events.LoadPluginEvent) { + filename := filepath.Base(ev.SourcePath) + destPath := filepath.Join(e.dir, filename) + + if err := copyFile(ev.SourcePath, destPath); err != nil { + logger.Error("plugin: import %s: %v", filename, err) + return + } + + p, err := load(destPath, e.st) + if err != nil { + logger.Error("plugin: load %s: %v", filename, err) + return + } + + if err := e.st.RegisterPlugin(p.name, p.path); err != nil { + logger.Error("plugin: register %s: %v", p.name, err) + } + + e.emitLogs(p) + e.plugins = append(e.plugins, p) +} + +// copyFile copies src to dst, creating the destination directory if needed. +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer in.Close() + + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return fmt.Errorf("mkdir: %w", err) + } + + out, err := os.Create(dst) + if err != nil { + return fmt.Errorf("create: %w", err) + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return fmt.Errorf("copy: %w", err) + } + return out.Sync() +} + +// Close shuts down all plugin VMs. +func (e *Engine) Close() { + for _, p := range e.plugins { + p.close() + } +} diff --git a/internal/plugin/engine_test.go b/internal/plugin/engine_test.go new file mode 100644 index 0000000..33a4f8a --- /dev/null +++ b/internal/plugin/engine_test.go @@ -0,0 +1,1003 @@ +package plugin_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/shiv/internal/events" + "github.com/shiv/internal/plugin" + "github.com/shiv/internal/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func pluginDir(t *testing.T, scripts map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, src := range scripts { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(src), 0644)) + } + return dir +} + +func newTestStore(t *testing.T) *store.Store { + t.Helper() + f, err := os.CreateTemp("", "shiv-plugin-engine-*.shiv") + require.NoError(t, err) + f.Close() + t.Cleanup(func() { os.Remove(f.Name()) }) + st, err := store.Open(f.Name()) + require.NoError(t, err) + t.Cleanup(func() { st.Close() }) + return st +} + +// newEngineWithBus creates an Engine using a caller-supplied bus so tests can +// register custom observers (e.g. to capture log events). +func newEngineWithBus(t *testing.T, st *store.Store, bus *events.Bus, scripts map[string]string) *plugin.Engine { + t.Helper() + dir := pluginDir(t, scripts) + e, err := plugin.NewEngine(dir, st, bus) + require.NoError(t, err) + t.Cleanup(func() { e.Close() }) + return e +} + +// newEngine creates an Engine with a no-op bus — sufficient for tests that do +// not need to observe log or enable events. +func newEngine(t *testing.T, st *store.Store, scripts map[string]string) *plugin.Engine { + t.Helper() + return newEngineWithBus(t, st, events.NewBus(), scripts) +} + +func requestEvent(t *testing.T, method, rawURL string, body []byte) events.RequestEvent { + t.Helper() + return events.RequestEvent{ + Request: httptest.NewRequest(method, rawURL, nil), + Body: body, + } +} + +func responseEvent() events.ResponseEvent { + return events.ResponseEvent{ + Timestamp: time.Now(), + Host: "example.com", + Proto: "HTTP/1.1", + Method: "GET", + URL: "http://example.com/path", + ReqHeaders: http.Header{"X-Req": []string{"req-val"}}, + ReqBody: []byte("req body"), + StatusCode: 200, + RespHeaders: http.Header{"X-Resp": []string{"resp-val"}}, + RespBody: []byte("resp body"), + DurationMs: 55, + TLS: true, + } +} + +func wsFrameEvent(connID uint64, payload []byte) events.WebSocketFrameEvent { + return events.WebSocketFrameEvent{ + ConnectionID: connID, + Timestamp: time.Now(), + Direction: events.WebSocketClient, + Opcode: events.WebSocketText, + Payload: payload, + } +} + +// ── NewEngine ───────────────────────────────────────────────────────────────── + +func TestNewEngine_EmptyDir_ReturnsEngine(t *testing.T) { + st := newTestStore(t) + e, err := plugin.NewEngine(t.TempDir(), st, events.NewBus()) + require.NoError(t, err) + assert.NotNil(t, e) + e.Close() +} + +func TestNewEngine_NonExistentDir_ReturnsEngine(t *testing.T) { + st := newTestStore(t) + e, err := plugin.NewEngine("/does/not/exist", st, events.NewBus()) + require.NoError(t, err) + assert.NotNil(t, e) + e.Close() +} + +func TestNewEngine_InvalidScript_SkippedEngineStillCreated(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "bad.lua": `%%% not lua`, + "good.lua": `x = 1`, + }) + assert.NotPanics(t, func() { + e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil)) + }) +} + +func TestNewEngine_RegistersPluginsInStore(t *testing.T) { + st := newTestStore(t) + newEngine(t, st, map[string]string{ + "a.lua": `x = 1`, + "b.lua": `y = 2`, + }) + + entries, err := st.AllPlugins() + require.NoError(t, err) + assert.Len(t, entries, 2) +} + +func TestNewEngine_RestoresEnabledStateFromStore(t *testing.T) { + st := newTestStore(t) + dir := pluginDir(t, map[string]string{ + "test.lua": `function on_request(r) r.drop = true; return r end`, + }) + + // First load — plugin is enabled by default and drops requests. + e1, err := plugin.NewEngine(dir, st, events.NewBus()) + require.NoError(t, err) + result := e1.HandleRequest(requestEvent(t, "GET", "http://example.com", nil)) + assert.True(t, result.Drop) + e1.Close() + + // Disable via store directly. + st.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "test.lua", Enabled: false}) + + // Second load — must restore disabled state from store. + e2, err := plugin.NewEngine(dir, st, events.NewBus()) + require.NoError(t, err) + t.Cleanup(func() { e2.Close() }) + result = e2.HandleRequest(requestEvent(t, "GET", "http://example.com", nil)) + assert.False(t, result.Drop, "plugin disabled in store must not fire on_request") +} + +func TestNewEngine_ScanRemovesStaleRecords(t *testing.T) { + st := newTestStore(t) + + // Manually register a plugin that won't be on disk. + require.NoError(t, st.RegisterPlugin("ghost.lua", "/ghost.lua")) + + dir := pluginDir(t, map[string]string{"real.lua": `x = 1`}) + _, err := plugin.NewEngine(dir, st, events.NewBus()) + require.NoError(t, err) + + entries, err := st.AllPlugins() + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, "real.lua", entries[0].Name) +} + +// ── HandleRequest — no hook ─────────────────────────────────────────────────── + +func TestHandleRequest_NoPlugins_PassThrough(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, nil) + ev := requestEvent(t, "GET", "http://example.com/path", []byte("body")) + result := e.HandleRequest(ev) + assert.False(t, result.Drop) + assert.Equal(t, ev.Request, result.Request) + assert.Equal(t, ev.Body, result.Body) +} + +func TestHandleRequest_PluginWithNoOnRequest_PassThrough(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{"noop.lua": `-- no hooks`}) + result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil)) + assert.False(t, result.Drop) +} + +func TestHandleRequest_NilRequest_PassThrough(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "hook.lua": `function on_request(r) r.drop = true; return r end`, + }) + result := e.HandleRequest(events.RequestEvent{Request: nil, Body: nil}) + assert.False(t, result.Drop) +} + +// ── HandleRequest — drop ────────────────────────────────────────────────────── + +func TestHandleRequest_Drop_ReturnsDrop(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "drop.lua": `function on_request(r) r.drop = true; return r end`, + }) + result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil)) + assert.True(t, result.Drop) +} + +func TestHandleRequest_FirstPluginDrops_SecondNotCalled(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "1_drop.lua": `function on_request(r) r.drop = true; return r end`, + "2_panic.lua": `function on_request(r) error("must not be called") end`, + }) + result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil)) + assert.True(t, result.Drop) +} + +// ── HandleRequest — modifications ───────────────────────────────────────────── + +func TestHandleRequest_ModifiesBody(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "body.lua": `function on_request(r) r.body = "injected"; return r end`, + }) + result := e.HandleRequest(requestEvent(t, "POST", "http://example.com", []byte("original"))) + assert.False(t, result.Drop) + assert.Equal(t, []byte("injected"), result.Body) +} + +func TestHandleRequest_ModifiesMethod(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "method.lua": `function on_request(r) r.method = "POST"; return r end`, + }) + result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil)) + assert.Equal(t, "POST", result.Request.Method) +} + +func TestHandleRequest_ModifiesURL(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "url.lua": `function on_request(r) r.url = "http://example.com/modified"; return r end`, + }) + result := e.HandleRequest(requestEvent(t, "GET", "http://example.com/original", nil)) + assert.Equal(t, "/modified", result.Request.URL.Path) +} + +func TestHandleRequest_ModifiesHeaders(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "headers.lua": `function on_request(r) r.headers["X-Plugin"] = "injected"; return r end`, + }) + result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil)) + assert.Equal(t, "injected", result.Request.Header.Get("X-Plugin")) +} + +func TestHandleRequest_ChainedPlugins_ModificationsAccumulate(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "1_first.lua": `function on_request(r) r.body = r.body .. "-first"; return r end`, + "2_second.lua": `function on_request(r) r.body = r.body .. "-second"; return r end`, + }) + result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", []byte("start"))) + assert.Equal(t, []byte("start-first-second"), result.Body) +} + +func TestHandleRequest_LuaError_PluginSkipped_ChainContinues(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "1_error.lua": `function on_request(r) error("oops") end`, + "2_ok.lua": `function on_request(r) r.body = "reached"; return r end`, + }) + result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", []byte("original"))) + assert.False(t, result.Drop) + assert.Equal(t, []byte("reached"), result.Body) +} + +// ── HandleRequest — enabled flag ────────────────────────────────────────────── + +func TestHandleRequest_DisabledPlugin_HookNotCalled(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "drop.lua": `function on_request(r) r.drop = true; return r end`, + }) + + e.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "drop.lua", Enabled: false}) + + result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil)) + assert.False(t, result.Drop, "disabled plugin must not fire on_request") +} + +func TestHandleRequest_ReenabledPlugin_HookCalledAgain(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "drop.lua": `function on_request(r) r.drop = true; return r end`, + }) + + e.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "drop.lua", Enabled: false}) + result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil)) + assert.False(t, result.Drop) + + e.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "drop.lua", Enabled: true}) + result = e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil)) + assert.True(t, result.Drop, "re-enabled plugin must fire on_request again") +} + +func TestHandleRequest_DisabledPluginInChain_OthersStillRun(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "1_disabled.lua": `function on_request(r) r.body = r.body .. "-disabled"; return r end`, + "2_enabled.lua": `function on_request(r) r.body = r.body .. "-enabled"; return r end`, + }) + + e.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "1_disabled.lua", Enabled: false}) + + result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", []byte("start"))) + assert.Equal(t, []byte("start-enabled"), result.Body) +} + +// ── ObservePluginEnabled ────────────────────────────────────────────────────── + +func TestObservePluginEnabled_UnknownPlugin_NoPanic(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, nil) + assert.NotPanics(t, func() { + e.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "ghost.lua", Enabled: false}) + }) +} + +func TestObservePluginEnabled_TakesEffectImmediately(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "drop.lua": `function on_request(r) r.drop = true; return r end`, + }) + + // Enabled by default — drops. + result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil)) + assert.True(t, result.Drop) + + // Disable — next call must pass through. + e.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "drop.lua", Enabled: false}) + result = e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil)) + assert.False(t, result.Drop) +} + +// ── ObserveResponse ─────────────────────────────────────────────────────────── + +func TestObserveResponse_NoPlugins_NoPanic(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, nil) + assert.NotPanics(t, func() { e.ObserveResponse(responseEvent()) }) +} + +func TestObserveResponse_ReceivesCorrectFields(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "observe.lua": ` + function on_response(r) + db.loot_add(r.host .. "|" .. r.method .. "|" .. tostring(r.status) .. "|" .. tostring(r.tls), "Info") + end + `, + }) + + ev := responseEvent() + e.ObserveResponse(ev) + time.Sleep(50 * time.Millisecond) + + loot, err := st.AllLoot() + require.NoError(t, err) + require.Len(t, loot, 1) + assert.Equal(t, fmt.Sprintf("%s|%s|%d|%v", ev.Host, ev.Method, ev.StatusCode, ev.TLS), loot[0].Title) +} + +func TestObserveResponse_MultiplePluginsAllCalled(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "a.lua": `function on_response(r) db.loot_add("a", "Info") end`, + "b.lua": `function on_response(r) db.loot_add("b", "Info") end`, + }) + e.ObserveResponse(responseEvent()) + time.Sleep(50 * time.Millisecond) + loot, err := st.AllLoot() + require.NoError(t, err) + assert.Len(t, loot, 2) +} + +func TestObserveResponse_LuaError_OtherPluginsContinue(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "1_error.lua": `function on_response(r) error("fail") end`, + "2_ok.lua": `function on_response(r) db.loot_add("ok", "Info") end`, + }) + e.ObserveResponse(responseEvent()) + time.Sleep(50 * time.Millisecond) + loot, err := st.AllLoot() + require.NoError(t, err) + require.Len(t, loot, 1) + assert.Equal(t, "ok", loot[0].Title) +} + +func TestObserveResponse_DisabledPlugin_HookNotCalled(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "obs.lua": `function on_response(r) db.loot_add("called", "Info") end`, + }) + + e.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "obs.lua", Enabled: false}) + e.ObserveResponse(responseEvent()) + time.Sleep(50 * time.Millisecond) + + loot, err := st.AllLoot() + require.NoError(t, err) + assert.Empty(t, loot, "disabled plugin must not fire on_response") +} + +// ── ObserveWebSocketFrame ───────────────────────────────────────────────────── + +func TestObserveWebSocketFrame_NoPlugins_ReturnsOriginalPayload(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, nil) + ev := wsFrameEvent(1, []byte("hello")) + result := e.ObserveWebSocketFrame(ev) + assert.Equal(t, []byte("hello"), result.Payload) +} + +func TestObserveWebSocketFrame_PluginWithNoHook_ReturnsOriginalPayload(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{"noop.lua": `-- no hooks`}) + result := e.ObserveWebSocketFrame(wsFrameEvent(1, []byte("original"))) + assert.Equal(t, []byte("original"), result.Payload) +} + +func TestObserveWebSocketFrame_PluginModifiesPayload(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "ws.lua": ` + function on_websocket_frame(f) + f.payload = f.payload .. "-modified" + return f + end + `, + }) + result := e.ObserveWebSocketFrame(wsFrameEvent(1, []byte("hello"))) + assert.Equal(t, []byte("hello-modified"), result.Payload) +} + +func TestObserveWebSocketFrame_ChainedPlugins_ModificationsAccumulate(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "1_first.lua": `function on_websocket_frame(f) f.payload = f.payload .. "-first"; return f end`, + "2_second.lua": `function on_websocket_frame(f) f.payload = f.payload .. "-second"; return f end`, + }) + result := e.ObserveWebSocketFrame(wsFrameEvent(1, []byte("start"))) + assert.Equal(t, []byte("start-first-second"), result.Payload) +} + +func TestObserveWebSocketFrame_ReceivesCorrectFields(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "ws.lua": ` + function on_websocket_frame(f) + db.loot_add( + tostring(f.connection_id) .. "|" .. tostring(f.direction) .. "|" .. tostring(f.opcode), + "Info" + ) + return f + end + `, + }) + + ev := events.WebSocketFrameEvent{ + ConnectionID: 42, + Timestamp: time.Now(), + Direction: events.WebSocketServer, + Opcode: events.WebSocketBinary, + Payload: []byte("data"), + } + e.ObserveWebSocketFrame(ev) + time.Sleep(50 * time.Millisecond) + + loot, err := st.AllLoot() + require.NoError(t, err) + require.Len(t, loot, 1) + assert.Equal(t, "42|1|2", loot[0].Title) +} + +func TestObserveWebSocketFrame_DisabledPlugin_HookNotCalled(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "ws.lua": `function on_websocket_frame(f) f.payload = "modified"; return f end`, + }) + + e.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "ws.lua", Enabled: false}) + result := e.ObserveWebSocketFrame(wsFrameEvent(1, []byte("original"))) + assert.Equal(t, []byte("original"), result.Payload, "disabled plugin must not modify frame") +} + +func TestObserveWebSocketFrame_LuaError_ReturnsLastGoodPayload(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "1_ok.lua": `function on_websocket_frame(f) f.payload = "good"; return f end`, + "2_error.lua": `function on_websocket_frame(f) error("fail") end`, + }) + result := e.ObserveWebSocketFrame(wsFrameEvent(1, []byte("original"))) + assert.Equal(t, []byte("good"), result.Payload) +} + +// ── ObserveLoadPlugin ───────────────────────────────────────────────────────── + +func TestObserveLoadPlugin_LoadsAndRegistersPlugin(t *testing.T) { + st := newTestStore(t) + destDir := t.TempDir() + e, err := plugin.NewEngine(destDir, st, events.NewBus()) + require.NoError(t, err) + t.Cleanup(func() { e.Close() }) + + // Write source file to a separate temp dir. + srcDir := t.TempDir() + srcPath := filepath.Join(srcDir, "new.lua") + require.NoError(t, os.WriteFile(srcPath, []byte(` + function on_request(r) + r.body = "from-new-plugin" + return r + end + `), 0644)) + + e.ObserveLoadPlugin(events.LoadPluginEvent{SourcePath: srcPath}) + + // Plugin must be active immediately. + result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", []byte("original"))) + assert.Equal(t, []byte("from-new-plugin"), result.Body) +} + +func TestObserveLoadPlugin_CopiesFileToPluginsDir(t *testing.T) { + st := newTestStore(t) + destDir := t.TempDir() + e, err := plugin.NewEngine(destDir, st, events.NewBus()) + require.NoError(t, err) + t.Cleanup(func() { e.Close() }) + + srcPath := filepath.Join(t.TempDir(), "copied.lua") + require.NoError(t, os.WriteFile(srcPath, []byte(`x = 1`), 0644)) + + e.ObserveLoadPlugin(events.LoadPluginEvent{SourcePath: srcPath}) + + _, err = os.Stat(filepath.Join(destDir, "copied.lua")) + assert.NoError(t, err, "file must be copied to plugins directory") +} + +func TestObserveLoadPlugin_RegistersInStore(t *testing.T) { + st := newTestStore(t) + destDir := t.TempDir() + e, err := plugin.NewEngine(destDir, st, events.NewBus()) + require.NoError(t, err) + t.Cleanup(func() { e.Close() }) + + srcPath := filepath.Join(t.TempDir(), "registered.lua") + require.NoError(t, os.WriteFile(srcPath, []byte(`x = 1`), 0644)) + + e.ObserveLoadPlugin(events.LoadPluginEvent{SourcePath: srcPath}) + time.Sleep(50 * time.Millisecond) + + entries, err := st.AllPlugins() + require.NoError(t, err) + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name) + } + assert.Contains(t, names, "registered.lua") +} + +func TestObserveLoadPlugin_BadSourcePath_NoPanic(t *testing.T) { + st := newTestStore(t) + e, err := plugin.NewEngine(t.TempDir(), st, events.NewBus()) + require.NoError(t, err) + t.Cleanup(func() { e.Close() }) + + assert.NotPanics(t, func() { + e.ObserveLoadPlugin(events.LoadPluginEvent{SourcePath: "/does/not/exist.lua"}) + }) +} + +func TestObserveLoadPlugin_InvalidLua_NoPanic(t *testing.T) { + st := newTestStore(t) + destDir := t.TempDir() + e, err := plugin.NewEngine(destDir, st, events.NewBus()) + require.NoError(t, err) + t.Cleanup(func() { e.Close() }) + + srcPath := filepath.Join(t.TempDir(), "bad.lua") + require.NoError(t, os.WriteFile(srcPath, []byte(`%%% invalid lua`), 0644)) + + assert.NotPanics(t, func() { + e.ObserveLoadPlugin(events.LoadPluginEvent{SourcePath: srcPath}) + }) +} + +// ── log() routing ───────────────────────────────────────────────────────────── + +func TestLog_RoutedThroughBus(t *testing.T) { + st := newTestStore(t) + bus := events.NewBus() + + var mu sync.Mutex + var logLines []string + bus.Register(events.PluginLogObserverFunc(func(e events.PluginLogEvent) { + mu.Lock() + logLines = append(logLines, e.Name+": "+e.Message) + mu.Unlock() + })) + + newEngineWithBus(t, st, bus, map[string]string{ + "logger.lua": ` + function on_request(r) + log("intercepted request") + return r + end + `, + }) + + newEngineWithBus(t, st, bus, map[string]string{ + "logger.lua": ` + function on_request(r) + log("intercepted request") + return r + end + `, + }).HandleRequest(requestEvent(t, "GET", "http://example.com", nil)) + + mu.Lock() + found := false + for _, l := range logLines { + if l == "logger.lua: intercepted request" { + found = true + break + } + } + mu.Unlock() + assert.True(t, found, "log() call must emit PluginLogEvent on the bus") +} + +func TestLog_OnLoad_DrainedAfterNewEngine(t *testing.T) { + st := newTestStore(t) + bus := events.NewBus() + + var mu sync.Mutex + var logLines []string + bus.Register(events.PluginLogObserverFunc(func(e events.PluginLogEvent) { + mu.Lock() + logLines = append(logLines, e.Message) + mu.Unlock() + })) + + dir := pluginDir(t, map[string]string{ + "startup.lua": ` + function on_load() + log("loaded ok") + end + `, + }) + e, err := plugin.NewEngine(dir, st, bus) + require.NoError(t, err) + t.Cleanup(func() { e.Close() }) + + mu.Lock() + lines := append([]string{}, logLines...) + mu.Unlock() + assert.Contains(t, lines, "loaded ok", "on_load log() calls must be emitted before NewEngine returns") +} + +// ── Engine.Close ────────────────────────────────────────────────────────────── + +func TestClose_NoPanic(t *testing.T) { + st := newTestStore(t) + e, err := plugin.NewEngine(t.TempDir(), st, events.NewBus()) + require.NoError(t, err) + assert.NotPanics(t, func() { e.Close() }) +} + +// ── API: log ────────────────────────────────────────────────────────────────── + +func TestAPI_Log_IsAvailable(t *testing.T) { + st := newTestStore(t) + dir := pluginDir(t, map[string]string{ + "log.lua": `function on_load() log("hello from plugin") end`, + }) + e, err := plugin.NewEngine(dir, st, events.NewBus()) + require.NoError(t, err) + e.Close() +} + +// ── API: http.get ───────────────────────────────────────────────────────────── + +func TestAPI_HTTPGet_ReturnsStatusAndBody(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "pong") + })) + defer upstream.Close() + + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "get.lua": fmt.Sprintf(` + function on_response(r) + local resp = http.get("%s") + if resp then db.loot_add(tostring(resp.status) .. "|" .. resp.body, "Info") end + end + `, upstream.URL), + }) + e.ObserveResponse(responseEvent()) + time.Sleep(100 * time.Millisecond) + + loot, err := st.AllLoot() + require.NoError(t, err) + require.Len(t, loot, 1) + assert.Equal(t, "200|pong", loot[0].Title) +} + +func TestAPI_HTTPGet_BadURL_ReturnsNilAndError(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "get_fail.lua": ` + function on_response(r) + local resp, err = http.get("http://127.0.0.1:1/") + if resp == nil and err ~= nil then db.loot_add("error_handled", "Info") end + end + `, + }) + e.ObserveResponse(responseEvent()) + time.Sleep(200 * time.Millisecond) + + loot, err := st.AllLoot() + require.NoError(t, err) + require.Len(t, loot, 1) + assert.Equal(t, "error_handled", loot[0].Title) +} + +// ── API: http.post ──────────────────────────────────────────────────────────── + +func TestAPI_HTTPPost_SendsBodyAndReturnsResponse(t *testing.T) { + var receivedBody string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, 512) + n, _ := r.Body.Read(buf) + receivedBody = string(buf[:n]) + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, "created") + })) + defer upstream.Close() + + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "post.lua": fmt.Sprintf(` + function on_response(r) + local resp = http.post("%s", '{"key":"val"}', "application/json") + if resp then db.loot_add(tostring(resp.status) .. "|" .. resp.body, "Info") end + end + `, upstream.URL), + }) + e.ObserveResponse(responseEvent()) + time.Sleep(100 * time.Millisecond) + + loot, err := st.AllLoot() + require.NoError(t, err) + require.Len(t, loot, 1) + assert.Equal(t, "201|created", loot[0].Title) + assert.Equal(t, `{"key":"val"}`, receivedBody) +} + +// ── API: http.request ───────────────────────────────────────────────────────── + +func TestAPI_HTTPRequest_SendsCustomMethodAndHeaders(t *testing.T) { + var receivedMethod, receivedHeader string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedMethod = r.Method + receivedHeader = r.Header.Get("X-Custom") + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "request.lua": fmt.Sprintf(` + function on_response(r) + local resp = http.request("DELETE", "%s", "", {["X-Custom"] = "plugin-value"}) + if resp then db.loot_add(tostring(resp.status), "Info") end + end + `, upstream.URL), + }) + e.ObserveResponse(responseEvent()) + time.Sleep(100 * time.Millisecond) + + assert.Equal(t, "DELETE", receivedMethod) + assert.Equal(t, "plugin-value", receivedHeader) +} + +// ── API: db.history ─────────────────────────────────────────────────────────── + +func TestAPI_DBHistory_ReturnsTransactions(t *testing.T) { + st := newTestStore(t) + require.NoError(t, st.Log(store.Transaction{ + Timestamp: time.Now(), Host: "target.com", Method: "GET", + URL: "http://target.com/", Proto: "HTTP/1.1", StatusCode: 200, InScope: true, + })) + time.Sleep(50 * time.Millisecond) + + e := newEngine(t, st, map[string]string{ + "history.lua": ` + function on_response(r) + local txs = db.history(10) + if txs then db.loot_add(tostring(#txs), "Info") end + end + `, + }) + e.ObserveResponse(responseEvent()) + time.Sleep(50 * time.Millisecond) + + loot, err := st.AllLoot() + require.NoError(t, err) + require.Len(t, loot, 1) + assert.Equal(t, "1", loot[0].Title) +} + +func TestAPI_DBHistory_TransactionFieldsExposed(t *testing.T) { + st := newTestStore(t) + require.NoError(t, st.Log(store.Transaction{ + Timestamp: time.Now(), Host: "check.com", Method: "POST", + URL: "http://check.com/api", Proto: "HTTP/1.1", StatusCode: 201, InScope: true, + })) + time.Sleep(50 * time.Millisecond) + + e := newEngine(t, st, map[string]string{ + "fields.lua": ` + function on_response(r) + local txs = db.history(1) + if txs and #txs > 0 then + local tx = txs[1] + db.loot_add(tx.host .. "|" .. tx.method .. "|" .. tostring(tx.status), "Info") + end + end + `, + }) + e.ObserveResponse(responseEvent()) + time.Sleep(50 * time.Millisecond) + + loot, err := st.AllLoot() + require.NoError(t, err) + require.Len(t, loot, 1) + assert.Equal(t, "check.com|POST|201", loot[0].Title) +} + +// ── API: db.history_filter ──────────────────────────────────────────────────── + +func TestAPI_DBHistoryFilter_FiltersByHost(t *testing.T) { + st := newTestStore(t) + for _, host := range []string{"alpha.com", "beta.com", "alpha.com"} { + require.NoError(t, st.Log(store.Transaction{ + Timestamp: time.Now(), Host: host, Method: "GET", + URL: "http://" + host + "/", Proto: "HTTP/1.1", StatusCode: 200, InScope: true, + })) + } + time.Sleep(50 * time.Millisecond) + + e := newEngine(t, st, map[string]string{ + "filter.lua": ` + function on_response(r) + local txs = db.history_filter("alpha.com") + db.loot_add(tostring(#txs), "Info") + end + `, + }) + e.ObserveResponse(responseEvent()) + time.Sleep(50 * time.Millisecond) + + loot, err := st.AllLoot() + require.NoError(t, err) + require.Len(t, loot, 1) + assert.Equal(t, "2", loot[0].Title) +} + +// ── API: db.scope ───────────────────────────────────────────────────────────── + +func TestAPI_DBScope_AddGetRemove(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "scope.lua": ` + function on_response(r) + db.scope_add("plugin-scope.com") + local entries = db.scope_get() + db.loot_add("count:" .. tostring(#entries), "Info") + if #entries > 0 then db.scope_remove(entries[1].id) end + local after = db.scope_get() + db.loot_add("after:" .. tostring(#after), "Info") + end + `, + }) + e.ObserveResponse(responseEvent()) + time.Sleep(50 * time.Millisecond) + + loot, err := st.AllLoot() + require.NoError(t, err) + require.Len(t, loot, 2) + titles := map[string]bool{} + for _, l := range loot { + titles[l.Title] = true + } + assert.True(t, titles["count:1"]) + assert.True(t, titles["after:0"]) +} + +// ── API: db.loot ────────────────────────────────────────────────────────────── + +func TestAPI_DBLoot_AllFieldsStored(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "loot.lua": ` + function on_response(r) + db.loot_add("my finding", "Critical", "detailed notes", "raw req", "raw resp") + end + `, + }) + e.ObserveResponse(responseEvent()) + time.Sleep(50 * time.Millisecond) + + loot, err := st.AllLoot() + require.NoError(t, err) + require.Len(t, loot, 1) + assert.Equal(t, "my finding", loot[0].Title) + assert.Equal(t, "Critical", loot[0].Severity) + assert.Equal(t, "detailed notes", loot[0].Notes) + assert.Equal(t, "raw req", loot[0].RawRequest) + assert.Equal(t, "raw resp", loot[0].RawResponse) +} + +func TestAPI_DBLoot_AddGetDelete(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "loot.lua": ` + function on_response(r) + local id = db.loot_add("finding", "High", "notes", "req", "resp") + local entries = db.loot_get() + db.loot_add("count:" .. tostring(#entries), "Info") + db.loot_delete(id) + local after = db.loot_get() + db.loot_add("after:" .. tostring(#after), "Info") + end + `, + }) + e.ObserveResponse(responseEvent()) + time.Sleep(50 * time.Millisecond) + + loot, err := st.AllLoot() + require.NoError(t, err) + titles := map[string]bool{} + for _, l := range loot { + titles[l.Title] = true + } + assert.True(t, titles["count:1"]) + assert.True(t, titles["after:1"]) +} + +// ── Concurrency ─────────────────────────────────────────────────────────────── + +func TestEngine_ConcurrentHandleRequest_Safe(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{ + "concurrent.lua": `function on_request(r) r.body = r.body .. "-ok"; return r end`, + }) + const goroutines = 30 + var wg sync.WaitGroup + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", []byte("body"))) + assert.False(t, result.Drop) + }() + } + wg.Wait() +} + +func TestEngine_ConcurrentObserveResponse_Safe(t *testing.T) { + st := newTestStore(t) + e := newEngine(t, st, map[string]string{"obs.lua": `function on_response(r) end`}) + const goroutines = 30 + var wg sync.WaitGroup + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + e.ObserveResponse(responseEvent()) + }() + } + wg.Wait() +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go new file mode 100644 index 0000000..86496a1 --- /dev/null +++ b/internal/plugin/plugin.go @@ -0,0 +1,197 @@ +package plugin + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + "sync/atomic" + "time" + + lua "github.com/yuin/gopher-lua" + + "github.com/shiv/internal/logger" + "github.com/shiv/internal/store" +) + +const callTimeout = 500 * time.Millisecond + +// Plugin is a single loaded Lua script with its own isolated VM. +// mu serialises all VM access — gopher-lua LState is not goroutine-safe. +type Plugin struct { + name string + path string + L *lua.LState + mu sync.Mutex + + // enabled controls whether this plugin's hooks are called. + // Uses atomic.Bool so the engine can check it without acquiring mu. + enabled atomic.Bool + + // logBuf accumulates log() calls made during a Lua hook execution. + // Written only while mu is held (log() can only be called from within + // a Lua hook, which always runs under mu). Drained by the engine after + // each hook call via drainLogs, which also acquires mu. + logBuf []string +} + +// load reads the Lua file, registers the full API, executes the script, +// then calls on_load() if defined. +func load(path string, st *store.Store) (*Plugin, error) { + name := filepath.Base(path) + + L := lua.NewState(lua.Options{ + SkipOpenLibs: false, + }) + + p := &Plugin{name: name, path: path, L: L} + p.enabled.Store(true) + + // Register API before executing the script so on_load can use it. + // The logFn closure appends to p.logBuf. It is safe without acquiring + // mu because log() is only ever invoked from within a Lua hook, which + // always runs under mu via callNoReturn or callWithBuilder. + registerAPI(L, st, func(msg string) { + p.logBuf = append(p.logBuf, msg) + }) + + if err := L.DoFile(path); err != nil { + L.Close() + return nil, fmt.Errorf("plugin %s: load: %w", name, err) + } + + // Call on_load() if defined — errors are logged but not fatal. + if L.GetGlobal("on_load") != lua.LNil { + if err := p.callNoReturn("on_load", lua.LNil); err != nil { + logger.Error("plugin %s: on_load: %v", name, err) + } + } + + return p, nil +} + +// callNoReturn invokes a Lua function that returns nothing. +// Holds mu for the duration. Enforces callTimeout. Recovers panics. +func (p *Plugin) callNoReturn(fn string, arg lua.LValue) (retErr error) { + p.mu.Lock() + defer p.mu.Unlock() + + defer func() { + if r := recover(); r != nil { + retErr = fmt.Errorf("panic: %v", r) + } + }() + + f := p.L.GetGlobal(fn) + if f == lua.LNil { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), callTimeout) + defer cancel() + p.L.SetContext(ctx) + defer p.L.SetContext(context.Background()) + + params := lua.P{Fn: f, NRet: 0, Protect: true} + if arg != lua.LNil { + return p.L.CallByParam(params, arg) + } + return p.L.CallByParam(params) +} + +// callWithBuilder builds a Lua table and calls fn with it in a single locked +// section, returning the result table. builder receives the VM and must +// construct and return the argument table — it runs under mu. The Lua call +// also runs under mu, so no VM access escapes the lock at any point. +// Returns the returned table, or nil if the function returned nothing. +func (p *Plugin) callWithBuilder(fn string, builder func(L *lua.LState) *lua.LTable) (ret *lua.LTable, retErr error) { + p.mu.Lock() + defer p.mu.Unlock() + + defer func() { + if r := recover(); r != nil { + retErr = fmt.Errorf("panic: %v", r) + } + }() + + f := p.L.GetGlobal(fn) + if f == lua.LNil { + return nil, nil + } + + arg := builder(p.L) + + ctx, cancel := context.WithTimeout(context.Background(), callTimeout) + defer cancel() + p.L.SetContext(ctx) + defer p.L.SetContext(context.Background()) + + if err := p.L.CallByParam(lua.P{Fn: f, NRet: 1, Protect: true}, arg); err != nil { + return nil, err + } + + top := p.L.Get(-1) + p.L.Pop(1) + + if tbl, ok := top.(*lua.LTable); ok { + return tbl, nil + } + return nil, nil +} + +// drainLogs returns all buffered log lines and clears the buffer. +// Called by the engine after each hook invocation so log lines can be +// emitted onto the bus. Acquires mu independently of the hook call. +func (p *Plugin) drainLogs() []string { + p.mu.Lock() + defer p.mu.Unlock() + if len(p.logBuf) == 0 { + return nil + } + lines := p.logBuf + p.logBuf = nil + return lines +} + +// close shuts down the Lua VM. +func (p *Plugin) close() { + p.mu.Lock() + defer p.mu.Unlock() + p.L.Close() +} + +// has returns true if the plugin defines the named global function. +func (p *Plugin) has(fn string) bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.L.GetGlobal(fn) != lua.LNil +} + +// loadDir scans dir for *.lua files and loads each as a Plugin. +// Files that fail to load are logged and skipped. +func loadDir(dir string, st *store.Store) ([]*Plugin, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("plugin: read dir %s: %w", dir, err) + } + + var plugins []*Plugin + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".lua" { + continue + } + path := filepath.Join(dir, e.Name()) + p, err := load(path, st) + if err != nil { + logger.Error("plugin: skip %s: %v", e.Name(), err) + continue + } + logger.Info("plugin: loaded %s", e.Name()) + plugins = append(plugins, p) + } + return plugins, nil +} diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go new file mode 100644 index 0000000..d020c31 --- /dev/null +++ b/internal/plugin/plugin_test.go @@ -0,0 +1,391 @@ +package plugin + +// White-box tests. Package plugin (not plugin_test) so unexported types and +// functions — load, loadDir, Plugin.has, Plugin.callWithBuilder, +// Plugin.callNoReturn — are directly accessible. + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + lua "github.com/yuin/gopher-lua" + + "github.com/shiv/internal/store" +) + +// ── helpers ─────────────────────────────────────────────────────────────────── + +// writeLua writes a Lua script to a temp file and returns its path. +// The file is removed when the test ends. +func writeLua(t *testing.T, src string) string { + t.Helper() + f, err := os.CreateTemp("", "shiv-plugin-*.lua") + require.NoError(t, err) + _, err = f.WriteString(src) + require.NoError(t, err) + require.NoError(t, f.Close()) + t.Cleanup(func() { os.Remove(f.Name()) }) + return f.Name() +} + +// newTestStore opens a temp SQLite store for plugin tests. +func newTestStore(t *testing.T) *store.Store { + t.Helper() + f, err := os.CreateTemp("", "shiv-plugin-store-*.shiv") + require.NoError(t, err) + f.Close() + t.Cleanup(func() { os.Remove(f.Name()) }) + st, err := store.Open(f.Name()) + require.NoError(t, err) + t.Cleanup(func() { st.Close() }) + return st +} + +// loadPlugin is a convenience wrapper around load that also registers cleanup. +func loadPlugin(t *testing.T, src string, st *store.Store) *Plugin { + t.Helper() + path := writeLua(t, src) + p, err := load(path, st) + require.NoError(t, err) + t.Cleanup(func() { p.close() }) + return p +} + +// ── load ────────────────────────────────────────────────────────────────────── + +func TestLoad_ValidScript_ReturnsPlugin(t *testing.T) { + st := newTestStore(t) + p := loadPlugin(t, `-- empty script`, st) + assert.NotNil(t, p) +} + +func TestLoad_InvalidScript_ReturnsError(t *testing.T) { + st := newTestStore(t) + path := writeLua(t, `this is not valid lua %%%`) + _, err := load(path, st) + assert.Error(t, err) +} + +func TestLoad_NonExistentFile_ReturnsError(t *testing.T) { + st := newTestStore(t) + _, err := load("/does/not/exist.lua", st) + assert.Error(t, err) +} + +func TestLoad_CallsOnLoad(t *testing.T) { + st := newTestStore(t) + // on_load sets a global that we can check afterwards. + p := loadPlugin(t, ` + loaded = false + function on_load() + loaded = true + end + `, st) + + p.mu.Lock() + v := p.L.GetGlobal("loaded") + p.mu.Unlock() + + assert.Equal(t, lua.LTrue, v) +} + +func TestLoad_OnLoadError_PluginStillLoaded(t *testing.T) { + st := newTestStore(t) + // on_load errors are logged but must not prevent the plugin from loading. + p, err := func() (*Plugin, error) { + path := writeLua(t, ` + function on_load() + error("intentional on_load failure") + end + `) + return load(path, st) + }() + // load itself should succeed despite on_load failing. + require.NoError(t, err) + assert.NotNil(t, p) + p.close() +} + +func TestLoad_SetsName(t *testing.T) { + st := newTestStore(t) + path := writeLua(t, `-- empty`) + p, err := load(path, st) + require.NoError(t, err) + defer p.close() + assert.Equal(t, filepath.Base(path), p.name) +} + +// ── has ─────────────────────────────────────────────────────────────────────── + +func TestHas_DefinedFunction_ReturnsTrue(t *testing.T) { + st := newTestStore(t) + p := loadPlugin(t, `function on_request(r) return r end`, st) + assert.True(t, p.has("on_request")) +} + +func TestHas_UndefinedFunction_ReturnsFalse(t *testing.T) { + st := newTestStore(t) + p := loadPlugin(t, `-- empty`, st) + assert.False(t, p.has("on_request")) +} + +func TestHas_NilGlobal_ReturnsFalse(t *testing.T) { + st := newTestStore(t) + p := loadPlugin(t, `on_request = nil`, st) + assert.False(t, p.has("on_request")) +} + +// ── callNoReturn ────────────────────────────────────────────────────────────── + +func TestCallNoReturn_UndefinedFunction_ReturnsNil(t *testing.T) { + st := newTestStore(t) + p := loadPlugin(t, `-- empty`, st) + err := p.callNoReturn("no_such_fn", lua.LNil) + assert.NoError(t, err) +} + +func TestCallNoReturn_FunctionExecutes(t *testing.T) { + st := newTestStore(t) + p := loadPlugin(t, ` + called = false + function my_fn() + called = true + end + `, st) + + err := p.callNoReturn("my_fn", lua.LNil) + require.NoError(t, err) + + p.mu.Lock() + v := p.L.GetGlobal("called") + p.mu.Unlock() + assert.Equal(t, lua.LTrue, v) +} + +func TestCallNoReturn_FunctionReceivesArg(t *testing.T) { + st := newTestStore(t) + p := loadPlugin(t, ` + received = nil + function my_fn(arg) + received = arg + end + `, st) + + p.mu.Lock() + arg := p.L.NewTable() + p.L.SetField(arg, "key", lua.LString("value")) + p.mu.Unlock() + + err := p.callNoReturn("my_fn", arg) + require.NoError(t, err) + + p.mu.Lock() + tbl, ok := p.L.GetGlobal("received").(*lua.LTable) + p.mu.Unlock() + require.True(t, ok) + + p.mu.Lock() + v := tbl.RawGetString("key") + p.mu.Unlock() + assert.Equal(t, lua.LString("value"), v) +} + +func TestCallNoReturn_LuaError_ReturnsError(t *testing.T) { + st := newTestStore(t) + p := loadPlugin(t, ` + function bad_fn() + error("intentional error") + end + `, st) + + err := p.callNoReturn("bad_fn", lua.LNil) + assert.Error(t, err) +} + +func TestCallNoReturn_Timeout_ReturnsError(t *testing.T) { + st := newTestStore(t) + // Infinite loop — must be killed by the context timeout. + p := loadPlugin(t, ` + function infinite() + while true do end + end + `, st) + + start := time.Now() + err := p.callNoReturn("infinite", lua.LNil) + elapsed := time.Since(start) + + assert.Error(t, err) + // Should terminate within a reasonable margin of callTimeout (500ms). + assert.Less(t, elapsed, callTimeout+300*time.Millisecond) +} + +// ── callWithBuilder ─────────────────────────────────────────────────────────── + +func TestCallWithBuilder_UndefinedFunction_ReturnsNilNoError(t *testing.T) { + st := newTestStore(t) + p := loadPlugin(t, `-- empty`, st) + + ret, err := p.callWithBuilder("no_such_fn", func(L *lua.LState) *lua.LTable { + return L.NewTable() + }) + + assert.NoError(t, err) + assert.Nil(t, ret) +} + +func TestCallWithBuilder_BuilderRunsUnderLock(t *testing.T) { + // Verify the builder is called (indirectly — if it panics the test fails). + st := newTestStore(t) + p := loadPlugin(t, `function fn(t) return t end`, st) + + builderCalled := false + ret, err := p.callWithBuilder("fn", func(L *lua.LState) *lua.LTable { + builderCalled = true + tbl := L.NewTable() + L.SetField(tbl, "x", lua.LString("hello")) + return tbl + }) + + require.NoError(t, err) + assert.True(t, builderCalled) + assert.NotNil(t, ret) +} + +func TestCallWithBuilder_FunctionCanModifyTable(t *testing.T) { + st := newTestStore(t) + p := loadPlugin(t, ` + function transform(t) + t.result = "modified" + return t + end + `, st) + + ret, err := p.callWithBuilder("transform", func(L *lua.LState) *lua.LTable { + tbl := L.NewTable() + L.SetField(tbl, "result", lua.LString("original")) + return tbl + }) + + require.NoError(t, err) + require.NotNil(t, ret) + + p.mu.Lock() + v := ret.RawGetString("result") + p.mu.Unlock() + assert.Equal(t, lua.LString("modified"), v) +} + +func TestCallWithBuilder_FunctionReturnsNil_ReturnsNilTable(t *testing.T) { + st := newTestStore(t) + p := loadPlugin(t, `function fn(t) return nil end`, st) + + ret, err := p.callWithBuilder("fn", func(L *lua.LState) *lua.LTable { + return L.NewTable() + }) + + assert.NoError(t, err) + assert.Nil(t, ret) +} + +func TestCallWithBuilder_LuaError_ReturnsError(t *testing.T) { + st := newTestStore(t) + p := loadPlugin(t, ` + function bad(t) + error("builder error") + end + `, st) + + _, err := p.callWithBuilder("bad", func(L *lua.LState) *lua.LTable { + return L.NewTable() + }) + + assert.Error(t, err) +} + +func TestCallWithBuilder_Timeout_ReturnsError(t *testing.T) { + st := newTestStore(t) + p := loadPlugin(t, ` + function slow(t) + while true do end + end + `, st) + + start := time.Now() + _, err := p.callWithBuilder("slow", func(L *lua.LState) *lua.LTable { + return L.NewTable() + }) + elapsed := time.Since(start) + + assert.Error(t, err) + assert.Less(t, elapsed, callTimeout+300*time.Millisecond) +} + +// ── loadDir ─────────────────────────────────────────────────────────────────── + +func TestLoadDir_NonExistentDir_ReturnsNilSlice(t *testing.T) { + st := newTestStore(t) + plugins, err := loadDir("/does/not/exist", st) + assert.NoError(t, err) + assert.Nil(t, plugins) +} + +func TestLoadDir_EmptyDir_ReturnsEmpty(t *testing.T) { + st := newTestStore(t) + dir := t.TempDir() + plugins, err := loadDir(dir, st) + assert.NoError(t, err) + assert.Empty(t, plugins) +} + +func TestLoadDir_LoadsLuaFiles(t *testing.T) { + st := newTestStore(t) + dir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.lua"), []byte(`x = 1`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.lua"), []byte(`y = 2`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "readme.txt"), []byte(`not lua`), 0644)) + + plugins, err := loadDir(dir, st) + require.NoError(t, err) + assert.Len(t, plugins, 2) + for _, p := range plugins { + p.close() + } +} + +func TestLoadDir_SkipsDirectories(t *testing.T) { + st := newTestStore(t) + dir := t.TempDir() + + require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir.lua"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "real.lua"), []byte(`z = 3`), 0644)) + + plugins, err := loadDir(dir, st) + require.NoError(t, err) + assert.Len(t, plugins, 1) + for _, p := range plugins { + p.close() + } +} + +func TestLoadDir_InvalidLuaSkipped_OthersLoaded(t *testing.T) { + st := newTestStore(t) + dir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(dir, "good.lua"), []byte(`g = 1`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.lua"), []byte(`%%% invalid`), 0644)) + + plugins, err := loadDir(dir, st) + require.NoError(t, err) + // bad.lua skipped, good.lua loaded + assert.Len(t, plugins, 1) + assert.Equal(t, "good.lua", plugins[0].name) + for _, p := range plugins { + p.close() + } +} |
