security: стабилизационный аудит Lua plugin system

Исправления:
- Install: идемпотентность (no duplicates in InstalledPlugins)
- ReloadPlugins: StopSchedulers + CallShutdownHooks перед CloseRuntimes
- StopSchedulers: обнуление scheduler=nil после остановки
- Scheduler.Stop: обнуление tasks после wg.Wait
- Lua sandbox: блокировка package.loadlib/seeall/preload/loaders/loaded/path/cpath/config/searchpath
- Lua sandbox: блокировка load (глобальная функция)
- CallPluginFunction: валидация funcName (regex [a-zA-Z_][a-zA-Z0-9_]*, max 3 segments)
- CallPluginFunction: убрана строковая сборка Lua-кодa, вызов через PCall напрямую
- PluginPage.svelte: проверка e.source === iframeEl.contentWindow
- PluginPage.svelte: type checking для msg.source, msg.action

Тесты:
- security_test.go: 18 новых тестов (sandbox, lifecycle, validation)
- Все существующие тесты проходят

Документация:
- docs/plugins-security.md: модель безопасности, sandbox, протокол, lifecycle
This commit is contained in:
mirivlad 2026-06-07 19:19:44 +08:00
parent c443ca23c5
commit 4df83cd361
9 changed files with 1305 additions and 54 deletions

View File

