1653 lines
44 KiB
Go
1653 lines
44 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSetPluginEnabled_FullLifecycle tests the complete flow:
|
|
// Install → SetPluginEnabled(true) → VM exists → on_init called → CallPluginFunction works.
|
|
func TestSetPluginEnabled_FullLifecycle(t *testing.T) {
|
|
root := setupPluginDir(t, map[string]*fsDir{
|
|
"lifecycle": {
|
|
files: map[string][]byte{
|
|
"plugin.json": []byte(`{
|
|
"name": "lifecycle",
|
|
"version": "1.0",
|
|
"hooks": {"on_install": "on_install", "on_init": "on_init", "on_shutdown": "on_shutdown"}
|
|
}`),
|
|
"main.lua": []byte(`
|
|
init_called = false
|
|
function on_install() end
|
|
function on_init()
|
|
init_called = true
|
|
end
|
|
function on_shutdown() end
|
|
function get_status()
|
|
return init_called
|
|
end
|
|
`),
|
|
},
|
|
},
|
|
})
|
|
|
|
mgr := NewManager(root)
|
|
mgr.Discover()
|
|
|
|
// Install
|
|
if err := mgr.Install("lifecycle"); err != nil {
|
|
t.Fatalf("install: %v", err)
|
|
}
|
|
|
|
// Enable (sets Enabled=true)
|
|
if err := mgr.Enable("lifecycle"); err != nil {
|
|
t.Fatalf("enable: %v", err)
|
|
}
|
|
|
|
// InitRuntimes creates VM, loads main.lua, calls on_init
|
|
mgr.InitRuntimes()
|
|
mgr.CallInitHooks()
|
|
|
|
// Verify plugin is active
|
|
plugins := mgr.Active()
|
|
if len(plugins) != 1 {
|
|
t.Fatalf("expected 1 active plugin, got %d", len(plugins))
|
|
}
|
|
if !plugins[0].Active {
|
|
t.Fatal("plugin should be active")
|
|
}
|
|
if plugins[0].vm == nil {
|
|
t.Fatal("plugin should have VM")
|
|
}
|
|
|
|
// Verify on_init was called via DoString
|
|
allPlugins := mgr.Plugins()
|
|
if len(allPlugins) != 1 {
|
|
t.Fatalf("expected 1 plugin, got %d", len(allPlugins))
|
|
}
|
|
result, err := allPlugins[0].vm.DoString("return get_status()")
|
|
if err != nil {
|
|
t.Fatalf("DoString: %v", err)
|
|
}
|
|
if result != "true" {
|
|
t.Errorf("on_init was not called, get_status() = %q", result)
|
|
}
|
|
|
|
// Deactivate
|
|
mgr.DeactivatePlugin("lifecycle")
|
|
if len(mgr.Active()) != 0 {
|
|
t.Errorf("expected 0 active after deactivate, got %d", len(mgr.Active()))
|
|
}
|
|
|
|
// Re-activate
|
|
mgr.ActivatePlugin("lifecycle")
|
|
if len(mgr.Active()) != 1 {
|
|
t.Errorf("expected 1 active after re-activate, got %d", len(mgr.Active()))
|
|
}
|
|
|
|
// Cleanup
|
|
mgr.StopSchedulers()
|
|
mgr.CloseRuntimes()
|
|
}
|
|
|
|
// TestActivatePlugin_Services verifies that ActivatePlugin sets CoreServices on the VM.
|
|
func TestActivatePlugin_Services(t *testing.T) {
|
|
root := setupPluginDir(t, map[string]*fsDir{
|
|
"svcplug": {
|
|
files: map[string][]byte{
|
|
"plugin.json": []byte(`{"name": "svcplug", "version": "1.0", "hooks": {"on_install": "on_install", "on_init": "on_init"}}`),
|
|
"main.lua": []byte(`function on_install() end function on_init() end`),
|
|
},
|
|
},
|
|
})
|
|
|
|
mgr := NewManager(root)
|
|
mgr.Discover()
|
|
mgr.Install("svcplug")
|
|
mgr.Enable("svcplug")
|
|
|
|
// ActivatePlugin should set services
|
|
mgr.ActivatePlugin("svcplug")
|
|
|
|
plugins := mgr.Active()
|
|
if len(plugins) != 1 {
|
|
t.Fatalf("expected 1 active plugin, got %d", len(plugins))
|
|
}
|
|
|
|
// Verify VM exists and has services (services will be nil in test, but the call path works)
|
|
if plugins[0].vm == nil {
|
|
t.Fatal("VM should exist after ActivatePlugin")
|
|
}
|
|
|
|
mgr.StopSchedulers()
|
|
mgr.CloseRuntimes()
|
|
}
|
|
|
|
// TestDiscover_Idempotent verifies that calling Discover multiple times
|
|
// does not duplicate plugins.
|
|
func TestDiscover_Idempotent(t *testing.T) {
|
|
root := setupPluginDir(t, map[string]*fsDir{
|
|
"alpha": {files: map[string][]byte{"plugin.json": []byte(`{"name":"alpha","hooks":{"on_install":"on_install"}}`)}},
|
|
"beta": {files: map[string][]byte{"plugin.json": []byte(`{"name":"beta","hooks":{"on_install":"on_install"}}`)}},
|
|
})
|
|
|
|
mgr := NewManager(root)
|
|
|
|
// Call Discover multiple times
|
|
for i := 0; i < 5; i++ {
|
|
mgr.Discover()
|
|
}
|
|
|
|
if len(mgr.Plugins()) != 2 {
|
|
t.Errorf("after 5 Discovers: plugins = %d, want 2", len(mgr.Plugins()))
|
|
}
|
|
|
|
// Verify no duplicates
|
|
names := make(map[string]int)
|
|
for _, p := range mgr.Plugins() {
|
|
names[p.Meta.Name]++
|
|
}
|
|
for name, count := range names {
|
|
if count > 1 {
|
|
t.Errorf("plugin %q appears %d times", name, count)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReloadPlugins_NoDuplicates verifies that the full ReloadPlugins cycle
|
|
// does not duplicate plugins.
|
|
func TestReloadPlugins_NoDuplicates(t *testing.T) {
|
|
root := setupPluginDir(t, map[string]*fsDir{
|
|
"reloadable": {
|
|
files: map[string][]byte{
|
|
"plugin.json": []byte(`{"name": "reloadable", "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("reloadable")
|
|
mgr.Enable("reloadable")
|
|
mgr.InitRuntimes()
|
|
|
|
if len(mgr.Plugins()) != 1 {
|
|
t.Fatalf("expected 1 plugin, got %d", len(mgr.Plugins()))
|
|
}
|
|
|
|
// Simulate ReloadPlugins cycle multiple times
|
|
for i := 0; i < 3; i++ {
|
|
mgr.StopSchedulers()
|
|
mgr.CallShutdownHooks()
|
|
mgr.CloseRuntimes()
|
|
mgr.Discover()
|
|
// SyncConfig would restore Enabled state from config
|
|
mgr.InitRuntimes()
|
|
mgr.CallInitHooks()
|
|
mgr.StartSchedulers()
|
|
}
|
|
|
|
if len(mgr.Plugins()) != 1 {
|
|
t.Errorf("after 3 reload cycles: plugins = %d, want 1", len(mgr.Plugins()))
|
|
}
|
|
|
|
mgr.StopSchedulers()
|
|
mgr.CloseRuntimes()
|
|
}
|
|
|
|
// TestCallPluginFunction_Timeout verifies that CallPluginFunction
|
|
// times out on infinite loops (thread-safe + timeout-safe).
|
|
func TestCallPluginFunction_Timeout(t *testing.T) {
|
|
dir := t.TempDir()
|
|
pluginDir := filepath.Join(dir, "timeoutfunc")
|
|
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: "timeoutfunc", Hooks: map[string]string{"on_install": "on_install"}},
|
|
Dir: pluginDir,
|
|
DataDir: filepath.Join(dir, "data"),
|
|
Active: true,
|
|
Enabled: true,
|
|
HasInstall: true,
|
|
}
|
|
os.MkdirAll(p.DataDir, 0755)
|
|
|
|
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)
|
|
}
|
|
|
|
// Set short timeout
|
|
vm.callTimeout = 1 * time.Second
|
|
|
|
// CallFunctionJSON should timeout, not hang
|
|
done := make(chan struct{})
|
|
go func() {
|
|
_, err := vm.CallFunctionJSON([]string{"infinite_loop"}, "")
|
|
if err == nil {
|
|
t.Error("expected timeout error from infinite_loop")
|
|
}
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// Success — function returned with timeout error
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("CallFunctionJSON hung — timeout didn't work")
|
|
}
|
|
}
|
|
|
|
// TestUninstall_ActivePlugin verifies that uninstalling an active plugin
|
|
// properly stops scheduler, calls on_shutdown, and closes VM.
|
|
func TestUninstall_ActivePlugin(t *testing.T) {
|
|
root := setupPluginDir(t, map[string]*fsDir{
|
|
"activeuninstall": {
|
|
files: map[string][]byte{
|
|
"plugin.json": []byte(`{"name": "activeuninstall", "version": "1.0", "hooks": {"on_install": "on_install", "on_uninstall": "on_uninstall", "on_shutdown": "on_shutdown"}}`),
|
|
"main.lua": []byte(`
|
|
shutdown_called = false
|
|
function on_install() end
|
|
function on_uninstall() end
|
|
function on_shutdown()
|
|
shutdown_called = true
|
|
end
|
|
function get_shutdown()
|
|
return shutdown_called
|
|
end
|
|
`),
|
|
},
|
|
},
|
|
})
|
|
|
|
mgr := NewManager(root)
|
|
mgr.Discover()
|
|
mgr.Install("activeuninstall")
|
|
mgr.Enable("activeuninstall")
|
|
mgr.InitRuntimes()
|
|
|
|
// Verify active
|
|
if len(mgr.Active()) != 1 {
|
|
t.Fatalf("expected 1 active plugin, got %d", len(mgr.Active()))
|
|
}
|
|
|
|
// Uninstall should: stop scheduler, call on_shutdown, close VM, then call on_uninstall
|
|
if err := mgr.Uninstall("activeuninstall"); err != nil {
|
|
t.Fatalf("uninstall: %v", err)
|
|
}
|
|
|
|
// Verify deactivated
|
|
if len(mgr.Active()) != 0 {
|
|
t.Errorf("expected 0 active after uninstall, got %d", len(mgr.Active()))
|
|
}
|
|
|
|
// Verify VM is nil
|
|
for _, p := range mgr.Plugins() {
|
|
if p.vm != nil {
|
|
t.Error("VM should be nil after uninstall")
|
|
}
|
|
if p.scheduler != nil {
|
|
t.Error("scheduler should be nil after uninstall")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGetPluginPanelHTML_PathTraversal verifies that panel path traversal is blocked.
|
|
func TestGetPluginPanelHTML_PathTraversal(t *testing.T) {
|
|
// We test the path validation logic directly
|
|
blockedPaths := []string{
|
|
"../../../../etc/passwd",
|
|
"..\\..\\..\\windows\\system32\\config\\sam",
|
|
"/etc/passwd",
|
|
"panel/../../../etc/shadow",
|
|
"subdir/../../etc/passwd",
|
|
}
|
|
|
|
for _, path := range blockedPaths {
|
|
t.Run(path, func(t *testing.T) {
|
|
// Simulate the validation from GetPluginPanelHTML
|
|
if !filepath.IsAbs(path) && !strings.Contains(path, "..") && strings.HasSuffix(strings.ToLower(path), ".html") {
|
|
t.Errorf("path %q should have been blocked", path)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Valid paths should pass
|
|
validPaths := []string{
|
|
"panels/calendar.html",
|
|
"panel.html",
|
|
"subdir/panel.html",
|
|
}
|
|
|
|
for _, path := range validPaths {
|
|
t.Run(path, func(t *testing.T) {
|
|
if filepath.IsAbs(path) || strings.Contains(path, "..") || !strings.HasSuffix(strings.ToLower(path), ".html") {
|
|
t.Errorf("path %q should be valid", path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPluginPage_GenericBridge verifies that PluginPage no longer hardcodes
|
|
// calendar-specific function names. This is verified by the frontend build
|
|
// (PluginPage.svelte uses funcPrefix derived from pluginName).
|
|
func TestPluginPage_GenericBridge(t *testing.T) {
|
|
// The PluginPage.svelte component now uses:
|
|
// $: funcPrefix = pluginName ? pluginName + '.' : ''
|
|
// instead of hardcoded 'calendar.' prefix.
|
|
// This is a structural guarantee — verified by frontend build.
|
|
t.Log("PluginPage uses funcPrefix derived from pluginName — verified by frontend build")
|
|
}
|
|
|
|
// TestFullLifecycle_EndToEnd tests the complete scenario:
|
|
// Install → Enable → Init → CallFunctionJSON → Deactivate → Activate → Reload → Uninstall.
|
|
func TestFullLifecycle_EndToEnd(t *testing.T) {
|
|
root := setupPluginDir(t, map[string]*fsDir{
|
|
"e2e": {
|
|
files: map[string][]byte{
|
|
"plugin.json": []byte(`{
|
|
"name": "e2e",
|
|
"version": "1.0",
|
|
"hooks": {"on_install": "on_install", "on_init": "on_init", "on_shutdown": "on_shutdown", "on_uninstall": "on_uninstall"}
|
|
}`),
|
|
"main.lua": []byte(`
|
|
counter = 0
|
|
function on_install() end
|
|
function on_init()
|
|
counter = counter + 1
|
|
end
|
|
function on_shutdown() end
|
|
function on_uninstall() end
|
|
function increment()
|
|
counter = counter + 1
|
|
return counter
|
|
end
|
|
function get_counter()
|
|
return counter
|
|
end
|
|
`),
|
|
},
|
|
},
|
|
})
|
|
|
|
mgr := NewManager(root)
|
|
mgr.Discover()
|
|
|
|
// 1. Install
|
|
if err := mgr.Install("e2e"); err != nil {
|
|
t.Fatalf("install: %v", err)
|
|
}
|
|
|
|
// 2. Enable (sets Enabled=true, persists to config)
|
|
mgr.Enable("e2e")
|
|
// Persist enabled state in config (normally done by SetPluginEnabled)
|
|
cfg, _ := config.LoadAppConfig()
|
|
if cfg == nil {
|
|
cfg = config.DefaultAppConfig()
|
|
}
|
|
cfg.EnabledPlugins = append(cfg.EnabledPlugins, "e2e")
|
|
config.SaveAppConfig(cfg)
|
|
|
|
// 3. InitRuntimes (creates VM, loads main.lua)
|
|
mgr.InitRuntimes()
|
|
mgr.CallInitHooks()
|
|
|
|
if len(mgr.Active()) != 1 {
|
|
t.Fatalf("expected 1 active, got %d", len(mgr.Active()))
|
|
}
|
|
|
|
// 4. CallFunctionJSON — call increment via LuaVM
|
|
allPlugins := mgr.Plugins()
|
|
if len(allPlugins) != 1 {
|
|
t.Fatalf("expected 1 plugin, got %d", len(allPlugins))
|
|
}
|
|
result, err := allPlugins[0].vm.CallFunctionJSON([]string{"increment"}, "")
|
|
if err != nil {
|
|
t.Fatalf("CallFunctionJSON(increment): %v", err)
|
|
}
|
|
if result != "2" { // counter was 1 after on_init, now 2
|
|
t.Errorf("increment() = %q, want %q", result, "2")
|
|
}
|
|
|
|
// 5. Deactivate
|
|
mgr.DeactivatePlugin("e2e")
|
|
if len(mgr.Active()) != 0 {
|
|
t.Errorf("expected 0 active after deactivate, got %d", len(mgr.Active()))
|
|
}
|
|
|
|
// 6. Re-activate
|
|
mgr.ActivatePlugin("e2e")
|
|
if len(mgr.Active()) != 1 {
|
|
t.Errorf("expected 1 active after re-activate, got %d", len(mgr.Active()))
|
|
}
|
|
|
|
// 7. Reload cycle (simulating ReloadPlugins)
|
|
mgr.StopSchedulers()
|
|
mgr.CallShutdownHooks()
|
|
mgr.CloseRuntimes()
|
|
mgr.Discover()
|
|
// In real usage, SyncConfig would restore Enabled from config.
|
|
// For this test, we re-enable manually since config was cleaned by Uninstall.
|
|
// But we haven't uninstalled yet — so SyncConfig should work.
|
|
appCfg, _ := config.LoadAppConfig()
|
|
mgr.SyncConfig(appCfg)
|
|
mgr.InitRuntimes()
|
|
mgr.CallInitHooks()
|
|
mgr.StartSchedulers()
|
|
|
|
if len(mgr.Plugins()) != 1 {
|
|
t.Errorf("after reload: plugins = %d, want 1", len(mgr.Plugins()))
|
|
}
|
|
// After reload + SyncConfig, plugin should be active again
|
|
if len(mgr.Active()) != 1 {
|
|
t.Errorf("after reload: active = %d, want 1", len(mgr.Active()))
|
|
}
|
|
|
|
// 8. Uninstall (first time — should succeed)
|
|
if err := mgr.Uninstall("e2e"); err != nil {
|
|
t.Fatalf("uninstall: %v", err)
|
|
}
|
|
|
|
if len(mgr.Active()) != 0 {
|
|
t.Errorf("after uninstall: active = %d, want 0", len(mgr.Active()))
|
|
}
|
|
|
|
// Verify config cleaned up
|
|
appCfg, _ = config.LoadAppConfig()
|
|
for _, n := range appCfg.InstalledPlugins {
|
|
if n == "e2e" {
|
|
t.Error("e2e should be removed from InstalledPlugins")
|
|
}
|
|
}
|
|
for _, n := range appCfg.EnabledPlugins {
|
|
if n == "e2e" {
|
|
t.Error("e2e should be removed from EnabledPlugins")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSetPluginEnabled_ActivateFails_NoConfigSave verifies that when
|
|
// ActivatePlugin fails, the plugin is NOT added to EnabledPlugins in config.
|
|
// We simulate a failure by using a plugin with an invalid background task interval
|
|
// (which causes scheduler.AddTask to fail inside ActivatePlugin).
|
|
func TestSetPluginEnabled_ActivateFails_NoConfigSave(t *testing.T) {
|
|
root := setupPluginDir(t, map[string]*fsDir{
|
|
"broken": {
|
|
files: map[string][]byte{
|
|
"plugin.json": []byte(`{
|
|
"name": "broken",
|
|
"version": "1.0",
|
|
"hooks": {"on_install": "on_install"},
|
|
"background_tasks": [{"id": "bad", "interval": "not-a-duration", "script": "dummy.lua"}]
|
|
}`),
|
|
"main.lua": []byte(`function on_install() end`),
|
|
},
|
|
},
|
|
})
|
|
|
|
mgr := NewManager(root)
|
|
mgr.Discover()
|
|
|
|
// Install succeeds
|
|
if err := mgr.Install("broken"); err != nil {
|
|
t.Fatalf("install: %v", err)
|
|
}
|
|
|
|
// Enable sets Enabled=true
|
|
if err := mgr.Enable("broken"); err != nil {
|
|
t.Fatalf("enable: %v", err)
|
|
}
|
|
|
|
// ActivatePlugin should fail (invalid background task interval)
|
|
err := mgr.ActivatePlugin("broken")
|
|
if err == nil {
|
|
t.Fatal("expected ActivatePlugin to fail with invalid background task interval")
|
|
}
|
|
|
|
// Verify plugin is NOT active
|
|
if len(mgr.Active()) != 0 {
|
|
t.Errorf("expected 0 active plugins, got %d", len(mgr.Active()))
|
|
}
|
|
|
|
// Verify config does NOT contain "broken" in EnabledPlugins
|
|
// (SetPluginEnabled would not have saved it because activation failed)
|
|
appCfg, _ := config.LoadAppConfig()
|
|
for _, n := range appCfg.EnabledPlugins {
|
|
if n == "broken" {
|
|
t.Error("broken plugin should NOT be in EnabledPlugins after failed activation")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestActivatePlugin_ErrorReturn verifies that ActivatePlugin returns
|
|
// descriptive errors for various failure modes.
|
|
func TestActivatePlugin_ErrorReturn(t *testing.T) {
|
|
// Test: plugin not found
|
|
root := setupPluginDir(t, map[string]*fsDir{
|
|
"exists": {
|
|
files: map[string][]byte{
|
|
"plugin.json": []byte(`{"name": "exists", "hooks": {"on_install": "on_install"}}`),
|
|
"main.lua": []byte(`function on_install() end`),
|
|
},
|
|
},
|
|
})
|
|
|
|
mgr := NewManager(root)
|
|
mgr.Discover()
|
|
|
|
// Not found
|
|
err := mgr.ActivatePlugin("nonexistent")
|
|
if err == nil || !strings.Contains(err.Error(), "not found") {
|
|
t.Errorf("expected 'not found' error, got: %v", err)
|
|
}
|
|
|
|
// Not installed
|
|
mgr.plugins[0].Enabled = true
|
|
err = mgr.ActivatePlugin("exists")
|
|
if err == nil || !strings.Contains(err.Error(), "not installed") {
|
|
t.Errorf("expected 'not installed' error, got: %v", err)
|
|
}
|
|
|
|
// Not enabled
|
|
mgr.plugins[0].Installed = true
|
|
mgr.plugins[0].Enabled = false
|
|
err = mgr.ActivatePlugin("exists")
|
|
if err == nil || !strings.Contains(err.Error(), "not enabled") {
|
|
t.Errorf("expected 'not enabled' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCallFunctionJSON_ThreadSafe verifies that CallFunctionJSON
|
|
// properly converts JSON params under the lock.
|
|
func TestCallFunctionJSON_ThreadSafe(t *testing.T) {
|
|
dir := t.TempDir()
|
|
pluginDir := filepath.Join(dir, "jsonplug")
|
|
os.MkdirAll(pluginDir, 0755)
|
|
os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(`
|
|
function echo_json(data)
|
|
return data["key"] .. ":" .. tostring(data["num"])
|
|
end
|
|
|
|
function echo_array(arr)
|
|
return arr[1] + arr[2]
|
|
end
|
|
`), 0644)
|
|
|
|
p := &Plugin{
|
|
Meta: Meta{Name: "jsonplug", Hooks: map[string]string{"on_install": "on_install"}},
|
|
Dir: pluginDir,
|
|
DataDir: filepath.Join(dir, "data"),
|
|
Active: true,
|
|
Enabled: true,
|
|
HasInstall: true,
|
|
}
|
|
os.MkdirAll(p.DataDir, 0755)
|
|
|
|
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)
|
|
}
|
|
|
|
// Test with JSON object
|
|
result, err := vm.CallFunctionJSON([]string{"echo_json"}, `{"key":"hello","num":42}`)
|
|
if err != nil {
|
|
t.Fatalf("CallFunctionJSON: %v", err)
|
|
}
|
|
if result != `"hello:42"` {
|
|
t.Errorf("echo_json = %q, want %q", result, `"hello:42"`)
|
|
}
|
|
|
|
// Test with JSON array
|
|
result, err = vm.CallFunctionJSON([]string{"echo_array"}, `[10, 20]`)
|
|
if err != nil {
|
|
t.Fatalf("CallFunctionJSON: %v", err)
|
|
}
|
|
if result != "30" {
|
|
t.Errorf("echo_array = %q, want %q", result, "30")
|
|
}
|
|
|
|
// Test with empty params
|
|
result, err = vm.CallFunctionJSON([]string{"echo_json"}, "")
|
|
if err == nil {
|
|
t.Error("expected error for missing required params")
|
|
}
|
|
}
|
|
|
|
// TestDeactivatePlugin_Idempotent verifies that deactivating an already
|
|
// inactive plugin is a no-op (no panic, no error).
|
|
func TestDeactivatePlugin_Idempotent(t *testing.T) {
|
|
root := setupPluginDir(t, map[string]*fsDir{
|
|
"idle": {
|
|
files: map[string][]byte{
|
|
"plugin.json": []byte(`{"name": "idle", "hooks": {"on_install": "on_install"}}`),
|
|
"main.lua": []byte(`function on_install() end`),
|
|
},
|
|
},
|
|
})
|
|
|
|
mgr := NewManager(root)
|
|
mgr.Discover()
|
|
mgr.Install("idle")
|
|
mgr.Enable("idle")
|
|
mgr.InitRuntimes()
|
|
|
|
// Deactivate twice — second should be no-op
|
|
mgr.DeactivatePlugin("idle")
|
|
mgr.DeactivatePlugin("idle") // should not panic
|
|
|
|
if len(mgr.Active()) != 0 {
|
|
t.Errorf("expected 0 active, got %d", len(mgr.Active()))
|
|
}
|
|
}
|
|
|
|
// TestInitRuntimes_SkipsDisabled verifies that InitRuntimes only activates
|
|
// plugins that are Enabled=true.
|
|
func TestInitRuntimes_SkipsDisabled(t *testing.T) {
|
|
root := setupPluginDir(t, map[string]*fsDir{
|
|
"on": {
|
|
files: map[string][]byte{
|
|
"plugin.json": []byte(`{"name": "on", "hooks": {"on_install": "on_install"}}`),
|
|
"main.lua": []byte(`function on_install() end`),
|
|
},
|
|
},
|
|
"off": {
|
|
files: map[string][]byte{
|
|
"plugin.json": []byte(`{"name": "off", "hooks": {"on_install": "on_install"}}`),
|
|
"main.lua": []byte(`function on_install() end`),
|
|
},
|
|
},
|
|
})
|
|
|
|
mgr := NewManager(root)
|
|
mgr.Discover()
|
|
mgr.Install("on")
|
|
mgr.Install("off")
|
|
mgr.Enable("on")
|
|
// "off" is NOT enabled
|
|
|
|
mgr.InitRuntimes()
|
|
|
|
if len(mgr.Active()) != 1 {
|
|
t.Errorf("expected 1 active (only 'on'), got %d", len(mgr.Active()))
|
|
}
|
|
if len(mgr.Plugins()) != 2 {
|
|
t.Errorf("expected 2 plugins total, got %d", len(mgr.Plugins()))
|
|
}
|
|
}
|