package pluginstore import ( "archive/zip" "bytes" "context" "errors" "fmt" "io" "os" "path" "path/filepath" "runtime" "sort" "strings" log "github.com/sirupsen/logrus" ) type InstallOptions struct { PluginsDir string GOOS string GOARCH string // PluginLoaded reports whether the plugin's dynamic library is currently // loaded by the running host. Windows installs are rejected only when they // would overwrite an existing target file while it returns true. PluginLoaded func() bool // BeforeWrite runs after the archive has been downloaded and verified, but // before an existing target plugin file is replaced. BeforeWrite func() error } // ErrLoadedPluginLocked is returned when an install would overwrite a plugin // library that is loaded by the running process on Windows. var ErrLoadedPluginLocked = errors.New("loaded plugin library cannot be overwritten while the server is running") type InstallResult struct { ID string `json:"id"` Version string `json:"version"` ReleaseTag string `json:"release_tag,omitempty"` InstallType string `json:"install_type,omitempty"` Path string `json:"path"` Overwritten bool `json:"overwritten"` Skipped bool `json:"skipped"` } func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptions) (InstallResult, error) { if errValidate := ValidatePlugin(plugin); errValidate != nil { return InstallResult{}, errValidate } options = normalizeInstallOptions(options) if PluginInstallType(plugin) == InstallTypeDirect { plugin.Version = normalizeVersion(plugin.Version) return c.InstallDirect(ctx, plugin, plugin.Install, options) } release, errRelease := c.FetchLatestRelease(ctx, plugin) if errRelease != nil { return InstallResult{}, errRelease } latestVersion, errVersion := ReleaseVersion(release) if errVersion != nil { return InstallResult{}, errVersion } plugin.Version = latestVersion return c.installRelease(ctx, plugin, release, latestVersion, options) } func (c Client) InstallManifest(ctx context.Context, manifest Manifest, options InstallOptions) (InstallResult, error) { if errValidate := manifest.Validate(); errValidate != nil { return InstallResult{}, errValidate } options = normalizeInstallOptions(options) switch manifest.InstallType() { case InstallTypeDirect: plugin, errPlugin := c.directPluginFromManifest(ctx, manifest) if errPlugin != nil { return InstallResult{}, errPlugin } return c.InstallDirect(ctx, plugin, plugin.Install, options) case InstallTypeGitHubRelease: return c.InstallVersion(ctx, manifest.Plugin(), manifest.ReleaseTag, manifest.Version, options) default: return InstallResult{}, fmt.Errorf("unsupported install type %q", manifest.Install.Type) } } // InstallVersion installs a plugin artifact from a fixed release tag/version. func (c Client) InstallVersion(ctx context.Context, plugin Plugin, releaseTag string, version string, options InstallOptions) (InstallResult, error) { if errValidate := ValidatePlugin(plugin); errValidate != nil { return InstallResult{}, errValidate } options = normalizeInstallOptions(options) version = normalizeVersion(version) if !validPluginVersion(version) { return InstallResult{}, fmt.Errorf("invalid plugin version %q", version) } releaseTag = strings.TrimSpace(releaseTag) if releaseTag == "" { releaseTag = version } release, errRelease := c.FetchReleaseByTag(ctx, plugin, releaseTag) if errRelease != nil { return InstallResult{}, errRelease } releaseVersion, errVersion := ReleaseVersion(release) if errVersion != nil { return InstallResult{}, errVersion } if releaseVersion != version { return InstallResult{}, fmt.Errorf("release tag %q resolved version %q, want %q", releaseTag, releaseVersion, version) } plugin.Version = version return c.installRelease(ctx, plugin, release, version, options) } func (c Client) installRelease(ctx context.Context, plugin Plugin, release Release, version string, options InstallOptions) (InstallResult, error) { archiveAsset, checksumAsset, errAssets := SelectReleaseAssets(release, plugin.ID, plugin.Version, options.GOOS, options.GOARCH) if errAssets != nil { return InstallResult{}, errAssets } archiveData, errArchive := c.DownloadAsset(ctx, archiveAsset) if errArchive != nil { return InstallResult{}, fmt.Errorf("download %s: %w", archiveAsset.Name, errArchive) } checksumData, errChecksum := c.DownloadAsset(ctx, checksumAsset) if errChecksum != nil { return InstallResult{}, fmt.Errorf("download checksums.txt: %w", errChecksum) } checksums, errParse := ParseChecksums(checksumData) if errParse != nil { return InstallResult{}, errParse } if errVerify := VerifyChecksum(archiveAsset.Name, archiveData, checksums); errVerify != nil { return InstallResult{}, errVerify } plugin.Version = version result, errInstall := InstallArchive(archiveData, plugin, options) if errInstall != nil { return InstallResult{}, errInstall } result.InstallType = InstallTypeGitHubRelease result.ReleaseTag = strings.TrimSpace(release.TagName) return result, nil } func (c Client) InstallDirect(ctx context.Context, plugin Plugin, plan InstallPlan, options InstallOptions) (InstallResult, error) { plugin.ID = strings.TrimSpace(plugin.ID) plugin.Version = normalizeVersion(plugin.Version) if !validPluginID(plugin.ID) { return InstallResult{}, fmt.Errorf("invalid plugin id %q", plugin.ID) } if !validPluginVersion(plugin.Version) { return InstallResult{}, fmt.Errorf("invalid plugin version %q", plugin.Version) } plan = NormalizeInstallPlan(plan) plan.Type = InstallTypeDirect if errValidate := ValidateInstallPlan(plan); errValidate != nil { return InstallResult{}, errValidate } options = normalizeInstallOptions(options) artifact, errSelect := SelectArtifact(plan, options.GOOS, options.GOARCH) if errSelect != nil { return InstallResult{}, errSelect } archiveData, errDownload := c.DownloadArtifact(ctx, artifact) if errDownload != nil { return InstallResult{}, fmt.Errorf("download artifact: %w", errDownload) } if errVerify := VerifyArtifactChecksum(artifact, archiveData); errVerify != nil { return InstallResult{}, errVerify } result, errInstall := InstallArchive(archiveData, plugin, options) if errInstall != nil { return InstallResult{}, errInstall } result.InstallType = InstallTypeDirect return result, nil } func (c Client) directPluginFromManifest(ctx context.Context, manifest Manifest) (Plugin, error) { plugin := manifest.Plugin() plugin.Version = normalizeVersion(manifest.Version) plugin.Install = NormalizeInstallPlan(plugin.Install) plugin.Install.Type = InstallTypeDirect if len(plugin.Install.Artifacts) > 0 { return plugin, nil } sourceURL := strings.TrimSpace(manifest.SourceURL) if sourceURL == "" { sourceURL = strings.TrimSpace(c.RegistryURL) } if sourceURL == "" { return Plugin{}, fmt.Errorf("direct install manifest missing source-url") } sourceClient := c sourceClient.RegistryURL = sourceURL registry, errRegistry := sourceClient.FetchRegistry(ctx) if errRegistry != nil { return Plugin{}, fmt.Errorf("fetch direct install source: %w", errRegistry) } resolved, okPlugin := registry.PluginByID(manifest.ID) if !okPlugin { return Plugin{}, fmt.Errorf("direct install plugin %q not found in source", strings.TrimSpace(manifest.ID)) } if PluginInstallType(resolved) != InstallTypeDirect { return Plugin{}, fmt.Errorf("direct install plugin %q resolved as %q", strings.TrimSpace(manifest.ID), PluginInstallType(resolved)) } return directPluginVersion(resolved, manifest.ID, manifest.Version) } func directPluginVersion(plugin Plugin, id string, version string) (Plugin, error) { id = strings.TrimSpace(id) version = normalizeVersion(version) if normalizeVersion(plugin.Version) == version { plugin.Version = version plugin.Install = NormalizeInstallPlan(plugin.Install) plugin.Install.Type = InstallTypeDirect if errPlan := ValidateInstallPlan(plugin.Install); errPlan != nil { return Plugin{}, fmt.Errorf("direct install plugin %q version %q: %w", id, version, errPlan) } return plugin, nil } for _, candidate := range plugin.Versions { if normalizeVersion(candidate.Version) != version { continue } plugin.Version = version plugin.Install = NormalizeInstallPlan(candidate.Install) if plugin.Install.Type == "" { plugin.Install.Type = InstallTypeDirect } if plugin.Install.Type != InstallTypeDirect { return Plugin{}, fmt.Errorf("direct install plugin %q version %q resolved as %q", id, version, plugin.Install.Type) } if errPlan := ValidateInstallPlan(plugin.Install); errPlan != nil { return Plugin{}, fmt.Errorf("direct install plugin %q version %q: %w", id, version, errPlan) } return plugin, nil } return Plugin{}, fmt.Errorf("direct install plugin %q version %q not found in source", id, version) } func InstallArchive(archiveData []byte, plugin Plugin, options InstallOptions) (InstallResult, error) { options = normalizeInstallOptions(options) id := strings.TrimSpace(plugin.ID) if !validPluginID(id) { return InstallResult{}, fmt.Errorf("invalid plugin id %q", plugin.ID) } version := normalizeVersion(plugin.Version) if !validPluginVersion(version) { return InstallResult{}, fmt.Errorf("invalid plugin version %q", plugin.Version) } plugin.Version = version reader, errZip := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData))) if errZip != nil { return InstallResult{}, fmt.Errorf("open zip: %w", errZip) } libraryData, mode, errLibrary := readTargetLibrary(reader, id, version, options.GOOS) if errLibrary != nil { return InstallResult{}, errLibrary } targetPath, errTarget := installTargetPath(options, id, version) if errTarget != nil { return InstallResult{}, errTarget } overwritten := false if _, errStat := os.Stat(targetPath); errStat == nil { overwritten = true } else if !errors.Is(errStat, os.ErrNotExist) { return InstallResult{}, fmt.Errorf("stat target plugin: %w", errStat) } if overwritten { existingData, errReadExisting := os.ReadFile(targetPath) if errReadExisting != nil { return InstallResult{}, fmt.Errorf("read target plugin: %w", errReadExisting) } if bytes.Equal(existingData, libraryData) { return InstallResult{ ID: id, Version: strings.TrimSpace(plugin.Version), Path: targetPath, Overwritten: true, Skipped: true, }, nil } } // Re-check immediately before replacing an existing file: the same version // may have been loaded while the archive was being downloaded and verified. if overwritten && options.BeforeWrite != nil { if errBeforeWrite := options.BeforeWrite(); errBeforeWrite != nil { return InstallResult{}, fmt.Errorf("prepare plugin write: %w", errBeforeWrite) } } if overwritten && loadedPluginInstallBlocked(options) { return InstallResult{}, ErrLoadedPluginLocked } if errWrite := writeFileAtomic(targetPath, libraryData, mode); errWrite != nil { return InstallResult{}, errWrite } return InstallResult{ ID: id, Version: strings.TrimSpace(plugin.Version), Path: targetPath, Overwritten: overwritten, }, nil } func installTargetPath(options InstallOptions, id string, version string) (string, error) { version = normalizeVersion(version) if !validPluginVersion(version) { return "", fmt.Errorf("invalid plugin version %q", version) } return filepath.Join(options.PluginsDir, options.GOOS, options.GOARCH, versionedPluginFileName(id, version, options.GOOS)), nil } func readTargetLibrary(reader *zip.Reader, id string, version string, goos string) ([]byte, os.FileMode, error) { targetName := strings.TrimSpace(id) + pluginExtension(goos) versionedTargetName := versionedPluginFileName(id, version, goos) var target *zip.File for _, file := range reader.File { cleanedName, errClean := cleanZipName(file.Name) if errClean != nil { return nil, 0, errClean } if file.FileInfo().IsDir() { continue } if !regularZipFile(file) { return nil, 0, fmt.Errorf("zip entry %s is not a regular file", file.Name) } if !hasDynamicLibraryExtension(cleanedName) { continue } if cleanedName != targetName && cleanedName != versionedTargetName { if path.Base(cleanedName) == targetName || path.Base(cleanedName) == versionedTargetName { return nil, 0, fmt.Errorf("target dynamic library must be at zip root") } return nil, 0, fmt.Errorf("dynamic library filename must be %s or %s", targetName, versionedTargetName) } if target != nil { return nil, 0, fmt.Errorf("zip contains multiple target dynamic libraries") } target = file } if target == nil { return nil, 0, fmt.Errorf("zip does not contain %s", targetName) } handle, errOpen := target.Open() if errOpen != nil { return nil, 0, fmt.Errorf("open %s: %w", targetName, errOpen) } defer func() { if errClose := handle.Close(); errClose != nil { log.WithError(errClose).Debug("failed to close plugin archive entry") } }() data, errRead := io.ReadAll(handle) if errRead != nil { return nil, 0, fmt.Errorf("read %s: %w", targetName, errRead) } mode := target.FileInfo().Mode().Perm() if mode == 0 { mode = 0o755 } return data, mode, nil } func versionedPluginFileName(id string, version string, goos string) string { return strings.TrimSpace(id) + "-v" + normalizeVersion(version) + pluginExtension(goos) } func cleanZipName(name string) (string, error) { if strings.TrimSpace(name) == "" { return "", fmt.Errorf("zip entry has empty name") } if strings.Contains(name, `\`) { return "", fmt.Errorf("zip entry %s uses backslash path separators", name) } if path.IsAbs(name) { return "", fmt.Errorf("zip entry %s is absolute", name) } cleaned := path.Clean(name) if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { return "", fmt.Errorf("zip entry %s escapes archive root", name) } return cleaned, nil } func regularZipFile(file *zip.File) bool { mode := file.FileInfo().Mode() return mode.IsRegular() || mode.Type() == 0 } func hasDynamicLibraryExtension(name string) bool { lowerName := strings.ToLower(name) return strings.HasSuffix(lowerName, ".dylib") || strings.HasSuffix(lowerName, ".so") || strings.HasSuffix(lowerName, ".dll") } type pluginFileInfo struct { ID string Path string Version string } func discoverCurrentPluginFiles(root string) ([]pluginFileInfo, error) { root = strings.TrimSpace(root) if root == "" { root = "plugins" } candidates := pluginCandidateDirs(root, runtime.GOOS, runtime.GOARCH) extension := pluginExtension(runtime.GOOS) selected := make([]pluginFileInfo, 0) seen := make(map[string]struct{}) for _, dir := range candidates { entries, errReadDir := os.ReadDir(dir) if errReadDir != nil { if os.IsNotExist(errReadDir) { continue } return 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 := pluginFileInfoFromPath(path, extension) if !okFile { continue } if _, exists := seen[file.ID]; exists { continue } seen[file.ID] = struct{}{} selected = append(selected, file) } } return selected, nil } func pluginCandidateDirs(root string, goos string, goarch string) []string { dirs := make([]string, 0, 2) dirs = append(dirs, filepath.Join(root, goos, goarch)) dirs = append(dirs, root) return dirs } func pluginIDFromPath(path string) string { file, ok := pluginFileInfoFromPath(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 pluginFileInfoFromPath(filePath string, requiredExtension string) (pluginFileInfo, bool) { base := filepath.Base(filePath) lowerBase := strings.ToLower(base) extension := strings.TrimSpace(requiredExtension) if extension != "" { if !strings.HasSuffix(lowerBase, strings.ToLower(extension)) { return pluginFileInfo{}, false } } else { for _, candidateExtension := range []string{".so", ".dylib", ".dll"} { if strings.HasSuffix(lowerBase, candidateExtension) { extension = candidateExtension break } } if extension == "" { return pluginFileInfo{}, 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 pluginFileInfo{}, false } return pluginFileInfo{ID: id, Path: filePath, Version: version}, true } func pluginExtension(goos string) string { switch strings.ToLower(strings.TrimSpace(goos)) { case "darwin", "mac", "macos", "osx": return ".dylib" case "windows": return ".dll" default: return ".so" } } func writeFileAtomic(targetPath string, data []byte, mode os.FileMode) error { targetDir := filepath.Dir(targetPath) if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { return fmt.Errorf("create plugin directory: %w", errMkdir) } temp, errTemp := os.CreateTemp(targetDir, "."+filepath.Base(targetPath)+".tmp-*") if errTemp != nil { return fmt.Errorf("create temp plugin file: %w", errTemp) } tempPath := temp.Name() removeTemp := true closed := false defer func() { if !closed { if errClose := temp.Close(); errClose != nil { log.WithError(errClose).Debug("failed to close temp plugin file") } } if removeTemp { if errRemove := os.Remove(tempPath); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) { log.WithError(errRemove).Debug("failed to remove temp plugin file") } } }() if errChmod := temp.Chmod(mode); errChmod != nil { return fmt.Errorf("chmod temp plugin file: %w", errChmod) } if _, errWrite := temp.Write(data); errWrite != nil { return fmt.Errorf("write temp plugin file: %w", errWrite) } if errSync := temp.Sync(); errSync != nil { return fmt.Errorf("sync temp plugin file: %w", errSync) } if errClose := temp.Close(); errClose != nil { return fmt.Errorf("close temp plugin file: %w", errClose) } closed = true if errRename := os.Rename(tempPath, targetPath); errRename != nil { if runtime.GOOS == "windows" { if errRemove := os.Remove(targetPath); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) { return fmt.Errorf("remove old plugin file: %w", errRemove) } if errRenameRetry := os.Rename(tempPath, targetPath); errRenameRetry == nil { removeTemp = false return nil } else { return fmt.Errorf("install plugin file: %w", errRenameRetry) } } return fmt.Errorf("install plugin file: %w", errRename) } removeTemp = false return nil } func loadedPluginInstallBlocked(options InstallOptions) bool { return options.PluginLoaded != nil && strings.EqualFold(options.GOOS, "windows") && options.PluginLoaded() } func normalizeInstallOptions(options InstallOptions) InstallOptions { options.PluginsDir = strings.TrimSpace(options.PluginsDir) if options.PluginsDir == "" { options.PluginsDir = "plugins" } options.GOOS = strings.TrimSpace(options.GOOS) if options.GOOS == "" { options.GOOS = runtime.GOOS } options.GOARCH = strings.TrimSpace(options.GOARCH) if options.GOARCH == "" { options.GOARCH = runtime.GOARCH } options.GOOS = normalizeGOOS(options.GOOS) options.GOARCH = normalizeGOARCH(options.GOARCH) return options }