@ -6,9 +6,11 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"verstak/internal/core/config" "verstak/internal/core/config"
"verstak/internal/core/plugins"
lua "github.com/yuin/gopher-lua" lua "github.com/yuin/gopher-lua"
) )
@ -204,13 +206,32 @@ func (a *App) ListSystemViewsWithPlugins() []SystemViewDTO {
return base return base
} }
// validLuaIdent matches a safe Lua identifier segment: [a-zA-Z_][a-zA-Z0-9_]*
var validLuaIdent = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
// CallPluginFunction calls a global Lua function on an active plugin. // CallPluginFunction calls a global Lua function on an active plugin.
// The funcName can use dots: "calendar.create_event" → _G.calendar.create_event // The funcName can use dots: "calendar.create_event" → _G.calendar.create_event
// Only alphanumeric identifiers with underscores are allowed (no Lua injection).
// Returns JSON string or error. // Returns JSON string or error.
func (a *App) CallPluginFunction(pluginName, funcName string, paramsJSON string) (string, error) { func (a *App) CallPluginFunction(pluginName, funcName string, paramsJSON string) (string, error) {
if a.plugins == nil { if a.plugins == nil {
return "", fmt.Errorf("plugin manager not ready") return "", fmt.Errorf("plugin manager not ready")
} }
// Validate funcName: only [a-zA-Z0-9_.]+ allowed, each segment must be valid ident
if funcName == "" {
return "", fmt.Errorf("funcName is empty")
}
segments := strings.Split(funcName, ".")
if len(segments) > 3 {
return "", fmt.Errorf("funcName %q too deep (max 2 dots)", funcName)
}
for _, seg := range segments {
if !validLuaIdent.MatchString(seg) {
return "", fmt.Errorf("funcName %q contains invalid segment %q", funcName, seg)
}
}
for _, p := range a.plugins.Plugins() { for _, p := range a.plugins.Plugins() {
if p.Meta.Name != pluginName || !p.Active { if p.Meta.Name != pluginName || !p.Active {
continue continue
@ -219,73 +240,107 @@ func (a *App) CallPluginFunction(pluginName, funcName string, paramsJSON string)
if vm == nil { if vm == nil {
continue continue
} }
// Resolve dotted path: "calendar.create_event" → _G.calendar.create_event
parts := strings.SplitN(funcName, ".", 2) // Resolve the function via _G to avoid string-based code generation
var luaFn string var fn lua.LValue
if len(parts) == 2 { if len(segments) == 1 {
luaFn = fmt.Sprintf("_G.%s['%s']", parts[0], parts[1]) fn = vm.LState().GetGlobal(segments[0])
} else { } else {
luaFn = fmt.Sprintf("_G['%s']", funcName) // Walk the dotted path: _G[seg1][seg2]...
} tbl := vm.LState().GetGlobal(segments[0])
for i := 1; i < len(segments); i++ {
// Parse params if t, ok := tbl.(*lua.LTable); ok {
var params interface{} tbl = t.RawGetString(segments[i])
if paramsJSON != "" && paramsJSON != "{}" { } else {
if err := json.Unmarshal([]byte(paramsJSON), &params); err != nil { tbl = lua.LNil
params = paramsJSON break
}
}
// Convert params to Lua value
var luaArg lua.LValue
switch v := params.(type) {
case nil:
luaArg = lua.LNil
case string:
luaArg = lua.LString(v)
case float64:
luaArg = lua.LNumber(v)
case bool:
luaArg = lua.LBool(v)
case map[string]interface{}:
tbl := vm.LState().NewTable()
for key, val := range v {
switch sv := val.(type) {
case string:
tbl.RawSetString(key, lua.LString(sv))
case float64:
tbl.RawSetString(key, lua.LNumber(sv))
case bool:
tbl.RawSetString(key, lua.LBool(sv))
} }
} }
luaArg = tbl fn = tbl
default:
luaArg = lua.LString(paramsJSON)
} }
var script string if fn == lua.LNil {
if luaArg == lua.LNil { return "", fmt.Errorf("function %q not found in plugin %q", funcName, pluginName)
script = fmt.Sprintf("return %s()", luaFn) }
} else { if _, ok := fn.(*lua.LFunction); !ok {
script = fmt.Sprintf("return %s(%s)", luaFn, luaArg.String()) return "", fmt.Errorf("%q is not a function in plugin %q", funcName, pluginName)
} }
result, err := vm.DoString(script) // Parse params into Lua value
luaArg, err := parseParamsToLua(vm, paramsJSON)
if err != nil { if err != nil {
return "", fmt.Errorf("parse params: %w", err)
}
// Call the function directly via PCall (no string-based code generation)
vm.LState().Push(fn)
if luaArg != nil {
vm.LState().Push(luaArg)
}
nargs := 0
if luaArg != nil {
nargs = 1
}
if err := vm.LState().PCall(nargs, 1, nil); err != nil {
return "", fmt.Errorf("call %s: %w", funcName, err) return "", fmt.Errorf("call %s: %w", funcName, err)
} }
return result, nil ret := vm.LState().Get(-1)
vm.LState().Pop(1)
return ret.String(), nil
} }
return "", fmt.Errorf("plugin %q not active or not found", pluginName) return "", fmt.Errorf("plugin %q not active or not found", pluginName)
} }
// parseParamsToLua converts a JSON params string to a lua.LValue.
// Empty or "{}" → nil (no argument).
func parseParamsToLua(vm *plugins.LuaVM, paramsJSON string) (lua.LValue, error) {
if paramsJSON == "" || paramsJSON == "{}" {
return nil, nil
}
var params interface{}
if err := json.Unmarshal([]byte(paramsJSON), &params); err != nil {
return nil, fmt.Errorf("invalid JSON params: %w", err)
}
return goToLua(vm.LState(), params), nil
}
// goToLua converts a Go interface{} to a lua.LValue.
func goToLua(L *lua.LState, v interface{}) lua.LValue {
switch val := v.(type) {
case nil:
return lua.LNil
case string:
return lua.LString(val)
case float64:
return lua.LNumber(val)
case bool:
return lua.LBool(val)
case map[string]interface{}:
tbl := L.NewTable()
for k, v := range val {
tbl.RawSetString(k, goToLua(L, v))
}
return tbl
case []interface{}:
tbl := L.NewTable()
for i, v := range val {
tbl.RawSetInt(i+1, goToLua(L, v))
}
return tbl
default:
return lua.LString(fmt.Sprintf("%v", v))
}
}
// ReloadPlugins re-scans the plugins directory and re-initializes runtimes. // ReloadPlugins re-scans the plugins directory and re-initializes runtimes.
func (a *App) ReloadPlugins() error { func (a *App) ReloadPlugins() error {
if a.plugins == nil { if a.plugins == nil {
return fmt.Errorf("plugin manager not ready") return fmt.Errorf("plugin manager not ready")
} }
log.Print("[plugins] reload requested") log.Print("[plugins] reload requested")
// Fully stop runtimes: schedulers first (they depend on VMs), then VMs
a.plugins.StopSchedulers()
a.plugins.CallShutdownHooks()
a.plugins.CloseRuntimes() a.plugins.CloseRuntimes()
a.plugins.Discover() a.plugins.Discover()

View File

@ -19,7 +19,7 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-BOc2ygMG.js"></script> <script type="module" crossorigin src="/assets/main-9sLWbwBV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Cjkp2F09.css"> <link rel="stylesheet" crossorigin href="/assets/main-Cjkp2F09.css">
</head> </head>
<body> <body>

203
docs/plugins-security.md Normal file
View File

@ -0,0 +1,203 @@
# Plugin Security Model
## Overview
Verstak's Lua plugin system is designed with a **sandbox-first** approach. Plugins run in isolated Lua 5.1 states with restricted access to the host system. This document defines the security boundaries.
## Lua Sandbox
### Blocked Global Functions
The following Lua globals are set to `nil` in every plugin VM:
| Function | Reason |
|----------|--------|
| `dofile` | Arbitrary file read/execute |
| `loadfile` | Arbitrary file read |
| `require` | Module loading (no filesystem access) |
| `module` | Module system (deprecated, unsafe) |
| `load` | Arbitrary code execution from string |
| `rawequal` | Bypass metatable protections |
| `rawget` | Bypass metatable protections |
| `rawset` | Bypass metatable protections |
| `rawlen` | Bypass metatable protections |
| `setfenv` | Environment manipulation |
| `getfenv` | Environment manipulation |
### Blocked Libraries
| Library | Status | Notes |
|---------|--------|-------|
| `io` | **Not opened** | No file I/O |
| `debug` | **Not opened** | No debug introspection |
| `coroutine` | **Not opened** | No coroutine creation |
| `os` | **Restricted** | Only `clock`, `date`, `difftime`, `time`, `tmpname` |
| `package` | **Restricted** | Only `seeall` is available; `loadlib`, `preload`, `loaders`, `loaded`, `path`, `cpath`, `config`, `searchpath` are blocked |
### Restricted `os.*` Functions
| Function | Status | Reason |
|----------|--------|--------|
| `os.clock` | ✅ Allowed | Safe timing |
| `os.date` | ✅ Allowed | Safe date formatting |
| `os.difftime` | ✅ Allowed | Safe time diff |
| `os.time` | ✅ Allowed | Safe timestamp |
| `os.tmpname` | ✅ Allowed | Safe temp name |
| `os.execute` | ❌ Blocked | Shell command execution |
| `os.exit` | ❌ Blocked | Process termination |
| `os.remove` | ❌ Blocked | File deletion |
| `os.rename` | ❌ Blocked | File renaming |
| `os.setlocale` | ❌ Blocked | Locale manipulation |
| `os.getenv` | ❌ Blocked | Environment variable access |
### Safe Libraries (Available)
- `string` — string manipulation
- `table` — table operations
- `math` — mathematical functions
- `type`, `tostring`, `tonumber` — type operations
- `pcall`, `xpcall` — protected calls
- `error`, `assert` — error handling
- `select`, `unpack` — argument manipulation
- `pairs`, `ipairs`, `next` — iteration
- `collectgarbage` — GC control
### Print Redirection
`print()` is redirected to Go's `log.Printf` with the `[lua]` prefix. Plugins cannot write to stdout directly.
## Plugin Function Call Security
### `CallPluginFunction` Validation
When the frontend calls a plugin function via `CallPluginFunction(pluginName, funcName, paramsJSON)`:
1. **funcName validation**: Only `[a-zA-Z_][a-zA-Z0-9_]*` segments separated by dots (max 2 dots, 3 segments)
2. **No string-based code generation**: Functions are resolved via `_G` table walk, not `DoString`
3. **Direct PCall**: Arguments are pushed as `lua.LValue` directly, not serialized to Lua code
4. **Plugin must be active**: Only enabled plugins can receive calls
### Blocked funcName Patterns
```
❌ "func;os.execute('id')" — semicolon injection
❌ "func..name" — empty segment
❌ "func[name]" — bracket injection
❌ "func()" — parentheses
❌ "func name" — space injection
❌ "func-name" — special chars
❌ "a.b.c.d" — too deep (>3 segments)
❌ "123func" — starts with number
```
## iframe Bridge Security
### PluginPage.svelte ↔ iframe Communication
1. **Source verification**: Messages are only accepted from the specific `iframeEl.contentWindow` (not just any `source: 'calendar-plugin'`)
2. **Type checking**: `msg.source`, `msg.action` must be strings
3. **sandbox attribute**: iframe uses `sandbox="allow-scripts allow-same-origin"` (no forms, no popups)
4. **srcdoc**: iframe content is injected as `srcdoc` (same origin, no external network)
### Message Protocol
All messages must have:
```js
{ source: 'calendar-plugin', action: 'ready' | 'get-events' | 'create-event' | ... }
```
Messages from iframe to Svelte are validated:
- `e.source === iframeEl.contentWindow` (origin check)
- `msg.source === 'calendar-plugin'` (source check)
- `typeof msg.action === 'string'` (type check)
## Plugin Lifecycle Security
### State Separation
| State | Description | Data Persistence |
|-------|-------------|-----------------|
| **Discovered** | Plugin found on disk | None |
| **Installed** | `on_install` hook ran, tables created | DB tables exist |
| **Enabled** | Plugin is active, Lua VM running | Full access |
| **Active** | Runtime is initialized | Full access |
| **Disabled** | Plugin deactivated, data preserved | DB tables exist |
| **Uninstalled** | `on_uninstall` hook ran, tables dropped | Data cleaned |
### Idempotency
- `Install` on already-installed plugin → error (no duplicate config entries)
- `Uninstall` on non-installed plugin → error
- `Enable` on non-installed plugin → error
- `Disable` on already-disabled plugin → no-op (error, but harmless)
- `ReloadPlugins` → stops schedulers, calls shutdown hooks, closes VMs, re-discovers
### ReloadPlugins Cleanup Order
1. `StopSchedulers()` — stops all background tasks, sets `scheduler = nil`
2. `CallShutdownHooks()` — calls `on_shutdown` for all active plugins
3. `CloseRuntimes()` — closes all Lua VMs, sets `vm = nil`
4. `Discover()` — re-scans plugins directory
5. `SyncConfig()` — applies installed/enabled state from config
6. `InitRuntimes()` — creates new VMs and schedulers
7. `CallInitHooks()` — calls `on_init` for all active plugins
8. `StartSchedulers()` — starts background tasks
## Network Access
Plugins have **no direct network access**. All HTTP/network operations must go through the `verstak.http.*` API (registered by the core), which:
- Uses Go's `net/http` (not Lua socket)
- Can be rate-limited by the core
- Goes through the proxy if configured
## Filesystem Access
Plugins have **no direct filesystem access**. All file operations must go through:
- `verstak.db.*` — SQLite queries (plugin's own DB only)
- `verstak.node.*` — Node CRUD (via core services)
- `verstak.files.*` — File operations (via core services)
The `io` library is not opened. `os.remove`, `os.rename` are blocked.
## Database Isolation
Each plugin gets its own SQLite database at `.verstak/plugins/<name>/data/`. Plugins cannot access:
- The main Verstak database
- Other plugins' databases
- System databases
## Calendar Plugin (Reference)
The Calendar plugin in `contrib/plugins/calendar/` is a **reference implementation**, not a core feature:
- It is **not** loaded by default
- It requires explicit `Install` + `Enable`
- Its tables are created only during `on_install`
- It can be fully `Uninstall`ed without affecting core functionality
- It does not add calendar UI to any core views
- It demonstrates the full plugin API: `verstak.db.*`, `verstak.config.*`, `verstak.state.*`, `verstak.node.*`, `verstak.activity.*`, `verstak.schedule.*`, `verstak.http.*`
## Security Tests
All security properties are verified by tests in `internal/core/plugins/security_test.go`:
- `TestLuaVM_Sandbox_Comprehensive` — 12 dangerous APIs blocked
- `TestLuaVM_Sandbox_SafeLibs` — 9 safe libraries available
- `TestLuaVM_Sandbox_GlobalsRemoved` — 11 dangerous globals nil
- `TestLuaVM_Sandbox_OsRestricted` — 6 os.* functions blocked, 5 safe ones available
- `TestLuaVM_Sandbox_IoBlocked` — io library not opened
- `TestLuaVM_Sandbox_DebugBlocked` — debug library not opened
- `TestLuaVM_Sandbox_CoroutineBlocked` — coroutine library not opened
- `TestLuaVM_Sandbox_Package*Blocked` — 9 package.* fields blocked
- `TestLuaVM_Sandbox_PrintIsRedirected` — print goes to Go log
- `TestLuaVM_DoStringTimeout` — infinite loops killed by timeout
- `TestLuaVM_Isolation` — plugins cannot see each other's globals
- `TestLuaVM_ClosedVM` — operations on closed VM return errors
- `TestLuaVM_DoString_InjectionAttempt` — dangerous APIs return nil
- `TestPluginManager_InstallIdempotent` — no duplicate config entries
- `TestPluginManager_DisablePreservesData` — Disable keeps data
- `TestPluginManager_UninstallRemovesFromConfig` — Uninstall cleans config
- `TestPluginManager_ReloadPlugins_Cleanup` — full cleanup on reload
- `TestCallPluginFunction_Validation` — funcName validation (5 valid, 10 invalid cases)

View File

@ -35,10 +35,15 @@
} }
} }
// Handle messages from iframe // Handle messages from iframe — only accept from our own iframeEl
function handleIframeMessage(e) { function handleIframeMessage(e) {
// Verify the message comes from our iframe (srcdoc = same origin)
if (!iframeEl || !iframeEl.contentWindow || e.source !== iframeEl.contentWindow) return
const msg = e.data const msg = e.data
if (!msg || !msg.source || msg.source !== 'calendar-plugin') return if (!msg || typeof msg !== 'object') return
if (!msg.source || msg.source !== 'calendar-plugin') return
if (!msg.action || typeof msg.action !== 'string') return
switch (msg.action) { switch (msg.action) {
case 'ready': case 'ready':

View File

@ -255,6 +255,7 @@ func (m *Manager) StopSchedulers() {
p := &m.plugins[i] p := &m.plugins[i]
if p.scheduler != nil { if p.scheduler != nil {
p.scheduler.Stop() p.scheduler.Stop()
p.scheduler = nil
} }
} }
} }
@ -426,13 +427,22 @@ func (m *Manager) Install(name string) error {
return fmt.Errorf("on_install: %w", err) return fmt.Errorf("on_install: %w", err)
} }
// Mark installed in config // Mark installed in config (idempotent — avoid duplicates)
p.Installed = true p.Installed = true
appCfg, _ := config.LoadAppConfig() appCfg, _ := config.LoadAppConfig()
if appCfg == nil { if appCfg == nil {
appCfg = config.DefaultAppConfig() appCfg = config.DefaultAppConfig()
} }
appCfg.InstalledPlugins = append(appCfg.InstalledPlugins, name) already := false
for _, n := range appCfg.InstalledPlugins {
if n == name {
already = true
break
}
}
if !already {
appCfg.InstalledPlugins = append(appCfg.InstalledPlugins, name)
}
if err := config.SaveAppConfig(appCfg); err != nil { if err := config.SaveAppConfig(appCfg); err != nil {
return fmt.Errorf("save config: %w", err) return fmt.Errorf("save config: %w", err)
} }

