Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View file

@ -0,0 +1,471 @@
package pluginstore
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
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"`
}
// Secret holds short-lived credential material that can be overwritten after use.
type Secret []byte
// Clear overwrites the secret and releases its backing slice reference.
func (s *Secret) Clear() {
if s == nil {
return
}
for index := range *s {
(*s)[index] = 0
}
*s = nil
}
type ResolvedAuthConfig 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"`
Token Secret `yaml:"token,omitempty" json:"token,omitempty"`
Username Secret `yaml:"username,omitempty" json:"username,omitempty"`
Password Secret `yaml:"password,omitempty" json:"password,omitempty"`
HeaderName string `yaml:"header-name,omitempty" json:"header_name,omitempty"`
HeaderValue Secret `yaml:"header-value,omitempty" json:"header_value,omitempty"`
}
func (c *ResolvedAuthConfig) Clear() {
if c == nil {
return
}
c.Token.Clear()
c.Username.Clear()
c.Password.Clear()
c.HeaderValue.Clear()
c.ApplyTo = nil
}
func ClearResolvedAuthConfigs(auth []ResolvedAuthConfig) {
for index := range auth {
auth[index].Clear()
}
}
func ResolvedAuthForRequest(auth []ResolvedAuthConfig, requestURL string, kind string) (ResolvedAuthConfig, bool) {
item, ok := matchingResolvedAuthConfig(auth, requestURL, kind)
if !ok {
return ResolvedAuthConfig{}, false
}
return cloneResolvedAuthConfig(item), true
}
func ValidateResolvedAuthConfig(item ResolvedAuthConfig) error {
parsed, errParse := url.Parse(strings.TrimSpace(item.Match))
if errParse != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("plugin store resolved auth match is invalid")
}
if !strings.EqualFold(parsed.Scheme, "https") {
return fmt.Errorf("plugin store resolved auth match must use https")
}
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return fmt.Errorf("plugin store resolved auth match must not contain credentials, query, or fragment")
}
for _, kind := range item.ApplyTo {
switch strings.ToLower(strings.TrimSpace(kind)) {
case RequestKindRegistry, RequestKindMetadata, RequestKindArtifact:
default:
return fmt.Errorf("plugin store resolved auth has unsupported apply_to %q", kind)
}
}
switch strings.ToLower(strings.TrimSpace(item.Type)) {
case "", AuthTypeNone:
return nil
case AuthTypeBearer, AuthTypeGitHubToken:
if len(item.Token) == 0 {
return fmt.Errorf("plugin store resolved auth token is empty")
}
case AuthTypeBasic:
if len(item.Username) == 0 || len(item.Password) == 0 {
return fmt.Errorf("plugin store resolved basic auth is incomplete")
}
case AuthTypeHeader:
if strings.TrimSpace(item.HeaderName) == "" || strings.ContainsAny(item.HeaderName, "\r\n:") {
return fmt.Errorf("plugin store resolved auth header name is invalid")
}
if len(item.HeaderValue) == 0 || secretContainsCRLF(item.HeaderValue) {
return fmt.Errorf("plugin store resolved auth header value is invalid")
}
default:
return fmt.Errorf("unsupported plugin store resolved auth type %q", item.Type)
}
return nil
}
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:
return strings.TrimSpace(os.Getenv(item.TokenEnv)) != ""
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 PluginAuthConfigured(source Source, plugin Plugin, auth []AuthConfig) bool {
if AuthConfigured(auth, source.URL, RequestKindRegistry) {
return true
}
switch PluginInstallType(plugin) {
case InstallTypeDirect:
for _, artifact := range PluginArtifacts(plugin) {
if AuthConfigured(auth, artifact.URL, RequestKindArtifact) {
return true
}
}
case InstallTypeGitHubRelease:
return pluginGitHubReleaseAuthConfigured(plugin, auth)
}
return false
}
func pluginGitHubReleaseAuthConfigured(plugin Plugin, auth []AuthConfig) bool {
owner, repo, errRepository := 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),
)
return AuthConfigured(auth, releasesURL+"latest", RequestKindMetadata) ||
AuthConfigured(auth, releasesURL+"tags/", RequestKindMetadata)
}
func applyPluginStoreAuth(headers http.Header, auth []AuthConfig, requestURL string, kind string) error {
_, errApply := applyPluginStoreAuthForClient(headers, nil, auth, requestURL, kind)
return errApply
}
func applyPluginStoreAuthForClient(headers http.Header, resolved []ResolvedAuthConfig, auth []AuthConfig, requestURL string, kind string) (bool, error) {
if item, ok := matchingResolvedAuthConfig(resolved, requestURL, kind); ok {
applied, errApply := applyResolvedPluginStoreAuth(headers, item)
return applied, errApply
}
item, ok := matchingAuthConfig(auth, requestURL, kind)
if !ok {
return false, nil
}
switch strings.ToLower(strings.TrimSpace(item.Type)) {
case "", AuthTypeNone:
return false, nil
case AuthTypeBearer:
token, errToken := envValueRequired(item.TokenEnv, "token-env")
if errToken != nil {
return false, errToken
}
headers.Set("Authorization", "Bearer "+token)
case AuthTypeBasic:
username, errUsername := envValueRequired(item.UsernameEnv, "username-env")
if errUsername != nil {
return false, errUsername
}
password, errPassword := envValueRequired(item.PasswordEnv, "password-env")
if errPassword != nil {
return false, errPassword
}
encoded := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
headers.Set("Authorization", "Basic "+encoded)
case AuthTypeHeader:
if strings.TrimSpace(item.HeaderName) == "" {
return false, fmt.Errorf("plugin store auth missing header-name")
}
value, errValue := envValueRequired(item.HeaderValueEnv, "header-value-env")
if errValue != nil {
return false, errValue
}
headers.Set(item.HeaderName, value)
case AuthTypeGitHubToken:
token, errToken := envValueRequired(item.TokenEnv, "token-env")
if errToken != nil {
return false, errToken
}
headers.Set("Authorization", "Bearer "+token)
default:
return false, fmt.Errorf("unsupported plugin store auth type %q", item.Type)
}
return true, nil
}
func applyResolvedPluginStoreAuth(headers http.Header, item ResolvedAuthConfig) (bool, error) {
switch strings.ToLower(strings.TrimSpace(item.Type)) {
case "", AuthTypeNone:
return false, nil
case AuthTypeBearer, AuthTypeGitHubToken:
if len(item.Token) == 0 {
return false, fmt.Errorf("plugin store resolved auth token is empty")
}
headers.Set("Authorization", "Bearer "+string(item.Token))
case AuthTypeBasic:
if len(item.Username) == 0 || len(item.Password) == 0 {
return false, fmt.Errorf("plugin store resolved basic auth is incomplete")
}
credential := make([]byte, 0, len(item.Username)+1+len(item.Password))
credential = append(credential, item.Username...)
credential = append(credential, ':')
credential = append(credential, item.Password...)
encoded := base64.StdEncoding.EncodeToString(credential)
for index := range credential {
credential[index] = 0
}
headers.Set("Authorization", "Basic "+encoded)
case AuthTypeHeader:
if strings.TrimSpace(item.HeaderName) == "" {
return false, fmt.Errorf("plugin store resolved auth missing header-name")
}
if len(item.HeaderValue) == 0 {
return false, fmt.Errorf("plugin store resolved auth header value is empty")
}
headers.Set(item.HeaderName, string(item.HeaderValue))
default:
return false, fmt.Errorf("unsupported plugin store resolved auth type %q", item.Type)
}
return true, 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 parsed.User != nil {
return fmt.Errorf("plugin store url must not contain credentials")
}
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 validateResolvedAuthExpiry(auth []ResolvedAuthConfig, expiresAt time.Time, now time.Time, requestURL string, kind string) error {
if expiresAt.IsZero() {
return nil
}
if _, ok := matchingResolvedAuthConfig(auth, requestURL, kind); !ok {
return nil
}
if !now.Before(expiresAt) {
return fmt.Errorf("plugin store resolved auth expired")
}
return nil
}
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 matchingResolvedAuthConfig(auth []ResolvedAuthConfig, requestURL string, kind string) (ResolvedAuthConfig, bool) {
requestURL = strings.TrimSpace(requestURL)
kind = strings.ToLower(strings.TrimSpace(kind))
for _, item := range auth {
if !pluginStoreURLMatchesAuthRule(requestURL, strings.TrimSpace(item.Match)) {
continue
}
if !resolvedAuthAppliesTo(item, kind) {
continue
}
return item, true
}
return ResolvedAuthConfig{}, false
}
func resolvedAuthAppliesTo(item ResolvedAuthConfig, 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 cloneResolvedAuthConfig(item ResolvedAuthConfig) ResolvedAuthConfig {
item.ApplyTo = append([]string(nil), item.ApplyTo...)
item.Token = append(Secret(nil), item.Token...)
item.Username = append(Secret(nil), item.Username...)
item.Password = append(Secret(nil), item.Password...)
item.HeaderValue = append(Secret(nil), item.HeaderValue...)
return item
}
func resolvedAuthConfigured(item ResolvedAuthConfig) bool {
switch strings.ToLower(strings.TrimSpace(item.Type)) {
case AuthTypeBearer, AuthTypeGitHubToken:
return len(item.Token) > 0
case AuthTypeBasic:
return len(item.Username) > 0 && len(item.Password) > 0
case AuthTypeHeader:
return strings.TrimSpace(item.HeaderName) != "" && len(item.HeaderValue) > 0
default:
return false
}
}
func secretContainsCRLF(secret Secret) bool {
for _, value := range secret {
if value == '\r' || value == '\n' {
return true
}
}
return 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
}

View file

@ -0,0 +1,403 @@
package pluginstore
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
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 TestPluginStoreGitHubTokenUsesExplicitTokenEnv(t *testing.T) {
t.Setenv("PLUGIN_STORE_TOKEN", "secret-token")
headers := http.Header{}
auth := []AuthConfig{{
Match: "https://api.github.com/repos/author-name/sample-provider/releases/",
ApplyTo: []string{RequestKindArtifact},
Type: AuthTypeGitHubToken,
TokenEnv: "PLUGIN_STORE_TOKEN",
}}
if errAuth := applyPluginStoreAuth(headers, auth, "https://api.github.com/repos/author-name/sample-provider/releases/assets/1", RequestKindArtifact); errAuth != nil {
t.Fatalf("applyPluginStoreAuth() error = %v", errAuth)
}
if gotAuth := headers.Get("Authorization"); gotAuth != "Bearer secret-token" {
t.Fatalf("Authorization = %q, want Bearer secret-token", gotAuth)
}
}
func TestPluginAuthConfiguredCoversInstallRequestKinds(t *testing.T) {
t.Setenv("PLUGIN_STORE_TOKEN", "secret-token")
source := Source{URL: "https://registry.example/registry.json"}
directPlugin := Plugin{
ID: "sample-provider",
Version: "1.0.0",
Install: InstallPlan{
Type: InstallTypeDirect,
Artifacts: []Artifact{{
GOOS: "linux",
GOARCH: "amd64",
URL: "https://downloads.example/private/sample-provider.zip",
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}},
},
}
gitHubPlugin := Plugin{
ID: "sample-provider",
Repository: "https://github.com/author-name/sample-provider",
}
tests := []struct {
name string
plugin Plugin
auth []AuthConfig
}{
{
name: "registry",
plugin: gitHubPlugin,
auth: []AuthConfig{{
Match: "https://registry.example/",
ApplyTo: []string{RequestKindRegistry},
Type: AuthTypeBearer,
TokenEnv: "PLUGIN_STORE_TOKEN",
}},
},
{
name: "direct artifact",
plugin: directPlugin,
auth: []AuthConfig{{
Match: "https://downloads.example/private/",
ApplyTo: []string{RequestKindArtifact},
Type: AuthTypeBearer,
TokenEnv: "PLUGIN_STORE_TOKEN",
}},
},
{
name: "github metadata",
plugin: gitHubPlugin,
auth: []AuthConfig{{
Match: "https://api.github.com/repos/author-name/sample-provider/releases/",
ApplyTo: []string{RequestKindMetadata},
Type: AuthTypeBearer,
TokenEnv: "PLUGIN_STORE_TOKEN",
}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !PluginAuthConfigured(source, tt.plugin, tt.auth) {
t.Fatal("PluginAuthConfigured() = false, want true")
}
})
}
}
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)
}
}
func TestResolvedPluginStoreAuthTakesPriorityOverEnvironmentAuth(t *testing.T) {
t.Setenv("PLUGIN_STORE_TOKEN", "environment-token")
headers := http.Header{}
resolved := []ResolvedAuthConfig{{
Match: "https://downloads.example/private/",
ApplyTo: []string{RequestKindArtifact},
Type: AuthTypeBearer,
Token: Secret("resolved-token"),
}}
auth := []AuthConfig{{
Match: "https://downloads.example/private/",
ApplyTo: []string{RequestKindArtifact},
Type: AuthTypeBearer,
TokenEnv: "PLUGIN_STORE_TOKEN",
}}
applied, errApply := applyPluginStoreAuthForClient(headers, resolved, auth, "https://downloads.example/private/plugin.zip", RequestKindArtifact)
if errApply != nil {
t.Fatalf("applyPluginStoreAuthForClient() error = %v", errApply)
}
if !applied || headers.Get("Authorization") != "Bearer resolved-token" {
t.Fatalf("Authorization = %q, want resolved token", headers.Get("Authorization"))
}
}
func TestResolvedNoAuthRuleBlocksEnvironmentFallback(t *testing.T) {
t.Setenv("PLUGIN_STORE_TOKEN", "environment-token")
headers := http.Header{}
resolved := []ResolvedAuthConfig{{
Match: "https://downloads.example/private/", ApplyTo: []string{RequestKindArtifact}, Type: AuthTypeNone,
}}
auth := []AuthConfig{{
Match: "https://downloads.example/private/", ApplyTo: []string{RequestKindArtifact}, Type: AuthTypeBearer, TokenEnv: "PLUGIN_STORE_TOKEN",
}}
applied, errApply := applyPluginStoreAuthForClient(headers, resolved, auth, "https://downloads.example/private/plugin.zip", RequestKindArtifact)
if errApply != nil {
t.Fatalf("applyPluginStoreAuthForClient() error = %v", errApply)
}
if applied || headers.Get("Authorization") != "" {
t.Fatalf("resolved none rule applied environment auth: %q", headers.Get("Authorization"))
}
}
func TestResolvedPluginStoreAuthIsNotForwardedAcrossOriginRedirect(t *testing.T) {
var redirectedAuth string
target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
redirectedAuth = r.Header.Get("Authorization")
_, _ = io.WriteString(w, "artifact")
}))
t.Cleanup(target.Close)
source := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+"/artifact.zip", http.StatusFound)
}))
t.Cleanup(source.Close)
client := Client{
HTTPClient: &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}}, //nolint:gosec -- test servers use ephemeral certificates.
ResolvedAuth: []ResolvedAuthConfig{{
Match: source.URL + "/private/",
ApplyTo: []string{RequestKindArtifact},
Type: AuthTypeBearer,
Token: Secret("temporary-token"),
}},
}
if _, errDownload := client.DownloadArtifact(context.Background(), Artifact{
GOOS: "linux",
GOARCH: "amd64",
URL: source.URL + "/private/artifact.zip",
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}); errDownload != nil && !strings.Contains(errDownload.Error(), "sha256 mismatch") {
t.Fatalf("DownloadArtifact() error = %v, want only checksum mismatch", errDownload)
}
if redirectedAuth != "" {
t.Fatalf("redirected Authorization = %q, want empty", redirectedAuth)
}
}
func TestAuthenticatedPluginStoreFailureDoesNotExposeResponseBody(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "secret diagnostic body", http.StatusUnauthorized)
}))
t.Cleanup(server.Close)
client := Client{
HTTPClient: server.Client(),
ResolvedAuth: []ResolvedAuthConfig{{
Match: server.URL + "/",
ApplyTo: []string{RequestKindArtifact},
Type: AuthTypeBearer,
Token: Secret("temporary-token"),
}},
}
_, errDownload := client.DownloadArtifact(context.Background(), Artifact{
GOOS: "linux",
GOARCH: "amd64",
URL: server.URL + "/artifact.zip",
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
})
if errDownload == nil {
t.Fatal("DownloadArtifact() error = nil, want unauthorized status")
}
if strings.Contains(errDownload.Error(), "secret diagnostic body") {
t.Fatalf("DownloadArtifact() error leaked response body: %v", errDownload)
}
}
func TestResolvedAuthClearOverwritesSecrets(t *testing.T) {
token := Secret("temporary-token")
backing := token
auth := ResolvedAuthConfig{Token: token, Username: Secret("user"), Password: Secret("pass"), HeaderValue: Secret("header")}
auth.Clear()
for index, value := range backing {
if value != 0 {
t.Fatalf("token byte %d = %d, want zero", index, value)
}
}
if auth.Token != nil || auth.Username != nil || auth.Password != nil || auth.HeaderValue != nil {
t.Fatalf("cleared auth retains secret references: %#v", auth)
}
}
func TestPluginStoreRequestErrorRedactsQueryAndFragment(t *testing.T) {
requestURL := "https://user:password@downloads.example/plugin.zip?trace=private-value#section"
cause := context.Canceled
errRequest := pluginStoreRequestError(requestURL, &url.Error{URL: requestURL, Err: cause})
if strings.Contains(errRequest.Error(), "private-value") || strings.Contains(errRequest.Error(), "section") || strings.Contains(errRequest.Error(), "trace=") || strings.Contains(errRequest.Error(), "password") || strings.Contains(errRequest.Error(), "user@") {
t.Fatalf("pluginStoreRequestError() leaked URL query or fragment: %v", errRequest)
}
if !strings.Contains(errRequest.Error(), "https://downloads.example/plugin.zip") {
t.Fatalf("pluginStoreRequestError() = %v, want sanitized URL", errRequest)
}
if !errors.Is(errRequest, cause) {
t.Fatalf("errors.Is(pluginStoreRequestError(), context.Canceled) = false")
}
}
func TestPluginStoreRequestURLRejectsCredentials(t *testing.T) {
errValidate := validatePluginStoreRequestURL(nil, "https://user:password@downloads.example/plugin.zip", RequestKindArtifact)
if errValidate == nil {
t.Fatal("validatePluginStoreRequestURL() error = nil, want URL credentials rejection")
}
if strings.Contains(errValidate.Error(), "password") {
t.Fatalf("validatePluginStoreRequestURL() error leaked URL credentials: %v", errValidate)
}
}
func TestResolvedAuthExpiryRejectsAuthenticatedRequest(t *testing.T) {
auth := []ResolvedAuthConfig{{
Match: "https://downloads.example/private/",
ApplyTo: []string{RequestKindArtifact},
Type: AuthTypeBearer,
Token: Secret("temporary-token"),
}}
now := time.Now().UTC()
client := Client{
HTTPClient: failingHTTPDoer{},
ResolvedAuth: auth,
ResolvedAuthExpiresAt: now.Add(-time.Second),
}
_, errDownload := client.DownloadArtifact(context.Background(), Artifact{
GOOS: "linux",
GOARCH: "amd64",
URL: "https://downloads.example/private/plugin.zip",
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
})
if errDownload == nil || !strings.Contains(errDownload.Error(), "resolved auth expired") {
t.Fatalf("DownloadArtifact() error = %v, want resolved auth expiry", errDownload)
}
}

