967 lines
25 KiB
Go
967 lines
25 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)
|
|
}
|
|
})
|
|
}
|
|
}
|