package pluginstore import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/url" "regexp" "strings") const ( DefaultRegistryURL = "https://raw.githubusercontent.com/router-for-me/CLIProxyAPI-Plugins-Store/main/registry.json" DefaultSourceID = "official" DefaultSourceName = "Official" SchemaVersion = 1 SchemaVersionV2 = 2 InstallTypeGitHubRelease = "github-release" InstallTypeDirect = "direct") var pluginVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z.+-]*$`)var pluginIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) type Source struct { ID string `json:"id"` Name string `json:"name"` URL string `json:"url"`} type Registry struct { SchemaVersion int `json:"schema_version"` Plugins []Plugin `json:"plugins"`} type Plugin struct { 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 { return Source{ ID: DefaultSourceID, Name: DefaultSourceName, URL: DefaultRegistryURL, }} func NormalizeSources(registryURLs []string) ([]Source, error) { out := []Source{DefaultSource()} seenIDs := map[string]string{DefaultSourceID: DefaultRegistryURL} seenURLs := map[string]struct{}{DefaultRegistryURL: {}} for _, registryURL := range registryURLs { registryURL = strings.TrimSpace(registryURL) if registryURL == "" { continue } if _, exists := seenURLs[registryURL]; exists { continue } source := Source{ ID: SourceID(registryURL), Name: SourceName(registryURL), URL: registryURL, } if existingURL, exists := seenIDs[source.ID]; exists { return nil, fmt.Errorf("plugin store source id collision for %q and %q", existingURL, registryURL) } seenIDs[source.ID] = registryURL seenURLs[registryURL] = struct{}{} out = append(out, source) } return out, nil} func SourceID(registryURL string) string { sum := sha256.Sum256([]byte(strings.TrimSpace(registryURL))) return "source-" + hex.EncodeToString(sum[:])[:12]} func SourceName(registryURL string) string { parsed, errParse := url.Parse(strings.TrimSpace(registryURL)) if errParse != nil || strings.TrimSpace(parsed.Host) == "" { return strings.TrimSpace(registryURL) } return parsed.Host} func ParseRegistry(data []byte) (Registry, error) { var registry Registry decoder := json.NewDecoder(bytes.NewReader(data)) if errDecode := decoder.Decode(®istry); errDecode != nil { return Registry{}, fmt.Errorf("decode registry: %w", errDecode) } normalizeRegistry(®istry) if errValidate := ValidateRegistry(registry); errValidate != nil { return Registry{}, errValidate } return registry, nil} func normalizeRegistry(registry *Registry) { if registry == nil { return } for index := range registry.Plugins { plugin := ®istry.Plugins[index] plugin.ID = strings.TrimSpace(plugin.ID) plugin.Name = strings.TrimSpace(plugin.Name) plugin.Description = strings.TrimSpace(plugin.Description) plugin.Author = strings.TrimSpace(plugin.Author) plugin.Version = strings.TrimSpace(plugin.Version) plugin.Repository = strings.TrimSpace(plugin.Repository) 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]) } }} func ValidateRegistry(registry Registry) error { 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) } id := strings.TrimSpace(plugin.ID) if _, exists := seen[id]; exists { return fmt.Errorf("plugins[%d]: duplicate plugin id %q", index, id) } seen[id] = struct{}{} } return nil} func ValidatePlugin(plugin Plugin) error { required := map[string]string{ "id": plugin.ID, "name": plugin.Name, "description": plugin.Description, "author": plugin.Author, } installType := PluginInstallType(plugin) if installType == InstallTypeGitHubRelease { required["repository"] = plugin.Repository } for field, value := range required { if strings.TrimSpace(value) == "" { return fmt.Errorf("missing required field %s", field) } } if !validPluginID(strings.TrimSpace(plugin.ID)) { return fmt.Errorf("invalid plugin id %q", plugin.ID) } // The version is optional since the latest release is the source of truth; // when present it is only used as a display fallback and must be valid. if version := strings.TrimSpace(plugin.Version); version != "" && !validPluginVersion(version) { return fmt.Errorf("invalid plugin version %q", plugin.Version) } 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)} func validPluginID(id string) bool { return pluginIDPattern.MatchString(id)} func GitHubRepositoryParts(repository string) (string, string, error) { repository = strings.TrimSpace(repository) parsed, errParse := url.Parse(repository) if errParse != nil { return "", "", fmt.Errorf("invalid repository URL: %w", errParse) } if parsed.Scheme != "https" || parsed.Host != "github.com" || parsed.RawQuery != "" || parsed.Fragment != "" { return "", "", fmt.Errorf("repository must be https://github.com/{owner}/{repo}") } segments := strings.Split(strings.Trim(parsed.EscapedPath(), "/"), "/") if len(segments) != 2 || segments[0] == "" || segments[1] == "" { return "", "", fmt.Errorf("repository must be https://github.com/{owner}/{repo}") } owner, errOwner := url.PathUnescape(segments[0]) if errOwner != nil { return "", "", fmt.Errorf("invalid repository owner: %w", errOwner) } repo, errRepo := url.PathUnescape(segments[1]) if errRepo != nil { return "", "", fmt.Errorf("invalid repository name: %w", errRepo) } if strings.HasSuffix(repo, ".git") { return "", "", fmt.Errorf("repository must be https://github.com/{owner}/{repo}") } return owner, repo, nil} func (r Registry) PluginByID(id string) (Plugin, bool) { id = strings.TrimSpace(id) for _, plugin := range r.Plugins { if strings.TrimSpace(plugin.ID) == id { return plugin, true } } return Plugin{}, false}
package pluginstore
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"regexp"
"strings"
)
const (
DefaultRegistryURL = "https://raw.githubusercontent.com/router-for-me/CLIProxyAPI-Plugins-Store/main/registry.json"
DefaultSourceID = "official"
DefaultSourceName = "Official"
SchemaVersion = 1
SchemaVersionV2 = 2
InstallTypeGitHubRelease = "github-release"
InstallTypeDirect = "direct"
)
var pluginVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z.+-]*$`)
var pluginIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
type Source struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
}
type Registry struct {
SchemaVersion int `json:"schema_version"`
Plugins []Plugin `json:"plugins"`
}
type Plugin struct {
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 {
return Source{
ID: DefaultSourceID,
Name: DefaultSourceName,
URL: DefaultRegistryURL,
}
}
func NormalizeSources(registryURLs []string) ([]Source, error) {
out := []Source{DefaultSource()}
seenIDs := map[string]string{DefaultSourceID: DefaultRegistryURL}
seenURLs := map[string]struct{}{DefaultRegistryURL: {}}
for _, registryURL := range registryURLs {
registryURL = strings.TrimSpace(registryURL)
if registryURL == "" {
continue
}
if _, exists := seenURLs[registryURL]; exists {
continue
}
source := Source{
ID: SourceID(registryURL),
Name: SourceName(registryURL),
URL: registryURL,
}
if existingURL, exists := seenIDs[source.ID]; exists {
return nil, fmt.Errorf("plugin store source id collision for %q and %q", existingURL, registryURL)
}
seenIDs[source.ID] = registryURL
seenURLs[registryURL] = struct{}{}
out = append(out, source)
}
return out, nil
}
func SourceID(registryURL string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(registryURL)))
return "source-" + hex.EncodeToString(sum[:])[:12]
}
func SourceName(registryURL string) string {
parsed, errParse := url.Parse(strings.TrimSpace(registryURL))
if errParse != nil || strings.TrimSpace(parsed.Host) == "" {
return strings.TrimSpace(registryURL)
}
return parsed.Host
}
func ParseRegistry(data []byte) (Registry, error) {
var registry Registry
decoder := json.NewDecoder(bytes.NewReader(data))
if errDecode := decoder.Decode(®istry); errDecode != nil {
return Registry{}, fmt.Errorf("decode registry: %w", errDecode)
}
normalizeRegistry(®istry)
if errValidate := ValidateRegistry(registry); errValidate != nil {
return Registry{}, errValidate
}
return registry, nil
}
func normalizeRegistry(registry *Registry) {
if registry == nil {
return
}
for index := range registry.Plugins {
plugin := ®istry.Plugins[index]
plugin.ID = strings.TrimSpace(plugin.ID)
plugin.Name = strings.TrimSpace(plugin.Name)
plugin.Description = strings.TrimSpace(plugin.Description)
plugin.Author = strings.TrimSpace(plugin.Author)
plugin.Version = strings.TrimSpace(plugin.Version)
plugin.Repository = strings.TrimSpace(plugin.Repository)
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])
}
}
}
func ValidateRegistry(registry Registry) error {
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)
}
id := strings.TrimSpace(plugin.ID)
if _, exists := seen[id]; exists {
return fmt.Errorf("plugins[%d]: duplicate plugin id %q", index, id)
}
seen[id] = struct{}{}
}
return nil
}
func ValidatePlugin(plugin Plugin) error {
required := map[string]string{
"id": plugin.ID,
"name": plugin.Name,
"description": plugin.Description,
"author": plugin.Author,
}
installType := PluginInstallType(plugin)
if installType == InstallTypeGitHubRelease {
required["repository"] = plugin.Repository
}
for field, value := range required {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("missing required field %s", field)
}
}
if !validPluginID(strings.TrimSpace(plugin.ID)) {
return fmt.Errorf("invalid plugin id %q", plugin.ID)
}
// The version is optional since the latest release is the source of truth;
// when present it is only used as a display fallback and must be valid.
if version := strings.TrimSpace(plugin.Version); version != "" && !validPluginVersion(version) {
return fmt.Errorf("invalid plugin version %q", plugin.Version)
}
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)
}
func validPluginID(id string) bool {
return pluginIDPattern.MatchString(id)
}
func GitHubRepositoryParts(repository string) (string, string, error) {
repository = strings.TrimSpace(repository)
parsed, errParse := url.Parse(repository)
if errParse != nil {
return "", "", fmt.Errorf("invalid repository URL: %w", errParse)
}
if parsed.Scheme != "https" || parsed.Host != "github.com" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", "", fmt.Errorf("repository must be https://github.com/{owner}/{repo}")
}
segments := strings.Split(strings.Trim(parsed.EscapedPath(), "/"), "/")
if len(segments) != 2 || segments[0] == "" || segments[1] == "" {
return "", "", fmt.Errorf("repository must be https://github.com/{owner}/{repo}")
}
owner, errOwner := url.PathUnescape(segments[0])
if errOwner != nil {
return "", "", fmt.Errorf("invalid repository owner: %w", errOwner)
}
repo, errRepo := url.PathUnescape(segments[1])
if errRepo != nil {
return "", "", fmt.Errorf("invalid repository name: %w", errRepo)
}
if strings.HasSuffix(repo, ".git") {
return "", "", fmt.Errorf("repository must be https://github.com/{owner}/{repo}")
}
return owner, repo, nil
}
func (r Registry) PluginByID(id string) (Plugin, bool) {
id = strings.TrimSpace(id)
for _, plugin := range r.Plugins {
if strings.TrimSpace(plugin.ID) == id {
return plugin, true
}
}
return Plugin{}, false
}