View file

@ -0,0 +1,45 @@
package pluginstore
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
)
func ParseChecksums(data []byte) (map[string]string, error) {
out := map[string]string{}
for lineNumber, rawLine := range strings.Split(string(data), "\n") {
line := strings.TrimSpace(rawLine)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
return nil, fmt.Errorf("line %d: invalid checksum entry", lineNumber+1)
}
hash := strings.ToLower(strings.TrimSpace(fields[0]))
if len(hash) != sha256.Size*2 {
return nil, fmt.Errorf("line %d: invalid sha256 length", lineNumber+1)
}
if _, errDecode := hex.DecodeString(hash); errDecode != nil {
return nil, fmt.Errorf("line %d: invalid sha256: %w", lineNumber+1, errDecode)
}
name := strings.TrimPrefix(strings.TrimSpace(fields[1]), "*")
out[name] = hash
}
return out, nil
}
func VerifyChecksum(name string, data []byte, checksums map[string]string) error {
expected := strings.ToLower(strings.TrimSpace(checksums[name]))
if expected == "" {
return fmt.Errorf("checksum for %s not found", name)
}
actualBytes := sha256.Sum256(data)
actual := hex.EncodeToString(actualBytes[:])
if actual != expected {
return fmt.Errorf("checksum mismatch for %s", name)
}
return nil
}

