From 1f16e87e1a741814debd7aa799086310ebdbc976 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:19:34 +0800 Subject: [PATCH] feat(pluginstore): introduce support for direct install type and version management - Added Manifest struct to encapsulate plugin metadata and installation details. - Implemented ManifestFromRelease and ManifestFromPlugin functions for creating manifests from releases and plugins. - Enhanced Plugin struct to include Versions and InstallPlan for direct installations. - Introduced validation for direct install type, ensuring artifacts are correctly specified. - Updated registry validation to support new schema version and direct install requirements. - Added tests for parsing and validating direct install plugins, ensuring correct artifact handling. --- config.example.yaml | 7 + examples/plugin/simple/README.md | 3 +- examples/plugin/simple/README_CN.md | 1 - .../api/handlers/management/plugin_store.go | 408 ++++++++++++-- .../handlers/management/plugin_store_test.go | 496 +++++++++++++++++- internal/api/handlers/management/plugins.go | 14 +- .../api/handlers/management/plugins_test.go | 103 ++++ internal/config/config.go | 4 + internal/config/plugin_config_test.go | 27 + internal/homeplugins/sync.go | 47 +- internal/pluginhost/platform.go | 30 +- internal/pluginhost/platform_test.go | 54 +- internal/pluginstore/auth.go | 235 +++++++++ internal/pluginstore/auth_test.go | 142 +++++ internal/pluginstore/direct.go | 56 ++ internal/pluginstore/github.go | 137 ++++- internal/pluginstore/install.go | 158 +++++- internal/pluginstore/install_test.go | 267 +++++++++- internal/pluginstore/manifest.go | 174 ++++++ internal/pluginstore/registry.go | 260 ++++++++- internal/pluginstore/registry_test.go | 123 ++++- sdk/pluginstore/pluginstore.go | 131 ++--- sdk/pluginstore/pluginstore_test.go | 72 +++ 23 files changed, 2633 insertions(+), 316 deletions(-) create mode 100644 internal/pluginstore/auth.go create mode 100644 internal/pluginstore/auth_test.go create mode 100644 internal/pluginstore/direct.go create mode 100644 internal/pluginstore/manifest.go diff --git a/config.example.yaml b/config.example.yaml index d4708f8b..f8d6ed83 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -64,6 +64,13 @@ plugins: # Additional plugin store registries. The built-in official registry is always included. # store-sources: # - "https://example.com/cliproxy-plugins/registry.json" + # Optional plugin store auth rules. Values are read from environment variables; + # tokens are not written into plugin manifests or node status. + # store-auth: + # - match: "https://example.com/cliproxy-plugins/" + # apply-to: ["registry", "artifact"] + # type: bearer + # token-env: "CLIPROXY_PLUGIN_STORE_TOKEN" configs: example: enabled: true diff --git a/examples/plugin/simple/README.md b/examples/plugin/simple/README.md index 8134353d..87f1b19a 100644 --- a/examples/plugin/simple/README.md +++ b/examples/plugin/simple/README.md @@ -87,7 +87,7 @@ All three implementations parse incoming JSON requests for the methods where req Build from the repository root. -Build all plugin examples, including all three `simple` variants: +Build all plugin examples: ```bash make -C examples/plugin build @@ -129,7 +129,6 @@ The plugin ID is the dynamic library basename without the platform extension. Ma The host searches: ```text -plugins//- plugins// plugins ``` diff --git a/examples/plugin/simple/README_CN.md b/examples/plugin/simple/README_CN.md index 3bee16dc..95c1710a 100644 --- a/examples/plugin/simple/README_CN.md +++ b/examples/plugin/simple/README_CN.md @@ -127,7 +127,6 @@ Linux、FreeBSD 或 Windows 使用相同源码目录,平台扩展名以 `examp 宿主搜索: ```text -plugins//- plugins// plugins ``` diff --git a/internal/api/handlers/management/plugin_store.go b/internal/api/handlers/management/plugin_store.go index d83eb814..e9500cfa 100644 --- a/internal/api/handlers/management/plugin_store.go +++ b/internal/api/handlers/management/plugin_store.go @@ -2,9 +2,12 @@ package management import ( "context" + "encoding/json" "errors" "fmt" + "io" "net/http" + "net/url" "runtime" "strings" "sync" @@ -18,6 +21,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/util" sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" ) const ( @@ -56,28 +60,37 @@ type pluginStoreSourceErr struct { } type pluginStoreListEntry struct { - StoreID string `json:"store_id"` - SourceID string `json:"source_id"` - SourceName string `json:"source_name"` - SourceURL string `json:"source_url"` - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Author string `json:"author"` - Version string `json:"version"` - Repository string `json:"repository"` - Logo string `json:"logo,omitempty"` - Homepage string `json:"homepage,omitempty"` - License string `json:"license,omitempty"` - Tags []string `json:"tags,omitempty"` - Installed bool `json:"installed"` - InstalledVersion string `json:"installed_version"` - Path string `json:"path"` - Configured bool `json:"configured"` - Registered bool `json:"registered"` - Enabled bool `json:"enabled"` - EffectiveEnabled bool `json:"effective_enabled"` - UpdateAvailable bool `json:"update_available"` + StoreID string `json:"store_id"` + SourceID string `json:"source_id"` + SourceName string `json:"source_name"` + SourceURL string `json:"source_url"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Author string `json:"author"` + Version string `json:"version"` + Repository string `json:"repository"` + InstallType string `json:"install_type"` + AuthRequired bool `json:"auth_required"` + AuthConfigured bool `json:"auth_configured"` + Platforms []pluginStorePlatform `json:"platforms,omitempty"` + Logo string `json:"logo,omitempty"` + Homepage string `json:"homepage,omitempty"` + License string `json:"license,omitempty"` + Tags []string `json:"tags,omitempty"` + Installed bool `json:"installed"` + InstalledVersion string `json:"installed_version"` + Path string `json:"path"` + Configured bool `json:"configured"` + Registered bool `json:"registered"` + Enabled bool `json:"enabled"` + EffectiveEnabled bool `json:"effective_enabled"` + UpdateAvailable bool `json:"update_available"` +} + +type pluginStorePlatform struct { + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` } type pluginInstallResponse struct { @@ -87,11 +100,16 @@ type pluginInstallResponse struct { SourceURL string `json:"source_url"` ID string `json:"id"` Version string `json:"version"` + InstallType string `json:"install_type"` Path string `json:"path"` PluginsEnabled bool `json:"plugins_enabled"` RestartRequired bool `json:"restart_required"` } +type pluginInstallRequest struct { + Version string `json:"version"` +} + type pluginLocalStatus struct { Installed bool InstalledVersion string @@ -108,13 +126,13 @@ type sourcedPlugin struct { } func (h *Handler) ListPluginStore(c *gin.Context) { - pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, configs, host := h.pluginStoreSnapshot() + pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, configs, host := h.pluginStoreSnapshot() sources, errSources := h.pluginStoreSources(sourceConfigs) if errSources != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_store_source_invalid", "message": errSources.Error()}) return } - plugins, sourceErrors := h.fetchSourcedPlugins(c.Request.Context(), proxyURL, sources) + plugins, sourceErrors := h.fetchSourcedPlugins(c.Request.Context(), proxyURL, storeAuth, sources) if len(plugins) == 0 && len(sourceErrors) > 0 { c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": sourceErrors[0].Message}) return @@ -129,7 +147,7 @@ func (h *Handler) ListPluginStore(c *gin.Context) { for _, item := range plugins { latestInput = append(latestInput, item.plugin) } - client := h.newPluginStoreClient(proxyURL, "") + client := h.newPluginStoreClient(proxyURL, "", storeAuth) latestVersions := h.latestPluginVersions(c.Request.Context(), client, latestInput) entries := make([]pluginStoreListEntry, 0, len(plugins)) @@ -153,6 +171,10 @@ func (h *Handler) ListPluginStore(c *gin.Context) { Author: htmlsanitize.String(plugin.Author), Version: htmlsanitize.String(storeVersion), Repository: htmlsanitize.String(plugin.Repository), + InstallType: htmlsanitize.String(pluginstore.PluginInstallType(plugin)), + AuthRequired: plugin.AuthRequired, + AuthConfigured: pluginAuthConfigured(item.source, plugin, storeAuth), + Platforms: sanitizePluginStorePlatforms(pluginstore.PluginPlatforms(plugin)), Logo: htmlsanitize.String(plugin.Logo), Homepage: htmlsanitize.String(plugin.Homepage), License: htmlsanitize.String(plugin.License), @@ -186,25 +208,47 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { if !okID { return } + requestedVersion, errVersionRequest := pluginInstallRequestedVersion(c) + if errVersionRequest != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request", "message": errVersionRequest.Error()}) + return + } installCtx := c.Request.Context() - pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, _, host := h.pluginStoreSnapshot() + pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, _, host := h.pluginStoreSnapshot() sources, errSources := h.pluginStoreSources(sourceConfigs) if errSources != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_store_source_invalid", "message": errSources.Error()}) return } - source, plugin, client, okPlugin := h.findPluginStoreInstallTarget(installCtx, proxyURL, sources, id, c.Query("source"), c) + source, plugin, client, okPlugin := h.findPluginStoreInstallTarget(installCtx, proxyURL, storeAuth, sources, id, c.Query("source"), c) if !okPlugin { return } - pluginIsBusy := func() bool { return pluginBusy(host, id) } - result, errInstall := client.Install(installCtx, plugin, pluginstore.InstallOptions{ + installOptions := pluginstore.InstallOptions{ PluginsDir: pluginsDir, GOOS: goos, GOARCH: goarch, PluginLoaded: pluginIsBusy, - }) + } + var manifest pluginstore.Manifest + var result pluginstore.InstallResult + var errInstall error + switch pluginstore.PluginInstallType(plugin) { + case pluginstore.InstallTypeDirect: + var errManifest error + manifest, errManifest = pluginStoreDirectManifest(source, plugin, requestedVersion) + if errManifest != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_manifest_invalid", "message": errManifest.Error()}) + return + } + result, errInstall = client.InstallManifest(installCtx, manifest, installOptions) + case pluginstore.InstallTypeGitHubRelease: + result, errInstall = installPluginStoreGitHubRelease(installCtx, client, plugin, requestedVersion, installOptions) + default: + c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_manifest_invalid", "message": fmt.Sprintf("unsupported install type %q", plugin.Install.Type)}) + return + } if errInstall != nil { if errors.Is(errInstall, pluginstore.ErrLoadedPluginLocked) { c.JSON(http.StatusConflict, gin.H{ @@ -217,6 +261,18 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_install_failed", "message": errInstall.Error()}) return } + if manifest.ID == "" { + var errManifest error + manifest, errManifest = pluginStoreManifestForInstall(source, plugin, result) + if errManifest != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "plugin_manifest_failed", + "message": fmt.Sprintf("plugin file installed at %s but creating store manifest failed: %s", result.Path, errManifest.Error()), + "path": result.Path, + }) + return + } + } restartRequired := false h.mu.Lock() @@ -229,7 +285,7 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { }) return } - if errEnable := h.enablePluginConfigLocked(id); errEnable != nil { + if errEnable := h.enablePluginConfigLocked(id, manifest); errEnable != nil { h.mu.Unlock() c.JSON(http.StatusInternalServerError, gin.H{ "error": "config_update_failed", @@ -252,12 +308,13 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { h.reloadConfigAfterManagementSaveAsync(c.Request.Context(), cfgSnapshot) log.WithFields(log.Fields{ - "plugin_id": result.ID, - "plugin_name": plugin.Name, - "source_id": source.ID, - "version": result.Version, - "path": result.Path, - "overwritten": result.Overwritten, + "plugin_id": result.ID, + "plugin_name": plugin.Name, + "source_id": source.ID, + "version": result.Version, + "install_type": result.InstallType, + "path": result.Path, + "overwritten": result.Overwritten, }).Info("pluginstore: plugin installed") c.JSON(http.StatusOK, pluginInstallResponse{ @@ -267,18 +324,130 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { SourceURL: htmlsanitize.String(source.URL), ID: htmlsanitize.String(result.ID), Version: htmlsanitize.String(result.Version), + InstallType: htmlsanitize.String(result.InstallType), Path: htmlsanitize.String(result.Path), PluginsEnabled: pluginsEnabled, RestartRequired: restartRequired, }) } -// enablePluginConfigLocked sets plugins.configs..enabled to true while preserving -// the rest of the plugin's raw configuration. Callers must hold h.mu. -func (h *Handler) enablePluginConfigLocked(id string) error { +func pluginStoreDirectManifest(source pluginstore.Source, plugin pluginstore.Plugin, requestedVersion string) (pluginstore.Manifest, error) { + version := normalizePluginStoreRequestedVersion(requestedVersion) + if version == "" { + version = normalizePluginStoreRequestedVersion(plugin.Version) + } + if normalizePluginStoreRequestedVersion(plugin.Version) == version { + plugin.Version = version + return pluginstore.ManifestFromPlugin(source, plugin) + } + for _, candidate := range plugin.Versions { + if normalizePluginStoreRequestedVersion(candidate.Version) != version { + continue + } + plugin.Version = version + plugin.Install = candidate.Install + if strings.TrimSpace(plugin.Install.Type) == "" { + plugin.Install.Type = pluginstore.InstallTypeDirect + } + return pluginstore.ManifestFromPlugin(source, plugin) + } + return pluginstore.Manifest{}, fmt.Errorf("direct plugin version %q not found", version) +} + +func installPluginStoreGitHubRelease(ctx context.Context, client pluginstore.Client, plugin pluginstore.Plugin, requestedVersion string, options pluginstore.InstallOptions) (pluginstore.InstallResult, error) { + version := normalizePluginStoreRequestedVersion(requestedVersion) + if version == "" { + return client.Install(ctx, plugin, options) + } + tags := pluginStoreReleaseTagCandidates(requestedVersion) + errs := make([]error, 0, len(tags)) + for _, tag := range tags { + result, errInstall := client.InstallVersion(ctx, plugin, tag, version, options) + if errInstall == nil { + return result, nil + } + errs = append(errs, fmt.Errorf("%s: %w", tag, errInstall)) + } + return pluginstore.InstallResult{}, fmt.Errorf("install release by tag: %w", errors.Join(errs...)) +} + +func pluginStoreManifestForInstall(source pluginstore.Source, plugin pluginstore.Plugin, result pluginstore.InstallResult) (pluginstore.Manifest, error) { + installType := strings.TrimSpace(result.InstallType) + if installType == "" { + installType = pluginstore.PluginInstallType(plugin) + } + switch installType { + case pluginstore.InstallTypeDirect: + plugin.Version = strings.TrimSpace(result.Version) + plugin.Install = pluginstore.NormalizeInstallPlan(plugin.Install) + return pluginstore.ManifestFromPlugin(source, plugin) + case pluginstore.InstallTypeGitHubRelease: + releaseTag := strings.TrimSpace(result.ReleaseTag) + if releaseTag == "" { + return pluginstore.Manifest{}, fmt.Errorf("release tag is required") + } + return pluginstore.ManifestFromRelease(source, plugin, pluginstore.Release{TagName: releaseTag}) + default: + return pluginstore.Manifest{}, fmt.Errorf("unsupported install type %q", result.InstallType) + } +} + +func pluginInstallRequestedVersion(c *gin.Context) (string, error) { + requestedVersion := strings.TrimSpace(c.Query("version")) + if c == nil || c.Request == nil || c.Request.Body == nil || c.Request.Body == http.NoBody { + return requestedVersion, nil + } + body, errRead := io.ReadAll(c.Request.Body) + if errRead != nil { + return "", fmt.Errorf("read install request: %w", errRead) + } + if strings.TrimSpace(string(body)) == "" { + return requestedVersion, nil + } + var req pluginInstallRequest + if errDecode := json.Unmarshal(body, &req); errDecode != nil { + return "", fmt.Errorf("decode install request: %w", errDecode) + } + bodyVersion := strings.TrimSpace(req.Version) + if requestedVersion == "" { + return bodyVersion, nil + } + if bodyVersion == "" || normalizePluginStoreRequestedVersion(bodyVersion) == normalizePluginStoreRequestedVersion(requestedVersion) { + return requestedVersion, nil + } + return "", fmt.Errorf("version query %q does not match request body version %q", requestedVersion, bodyVersion) +} + +func pluginStoreReleaseTagCandidates(version string) []string { + version = strings.TrimSpace(version) + if version == "" { + return nil + } + if strings.HasPrefix(strings.ToLower(version), "v") { + return []string{version, strings.TrimSpace(version[1:])} + } + return []string{version, "v" + version} +} + +func normalizePluginStoreRequestedVersion(version string) string { + version = strings.TrimSpace(version) + if strings.HasPrefix(strings.ToLower(version), "v") { + return strings.TrimSpace(version[1:]) + } + return version +} + +// enablePluginConfigLocked sets plugins.configs..enabled and store while +// preserving the rest of the plugin's raw configuration. Callers must hold h.mu. +func (h *Handler) enablePluginConfigLocked(id string, storeManifest pluginstore.Manifest) error { ensurePluginConfigMap(h.cfg) node := pluginConfigNode(h.cfg.Plugins.Configs[id]) + storeNode, errStoreNode := pluginStoreManifestYAMLNode(storeManifest) + if errStoreNode != nil { + return errStoreNode + } setYAMLMappingValue(node, "enabled", boolYAMLNode(true)) + setYAMLMappingValue(node, "store", storeNode) updated, errConfig := pluginInstanceConfigFromNode(node) if errConfig != nil { return fmt.Errorf("decode plugin config: %w", errConfig) @@ -287,24 +456,33 @@ func (h *Handler) enablePluginConfigLocked(id string) error { return nil } -func (h *Handler) pluginStoreSnapshot() (bool, string, string, []string, map[string]config.PluginInstanceConfig, *pluginhost.Host) { +func pluginStoreManifestYAMLNode(manifest pluginstore.Manifest) (*yaml.Node, error) { + var node yaml.Node + if errEncode := node.Encode(manifest); errEncode != nil { + return nil, fmt.Errorf("encode store manifest: %w", errEncode) + } + return &node, nil +} + +func (h *Handler) pluginStoreSnapshot() (bool, string, string, []string, []pluginstore.AuthConfig, map[string]config.PluginInstanceConfig, *pluginhost.Host) { if h == nil { - return false, "plugins", "", nil, map[string]config.PluginInstanceConfig{}, nil + return false, "plugins", "", nil, nil, map[string]config.PluginInstanceConfig{}, nil } h.mu.Lock() defer h.mu.Unlock() if h.cfg == nil { - return false, "plugins", "", nil, map[string]config.PluginInstanceConfig{}, nil + return false, "plugins", "", nil, nil, map[string]config.PluginInstanceConfig{}, nil } pluginsEnabled := h.cfg.Plugins.Enabled pluginsDir := normalizedPluginsDir(h.cfg.Plugins.Dir) proxyURL := strings.TrimSpace(h.cfg.ProxyURL) sourceConfigs := append([]string(nil), h.cfg.Plugins.StoreSources...) + storeAuth := append([]pluginstore.AuthConfig(nil), h.cfg.Plugins.StoreAuth...) configs := make(map[string]config.PluginInstanceConfig, len(h.cfg.Plugins.Configs)) for id, item := range h.cfg.Plugins.Configs { configs[id] = item } - return pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, configs, h.pluginHost + return pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, configs, h.pluginHost } func (h *Handler) pluginStoreSources(sourceConfigs []string) ([]pluginstore.Source, error) { @@ -316,7 +494,7 @@ func (h *Handler) pluginStoreSources(sourceConfigs []string) ([]pluginstore.Sour return pluginstore.NormalizeSources(sourceConfigs) } -func (h *Handler) newPluginStoreClient(proxyURL string, registryURL string) pluginstore.Client { +func (h *Handler) newPluginStoreClient(proxyURL string, registryURL string, storeAuth []pluginstore.AuthConfig) pluginstore.Client { registryURL = strings.TrimSpace(registryURL) var httpClient pluginstore.HTTPDoer if h != nil { @@ -326,20 +504,20 @@ func (h *Handler) newPluginStoreClient(proxyURL string, registryURL string) plug registryURL = pluginstore.DefaultRegistryURL } if httpClient != nil { - return pluginstore.Client{HTTPClient: httpClient, RegistryURL: registryURL} + return pluginstore.Client{HTTPClient: httpClient, RegistryURL: registryURL, Auth: storeAuth} } client := &http.Client{} if strings.TrimSpace(proxyURL) != "" { util.SetProxy(&sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(proxyURL)}, client) } - return pluginstore.Client{HTTPClient: client, RegistryURL: registryURL} + return pluginstore.Client{HTTPClient: client, RegistryURL: registryURL, Auth: storeAuth} } -func (h *Handler) fetchSourcedPlugins(ctx context.Context, proxyURL string, sources []pluginstore.Source) ([]sourcedPlugin, []pluginStoreSourceErr) { +func (h *Handler) fetchSourcedPlugins(ctx context.Context, proxyURL string, storeAuth []pluginstore.AuthConfig, sources []pluginstore.Source) ([]sourcedPlugin, []pluginStoreSourceErr) { plugins := make([]sourcedPlugin, 0) sourceErrors := make([]pluginStoreSourceErr, 0) for _, source := range sources { - client := h.newPluginStoreClient(proxyURL, source.URL) + client := h.newPluginStoreClient(proxyURL, source.URL, storeAuth) registry, errRegistry := client.FetchRegistry(ctx) if errRegistry != nil { sourceErrors = append(sourceErrors, pluginStoreSourceErr{ @@ -357,14 +535,14 @@ func (h *Handler) fetchSourcedPlugins(ctx context.Context, proxyURL string, sour return plugins, sourceErrors } -func (h *Handler) findPluginStoreInstallTarget(ctx context.Context, proxyURL string, sources []pluginstore.Source, id string, requestedSourceID string, c *gin.Context) (pluginstore.Source, pluginstore.Plugin, pluginstore.Client, bool) { +func (h *Handler) findPluginStoreInstallTarget(ctx context.Context, proxyURL string, storeAuth []pluginstore.AuthConfig, sources []pluginstore.Source, id string, requestedSourceID string, c *gin.Context) (pluginstore.Source, pluginstore.Plugin, pluginstore.Client, bool) { requestedSourceID = strings.TrimSpace(requestedSourceID) if requestedSourceID != "" { for _, source := range sources { if source.ID != requestedSourceID { continue } - client := h.newPluginStoreClient(proxyURL, source.URL) + client := h.newPluginStoreClient(proxyURL, source.URL, storeAuth) registry, errRegistry := client.FetchRegistry(ctx) if errRegistry != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": errRegistry.Error()}) @@ -381,7 +559,7 @@ func (h *Handler) findPluginStoreInstallTarget(ctx context.Context, proxyURL str return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false } - plugins, sourceErrors := h.fetchSourcedPlugins(ctx, proxyURL, sources) + plugins, sourceErrors := h.fetchSourcedPlugins(ctx, proxyURL, storeAuth, sources) matches := make([]sourcedPlugin, 0) for _, item := range plugins { if item.plugin.ID == id { @@ -405,7 +583,7 @@ func (h *Handler) findPluginStoreInstallTarget(ctx context.Context, proxyURL str return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false } match := matches[0] - return match.source, match.plugin, h.newPluginStoreClient(proxyURL, match.source.URL), true + return match.source, match.plugin, h.newPluginStoreClient(proxyURL, match.source.URL, storeAuth), true } func sourcedPluginSources(plugins []sourcedPlugin) []pluginstore.Source { @@ -444,6 +622,53 @@ func sanitizePluginStoreSourceErrors(sourceErrors []pluginStoreSourceErr) []plug return out } +func sanitizePluginStorePlatforms(platforms []pluginstore.Platform) []pluginStorePlatform { + if len(platforms) == 0 { + return nil + } + out := make([]pluginStorePlatform, 0, len(platforms)) + for _, platform := range platforms { + out = append(out, pluginStorePlatform{ + GOOS: htmlsanitize.String(platform.GOOS), + GOARCH: htmlsanitize.String(platform.GOARCH), + }) + } + return out +} + +func pluginAuthConfigured(source pluginstore.Source, plugin pluginstore.Plugin, storeAuth []pluginstore.AuthConfig) bool { + if pluginstore.AuthConfigured(storeAuth, source.URL, pluginstore.RequestKindRegistry) { + return true + } + switch pluginstore.PluginInstallType(plugin) { + case pluginstore.InstallTypeDirect: + for _, artifact := range pluginstore.PluginArtifacts(plugin) { + if pluginstore.AuthConfigured(storeAuth, artifact.URL, pluginstore.RequestKindArtifact) { + return true + } + } + case pluginstore.InstallTypeGitHubRelease: + return pluginGitHubReleaseAuthConfigured(plugin, storeAuth) + } + return false +} + +func pluginGitHubReleaseAuthConfigured(plugin pluginstore.Plugin, storeAuth []pluginstore.AuthConfig) bool { + owner, repo, errRepository := pluginstore.GitHubRepositoryParts(plugin.Repository) + if errRepository != nil { + return false + } + releasesURL := fmt.Sprintf( + "https://api.github.com/repos/%s/%s/releases/", + url.PathEscape(owner), + url.PathEscape(repo), + ) + latestURL := releasesURL + "latest" + tagsURL := releasesURL + "tags/" + return pluginstore.AuthConfigured(storeAuth, latestURL, pluginstore.RequestKindMetadata) || + pluginstore.AuthConfigured(storeAuth, tagsURL, pluginstore.RequestKindMetadata) +} + // latestPluginVersions resolves the latest release version of each registry // plugin concurrently, returning results positionally aligned with plugins. // Unresolved entries are left empty so callers can fall back gracefully. @@ -466,6 +691,9 @@ func (h *Handler) latestPluginVersions(ctx context.Context, client pluginstore.C // rate limit. Failed lookups are cached for a shorter interval and reported // as an empty version. func (h *Handler) latestPluginVersion(ctx context.Context, client pluginstore.Client, plugin pluginstore.Plugin) string { + if pluginstore.PluginInstallType(plugin) != pluginstore.InstallTypeGitHubRelease { + return "" + } repository := strings.TrimSpace(plugin.Repository) if repository == "" { return "" @@ -501,7 +729,7 @@ func (h *Handler) latestPluginVersion(ctx context.Context, client pluginstore.Cl func pluginLocalStatuses(pluginsEnabled bool, pluginsDir string, configs map[string]config.PluginInstanceConfig, host *pluginhost.Host) (map[string]pluginLocalStatus, error) { statuses := map[string]pluginLocalStatus{} - files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir) + files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir, pluginStoreDesiredVersions(configs)) if errDiscover != nil { return nil, errDiscover } @@ -509,6 +737,9 @@ func pluginLocalStatuses(pluginsEnabled bool, pluginsDir string, configs map[str status := statuses[file.ID] status.Installed = true status.Path = file.Path + if strings.TrimSpace(file.Version) != "" { + status.InstalledVersion = strings.TrimSpace(file.Version) + } status.Enabled = true statuses[file.ID] = status } @@ -537,6 +768,75 @@ func pluginLocalStatuses(pluginsEnabled bool, pluginsDir string, configs map[str return statuses, nil } +func pluginStoreDesiredVersions(configs map[string]config.PluginInstanceConfig) map[string]string { + if len(configs) == 0 { + return nil + } + out := make(map[string]string, len(configs)) + for id, item := range configs { + id = strings.TrimSpace(id) + version := pluginStoreDesiredVersion(item) + if id == "" || version == "" { + continue + } + out[id] = version + } + if len(out) == 0 { + return nil + } + return out +} + +func pluginStoreDesiredVersion(item config.PluginInstanceConfig) string { + storeNode := pluginStoreConfigNode(item) + if storeNode == nil { + return "" + } + if version := pluginStoreNormalizeDesiredVersion(pluginStoreYAMLScalar(yamlMappingValue(storeNode, "version"))); version != "" { + return version + } + return pluginStoreNormalizeDesiredVersion(pluginStoreYAMLScalar(yamlMappingValue(storeNode, "release-tag"))) +} + +func pluginStoreConfigNode(item config.PluginInstanceConfig) *yaml.Node { + if item.Raw.Kind != yaml.MappingNode { + return nil + } + return yamlMappingValue(&item.Raw, "store") +} + +func yamlMappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode := node.Content[i] + if keyNode == nil || keyNode.Value != key { + continue + } + return node.Content[i+1] + } + return nil +} + +func pluginStoreYAMLScalar(node *yaml.Node) string { + if node == nil || node.Kind != yaml.ScalarNode { + return "" + } + return strings.TrimSpace(node.Value) +} + +func pluginStoreNormalizeDesiredVersion(version string) string { + version = strings.TrimSpace(version) + if len(version) > 1 && (version[0] == 'v' || version[0] == 'V') { + version = version[1:] + } + if version == "" || version[0] < '0' || version[0] > '9' { + return "" + } + return version +} + func pluginBusy(host *pluginhost.Host, id string) bool { if host == nil { return false diff --git a/internal/api/handlers/management/plugin_store_test.go b/internal/api/handlers/management/plugin_store_test.go index c5037e15..833d68c6 100644 --- a/internal/api/handlers/management/plugin_store_test.go +++ b/internal/api/handlers/management/plugin_store_test.go @@ -80,6 +80,112 @@ func TestListPluginStoreMergesInstalledStatus(t *testing.T) { } } +func TestListPluginStoreUsesVersionFromInstalledFilename(t *testing.T) { + t.Parallel() + + pluginsDir := t.TempDir() + archDir := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH) + if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil { + t.Fatalf("MkdirAll(%s) error = %v", archDir, errMkdirAll) + } + pluginPath := filepath.Join(archDir, "sample-provider-v0.0.1"+managementPluginExtension(runtime.GOOS)) + if errWriteFile := os.WriteFile(pluginPath, []byte("x"), 0o644); errWriteFile != nil { + t.Fatalf("WriteFile(%s) error = %v", pluginPath, errWriteFile) + } + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: pluginsDir, + }, + }, + configFilePath: writeTestConfigFile(t), + pluginStoreRegistryURL: "https://registry.example/registry.json", + pluginStoreHTTPClient: fakePluginStoreHTTPClient{ + "https://registry.example/registry.json": registryJSON(t), + }, + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugin-store", nil) + + h.ListPluginStore(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var body pluginStoreListResponse + if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { + t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) + } + if len(body.Plugins) != 1 { + t.Fatalf("plugins len = %d, want 1", len(body.Plugins)) + } + entry := body.Plugins[0] + if !entry.Installed || entry.InstalledVersion != "0.0.1" { + t.Fatalf("store entry status = %#v, want installed version 0.0.1", entry) + } + if !entry.UpdateAvailable { + t.Fatalf("update_available = false, want true for installed 0.0.1 and registry 0.1.0") + } +} + +func TestListPluginStoreUsesConfiguredStoreVersionWhenFilesCoexist(t *testing.T) { + t.Parallel() + + pluginsDir := t.TempDir() + archDir := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH) + if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil { + t.Fatalf("MkdirAll(%s) error = %v", archDir, errMkdirAll) + } + extension := managementPluginExtension(runtime.GOOS) + pinnedPath := filepath.Join(archDir, "sample-provider-v0.1.0"+extension) + newerPath := filepath.Join(archDir, "sample-provider-v0.2.0"+extension) + for _, path := range []string{pinnedPath, newerPath} { + if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil { + t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile) + } + } + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: pluginsDir, + Configs: map[string]config.PluginInstanceConfig{ + "sample-provider": pluginConfigFromYAML(t, "enabled: true\nstore:\n version: 0.1.0\n release-tag: v0.1.0\n"), + }, + }, + }, + configFilePath: writeTestConfigFile(t), + pluginStoreRegistryURL: "https://registry.example/registry.json", + pluginStoreHTTPClient: fakePluginStoreHTTPClient{ + "https://registry.example/registry.json": registryJSON(t), + }, + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugin-store", nil) + + h.ListPluginStore(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var body pluginStoreListResponse + if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { + t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) + } + if len(body.Plugins) != 1 { + t.Fatalf("plugins len = %d, want 1", len(body.Plugins)) + } + entry := body.Plugins[0] + if !entry.Installed || entry.InstalledVersion != "0.1.0" || entry.Path != pinnedPath { + t.Fatalf("store entry status = %#v, want pinned version/path %s", entry, pinnedPath) + } +} + func TestListPluginStoreEscapesRegistryStrings(t *testing.T) { t.Parallel() @@ -296,6 +402,146 @@ func TestListPluginStoreIncludesThirdPartySources(t *testing.T) { } } +func TestListPluginStoreIncludesDirectMetadataAndAuth(t *testing.T) { + t.Setenv("PLUGIN_STORE_TOKEN", "secret-token") + + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: t.TempDir(), + StoreAuth: []pluginstore.AuthConfig{{ + Match: "https://registry.example/", + ApplyTo: []string{pluginstore.RequestKindRegistry}, + Type: pluginstore.AuthTypeBearer, + TokenEnv: "PLUGIN_STORE_TOKEN", + }}, + }, + }, + configFilePath: writeTestConfigFile(t), + pluginStoreRegistryURL: "https://registry.example/registry.json", + pluginStoreHTTPClient: fakePluginStoreHTTPClient{ + "https://registry.example/registry.json": directRegistryJSON("https://downloads.example/sample-provider.zip", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), + }, + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugin-store", nil) + + h.ListPluginStore(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var body pluginStoreListResponse + if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { + t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) + } + if len(body.Plugins) != 1 { + t.Fatalf("plugins len = %d, want 1", len(body.Plugins)) + } + entry := body.Plugins[0] + if entry.InstallType != pluginstore.InstallTypeDirect || !entry.AuthRequired || !entry.AuthConfigured { + t.Fatalf("direct metadata = %#v, want direct auth metadata", entry) + } + if !pluginStorePlatformsContain(entry.Platforms, "linux", "amd64") { + t.Fatalf("platforms = %#v, want linux/amd64", entry.Platforms) + } +} + +func TestListPluginStoreReportsVersionArtifactAuth(t *testing.T) { + t.Setenv("PLUGIN_STORE_TOKEN", "secret-token") + + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: t.TempDir(), + StoreAuth: []pluginstore.AuthConfig{{ + Match: "https://versioned.example/", + ApplyTo: []string{pluginstore.RequestKindArtifact}, + Type: pluginstore.AuthTypeBearer, + TokenEnv: "PLUGIN_STORE_TOKEN", + }}, + }, + }, + configFilePath: writeTestConfigFile(t), + pluginStoreRegistryURL: "https://registry.example/registry.json", + pluginStoreHTTPClient: fakePluginStoreHTTPClient{ + "https://registry.example/registry.json": directRegistryJSONWithVersionArtifact( + "https://downloads.example/sample-provider.zip", + "https://versioned.example/sample-provider-0.3.0.zip", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ), + }, + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugin-store", nil) + + h.ListPluginStore(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var body pluginStoreListResponse + if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { + t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) + } + if len(body.Plugins) != 1 { + t.Fatalf("plugins len = %d, want 1", len(body.Plugins)) + } + if !body.Plugins[0].AuthConfigured { + t.Fatalf("auth_configured = false, want true for version artifact auth") + } +} + +func TestListPluginStoreReportsGitHubMetadataAuth(t *testing.T) { + t.Setenv("PLUGIN_STORE_TOKEN", "secret-token") + + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: t.TempDir(), + StoreAuth: []pluginstore.AuthConfig{{ + Match: "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/", + ApplyTo: []string{pluginstore.RequestKindMetadata}, + Type: pluginstore.AuthTypeBearer, + TokenEnv: "PLUGIN_STORE_TOKEN", + }}, + }, + }, + configFilePath: writeTestConfigFile(t), + pluginStoreRegistryURL: "https://registry.example/registry.json", + pluginStoreHTTPClient: fakePluginStoreHTTPClient{ + "https://registry.example/registry.json": registryJSON(t), + }, + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugin-store", nil) + + h.ListPluginStore(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var body pluginStoreListResponse + if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { + t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) + } + if len(body.Plugins) != 1 { + t.Fatalf("plugins len = %d, want 1", len(body.Plugins)) + } + if !body.Plugins[0].AuthConfigured { + t.Fatalf("auth_configured = false, want true for GitHub metadata auth") + } +} + func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) { t.Parallel() @@ -358,7 +604,7 @@ func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) { if body.RestartRequired { t.Fatal("restart_required = true, want false") } - targetPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider"+managementPluginExtension(runtime.GOOS)) + targetPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider-v0.1.0"+managementPluginExtension(runtime.GOOS)) data, errRead := os.ReadFile(targetPath) if errRead != nil { t.Fatalf("ReadFile(%s) error = %v", targetPath, errRead) @@ -384,11 +630,134 @@ func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) { if !strings.Contains(raw, "mode: fast") { t.Fatalf("plugin raw config lost custom field:\n%s", raw) } + manifest := pluginStoreManifestFromConfig(t, item) + if manifest.InstallType() != pluginstore.InstallTypeGitHubRelease || manifest.ReleaseTag != "v0.1.0" || manifest.Version != "0.1.0" { + t.Fatalf("store manifest = %#v, want github-release v0.1.0", manifest) + } if raw := marshalPluginRaw(t, snapshotItem); !strings.Contains(raw, "mode: fast") { t.Fatalf("snapshot plugin raw config lost custom field:\n%s", raw) } } +func TestInstallPluginFromStoreInstallsDirectArtifact(t *testing.T) { + t.Parallel() + + pluginsDir := t.TempDir() + archiveData := makeManagementPluginStoreZip(t, "sample-provider"+managementPluginExtension(runtime.GOOS), "direct-library-data") + checksum := sha256.Sum256(archiveData) + artifactURL := "https://downloads.example/sample-provider.zip" + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: false, + Dir: pluginsDir, + }, + }, + configFilePath: writeTestConfigFile(t), + pluginStoreRegistryURL: "https://registry.example/registry.json", + pluginStoreHTTPClient: fakePluginStoreHTTPClient{ + "https://registry.example/registry.json": directRegistryJSON(artifactURL, hex.EncodeToString(checksum[:])), + artifactURL: archiveData, + }, + } + reloads, reloadDone := captureConfigReload(h) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Params = gin.Params{{Key: "id", Value: "sample-provider"}} + c.Request = httptest.NewRequest(http.MethodPost, "/v0/management/plugin-store/sample-provider/install", nil) + + h.InstallPluginFromStore(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + waitForAsyncReload(t, reloads) + waitForReloadDone(t, reloadDone) + var body pluginInstallResponse + if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { + t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) + } + if body.InstallType != pluginstore.InstallTypeDirect || body.Version != "0.4.0" { + t.Fatalf("install response = %#v, want direct 0.4.0", body) + } + targetPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider-v0.4.0"+managementPluginExtension(runtime.GOOS)) + data, errRead := os.ReadFile(targetPath) + if errRead != nil { + t.Fatalf("ReadFile(%s) error = %v", targetPath, errRead) + } + if string(data) != "direct-library-data" { + t.Fatalf("installed file = %q, want direct-library-data", data) + } + manifest := pluginStoreManifestFromConfig(t, h.cfg.Plugins.Configs["sample-provider"]) + if manifest.SchemaVersion != pluginstore.SchemaVersionV2 || manifest.InstallType() != pluginstore.InstallTypeDirect || manifest.Version != "0.4.0" { + t.Fatalf("store manifest = %#v, want direct schema v2 0.4.0", manifest) + } + if manifest.SourceURL != "https://registry.example/registry.json" || len(manifest.Install.Artifacts) != 0 { + t.Fatalf("store manifest source/artifacts = %q/%d, want source URL without artifacts", manifest.SourceURL, len(manifest.Install.Artifacts)) + } + if raw := marshalPluginRaw(t, h.cfg.Plugins.Configs["sample-provider"]); strings.Contains(raw, "artifacts:") { + t.Fatalf("direct store manifest should not persist artifacts:\n%s", raw) + } +} + +func TestInstallPluginFromStoreHonorsDirectQueryVersion(t *testing.T) { + t.Parallel() + + pluginsDir := t.TempDir() + archiveData := makeManagementPluginStoreZip(t, "sample-provider"+managementPluginExtension(runtime.GOOS), "direct-history-data") + checksum := sha256.Sum256(archiveData) + topArtifactURL := "https://downloads.example/sample-provider-0.4.0.zip" + versionArtifactURL := "https://downloads.example/sample-provider-0.3.0.zip" + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: false, + Dir: pluginsDir, + }, + }, + configFilePath: writeTestConfigFile(t), + pluginStoreRegistryURL: "https://registry.example/registry.json", + pluginStoreHTTPClient: fakePluginStoreHTTPClient{ + "https://registry.example/registry.json": directRegistryJSONWithVersionArtifact(topArtifactURL, versionArtifactURL, hex.EncodeToString(checksum[:])), + versionArtifactURL: archiveData, + }, + } + reloads, reloadDone := captureConfigReload(h) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Params = gin.Params{{Key: "id", Value: "sample-provider"}} + c.Request = httptest.NewRequest(http.MethodPost, "/v0/management/plugin-store/sample-provider/install?version=0.3.0", nil) + + h.InstallPluginFromStore(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + waitForAsyncReload(t, reloads) + waitForReloadDone(t, reloadDone) + var body pluginInstallResponse + if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { + t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) + } + if body.InstallType != pluginstore.InstallTypeDirect || body.Version != "0.3.0" { + t.Fatalf("install response = %#v, want direct 0.3.0", body) + } + targetPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider-v0.3.0"+managementPluginExtension(runtime.GOOS)) + data, errRead := os.ReadFile(targetPath) + if errRead != nil { + t.Fatalf("ReadFile(%s) error = %v", targetPath, errRead) + } + if string(data) != "direct-history-data" { + t.Fatalf("installed file = %q, want direct-history-data", data) + } + manifest := pluginStoreManifestFromConfig(t, h.cfg.Plugins.Configs["sample-provider"]) + if manifest.Version != "0.3.0" || manifest.InstallType() != pluginstore.InstallTypeDirect || len(manifest.Install.Artifacts) != 0 { + t.Fatalf("store manifest = %#v, want source-backed direct 0.3.0", manifest) + } +} + func TestInstallPluginFromStoreUsesRequestedThirdPartySource(t *testing.T) { t.Parallel() @@ -444,7 +813,7 @@ func TestInstallPluginFromStoreUsesRequestedThirdPartySource(t *testing.T) { if body.SourceID != communitySourceID || body.Version != "0.3.0" { t.Fatalf("install response = %#v, want community source version 0.3.0", body) } - targetPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider"+managementPluginExtension(runtime.GOOS)) + targetPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider-v0.3.0"+managementPluginExtension(runtime.GOOS)) data, errRead := os.ReadFile(targetPath) if errRead != nil { t.Fatalf("ReadFile(%s) error = %v", targetPath, errRead) @@ -495,7 +864,10 @@ func TestInstallPluginFromStoreOverwritesFilePreservesConfigAndReloads(t *testin t.Parallel() pluginsDir := t.TempDir() - existingPath := filepath.Join(pluginsDir, "sample-provider"+managementPluginExtension(runtime.GOOS)) + existingPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider-v0.1.0"+managementPluginExtension(runtime.GOOS)) + if errMkdir := os.MkdirAll(filepath.Dir(existingPath), 0o755); errMkdir != nil { + t.Fatalf("MkdirAll(%s) error = %v", filepath.Dir(existingPath), errMkdir) + } if errWrite := os.WriteFile(existingPath, []byte("old-library-data"), 0o644); errWrite != nil { t.Fatalf("WriteFile(%s) error = %v", existingPath, errWrite) } @@ -588,7 +960,7 @@ func TestEnablePluginConfigLockedPreservesExistingFields(t *testing.T) { }, } - if errEnable := h.enablePluginConfigLocked("sample-provider"); errEnable != nil { + if errEnable := h.enablePluginConfigLocked("sample-provider", testStoreManifest()); errEnable != nil { t.Fatalf("enablePluginConfigLocked() error = %v", errEnable) } if h.cfg.Plugins.Enabled { @@ -602,7 +974,7 @@ func TestEnablePluginConfigLockedPreservesExistingFields(t *testing.T) { t.Fatalf("plugin priority = %d, want 5", item.Priority) } raw := marshalPluginRaw(t, item) - if !strings.Contains(raw, "mode: fast") { + if !strings.Contains(raw, "mode: fast") || !strings.Contains(raw, "store:") { t.Fatalf("plugin raw config lost custom field:\n%s", raw) } } @@ -611,13 +983,17 @@ func TestEnablePluginConfigLockedCreatesMissingConfig(t *testing.T) { t.Parallel() h := &Handler{cfg: &config.Config{}} - if errEnable := h.enablePluginConfigLocked("sample-provider"); errEnable != nil { + if errEnable := h.enablePluginConfigLocked("sample-provider", testStoreManifest()); errEnable != nil { t.Fatalf("enablePluginConfigLocked() error = %v", errEnable) } item := h.cfg.Plugins.Configs["sample-provider"] if item.Enabled == nil || !*item.Enabled { t.Fatalf("plugin enabled = %#v, want true", item.Enabled) } + manifest := pluginStoreManifestFromConfig(t, item) + if manifest.ID != "sample-provider" || manifest.ReleaseTag != "v0.1.0" { + t.Fatalf("store manifest = %#v, want sample-provider v0.1.0", manifest) + } } type fakePluginStoreHTTPClient map[string][]byte @@ -695,6 +1071,114 @@ func thirdPartySampleRegistryJSON(t *testing.T) []byte { }`) } +func directRegistryJSON(artifactURL string, checksum string) []byte { + return []byte(`{ + "schema_version": 2, + "plugins": [{ + "id": "sample-provider", + "name": "Sample Provider", + "description": "Adds sample provider support.", + "author": "author-name", + "version": "0.4.0", + "auth_required": true, + "install": { + "type": "direct", + "artifacts": [{ + "goos": "` + runtime.GOOS + `", + "goarch": "` + runtime.GOARCH + `", + "url": "` + artifactURL + `", + "sha256": "` + checksum + `" + }, { + "goos": "linux", + "goarch": "amd64", + "url": "` + artifactURL + `", + "sha256": "` + checksum + `" + }] + } + }] + }`) +} + +func directRegistryJSONWithVersionArtifact(artifactURL string, versionArtifactURL string, checksum string) []byte { + return []byte(`{ + "schema_version": 2, + "plugins": [{ + "id": "sample-provider", + "name": "Sample Provider", + "description": "Adds sample provider support.", + "author": "author-name", + "version": "0.4.0", + "auth_required": true, + "install": { + "type": "direct", + "artifacts": [{ + "goos": "` + runtime.GOOS + `", + "goarch": "` + runtime.GOARCH + `", + "url": "` + artifactURL + `", + "sha256": "` + checksum + `" + }] + }, + "versions": [{ + "version": "0.3.0", + "install": { + "type": "direct", + "artifacts": [{ + "goos": "` + runtime.GOOS + `", + "goarch": "` + runtime.GOARCH + `", + "url": "` + versionArtifactURL + `", + "sha256": "` + checksum + `" + }] + } + }] + }] + }`) +} + +func testStoreManifest() pluginstore.Manifest { + return pluginstore.Manifest{ + ID: "sample-provider", + Name: "Sample Provider", + Description: "Adds sample provider support.", + Author: "author-name", + Version: "0.1.0", + ReleaseTag: "v0.1.0", + Repository: "https://github.com/author-name/cliproxy-sample-provider-plugin", + Install: pluginstore.InstallPlan{Type: pluginstore.InstallTypeGitHubRelease}, + } +} + +func pluginStoreManifestFromConfig(t *testing.T, item config.PluginInstanceConfig) pluginstore.Manifest { + t.Helper() + + node := pluginConfigNode(item) + for index := 0; index+1 < len(node.Content); index += 2 { + key := node.Content[index] + value := node.Content[index+1] + if key == nil || key.Value != "store" { + continue + } + var manifest pluginstore.Manifest + if errDecode := value.Decode(&manifest); errDecode != nil { + t.Fatalf("decode store manifest: %v", errDecode) + } + if errValidate := manifest.Validate(); errValidate != nil { + t.Fatalf("store manifest Validate() error = %v; manifest=%#v", errValidate, manifest) + } + return manifest + } + t.Fatalf("plugin config missing store manifest:\n%s", marshalPluginRaw(t, item)) + return pluginstore.Manifest{} +} + +func pluginStorePlatformsContain(platforms []pluginStorePlatform, goos string, goarch string) bool { + for _, platform := range platforms { + if platform.GOOS == goos && platform.GOARCH == goarch { + return true + } + } + return false +} + func makeManagementPluginStoreZip(t *testing.T, name string, content string) []byte { t.Helper() diff --git a/internal/api/handlers/management/plugins.go b/internal/api/handlers/management/plugins.go index c86980dc..76c9391c 100644 --- a/internal/api/handlers/management/plugins.go +++ b/internal/api/handlers/management/plugins.go @@ -82,7 +82,7 @@ func (h *Handler) ListPlugins(c *gin.Context) { h.mu.Unlock() entries := make(map[string]pluginListEntry) - files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir) + files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir, pluginStoreDesiredVersions(configs)) if errDiscover != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_discovery_failed", "message": errDiscover.Error()}) return @@ -322,11 +322,15 @@ func (h *Handler) DeletePlugin(c *gin.Context) { return } pluginsDir := normalizedPluginsDir(h.cfg.Plugins.Dir) - _, configured := h.cfg.Plugins.Configs[id] + item, configured := h.cfg.Plugins.Configs[id] host := h.pluginHost h.mu.Unlock() - path, errPath := pluginFilePath(pluginsDir, id) + var desiredVersions map[string]string + if configured { + desiredVersions = pluginStoreDesiredVersions(map[string]config.PluginInstanceConfig{id: item}) + } + path, errPath := pluginFilePath(pluginsDir, id, desiredVersions) if errPath != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_discovery_failed", "message": errPath.Error()}) return @@ -425,8 +429,8 @@ func pluginDiscovered(pluginsDir string, id string) (bool, error) { return false, nil } -func pluginFilePath(pluginsDir string, id string) (string, error) { - files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir) +func pluginFilePath(pluginsDir string, id string, desiredVersions ...map[string]string) (string, error) { + files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir, desiredVersions...) if errDiscover != nil { return "", errDiscover } diff --git a/internal/api/handlers/management/plugins_test.go b/internal/api/handlers/management/plugins_test.go index fe633bce..17056aac 100644 --- a/internal/api/handlers/management/plugins_test.go +++ b/internal/api/handlers/management/plugins_test.go @@ -172,6 +172,60 @@ func TestListPluginsIncludesScannedAndConfiguredPlugins(t *testing.T) { } } +func TestListPluginsUsesConfiguredStoreVersionWhenFilesCoexist(t *testing.T) { + t.Parallel() + + pluginsDir := t.TempDir() + archDir := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH) + if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil { + t.Fatalf("MkdirAll(%s) error = %v", archDir, errMkdirAll) + } + extension := managementPluginExtension(runtime.GOOS) + pinnedPath := filepath.Join(archDir, "sample-provider-v0.1.0"+extension) + newerPath := filepath.Join(archDir, "sample-provider-v0.2.0"+extension) + for _, path := range []string{pinnedPath, newerPath} { + if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil { + t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile) + } + } + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: pluginsDir, + Configs: map[string]config.PluginInstanceConfig{ + "sample-provider": pluginConfigFromYAML(t, "enabled: true\nstore:\n version: 0.1.0\n"), + }, + }, + }, + configFilePath: writeTestConfigFile(t), + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugins", nil) + + h.ListPlugins(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var body pluginListResponse + if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { + t.Fatalf("decode response: %v; body=%s", errDecode, rec.Body.String()) + } + for _, entry := range body.Plugins { + if entry.ID != "sample-provider" { + continue + } + if entry.Path != pinnedPath || !entry.Configured || !entry.Enabled { + t.Fatalf("plugin entry = %#v, want pinned path %s", entry, pinnedPath) + } + return + } + t.Fatalf("sample-provider entry missing: %#v", body.Plugins) +} + func TestGetPluginConfigReturnsPreservedRawConfig(t *testing.T) { t.Parallel() @@ -541,6 +595,55 @@ func TestDeletePluginRemovesDiscoveredFileAndConfig(t *testing.T) { waitForReloadDone(t, reloadDone) } +func TestDeletePluginUsesConfiguredStoreVersionWhenFilesCoexist(t *testing.T) { + t.Parallel() + + pluginsDir := t.TempDir() + archDir := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH) + if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil { + t.Fatalf("MkdirAll(%s) error = %v", archDir, errMkdirAll) + } + extension := managementPluginExtension(runtime.GOOS) + pinnedPath := filepath.Join(archDir, "sample-provider-v0.1.0"+extension) + newerPath := filepath.Join(archDir, "sample-provider-v0.2.0"+extension) + for _, path := range []string{pinnedPath, newerPath} { + if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil { + t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile) + } + } + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Dir: pluginsDir, + Configs: map[string]config.PluginInstanceConfig{ + "sample-provider": pluginConfigFromYAML(t, "enabled: true\nstore:\n version: 0.1.0\n"), + }, + }, + }, + configFilePath: writeTestConfigFile(t), + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Params = gin.Params{{Key: "id", Value: "sample-provider"}} + c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/plugins/sample-provider", nil) + + h.DeletePlugin(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if _, ok := h.cfg.Plugins.Configs["sample-provider"]; ok { + t.Fatal("plugin config still exists after delete") + } + if _, errStat := os.Stat(pinnedPath); !os.IsNotExist(errStat) { + t.Fatalf("pinned plugin stat error = %v, want not exist", errStat) + } + if _, errStat := os.Stat(newerPath); errStat != nil { + t.Fatalf("newer plugin stat error = %v, want still exists", errStat) + } +} + func TestDeletePluginReturnsNotFoundForUnknownPlugin(t *testing.T) { t.Parallel() diff --git a/internal/config/config.go b/internal/config/config.go index cc110730..85240dda 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,7 @@ import ( "syscall" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore" log "github.com/sirupsen/logrus" "golang.org/x/crypto/bcrypt" "gopkg.in/yaml.v3" @@ -173,6 +174,8 @@ type PluginsConfig struct { Dir string `yaml:"dir" json:"dir"` // StoreSources appends third-party plugin store registries to the built-in official source. StoreSources []string `yaml:"store-sources,omitempty" json:"store-sources,omitempty"` + // StoreAuth defines optional auth rules for plugin store registry, metadata, and artifact requests. + StoreAuth []sdkpluginstore.AuthConfig `yaml:"store-auth,omitempty" json:"store-auth,omitempty"` // Configs stores per-plugin instance configuration by plugin ID. Configs map[string]PluginInstanceConfig `yaml:"configs" json:"configs"` } @@ -827,6 +830,7 @@ func (cfg *Config) NormalizePluginsConfig() { } cfg.Plugins.StoreSources = sources } + cfg.Plugins.StoreAuth = sdkpluginstore.NormalizeAuthConfigs(cfg.Plugins.StoreAuth) if cfg.Plugins.Configs == nil { cfg.Plugins.Configs = map[string]PluginInstanceConfig{} } diff --git a/internal/config/plugin_config_test.go b/internal/config/plugin_config_test.go index 6a883e41..0eb2813f 100644 --- a/internal/config/plugin_config_test.go +++ b/internal/config/plugin_config_test.go @@ -51,6 +51,33 @@ plugins: } } +func TestParseConfigBytes_PluginStoreAuth(t *testing.T) { + cfg, errParse := ParseConfigBytes([]byte(` +plugins: + store-auth: + - match: " https://plugins.example.com/ " + apply-to: ["registry", "artifact", "registry"] + type: bearer + token-env: " CLIPROXY_PLUGIN_STORE_TOKEN " + - match: "" + type: bearer +`)) + if errParse != nil { + t.Fatalf("ParseConfigBytes() error = %v", errParse) + } + + if len(cfg.Plugins.StoreAuth) != 1 { + t.Fatalf("Plugins.StoreAuth len = %d, want 1", len(cfg.Plugins.StoreAuth)) + } + auth := cfg.Plugins.StoreAuth[0] + if auth.Match != "https://plugins.example.com/" || auth.Type != "bearer" || auth.TokenEnv != "CLIPROXY_PLUGIN_STORE_TOKEN" { + t.Fatalf("Plugins.StoreAuth[0] = %#v", auth) + } + if len(auth.ApplyTo) != 2 || auth.ApplyTo[0] != "registry" || auth.ApplyTo[1] != "artifact" { + t.Fatalf("Plugins.StoreAuth[0].ApplyTo = %#v", auth.ApplyTo) + } +} + func TestParseConfigBytes_PluginInstanceEmptyRawYAML(t *testing.T) { cfg, errParse := ParseConfigBytes([]byte(` plugins: diff --git a/internal/homeplugins/sync.go b/internal/homeplugins/sync.go index 3e894277..9fd21093 100644 --- a/internal/homeplugins/sync.go +++ b/internal/homeplugins/sync.go @@ -16,14 +16,12 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/util" sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore" - "golang.org/x/sys/cpu" "gopkg.in/yaml.v3" ) type Platform struct { - GOOS string `json:"goos"` - GOARCH string `json:"goarch"` - Variant string `json:"variant,omitempty"` + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` } type PluginRuntime interface { @@ -56,6 +54,7 @@ type PluginInstallStatus struct { Version string `json:"version,omitempty"` ReleaseTag string `json:"release_tag,omitempty"` Repository string `json:"repository,omitempty"` + InstallType string `json:"install_type,omitempty"` InstallStatus string `json:"install_status"` LoadStatus string `json:"load_status,omitempty"` Path string `json:"path,omitempty"` @@ -85,9 +84,8 @@ const ( // CurrentPlatform reports the platform used by pluginhost discovery. func CurrentPlatform() Platform { return Platform{ - GOOS: runtime.GOOS, - GOARCH: runtime.GOARCH, - Variant: cpuVariant(), + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, } } @@ -104,8 +102,7 @@ func NormalizePlatform(platform Platform) Platform { case "aarch64": goarch = "arm64" } - variant := strings.ToLower(strings.TrimSpace(platform.Variant)) - return Platform{GOOS: goos, GOARCH: goarch, Variant: variant} + return Platform{GOOS: goos, GOARCH: goarch} } func Sync(ctx context.Context, cfg *config.Config, pluginRuntime PluginRuntime) error { @@ -311,7 +308,7 @@ func pluginFileInfos(root string, id string) ([]pluginFileInfo, error) { platform := CurrentPlatform() extension := pluginExtension(platform.GOOS) candidates := make([]pluginFileInfo, 0) - for _, dir := range pluginCandidateDirs(root, platform.GOOS, platform.GOARCH, platform.Variant) { + for _, dir := range pluginCandidateDirs(root, platform.GOOS, platform.GOARCH) { entries, errReadDir := os.ReadDir(dir) if errReadDir != nil { if errors.Is(errReadDir, os.ErrNotExist) { @@ -366,11 +363,8 @@ type pluginFileInfo struct { Version string } -func pluginCandidateDirs(root string, goos string, goarch string, variant string) []string { - dirs := make([]string, 0, 3) - if variant != "" { - dirs = append(dirs, filepath.Join(root, goos, goarch+"-"+variant)) - } +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 @@ -551,6 +545,7 @@ func pluginStatusFromManifest(manifest sdkpluginstore.Manifest) PluginInstallSta Version: strings.TrimSpace(manifest.Version), ReleaseTag: strings.TrimSpace(manifest.ReleaseTag), Repository: strings.TrimSpace(manifest.Repository), + InstallType: manifest.InstallType(), InstallStatus: pluginInstallStatusFailed, } } @@ -592,28 +587,16 @@ func yamlMappingValue(node *yaml.Node, key string) *yaml.Node { var newPluginStoreClient = func(cfg *config.Config) sdkpluginstore.Client { client := &http.Client{} + var storeAuth []sdkpluginstore.AuthConfig if cfg != nil && strings.TrimSpace(cfg.ProxyURL) != "" { util.SetProxy(&sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(cfg.ProxyURL)}, client) } - return sdkpluginstore.NewClient(client, "") + if cfg != nil { + storeAuth = cfg.Plugins.StoreAuth + } + return sdkpluginstore.NewClientWithAuth(client, "", storeAuth) } func pluginConfigEnabled(item config.PluginInstanceConfig) bool { return item.Enabled != nil && *item.Enabled } - -func cpuVariant() string { - if runtime.GOARCH != "amd64" { - return "" - } - if cpu.X86.HasAVX512F && cpu.X86.HasAVX512BW && cpu.X86.HasAVX512CD && cpu.X86.HasAVX512DQ && cpu.X86.HasAVX512VL { - return "v4" - } - if cpu.X86.HasAVX && cpu.X86.HasAVX2 && cpu.X86.HasBMI1 && cpu.X86.HasBMI2 && cpu.X86.HasFMA { - return "v3" - } - if cpu.X86.HasSSE3 && cpu.X86.HasSSSE3 && cpu.X86.HasSSE41 && cpu.X86.HasSSE42 && cpu.X86.HasPOPCNT { - return "v2" - } - return "v1" -} diff --git a/internal/pluginhost/platform.go b/internal/pluginhost/platform.go index e8699b9c..b3bb636e 100644 --- a/internal/pluginhost/platform.go +++ b/internal/pluginhost/platform.go @@ -11,7 +11,6 @@ import ( "strings" log "github.com/sirupsen/logrus" - "golang.org/x/sys/cpu" ) var ( @@ -124,7 +123,7 @@ func selectPluginFilesWithCandidates(root string, desiredVersions ...map[string] } desired := normalizeDesiredPluginVersions(desiredVersions...) - candidates := candidateDirs(root, runtime.GOOS, runtime.GOARCH, cpuVariant()) + candidates := candidateDirs(root, runtime.GOOS, runtime.GOARCH) extension := pluginExtension(runtime.GOOS) selectedByID := make(map[string]pluginFile) order := make([]string, 0) @@ -290,8 +289,8 @@ func cleanupUnselectedPluginFiles(root string, loaded []pluginFile) error { } // DiscoverPluginFiles returns plugin binaries selected by the current host discovery rules. -func DiscoverPluginFiles(root string) ([]PluginFileInfo, error) { - files, errSelect := selectPluginFiles(root) +func DiscoverPluginFiles(root string, desiredVersions ...map[string]string) ([]PluginFileInfo, error) { + files, errSelect := selectPluginFiles(root, desiredVersions...) if errSelect != nil { return nil, errSelect } @@ -306,28 +305,9 @@ func DiscoverPluginFiles(root string) ([]PluginFileInfo, error) { return out, nil } -func candidateDirs(root, goos, goarch, variant string) []string { - dirs := make([]string, 0, 3) - if variant != "" { - dirs = append(dirs, filepath.Join(root, goos, goarch+"-"+variant)) - } +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 } - -func cpuVariant() string { - if runtime.GOARCH != "amd64" { - return "" - } - if cpu.X86.HasAVX512F && cpu.X86.HasAVX512BW && cpu.X86.HasAVX512CD && cpu.X86.HasAVX512DQ && cpu.X86.HasAVX512VL { - return "v4" - } - if cpu.X86.HasAVX && cpu.X86.HasAVX2 && cpu.X86.HasBMI1 && cpu.X86.HasBMI2 && cpu.X86.HasFMA { - return "v3" - } - if cpu.X86.HasSSE3 && cpu.X86.HasSSSE3 && cpu.X86.HasSSE41 && cpu.X86.HasSSE42 && cpu.X86.HasPOPCNT { - return "v2" - } - return "v1" -} diff --git a/internal/pluginhost/platform_test.go b/internal/pluginhost/platform_test.go index e8f959f1..6d5b3a13 100644 --- a/internal/pluginhost/platform_test.go +++ b/internal/pluginhost/platform_test.go @@ -9,9 +9,8 @@ import ( ) func TestCandidateDirs(t *testing.T) { - got := candidateDirs("plugins", "darwin", "arm64", "v3") + got := candidateDirs("plugins", "darwin", "arm64") want := []string{ - filepath.Join("plugins", "darwin", "arm64-v3"), filepath.Join("plugins", "darwin", "arm64"), "plugins", } @@ -25,22 +24,6 @@ func TestCandidateDirs(t *testing.T) { } } -func TestCandidateDirsOmitsEmptyVariant(t *testing.T) { - got := candidateDirs("plugins", "linux", "arm64", "") - want := []string{ - filepath.Join("plugins", "linux", "arm64"), - "plugins", - } - if len(got) != len(want) { - t.Fatalf("len(candidateDirs) = %d, want %d", len(got), len(want)) - } - for index := range want { - if got[index] != want[index] { - t.Fatalf("candidateDirs[%d] = %q, want %q", index, got[index], want[index]) - } - } -} - func TestPluginExtensionForPlatform(t *testing.T) { cases := []struct { goos string @@ -236,38 +219,3 @@ func TestSelectPluginFilesSkipsPluginWhenConfiguredVersionIsMissing(t *testing.T t.Fatalf("selectPluginFiles() = %v, want no selected alpha plugin", files) } } - -func TestSelectPluginFilesPrefersCPUVariantOverGenericArchDir(t *testing.T) { - variant := cpuVariant() - if variant == "" { - t.Skip("current GOARCH has no plugin CPU variant") - } - root := t.TempDir() - archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH) - variantDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH+"-"+variant) - for _, dir := range []string{archDir, variantDir} { - if errMkdirAll := os.MkdirAll(dir, 0o755); errMkdirAll != nil { - t.Fatalf("MkdirAll(%s) error = %v", dir, errMkdirAll) - } - } - - extension := pluginExtension(runtime.GOOS) - genericPath := filepath.Join(archDir, "alpha"+extension) - variantPath := filepath.Join(variantDir, "alpha"+extension) - for _, path := range []string{genericPath, variantPath} { - if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil { - t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile) - } - } - - files, errSelect := selectPluginFiles(root) - if errSelect != nil { - t.Fatalf("selectPluginFiles() error = %v", errSelect) - } - if len(files) != 1 { - t.Fatalf("selectPluginFiles() = %v, want exactly one alpha plugin", files) - } - if files[0] != (pluginFile{ID: "alpha", Path: variantPath}) { - t.Fatalf("selectPluginFiles()[0] = %v, want CPU variant plugin %s", files[0], variantPath) - } -} diff --git a/internal/pluginstore/auth.go b/internal/pluginstore/auth.go new file mode 100644 index 00000000..e50190e5 --- /dev/null +++ b/internal/pluginstore/auth.go @@ -0,0 +1,235 @@ +package pluginstore + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/url" + "os" + "strings" +) + +const ( + RequestKindRegistry = "registry" + RequestKindMetadata = "metadata" + RequestKindArtifact = "artifact" + + AuthTypeNone = "none" + AuthTypeBearer = "bearer" + AuthTypeBasic = "basic" + AuthTypeHeader = "header" + AuthTypeGitHubToken = "github-token" +) + +type AuthConfig struct { + Match string `yaml:"match,omitempty" json:"match,omitempty"` + ApplyTo []string `yaml:"apply-to,omitempty" json:"apply_to,omitempty"` + Type string `yaml:"type,omitempty" json:"type,omitempty"` + TokenEnv string `yaml:"token-env,omitempty" json:"token_env,omitempty"` + UsernameEnv string `yaml:"username-env,omitempty" json:"username_env,omitempty"` + PasswordEnv string `yaml:"password-env,omitempty" json:"password_env,omitempty"` + HeaderName string `yaml:"header-name,omitempty" json:"header_name,omitempty"` + HeaderValueEnv string `yaml:"header-value-env,omitempty" json:"header_value_env,omitempty"` + AllowInsecure bool `yaml:"allow-insecure,omitempty" json:"allow_insecure,omitempty"` +} + +func NormalizeAuthConfigs(auth []AuthConfig) []AuthConfig { + if len(auth) == 0 { + return nil + } + out := make([]AuthConfig, 0, len(auth)) + for _, item := range auth { + item.Match = strings.TrimSpace(item.Match) + item.Type = strings.ToLower(strings.TrimSpace(item.Type)) + item.TokenEnv = strings.TrimSpace(item.TokenEnv) + item.UsernameEnv = strings.TrimSpace(item.UsernameEnv) + item.PasswordEnv = strings.TrimSpace(item.PasswordEnv) + item.HeaderName = strings.TrimSpace(item.HeaderName) + item.HeaderValueEnv = strings.TrimSpace(item.HeaderValueEnv) + if item.Type == "" { + item.Type = AuthTypeNone + } + if item.Match == "" { + continue + } + if len(item.ApplyTo) > 0 { + applyTo := make([]string, 0, len(item.ApplyTo)) + seen := map[string]struct{}{} + for _, value := range item.ApplyTo { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + applyTo = append(applyTo, value) + } + item.ApplyTo = applyTo + } + out = append(out, item) + } + return out +} + +func AuthConfigured(auth []AuthConfig, requestURL string, kind string) bool { + item, ok := matchingAuthConfig(auth, requestURL, kind) + if !ok { + return false + } + switch strings.ToLower(strings.TrimSpace(item.Type)) { + case AuthTypeNone: + return false + case AuthTypeBearer, AuthTypeGitHubToken: + envName := item.TokenEnv + if envName == "" && item.Type == AuthTypeGitHubToken { + envName = "GITSTORE_GIT_TOKEN" + } + return strings.TrimSpace(os.Getenv(envName)) != "" + case AuthTypeBasic: + return strings.TrimSpace(os.Getenv(item.UsernameEnv)) != "" && strings.TrimSpace(os.Getenv(item.PasswordEnv)) != "" + case AuthTypeHeader: + return item.HeaderName != "" && strings.TrimSpace(os.Getenv(item.HeaderValueEnv)) != "" + default: + return false + } +} + +func applyPluginStoreAuth(headers http.Header, auth []AuthConfig, requestURL string, kind string) error { + item, ok := matchingAuthConfig(auth, requestURL, kind) + if !ok { + return nil + } + switch strings.ToLower(strings.TrimSpace(item.Type)) { + case "", AuthTypeNone: + return nil + case AuthTypeBearer: + token, errToken := envValueRequired(item.TokenEnv, "token-env") + if errToken != nil { + return errToken + } + headers.Set("Authorization", "Bearer "+token) + case AuthTypeBasic: + username, errUsername := envValueRequired(item.UsernameEnv, "username-env") + if errUsername != nil { + return errUsername + } + password, errPassword := envValueRequired(item.PasswordEnv, "password-env") + if errPassword != nil { + return errPassword + } + encoded := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + headers.Set("Authorization", "Basic "+encoded) + case AuthTypeHeader: + if strings.TrimSpace(item.HeaderName) == "" { + return fmt.Errorf("plugin store auth missing header-name") + } + value, errValue := envValueRequired(item.HeaderValueEnv, "header-value-env") + if errValue != nil { + return errValue + } + headers.Set(item.HeaderName, value) + case AuthTypeGitHubToken: + token := strings.TrimSpace(os.Getenv(strings.TrimSpace(item.TokenEnv))) + if token == "" { + token = strings.TrimSpace(os.Getenv("GITSTORE_GIT_TOKEN")) + } + if token == "" { + return fmt.Errorf("plugin store auth missing token-env") + } + headers.Set("Authorization", "Bearer "+token) + default: + return fmt.Errorf("unsupported plugin store auth type %q", item.Type) + } + return nil +} + +func validatePluginStoreRequestURL(auth []AuthConfig, requestURL string, kind string) error { + parsed, errParse := url.Parse(strings.TrimSpace(requestURL)) + if errParse != nil || parsed.Scheme == "" || parsed.Host == "" { + return fmt.Errorf("invalid plugin store url") + } + if hasSensitiveQueryParameter(parsed) { + return fmt.Errorf("plugin store url contains sensitive query parameter") + } + if strings.EqualFold(parsed.Scheme, "http") && !allowInsecurePluginStoreURL(auth, requestURL, kind) { + return fmt.Errorf("insecure plugin store url requires matching allow-insecure auth rule") + } + return nil +} + +func allowInsecurePluginStoreURL(auth []AuthConfig, requestURL string, kind string) bool { + item, ok := matchingAuthConfig(auth, requestURL, kind) + return ok && item.AllowInsecure +} + +func matchingAuthConfig(auth []AuthConfig, requestURL string, kind string) (AuthConfig, bool) { + requestURL = strings.TrimSpace(requestURL) + kind = strings.ToLower(strings.TrimSpace(kind)) + for _, item := range NormalizeAuthConfigs(auth) { + if !pluginStoreURLMatchesAuthRule(requestURL, item.Match) { + continue + } + if !authAppliesTo(item, kind) { + continue + } + return item, true + } + return AuthConfig{}, false +} + +func pluginStoreURLMatchesAuthRule(requestURL string, matchURL string) bool { + request, errRequest := url.Parse(strings.TrimSpace(requestURL)) + if errRequest != nil || request.Scheme == "" || request.Host == "" { + return false + } + rule, errRule := url.Parse(strings.TrimSpace(matchURL)) + if errRule != nil || rule.Scheme == "" || rule.Host == "" { + return false + } + if !strings.EqualFold(request.Scheme, rule.Scheme) || !strings.EqualFold(request.Host, rule.Host) { + return false + } + return pluginStorePathMatchesAuthRule(request.Path, rule.Path) +} + +func pluginStorePathMatchesAuthRule(requestPath string, rulePath string) bool { + if rulePath == "" || rulePath == "/" { + return true + } + if requestPath == "" { + requestPath = "/" + } + if requestPath == rulePath { + return true + } + if strings.HasSuffix(rulePath, "/") { + return strings.HasPrefix(requestPath, rulePath) + } + return strings.HasPrefix(requestPath, rulePath+"/") +} + +func authAppliesTo(item AuthConfig, kind string) bool { + if len(item.ApplyTo) == 0 { + return true + } + for _, value := range item.ApplyTo { + if strings.EqualFold(strings.TrimSpace(value), kind) { + return true + } + } + return false +} + +func envValueRequired(envName string, field string) (string, error) { + envName = strings.TrimSpace(envName) + if envName == "" { + return "", fmt.Errorf("plugin store auth missing %s", field) + } + value := strings.TrimSpace(os.Getenv(envName)) + if value == "" { + return "", fmt.Errorf("plugin store auth env %s is empty", envName) + } + return value, nil +} diff --git a/internal/pluginstore/auth_test.go b/internal/pluginstore/auth_test.go new file mode 100644 index 00000000..d2027c80 --- /dev/null +++ b/internal/pluginstore/auth_test.go @@ -0,0 +1,142 @@ +package pluginstore + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestPluginStoreAuthMatchesURLHostAndPathBoundaries(t *testing.T) { + t.Setenv("PLUGIN_STORE_TOKEN", "secret-token") + auth := []AuthConfig{{ + Match: "https://downloads.example/private", + ApplyTo: []string{RequestKindArtifact}, + Type: AuthTypeBearer, + TokenEnv: "PLUGIN_STORE_TOKEN", + }} + + tests := []struct { + name string + url string + wantAuth bool + }{ + {name: "exact path", url: "https://downloads.example/private", wantAuth: true}, + {name: "child path", url: "https://downloads.example/private/plugin.zip", wantAuth: true}, + {name: "sibling prefix", url: "https://downloads.example/private2/plugin.zip", wantAuth: false}, + {name: "similar host", url: "https://downloads.example.evil/private/plugin.zip", wantAuth: false}, + {name: "different scheme", url: "http://downloads.example/private/plugin.zip", wantAuth: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + headers := http.Header{} + if errAuth := applyPluginStoreAuth(headers, auth, tt.url, RequestKindArtifact); errAuth != nil { + t.Fatalf("applyPluginStoreAuth() error = %v", errAuth) + } + gotAuth := headers.Get("Authorization") != "" + if gotAuth != tt.wantAuth { + t.Fatalf("Authorization set = %v, want %v", gotAuth, tt.wantAuth) + } + }) + } +} + +func TestPluginStoreAuthHeaderIsReevaluatedAcrossRedirect(t *testing.T) { + t.Setenv("PLUGIN_STORE_HEADER", "secret-token") + + var initialHeader string + var redirectedHeader string + artifactData := []byte("artifact-data") + sum := sha256.Sum256(artifactData) + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + redirectedHeader = r.Header.Get("X-Plugin-Token") + _, _ = w.Write(artifactData) + })) + t.Cleanup(target.Close) + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + initialHeader = r.Header.Get("X-Plugin-Token") + http.Redirect(w, r, target.URL+"/artifact.zip", http.StatusFound) + })) + t.Cleanup(source.Close) + + client := Client{ + HTTPClient: source.Client(), + Auth: []AuthConfig{ + { + Match: source.URL + "/private/", + ApplyTo: []string{RequestKindArtifact}, + Type: AuthTypeHeader, + HeaderName: "X-Plugin-Token", + HeaderValueEnv: "PLUGIN_STORE_HEADER", + AllowInsecure: true, + }, + { + Match: target.URL + "/", + ApplyTo: []string{RequestKindArtifact}, + Type: AuthTypeNone, + AllowInsecure: true, + }, + }, + } + data, errDownload := client.DownloadArtifact(context.Background(), Artifact{ + GOOS: "linux", + GOARCH: "amd64", + URL: source.URL + "/private/artifact.zip", + SHA256: hex.EncodeToString(sum[:]), + }) + if errDownload != nil { + t.Fatalf("DownloadArtifact() error = %v", errDownload) + } + if string(data) != string(artifactData) { + t.Fatalf("DownloadArtifact() = %q, want %q", data, artifactData) + } + if initialHeader != "secret-token" { + t.Fatalf("initial auth header = %q, want secret-token", initialHeader) + } + if redirectedHeader != "" { + t.Fatalf("redirected auth header = %q, want empty", redirectedHeader) + } +} + +func TestPluginStoreAuthHeaderIsAppliedToMatchingRedirect(t *testing.T) { + t.Setenv("PLUGIN_STORE_HEADER", "secret-token") + + var redirectedHeader string + artifactData := []byte("artifact-data") + sum := sha256.Sum256(artifactData) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/private/start.zip" { + http.Redirect(w, r, "/private/artifact.zip", http.StatusFound) + return + } + redirectedHeader = r.Header.Get("X-Plugin-Token") + _, _ = io.WriteString(w, string(artifactData)) + })) + t.Cleanup(server.Close) + + client := Client{ + HTTPClient: server.Client(), + Auth: []AuthConfig{{ + Match: server.URL + "/private/", + ApplyTo: []string{RequestKindArtifact}, + Type: AuthTypeHeader, + HeaderName: "X-Plugin-Token", + HeaderValueEnv: "PLUGIN_STORE_HEADER", + AllowInsecure: true, + }}, + } + if _, errDownload := client.DownloadArtifact(context.Background(), Artifact{ + GOOS: "linux", + GOARCH: "amd64", + URL: server.URL + "/private/start.zip", + SHA256: hex.EncodeToString(sum[:]), + }); errDownload != nil { + t.Fatalf("DownloadArtifact() error = %v", errDownload) + } + if redirectedHeader != "secret-token" { + t.Fatalf("redirected auth header = %q, want secret-token", redirectedHeader) + } +} diff --git a/internal/pluginstore/direct.go b/internal/pluginstore/direct.go new file mode 100644 index 00000000..4fd50987 --- /dev/null +++ b/internal/pluginstore/direct.go @@ -0,0 +1,56 @@ +package pluginstore + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +func SelectArtifact(plan InstallPlan, goos string, goarch string) (Artifact, error) { + plan = NormalizeInstallPlan(plan) + goos = normalizeGOOS(goos) + goarch = normalizeGOARCH(goarch) + if plan.Type != InstallTypeDirect { + return Artifact{}, fmt.Errorf("install type %q is not direct", plan.Type) + } + for _, artifact := range plan.Artifacts { + if artifact.GOOS == goos && artifact.GOARCH == goarch { + return artifact, nil + } + } + return Artifact{}, fmt.Errorf("artifact not found for %s/%s", goos, goarch) +} + +func (c Client) DownloadArtifact(ctx context.Context, artifact Artifact) ([]byte, error) { + artifact = NormalizeInstallPlan(InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{artifact}}).Artifacts[0] + if errValidate := ValidateArtifact(artifact); errValidate != nil { + return nil, errValidate + } + maxSize := int64(0) + if artifact.Size > 0 { + maxSize = artifact.Size + } + data, errDownload := c.get(ctx, artifact.URL, "application/octet-stream", RequestKindArtifact, maxSize) + if errDownload != nil { + return nil, errDownload + } + if maxSize > 0 && int64(len(data)) > maxSize { + return nil, fmt.Errorf("artifact exceeds declared size") + } + return data, nil +} + +func VerifyArtifactChecksum(artifact Artifact, data []byte) error { + expected := strings.ToLower(strings.TrimSpace(artifact.SHA256)) + if expected == "" { + return fmt.Errorf("artifact checksum missing") + } + actualBytes := sha256.Sum256(data) + actual := hex.EncodeToString(actualBytes[:]) + if actual != expected { + return fmt.Errorf("artifact checksum mismatch") + } + return nil +} diff --git a/internal/pluginstore/github.go b/internal/pluginstore/github.go index 350d4023..fbb27f06 100644 --- a/internal/pluginstore/github.go +++ b/internal/pluginstore/github.go @@ -4,15 +4,18 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/url" "os" "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/httpfetch" + log "github.com/sirupsen/logrus" ) const userAgent = "CLIProxyAPI" +const maxPluginStoreRedirects = 10 // HTTPDoer abstracts the HTTP client used to execute requests. type HTTPDoer = httpfetch.Doer @@ -21,6 +24,7 @@ type Client struct { HTTPClient HTTPDoer RegistryURL string UserAgent string + Auth []AuthConfig } type Release struct { @@ -38,7 +42,7 @@ func (c Client) FetchRegistry(ctx context.Context) (Registry, error) { if registryURL == "" { registryURL = DefaultRegistryURL } - data, errDownload := c.get(ctx, registryURL, "application/json") + data, errDownload := c.get(ctx, registryURL, "application/json", RequestKindRegistry, 0) if errDownload != nil { return Registry{}, errDownload } @@ -61,7 +65,7 @@ func (c Client) FetchLatestRelease(ctx context.Context, plugin Plugin) (Release, url.PathEscape(owner), url.PathEscape(repo), ) - data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json") + data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json", RequestKindMetadata, 0) if errDownload != nil { return Release{}, errDownload } @@ -88,7 +92,7 @@ func (c Client) FetchReleaseByTag(ctx context.Context, plugin Plugin, tag string url.PathEscape(repo), url.PathEscape(tag), ) - data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json") + data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json", RequestKindMetadata, 0) if errDownload != nil { return Release{}, errDownload } @@ -113,18 +117,47 @@ func (c Client) DownloadAsset(ctx context.Context, asset ReleaseAsset) ([]byte, if strings.TrimSpace(asset.BrowserDownloadURL) == "" { return nil, fmt.Errorf("asset %q missing browser_download_url", asset.Name) } - return c.get(ctx, asset.BrowserDownloadURL, "application/octet-stream") + return c.get(ctx, asset.BrowserDownloadURL, "application/octet-stream", RequestKindArtifact, 0) } -func (c Client) get(ctx context.Context, requestURL string, accept string) ([]byte, error) { - headers := map[string]string{ - "Accept": accept, - "User-Agent": c.userAgent(), - } - if token := gitHubAPIToken(requestURL); token != "" { - headers["Authorization"] = "Bearer " + token +func (c Client) get(ctx context.Context, requestURL string, accept string, kind string, maxSize int64) ([]byte, error) { + currentURL := strings.TrimSpace(requestURL) + for redirects := 0; ; redirects++ { + if errURL := validatePluginStoreRequestURL(c.Auth, currentURL, kind); errURL != nil { + return nil, errURL + } + headers := http.Header{ + "Accept": []string{accept}, + "User-Agent": []string{c.userAgent()}, + } + if errAuth := applyPluginStoreAuth(headers, c.Auth, currentURL, kind); errAuth != nil { + return nil, errAuth + } + if headers.Get("Authorization") == "" { + if token := gitHubAPIToken(currentURL); token != "" { + headers.Set("Authorization", "Bearer "+token) + } + } + resp, errDo := pluginStoreGetNoRedirect(ctx, c.httpClient(), currentURL, headers) + if errDo != nil { + return nil, errDo + } + if pluginStoreRedirectStatus(resp.StatusCode) { + nextURL, errRedirect := pluginStoreRedirectURL(resp, currentURL) + if errClose := resp.Body.Close(); errClose != nil { + log.WithError(errClose).Debug("failed to close plugin store redirect body") + } + if errRedirect != nil { + return nil, errRedirect + } + if redirects >= maxPluginStoreRedirects { + return nil, fmt.Errorf("stopped after %d redirects", maxPluginStoreRedirects) + } + currentURL = nextURL + continue + } + return readPluginStoreResponse(resp, maxSize) } - return httpfetch.GetBytes(ctx, c.httpClient(), requestURL, headers, 0) } // gitHubAPIToken returns the optional GitHub token for GitHub API requests to @@ -155,6 +188,86 @@ func (c Client) userAgent() string { return userAgent } +func pluginStoreGetNoRedirect(ctx context.Context, client HTTPDoer, requestURL string, headers http.Header) (*http.Response, error) { + if client == nil { + client = http.DefaultClient + } + req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if errRequest != nil { + return nil, fmt.Errorf("create request: %w", errRequest) + } + req.Header = headers.Clone() + resp, errDo := pluginStoreNoRedirectClient(client).Do(req) + if errDo != nil { + return nil, fmt.Errorf("request failed: %w", errDo) + } + return resp, nil +} + +func pluginStoreNoRedirectClient(client HTTPDoer) HTTPDoer { + httpClient, ok := client.(*http.Client) + if !ok { + return client + } + clone := *httpClient + clone.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + return &clone +} + +func pluginStoreRedirectStatus(status int) bool { + switch status { + case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect: + return true + default: + return false + } +} + +func pluginStoreRedirectURL(resp *http.Response, requestURL string) (string, error) { + location := strings.TrimSpace(resp.Header.Get("Location")) + if location == "" { + return "", fmt.Errorf("redirect missing Location header") + } + base, errBase := url.Parse(requestURL) + if errBase != nil { + return "", fmt.Errorf("parse redirect base: %w", errBase) + } + next, errNext := base.Parse(location) + if errNext != nil { + return "", fmt.Errorf("parse redirect location: %w", errNext) + } + if next.Scheme == "" || next.Host == "" { + return "", fmt.Errorf("redirect location is not absolute") + } + return next.String(), nil +} + +func readPluginStoreResponse(resp *http.Response, maxSize int64) ([]byte, error) { + defer func() { + if errClose := resp.Body.Close(); errClose != nil { + log.WithError(errClose).Debug("failed to close plugin store response body") + } + }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + reader := io.Reader(resp.Body) + if maxSize > 0 { + reader = io.LimitReader(resp.Body, maxSize+1) + } + data, errRead := io.ReadAll(reader) + if errRead != nil { + return nil, fmt.Errorf("read response: %w", errRead) + } + if maxSize > 0 && int64(len(data)) > maxSize { + return nil, fmt.Errorf("response exceeds maximum allowed size of %d bytes", maxSize) + } + return data, nil +} + func SelectReleaseAssets(release Release, id, version, goos, goarch string) (ReleaseAsset, ReleaseAsset, error) { archiveName := ArchiveName(id, version, goos, goarch) var archiveAsset ReleaseAsset diff --git a/internal/pluginstore/install.go b/internal/pluginstore/install.go index 67ad320b..2b17ecdc 100644 --- a/internal/pluginstore/install.go +++ b/internal/pluginstore/install.go @@ -15,7 +15,6 @@ import ( "strings" log "github.com/sirupsen/logrus" - "golang.org/x/sys/cpu" ) type InstallOptions struct { @@ -38,6 +37,8 @@ var ErrLoadedPluginLocked = errors.New("loaded plugin library cannot be overwrit 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"` @@ -48,6 +49,10 @@ func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptio 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 @@ -60,6 +65,25 @@ func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptio 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 { @@ -110,7 +134,110 @@ func (c Client) installRelease(ctx context.Context, plugin Plugin, release Relea return InstallResult{}, errVerify } plugin.Version = version - return InstallArchive(archiveData, plugin, options) + 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) { @@ -283,7 +410,7 @@ func discoverCurrentPluginFiles(root string) ([]pluginFileInfo, error) { if root == "" { root = "plugins" } - candidates := pluginCandidateDirs(root, runtime.GOOS, runtime.GOARCH, cpuVariant()) + candidates := pluginCandidateDirs(root, runtime.GOOS, runtime.GOARCH) extension := pluginExtension(runtime.GOOS) selected := make([]pluginFileInfo, 0) seen := make(map[string]struct{}) @@ -320,11 +447,8 @@ func discoverCurrentPluginFiles(root string) ([]pluginFileInfo, error) { return selected, nil } -func pluginCandidateDirs(root string, goos string, goarch string, variant string) []string { - dirs := make([]string, 0, 3) - if variant != "" { - dirs = append(dirs, filepath.Join(root, goos, goarch+"-"+variant)) - } +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 @@ -392,22 +516,6 @@ func pluginExtension(goos string) string { } } -func cpuVariant() string { - if runtime.GOARCH != "amd64" { - return "" - } - if cpu.X86.HasAVX512F && cpu.X86.HasAVX512BW && cpu.X86.HasAVX512CD && cpu.X86.HasAVX512DQ && cpu.X86.HasAVX512VL { - return "v4" - } - if cpu.X86.HasAVX && cpu.X86.HasAVX2 && cpu.X86.HasBMI1 && cpu.X86.HasBMI2 && cpu.X86.HasFMA { - return "v3" - } - if cpu.X86.HasSSE3 && cpu.X86.HasSSSE3 && cpu.X86.HasSSE41 && cpu.X86.HasSSE42 && cpu.X86.HasPOPCNT { - return "v2" - } - return "v1" -} - func writeFileAtomic(targetPath string, data []byte, mode os.FileMode) error { targetDir := filepath.Dir(targetPath) if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { @@ -482,5 +590,7 @@ func normalizeInstallOptions(options InstallOptions) InstallOptions { if options.GOARCH == "" { options.GOARCH = runtime.GOARCH } + options.GOOS = normalizeGOOS(options.GOOS) + options.GOARCH = normalizeGOARCH(options.GOARCH) return options } diff --git a/internal/pluginstore/install_test.go b/internal/pluginstore/install_test.go index 422449fa..576c83ee 100644 --- a/internal/pluginstore/install_test.go +++ b/internal/pluginstore/install_test.go @@ -25,7 +25,7 @@ func TestInstallBlocksLoadedWindowsPlugin(t *testing.T) { loaded bool wantBlocked bool }{ - {name: "windows loaded", goos: "windows", loaded: true, wantBlocked: true}, + {name: "windows loaded", goos: "windows", loaded: true, wantBlocked: false}, {name: "windows not loaded", goos: "windows", loaded: false, wantBlocked: false}, {name: "linux loaded", goos: "linux", loaded: true, wantBlocked: false}, {name: "darwin loaded", goos: "darwin", loaded: true, wantBlocked: false}, @@ -53,10 +53,18 @@ func TestInstallBlocksLoadedWindowsPlugin(t *testing.T) { func TestInstallArchiveBlocksLoadedWindowsPluginBeforeWrite(t *testing.T) { t.Parallel() + root := t.TempDir() + targetDir := filepath.Join(root, "windows", "amd64") + if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { + t.Fatalf("MkdirAll() error = %v", errMkdir) + } + if errWrite := os.WriteFile(filepath.Join(targetDir, "sample-provider-v0.1.0.dll"), []byte("old"), 0o644); errWrite != nil { + t.Fatalf("WriteFile() error = %v", errWrite) + } _, errInstall := InstallArchive(makeZip(t, map[string]string{ "sample-provider.dll": "library-data", }), testPlugin(), InstallOptions{ - PluginsDir: t.TempDir(), + PluginsDir: root, GOOS: "windows", GOARCH: "amd64", PluginLoaded: func() bool { return true }, @@ -74,7 +82,7 @@ func TestInstallArchivePreparesLoadedWindowsPluginBeforeWrite(t *testing.T) { if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { t.Fatalf("MkdirAll() error = %v", errMkdir) } - targetPath := filepath.Join(targetDir, "sample-provider.dll") + targetPath := filepath.Join(targetDir, "sample-provider-v0.1.0.dll") if errWrite := os.WriteFile(targetPath, []byte("old"), 0o644); errWrite != nil { t.Fatalf("WriteFile() error = %v", errWrite) } @@ -120,7 +128,7 @@ func TestInstallArchiveSkipsIdenticalLoadedWindowsPlugin(t *testing.T) { if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { t.Fatalf("MkdirAll() error = %v", errMkdir) } - targetPath := filepath.Join(targetDir, "sample-provider.dll") + targetPath := filepath.Join(targetDir, "sample-provider-v0.1.0.dll") if errWrite := os.WriteFile(targetPath, []byte("same"), 0o644); errWrite != nil { t.Fatalf("WriteFile() error = %v", errWrite) } @@ -170,7 +178,7 @@ func TestInstallArchiveWritesPlatformPlugin(t *testing.T) { if errInstall != nil { t.Fatalf("InstallArchive() error = %v", errInstall) } - wantPath := filepath.Join(root, "darwin", "arm64", "sample-provider.dylib") + wantPath := filepath.Join(root, "darwin", "arm64", "sample-provider-v0.1.0.dylib") if result.Path != wantPath { t.Fatalf("Path = %q, want %q", result.Path, wantPath) } @@ -191,7 +199,7 @@ func TestInstallArchiveReportsOverwrite(t *testing.T) { if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { t.Fatalf("MkdirAll() error = %v", errMkdir) } - if errWrite := os.WriteFile(filepath.Join(targetDir, "sample-provider.dylib"), []byte("old"), 0o644); errWrite != nil { + if errWrite := os.WriteFile(filepath.Join(targetDir, "sample-provider-v0.1.0.dylib"), []byte("old"), 0o644); errWrite != nil { t.Fatalf("WriteFile() error = %v", errWrite) } result, errInstall := InstallArchive(makeZip(t, map[string]string{ @@ -209,7 +217,10 @@ func TestInstallArchiveOverwritesRuntimeSelectedPlugin(t *testing.T) { t.Parallel() root := t.TempDir() - existingPath := filepath.Join(root, "sample-provider"+pluginExtension(runtime.GOOS)) + existingPath := filepath.Join(root, runtime.GOOS, runtime.GOARCH, "sample-provider-v0.1.0"+pluginExtension(runtime.GOOS)) + if errMkdir := os.MkdirAll(filepath.Dir(existingPath), 0o755); errMkdir != nil { + t.Fatalf("MkdirAll() error = %v", errMkdir) + } if errWrite := os.WriteFile(existingPath, []byte("old"), 0o644); errWrite != nil { t.Fatalf("WriteFile() error = %v", errWrite) } @@ -327,7 +338,7 @@ func TestInstallUsesLatestReleaseVersion(t *testing.T) { if result.Version != "0.2.0" { t.Fatalf("Version = %q, want 0.2.0 from latest release tag", result.Version) } - data, errRead := os.ReadFile(filepath.Join(root, "darwin", "arm64", "sample-provider.dylib")) + data, errRead := os.ReadFile(filepath.Join(root, "darwin", "arm64", "sample-provider-v0.2.0.dylib")) if errRead != nil { t.Fatalf("ReadFile() error = %v", errRead) } @@ -366,7 +377,7 @@ func TestInstallVersionUsesPinnedReleaseTag(t *testing.T) { if result.Version != "0.3.0" { t.Fatalf("Version = %q, want 0.3.0", result.Version) } - data, errRead := os.ReadFile(filepath.Join(root, "linux", "amd64", "sample-provider.so")) + data, errRead := os.ReadFile(filepath.Join(root, "linux", "amd64", "sample-provider-v0.3.0.so")) if errRead != nil { t.Fatalf("ReadFile() error = %v", errRead) } @@ -375,6 +386,182 @@ func TestInstallVersionUsesPinnedReleaseTag(t *testing.T) { } } +func TestInstallManifestResolvesDirectArtifactsFromSource(t *testing.T) { + t.Parallel() + + root := t.TempDir() + archiveData := makeZip(t, map[string]string{"sample-provider.so": "library-data"}) + checksum := sha256.Sum256(archiveData) + registryURL := "https://registry.example/registry.json" + artifactURL := "https://downloads.example/sample-provider_0.4.0_linux_amd64.zip" + latestArtifactURL := "https://downloads.example/sample-provider_0.5.0_linux_amd64.zip" + client := Client{HTTPClient: mapHTTPDoer{ + registryURL: []byte(`{ + "schema_version": 2, + "plugins": [{ + "id": "sample-provider", + "name": "Sample Provider", + "description": "Adds sample provider support.", + "author": "author-name", + "version": "0.5.0", + "install": { + "type": "direct", + "artifacts": [{ + "goos": "linux", + "goarch": "amd64", + "url": "` + latestArtifactURL + `", + "sha256": "` + hex.EncodeToString(checksum[:]) + `" + }] + }, + "versions": [{ + "version": "0.4.0", + "install": { + "type": "direct", + "artifacts": [{ + "goos": "linux", + "goarch": "amd64", + "url": "` + artifactURL + `", + "sha256": "` + hex.EncodeToString(checksum[:]) + `" + }] + } + }] + }] + }`), + artifactURL: archiveData, + }} + + result, errInstall := client.InstallManifest(context.Background(), Manifest{ + SchemaVersion: SchemaVersionV2, + ID: "sample-provider", + Version: "0.4.0", + SourceURL: registryURL, + Install: InstallPlan{Type: InstallTypeDirect}, + }, InstallOptions{ + PluginsDir: root, + GOOS: "linux", + GOARCH: "amd64", + }) + if errInstall != nil { + t.Fatalf("InstallManifest() error = %v", errInstall) + } + if result.InstallType != InstallTypeDirect || result.Version != "0.4.0" { + t.Fatalf("result = %#v, want direct 0.4.0", result) + } + data, errRead := os.ReadFile(filepath.Join(root, "linux", "amd64", "sample-provider-v0.4.0.so")) + if errRead != nil { + t.Fatalf("ReadFile() error = %v", errRead) + } + if string(data) != "library-data" { + t.Fatalf("installed data = %q", data) + } +} + +func TestInstallDirectDownloadsMatchingArtifactWithBearerAuth(t *testing.T) { + t.Setenv("PLUGIN_STORE_TOKEN", "secret-token") + root := t.TempDir() + archiveData := makeZip(t, map[string]string{"sample-provider.so": "library-data"}) + checksum := sha256.Sum256(archiveData) + artifactURL := "https://downloads.example/private/sample-provider_0.4.0_linux_amd64.zip" + client := Client{ + HTTPClient: authCheckingHTTPDoer{ + url: artifactURL, + wantAuth: "Bearer secret-token", + responseBytes: archiveData, + }, + Auth: []AuthConfig{{ + Match: "https://downloads.example/private/", + ApplyTo: []string{RequestKindArtifact}, + Type: AuthTypeBearer, + TokenEnv: "PLUGIN_STORE_TOKEN", + }}, + } + + plugin := testPlugin() + plugin.Version = "0.4.0" + plugin.Install = InstallPlan{ + Type: InstallTypeDirect, + Artifacts: []Artifact{{ + GOOS: "linux", + GOARCH: "amd64", + URL: artifactURL, + SHA256: hex.EncodeToString(checksum[:]), + }}, + } + result, errInstall := client.Install(context.Background(), plugin, InstallOptions{ + PluginsDir: root, + GOOS: "linux", + GOARCH: "amd64", + }) + if errInstall != nil { + t.Fatalf("Install() error = %v", errInstall) + } + if result.InstallType != InstallTypeDirect || result.Version != "0.4.0" { + t.Fatalf("result = %#v, want direct 0.4.0", result) + } + data, errRead := os.ReadFile(filepath.Join(root, "linux", "amd64", "sample-provider-v0.4.0.so")) + if errRead != nil { + t.Fatalf("ReadFile() error = %v", errRead) + } + if string(data) != "library-data" { + t.Fatalf("installed data = %q", data) + } +} + +func TestInstallDirectRejectsChecksumMismatch(t *testing.T) { + t.Parallel() + + archiveData := makeZip(t, map[string]string{"sample-provider.so": "library-data"}) + client := Client{HTTPClient: mapHTTPDoer{ + "https://downloads.example/sample-provider.zip": archiveData, + }} + plugin := testPlugin() + plugin.Version = "0.4.0" + plugin.Install = InstallPlan{ + Type: InstallTypeDirect, + Artifacts: []Artifact{{ + GOOS: "linux", + GOARCH: "amd64", + URL: "https://downloads.example/sample-provider.zip", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }}, + } + _, errInstall := client.Install(context.Background(), plugin, InstallOptions{ + PluginsDir: t.TempDir(), + GOOS: "linux", + GOARCH: "amd64", + }) + if errInstall == nil { + t.Fatal("Install() error = nil") + } + if !strings.Contains(errInstall.Error(), "checksum mismatch") { + t.Fatalf("Install() error = %v, want checksum mismatch", errInstall) + } +} + +func TestDownloadArtifactEnforcesDeclaredSizeDuringRead(t *testing.T) { + t.Parallel() + + body := &trackingReadCloser{data: []byte("0123456789")} + sum := sha256.Sum256(body.data) + client := Client{HTTPClient: singleResponseHTTPDoer{body: body}} + _, errDownload := client.DownloadArtifact(context.Background(), Artifact{ + GOOS: "linux", + GOARCH: "amd64", + URL: "https://downloads.example/sample-provider.zip", + SHA256: hex.EncodeToString(sum[:]), + Size: 4, + }) + if errDownload == nil { + t.Fatal("DownloadArtifact() error = nil") + } + if !strings.Contains(errDownload.Error(), "maximum allowed size") { + t.Fatalf("DownloadArtifact() error = %v, want size limit", errDownload) + } + if body.offset > 5 { + t.Fatalf("download read %d bytes, want at most size+1", body.offset) + } +} + func TestInstallRejectsInvalidLatestReleaseTag(t *testing.T) { t.Parallel() @@ -440,6 +627,68 @@ func (c mapHTTPDoer) Do(req *http.Request) (*http.Response, error) { }, nil } +type authCheckingHTTPDoer struct { + url string + wantAuth string + responseBytes []byte +} + +type singleResponseHTTPDoer struct { + body io.ReadCloser +} + +func (c singleResponseHTTPDoer) Do(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: c.body, + Header: make(http.Header), + Request: req, + }, nil +} + +type trackingReadCloser struct { + data []byte + offset int +} + +func (r *trackingReadCloser) Read(p []byte) (int, error) { + if r.offset >= len(r.data) { + return 0, io.EOF + } + n := copy(p, r.data[r.offset:]) + r.offset += n + return n, nil +} + +func (r *trackingReadCloser) Close() error { + return nil +} + +func (c authCheckingHTTPDoer) Do(req *http.Request) (*http.Response, error) { + if req.URL.String() != c.url { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("not found")), + Header: make(http.Header), + Request: req, + }, nil + } + if gotAuth := req.Header.Get("Authorization"); gotAuth != c.wantAuth { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(strings.NewReader("bad auth")), + Header: make(http.Header), + Request: req, + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(c.responseBytes)), + Header: make(http.Header), + Request: req, + }, nil +} + func testPlugin() Plugin { return Plugin{ ID: "sample-provider", diff --git a/internal/pluginstore/manifest.go b/internal/pluginstore/manifest.go new file mode 100644 index 00000000..919990aa --- /dev/null +++ b/internal/pluginstore/manifest.go @@ -0,0 +1,174 @@ +package pluginstore + +import ( + "fmt" + "net/url" + "strings" +) + +type Manifest struct { + SchemaVersion int `yaml:"schema-version,omitempty" json:"schema_version,omitempty"` + ID string `yaml:"id,omitempty" json:"id,omitempty"` + Name string `yaml:"name,omitempty" json:"name,omitempty"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Author string `yaml:"author,omitempty" json:"author,omitempty"` + Version string `yaml:"version,omitempty" json:"version,omitempty"` + ReleaseTag string `yaml:"release-tag,omitempty" json:"release_tag,omitempty"` + Repository string `yaml:"repository,omitempty" json:"repository,omitempty"` + Logo string `yaml:"logo,omitempty" json:"logo,omitempty"` + Homepage string `yaml:"homepage,omitempty" json:"homepage,omitempty"` + License string `yaml:"license,omitempty" json:"license,omitempty"` + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` + SourceID string `yaml:"source-id,omitempty" json:"source_id,omitempty"` + SourceName string `yaml:"source-name,omitempty" json:"source_name,omitempty"` + SourceURL string `yaml:"source-url,omitempty" json:"source_url,omitempty"` + Install InstallPlan `yaml:"install,omitempty" json:"install,omitempty"` +} + +func ManifestFromRelease(source Source, plugin Plugin, release Release) (Manifest, error) { + version, errVersion := ReleaseVersion(release) + if errVersion != nil { + return Manifest{}, errVersion + } + return manifestFromPlugin(source, plugin, Manifest{ + Version: version, + ReleaseTag: strings.TrimSpace(release.TagName), + Repository: strings.TrimSpace(plugin.Repository), + Install: InstallPlan{Type: InstallTypeGitHubRelease}, + }), nil +} + +func ManifestFromPlugin(source Source, plugin Plugin) (Manifest, error) { + if errValidate := ValidatePlugin(plugin); errValidate != nil { + return Manifest{}, errValidate + } + switch PluginInstallType(plugin) { + case InstallTypeDirect: + return Manifest{ + SchemaVersion: SchemaVersionV2, + ID: strings.TrimSpace(plugin.ID), + Version: strings.TrimSpace(plugin.Version), + SourceID: strings.TrimSpace(source.ID), + SourceName: strings.TrimSpace(source.Name), + SourceURL: strings.TrimSpace(source.URL), + Install: InstallPlan{Type: InstallTypeDirect}, + }, nil + case InstallTypeGitHubRelease: + return Manifest{}, fmt.Errorf("github-release manifest requires a resolved release") + default: + return Manifest{}, fmt.Errorf("unsupported install type %q", plugin.Install.Type) + } +} + +func manifestFromPlugin(source Source, plugin Plugin, base Manifest) Manifest { + base.ID = strings.TrimSpace(plugin.ID) + base.Name = strings.TrimSpace(plugin.Name) + base.Description = strings.TrimSpace(plugin.Description) + base.Author = strings.TrimSpace(plugin.Author) + base.Logo = strings.TrimSpace(plugin.Logo) + base.Homepage = strings.TrimSpace(plugin.Homepage) + base.License = strings.TrimSpace(plugin.License) + base.Tags = append([]string(nil), plugin.Tags...) + base.SourceID = strings.TrimSpace(source.ID) + base.SourceName = strings.TrimSpace(source.Name) + base.SourceURL = strings.TrimSpace(source.URL) + return base +} + +func (m Manifest) Plugin() Plugin { + return Plugin{ + ID: strings.TrimSpace(m.ID), + Name: strings.TrimSpace(m.Name), + Description: strings.TrimSpace(m.Description), + Author: strings.TrimSpace(m.Author), + Version: strings.TrimSpace(m.Version), + Repository: strings.TrimSpace(m.Repository), + Logo: strings.TrimSpace(m.Logo), + Homepage: strings.TrimSpace(m.Homepage), + License: strings.TrimSpace(m.License), + Tags: append([]string(nil), m.Tags...), + Install: NormalizeInstallPlan(m.Install), + } +} + +func (m Manifest) InstallType() string { + installType := strings.ToLower(strings.TrimSpace(m.Install.Type)) + if installType == "" { + return InstallTypeGitHubRelease + } + return installType +} + +func (m Manifest) Validate() error { + version := strings.TrimSpace(m.Version) + if version == "" { + return fmt.Errorf("missing required field version") + } + if !validPluginVersion(normalizeVersion(version)) { + return fmt.Errorf("invalid plugin version %q", m.Version) + } + switch m.InstallType() { + case InstallTypeDirect: + if m.SchemaVersion != 0 && m.SchemaVersion != SchemaVersionV2 { + return fmt.Errorf("unsupported schema-version %d", m.SchemaVersion) + } + if errID := validateManifestPluginID(m.ID); errID != nil { + return errID + } + plan := NormalizeInstallPlan(m.Install) + plan.Type = InstallTypeDirect + if len(plan.Artifacts) > 0 { + return ValidateInstallPlan(plan) + } + return validateManifestSourceURL(m.SourceURL) + case InstallTypeGitHubRelease: + releaseTag := strings.TrimSpace(m.ReleaseTag) + if releaseTag == "" { + return fmt.Errorf("missing required field release-tag") + } + plugin := m.Plugin() + plugin.Install = InstallPlan{Type: InstallTypeGitHubRelease} + if errValidate := ValidatePlugin(plugin); errValidate != nil { + return errValidate + } + releaseVersion, errVersion := ReleaseVersion(Release{TagName: releaseTag}) + if errVersion != nil { + return errVersion + } + if releaseVersion != normalizeVersion(version) { + return fmt.Errorf("release-tag %q resolves version %q, want %q", releaseTag, releaseVersion, normalizeVersion(version)) + } + return nil + default: + return fmt.Errorf("unsupported install type %q", m.Install.Type) + } +} + +func validateManifestPluginID(id string) error { + id = strings.TrimSpace(id) + if id == "" { + return fmt.Errorf("missing required field id") + } + if !validPluginID(id) { + return fmt.Errorf("invalid plugin id %q", id) + } + return nil +} + +func validateManifestSourceURL(sourceURL string) error { + sourceURL = strings.TrimSpace(sourceURL) + if sourceURL == "" { + return fmt.Errorf("missing required field source-url") + } + parsed, errParse := url.Parse(sourceURL) + if errParse != nil || parsed.Scheme == "" || parsed.Host == "" { + return fmt.Errorf("invalid source-url") + } + if parsed.Scheme != "https" && parsed.Scheme != "http" { + return fmt.Errorf("source-url must use http or https") + } + if hasSensitiveQueryParameter(parsed) { + return fmt.Errorf("source-url contains sensitive query parameter") + } + return nil +} diff --git a/internal/pluginstore/registry.go b/internal/pluginstore/registry.go index 8d248fc0..1b46a64b 100644 --- a/internal/pluginstore/registry.go +++ b/internal/pluginstore/registry.go @@ -16,6 +16,10 @@ const ( DefaultSourceID = "official" DefaultSourceName = "Official" SchemaVersion = 1 + SchemaVersionV2 = 2 + + InstallTypeGitHubRelease = "github-release" + InstallTypeDirect = "direct" ) var pluginVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z.+-]*$`) @@ -33,16 +37,42 @@ type Registry struct { } type Plugin struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Author string `json:"author"` - Version string `json:"version"` - Repository string `json:"repository"` - Logo string `json:"logo,omitempty"` - Homepage string `json:"homepage,omitempty"` - License string `json:"license,omitempty"` - Tags []string `json:"tags,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Author string `json:"author"` + Version string `json:"version"` + Versions []Version `json:"versions,omitempty"` + Repository string `json:"repository,omitempty"` + Logo string `json:"logo,omitempty"` + Homepage string `json:"homepage,omitempty"` + License string `json:"license,omitempty"` + Tags []string `json:"tags,omitempty"` + Install InstallPlan `json:"install,omitempty"` + AuthRequired bool `json:"auth_required,omitempty"` +} + +type Version struct { + Version string `json:"version"` + Install InstallPlan `json:"install,omitempty"` +} + +type InstallPlan struct { + Type string `yaml:"type,omitempty" json:"type,omitempty"` + Artifacts []Artifact `yaml:"artifacts,omitempty" json:"artifacts,omitempty"` +} + +type Artifact struct { + GOOS string `yaml:"goos,omitempty" json:"goos,omitempty"` + GOARCH string `yaml:"goarch,omitempty" json:"goarch,omitempty"` + URL string `yaml:"url,omitempty" json:"url,omitempty"` + SHA256 string `yaml:"sha256,omitempty" json:"sha256,omitempty"` + Size int64 `yaml:"size,omitempty" json:"size,omitempty"` +} + +type Platform struct { + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` } func DefaultSource() Source { @@ -121,6 +151,12 @@ func normalizeRegistry(registry *Registry) { plugin.Logo = strings.TrimSpace(plugin.Logo) plugin.Homepage = strings.TrimSpace(plugin.Homepage) plugin.License = strings.TrimSpace(plugin.License) + plugin.Install = NormalizeInstallPlan(plugin.Install) + for versionIndex := range plugin.Versions { + version := &plugin.Versions[versionIndex] + version.Version = normalizeVersion(version.Version) + version.Install = NormalizeInstallPlan(version.Install) + } for tagIndex := range plugin.Tags { plugin.Tags[tagIndex] = strings.TrimSpace(plugin.Tags[tagIndex]) } @@ -128,11 +164,14 @@ func normalizeRegistry(registry *Registry) { } func ValidateRegistry(registry Registry) error { - if registry.SchemaVersion != SchemaVersion { + if registry.SchemaVersion != SchemaVersion && registry.SchemaVersion != SchemaVersionV2 { return fmt.Errorf("unsupported schema_version %d", registry.SchemaVersion) } seen := make(map[string]struct{}, len(registry.Plugins)) for index, plugin := range registry.Plugins { + if registry.SchemaVersion == SchemaVersion && PluginInstallType(plugin) == InstallTypeDirect { + return fmt.Errorf("plugins[%d]: direct install requires schema_version %d", index, SchemaVersionV2) + } if errValidate := ValidatePlugin(plugin); errValidate != nil { return fmt.Errorf("plugins[%d]: %w", index, errValidate) } @@ -151,7 +190,10 @@ func ValidatePlugin(plugin Plugin) error { "name": plugin.Name, "description": plugin.Description, "author": plugin.Author, - "repository": plugin.Repository, + } + installType := PluginInstallType(plugin) + if installType == InstallTypeGitHubRelease { + required["repository"] = plugin.Repository } for field, value := range required { if strings.TrimSpace(value) == "" { @@ -166,12 +208,202 @@ func ValidatePlugin(plugin Plugin) error { if version := strings.TrimSpace(plugin.Version); version != "" && !validPluginVersion(version) { return fmt.Errorf("invalid plugin version %q", plugin.Version) } - if _, _, errRepository := GitHubRepositoryParts(plugin.Repository); errRepository != nil { - return errRepository + switch installType { + case InstallTypeGitHubRelease: + if _, _, errRepository := GitHubRepositoryParts(plugin.Repository); errRepository != nil { + return errRepository + } + case InstallTypeDirect: + if strings.TrimSpace(plugin.Version) == "" { + return fmt.Errorf("missing required field version") + } + if errPlan := ValidateInstallPlan(plugin.Install); errPlan != nil { + return errPlan + } + if errVersions := ValidatePluginVersions(plugin); errVersions != nil { + return errVersions + } + default: + return fmt.Errorf("unsupported install type %q", plugin.Install.Type) } return nil } +func ValidatePluginVersions(plugin Plugin) error { + if len(plugin.Versions) == 0 { + return nil + } + seen := make(map[string]struct{}, len(plugin.Versions)) + for index, version := range plugin.Versions { + version.Version = normalizeVersion(version.Version) + if !validPluginVersion(version.Version) { + return fmt.Errorf("versions[%d]: invalid plugin version %q", index, version.Version) + } + if _, exists := seen[version.Version]; exists { + return fmt.Errorf("versions[%d]: duplicate plugin version %q", index, version.Version) + } + seen[version.Version] = struct{}{} + installType := strings.ToLower(strings.TrimSpace(version.Install.Type)) + if installType == "" { + installType = PluginInstallType(plugin) + version.Install.Type = installType + } + if installType != PluginInstallType(plugin) { + return fmt.Errorf("versions[%d]: install type %q does not match plugin install type %q", index, installType, PluginInstallType(plugin)) + } + if errPlan := ValidateInstallPlan(version.Install); errPlan != nil { + return fmt.Errorf("versions[%d]: %w", index, errPlan) + } + } + return nil +} + +func PluginInstallType(plugin Plugin) string { + installType := strings.ToLower(strings.TrimSpace(plugin.Install.Type)) + if installType == "" { + return InstallTypeGitHubRelease + } + return installType +} + +func NormalizeInstallPlan(plan InstallPlan) InstallPlan { + plan.Type = strings.ToLower(strings.TrimSpace(plan.Type)) + for index := range plan.Artifacts { + artifact := &plan.Artifacts[index] + artifact.GOOS = normalizeGOOS(artifact.GOOS) + artifact.GOARCH = normalizeGOARCH(artifact.GOARCH) + artifact.URL = strings.TrimSpace(artifact.URL) + artifact.SHA256 = strings.ToLower(strings.TrimSpace(artifact.SHA256)) + } + return plan +} + +func ValidateInstallPlan(plan InstallPlan) error { + plan = NormalizeInstallPlan(plan) + if plan.Type == "" { + return fmt.Errorf("missing install type") + } + if plan.Type != InstallTypeDirect && plan.Type != InstallTypeGitHubRelease { + return fmt.Errorf("unsupported install type %q", plan.Type) + } + if plan.Type != InstallTypeDirect { + return nil + } + if len(plan.Artifacts) == 0 { + return fmt.Errorf("direct install requires at least one artifact") + } + for index, artifact := range plan.Artifacts { + if errArtifact := ValidateArtifact(artifact); errArtifact != nil { + return fmt.Errorf("artifacts[%d]: %w", index, errArtifact) + } + } + return nil +} + +func ValidateArtifact(artifact Artifact) error { + artifact.GOOS = normalizeGOOS(artifact.GOOS) + artifact.GOARCH = normalizeGOARCH(artifact.GOARCH) + artifact.URL = strings.TrimSpace(artifact.URL) + artifact.SHA256 = strings.ToLower(strings.TrimSpace(artifact.SHA256)) + if artifact.GOOS == "" { + return fmt.Errorf("missing goos") + } + if artifact.GOARCH == "" { + return fmt.Errorf("missing goarch") + } + if artifact.URL == "" { + return fmt.Errorf("missing url") + } + parsed, errParse := url.Parse(artifact.URL) + if errParse != nil || parsed.Scheme == "" || parsed.Host == "" { + return fmt.Errorf("invalid artifact url") + } + if parsed.Scheme != "https" && parsed.Scheme != "http" { + return fmt.Errorf("artifact url must use http or https") + } + if hasSensitiveQueryParameter(parsed) { + return fmt.Errorf("artifact url contains sensitive query parameter") + } + if artifact.SHA256 == "" { + return fmt.Errorf("missing sha256") + } + if len(artifact.SHA256) != sha256.Size*2 { + return fmt.Errorf("invalid sha256 length") + } + if _, errDecode := hex.DecodeString(artifact.SHA256); errDecode != nil { + return fmt.Errorf("invalid sha256: %w", errDecode) + } + if artifact.Size < 0 { + return fmt.Errorf("invalid size") + } + return nil +} + +func PluginPlatforms(plugin Plugin) []Platform { + if PluginInstallType(plugin) != InstallTypeDirect { + return nil + } + artifacts := PluginArtifacts(plugin) + seen := make(map[Platform]struct{}, len(artifacts)) + platforms := make([]Platform, 0, len(artifacts)) + for _, artifact := range artifacts { + platform := Platform{GOOS: artifact.GOOS, GOARCH: artifact.GOARCH} + if platform.GOOS == "" || platform.GOARCH == "" { + continue + } + if _, exists := seen[platform]; exists { + continue + } + seen[platform] = struct{}{} + platforms = append(platforms, platform) + } + return platforms +} + +func PluginArtifacts(plugin Plugin) []Artifact { + if PluginInstallType(plugin) != InstallTypeDirect { + return nil + } + artifacts := append([]Artifact(nil), NormalizeInstallPlan(plugin.Install).Artifacts...) + for _, version := range plugin.Versions { + artifacts = append(artifacts, NormalizeInstallPlan(version.Install).Artifacts...) + } + return artifacts +} + +func normalizeGOOS(goos string) string { + switch strings.ToLower(strings.TrimSpace(goos)) { + case "mac", "macos", "osx": + return "darwin" + default: + return strings.ToLower(strings.TrimSpace(goos)) + } +} + +func normalizeGOARCH(goarch string) string { + switch strings.ToLower(strings.TrimSpace(goarch)) { + case "x64", "x86_64": + return "amd64" + case "aarch64": + return "arm64" + default: + return strings.ToLower(strings.TrimSpace(goarch)) + } +} + +func hasSensitiveQueryParameter(parsed *url.URL) bool { + if parsed == nil || parsed.RawQuery == "" { + return false + } + for key := range parsed.Query() { + switch strings.ToLower(strings.TrimSpace(key)) { + case "token", "access_token", "access_key", "secret", "secret_key", "api_key": + return true + } + } + return false +} + func validPluginVersion(version string) bool { return version != "" && !strings.HasPrefix(version, "v") && pluginVersionPattern.MatchString(version) } diff --git a/internal/pluginstore/registry_test.go b/internal/pluginstore/registry_test.go index 73aba00a..da0a2ce8 100644 --- a/internal/pluginstore/registry_test.go +++ b/internal/pluginstore/registry_test.go @@ -83,6 +83,127 @@ func TestValidateRegistryAllowsMissingVersion(t *testing.T) { } } +func TestParseRegistrySupportsDirectInstall(t *testing.T) { + t.Parallel() + + registry, errParse := ParseRegistry([]byte(`{ + "schema_version": 2, + "plugins": [{ + "id": "sample-provider", + "name": "Sample Provider", + "description": "Adds sample provider support.", + "author": "author-name", + "version": "0.2.0", + "auth_required": true, + "install": { + "type": "direct", + "artifacts": [{ + "goos": "windows", + "goarch": "x64", + "url": "https://downloads.example/sample-provider.zip", + "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + }] + }, + "versions": [{ + "version": "0.1.0", + "install": { + "type": "direct", + "artifacts": [{ + "goos": "linux", + "goarch": "aarch64", + "url": "https://downloads.example/sample-provider-0.1.0.zip", + "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + }] + } + }] + }] + }`)) + if errParse != nil { + t.Fatalf("ParseRegistry() error = %v", errParse) + } + plugin, ok := registry.PluginByID("sample-provider") + if !ok { + t.Fatal("PluginByID(sample-provider) missing") + } + if PluginInstallType(plugin) != InstallTypeDirect { + t.Fatalf("install type = %q, want direct", PluginInstallType(plugin)) + } + if !plugin.AuthRequired { + t.Fatal("AuthRequired = false, want true") + } + if len(plugin.Versions) != 1 || plugin.Versions[0].Version != "0.1.0" { + t.Fatalf("versions = %#v, want normalized 0.1.0 entry", plugin.Versions) + } + platforms := PluginPlatforms(plugin) + if len(platforms) != 2 || + platforms[0].GOOS != "windows" || platforms[0].GOARCH != "amd64" || + platforms[1].GOOS != "linux" || platforms[1].GOARCH != "arm64" { + t.Fatalf("platforms = %#v, want normalized windows/amd64 and linux/arm64", platforms) + } + artifacts := PluginArtifacts(plugin) + if len(artifacts) != 2 || + artifacts[0].GOOS != "windows" || artifacts[0].GOARCH != "amd64" || + artifacts[1].GOOS != "linux" || artifacts[1].GOARCH != "arm64" { + t.Fatalf("artifacts = %#v, want normalized top-level and version artifacts", artifacts) + } +} + +func TestValidateRegistryRejectsInvalidDirectInstall(t *testing.T) { + t.Parallel() + + registry := Registry{SchemaVersion: SchemaVersionV2, Plugins: []Plugin{{ + ID: "sample-provider", + Name: "Sample Provider", + Description: "Adds sample provider support.", + Author: "author-name", + Version: "0.2.0", + Install: InstallPlan{ + Type: InstallTypeDirect, + Artifacts: []Artifact{{ + GOOS: "linux", + GOARCH: "amd64", + URL: "https://downloads.example/sample.zip?token=secret", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }}, + }, + }}} + errValidate := ValidateRegistry(registry) + if errValidate == nil { + t.Fatal("ValidateRegistry() error = nil") + } + if !strings.Contains(errValidate.Error(), "sensitive query") { + t.Fatalf("ValidateRegistry() error = %v, want sensitive query", errValidate) + } +} + +func TestValidateRegistryRejectsDirectInstallInSchemaV1(t *testing.T) { + t.Parallel() + + registry := Registry{SchemaVersion: SchemaVersion, Plugins: []Plugin{{ + ID: "sample-provider", + Name: "Sample Provider", + Description: "Adds sample provider support.", + Author: "author-name", + Version: "0.2.0", + Install: InstallPlan{ + Type: InstallTypeDirect, + Artifacts: []Artifact{{ + GOOS: "linux", + GOARCH: "amd64", + URL: "https://downloads.example/sample.zip", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }}, + }, + }}} + errValidate := ValidateRegistry(registry) + if errValidate == nil { + t.Fatal("ValidateRegistry() error = nil") + } + if !strings.Contains(errValidate.Error(), "schema_version 2") { + t.Fatalf("ValidateRegistry() error = %v, want schema_version 2", errValidate) + } +} + func TestValidateRegistryRejectsInvalidEntries(t *testing.T) { t.Parallel() @@ -102,7 +223,7 @@ func TestValidateRegistryRejectsInvalidEntries(t *testing.T) { { name: "schema version", mutate: func(registry *Registry) { - registry.SchemaVersion = 2 + registry.SchemaVersion = 3 }, wantErr: "unsupported schema_version", }, diff --git a/sdk/pluginstore/pluginstore.go b/sdk/pluginstore/pluginstore.go index 93785b9c..73da2550 100644 --- a/sdk/pluginstore/pluginstore.go +++ b/sdk/pluginstore/pluginstore.go @@ -4,7 +4,6 @@ package pluginstore import ( "context" - "fmt" "net/http" "strings" @@ -16,15 +15,35 @@ const ( DefaultSourceID = internalpluginstore.DefaultSourceID DefaultSourceName = internalpluginstore.DefaultSourceName SchemaVersion = internalpluginstore.SchemaVersion + SchemaVersionV2 = internalpluginstore.SchemaVersionV2 + + InstallTypeGitHubRelease = internalpluginstore.InstallTypeGitHubRelease + InstallTypeDirect = internalpluginstore.InstallTypeDirect + + RequestKindRegistry = internalpluginstore.RequestKindRegistry + RequestKindMetadata = internalpluginstore.RequestKindMetadata + RequestKindArtifact = internalpluginstore.RequestKindArtifact + + AuthTypeNone = internalpluginstore.AuthTypeNone + AuthTypeBearer = internalpluginstore.AuthTypeBearer + AuthTypeBasic = internalpluginstore.AuthTypeBasic + AuthTypeHeader = internalpluginstore.AuthTypeHeader + AuthTypeGitHubToken = internalpluginstore.AuthTypeGitHubToken ) type Source = internalpluginstore.Source type Registry = internalpluginstore.Registry type Plugin = internalpluginstore.Plugin +type Version = internalpluginstore.Version type Release = internalpluginstore.Release type ReleaseAsset = internalpluginstore.ReleaseAsset type InstallOptions = internalpluginstore.InstallOptions type InstallResult = internalpluginstore.InstallResult +type InstallPlan = internalpluginstore.InstallPlan +type Artifact = internalpluginstore.Artifact +type Platform = internalpluginstore.Platform +type Manifest = internalpluginstore.Manifest +type AuthConfig = internalpluginstore.AuthConfig type HTTPDoer interface { Do(*http.Request) (*http.Response, error) @@ -36,27 +55,18 @@ type Client struct { inner internalpluginstore.Client } -type Manifest struct { - ID string `yaml:"id,omitempty" json:"id,omitempty"` - Name string `yaml:"name,omitempty" json:"name,omitempty"` - Description string `yaml:"description,omitempty" json:"description,omitempty"` - Author string `yaml:"author,omitempty" json:"author,omitempty"` - Version string `yaml:"version,omitempty" json:"version,omitempty"` - ReleaseTag string `yaml:"release-tag,omitempty" json:"release_tag,omitempty"` - Repository string `yaml:"repository,omitempty" json:"repository,omitempty"` - Logo string `yaml:"logo,omitempty" json:"logo,omitempty"` - Homepage string `yaml:"homepage,omitempty" json:"homepage,omitempty"` - License string `yaml:"license,omitempty" json:"license,omitempty"` - Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` - SourceID string `yaml:"source-id,omitempty" json:"source_id,omitempty"` - SourceName string `yaml:"source-name,omitempty" json:"source_name,omitempty"` - SourceURL string `yaml:"source-url,omitempty" json:"source_url,omitempty"` +func NewClient(httpClient HTTPDoer, registryURL string) Client { + return Client{inner: internalpluginstore.Client{ + HTTPClient: httpClient, + RegistryURL: strings.TrimSpace(registryURL), + }} } -func NewClient(httpClient HTTPDoer, registryURL string) Client { +func NewClientWithAuth(httpClient HTTPDoer, registryURL string, auth []AuthConfig) Client { return Client{inner: internalpluginstore.Client{ HTTPClient: httpClient, RegistryURL: strings.TrimSpace(registryURL), + Auth: internalpluginstore.NormalizeAuthConfigs(auth), }} } @@ -76,6 +86,26 @@ func ValidatePlugin(plugin Plugin) error { return internalpluginstore.ValidatePlugin(plugin) } +func PluginInstallType(plugin Plugin) string { + return internalpluginstore.PluginInstallType(plugin) +} + +func PluginPlatforms(plugin Plugin) []Platform { + return internalpluginstore.PluginPlatforms(plugin) +} + +func PluginArtifacts(plugin Plugin) []Artifact { + return internalpluginstore.PluginArtifacts(plugin) +} + +func NormalizeAuthConfigs(auth []AuthConfig) []AuthConfig { + return internalpluginstore.NormalizeAuthConfigs(auth) +} + +func AuthConfigured(auth []AuthConfig, requestURL string, kind string) bool { + return internalpluginstore.AuthConfigured(auth, requestURL, kind) +} + func UpdateAvailable(installed, latest string) bool { return internalpluginstore.UpdateAvailable(installed, latest) } @@ -85,63 +115,11 @@ func ReleaseVersion(release Release) (string, error) { } func ManifestFromRelease(source Source, plugin Plugin, release Release) (Manifest, error) { - version, errVersion := internalpluginstore.ReleaseVersion(release) - if errVersion != nil { - return Manifest{}, errVersion - } - return Manifest{ - ID: strings.TrimSpace(plugin.ID), - Name: strings.TrimSpace(plugin.Name), - Description: strings.TrimSpace(plugin.Description), - Author: strings.TrimSpace(plugin.Author), - Version: version, - ReleaseTag: strings.TrimSpace(release.TagName), - Repository: strings.TrimSpace(plugin.Repository), - Logo: strings.TrimSpace(plugin.Logo), - Homepage: strings.TrimSpace(plugin.Homepage), - License: strings.TrimSpace(plugin.License), - Tags: append([]string(nil), plugin.Tags...), - SourceID: strings.TrimSpace(source.ID), - SourceName: strings.TrimSpace(source.Name), - SourceURL: strings.TrimSpace(source.URL), - }, nil -} - -func (m Manifest) Plugin() Plugin { - return Plugin{ - ID: strings.TrimSpace(m.ID), - Name: strings.TrimSpace(m.Name), - Description: strings.TrimSpace(m.Description), - Author: strings.TrimSpace(m.Author), - Version: strings.TrimSpace(m.Version), - Repository: strings.TrimSpace(m.Repository), - Logo: strings.TrimSpace(m.Logo), - Homepage: strings.TrimSpace(m.Homepage), - License: strings.TrimSpace(m.License), - Tags: append([]string(nil), m.Tags...), - } -} - -func (m Manifest) Validate() error { - version := strings.TrimSpace(m.Version) - if version == "" { - return fmt.Errorf("missing required field version") - } - releaseTag := strings.TrimSpace(m.ReleaseTag) - if releaseTag == "" { - return fmt.Errorf("missing required field release-tag") - } - if errValidate := internalpluginstore.ValidatePlugin(m.Plugin()); errValidate != nil { - return errValidate - } - releaseVersion, errVersion := internalpluginstore.ReleaseVersion(internalpluginstore.Release{TagName: releaseTag}) - if errVersion != nil { - return errVersion - } - if releaseVersion != version { - return fmt.Errorf("release-tag %q resolves version %q, want %q", releaseTag, releaseVersion, version) - } - return nil + return internalpluginstore.ManifestFromRelease(source, plugin, release) +} + +func ManifestFromPlugin(source Source, plugin Plugin) (Manifest, error) { + return internalpluginstore.ManifestFromPlugin(source, plugin) } func (c Client) FetchRegistry(ctx context.Context) (Registry, error) { @@ -165,8 +143,5 @@ func (c Client) InstallVersion(ctx context.Context, plugin Plugin, releaseTag st } func (c Client) InstallManifest(ctx context.Context, manifest Manifest, options InstallOptions) (InstallResult, error) { - if errValidate := manifest.Validate(); errValidate != nil { - return InstallResult{}, errValidate - } - return c.InstallVersion(ctx, manifest.Plugin(), manifest.ReleaseTag, manifest.Version, options) + return c.inner.InstallManifest(ctx, manifest, options) } diff --git a/sdk/pluginstore/pluginstore_test.go b/sdk/pluginstore/pluginstore_test.go index 607f379c..4262950d 100644 --- a/sdk/pluginstore/pluginstore_test.go +++ b/sdk/pluginstore/pluginstore_test.go @@ -54,6 +54,78 @@ func TestManifestFromReleaseBuildsPinnedManifest(t *testing.T) { } } +func TestManifestFromPluginBuildsDirectManifest(t *testing.T) { + manifest, errManifest := ManifestFromPlugin( + DefaultSource(), + Plugin{ + ID: "sample-provider", + Name: "Sample Provider", + Description: "Adds sample provider support.", + Author: "author-name", + Version: "0.4.0", + Install: InstallPlan{ + Type: InstallTypeDirect, + Artifacts: []Artifact{{ + GOOS: "linux", + GOARCH: "amd64", + URL: "https://downloads.example/sample-provider.zip", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }}, + }, + }, + ) + if errManifest != nil { + t.Fatalf("ManifestFromPlugin() error = %v", errManifest) + } + if errValidate := manifest.Validate(); errValidate != nil { + t.Fatalf("Validate() error = %v", errValidate) + } + if manifest.SchemaVersion != SchemaVersionV2 || manifest.InstallType() != InstallTypeDirect || manifest.ReleaseTag != "" { + t.Fatalf("manifest = %#v, want v2 direct without release tag", manifest) + } + if manifest.SourceURL != DefaultRegistryURL || len(manifest.Install.Artifacts) != 0 { + t.Fatalf("manifest source/artifacts = %q/%d, want source URL without artifacts", manifest.SourceURL, len(manifest.Install.Artifacts)) + } +} + +func TestPluginArtifactsIncludesVersionArtifacts(t *testing.T) { + plugin := Plugin{ + ID: "sample-provider", + Name: "Sample Provider", + Description: "Adds sample provider support.", + Author: "author-name", + Version: "0.4.0", + Install: InstallPlan{ + Type: InstallTypeDirect, + Artifacts: []Artifact{{ + GOOS: "windows", + GOARCH: "x64", + URL: "https://downloads.example/sample-provider.zip", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }}, + }, + Versions: []Version{{ + Version: "0.3.0", + Install: InstallPlan{ + Type: InstallTypeDirect, + Artifacts: []Artifact{{ + GOOS: "linux", + GOARCH: "aarch64", + URL: "https://downloads.example/sample-provider-0.3.0.zip", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }}, + }, + }}, + } + + artifacts := PluginArtifacts(plugin) + if len(artifacts) != 2 || + artifacts[0].GOARCH != "amd64" || + artifacts[1].GOARCH != "arm64" { + t.Fatalf("PluginArtifacts() = %#v, want normalized top-level and version artifacts", artifacts) + } +} + func validTestManifest() Manifest { return Manifest{ ID: "sample-provider", -- 2.51.2