mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-24 15:47:44 +08:00
278 lines
8.7 KiB
Go
278 lines
8.7 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. Loaded libraries cannot be overwritten on
|
|
// Windows, so installs targeting Windows are rejected while it returns true.
|
|
PluginLoaded func() bool
|
|
}
|
|
|
|
// 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) {
|
|
return InstallResult{}, ErrLoadedPluginLocked
|
|
}
|
|
release, errRelease := c.FetchRelease(ctx, plugin)
|
|
if errRelease != nil {
|
|
return InstallResult{}, errRelease
|
|
}
|
|
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 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 {
|
|
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
|
|
}
|