View file

@ -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
}

View file

@ -0,0 +1,335 @@
package pluginstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"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
type Client struct {
HTTPClient HTTPDoer
RegistryURL string
UserAgent string
Auth []AuthConfig
ResolvedAuth []ResolvedAuthConfig
ResolvedAuthExpiresAt time.Time
}
type Release struct {
TagName string `json:"tag_name"`
Assets []ReleaseAsset `json:"assets"`
}
type ReleaseAsset struct {
APIURL string `json:"url"`
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
func (c Client) FetchRegistry(ctx context.Context) (Registry, error) {
registryURL := strings.TrimSpace(c.RegistryURL)
if registryURL == "" {
registryURL = DefaultRegistryURL
}
data, errDownload := c.get(ctx, registryURL, "application/json", RequestKindRegistry, 0)
if errDownload != nil {
return Registry{}, errDownload
}
registry, errParse := ParseRegistry(data)
if errParse != nil {
return Registry{}, errParse
}
return registry, nil
}
// FetchLatestRelease returns the latest published release of the plugin's
// GitHub repository, mirroring the WebUI panel update check.
func (c Client) FetchLatestRelease(ctx context.Context, plugin Plugin) (Release, error) {
owner, repo, errRepository := GitHubRepositoryParts(plugin.Repository)
if errRepository != nil {
return Release{}, errRepository
}
releaseURL := fmt.Sprintf(
"https://api.github.com/repos/%s/%s/releases/latest",
url.PathEscape(owner),
url.PathEscape(repo),
)
data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json", RequestKindMetadata, 0)
if errDownload != nil {
return Release{}, errDownload
}
var release Release
if errDecode := json.Unmarshal(data, &release); errDecode != nil {
return Release{}, fmt.Errorf("decode release: %w", errDecode)
}
return release, nil
}
// FetchReleaseByTag returns a published release by its exact GitHub tag.
func (c Client) FetchReleaseByTag(ctx context.Context, plugin Plugin, tag string) (Release, error) {
owner, repo, errRepository := GitHubRepositoryParts(plugin.Repository)
if errRepository != nil {
return Release{}, errRepository
}
tag = strings.TrimSpace(tag)
if tag == "" {
return Release{}, fmt.Errorf("release tag is required")
}
releaseURL := fmt.Sprintf(
"https://api.github.com/repos/%s/%s/releases/tags/%s",
url.PathEscape(owner),
url.PathEscape(repo),
url.PathEscape(tag),
)
data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json", RequestKindMetadata, 0)
if errDownload != nil {
return Release{}, errDownload
}
var release Release
if errDecode := json.Unmarshal(data, &release); errDecode != nil {
return Release{}, fmt.Errorf("decode release: %w", errDecode)
}
return release, nil
}
// ReleaseVersion derives the plugin version from the release tag, stripping a
// leading "v"/"V" and validating the result.
func ReleaseVersion(release Release) (string, error) {
version := normalizeVersion(release.TagName)
if !validPluginVersion(version) {
return "", fmt.Errorf("invalid release tag %q", release.TagName)
}
return version, nil
}
func (c Client) DownloadAsset(ctx context.Context, asset ReleaseAsset) ([]byte, error) {
downloadURL := strings.TrimSpace(asset.BrowserDownloadURL)
apiURL := strings.TrimSpace(asset.APIURL)
if downloadURL == "" || c.releaseAssetAPIAuthenticated(apiURL) {
if apiURL != "" {
downloadURL = apiURL
}
}
if downloadURL == "" {
return nil, fmt.Errorf("asset %q missing download url", asset.Name)
}
return c.get(ctx, downloadURL, "application/octet-stream", RequestKindArtifact, 0)
}
func (c Client) releaseAssetAPIAuthenticated(apiURL string) bool {
apiURL = strings.TrimSpace(apiURL)
if apiURL == "" {
return false
}
if item, ok := matchingResolvedAuthConfig(c.ResolvedAuth, apiURL, RequestKindArtifact); ok {
return resolvedAuthConfigured(item)
}
return AuthConfigured(c.Auth, apiURL, RequestKindArtifact)
}
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
}
if errExpiry := validateResolvedAuthExpiry(c.ResolvedAuth, c.ResolvedAuthExpiresAt, time.Now().UTC(), currentURL, kind); errExpiry != nil {
return nil, errExpiry
}
headers := http.Header{
"Accept": []string{accept},
"User-Agent": []string{c.userAgent()},
}
authenticated, errAuth := applyPluginStoreAuthForClient(headers, c.ResolvedAuth, c.Auth, currentURL, kind)
if errAuth != nil {
return nil, errAuth
}
resp, errDo := pluginStoreGetNoRedirect(ctx, c.httpClient(), currentURL, headers)
if authenticated {
for name := range headers {
headers.Del(name)
}
if resp != nil && resp.Request != nil {
resp.Request.Header = nil
}
}
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, authenticated)
}
}
func (c Client) httpClient() HTTPDoer {
if c.HTTPClient != nil {
return c.HTTPClient
}
return http.DefaultClient
}
func (c Client) userAgent() string {
if strings.TrimSpace(c.UserAgent) != "" {
return strings.TrimSpace(c.UserAgent)
}
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, pluginStoreRequestError(requestURL, 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, authenticated bool) ([]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 {
if authenticated {
return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
}
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 pluginStoreRequestError(requestURL string, err error) error {
parsed, errParse := url.Parse(strings.TrimSpace(requestURL))
safeURL := "plugin store url"
if errParse == nil && parsed.Scheme != "" && parsed.Host != "" {
parsed.User = nil
parsed.RawQuery = ""
parsed.ForceQuery = false
parsed.Fragment = ""
safeURL = parsed.String()
}
var urlError *url.Error
if errors.As(err, &urlError) && urlError.Err != nil {
err = urlError.Err
}
return fmt.Errorf("request %s failed: %w", safeURL, err)
}
func SelectReleaseAssets(release Release, id, version, goos, goarch string) (ReleaseAsset, ReleaseAsset, error) {
archiveName := ArchiveName(id, version, goos, goarch)
var archiveAsset ReleaseAsset
var checksumAsset ReleaseAsset
for _, asset := range release.Assets {
switch strings.TrimSpace(asset.Name) {
case archiveName:
archiveAsset = asset
case "checksums.txt":
checksumAsset = asset
}
}
if strings.TrimSpace(archiveAsset.Name) == "" {
return ReleaseAsset{}, ReleaseAsset{}, fmt.Errorf("release asset %s not found", archiveName)
}
if strings.TrimSpace(checksumAsset.Name) == "" {
return ReleaseAsset{}, ReleaseAsset{}, fmt.Errorf("release asset checksums.txt not found")
}
return archiveAsset, checksumAsset, nil
}
func ArchiveName(id, version, goos, goarch string) string {
return fmt.Sprintf(
"%s_%s_%s_%s.zip",
strings.TrimSpace(id),
strings.TrimSpace(version),
strings.TrimSpace(goos),
strings.TrimSpace(goarch),
)
}

View file

@ -0,0 +1,129 @@
package pluginstore
import (
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
)
func TestSelectReleaseAssets(t *testing.T) {
t.Parallel()
release := Release{Assets: []ReleaseAsset{
{Name: "sample-provider_0.1.0_darwin_arm64.zip", BrowserDownloadURL: "https://example.com/sample-provider.zip"},
{Name: "checksums.txt", BrowserDownloadURL: "https://example.com/checksums.txt"},
}}
archiveAsset, checksumAsset, errSelect := SelectReleaseAssets(release, "sample-provider", "0.1.0", "darwin", "arm64")
if errSelect != nil {
t.Fatalf("SelectReleaseAssets() error = %v", errSelect)
}
if archiveAsset.BrowserDownloadURL != "https://example.com/sample-provider.zip" {
t.Fatalf("archive URL = %q", archiveAsset.BrowserDownloadURL)
}
if checksumAsset.BrowserDownloadURL != "https://example.com/checksums.txt" {
t.Fatalf("checksum URL = %q", checksumAsset.BrowserDownloadURL)
}
}
func TestSelectReleaseAssetsRejectsMissingAssets(t *testing.T) {
t.Parallel()
tests := []struct {
name string
release Release
wantErr string
}{
{
name: "missing zip",
release: Release{Assets: []ReleaseAsset{
{Name: "checksums.txt", BrowserDownloadURL: "https://example.com/checksums.txt"},
}},
wantErr: "sample-provider_0.1.0_darwin_arm64.zip",
},
{
name: "missing checksum",
release: Release{Assets: []ReleaseAsset{
{Name: "sample-provider_0.1.0_darwin_arm64.zip", BrowserDownloadURL: "https://example.com/sample-provider.zip"},
}},
wantErr: "checksums.txt",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, _, errSelect := SelectReleaseAssets(tt.release, "sample-provider", "0.1.0", "darwin", "arm64")
if errSelect == nil {
t.Fatal("SelectReleaseAssets() error = nil")
}
if !strings.Contains(errSelect.Error(), tt.wantErr) {
t.Fatalf("SelectReleaseAssets() error = %v, want substring %q", errSelect, tt.wantErr)
}
})
}
}
func TestReleaseVersion(t *testing.T) {
t.Parallel()
tests := []struct {
name string
tagName string
want string
wantErr bool
}{
{name: "v prefix", tagName: "v1.2.3", want: "1.2.3"},
{name: "no prefix", tagName: "0.1.0", want: "0.1.0"},
{name: "whitespace", tagName: " v2.0.0 ", want: "2.0.0"},
{name: "empty", tagName: "", wantErr: true},
{name: "non numeric", tagName: "latest", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
version, errVersion := ReleaseVersion(Release{TagName: tt.tagName})
if tt.wantErr {
if errVersion == nil {
t.Fatalf("ReleaseVersion(%q) error = nil", tt.tagName)
}
return
}
if errVersion != nil {
t.Fatalf("ReleaseVersion(%q) error = %v", tt.tagName, errVersion)
}
if version != tt.want {
t.Fatalf("ReleaseVersion(%q) = %q, want %q", tt.tagName, version, tt.want)
}
})
}
}
func TestParseChecksumsAndVerifyChecksum(t *testing.T) {
t.Parallel()
data := []byte("zip-data")
sum := sha256.Sum256(data)
checksumText := hex.EncodeToString(sum[:]) + " sample-provider_0.1.0_darwin_arm64.zip\n"
checksums, errParse := ParseChecksums([]byte(checksumText))
if errParse != nil {
t.Fatalf("ParseChecksums() error = %v", errParse)
}
if errVerify := VerifyChecksum("sample-provider_0.1.0_darwin_arm64.zip", data, checksums); errVerify != nil {
t.Fatalf("VerifyChecksum() error = %v", errVerify)
}
}
func TestVerifyChecksumRejectsMissingAndMismatch(t *testing.T) {
t.Parallel()
sum := sha256.Sum256([]byte("zip-data"))
checksums := map[string]string{"sample-provider.zip": hex.EncodeToString(sum[:])}
if errVerify := VerifyChecksum("missing.zip", []byte("zip-data"), checksums); errVerify == nil {
t.Fatal("VerifyChecksum() missing checksum error = nil")
}
if errVerify := VerifyChecksum("sample-provider.zip", []byte("other"), checksums); errVerify == nil {
t.Fatal("VerifyChecksum() mismatch error = nil")
}
}

View file

@ -0,0 +1,110 @@
package pluginstore
import (
"fmt"
"net/url"
"strings"
"time"
)
const PluginSyncSchemaVersion = 1
type PluginSyncRequest struct {
SchemaVersion int `json:"schema_version"`
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
InstalledVersions map[string]string `json:"installed_versions,omitempty"`
}
func (r *PluginSyncRequest) Clear() {
if r == nil {
return
}
clear(r.InstalledVersions)
r.InstalledVersions = nil
}
type PluginSyncItem struct {
Manifest Manifest `json:"manifest"`
Auth []ResolvedAuthConfig `json:"auth,omitempty"`
}
func (i *PluginSyncItem) Clear() {
if i == nil {
return
}
ClearResolvedAuthConfigs(i.Auth)
i.Auth = nil
i.Manifest = Manifest{}
}
type PluginSyncResponse struct {
SchemaVersion int `json:"schema_version"`
ExpiresAt time.Time `json:"expires_at"`
Items []PluginSyncItem `json:"items"`
}
func (r *PluginSyncResponse) Validate(now time.Time) error {
if r == nil {
return fmt.Errorf("plugin sync response is nil")
}
if r.SchemaVersion != PluginSyncSchemaVersion {
return fmt.Errorf("unsupported plugin sync schema_version %d", r.SchemaVersion)
}
if r.ExpiresAt.IsZero() {
return fmt.Errorf("plugin sync response missing expires_at")
}
if !now.Before(r.ExpiresAt) {
return fmt.Errorf("plugin sync response expired")
}
seen := make(map[string]struct{}, len(r.Items))
for index := range r.Items {
item := &r.Items[index]
if errManifest := item.Manifest.Validate(); errManifest != nil {
return fmt.Errorf("plugin sync item %d: %w", index, errManifest)
}
if errURLs := validatePluginSyncManifestURLs(item.Manifest); errURLs != nil {
return fmt.Errorf("plugin sync item %d: %w", index, errURLs)
}
id := strings.TrimSpace(item.Manifest.ID)
if _, exists := seen[id]; exists {
return fmt.Errorf("plugin sync response contains duplicate plugin %q", id)
}
seen[id] = struct{}{}
for authIndex := range item.Auth {
if errAuth := ValidateResolvedAuthConfig(item.Auth[authIndex]); errAuth != nil {
return fmt.Errorf("plugin sync item %d auth %d: %w", index, authIndex, errAuth)
}
}
}
return nil
}
func validatePluginSyncManifestURLs(manifest Manifest) error {
if manifest.InstallType() != InstallTypeDirect {
return nil
}
plan := NormalizeInstallPlan(manifest.Install)
if len(plan.Artifacts) == 0 {
return fmt.Errorf("direct plugin sync manifest requires pinned artifacts")
}
for index, artifact := range plan.Artifacts {
parsed, errParse := url.Parse(strings.TrimSpace(artifact.URL))
if errParse != nil || !strings.EqualFold(parsed.Scheme, "https") {
return fmt.Errorf("direct plugin sync artifact %d must use https", index)
}
}
return nil
}
func (r *PluginSyncResponse) Clear() {
if r == nil {
return
}
for index := range r.Items {
r.Items[index].Clear()
}
r.Items = nil
r.ExpiresAt = time.Time{}
r.SchemaVersion = 0
}

View file

@ -0,0 +1,161 @@
package pluginstore
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
)
func TestPluginSyncResponseValidatesAndClearsResolvedAuth(t *testing.T) {
response := PluginSyncResponse{
SchemaVersion: PluginSyncSchemaVersion,
ExpiresAt: time.Now().UTC().Add(time.Minute),
Items: []PluginSyncItem{{
Manifest: Manifest{
SchemaVersion: SchemaVersionV2,
ID: "sample",
Version: "1.0.0",
Install: InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{{
GOOS: "linux", GOARCH: "amd64", URL: "https://downloads.example/sample.zip",
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}}},
},
Auth: []ResolvedAuthConfig{{
Match: "https://downloads.example/", Type: AuthTypeBearer, Token: Secret("temporary-token"),
}},
}},
}
if errValidate := response.Validate(time.Now().UTC()); errValidate != nil {
t.Fatalf("Validate() error = %v", errValidate)
}
backing := response.Items[0].Auth[0].Token
response.Clear()
for index, value := range backing {
if value != 0 {
t.Fatalf("token byte %d = %d, want zero", index, value)
}
}
if response.Items != nil || !response.ExpiresAt.IsZero() || response.SchemaVersion != 0 {
t.Fatalf("Clear() left response state: %#v", response)
}
}
func TestPluginSyncResponseJSONKeepsSecretsOutOfPlainText(t *testing.T) {
response := PluginSyncResponse{
SchemaVersion: PluginSyncSchemaVersion,
ExpiresAt: time.Now().UTC().Add(time.Minute),
Items: []PluginSyncItem{{Auth: []ResolvedAuthConfig{{Token: Secret("temporary-token")}}}},
}
raw, errMarshal := json.Marshal(response)
if errMarshal != nil {
t.Fatalf("Marshal() error = %v", errMarshal)
}
if bytes.Contains(raw, []byte("temporary-token")) {
t.Fatalf("Marshal() exposed token as plain text: %s", raw)
}
var decoded PluginSyncResponse
if errUnmarshal := json.Unmarshal(raw, &decoded); errUnmarshal != nil {
t.Fatalf("Unmarshal() error = %v", errUnmarshal)
}
if got := string(decoded.Items[0].Auth[0].Token); got != "temporary-token" {
t.Fatalf("decoded token = %q, want temporary-token", got)
}
decoded.Clear()
}
func TestPluginSyncResponseRejectsExpiredPlan(t *testing.T) {
response := PluginSyncResponse{SchemaVersion: PluginSyncSchemaVersion, ExpiresAt: time.Now().UTC().Add(-time.Second)}
if errValidate := response.Validate(time.Now().UTC()); errValidate == nil {
t.Fatal("Validate() error = nil, want expired response")
}
}
func TestPluginSyncResponseRejectsInsecureResolvedAuthMatch(t *testing.T) {
response := PluginSyncResponse{
SchemaVersion: PluginSyncSchemaVersion,
ExpiresAt: time.Now().UTC().Add(time.Minute),
Items: []PluginSyncItem{{
Manifest: Manifest{
SchemaVersion: SchemaVersionV2, ID: "sample", Version: "1.0.0",
Install: InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{{
GOOS: "linux", GOARCH: "amd64", URL: "https://downloads.example/sample.zip",
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}}},
},
Auth: []ResolvedAuthConfig{{Match: "http://downloads.example/", Type: AuthTypeBearer, Token: Secret("token")}},
}},
}
defer response.Clear()
if errValidate := response.Validate(time.Now().UTC()); errValidate == nil {
t.Fatal("Validate() error = nil, want insecure auth match rejection")
}
}
func TestPluginSyncResponseRejectsHTTPArtifact(t *testing.T) {
response := PluginSyncResponse{
SchemaVersion: PluginSyncSchemaVersion,
ExpiresAt: time.Now().UTC().Add(time.Minute),
Items: []PluginSyncItem{{
Manifest: Manifest{
SchemaVersion: SchemaVersionV2, ID: "sample", Version: "1.0.0",
Install: InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{{
GOOS: "linux", GOARCH: "amd64", URL: "http://downloads.example/sample.zip",
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}}},
},
}},
}
defer response.Clear()
if errValidate := response.Validate(time.Now().UTC()); errValidate == nil {
t.Fatal("Validate() error = nil, want HTTP artifact rejection")
}
}
func TestPluginSyncResponseRejectsHTTPArtifactWithResolvedAuth(t *testing.T) {
response := PluginSyncResponse{
SchemaVersion: PluginSyncSchemaVersion,
ExpiresAt: time.Now().UTC().Add(time.Minute),
Items: []PluginSyncItem{{
Manifest: Manifest{
SchemaVersion: SchemaVersionV2, ID: "sample", Version: "1.0.0",
Install: InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{{
GOOS: "linux", GOARCH: "amd64", URL: "http://downloads.example/sample.zip",
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}}},
},
Auth: []ResolvedAuthConfig{{
Match: "https://downloads.example/", ApplyTo: []string{RequestKindArtifact}, Type: AuthTypeBearer, Token: Secret("token"),
}},
}},
}
defer response.Clear()
if errValidate := response.Validate(time.Now().UTC()); errValidate == nil {
t.Fatal("Validate() error = nil, want HTTP artifact rejection")
}
}
func TestPluginSyncResponseRejectsArtifactURLCredentials(t *testing.T) {
response := PluginSyncResponse{
SchemaVersion: PluginSyncSchemaVersion,
ExpiresAt: time.Now().UTC().Add(time.Minute),
Items: []PluginSyncItem{{
Manifest: Manifest{
SchemaVersion: SchemaVersionV2, ID: "sample", Version: "1.0.0",
Install: InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{{
GOOS: "linux", GOARCH: "amd64", URL: "https://user:password@downloads.example/sample.zip",
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}}},
},
}},
}
defer response.Clear()
errValidate := response.Validate(time.Now().UTC())
if errValidate == nil {
t.Fatal("Validate() error = nil, want artifact URL credentials rejection")
}
if strings.Contains(errValidate.Error(), "password") {
t.Fatalf("Validate() error leaked URL credentials: %v", errValidate)
}
}

