linstrom/plugins/pluginLoader.go
mstar 94106bb82f
Some checks failed
/ docker (push) Has been cancelled
Start work on lua based plugin system
2025-06-02 17:40:53 +02:00

200 lines
5.8 KiB
Go

package plugins
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path"
"sync"
"git.mstar.dev/mstar/goutils/other"
lua "github.com/yuin/gopher-lua"
"github.com/yuin/gopher-lua/parse"
)
/*
Plugin structure expectation:
- meta.json for plugin metadata:
- version
- name
- source location (where the plugin can be sourced from)
- license
- file hashes (every file in the plugin dir, sha256, hex encoded)
- entry file name
- Hook to table key name list (which function in the returned table is for what hook)
- Entry script returns a table
*/
const PluginMetadataFileName = "meta.json"
var (
ErrNoHashForFile = errors.New("no hash for file in the metadata")
ErrInvalidHash = errors.New("hashes don't match")
ErrMissingFile = errors.New("a required file is missing")
ErrInvalidEntryReturnValue = errors.New(
"entry script returned an invalid value. Must be a table",
)
)
var compCache = map[string]*lua.FunctionProto{}
var compCacheLock = sync.RWMutex{}
// Load the plugin located in a provided folder into the given state.
// Validates the data, compiles it for future reuse and links up the hooks
func LoadPlugin(location string, lState *lua.LState) (*pluginHolder, error) {
metaFileData, err := os.ReadFile(location + "/" + PluginMetadataFileName)
if err != nil {
return nil, err
}
metadata := PluginMetadata{}
err = json.Unmarshal(metaFileData, &metadata)
if err != nil {
return nil, err
}
compCacheLock.RLock()
precompiled, ok := compCache[metadata.Name+metadata.Version]
compCacheLock.RUnlock()
if !ok {
// This version of a plugin not in the cache yet, validate the plugin dir, compile it and add it to the cache
precompiled, err = compilePluginDir(&metadata, location)
if err != nil {
return nil, err
}
compCacheLock.Lock()
compCache[metadata.Name+metadata.Version] = precompiled
compCacheLock.Unlock()
}
lFunc := lState.NewFunctionFromProto(precompiled)
lState.Push(lFunc)
if err = lState.PCall(0, lua.MultRet, nil); err != nil {
return nil, err
}
return generatePluginHolder(lState, &metadata)
}
// Compiles a given lua file for future use
func compileFile(filePath string) (*lua.FunctionProto, error) {
file, err := os.Open(filePath)
defer func() { _ = file.Close() }()
if err != nil {
return nil, err
}
reader := bufio.NewReader(file)
chunk, err := parse.Parse(reader, filePath)
if err != nil {
return nil, err
}
proto, err := lua.Compile(chunk, filePath)
if err != nil {
return nil, err
}
return proto, nil
}
// Validates the given plugin dir and compiles it for future use
func compilePluginDir(metadata *PluginMetadata, location string) (*lua.FunctionProto, error) {
if err := checkDir(location, "", metadata.Hashes); err != nil {
return nil, err
}
entryFileName := path.Join(location, metadata.EntryFile)
if _, err := os.Stat(entryFileName); err != nil {
return nil, other.Error("plugins", fmt.Sprintf("missing entry file %s", entryFileName), err)
}
compiled, err := compileFile(entryFileName)
if err != nil {
return nil, err
}
return compiled, nil
}
// Ensures that all files in the given root directory (plus suffix) match the expected hashes provided in the metadata
func checkDir(rootPath string, suffix string, expectedHashes map[string]string) error {
rootEntries, err := os.ReadDir(path.Join(rootPath, suffix))
if err != nil {
return err
}
for _, entry := range rootEntries {
if suffix == "" && entry.Name() == PluginMetadataFileName {
continue
}
if entry.IsDir() {
if err = checkDir(rootPath, path.Join(suffix, entry.Name()), expectedHashes); err != nil {
return err
}
}
fileName := path.Join(rootPath, suffix, entry.Name())
expectedHash, ok := expectedHashes[fileName]
if !ok {
return other.Error(
"plugins",
fmt.Sprintf("No hash found in metadata for file %s", fileName),
ErrNoHashForFile,
)
}
f, err := os.Open(fileName)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
if err = checkHash(f, expectedHash); err != nil {
return err
}
}
return nil
}
// Checks if a hash matches the given file
func checkHash(file *os.File, expected string) error {
hasher := sha256.New()
_, err := io.Copy(hasher, file)
if err != nil {
return err
}
sum := hasher.Sum([]byte{})
if expected != hex.EncodeToString(sum) {
return ErrInvalidHash
}
return nil
}
// Properly inserts the plugin into the state's global context.
// Also gets links to all functions found that are named as hook in the metadata.
// Assumptions: Called after the entry file has been run, but before any other operations
func generatePluginHolder(l *lua.LState, meta *PluginMetadata) (*pluginHolder, error) {
pluginTable, ok := l.Get(1).(*lua.LTable)
if !ok || pluginTable.MaxN() != 0 {
return nil, ErrInvalidEntryReturnValue
}
l.SetGlobal(meta.Name, pluginTable)
pMap := lTableToMap(pluginTable)
holder := pluginHolder{
meta: meta,
table: pluginTable,
}
if postLocalMessageHookName, ok := meta.HookNames[HookPostLocalMessage]; ok {
if lV, ok := pMap[lua.LString(postLocalMessageHookName)].(*lua.LFunction); ok {
holder.postLocalMessage = lV
}
}
if postExternalMessageHookName, ok := meta.HookNames[HookPostExternalMessage]; ok {
if lV, ok := pMap[lua.LString(postExternalMessageHookName)].(*lua.LFunction); ok {
holder.postExternalMessage = lV
}
}
if postNewLocalAccountName, ok := meta.HookNames[HookPostNewLocalAccount]; ok {
if lV, ok := pMap[lua.LString(postNewLocalAccountName)].(*lua.LFunction); ok {
holder.postNewLocalAccount = lV
}
}
if postRemoteAccountDiscoveredName, ok := meta.HookNames[HookPostRemoteAccountDiscovered]; ok {
if lV, ok := pMap[lua.LString(postRemoteAccountDiscoveredName)].(*lua.LFunction); ok {
holder.postRemoteAccountDiscovered = lV
}
}
return &holder, nil
}