Files
LTbinglingfeng 40f4b8b856 feat(pluginstore): fetch and install plugins from latest release
Replace the tag-pinned release lookup with the repository latest
release endpoint. Derive the plugin version from the release tag,
validate it, and attach an optional token to API requests to raise
the rate limit.
2026-06-13 04:00:05 +08:00

302 lines
9.6 KiB
Go

package pluginstore
import (
"archive/zip"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"runtime"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
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 while it returns
// true unless BeforeWrite can unload the plugin before replacement.
PluginLoaded func() bool
// BeforeWrite runs after the archive has been downloaded and verified, but
// before the 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"`
Path string `json:"path"`
Overwritten bool `json:"overwritten"`
}
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 loadedPluginInstallBlocked(options) && options.BeforeWrite == nil {
return InstallResult{}, ErrLoadedPluginLocked
}
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
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
}
return InstallArchive(archiveData, plugin, options)
}
func InstallArchive(archiveData []byte, plugin Plugin, options InstallOptions) (InstallResult, error) {
options = normalizeInstallOptions(options)
id := strings.TrimSpace(plugin.ID)
if !pluginhost.ValidatePluginID(id) {
return InstallResult{}, fmt.Errorf("invalid plugin id %q", plugin.ID)
}
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, options.GOOS)
if errLibrary != nil {
return InstallResult{}, errLibrary
}
targetPath, errTarget := installTargetPath(options, id)
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)
}
// Re-check immediately before writing: the plugin may have been loaded
// while the archive was being downloaded and verified.
if options.BeforeWrite != nil {
if errBeforeWrite := options.BeforeWrite(); errBeforeWrite != nil {
return InstallResult{}, fmt.Errorf("prepare plugin write: %w", errBeforeWrite)
}
}
if 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) (string, error) {
defaultPath := filepath.Join(options.PluginsDir, options.GOOS, options.GOARCH, id+pluginhost.PluginExtension(options.GOOS))
if options.GOOS != runtime.GOOS || options.GOARCH != runtime.GOARCH {
return defaultPath, nil
}
files, errDiscover := pluginhost.DiscoverPluginFiles(options.PluginsDir)
if errDiscover != nil {
return "", fmt.Errorf("discover current plugin files: %w", errDiscover)
}
for _, file := range files {
if file.ID == id && strings.TrimSpace(file.Path) != "" {
return file.Path, nil
}
}
return defaultPath, nil
}
func readTargetLibrary(reader *zip.Reader, id string, goos string) ([]byte, os.FileMode, error) {
targetName := strings.TrimSpace(id) + pluginhost.PluginExtension(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 {
if path.Base(cleanedName) == targetName {
return nil, 0, fmt.Errorf("target dynamic library must be at zip root")
}
return nil, 0, fmt.Errorf("dynamic library filename must be %s", targetName)
}
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 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")
}
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
}
return options
}