View file

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

View file

@ -0,0 +1,814 @@
package pluginstore
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestInstallBlocksLoadedWindowsPlugin(t *testing.T) {
t.Parallel()
tests := []struct {
name string
goos string
loaded bool
wantBlocked bool
}{
{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},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, errInstall := Client{HTTPClient: failingHTTPDoer{}}.Install(context.Background(), testPlugin(), InstallOptions{
PluginsDir: t.TempDir(),
GOOS: tt.goos,
GOARCH: "amd64",
PluginLoaded: func() bool { return tt.loaded },
})
if errInstall == nil {
t.Fatal("Install() error = nil")
}
if gotBlocked := errors.Is(errInstall, ErrLoadedPluginLocked); gotBlocked != tt.wantBlocked {
t.Fatalf("Install() error = %v, blocked = %v, want %v", errInstall, gotBlocked, tt.wantBlocked)
}
})
}
}
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: root,
GOOS: "windows",
GOARCH: "amd64",
PluginLoaded: func() bool { return true },
})
if !errors.Is(errInstall, ErrLoadedPluginLocked) {
t.Fatalf("InstallArchive() error = %v, want ErrLoadedPluginLocked", errInstall)
}
}
func TestInstallArchivePreparesLoadedWindowsPluginBeforeWrite(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)
}
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)
}
loaded := true
prepared := false
result, errInstall := InstallArchive(makeZip(t, map[string]string{
"sample-provider.dll": "new",
}), testPlugin(), InstallOptions{
PluginsDir: root,
GOOS: "windows",
GOARCH: "amd64",
PluginLoaded: func() bool { return loaded },
BeforeWrite: func() error {
prepared = true
loaded = false
return nil
},
})
if errInstall != nil {
t.Fatalf("InstallArchive() error = %v", errInstall)
}
if !prepared {
t.Fatal("BeforeWrite was not called")
}
if !result.Overwritten {
t.Fatal("Overwritten = false, want true")
}
data, errRead := os.ReadFile(targetPath)
if errRead != nil {
t.Fatalf("ReadFile() error = %v", errRead)
}
if string(data) != "new" {
t.Fatalf("installed data = %q, want new", data)
}
}
func TestInstallArchiveSkipsIdenticalLoadedWindowsPlugin(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)
}
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)
}
beforeWriteCalled := false
result, errInstall := InstallArchive(makeZip(t, map[string]string{
"sample-provider.dll": "same",
}), testPlugin(), InstallOptions{
PluginsDir: root,
GOOS: "windows",
GOARCH: "amd64",
PluginLoaded: func() bool { return true },
BeforeWrite: func() error {
beforeWriteCalled = true
return errors.New("before write should not run")
},
})
if errInstall != nil {
t.Fatalf("InstallArchive() error = %v", errInstall)
}
if beforeWriteCalled {
t.Fatal("BeforeWrite was called for identical artifact")
}
if !result.Overwritten {
t.Fatal("Overwritten = false, want true")
}
if !result.Skipped {
t.Fatal("Skipped = false, want true")
}
data, errRead := os.ReadFile(targetPath)
if errRead != nil {
t.Fatalf("ReadFile() error = %v", errRead)
}
if string(data) != "same" {
t.Fatalf("installed data = %q, want same", data)
}
}
func TestInstallArchiveWritesPlatformPlugin(t *testing.T) {
t.Parallel()
root := t.TempDir()
result, errInstall := InstallArchive(makeZip(t, map[string]string{
"README.md": "ignored",
"sample-provider.dylib": "library-data",
}), testPlugin(), InstallOptions{PluginsDir: root, GOOS: "darwin", GOARCH: "arm64"})
if errInstall != nil {
t.Fatalf("InstallArchive() error = %v", errInstall)
}
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)
}
data, errRead := os.ReadFile(wantPath)
if errRead != nil {
t.Fatalf("ReadFile() error = %v", errRead)
}
if string(data) != "library-data" {
t.Fatalf("installed data = %q", data)
}
}
func TestInstallArchiveReportsOverwrite(t *testing.T) {
t.Parallel()
root := t.TempDir()
targetDir := filepath.Join(root, "darwin", "arm64")
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.dylib"), []byte("old"), 0o644); errWrite != nil {
t.Fatalf("WriteFile() error = %v", errWrite)
}
result, errInstall := InstallArchive(makeZip(t, map[string]string{
"sample-provider.dylib": "new",
}), testPlugin(), InstallOptions{PluginsDir: root, GOOS: "darwin", GOARCH: "arm64"})
if errInstall != nil {
t.Fatalf("InstallArchive() error = %v", errInstall)
}
if !result.Overwritten {
t.Fatal("Overwritten = false, want true")
}
}
func TestInstallArchiveOverwritesRuntimeSelectedPlugin(t *testing.T) {
t.Parallel()
root := t.TempDir()
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)
}
result, errInstall := InstallArchive(makeZip(t, map[string]string{
"sample-provider" + pluginExtension(runtime.GOOS): "new",
}), testPlugin(), InstallOptions{PluginsDir: root, GOOS: runtime.GOOS, GOARCH: runtime.GOARCH})
if errInstall != nil {
t.Fatalf("InstallArchive() error = %v", errInstall)
}
if result.Path != existingPath {
t.Fatalf("Path = %q, want selected runtime plugin %q", result.Path, existingPath)
}
if !result.Overwritten {
t.Fatal("Overwritten = false, want true")
}
data, errRead := os.ReadFile(existingPath)
if errRead != nil {
t.Fatalf("ReadFile() error = %v", errRead)
}
if string(data) != "new" {
t.Fatalf("installed data = %q, want new", data)
}
}
func TestInstallArchiveRejectsUnsafeArchives(t *testing.T) {
t.Parallel()
tests := []struct {
name string
files map[string]string
wantErr string
}{
{
name: "zip slip",
files: map[string]string{"../sample-provider.dylib": "library"},
wantErr: "escapes archive root",
},
{
name: "absolute path",
files: map[string]string{"/sample-provider.dylib": "library"},
wantErr: "is absolute",
},
{
name: "nested target",
files: map[string]string{"nested/sample-provider.dylib": "library"},
wantErr: "zip root",
},
{
name: "extension mismatch",
files: map[string]string{"sample-provider.so": "library"},
wantErr: "sample-provider.dylib",
},
{
name: "filename mismatch",
files: map[string]string{"other.dylib": "library"},
wantErr: "sample-provider.dylib",
},
{
name: "missing target",
files: map[string]string{"README.md": "library"},
wantErr: "does not contain",
},
{
name: "multiple targets",
files: map[string]string{
"sample-provider.dylib": "library",
"copy.dylib": "library",
},
wantErr: "sample-provider.dylib",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, errInstall := InstallArchive(makeZip(t, tt.files), testPlugin(), InstallOptions{PluginsDir: t.TempDir(), GOOS: "darwin", GOARCH: "arm64"})
if errInstall == nil {
t.Fatal("InstallArchive() error = nil")
}
if !strings.Contains(errInstall.Error(), tt.wantErr) {
t.Fatalf("InstallArchive() error = %v, want substring %q", errInstall, tt.wantErr)
}
})
}
}
func TestInstallUsesLatestReleaseVersion(t *testing.T) {
t.Parallel()
root := t.TempDir()
archiveData := makeZip(t, map[string]string{"sample-provider.dylib": "library-data"})
archiveName := "sample-provider_0.2.0_darwin_arm64.zip"
checksum := sha256.Sum256(archiveData)
client := Client{HTTPClient: mapHTTPDoer{
"https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{
"tag_name": "v0.2.0",
"assets": [
{
"name": "` + archiveName + `",
"url": "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/assets/1",
"browser_download_url": "https://downloads.example/` + archiveName + `"
},
{
"name": "checksums.txt",
"url": "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/assets/2",
"browser_download_url": "https://downloads.example/checksums.txt"
}
]
}`),
"https://downloads.example/" + archiveName: archiveData,
"https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"),
}}
result, errInstall := client.Install(context.Background(), testPlugin(), InstallOptions{
PluginsDir: root,
GOOS: "darwin",
GOARCH: "arm64",
})
if errInstall != nil {
t.Fatalf("Install() error = %v", errInstall)
}
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-v0.2.0.dylib"))
if errRead != nil {
t.Fatalf("ReadFile() error = %v", errRead)
}
if string(data) != "library-data" {
t.Fatalf("installed data = %q", data)
}
}
func TestDownloadAssetFallsBackToReleaseAssetAPIURLWhenBrowserDownloadURLEmpty(t *testing.T) {
apiURL := "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/assets/1"
client := Client{HTTPClient: mapHTTPDoer{
apiURL: []byte("artifact-data"),
}}
data, errDownload := client.DownloadAsset(context.Background(), ReleaseAsset{
Name: "sample-provider_0.2.0_darwin_arm64.zip",
APIURL: apiURL,
})
if errDownload != nil {
t.Fatalf("DownloadAsset() error = %v", errDownload)
}
if string(data) != "artifact-data" {
t.Fatalf("DownloadAsset() = %q, want artifact-data", data)
}
}
func TestDownloadAssetUsesAPIURLWhenAuthMatchesArtifact(t *testing.T) {
t.Setenv("PLUGIN_STORE_TOKEN", "secret-token")
apiURL := "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/assets/1"
client := Client{
HTTPClient: authCheckingHTTPDoer{
url: apiURL,
wantAuth: "Bearer secret-token",
responseBytes: []byte("artifact-data"),
},
Auth: []AuthConfig{{
Match: "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/",
ApplyTo: []string{RequestKindArtifact},
Type: AuthTypeBearer,
TokenEnv: "PLUGIN_STORE_TOKEN",
}},
}
data, errDownload := client.DownloadAsset(context.Background(), ReleaseAsset{
Name: "sample-provider_0.2.0_darwin_arm64.zip",
APIURL: apiURL,
BrowserDownloadURL: "https://downloads.example/sample-provider.zip",
})
if errDownload != nil {
t.Fatalf("DownloadAsset() error = %v", errDownload)
}
if string(data) != "artifact-data" {
t.Fatalf("DownloadAsset() = %q, want artifact-data", data)
}
}
func TestDownloadAssetUsesAPIURLWhenResolvedAuthMatchesArtifact(t *testing.T) {
apiURL := "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/assets/1"
client := Client{
HTTPClient: authCheckingHTTPDoer{
url: apiURL,
wantAuth: "Bearer temporary-token",
responseBytes: []byte("artifact-data"),
},
ResolvedAuth: []ResolvedAuthConfig{{
Match: "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/",
ApplyTo: []string{RequestKindArtifact},
Type: AuthTypeGitHubToken,
Token: Secret("temporary-token"),
}},
}
data, errDownload := client.DownloadAsset(context.Background(), ReleaseAsset{
Name: "sample-provider_0.2.0_darwin_arm64.zip",
APIURL: apiURL,
BrowserDownloadURL: "https://downloads.example/sample-provider.zip",
})
if errDownload != nil {
t.Fatalf("DownloadAsset() error = %v", errDownload)
}
if string(data) != "artifact-data" {
t.Fatalf("DownloadAsset() = %q, want artifact-data", data)
}
}
func TestDownloadAssetUsesBrowserDownloadURLWithUnrelatedAuth(t *testing.T) {
t.Setenv("PLUGIN_STORE_TOKEN", "secret-token")
browserURL := "https://downloads.example/sample-provider.zip"
client := Client{
HTTPClient: mapHTTPDoer{
browserURL: []byte("artifact-data"),
},
Auth: []AuthConfig{{
Match: "https://registry.example/",
ApplyTo: []string{RequestKindRegistry},
Type: AuthTypeBearer,
TokenEnv: "PLUGIN_STORE_TOKEN",
}},
}
data, errDownload := client.DownloadAsset(context.Background(), ReleaseAsset{
Name: "sample-provider_0.2.0_darwin_arm64.zip",
APIURL: "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/assets/1",
BrowserDownloadURL: browserURL,
})
if errDownload != nil {
t.Fatalf("DownloadAsset() error = %v", errDownload)
}
if string(data) != "artifact-data" {
t.Fatalf("DownloadAsset() = %q, want artifact-data", data)
}
}
func TestInstallVersionUsesPinnedReleaseTag(t *testing.T) {
t.Parallel()
root := t.TempDir()
archiveData := makeZip(t, map[string]string{"sample-provider.so": "library-data"})
archiveName := "sample-provider_0.3.0_linux_amd64.zip"
checksum := sha256.Sum256(archiveData)
client := Client{HTTPClient: mapHTTPDoer{
"https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/tags/v0.3.0": []byte(`{
"tag_name": "v0.3.0",
"assets": [
{"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"},
{"name": "checksums.txt", "browser_download_url": "https://downloads.example/checksums.txt"}
]
}`),
"https://downloads.example/" + archiveName: archiveData,
"https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"),
}}
result, errInstall := client.InstallVersion(context.Background(), testPlugin(), "v0.3.0", "0.3.0", InstallOptions{
PluginsDir: root,
GOOS: "linux",
GOARCH: "amd64",
})
if errInstall != nil {
t.Fatalf("InstallVersion() error = %v", errInstall)
}
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-v0.3.0.so"))
if errRead != nil {
t.Fatalf("ReadFile() error = %v", errRead)
}
if string(data) != "library-data" {
t.Fatalf("installed data = %q", data)
}
}
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()
client := Client{HTTPClient: mapHTTPDoer{
"https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{"tag_name": "latest", "assets": []}`),
}}
_, errInstall := client.Install(context.Background(), testPlugin(), InstallOptions{
PluginsDir: t.TempDir(),
GOOS: "darwin",
GOARCH: "arm64",
})
if errInstall == nil {
t.Fatal("Install() error = nil")
}
if !strings.Contains(errInstall.Error(), "invalid release tag") {
t.Fatalf("Install() error = %v, want invalid release tag", errInstall)
}
}
func makeZip(t *testing.T, files map[string]string) []byte {
t.Helper()
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
for name, content := range files {
file, errCreate := writer.Create(name)
if errCreate != nil {
t.Fatalf("Create(%s) error = %v", name, errCreate)
}
if _, errWrite := file.Write([]byte(content)); errWrite != nil {
t.Fatalf("Write(%s) error = %v", name, errWrite)
}
}
if errClose := writer.Close(); errClose != nil {
t.Fatalf("Close() error = %v", errClose)
}
return buffer.Bytes()
}
type failingHTTPDoer struct{}
func (failingHTTPDoer) Do(*http.Request) (*http.Response, error) {
return nil, errors.New("network unavailable")
}
type mapHTTPDoer map[string][]byte
func (c mapHTTPDoer) Do(req *http.Request) (*http.Response, error) {
body, ok := c[req.URL.String()]
if !ok {
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(strings.NewReader("not found")),
Header: make(http.Header),
Request: req,
}, nil
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
Header: make(http.Header),
Request: req,
}, 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",
Name: "Sample Provider",
Description: "Adds sample provider support.",
Author: "author-name",
Version: "0.1.0",
Repository: "https://github.com/author-name/cliproxy-sample-provider-plugin",
}
}

