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 }