summaryrefslogtreecommitdiff
path: root/internal/plugin
diff options
context:
space:
mode:
Diffstat (limited to 'internal/plugin')
-rw-r--r--internal/plugin/api.go313
-rw-r--r--internal/plugin/engine.go347
-rw-r--r--internal/plugin/engine_test.go1003
-rw-r--r--internal/plugin/plugin.go197
-rw-r--r--internal/plugin/plugin_test.go391
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()
+ }
+}