View file

@ -0,0 +1,193 @@
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:
manifest := manifestFromPlugin(source, plugin, Manifest{
SchemaVersion: SchemaVersionV2,
Version: strings.TrimSpace(plugin.Version),
Install: NormalizeInstallPlan(plugin.Install),
})
if errValidate := manifest.Validate(); errValidate != nil {
return Manifest{}, errValidate
}
return manifest, 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 {
if errValidate := ValidateInstallPlan(plan); errValidate != nil {
return errValidate
}
return validatePinnedArtifactURLs(plan.Artifacts)
}
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 validatePinnedArtifactURLs(artifacts []Artifact) error {
for index, artifact := range artifacts {
parsed, errParse := url.Parse(strings.TrimSpace(artifact.URL))
if errParse != nil {
return fmt.Errorf("artifacts[%d]: invalid artifact url", index)
}
if parsed.User != nil {
return fmt.Errorf("artifacts[%d]: pinned artifact url must not contain credentials", index)
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return fmt.Errorf("artifacts[%d]: pinned artifact url must not contain query or fragment", index)
}
}
return nil
}
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
}

View file

@ -0,0 +1,450 @@
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(&registry); errDecode != nil {
return Registry{}, fmt.Errorf("decode registry: %w", errDecode)
}
normalizeRegistry(&registry)
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 := &registry.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
}

