Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
471
backend/internal/pluginstore/auth.go
Normal file
471
backend/internal/pluginstore/auth.go
Normal 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
|
||||
}
|
||||
403
backend/internal/pluginstore/auth_test.go
Normal file
403
backend/internal/pluginstore/auth_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
45
backend/internal/pluginstore/checksum.go
Normal file
45
backend/internal/pluginstore/checksum.go
Normal 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
|
||||
}
|
||||
56
backend/internal/pluginstore/direct.go
Normal file
56
backend/internal/pluginstore/direct.go
Normal 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
|
||||
}
|
||||
335
backend/internal/pluginstore/github.go
Normal file
335
backend/internal/pluginstore/github.go
Normal 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),
|
||||
)
|
||||
}
|
||||
129
backend/internal/pluginstore/github_test.go
Normal file
129
backend/internal/pluginstore/github_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
110
backend/internal/pluginstore/home_sync.go
Normal file
110
backend/internal/pluginstore/home_sync.go
Normal 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
|
||||
}
|
||||
161
backend/internal/pluginstore/home_sync_test.go
Normal file
161
backend/internal/pluginstore/home_sync_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
596
backend/internal/pluginstore/install.go
Normal file
596
backend/internal/pluginstore/install.go
Normal 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
|
||||
}
|
||||
814
backend/internal/pluginstore/install_test.go
Normal file
814
backend/internal/pluginstore/install_test.go
Normal 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",
|
||||
}
|
||||
}
|
||||
193
backend/internal/pluginstore/manifest.go
Normal file
193
backend/internal/pluginstore/manifest.go
Normal 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
|
||||
}
|
||||
450
backend/internal/pluginstore/registry.go
Normal file
450
backend/internal/pluginstore/registry.go
Normal 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(®istry); errDecode != nil {
|
||||
return Registry{}, fmt.Errorf("decode registry: %w", errDecode)
|
||||
}
|
||||
normalizeRegistry(®istry)
|
||||
if errValidate := ValidateRegistry(registry); errValidate != nil {
|
||||
return Registry{}, errValidate
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func normalizeRegistry(registry *Registry) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
for index := range registry.Plugins {
|
||||
plugin := ®istry.Plugins[index]
|
||||
plugin.ID = strings.TrimSpace(plugin.ID)
|
||||
plugin.Name = strings.TrimSpace(plugin.Name)
|
||||
plugin.Description = strings.TrimSpace(plugin.Description)
|
||||
plugin.Author = strings.TrimSpace(plugin.Author)
|
||||
plugin.Version = strings.TrimSpace(plugin.Version)
|
||||
plugin.Repository = strings.TrimSpace(plugin.Repository)
|
||||
plugin.Logo = strings.TrimSpace(plugin.Logo)
|
||||
plugin.Homepage = strings.TrimSpace(plugin.Homepage)
|
||||
plugin.License = strings.TrimSpace(plugin.License)
|
||||
plugin.Install = NormalizeInstallPlan(plugin.Install)
|
||||
for versionIndex := range plugin.Versions {
|
||||
version := &plugin.Versions[versionIndex]
|
||||
version.Version = normalizeVersion(version.Version)
|
||||
version.Install = NormalizeInstallPlan(version.Install)
|
||||
}
|
||||
for tagIndex := range plugin.Tags {
|
||||
plugin.Tags[tagIndex] = strings.TrimSpace(plugin.Tags[tagIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateRegistry(registry Registry) error {
|
||||
if registry.SchemaVersion != SchemaVersion && registry.SchemaVersion != SchemaVersionV2 {
|
||||
return fmt.Errorf("unsupported schema_version %d", registry.SchemaVersion)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(registry.Plugins))
|
||||
for index, plugin := range registry.Plugins {
|
||||
if registry.SchemaVersion == SchemaVersion && PluginInstallType(plugin) == InstallTypeDirect {
|
||||
return fmt.Errorf("plugins[%d]: direct install requires schema_version %d", index, SchemaVersionV2)
|
||||
}
|
||||
if errValidate := ValidatePlugin(plugin); errValidate != nil {
|
||||
return fmt.Errorf("plugins[%d]: %w", index, errValidate)
|
||||
}
|
||||
id := strings.TrimSpace(plugin.ID)
|
||||
if _, exists := seen[id]; exists {
|
||||
return fmt.Errorf("plugins[%d]: duplicate plugin id %q", index, id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidatePlugin(plugin Plugin) error {
|
||||
required := map[string]string{
|
||||
"id": plugin.ID,
|
||||
"name": plugin.Name,
|
||||
"description": plugin.Description,
|
||||
"author": plugin.Author,
|
||||
}
|
||||
installType := PluginInstallType(plugin)
|
||||
if installType == InstallTypeGitHubRelease {
|
||||
required["repository"] = plugin.Repository
|
||||
}
|
||||
for field, value := range required {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("missing required field %s", field)
|
||||
}
|
||||
}
|
||||
if !validPluginID(strings.TrimSpace(plugin.ID)) {
|
||||
return fmt.Errorf("invalid plugin id %q", plugin.ID)
|
||||
}
|
||||
// The version is optional since the latest release is the source of truth;
|
||||
// when present it is only used as a display fallback and must be valid.
|
||||
if version := strings.TrimSpace(plugin.Version); version != "" && !validPluginVersion(version) {
|
||||
return fmt.Errorf("invalid plugin version %q", plugin.Version)
|
||||
}
|
||||
switch installType {
|
||||
case InstallTypeGitHubRelease:
|
||||
if _, _, errRepository := GitHubRepositoryParts(plugin.Repository); errRepository != nil {
|
||||
return errRepository
|
||||
}
|
||||
case InstallTypeDirect:
|
||||
if strings.TrimSpace(plugin.Version) == "" {
|
||||
return fmt.Errorf("missing required field version")
|
||||
}
|
||||
if errPlan := ValidateInstallPlan(plugin.Install); errPlan != nil {
|
||||
return errPlan
|
||||
}
|
||||
if errVersions := ValidatePluginVersions(plugin); errVersions != nil {
|
||||
return errVersions
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported install type %q", plugin.Install.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidatePluginVersions(plugin Plugin) error {
|
||||
if len(plugin.Versions) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(plugin.Versions))
|
||||
for index, version := range plugin.Versions {
|
||||
version.Version = normalizeVersion(version.Version)
|
||||
if !validPluginVersion(version.Version) {
|
||||
return fmt.Errorf("versions[%d]: invalid plugin version %q", index, version.Version)
|
||||
}
|
||||
if _, exists := seen[version.Version]; exists {
|
||||
return fmt.Errorf("versions[%d]: duplicate plugin version %q", index, version.Version)
|
||||
}
|
||||
seen[version.Version] = struct{}{}
|
||||
installType := strings.ToLower(strings.TrimSpace(version.Install.Type))
|
||||
if installType == "" {
|
||||
installType = PluginInstallType(plugin)
|
||||
version.Install.Type = installType
|
||||
}
|
||||
if installType != PluginInstallType(plugin) {
|
||||
return fmt.Errorf("versions[%d]: install type %q does not match plugin install type %q", index, installType, PluginInstallType(plugin))
|
||||
}
|
||||
if errPlan := ValidateInstallPlan(version.Install); errPlan != nil {
|
||||
return fmt.Errorf("versions[%d]: %w", index, errPlan)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func PluginInstallType(plugin Plugin) string {
|
||||
installType := strings.ToLower(strings.TrimSpace(plugin.Install.Type))
|
||||
if installType == "" {
|
||||
return InstallTypeGitHubRelease
|
||||
}
|
||||
return installType
|
||||
}
|
||||
|
||||
func NormalizeInstallPlan(plan InstallPlan) InstallPlan {
|
||||
plan.Type = strings.ToLower(strings.TrimSpace(plan.Type))
|
||||
for index := range plan.Artifacts {
|
||||
artifact := &plan.Artifacts[index]
|
||||
artifact.GOOS = normalizeGOOS(artifact.GOOS)
|
||||
artifact.GOARCH = normalizeGOARCH(artifact.GOARCH)
|
||||
artifact.URL = strings.TrimSpace(artifact.URL)
|
||||
artifact.SHA256 = strings.ToLower(strings.TrimSpace(artifact.SHA256))
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
func ValidateInstallPlan(plan InstallPlan) error {
|
||||
plan = NormalizeInstallPlan(plan)
|
||||
if plan.Type == "" {
|
||||
return fmt.Errorf("missing install type")
|
||||
}
|
||||
if plan.Type != InstallTypeDirect && plan.Type != InstallTypeGitHubRelease {
|
||||
return fmt.Errorf("unsupported install type %q", plan.Type)
|
||||
}
|
||||
if plan.Type != InstallTypeDirect {
|
||||
return nil
|
||||
}
|
||||
if len(plan.Artifacts) == 0 {
|
||||
return fmt.Errorf("direct install requires at least one artifact")
|
||||
}
|
||||
for index, artifact := range plan.Artifacts {
|
||||
if errArtifact := ValidateArtifact(artifact); errArtifact != nil {
|
||||
return fmt.Errorf("artifacts[%d]: %w", index, errArtifact)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateArtifact(artifact Artifact) error {
|
||||
artifact.GOOS = normalizeGOOS(artifact.GOOS)
|
||||
artifact.GOARCH = normalizeGOARCH(artifact.GOARCH)
|
||||
artifact.URL = strings.TrimSpace(artifact.URL)
|
||||
artifact.SHA256 = strings.ToLower(strings.TrimSpace(artifact.SHA256))
|
||||
if artifact.GOOS == "" {
|
||||
return fmt.Errorf("missing goos")
|
||||
}
|
||||
if artifact.GOARCH == "" {
|
||||
return fmt.Errorf("missing goarch")
|
||||
}
|
||||
if artifact.URL == "" {
|
||||
return fmt.Errorf("missing url")
|
||||
}
|
||||
parsed, errParse := url.Parse(artifact.URL)
|
||||
if errParse != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return fmt.Errorf("invalid artifact url")
|
||||
}
|
||||
if parsed.Scheme != "https" && parsed.Scheme != "http" {
|
||||
return fmt.Errorf("artifact url must use http or https")
|
||||
}
|
||||
if hasSensitiveQueryParameter(parsed) {
|
||||
return fmt.Errorf("artifact url contains sensitive query parameter")
|
||||
}
|
||||
if artifact.SHA256 == "" {
|
||||
return fmt.Errorf("missing sha256")
|
||||
}
|
||||
if len(artifact.SHA256) != sha256.Size*2 {
|
||||
return fmt.Errorf("invalid sha256 length")
|
||||
}
|
||||
if _, errDecode := hex.DecodeString(artifact.SHA256); errDecode != nil {
|
||||
return fmt.Errorf("invalid sha256: %w", errDecode)
|
||||
}
|
||||
if artifact.Size < 0 {
|
||||
return fmt.Errorf("invalid size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func PluginPlatforms(plugin Plugin) []Platform {
|
||||
if PluginInstallType(plugin) != InstallTypeDirect {
|
||||
return nil
|
||||
}
|
||||
artifacts := PluginArtifacts(plugin)
|
||||
seen := make(map[Platform]struct{}, len(artifacts))
|
||||
platforms := make([]Platform, 0, len(artifacts))
|
||||
for _, artifact := range artifacts {
|
||||
platform := Platform{GOOS: artifact.GOOS, GOARCH: artifact.GOARCH}
|
||||
if platform.GOOS == "" || platform.GOARCH == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[platform]; exists {
|
||||
continue
|
||||
}
|
||||
seen[platform] = struct{}{}
|
||||
platforms = append(platforms, platform)
|
||||
}
|
||||
return platforms
|
||||
}
|
||||
|
||||
func PluginArtifacts(plugin Plugin) []Artifact {
|
||||
if PluginInstallType(plugin) != InstallTypeDirect {
|
||||
return nil
|
||||
}
|
||||
artifacts := append([]Artifact(nil), NormalizeInstallPlan(plugin.Install).Artifacts...)
|
||||
for _, version := range plugin.Versions {
|
||||
artifacts = append(artifacts, NormalizeInstallPlan(version.Install).Artifacts...)
|
||||
}
|
||||
return artifacts
|
||||
}
|
||||
|
||||
func normalizeGOOS(goos string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(goos)) {
|
||||
case "mac", "macos", "osx":
|
||||
return "darwin"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(goos))
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeGOARCH(goarch string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(goarch)) {
|
||||
case "x64", "x86_64":
|
||||
return "amd64"
|
||||
case "aarch64":
|
||||
return "arm64"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(goarch))
|
||||
}
|
||||
}
|
||||
|
||||
func hasSensitiveQueryParameter(parsed *url.URL) bool {
|
||||
if parsed == nil || parsed.RawQuery == "" {
|
||||
return false
|
||||
}
|
||||
for key := range parsed.Query() {
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "token", "access_token", "access_key", "secret", "secret_key", "api_key":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validPluginVersion(version string) bool {
|
||||
return version != "" && !strings.HasPrefix(version, "v") && pluginVersionPattern.MatchString(version)
|
||||
}
|
||||
|
||||
func validPluginID(id string) bool {
|
||||
return pluginIDPattern.MatchString(id)
|
||||
}
|
||||
|
||||
func GitHubRepositoryParts(repository string) (string, string, error) {
|
||||
repository = strings.TrimSpace(repository)
|
||||
parsed, errParse := url.Parse(repository)
|
||||
if errParse != nil {
|
||||
return "", "", fmt.Errorf("invalid repository URL: %w", errParse)
|
||||
}
|
||||
if parsed.Scheme != "https" || parsed.Host != "github.com" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", "", fmt.Errorf("repository must be https://github.com/{owner}/{repo}")
|
||||
}
|
||||
segments := strings.Split(strings.Trim(parsed.EscapedPath(), "/"), "/")
|
||||
if len(segments) != 2 || segments[0] == "" || segments[1] == "" {
|
||||
return "", "", fmt.Errorf("repository must be https://github.com/{owner}/{repo}")
|
||||
}
|
||||
owner, errOwner := url.PathUnescape(segments[0])
|
||||
if errOwner != nil {
|
||||
return "", "", fmt.Errorf("invalid repository owner: %w", errOwner)
|
||||
}
|
||||
repo, errRepo := url.PathUnescape(segments[1])
|
||||
if errRepo != nil {
|
||||
return "", "", fmt.Errorf("invalid repository name: %w", errRepo)
|
||||
}
|
||||
if strings.HasSuffix(repo, ".git") {
|
||||
return "", "", fmt.Errorf("repository must be https://github.com/{owner}/{repo}")
|
||||
}
|
||||
return owner, repo, nil
|
||||
}
|
||||
|
||||
func (r Registry) PluginByID(id string) (Plugin, bool) {
|
||||
id = strings.TrimSpace(id)
|
||||
for _, plugin := range r.Plugins {
|
||||
if strings.TrimSpace(plugin.ID) == id {
|
||||
return plugin, true
|
||||
}
|
||||
}
|
||||
return Plugin{}, false
|
||||
}
|
||||
339
backend/internal/pluginstore/registry_test.go
Normal file
339
backend/internal/pluginstore/registry_test.go
Normal 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(®istry)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
69
backend/internal/pluginstore/version.go
Normal file
69
backend/internal/pluginstore/version.go
Normal 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
|
||||
}
|
||||
34
backend/internal/pluginstore/version_test.go
Normal file
34
backend/internal/pluginstore/version_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue