package pluginhost import ( "errors" "os" "path/filepath" "regexp" "runtime" "sort" "strconv" "strings" log "github.com/sirupsen/logrus") var ( pluginIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) pluginVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z.+-]*$`)) type pluginFile struct { ID string Path string Version string} // PluginFileInfo describes a plugin binary selected by the host discovery rules.type PluginFileInfo struct { ID string Path string Version string} // ValidatePluginID reports whether id can be used as a plugin configuration key.func ValidatePluginID(id string) bool { return validPluginID(id)} func validPluginID(id string) bool { return pluginIDPattern.MatchString(id)} func validPluginVersion(version string) bool { return version != "" && !strings.HasPrefix(version, "v") && pluginVersionPattern.MatchString(version)} func pluginIDFromPath(path string) string { file, ok := pluginFileFromPath(path, "") if ok { return file.ID } base := filepath.Base(path) lowerBase := strings.ToLower(base) for _, extension := range []string{".so", ".dylib", ".dll"} { if strings.HasSuffix(lowerBase, extension) { return base[:len(base)-len(extension)] } } return base} func pluginFileFromPath(filePath string, requiredExtension string) (pluginFile, bool) { base := filepath.Base(filePath) lowerBase := strings.ToLower(base) extension := strings.TrimSpace(requiredExtension) if extension != "" { if !strings.HasSuffix(lowerBase, strings.ToLower(extension)) { return pluginFile{}, false } } else { for _, candidateExtension := range []string{".so", ".dylib", ".dll"} { if strings.HasSuffix(lowerBase, candidateExtension) { extension = candidateExtension break } } if extension == "" { return pluginFile{}, false } } name := base[:len(base)-len(extension)] id := name version := "" if versionIndex := strings.LastIndex(name, "-v"); versionIndex > 0 { candidateID := name[:versionIndex] candidateVersion := name[versionIndex+2:] if validPluginID(candidateID) && validPluginVersion(candidateVersion) { id = candidateID version = candidateVersion } } if !validPluginID(id) { return pluginFile{}, false } return pluginFile{ID: id, Path: filePath, Version: version}, true} // PluginExtension returns the dynamic library file extension used for goos.func PluginExtension(goos string) string { return pluginExtension(goos)} func pluginExtension(goos string) string { switch goos { case "darwin": return ".dylib" case "windows": return ".dll" default: return ".so" }} func selectPluginFiles(root string, desiredVersions ...map[string]string) ([]pluginFile, error) { selected, _, errSelect := selectPluginFilesWithCandidates(root, desiredVersions...) return selected, errSelect} func selectPluginFilesWithCandidates(root string, desiredVersions ...map[string]string) ([]pluginFile, []pluginFile, error) { root = strings.TrimSpace(root) if root == "" { root = "plugins" } desired := normalizeDesiredPluginVersions(desiredVersions...) candidates := candidateDirs(root, runtime.GOOS, runtime.GOARCH) extension := pluginExtension(runtime.GOOS) selectedByID := make(map[string]pluginFile) order := make([]string, 0) all := make([]pluginFile, 0) for _, dir := range candidates { entries, errReadDir := os.ReadDir(dir) if errReadDir != nil { if os.IsNotExist(errReadDir) { continue } return nil, nil, errReadDir } files := make([]string, 0, len(entries)) for _, entry := range entries { if entry == nil || !entry.Type().IsRegular() { continue } if strings.HasSuffix(strings.ToLower(entry.Name()), extension) { files = append(files, filepath.Join(dir, entry.Name())) } } sort.Strings(files) for _, path := range files { file, okFile := pluginFileFromPath(path, extension) if !okFile { continue } all = append(all, file) current, exists := selectedByID[file.ID] if !exists { selectedByID[file.ID] = file order = append(order, file.ID) continue } if pluginFilePreferredForDesired(file, current, desired[file.ID]) { selectedByID[file.ID] = file } } } selected := make([]pluginFile, 0, len(order)) for _, id := range order { file := selectedByID[id] if desiredVersion := desired[id]; desiredVersion != "" && file.Version != desiredVersion { continue } selected = append(selected, file) } return selected, all, nil} func normalizeDesiredPluginVersions(sources ...map[string]string) map[string]string { out := make(map[string]string) for _, source := range sources { for id, version := range source { id = strings.TrimSpace(id) version = normalizePluginDesiredVersion(version) if id == "" || version == "" { continue } out[id] = version } } return out} func pluginFilePreferredForDesired(candidate pluginFile, current pluginFile, desiredVersion string) bool { desiredVersion = normalizePluginDesiredVersion(desiredVersion) if desiredVersion != "" { candidateMatches := candidate.Version == desiredVersion currentMatches := current.Version == desiredVersion if candidateMatches != currentMatches { return candidateMatches } } return pluginFilePreferred(candidate, current)} func pluginFilePreferred(candidate pluginFile, current pluginFile) bool { if candidate.Version == "" { return false } if current.Version == "" { return true } comparison, comparable := comparePluginVersions(candidate.Version, current.Version) if !comparable { return candidate.Version > current.Version } return comparison > 0} func comparePluginVersions(a, b string) (int, bool) { segmentsA := strings.Split(a, ".") segmentsB := strings.Split(b, ".") length := len(segmentsA) if len(segmentsB) > length { length = len(segmentsB) } for index := 0; index < length; index++ { numberA, okA := pluginVersionSegment(segmentsA, index) numberB, okB := pluginVersionSegment(segmentsB, index) if !okA || !okB { return 0, false } if numberA != numberB { if numberA < numberB { return -1, true } return 1, true } } return 0, true} func pluginVersionSegment(segments []string, index int) (int64, bool) { if index >= len(segments) { return 0, true } number, errParse := strconv.ParseInt(segments[index], 10, 64) if errParse != nil || number < 0 { return 0, false } return number, true} func cleanupUnselectedPluginFiles(root string, loaded []pluginFile) error { if len(loaded) == 0 { return nil } _, candidates, errSelect := selectPluginFilesWithCandidates(root) if errSelect != nil { return errSelect } loadedByID := make(map[string]map[string]struct{}, len(loaded)) for _, file := range loaded { if strings.TrimSpace(file.ID) == "" || strings.TrimSpace(file.Path) == "" { continue } paths := loadedByID[file.ID] if paths == nil { paths = make(map[string]struct{}) loadedByID[file.ID] = paths } paths[filepath.Clean(file.Path)] = struct{}{} } var errs []error for _, candidate := range candidates { paths := loadedByID[candidate.ID] if len(paths) == 0 { continue } if _, selected := paths[filepath.Clean(candidate.Path)]; selected { continue } if errRemove := os.Remove(candidate.Path); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) { errs = append(errs, errRemove) log.WithError(errRemove).Warnf("pluginhost: failed to remove old plugin file %s", candidate.Path) continue } log.WithFields(pluginLogFields(candidate.ID, "", candidate.Version, candidate.Path)).Info("pluginhost: old plugin file removed") } return errors.Join(errs...)} // DiscoverPluginFiles returns plugin binaries selected by the current host discovery rules.func DiscoverPluginFiles(root string, desiredVersions ...map[string]string) ([]PluginFileInfo, error) { files, errSelect := selectPluginFiles(root, desiredVersions...) if errSelect != nil { return nil, errSelect } out := make([]PluginFileInfo, 0, len(files)) for _, file := range files { out = append(out, PluginFileInfo{ ID: file.ID, Path: file.Path, Version: file.Version, }) } return out, nil} func candidateDirs(root, goos, goarch string) []string { dirs := make([]string, 0, 2) dirs = append(dirs, filepath.Join(root, goos, goarch)) dirs = append(dirs, root) return dirs}
package pluginhost
import (
"errors"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
)
var (
pluginIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
pluginVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z.+-]*$`)
)
type pluginFile struct {
ID string
Path string
Version string
}
// PluginFileInfo describes a plugin binary selected by the host discovery rules.
type PluginFileInfo struct {
ID string
Path string
Version string
}
// ValidatePluginID reports whether id can be used as a plugin configuration key.
func ValidatePluginID(id string) bool {
return validPluginID(id)
}
func validPluginID(id string) bool {
return pluginIDPattern.MatchString(id)
}
func validPluginVersion(version string) bool {
return version != "" && !strings.HasPrefix(version, "v") && pluginVersionPattern.MatchString(version)
}
func pluginIDFromPath(path string) string {
file, ok := pluginFileFromPath(path, "")
if ok {
return file.ID
}
base := filepath.Base(path)
lowerBase := strings.ToLower(base)
for _, extension := range []string{".so", ".dylib", ".dll"} {
if strings.HasSuffix(lowerBase, extension) {
return base[:len(base)-len(extension)]
}
}
return base
}
func pluginFileFromPath(filePath string, requiredExtension string) (pluginFile, bool) {
base := filepath.Base(filePath)
lowerBase := strings.ToLower(base)
extension := strings.TrimSpace(requiredExtension)
if extension != "" {
if !strings.HasSuffix(lowerBase, strings.ToLower(extension)) {
return pluginFile{}, false
}
} else {
for _, candidateExtension := range []string{".so", ".dylib", ".dll"} {
if strings.HasSuffix(lowerBase, candidateExtension) {
extension = candidateExtension
break
}
}
if extension == "" {
return pluginFile{}, false
}
}
name := base[:len(base)-len(extension)]
id := name
version := ""
if versionIndex := strings.LastIndex(name, "-v"); versionIndex > 0 {
candidateID := name[:versionIndex]
candidateVersion := name[versionIndex+2:]
if validPluginID(candidateID) && validPluginVersion(candidateVersion) {
id = candidateID
version = candidateVersion
}
}
if !validPluginID(id) {
return pluginFile{}, false
}
return pluginFile{ID: id, Path: filePath, Version: version}, true
}
// PluginExtension returns the dynamic library file extension used for goos.
func PluginExtension(goos string) string {
return pluginExtension(goos)
}
func pluginExtension(goos string) string {
switch goos {
case "darwin":
return ".dylib"
case "windows":
return ".dll"
default:
return ".so"
}
}
func selectPluginFiles(root string, desiredVersions ...map[string]string) ([]pluginFile, error) {
selected, _, errSelect := selectPluginFilesWithCandidates(root, desiredVersions...)
return selected, errSelect
}
func selectPluginFilesWithCandidates(root string, desiredVersions ...map[string]string) ([]pluginFile, []pluginFile, error) {
root = strings.TrimSpace(root)
if root == "" {
root = "plugins"
}
desired := normalizeDesiredPluginVersions(desiredVersions...)
candidates := candidateDirs(root, runtime.GOOS, runtime.GOARCH)
extension := pluginExtension(runtime.GOOS)
selectedByID := make(map[string]pluginFile)
order := make([]string, 0)
all := make([]pluginFile, 0)
for _, dir := range candidates {
entries, errReadDir := os.ReadDir(dir)
if errReadDir != nil {
if os.IsNotExist(errReadDir) {
continue
}
return nil, nil, errReadDir
}
files := make([]string, 0, len(entries))
for _, entry := range entries {
if entry == nil || !entry.Type().IsRegular() {
continue
}
if strings.HasSuffix(strings.ToLower(entry.Name()), extension) {
files = append(files, filepath.Join(dir, entry.Name()))
}
}
sort.Strings(files)
for _, path := range files {
file, okFile := pluginFileFromPath(path, extension)
if !okFile {
continue
}
all = append(all, file)
current, exists := selectedByID[file.ID]
if !exists {
selectedByID[file.ID] = file
order = append(order, file.ID)
continue
}
if pluginFilePreferredForDesired(file, current, desired[file.ID]) {
selectedByID[file.ID] = file
}
}
}
selected := make([]pluginFile, 0, len(order))
for _, id := range order {
file := selectedByID[id]
if desiredVersion := desired[id]; desiredVersion != "" && file.Version != desiredVersion {
continue
}
selected = append(selected, file)
}
return selected, all, nil
}
func normalizeDesiredPluginVersions(sources ...map[string]string) map[string]string {
out := make(map[string]string)
for _, source := range sources {
for id, version := range source {
id = strings.TrimSpace(id)
version = normalizePluginDesiredVersion(version)
if id == "" || version == "" {
continue
}
out[id] = version
}
}
return out
}
func pluginFilePreferredForDesired(candidate pluginFile, current pluginFile, desiredVersion string) bool {
desiredVersion = normalizePluginDesiredVersion(desiredVersion)
if desiredVersion != "" {
candidateMatches := candidate.Version == desiredVersion
currentMatches := current.Version == desiredVersion
if candidateMatches != currentMatches {
return candidateMatches
}
}
return pluginFilePreferred(candidate, current)
}
func pluginFilePreferred(candidate pluginFile, current pluginFile) bool {
if candidate.Version == "" {
return false
}
if current.Version == "" {
return true
}
comparison, comparable := comparePluginVersions(candidate.Version, current.Version)
if !comparable {
return candidate.Version > current.Version
}
return comparison > 0
}
func comparePluginVersions(a, b string) (int, bool) {
segmentsA := strings.Split(a, ".")
segmentsB := strings.Split(b, ".")
length := len(segmentsA)
if len(segmentsB) > length {
length = len(segmentsB)
}
for index := 0; index < length; index++ {
numberA, okA := pluginVersionSegment(segmentsA, index)
numberB, okB := pluginVersionSegment(segmentsB, index)
if !okA || !okB {
return 0, false
}
if numberA != numberB {
if numberA < numberB {
return -1, true
}
return 1, true
}
}
return 0, true
}
func pluginVersionSegment(segments []string, index int) (int64, bool) {
if index >= len(segments) {
return 0, true
}
number, errParse := strconv.ParseInt(segments[index], 10, 64)
if errParse != nil || number < 0 {
return 0, false
}
return number, true
}
func cleanupUnselectedPluginFiles(root string, loaded []pluginFile) error {
if len(loaded) == 0 {
return nil
}
_, candidates, errSelect := selectPluginFilesWithCandidates(root)
if errSelect != nil {
return errSelect
}
loadedByID := make(map[string]map[string]struct{}, len(loaded))
for _, file := range loaded {
if strings.TrimSpace(file.ID) == "" || strings.TrimSpace(file.Path) == "" {
continue
}
paths := loadedByID[file.ID]
if paths == nil {
paths = make(map[string]struct{})
loadedByID[file.ID] = paths
}
paths[filepath.Clean(file.Path)] = struct{}{}
}
var errs []error
for _, candidate := range candidates {
paths := loadedByID[candidate.ID]
if len(paths) == 0 {
continue
}
if _, selected := paths[filepath.Clean(candidate.Path)]; selected {
continue
}
if errRemove := os.Remove(candidate.Path); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) {
errs = append(errs, errRemove)
log.WithError(errRemove).Warnf("pluginhost: failed to remove old plugin file %s", candidate.Path)
continue
}
log.WithFields(pluginLogFields(candidate.ID, "", candidate.Version, candidate.Path)).Info("pluginhost: old plugin file removed")
}
return errors.Join(errs...)
}
// DiscoverPluginFiles returns plugin binaries selected by the current host discovery rules.
func DiscoverPluginFiles(root string, desiredVersions ...map[string]string) ([]PluginFileInfo, error) {
files, errSelect := selectPluginFiles(root, desiredVersions...)
if errSelect != nil {
return nil, errSelect
}
out := make([]PluginFileInfo, 0, len(files))
for _, file := range files {
out = append(out, PluginFileInfo{
ID: file.ID,
Path: file.Path,
Version: file.Version,
})
}
return out, nil
}
func candidateDirs(root, goos, goarch string) []string {
dirs := make([]string, 0, 2)
dirs = append(dirs, filepath.Join(root, goos, goarch))
dirs = append(dirs, root)
return dirs
}