View file

@ -0,0 +1,339 @@
package pluginstore
import (
"strings"
"testing"
)
func TestParseRegistryValidatesRegistry(t *testing.T) {
t.Parallel()
registry, errParse := ParseRegistry([]byte(`{
"schema_version": 1,
"plugins": [{
"id": "sample-provider",
"name": "Sample Provider",
"description": "Adds sample provider support.",
"author": "author-name",
"version": "0.1.0",
"repository": "https://github.com/author-name/cliproxy-sample-provider-plugin",
"logo": "https://example.com/logo.png",
"homepage": "https://github.com/author-name/cliproxy-sample-provider-plugin",
"license": "MIT",
"tags": ["provider"]
}]
}`))
if errParse != nil {
t.Fatalf("ParseRegistry() error = %v", errParse)
}
plugin, ok := registry.PluginByID("sample-provider")
if !ok {
t.Fatal("PluginByID(sample-provider) missing")
}
if plugin.Version != "0.1.0" {
t.Fatalf("plugin version = %q, want 0.1.0", plugin.Version)
}
}
func TestParseRegistryNormalizesPluginFields(t *testing.T) {
t.Parallel()
registry, errParse := ParseRegistry([]byte(`{
"schema_version": 1,
"plugins": [{
"id": " sample-provider ",
"name": " Sample Provider ",
"description": " Adds sample provider support. ",
"author": " author-name ",
"version": " 0.1.0 ",
"repository": " https://github.com/author-name/cliproxy-sample-provider-plugin ",
"logo": " https://example.com/logo.png ",
"homepage": " https://github.com/author-name/cliproxy-sample-provider-plugin ",
"license": " MIT ",
"tags": [" provider "]
}]
}`))
if errParse != nil {
t.Fatalf("ParseRegistry() error = %v", errParse)
}
plugin, ok := registry.PluginByID("sample-provider")
if !ok {
t.Fatal("PluginByID(sample-provider) missing")
}
if plugin.ID != "sample-provider" || plugin.Version != "0.1.0" || plugin.Repository != "https://github.com/author-name/cliproxy-sample-provider-plugin" {
t.Fatalf("plugin not normalized: %#v", plugin)
}
if plugin.Name != "Sample Provider" || plugin.Tags[0] != "provider" {
t.Fatalf("plugin display fields not normalized: %#v", plugin)
}
}
func TestValidateRegistryAllowsMissingVersion(t *testing.T) {
t.Parallel()
registry := Registry{SchemaVersion: 1, Plugins: []Plugin{{
ID: "sample-provider",
Name: "Sample Provider",
Description: "Adds sample provider support.",
Author: "author-name",
Repository: "https://github.com/author-name/cliproxy-sample-provider-plugin",
}}}
if errValidate := ValidateRegistry(registry); errValidate != nil {
t.Fatalf("ValidateRegistry() error = %v, want nil for missing version", errValidate)
}
}
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()
valid := Plugin{
ID: "sample-provider",
Name: "Sample Provider",
Description: "Adds sample provider support.",
Author: "author-name",
Version: "0.1.0",
Repository: "https://github.com/author-name/cliproxy-sample-provider-plugin",
}
tests := []struct {
name string
mutate func(*Registry)
wantErr string
}{
{
name: "schema version",
mutate: func(registry *Registry) {
registry.SchemaVersion = 3
},
wantErr: "unsupported schema_version",
},
{
name: "missing required field",
mutate: func(registry *Registry) {
registry.Plugins[0].Name = ""
},
wantErr: "missing required field name",
},
{
name: "duplicate id",
mutate: func(registry *Registry) {
registry.Plugins = append(registry.Plugins, valid)
},
wantErr: "duplicate plugin id",
},
{
name: "invalid id",
mutate: func(registry *Registry) {
registry.Plugins[0].ID = "../sample-provider"
},
wantErr: "invalid plugin id",
},
{
name: "v-prefixed version",
mutate: func(registry *Registry) {
registry.Plugins[0].Version = "v0.1.0"
},
wantErr: "invalid plugin version",
},
{
name: "invalid repository",
mutate: func(registry *Registry) {
registry.Plugins[0].Repository = "https://example.com/author/repo"
},
wantErr: "repository must be",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
registry := Registry{SchemaVersion: 1, Plugins: []Plugin{valid}}
tt.mutate(&registry)
errValidate := ValidateRegistry(registry)
if errValidate == nil {
t.Fatal("ValidateRegistry() error = nil")
}
if !strings.Contains(errValidate.Error(), tt.wantErr) {
t.Fatalf("ValidateRegistry() error = %v, want substring %q", errValidate, tt.wantErr)
}
})
}
}
func TestNormalizeSourcesAppendsURLsToDefaultSource(t *testing.T) {
t.Parallel()
sources, errNormalize := NormalizeSources([]string{" https://community.example/registry.json "})
if errNormalize != nil {
t.Fatalf("NormalizeSources() error = %v", errNormalize)
}
if len(sources) != 2 {
t.Fatalf("sources len = %d, want 2", len(sources))
}
if sources[0].ID != DefaultSourceID || sources[0].URL != DefaultRegistryURL {
t.Fatalf("default source = %#v", sources[0])
}
if sources[1].ID != SourceID("https://community.example/registry.json") ||
sources[1].Name != "community.example" ||
sources[1].URL != "https://community.example/registry.json" {
t.Fatalf("third-party source = %#v", sources[1])
}
}
func TestNormalizeSourcesSkipsDuplicates(t *testing.T) {
t.Parallel()
sources, errNormalize := NormalizeSources([]string{
DefaultRegistryURL,
"https://community.example/registry.json",
"https://community.example/registry.json",
})
if errNormalize != nil {
t.Fatalf("NormalizeSources() error = %v", errNormalize)
}
if len(sources) != 2 {
t.Fatalf("sources len = %d, want 2: %#v", len(sources), sources)
}
}
func TestGitHubRepositoryPartsRejectsNonRepositoryURLs(t *testing.T) {
t.Parallel()
tests := []string{
"http://github.com/owner/repo",
"https://github.com/owner",
"https://github.com/owner/repo/issues",
"https://github.com/owner/repo.git",
"https://github.com/owner/repo?tab=readme",
}
for _, repository := range tests {
t.Run(repository, func(t *testing.T) {
t.Parallel()
if _, _, errParse := GitHubRepositoryParts(repository); errParse == nil {
t.Fatalf("GitHubRepositoryParts(%q) error = nil", repository)
}
})
}
}