View File

@ -72,10 +72,19 @@ func NewLuaVM(p *Plugin) (*LuaVM, error) {
} }
// Disable dangerous functions // Disable dangerous functions
for _, name := range []string{"dofile", "loadfile", "require", "module", "rawequal", "rawget", "rawset", "rawlen", "setfenv", "getfenv"} { for _, name := range []string{"dofile", "loadfile", "require", "module", "rawequal", "rawget", "rawset", "rawlen", "setfenv", "getfenv", "load"} {
L.SetGlobal(name, lua.LNil) L.SetGlobal(name, lua.LNil)
} }
// Restrict package table: remove dangerous fields
if pkgTbl := L.GetGlobal("package"); pkgTbl != lua.LNil {
if tbl, ok := pkgTbl.(*lua.LTable); ok {
for _, name := range []string{"loadlib", "seeall", "preload", "loaders", "loaded", "path", "cpath", "config", "searchpath"} {
tbl.RawSetString(name, lua.LNil)
}
}
}
// Restrict os.* to safe subset // Restrict os.* to safe subset
osSafe := map[string]bool{ osSafe := map[string]bool{
"clock": true, "date": true, "difftime": true, "time": true, "clock": true, "date": true, "difftime": true, "time": true,

View File

@ -77,8 +77,11 @@ func (s *Scheduler) Stop() {
t.stopped = true t.stopped = true
} }
} }
tasks := s.tasks
s.tasks = nil
s.mu.Unlock() s.mu.Unlock()
s.wg.Wait() s.wg.Wait()
_ = tasks // keep reference until wg done
} }
func (s *Scheduler) runTask(t *Task) { func (s *Scheduler) runTask(t *Task) {

View File

@ -0,0 +1,966 @@
package plugins
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"verstak/internal/core/config"
lua "github.com/yuin/gopher-lua"
)
// validLuaIdent matches a safe Lua identifier segment: [a-zA-Z_][a-zA-Z0-9_]*
var validLuaIdent = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
// TestLuaVM_Sandbox_Comprehensive verifies that dangerous Lua APIs are blocked.
func TestLuaVM_Sandbox_Comprehensive(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "sandbox2")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
mainLua := `
function test_dofile()
if dofile == nil then return false end
local ok = pcall(function() dofile("/etc/passwd") end)
return ok
end
function test_loadfile()
if loadfile == nil then return false end
local ok = pcall(function() loadfile("/etc/passwd") end)
return ok
end
function test_require()
if require == nil then return false end
local ok = pcall(function() require("os") end)
return ok
end
function test_os_execute()
if os == nil or os.execute == nil then return false end
local ok = pcall(function() os.execute("id") end)
return ok
end
function test_os_remove()
if os == nil or os.remove == nil then return false end
local ok = pcall(function() os.remove("/tmp/test") end)
return ok
end
function test_os_rename()
if os == nil or os.rename == nil then return false end
local ok = pcall(function() os.rename("/tmp/a", "/tmp/b") end)
return ok
end
function test_os_getenv()
if os == nil or os.getenv == nil then return false end
local ok = pcall(function() return os.getenv("PATH") end)
return ok
end
function test_io_open()
if io == nil or io.open == nil then return false end
local ok = pcall(function() io.open("/etc/passwd", "r") end)
return ok
end
function test_load()
if load == nil then return false end
local ok = pcall(function() load("return 1") end)
return ok
end
function test_package_loadlib()
if package == nil or package.loadlib == nil then return false end
local ok = pcall(function() package.loadlib("libc.so.6", "malloc") end)
return ok
end
function test_rawget()
if rawget == nil then return false end
return true
end
function test_setfenv()
if setfenv == nil then return false end
return true
end
`
if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil {
t.Fatal(err)
}
p := &Plugin{
Meta: Meta{Name: "sandbox2"},
Dir: pluginDir,
Active: true,
}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
if err := vm.LoadScript("main.lua"); err != nil {
t.Fatalf("LoadScript: %v", err)
}
dangerousTests := []struct {
name string
fnName string
wantSafe bool
}{
{"dofile", "test_dofile", true},
{"loadfile", "test_loadfile", true},
{"require", "test_require", true},
{"os.execute", "test_os_execute", true},
{"os.remove", "test_os_remove", true},
{"os.rename", "test_os_rename", true},
{"os.getenv", "test_os_getenv", true},
{"io.open", "test_io_open", true},
{"load", "test_load", true},
{"package.loadlib", "test_package_loadlib", true},
{"rawget", "test_rawget", true},
{"setfenv", "test_setfenv", true},
}
for _, tt := range dangerousTests {
t.Run(tt.name, func(t *testing.T) {
result, err := vm.CallHookWithResult(tt.fnName)
if err != nil {
t.Fatalf("CallHook(%s): %v", tt.fnName, err)
}
succeeded := result == lua.LTrue
if tt.wantSafe && succeeded {
t.Errorf("%s: dangerous API was NOT blocked (returned true)", tt.name)
}
})
}
}
// TestLuaVM_Sandbox_SafeLibs verifies that safe Lua libraries still work.
func TestLuaVM_Sandbox_SafeLibs(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "safelibs")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
mainLua := `
function test_string()
local s = string.format("hello %s", "world")
return s == "hello world"
end
function test_table()
local t = {1, 2, 3}
return #t == 3
end
function test_math()
return math.max(1, 2, 3) == 3
end
function test_type()
return type(42) == "number" and type("hi") == "string"
end
function test_pcall()
local ok, err = pcall(function() error("test") end)
return not ok and err ~= nil
end
function test_pairs()
local t = {a=1, b=2}
local count = 0
for k, v in pairs(t) do count = count + 1 end
return count == 2
end
function test_os_clock()
if os == nil or os.clock == nil then return false end
local c = os.clock()
return c > 0
end
function test_os_date()
if os == nil or os.date == nil then return false end
local d = os.date("%Y")
return #d == 4
end
function test_os_time()
if os == nil or os.time == nil then return false end
local t = os.time()
return t > 0
end
`
if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil {
t.Fatal(err)
}
p := &Plugin{
Meta: Meta{Name: "safelibs"},
Dir: pluginDir,
Active: true,
}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
if err := vm.LoadScript("main.lua"); err != nil {
t.Fatalf("LoadScript: %v", err)
}
safeTests := []struct {
name string
fnName string
}{
{"string", "test_string"},
{"table", "test_table"},
{"math", "test_math"},
{"type", "test_type"},
{"pcall", "test_pcall"},
{"pairs", "test_pairs"},
{"os.clock", "test_os_clock"},
{"os.date", "test_os_date"},
{"os.time", "test_os_time"},
}
for _, tt := range safeTests {
t.Run(tt.name, func(t *testing.T) {
result, err := vm.CallHookWithResult(tt.fnName)
if err != nil {
t.Fatalf("CallHook(%s): %v", tt.fnName, err)
}
if result != lua.LTrue {
t.Errorf("%s: expected true, got %v", tt.name, result)
}
})
}
}
// TestLuaVM_Sandbox_GlobalsRemoved verifies that dangerous globals are nil.
func TestLuaVM_Sandbox_GlobalsRemoved(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "globals")
os.MkdirAll(pluginDir, 0755)
p := &Plugin{Meta: Meta{Name: "globals"}, Dir: pluginDir, Active: true}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
dangerous := []string{"dofile", "loadfile", "require", "module", "rawequal", "rawget", "rawset", "rawlen", "setfenv", "getfenv", "load"}
for _, name := range dangerous {
val := vm.L.GetGlobal(name)
if val != lua.LNil {
t.Errorf("global %q should be nil, got %v", name, val)
}
}
}
// TestLuaVM_Sandbox_OsRestricted verifies that os.* is restricted to safe subset.
func TestLuaVM_Sandbox_OsRestricted(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "osrestrict")
os.MkdirAll(pluginDir, 0755)
p := &Plugin{Meta: Meta{Name: "osrestrict"}, Dir: pluginDir, Active: true}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
blocked := []string{"execute", "exit", "remove", "rename", "setlocale", "getenv"}
for _, name := range blocked {
osTbl := vm.L.GetGlobal("os")
if osTbl == lua.LNil {
t.Fatal("os table is nil")
}
tbl := osTbl.(*lua.LTable)
val := tbl.RawGetString(name)
if val != lua.LNil {
t.Errorf("os.%s should be nil, got %v", name, val)
}
}
safe := []string{"clock", "date", "difftime", "time", "tmpname"}
for _, name := range safe {
osTbl := vm.L.GetGlobal("os")
tbl := osTbl.(*lua.LTable)
val := tbl.RawGetString(name)
if val == lua.LNil {
t.Errorf("os.%s should be available, got nil", name)
}
}
}
// TestLuaVM_Sandbox_IoBlocked verifies that io.* is completely blocked.
func TestLuaVM_Sandbox_IoBlocked(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "ioblock")
os.MkdirAll(pluginDir, 0755)
p := &Plugin{Meta: Meta{Name: "ioblock"}, Dir: pluginDir, Active: true}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
ioTbl := vm.L.GetGlobal("io")
if ioTbl != lua.LNil {
t.Errorf("io should be nil (not opened), got %v", ioTbl)
}
}
// TestLuaVM_Sandbox_DebugBlocked verifies that debug library is not available.
func TestLuaVM_Sandbox_DebugBlocked(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "debugblock")
os.MkdirAll(pluginDir, 0755)
p := &Plugin{Meta: Meta{Name: "debugblock"}, Dir: pluginDir, Active: true}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
debugVal := vm.L.GetGlobal("debug")
if debugVal != lua.LNil {
t.Errorf("debug should be nil (not opened), got %v", debugVal)
}
}
// TestLuaVM_Sandbox_CoroutineBlocked verifies coroutine library is not available.
func TestLuaVM_Sandbox_CoroutineBlocked(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "coroblock")
os.MkdirAll(pluginDir, 0755)
p := &Plugin{Meta: Meta{Name: "coroblock"}, Dir: pluginDir, Active: true}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
coroVal := vm.L.GetGlobal("coroutine")
if coroVal != lua.LNil {
t.Errorf("coroutine should be nil (not opened), got %v", coroVal)
}
}
// TestLuaVM_Sandbox_PackageLoadlibBlocked verifies package.loadlib is blocked.
func TestLuaVM_Sandbox_PackageLoadlibBlocked(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "loadlib")
os.MkdirAll(pluginDir, 0755)
p := &Plugin{Meta: Meta{Name: "loadlib"}, Dir: pluginDir, Active: true}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
pkgTbl := vm.L.GetGlobal("package")
if pkgTbl == lua.LNil {
t.Fatal("package table is nil")
}
tbl := pkgTbl.(*lua.LTable)
loadlib := tbl.RawGetString("loadlib")
if loadlib != lua.LNil {
t.Errorf("package.loadlib should be nil, got %v", loadlib)
}
}
// TestLuaVM_Sandbox_PackageSeeallBlocked verifies package.seeall is blocked.
func TestLuaVM_Sandbox_PackageSeeallBlocked(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "seeall")
os.MkdirAll(pluginDir, 0755)
p := &Plugin{Meta: Meta{Name: "seeall"}, Dir: pluginDir, Active: true}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
pkgTbl := vm.L.GetGlobal("package")
if pkgTbl == lua.LNil {
t.Fatal("package table is nil")
}
tbl := pkgTbl.(*lua.LTable)
seeall := tbl.RawGetString("seeall")
if seeall != lua.LNil {
t.Errorf("package.seeall should be nil, got %v", seeall)
}
}
// TestLuaVM_Sandbox_PackagePreloadBlocked verifies package.preload is blocked.
func TestLuaVM_Sandbox_PackagePreloadBlocked(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "preload")
os.MkdirAll(pluginDir, 0755)
p := &Plugin{Meta: Meta{Name: "preload"}, Dir: pluginDir, Active: true}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
pkgTbl := vm.L.GetGlobal("package")
if pkgTbl == lua.LNil {
t.Fatal("package table is nil")
}
tbl := pkgTbl.(*lua.LTable)
preload := tbl.RawGetString("preload")
if preload != lua.LNil {
t.Errorf("package.preload should be nil, got %v", preload)
}
}
// TestLuaVM_Sandbox_PackageLoadersBlocked verifies package.loaders is blocked.
func TestLuaVM_Sandbox_PackageLoadersBlocked(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "loaders")
os.MkdirAll(pluginDir, 0755)
p := &Plugin{Meta: Meta{Name: "loaders"}, Dir: pluginDir, Active: true}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
pkgTbl := vm.L.GetGlobal("package")
if pkgTbl == lua.LNil {
t.Fatal("package table is nil")
}
tbl := pkgTbl.(*lua.LTable)
loaders := tbl.RawGetString("loaders")
if loaders != lua.LNil {
t.Errorf("package.loaders should be nil, got %v", loaders)
}
}
// TestLuaVM_Sandbox_PackagePathBlocked verifies package.path is blocked.
func TestLuaVM_Sandbox_PackagePathBlocked(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "pkgpath")
os.MkdirAll(pluginDir, 0755)
p := &Plugin{Meta: Meta{Name: "pkgpath"}, Dir: pluginDir, Active: true}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
pkgTbl := vm.L.GetGlobal("package")
if pkgTbl == lua.LNil {
t.Fatal("package table is nil")
}
tbl := pkgTbl.(*lua.LTable)
path := tbl.RawGetString("path")
if path != lua.LNil {
t.Errorf("package.path should be nil, got %v", path)
}
}
// TestLuaVM_Sandbox_PackageCpathBlocked verifies package.cpath is blocked.
func TestLuaVM_Sandbox_PackageCpathBlocked(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "pkgcpath")
os.MkdirAll(pluginDir, 0755)
p := &Plugin{Meta: Meta{Name: "pkgcpath"}, Dir: pluginDir, Active: true}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
pkgTbl := vm.L.GetGlobal("package")
if pkgTbl == lua.LNil {
t.Fatal("package table is nil")
}
tbl := pkgTbl.(*lua.LTable)
cpath := tbl.RawGetString("cpath")
if cpath != lua.LNil {
t.Errorf("package.cpath should be nil, got %v", cpath)
}
}
// TestLuaVM_Sandbox_PackageLoadedBlocked verifies package.loaded is blocked.
func TestLuaVM_Sandbox_PackageLoadedBlocked(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "pkgloaded")
os.MkdirAll(pluginDir, 0755)
p := &Plugin{Meta: Meta{Name: "pkgloaded"}, Dir: pluginDir, Active: true}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
pkgTbl := vm.L.GetGlobal("package")
if pkgTbl == lua.LNil {
t.Fatal("package table is nil")
}
tbl := pkgTbl.(*lua.LTable)
loaded := tbl.RawGetString("loaded")
if loaded != lua.LNil {
t.Errorf("package.loaded should be nil, got %v", loaded)
}
}
// TestLuaVM_Sandbox_PackageConfigBlocked verifies package.config is blocked.
func TestLuaVM_Sandbox_PackageConfigBlocked(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "pkgconfig")
os.MkdirAll(pluginDir, 0755)
p := &Plugin{Meta: Meta{Name: "pkgconfig"}, Dir: pluginDir, Active: true}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
pkgTbl := vm.L.GetGlobal("package")
if pkgTbl == lua.LNil {
t.Fatal("package table is nil")
}
tbl := pkgTbl.(*lua.LTable)
config := tbl.RawGetString("config")
if config != lua.LNil {
t.Errorf("package.config should be nil, got %v", config)
}
}
// TestLuaVM_Sandbox_PackageSearchpathBlocked verifies package.searchpath is blocked.
func TestLuaVM_Sandbox_PackageSearchpathBlocked(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "searchpath")
os.MkdirAll(pluginDir, 0755)
p := &Plugin{Meta: Meta{Name: "searchpath"}, Dir: pluginDir, Active: true}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
pkgTbl := vm.L.GetGlobal("package")
if pkgTbl == lua.LNil {
t.Fatal("package table is nil")
}
tbl := pkgTbl.(*lua.LTable)
searchpath := tbl.RawGetString("searchpath")
if searchpath != lua.LNil {
t.Errorf("package.searchpath should be nil, got %v", searchpath)
}
}
// TestLuaVM_Sandbox_PrintIsRedirected verifies print is the Go-redirected version.
func TestLuaVM_Sandbox_PrintIsRedirected(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "printredir")
os.MkdirAll(pluginDir, 0755)
p := &Plugin{Meta: Meta{Name: "printredir"}, Dir: pluginDir, Active: true}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
printVal := vm.L.GetGlobal("print")
if printVal == lua.LNil {
t.Fatal("print should be available")
}
if _, ok := printVal.(*lua.LFunction); !ok {
t.Errorf("print should be a function, got %T", printVal)
}
}
// TestLuaVM_DoStringTimeout verifies that infinite loops are killed by timeout.
func TestLuaVM_DoStringTimeout(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "timeout")
os.MkdirAll(pluginDir, 0755)
os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(`
function infinite_loop()
while true do end
end
`), 0644)
p := &Plugin{Meta: Meta{Name: "timeout"}, Dir: pluginDir, Active: true}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
if err := vm.LoadScript("main.lua"); err != nil {
t.Fatalf("LoadScript: %v", err)
}
vm.callTimeout = 1 * time.Second
done := make(chan error, 1)
go func() {
_, err := vm.CallHookWithResult("infinite_loop")
done <- err
}()
select {
case err := <-done:
if err == nil {
t.Error("expected timeout error for infinite loop")
}
case <-time.After(5 * time.Second):
t.Fatal("test hung — timeout didn't work")
}
}
// TestLuaVM_Isolation verifies that two plugins have separate Lua states.
func TestLuaVM_Isolation(t *testing.T) {
dir := t.TempDir()
pluginDirA := filepath.Join(dir, "plugina")
os.MkdirAll(pluginDirA, 0755)
os.WriteFile(filepath.Join(pluginDirA, "main.lua"), []byte(`
function set_global()
plugin_a_secret = "leaked"
end
function get_global()
return plugin_a_secret
end
`), 0644)
pluginDirB := filepath.Join(dir, "pluginb")
os.MkdirAll(pluginDirB, 0755)
os.WriteFile(filepath.Join(pluginDirB, "main.lua"), []byte(`
function check_isolation()
return plugin_a_secret == nil
end
`), 0644)
pA := &Plugin{Meta: Meta{Name: "plugina"}, Dir: pluginDirA, Active: true}
pB := &Plugin{Meta: Meta{Name: "pluginb"}, Dir: pluginDirB, Active: true}
vmA, _ := NewLuaVM(pA)
defer vmA.Close()
vmB, _ := NewLuaVM(pB)
defer vmB.Close()
vmA.LoadScript("main.lua")
vmB.LoadScript("main.lua")
vmA.CallHook("set_global")
result, err := vmB.CallHookWithResult("check_isolation")
if err != nil {
t.Fatalf("CallHook: %v", err)
}
if result != lua.LTrue {
t.Error("plugin B could see plugin A's global — isolation broken")
}
}
// TestLuaVM_ClosedVM verifies that operations on a closed VM return errors.
func TestLuaVM_ClosedVM(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "closed")
os.MkdirAll(pluginDir, 0755)
os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(`function test() return true end`), 0644)
p := &Plugin{Meta: Meta{Name: "closed"}, Dir: pluginDir, Active: true}
vm, _ := NewLuaVM(p)
vm.LoadScript("main.lua")
vm.Close()
_, err := vm.DoString("return 1")
if err == nil {
t.Error("expected error from DoString on closed VM")
}
}
// TestLuaVM_DoString_InjectionAttempt verifies that dangerous APIs are blocked.
func TestLuaVM_DoString_InjectionAttempt(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "inject")
os.MkdirAll(pluginDir, 0755)
os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(`
function innocent()
return "innocent"
end
`), 0644)
p := &Plugin{Meta: Meta{Name: "inject"}, Dir: pluginDir, Active: true}
vm, _ := NewLuaVM(p)
defer vm.Close()
vm.LoadScript("main.lua")
// dofile is blocked — attempting to call it should error
_, err := vm.DoString("return dofile('/etc/passwd')")
if err == nil {
t.Error("dofile should be blocked in sandbox")
}
// package.loadlib is nil — accessing it should return "nil"
result, err := vm.DoString("return package.loadlib")
if err != nil {
t.Fatalf("DoString(package.loadlib): %v", err)
}
if result != "nil" {
t.Errorf("package.loadlib should be nil, got %q", result)
}
// package.loaders is nil
result, err = vm.DoString("return package.loaders")
if err != nil {
t.Fatalf("DoString(package.loaders): %v", err)
}
if result != "nil" {
t.Errorf("package.loaders should be nil, got %q", result)
}
// os.execute is nil
result, err = vm.DoString("return os.execute")
if err != nil {
t.Fatalf("DoString(os.execute): %v", err)
}
if result != "nil" {
t.Errorf("os.execute should be nil, got %q", result)
}
// io is nil (not opened)
result, err = vm.DoString("return io")
if err != nil {
t.Fatalf("DoString(io): %v", err)
}
if result != "nil" {
t.Errorf("io should be nil, got %q", result)
}
// load is nil (blocked global)
result, err = vm.DoString("return load")
if err != nil {
t.Fatalf("DoString(load): %v", err)
}
if result != "nil" {
t.Errorf("load should be nil, got %q", result)
}
}
// TestPluginManager_InstallIdempotent verifies that installing an already-installed
// plugin does not duplicate entries in config.
func TestPluginManager_InstallIdempotent(t *testing.T) {
root := setupPluginDir(t, map[string]*fsDir{
"idem": {
files: map[string][]byte{
"plugin.json": []byte(`{"name": "idem", "version": "1.0", "hooks": {"on_install": "on_install"}}`),
"main.lua": []byte(`function on_install() end`),
},
},
})
mgr := NewManager(root)
mgr.Discover()
if err := mgr.Install("idem"); err != nil {
t.Fatalf("first install: %v", err)
}
if err := mgr.Install("idem"); err == nil {
t.Fatal("expected error on double install, got nil")
}
appCfg, _ := config.LoadAppConfig()
count := 0
for _, n := range appCfg.InstalledPlugins {
if n == "idem" {
count++
}
}
if count != 1 {
t.Errorf("expected 1 entry in InstalledPlugins, got %d: %v", count, appCfg.InstalledPlugins)
}
}
// TestPluginManager_DisablePreservesData verifies that Disable does not
// remove the plugin from the installed list.
func TestPluginManager_DisablePreservesData(t *testing.T) {
root := setupPluginDir(t, map[string]*fsDir{
"keepdata": {
files: map[string][]byte{
"plugin.json": []byte(`{"name": "keepdata", "version": "1.0", "hooks": {"on_install": "on_install"}}`),
"main.lua": []byte(`function on_install() end`),
},
},
})
mgr := NewManager(root)
mgr.Discover()
mgr.Install("keepdata")
mgr.Enable("keepdata")
mgr.Disable("keepdata")
p := mgr.Plugins()[0]
if !p.Installed {
t.Error("Installed should remain true after Disable")
}
if p.Active {
t.Error("Active should be false after Disable")
}
}
// TestPluginManager_UninstallRemovesFromConfig verifies that Uninstall
// removes the plugin from both installed and enabled lists.
func TestPluginManager_UninstallRemovesFromConfig(t *testing.T) {
root := setupPluginDir(t, map[string]*fsDir{
"gone": {
files: map[string][]byte{
"plugin.json": []byte(`{"name": "gone", "version": "1.0", "hooks": {"on_install": "on_install", "on_uninstall": "on_uninstall"}}`),
"main.lua": []byte(`function on_install() end function on_uninstall() end`),
},
},
})
mgr := NewManager(root)
mgr.Discover()
mgr.Install("gone")
mgr.Enable("gone")
if err := mgr.Uninstall("gone"); err != nil {
t.Fatalf("uninstall: %v", err)
}
appCfg, _ := config.LoadAppConfig()
for _, n := range appCfg.InstalledPlugins {
if n == "gone" {
t.Error("plugin should be removed from InstalledPlugins after Uninstall")
}
}
for _, n := range appCfg.EnabledPlugins {
if n == "gone" {
t.Error("plugin should be removed from EnabledPlugins after Uninstall")
}
}
}
// TestPluginManager_ReloadPlugins_Cleanup verifies that ReloadPlugins
// properly stops schedulers and closes old VMs.
func TestPluginManager_ReloadPlugins_Cleanup(t *testing.T) {
root := setupPluginDir(t, map[string]*fsDir{
"reloadme": {
files: map[string][]byte{
"plugin.json": []byte(`{"name": "reloadme", "version": "1.0", "hooks": {"on_install": "on_install", "on_init": "on_init", "on_shutdown": "on_shutdown"}}`),
"main.lua": []byte(`function on_install() end function on_init() end function on_shutdown() end`),
},
},
})
mgr := NewManager(root)
mgr.Discover()
mgr.Install("reloadme")
mgr.Enable("reloadme")
mgr.InitRuntimes()
if len(mgr.Active()) != 1 {
t.Fatalf("expected 1 active plugin, got %d", len(mgr.Active()))
}
// Simulate what ReloadPlugins does
mgr.StopSchedulers()
mgr.CallShutdownHooks()
mgr.CloseRuntimes()
for _, p := range mgr.Plugins() {
if p.vm != nil {
t.Error("VM should be nil after CloseRuntimes")
}
if p.scheduler != nil {
t.Error("scheduler should be nil after StopSchedulers")
}
}
mgr.InitRuntimes()
mgr.CallInitHooks()
mgr.StartSchedulers()
if len(mgr.Active()) != 1 {
t.Errorf("expected 1 active plugin after re-init, got %d", len(mgr.Active()))
}
mgr.StopSchedulers()
mgr.CloseRuntimes()
}
// TestCallPluginFunction_Validation tests that the funcName validation
// rejects dangerous patterns.
func TestCallPluginFunction_Validation(t *testing.T) {
validCases := []string{
"create_event",
"calendar.create_event",
"a.b.c",
"_private",
"func123",
}
invalidCases := []string{
"",
"func;os.execute('id')",
"func..name",
"func[name]",
"func()",
"func name",
"func-name",
"func@name",
"a.b.c.d",
"123func",
}
for _, tc := range validCases {
t.Run("valid_"+tc, func(t *testing.T) {
segments := strings.Split(tc, ".")
if len(segments) > 3 {
t.Fatalf("should be valid: %s", tc)
}
for _, seg := range segments {
if !validLuaIdent.MatchString(seg) {
t.Fatalf("should be valid: %s (segment %s)", tc, seg)
}
}
})
}
for _, tc := range invalidCases {
t.Run("invalid_"+tc, func(t *testing.T) {
if tc == "" {
return
}
segments := strings.Split(tc, ".")
valid := true
if len(segments) > 3 {
valid = false
} else {
for _, seg := range segments {
if !validLuaIdent.MatchString(seg) {
valid = false
break
}
}
}
if valid {
t.Errorf("should be invalid: %s", tc)
}
})
}
}