View file

@ -0,0 +1,69 @@
package pluginstore
import (
"strconv"
"strings"
)
// UpdateAvailable reports whether latest should be offered as an upgrade over
// installed. A leading "v"/"V" is ignored on both sides. Versions are compared
// numerically when both are dotted release numbers, so an installed version
// newer than the registry one is not reported as an update; otherwise any
// difference counts as an update.
func UpdateAvailable(installed, latest string) bool {
installed = normalizeVersion(installed)
latest = normalizeVersion(latest)
if installed == "" || latest == "" || installed == latest {
return false
}
comparison, comparable := compareVersions(installed, latest)
if !comparable {
return true
}
return comparison < 0
}
func normalizeVersion(version string) string {
version = strings.TrimSpace(version)
if len(version) > 1 && (version[0] == 'v' || version[0] == 'V') {
version = version[1:]
}
return version
}
// compareVersions compares dotted numeric versions segment by segment, with
// missing segments treated as zero. It reports false when either version
// contains a non-numeric segment.
func compareVersions(a, b string) (int, bool) {
segmentsA := strings.Split(a, ".")
segmentsB := strings.Split(b, ".")
length := len(segmentsA)
if len(segmentsB) > length {
length = len(segmentsB)
}
for index := 0; index < length; index++ {
numberA, okA := versionSegment(segmentsA, index)
numberB, okB := versionSegment(segmentsB, index)
if !okA || !okB {
return 0, false
}
if numberA != numberB {
if numberA < numberB {
return -1, true
}
return 1, true
}
}
return 0, true
}
func versionSegment(segments []string, index int) (int64, bool) {
if index >= len(segments) {
return 0, true
}
number, errParse := strconv.ParseInt(segments[index], 10, 64)
if errParse != nil || number < 0 {
return 0, false
}
return number, true
}

View file

@ -0,0 +1,34 @@
package pluginstore
import "testing"
func TestUpdateAvailable(t *testing.T) {
t.Parallel()
tests := []struct {
name string
installed string
latest string
want bool
}{
{name: "unknown installed", installed: "", latest: "0.2.0", want: false},
{name: "same version", installed: "0.1.0", latest: "0.1.0", want: false},
{name: "same version with v prefix", installed: "v0.1.0", latest: "0.1.0", want: false},
{name: "newer registry version", installed: "0.1.0", latest: "0.2.0", want: true},
{name: "newer registry version with v prefix", installed: "v0.1.0", latest: "0.2.0", want: true},
{name: "numeric not lexicographic", installed: "0.1.9", latest: "0.1.10", want: true},
{name: "installed newer than registry", installed: "0.2.0", latest: "0.1.0", want: false},
{name: "missing segments treated as zero", installed: "0.1", latest: "0.1.0", want: false},
{name: "prerelease falls back to inequality", installed: "0.1.0-rc1", latest: "0.1.0", want: true},
{name: "non numeric falls back to inequality", installed: "dev", latest: "0.1.0", want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := UpdateAvailable(tt.installed, tt.latest); got != tt.want {
t.Fatalf("UpdateAvailable(%q, %q) = %v, want %v", tt.installed, tt.latest, got, tt.want)
}
})
}
}