mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 01:16:47 +08:00
feat(pluginstore): implement plugin synchronization and validation
- Introduced PluginSyncRequest and PluginSyncResponse types for managing plugin synchronization. - Added validation logic for plugin sync responses, including checks for schema version and expiration. - Implemented functionality to clear sensitive data from plugin sync requests and responses. - Enhanced error handling for HTTP requests related to plugin synchronization. - Updated manifest validation to reject artifact URLs containing credentials or query parameters. - Added tests for plugin synchronization, ensuring proper validation and error handling. - Refactored existing code to support resolved authentication configurations for plugin downloads.
This commit is contained in:
+48
-9
@@ -37,6 +37,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -152,6 +153,7 @@ func main() {
|
||||
var configLoadedFromHome bool
|
||||
var homeClient *home.Client
|
||||
var homePluginSyncReport homeplugins.SyncReport
|
||||
var homePluginStatusReady bool
|
||||
var (
|
||||
usePostgresStore bool
|
||||
pgStoreDSN string
|
||||
@@ -303,16 +305,53 @@ func main() {
|
||||
parsed.Home = homeCfg
|
||||
parsed.Port = 8317 // Default to 8317 for home mode, can be overridden by home config
|
||||
parsed.UsageStatisticsEnabled = true
|
||||
ctxHomePlugins, cancelHomePlugins := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
pluginSyncCfg := *parsed
|
||||
parsed.Plugins.StoreAuth = nil
|
||||
var errHomePlugins error
|
||||
homePluginSyncReport, errHomePlugins = homeplugins.SyncWithReport(ctxHomePlugins, parsed, pluginHost)
|
||||
cancelHomePlugins()
|
||||
errReportPlugins := home.ReportPluginStatus(context.Background(), homeClient, homeCfg.NodeID, homePluginSyncReport)
|
||||
if errHomePlugins != nil {
|
||||
log.Errorf("failed to fetch plugins from home: %v", errHomePlugins)
|
||||
platform := homeplugins.CurrentPlatform()
|
||||
if pluginSyncCfg.Plugins.Enabled {
|
||||
ctxHomePlugins, cancelHomePlugins := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
installedVersions, errInstalledPlugins := homeplugins.InstalledVersions(&pluginSyncCfg)
|
||||
if errInstalledPlugins != nil {
|
||||
homePluginStatusReady = true
|
||||
errHomePlugins = errInstalledPlugins
|
||||
homePluginSyncReport = homeplugins.CompletedSyncReport(platform, errInstalledPlugins)
|
||||
} else {
|
||||
pluginSyncRequest := sdkpluginstore.PluginSyncRequest{
|
||||
SchemaVersion: sdkpluginstore.PluginSyncSchemaVersion,
|
||||
GOOS: platform.GOOS,
|
||||
GOARCH: platform.GOARCH,
|
||||
InstalledVersions: installedVersions,
|
||||
}
|
||||
pluginSyncResponse, errFetchPlugins := homeClient.GetPluginSync(ctxHomePlugins, pluginSyncRequest)
|
||||
errHomePlugins = errFetchPlugins
|
||||
switch {
|
||||
case errHomePlugins == nil:
|
||||
homePluginStatusReady = true
|
||||
homePluginSyncReport, errHomePlugins = homeplugins.SyncResolvedWithReport(ctxHomePlugins, &pluginSyncCfg, pluginSyncResponse.Items, pluginSyncResponse.ExpiresAt, pluginSyncRequest.InstalledVersions, pluginHost)
|
||||
case errors.Is(errHomePlugins, home.ErrPluginSyncUnsupported):
|
||||
homePluginStatusReady = true
|
||||
homePluginSyncReport, errHomePlugins = homeplugins.SyncWithReport(ctxHomePlugins, &pluginSyncCfg, pluginHost)
|
||||
default:
|
||||
homePluginStatusReady = true
|
||||
homePluginSyncReport = homeplugins.CompletedSyncReport(platform, errHomePlugins)
|
||||
}
|
||||
pluginSyncRequest.Clear()
|
||||
pluginSyncResponse.Clear()
|
||||
}
|
||||
cancelHomePlugins()
|
||||
} else {
|
||||
homePluginStatusReady = true
|
||||
homePluginSyncReport = homeplugins.CompletedSyncReport(platform, nil)
|
||||
}
|
||||
if errReportPlugins != nil {
|
||||
log.Warnf("failed to report home plugin sync status: %v", errReportPlugins)
|
||||
if errHomePlugins != nil {
|
||||
log.Errorf("failed to sync plugins from home: %v", errHomePlugins)
|
||||
}
|
||||
if homePluginStatusReady {
|
||||
errReportPlugins := home.ReportPluginStatus(context.Background(), homeClient, homeCfg.NodeID, homePluginSyncReport)
|
||||
if errReportPlugins != nil {
|
||||
log.Warnf("failed to report home plugin sync status: %v", errReportPlugins)
|
||||
}
|
||||
}
|
||||
if errHomePlugins != nil {
|
||||
return
|
||||
@@ -570,7 +609,7 @@ func main() {
|
||||
// Register built-in access providers before constructing services.
|
||||
configaccess.Register(&cfg.SDKConfig)
|
||||
pluginHost.ApplyConfig(context.Background(), cfg)
|
||||
if configLoadedFromHome {
|
||||
if configLoadedFromHome && homePluginStatusReady {
|
||||
errHomePluginLoad := homeplugins.MarkLoadResults(&homePluginSyncReport, pluginHost)
|
||||
errReportPlugins := home.ReportPluginStatus(context.Background(), homeClient, cfg.Home.NodeID, homePluginSyncReport)
|
||||
if errHomePluginLoad != nil {
|
||||
|
||||
@@ -80,6 +80,35 @@ func TestListPluginStoreMergesInstalledStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginStoreDirectManifestPinsRequestedVersionArtifacts(t *testing.T) {
|
||||
plugin := pluginstore.Plugin{
|
||||
ID: "sample", Name: "Sample", Description: "Sample plugin", Author: "tester", Version: "1.0.0",
|
||||
Install: pluginstore.InstallPlan{Type: pluginstore.InstallTypeDirect, Artifacts: []pluginstore.Artifact{{
|
||||
GOOS: "linux", GOARCH: "amd64", URL: "https://downloads.example/sample-1.0.0.zip",
|
||||
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", Size: 100,
|
||||
}}},
|
||||
Versions: []pluginstore.Version{{
|
||||
Version: "0.9.0",
|
||||
Install: pluginstore.InstallPlan{Type: pluginstore.InstallTypeDirect, Artifacts: []pluginstore.Artifact{{
|
||||
GOOS: "linux", GOARCH: "amd64", URL: "https://downloads.example/sample-0.9.0.zip",
|
||||
SHA256: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", Size: 90,
|
||||
}}},
|
||||
}},
|
||||
}
|
||||
|
||||
manifest, errManifest := pluginStoreDirectManifest(pluginstore.DefaultSource(), plugin, "0.9.0")
|
||||
if errManifest != nil {
|
||||
t.Fatalf("pluginStoreDirectManifest() error = %v", errManifest)
|
||||
}
|
||||
if manifest.Version != "0.9.0" || len(manifest.Install.Artifacts) != 1 {
|
||||
t.Fatalf("manifest = %#v, want pinned historical version artifact", manifest)
|
||||
}
|
||||
artifact := manifest.Install.Artifacts[0]
|
||||
if artifact.URL != "https://downloads.example/sample-0.9.0.zip" || artifact.Size != 90 {
|
||||
t.Fatalf("artifact = %#v, want historical 0.9.0 artifact", artifact)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPluginStoreUsesVersionFromInstalledFilename(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -887,11 +916,11 @@ func TestInstallPluginFromStoreInstallsDirectArtifact(t *testing.T) {
|
||||
if manifest.SchemaVersion != pluginstore.SchemaVersionV2 || manifest.InstallType() != pluginstore.InstallTypeDirect || manifest.Version != "0.4.0" {
|
||||
t.Fatalf("store manifest = %#v, want direct schema v2 0.4.0", manifest)
|
||||
}
|
||||
if manifest.SourceURL != "https://registry.example/registry.json" || len(manifest.Install.Artifacts) != 0 {
|
||||
t.Fatalf("store manifest source/artifacts = %q/%d, want source URL without artifacts", manifest.SourceURL, len(manifest.Install.Artifacts))
|
||||
if manifest.SourceURL != "https://registry.example/registry.json" || len(manifest.Install.Artifacts) == 0 {
|
||||
t.Fatalf("store manifest source/artifacts = %q/%d, want source URL with pinned artifacts", manifest.SourceURL, len(manifest.Install.Artifacts))
|
||||
}
|
||||
if raw := marshalPluginRaw(t, h.cfg.Plugins.Configs["sample-provider"]); strings.Contains(raw, "artifacts:") {
|
||||
t.Fatalf("direct store manifest should not persist artifacts:\n%s", raw)
|
||||
if raw := marshalPluginRaw(t, h.cfg.Plugins.Configs["sample-provider"]); !strings.Contains(raw, "artifacts:") {
|
||||
t.Fatalf("direct store manifest should persist pinned artifacts:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -947,8 +976,11 @@ func TestInstallPluginFromStoreHonorsDirectQueryVersion(t *testing.T) {
|
||||
t.Fatalf("installed file = %q, want direct-history-data", data)
|
||||
}
|
||||
manifest := pluginStoreManifestFromConfig(t, h.cfg.Plugins.Configs["sample-provider"])
|
||||
if manifest.Version != "0.3.0" || manifest.InstallType() != pluginstore.InstallTypeDirect || len(manifest.Install.Artifacts) != 0 {
|
||||
t.Fatalf("store manifest = %#v, want source-backed direct 0.3.0", manifest)
|
||||
if manifest.Version != "0.3.0" || manifest.InstallType() != pluginstore.InstallTypeDirect || len(manifest.Install.Artifacts) != 1 {
|
||||
t.Fatalf("store manifest = %#v, want pinned direct 0.3.0", manifest)
|
||||
}
|
||||
if manifest.Install.Artifacts[0].URL != versionArtifactURL {
|
||||
t.Fatalf("store manifest artifact = %#v, want requested version URL", manifest.Install.Artifacts[0])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,8 @@ type PluginsConfig struct {
|
||||
StoreSources []string `yaml:"store-sources,omitempty" json:"store-sources,omitempty"`
|
||||
// StoreAuth defines optional auth rules for plugin store registry, metadata, and artifact requests.
|
||||
StoreAuth []sdkpluginstore.AuthConfig `yaml:"store-auth,omitempty" json:"store-auth,omitempty"`
|
||||
// SyncRevision changes when Home-managed plugin credentials change.
|
||||
SyncRevision int64 `yaml:"sync-revision,omitempty" json:"sync-revision,omitempty"`
|
||||
// Configs stores per-plugin instance configuration by plugin ID.
|
||||
Configs map[string]PluginInstanceConfig `yaml:"configs" json:"configs"`
|
||||
}
|
||||
|
||||
@@ -117,6 +117,16 @@ plugins:
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigBytes_PluginSyncRevision(t *testing.T) {
|
||||
cfg, errParse := ParseConfigBytes([]byte("plugins:\n sync-revision: 42\n"))
|
||||
if errParse != nil {
|
||||
t.Fatalf("ParseConfigBytes() error = %v", errParse)
|
||||
}
|
||||
if cfg.Plugins.SyncRevision != 42 {
|
||||
t.Fatalf("Plugins.SyncRevision = %d, want 42", cfg.Plugins.SyncRevision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigBytes_PluginInstanceEmptyRawYAML(t *testing.T) {
|
||||
cfg, errParse := ParseConfigBytes([]byte(`
|
||||
plugins:
|
||||
|
||||
+93
-6
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -31,6 +32,7 @@ const (
|
||||
redisKeyAppLog = "app-log"
|
||||
redisKeyPluginStatus = "plugin-status"
|
||||
redisKeyPluginTasks = "plugin-tasks"
|
||||
redisKeyPluginSync = "plugin-sync"
|
||||
|
||||
homeReconnectInterval = time.Second
|
||||
homeReconnectFailoverThreshold = 3
|
||||
@@ -39,13 +41,16 @@ const (
|
||||
redisChannelCluster = "cluster"
|
||||
)
|
||||
|
||||
const pluginSyncUnsupportedErrorType = "plugin_sync_unsupported"
|
||||
|
||||
var (
|
||||
ErrDisabled = errors.New("home client disabled")
|
||||
ErrNotConnected = errors.New("home not connected")
|
||||
ErrEmptyResponse = errors.New("home returned empty response")
|
||||
ErrAuthNotFound = errors.New("home auth not found")
|
||||
ErrConfigNotFound = errors.New("home config not found")
|
||||
ErrModelsNotFound = errors.New("home models not found")
|
||||
ErrDisabled = errors.New("home client disabled")
|
||||
ErrNotConnected = errors.New("home not connected")
|
||||
ErrEmptyResponse = errors.New("home returned empty response")
|
||||
ErrAuthNotFound = errors.New("home auth not found")
|
||||
ErrConfigNotFound = errors.New("home config not found")
|
||||
ErrModelsNotFound = errors.New("home models not found")
|
||||
ErrPluginSyncUnsupported = errors.New("home plugin sync is unsupported")
|
||||
)
|
||||
|
||||
type clusterNode struct {
|
||||
@@ -930,6 +935,88 @@ func (c *Client) GetPluginTasks(ctx context.Context) ([]PluginTask, error) {
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetPluginSync(ctx context.Context, request pluginstore.PluginSyncRequest) (pluginstore.PluginSyncResponse, error) {
|
||||
cmd, errClient := c.commandClient()
|
||||
if errClient != nil {
|
||||
return pluginstore.PluginSyncResponse{}, errClient
|
||||
}
|
||||
payload, errMarshal := json.Marshal(request)
|
||||
if errMarshal != nil {
|
||||
return pluginstore.PluginSyncResponse{}, fmt.Errorf("marshal plugin sync request: %w", errMarshal)
|
||||
}
|
||||
requestCmd := redis.NewStringCmd(ctx, "get", redisKeyPluginSync, string(payload))
|
||||
if errProcess := cmd.Process(ctx, requestCmd); errProcess != nil {
|
||||
if message, ok := pluginSyncUnsupportedMessage(errProcess.Error()); ok {
|
||||
return pluginstore.PluginSyncResponse{}, fmt.Errorf("%w: %s", ErrPluginSyncUnsupported, message)
|
||||
}
|
||||
return pluginstore.PluginSyncResponse{}, errProcess
|
||||
}
|
||||
raw, errBytes := requestCmd.Bytes()
|
||||
if errBytes != nil {
|
||||
return pluginstore.PluginSyncResponse{}, errBytes
|
||||
}
|
||||
defer func() {
|
||||
requestCmd.SetVal("")
|
||||
for index := range raw {
|
||||
raw[index] = 0
|
||||
}
|
||||
}()
|
||||
if len(raw) == 0 {
|
||||
return pluginstore.PluginSyncResponse{}, ErrEmptyResponse
|
||||
}
|
||||
if message, ok := pluginSyncUnsupportedResponse(raw); ok {
|
||||
return pluginstore.PluginSyncResponse{}, fmt.Errorf("%w: %s", ErrPluginSyncUnsupported, message)
|
||||
}
|
||||
var response pluginstore.PluginSyncResponse
|
||||
if errUnmarshal := json.Unmarshal(raw, &response); errUnmarshal != nil {
|
||||
response.Clear()
|
||||
return pluginstore.PluginSyncResponse{}, fmt.Errorf("decode plugin sync response: %w", errUnmarshal)
|
||||
}
|
||||
if errValidate := response.Validate(time.Now().UTC()); errValidate != nil {
|
||||
response.Clear()
|
||||
return pluginstore.PluginSyncResponse{}, errValidate
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func pluginSyncUnsupportedResponse(raw []byte) (string, bool) {
|
||||
var response struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if errUnmarshal := json.Unmarshal(raw, &response); errUnmarshal != nil {
|
||||
return "", false
|
||||
}
|
||||
if pluginSyncUnsupportedCode(response.Error.Code) || pluginSyncUnsupportedCode(response.Error.Type) {
|
||||
message := strings.TrimSpace(response.Error.Message)
|
||||
if message == "" {
|
||||
message = pluginSyncUnsupportedErrorType
|
||||
}
|
||||
return message, true
|
||||
}
|
||||
return pluginSyncUnsupportedMessage(response.Error.Message)
|
||||
}
|
||||
|
||||
func pluginSyncUnsupportedCode(code string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(code), pluginSyncUnsupportedErrorType)
|
||||
}
|
||||
|
||||
func pluginSyncUnsupportedMessage(message string) (string, bool) {
|
||||
message = strings.ToLower(strings.TrimSpace(message))
|
||||
message = strings.TrimSpace(strings.TrimPrefix(message, "err "))
|
||||
switch message {
|
||||
case pluginSyncUnsupportedErrorType,
|
||||
"unsupported key",
|
||||
"wrong number of arguments for 'get' command":
|
||||
return message, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleSubscriptionPayload(ctx context.Context, channel string, payload string, onConfig func([]byte) error) error {
|
||||
payload = strings.TrimSpace(payload)
|
||||
if payload == "" {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
|
||||
)
|
||||
|
||||
func TestAuthDispatchRequestIncludesCount(t *testing.T) {
|
||||
@@ -314,6 +316,163 @@ func TestGetPluginTasksUsesPluginTasksKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPluginSyncUsesDedicatedCommandAndDecodesResponse(t *testing.T) {
|
||||
response := pluginstore.PluginSyncResponse{
|
||||
SchemaVersion: pluginstore.PluginSyncSchemaVersion,
|
||||
ExpiresAt: time.Now().UTC().Add(time.Minute),
|
||||
Items: []pluginstore.PluginSyncItem{{
|
||||
Manifest: pluginstore.Manifest{
|
||||
SchemaVersion: pluginstore.SchemaVersionV2,
|
||||
ID: "sample",
|
||||
Version: "1.0.0",
|
||||
Install: pluginstore.InstallPlan{Type: pluginstore.InstallTypeDirect, Artifacts: []pluginstore.Artifact{{
|
||||
GOOS: "linux", GOARCH: "amd64", URL: "https://downloads.example/sample.zip",
|
||||
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
}}},
|
||||
},
|
||||
Auth: []pluginstore.ResolvedAuthConfig{{
|
||||
Match: "https://downloads.example/", Type: pluginstore.AuthTypeBearer, Token: pluginstore.Secret("temporary-token"),
|
||||
}},
|
||||
}},
|
||||
}
|
||||
payload, errMarshal := json.Marshal(response)
|
||||
if errMarshal != nil {
|
||||
t.Fatalf("Marshal() error = %v", errMarshal)
|
||||
}
|
||||
client, commands := newRedisCommandTestClient(t, func(args []string) string {
|
||||
if len(args) > 0 && strings.EqualFold(args[0], "GET") {
|
||||
return fmt.Sprintf("$%d\r\n%s\r\n", len(payload), payload)
|
||||
}
|
||||
return "-ERR unexpected command\r\n"
|
||||
})
|
||||
request := pluginstore.PluginSyncRequest{
|
||||
SchemaVersion: pluginstore.PluginSyncSchemaVersion,
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
InstalledVersions: map[string]string{
|
||||
"sample": "0.9.0",
|
||||
},
|
||||
}
|
||||
|
||||
gotResponse, errSync := client.GetPluginSync(context.Background(), request)
|
||||
if errSync != nil {
|
||||
t.Fatalf("GetPluginSync() error = %v", errSync)
|
||||
}
|
||||
defer gotResponse.Clear()
|
||||
if len(gotResponse.Items) != 1 || string(gotResponse.Items[0].Auth[0].Token) != "temporary-token" {
|
||||
t.Fatalf("response = %#v, want one item with temporary token", gotResponse)
|
||||
}
|
||||
got := commands.Last()
|
||||
if len(got) != 3 || !strings.EqualFold(got[0], "get") || got[1] != "plugin-sync" {
|
||||
t.Fatalf("plugin sync command = %#v, want GET plugin-sync <request>", got)
|
||||
}
|
||||
var gotRequest pluginstore.PluginSyncRequest
|
||||
if errUnmarshal := json.Unmarshal([]byte(got[2]), &gotRequest); errUnmarshal != nil {
|
||||
t.Fatalf("decode request command: %v", errUnmarshal)
|
||||
}
|
||||
if gotRequest.InstalledVersions["sample"] != "0.9.0" {
|
||||
t.Fatalf("request = %#v, want installed sample 0.9.0", gotRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPluginSyncRecognizesUnsupportedHomeProtocol(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
}{
|
||||
{
|
||||
name: "legacy json error",
|
||||
response: func() string {
|
||||
payload := `{"error":{"type":"error","message":"wrong number of arguments for 'get' command"}}`
|
||||
return fmt.Sprintf("$%d\r\n%s\r\n", len(payload), payload)
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "redis unsupported key",
|
||||
response: "-ERR unsupported key\r\n",
|
||||
},
|
||||
{
|
||||
name: "structured unsupported type",
|
||||
response: func() string {
|
||||
payload := `{"error":{"type":"plugin_sync_unsupported","message":"plugin sync is unsupported"}}`
|
||||
return fmt.Sprintf("$%d\r\n%s\r\n", len(payload), payload)
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "redis unsupported code",
|
||||
response: "-ERR plugin_sync_unsupported\r\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client, _ := newRedisCommandTestClient(t, func(args []string) string {
|
||||
if len(args) > 0 && strings.EqualFold(args[0], "GET") {
|
||||
return tt.response
|
||||
}
|
||||
return "-ERR unexpected command\r\n"
|
||||
})
|
||||
_, errSync := client.GetPluginSync(context.Background(), pluginstore.PluginSyncRequest{
|
||||
SchemaVersion: pluginstore.PluginSyncSchemaVersion,
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
})
|
||||
if !errors.Is(errSync, ErrPluginSyncUnsupported) {
|
||||
t.Fatalf("GetPluginSync() error = %v, want ErrPluginSyncUnsupported", errSync)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPluginSyncDoesNotFallbackForOtherHomeErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
}{
|
||||
{
|
||||
name: "runtime not ready",
|
||||
response: func() string {
|
||||
payload := `{"error":{"type":"error","message":"runtime not ready"}}`
|
||||
return fmt.Sprintf("$%d\r\n%s\r\n", len(payload), payload)
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "unsupported key substring",
|
||||
response: func() string {
|
||||
payload := `{"error":{"type":"error","message":"plugin registry contains unsupported key metadata"}}`
|
||||
return fmt.Sprintf("$%d\r\n%s\r\n", len(payload), payload)
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "wrong arguments substring",
|
||||
response: "-ERR failed to get plugin sync: wrong number of arguments in credential resolver\r\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client, _ := newRedisCommandTestClient(t, func(args []string) string {
|
||||
if len(args) > 0 && strings.EqualFold(args[0], "GET") {
|
||||
return tt.response
|
||||
}
|
||||
return "-ERR unexpected command\r\n"
|
||||
})
|
||||
|
||||
_, errSync := client.GetPluginSync(context.Background(), pluginstore.PluginSyncRequest{
|
||||
SchemaVersion: pluginstore.PluginSyncSchemaVersion,
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
})
|
||||
if errSync == nil {
|
||||
t.Fatal("GetPluginSync() error = nil, want plugin sync failure")
|
||||
}
|
||||
if errors.Is(errSync, ErrPluginSyncUnsupported) {
|
||||
t.Fatalf("GetPluginSync() error = %v, want no legacy fallback", errSync)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type redisCommandLog struct {
|
||||
mu sync.Mutex
|
||||
commands [][]string
|
||||
|
||||
@@ -192,6 +192,157 @@ func SyncPlatformWithReport(ctx context.Context, cfg *config.Config, pluginRunti
|
||||
return report, errSync
|
||||
}
|
||||
|
||||
func SyncResolvedWithReport(ctx context.Context, cfg *config.Config, items []sdkpluginstore.PluginSyncItem, expiresAt time.Time, installedVersions map[string]string, pluginRuntime PluginRuntime) (SyncReport, error) {
|
||||
defer func() {
|
||||
for index := range items {
|
||||
items[index].Clear()
|
||||
}
|
||||
}()
|
||||
platform := NormalizePlatform(CurrentPlatform())
|
||||
report := newSyncReport(platform)
|
||||
if cfg == nil || !cfg.Home.Enabled || !cfg.Plugins.Enabled {
|
||||
finishReport(&report, nil)
|
||||
return report, nil
|
||||
}
|
||||
root, errResolvePluginsDir := config.ResolvePluginsDir(cfg.Plugins.Dir)
|
||||
if errResolvePluginsDir != nil {
|
||||
errPluginsDir := fmt.Errorf("home plugins: %w", errResolvePluginsDir)
|
||||
finishReport(&report, errPluginsDir)
|
||||
return report, errPluginsDir
|
||||
}
|
||||
addInstalledVersionStatuses(&report, cfg, root, installedVersions)
|
||||
var syncErrors []error
|
||||
for index := range items {
|
||||
if !time.Now().UTC().Before(expiresAt) {
|
||||
errExpired := fmt.Errorf("home plugins: plugin sync response expired")
|
||||
syncErrors = append(syncErrors, errExpired)
|
||||
break
|
||||
}
|
||||
item := &items[index]
|
||||
manifest := item.Manifest
|
||||
status := pluginStatusFromManifest(manifest)
|
||||
result, errInstall := installResolvedManifest(ctx, cfg, manifest, item.Auth, expiresAt, root, platform, pluginRuntime)
|
||||
item.Clear()
|
||||
if errInstall != nil {
|
||||
status.InstallStatus = pluginInstallStatusFailed
|
||||
status.Error = errInstall.Error()
|
||||
upsertPluginInstallStatus(&report, status)
|
||||
syncErrors = append(syncErrors, errInstall)
|
||||
continue
|
||||
}
|
||||
status.Path = strings.TrimSpace(result.Path)
|
||||
status.Skipped = result.Skipped
|
||||
status.Overwritten = result.Overwritten
|
||||
if result.Skipped {
|
||||
status.InstallStatus = pluginInstallStatusSkipped
|
||||
} else {
|
||||
status.InstallStatus = pluginInstallStatusInstalled
|
||||
}
|
||||
upsertPluginInstallStatus(&report, status)
|
||||
}
|
||||
errSync := errors.Join(syncErrors...)
|
||||
finishReport(&report, errSync)
|
||||
return report, errSync
|
||||
}
|
||||
|
||||
func addInstalledVersionStatuses(report *SyncReport, cfg *config.Config, root string, installedVersions map[string]string) {
|
||||
if report == nil || cfg == nil || len(installedVersions) == 0 {
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(cfg.Plugins.Configs))
|
||||
for id := range cfg.Plugins.Configs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
item := cfg.Plugins.Configs[id]
|
||||
if !pluginConfigEnabled(item) {
|
||||
continue
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
version, okVersion := installedVersions[id]
|
||||
if !okVersion {
|
||||
continue
|
||||
}
|
||||
status := PluginInstallStatus{
|
||||
ID: id,
|
||||
Version: strings.TrimSpace(version),
|
||||
InstallStatus: pluginInstallStatusSkipped,
|
||||
Skipped: true,
|
||||
}
|
||||
files, errFiles := pluginFileInfos(root, id)
|
||||
if errFiles == nil {
|
||||
for _, file := range files {
|
||||
if strings.TrimSpace(file.Version) == status.Version {
|
||||
status.Path = strings.TrimSpace(file.Path)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
manifest, okManifest, errManifest := storeManifestFromPluginConfig(id, item)
|
||||
if errManifest == nil && okManifest && pluginVersionsEqual(status.Version, manifest.Version) {
|
||||
status.ReleaseTag = strings.TrimSpace(manifest.ReleaseTag)
|
||||
status.Repository = strings.TrimSpace(manifest.Repository)
|
||||
status.InstallType = manifest.InstallType()
|
||||
}
|
||||
report.Plugins = append(report.Plugins, status)
|
||||
}
|
||||
}
|
||||
|
||||
func pluginVersionsEqual(left string, right string) bool {
|
||||
left = strings.TrimSpace(left)
|
||||
right = strings.TrimSpace(right)
|
||||
if left == "" || right == "" {
|
||||
return false
|
||||
}
|
||||
return !sdkpluginstore.UpdateAvailable(left, right) && !sdkpluginstore.UpdateAvailable(right, left)
|
||||
}
|
||||
|
||||
func upsertPluginInstallStatus(report *SyncReport, status PluginInstallStatus) {
|
||||
if report == nil {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(status.ID)
|
||||
for index := range report.Plugins {
|
||||
if strings.TrimSpace(report.Plugins[index].ID) == id {
|
||||
report.Plugins[index] = status
|
||||
return
|
||||
}
|
||||
}
|
||||
report.Plugins = append(report.Plugins, status)
|
||||
}
|
||||
|
||||
func installResolvedManifest(ctx context.Context, cfg *config.Config, manifest sdkpluginstore.Manifest, auth []sdkpluginstore.ResolvedAuthConfig, expiresAt time.Time, root string, platform Platform, pluginRuntime PluginRuntime) (sdkpluginstore.InstallResult, error) {
|
||||
client := newResolvedPluginStoreClient(cfg, auth, expiresAt)
|
||||
defer client.ClearAuth()
|
||||
return installManifest(ctx, client, manifest, root, platform, pluginRuntime)
|
||||
}
|
||||
|
||||
func InstalledVersions(cfg *config.Config) (map[string]string, error) {
|
||||
if cfg == nil {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
root, errResolvePluginsDir := config.ResolvePluginsDir(cfg.Plugins.Dir)
|
||||
if errResolvePluginsDir != nil {
|
||||
return nil, fmt.Errorf("home plugins: %w", errResolvePluginsDir)
|
||||
}
|
||||
versions := make(map[string]string, len(cfg.Plugins.Configs))
|
||||
for id := range cfg.Plugins.Configs {
|
||||
files, errFiles := pluginFileInfos(root, id)
|
||||
if errFiles != nil {
|
||||
return nil, fmt.Errorf("home plugins: discover installed plugin %s: %w", id, errFiles)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
continue
|
||||
}
|
||||
version := strings.TrimSpace(files[0].Version)
|
||||
if version != "" {
|
||||
versions[strings.TrimSpace(id)] = version
|
||||
}
|
||||
}
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
func installManifest(ctx context.Context, client sdkpluginstore.Client, manifest sdkpluginstore.Manifest, root string, platform Platform, pluginRuntime PluginRuntime) (sdkpluginstore.InstallResult, error) {
|
||||
id := strings.TrimSpace(manifest.ID)
|
||||
if id == "" {
|
||||
@@ -485,16 +636,22 @@ func MarkLoadResults(report *SyncReport, inspector PluginLoadInspector) error {
|
||||
}
|
||||
report.Phase = pluginTaskPhaseLoad
|
||||
var loadErrors []error
|
||||
preserveSyncError := !report.OK && strings.TrimSpace(report.Error) != ""
|
||||
if preserveSyncError {
|
||||
loadErrors = append(loadErrors, errors.New(report.Error))
|
||||
}
|
||||
for index := range report.Plugins {
|
||||
status := &report.Plugins[index]
|
||||
if status.InstallStatus == pluginInstallStatusFailed {
|
||||
if status.LoadStatus == "" {
|
||||
status.LoadStatus = pluginInstallStatusSkipped
|
||||
}
|
||||
if strings.TrimSpace(status.Error) != "" {
|
||||
loadErrors = append(loadErrors, errors.New(status.Error))
|
||||
} else {
|
||||
loadErrors = append(loadErrors, fmt.Errorf("home plugins: plugin %s install failed", status.ID))
|
||||
if !preserveSyncError {
|
||||
if strings.TrimSpace(status.Error) != "" {
|
||||
loadErrors = append(loadErrors, errors.New(status.Error))
|
||||
} else {
|
||||
loadErrors = append(loadErrors, fmt.Errorf("home plugins: plugin %s install failed", status.ID))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -529,6 +686,13 @@ func newSyncReport(platform Platform) SyncReport {
|
||||
}
|
||||
}
|
||||
|
||||
// CompletedSyncReport builds a completed report for outcomes before plugin installation starts.
|
||||
func CompletedSyncReport(platform Platform, errSync error) SyncReport {
|
||||
report := newSyncReport(platform)
|
||||
finishReport(&report, errSync)
|
||||
return report
|
||||
}
|
||||
|
||||
func finishReport(report *SyncReport, errTask error) {
|
||||
if report == nil {
|
||||
return
|
||||
@@ -604,6 +768,14 @@ var newPluginStoreClient = func(cfg *config.Config) sdkpluginstore.Client {
|
||||
return sdkpluginstore.NewClientWithAuth(client, "", storeAuth)
|
||||
}
|
||||
|
||||
var newResolvedPluginStoreClient = func(cfg *config.Config, auth []sdkpluginstore.ResolvedAuthConfig, expiresAt time.Time) sdkpluginstore.Client {
|
||||
client := &http.Client{}
|
||||
if cfg != nil && strings.TrimSpace(cfg.ProxyURL) != "" {
|
||||
util.SetProxy(&sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(cfg.ProxyURL)}, client)
|
||||
}
|
||||
return sdkpluginstore.NewClientWithResolvedAuthExpiry(client, "", auth, expiresAt)
|
||||
}
|
||||
|
||||
func pluginConfigEnabled(item config.PluginInstanceConfig) bool {
|
||||
return item.Enabled != nil && *item.Enabled
|
||||
}
|
||||
|
||||
@@ -6,13 +6,16 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
|
||||
@@ -72,6 +75,207 @@ func TestSyncPlatformInstallsManifestArtifact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncResolvedWithReportUsesTemporaryAuthAndClearsIt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
libraryName := "sample" + pluginExtension(runtime.GOOS)
|
||||
archiveData := makeZip(t, map[string]string{libraryName: "library-data"})
|
||||
checksum := sha256.Sum256(archiveData)
|
||||
var authenticated bool
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer temporary-token" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
authenticated = true
|
||||
_, _ = w.Write(archiveData)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
response, errUnauthenticated := server.Client().Get(server.URL + "/private/sample.zip")
|
||||
if errUnauthenticated != nil {
|
||||
t.Fatalf("unauthenticated GET error = %v", errUnauthenticated)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated status = %d, want 401", response.StatusCode)
|
||||
}
|
||||
|
||||
originalClient := newResolvedPluginStoreClient
|
||||
newResolvedPluginStoreClient = func(_ *config.Config, auth []sdkpluginstore.ResolvedAuthConfig, expiresAt time.Time) sdkpluginstore.Client {
|
||||
return sdkpluginstore.NewClientWithResolvedAuthExpiry(server.Client(), "", auth, expiresAt)
|
||||
}
|
||||
defer func() { newResolvedPluginStoreClient = originalClient }()
|
||||
token := sdkpluginstore.Secret("temporary-token")
|
||||
backing := token
|
||||
items := []sdkpluginstore.PluginSyncItem{{
|
||||
Manifest: sdkpluginstore.Manifest{
|
||||
SchemaVersion: sdkpluginstore.SchemaVersionV2,
|
||||
ID: "sample",
|
||||
Version: "1.0.0",
|
||||
Install: sdkpluginstore.InstallPlan{Type: sdkpluginstore.InstallTypeDirect, Artifacts: []sdkpluginstore.Artifact{{
|
||||
GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, URL: server.URL + "/private/sample.zip",
|
||||
SHA256: hex.EncodeToString(checksum[:]), Size: int64(len(archiveData)),
|
||||
}}},
|
||||
},
|
||||
Auth: []sdkpluginstore.ResolvedAuthConfig{{
|
||||
Match: server.URL + "/private/", ApplyTo: []string{sdkpluginstore.RequestKindArtifact}, Type: sdkpluginstore.AuthTypeBearer, Token: token,
|
||||
}},
|
||||
}}
|
||||
enabled := true
|
||||
cfg := &config.Config{
|
||||
Home: config.HomeConfig{Enabled: true},
|
||||
Plugins: config.PluginsConfig{Enabled: true, Dir: root, Configs: map[string]config.PluginInstanceConfig{"sample": {Enabled: &enabled}}},
|
||||
}
|
||||
|
||||
report, errSync := SyncResolvedWithReport(context.Background(), cfg, items, time.Now().UTC().Add(time.Minute), map[string]string{"sample": "0.9.0"}, nil)
|
||||
if errSync != nil {
|
||||
t.Fatalf("SyncResolvedWithReport() error = %v", errSync)
|
||||
}
|
||||
if !authenticated || !report.OK || len(report.Plugins) != 1 || report.Plugins[0].Version != "1.0.0" {
|
||||
t.Fatalf("authenticated=%v report=%+v, want successful authenticated install", authenticated, report)
|
||||
}
|
||||
for index, value := range backing {
|
||||
if value != 0 {
|
||||
t.Fatalf("token byte %d = %d, want zero after sync", index, value)
|
||||
}
|
||||
}
|
||||
if items[0].Auth != nil {
|
||||
t.Fatalf("sync item retained auth references: %#v", items[0].Auth)
|
||||
}
|
||||
target := pluginTestPath(root, runtime.GOOS, runtime.GOARCH, "sample", "1.0.0")
|
||||
if got, errRead := os.ReadFile(target); errRead != nil || string(got) != "library-data" {
|
||||
t.Fatalf("installed plugin = %q, error = %v", got, errRead)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncResolvedWithReportIncludesUnchangedInstalledPlugins(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := pluginTestPath(root, runtime.GOOS, runtime.GOARCH, "sample", "1.0.0")
|
||||
if errMkdir := os.MkdirAll(filepath.Dir(target), 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", errMkdir)
|
||||
}
|
||||
if errWrite := os.WriteFile(target, []byte("plugin"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile() error = %v", errWrite)
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Home: config.HomeConfig{Enabled: true},
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: root,
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample": pluginConfigFromYAML(t, `
|
||||
enabled: true
|
||||
store:
|
||||
id: sample
|
||||
name: Sample
|
||||
description: Adds sample support.
|
||||
author: owner
|
||||
version: 1.0.0
|
||||
release-tag: v1.0.0
|
||||
repository: https://github.com/owner/sample-plugin
|
||||
`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
report, errSync := SyncResolvedWithReport(
|
||||
context.Background(),
|
||||
cfg,
|
||||
nil,
|
||||
time.Now().UTC().Add(time.Minute),
|
||||
map[string]string{"sample": "1.0.0"},
|
||||
nil,
|
||||
)
|
||||
if errSync != nil {
|
||||
t.Fatalf("SyncResolvedWithReport() error = %v", errSync)
|
||||
}
|
||||
if len(report.Plugins) != 1 || report.Plugins[0].ID != "sample" || report.Plugins[0].InstallStatus != pluginInstallStatusSkipped {
|
||||
t.Fatalf("report plugins = %+v, want unchanged installed sample", report.Plugins)
|
||||
}
|
||||
status := report.Plugins[0]
|
||||
if status.Path != target || status.ReleaseTag != "v1.0.0" || status.Repository != "https://github.com/owner/sample-plugin" || status.InstallType != sdkpluginstore.InstallTypeGitHubRelease {
|
||||
t.Fatalf("unchanged plugin status = %+v, want preserved path and manifest metadata", status)
|
||||
}
|
||||
if errLoad := MarkLoadResults(&report, fakePluginLoadInspector{}); errLoad == nil {
|
||||
t.Fatal("MarkLoadResults() error = nil, want installed plugin load failure")
|
||||
}
|
||||
if report.Plugins[0].LoadStatus != pluginLoadStatusFailed {
|
||||
t.Fatalf("load status = %q, want failed", report.Plugins[0].LoadStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncResolvedWithReportDoesNotMixInstalledAndConfiguredMetadata(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := pluginTestPath(root, runtime.GOOS, runtime.GOARCH, "sample", "1.0.0")
|
||||
if errMkdir := os.MkdirAll(filepath.Dir(target), 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", errMkdir)
|
||||
}
|
||||
if errWrite := os.WriteFile(target, []byte("plugin"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile() error = %v", errWrite)
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Home: config.HomeConfig{Enabled: true},
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: root,
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample": pluginConfigFromYAML(t, `
|
||||
enabled: true
|
||||
store:
|
||||
id: sample
|
||||
name: Sample
|
||||
description: Adds sample support.
|
||||
author: owner
|
||||
version: 2.0.0
|
||||
release-tag: v2.0.0
|
||||
repository: https://github.com/owner/sample-plugin-v2
|
||||
`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
report, errSync := SyncResolvedWithReport(
|
||||
context.Background(),
|
||||
cfg,
|
||||
nil,
|
||||
time.Now().UTC().Add(time.Minute),
|
||||
map[string]string{"sample": "1.0.0"},
|
||||
nil,
|
||||
)
|
||||
if errSync != nil {
|
||||
t.Fatalf("SyncResolvedWithReport() error = %v", errSync)
|
||||
}
|
||||
if len(report.Plugins) != 1 {
|
||||
t.Fatalf("report plugins = %+v, want one installed sample", report.Plugins)
|
||||
}
|
||||
status := report.Plugins[0]
|
||||
if status.Version != "1.0.0" || status.Path != target {
|
||||
t.Fatalf("installed plugin status = %+v, want version 1.0.0 at %s", status, target)
|
||||
}
|
||||
if status.ReleaseTag != "" || status.Repository != "" || status.InstallType != "" {
|
||||
t.Fatalf("installed plugin status = %+v, want no metadata from configured version 2.0.0", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstalledVersionsUsesPluginFilesOnDisk(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := pluginTestPath(root, runtime.GOOS, runtime.GOARCH, "sample", "2.3.4")
|
||||
if errMkdir := os.MkdirAll(filepath.Dir(target), 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", errMkdir)
|
||||
}
|
||||
if errWrite := os.WriteFile(target, []byte("plugin"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile() error = %v", errWrite)
|
||||
}
|
||||
cfg := &config.Config{Plugins: config.PluginsConfig{Dir: root, Configs: map[string]config.PluginInstanceConfig{"sample": {}}}}
|
||||
|
||||
versions, errVersions := InstalledVersions(cfg)
|
||||
if errVersions != nil {
|
||||
t.Fatalf("InstalledVersions() error = %v", errVersions)
|
||||
}
|
||||
if versions["sample"] != "2.3.4" {
|
||||
t.Fatalf("InstalledVersions() = %#v, want sample 2.3.4", versions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPlatformWithReportRecordsSuccessfulInstall(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
archiveData := makeZip(t, map[string]string{"sample.dll": "library-data"})
|
||||
@@ -302,6 +506,51 @@ func TestMarkLoadResultsPreservesInstallFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkLoadResultsPreservesGlobalSyncFailure(t *testing.T) {
|
||||
report := newSyncReport(Platform{GOOS: "linux", GOARCH: "amd64"})
|
||||
report.Plugins = append(report.Plugins, PluginInstallStatus{
|
||||
ID: "installed", InstallStatus: pluginInstallStatusInstalled,
|
||||
})
|
||||
errExpired := errors.New("home plugins: plugin sync response expired")
|
||||
finishReport(&report, errExpired)
|
||||
|
||||
errLoad := MarkLoadResults(&report, fakePluginLoadInspector{"installed": true})
|
||||
if errLoad == nil || !strings.Contains(errLoad.Error(), "plugin sync response expired") {
|
||||
t.Fatalf("MarkLoadResults() error = %v, want preserved sync expiry", errLoad)
|
||||
}
|
||||
if report.OK || report.Status != pluginTaskStatusError || report.Phase != pluginTaskPhaseLoad {
|
||||
t.Fatalf("report = %+v, want failed load phase", report)
|
||||
}
|
||||
if !strings.Contains(report.Error, "plugin sync response expired") {
|
||||
t.Fatalf("report error = %q, want preserved sync expiry", report.Error)
|
||||
}
|
||||
if report.Plugins[0].LoadStatus != pluginLoadStatusLoaded {
|
||||
t.Fatalf("load status = %q, want loaded", report.Plugins[0].LoadStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletedSyncReport(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errSync error
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "success", wantOK: true},
|
||||
{name: "failure", errSync: errors.New("home plugins: inspect installed plugins: access denied")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
report := CompletedSyncReport(Platform{GOOS: "linux", GOARCH: "amd64"}, tt.errSync)
|
||||
if report.OK != tt.wantOK || report.Task != pluginTaskName || report.FinishedAt.IsZero() {
|
||||
t.Fatalf("report = %+v, want completed plugin sync report with ok=%v", report, tt.wantOK)
|
||||
}
|
||||
if tt.errSync != nil && (report.Status != pluginTaskStatusError || report.Error != tt.errSync.Error()) {
|
||||
t.Fatalf("report = %+v, want error %q", report, tt.errSync.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWithReportRejectsUnresolvedPluginsDir(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
t.Setenv("HOME", "")
|
||||
|
||||
+222
-10
@@ -7,6 +7,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -33,6 +34,98 @@ type AuthConfig struct {
|
||||
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
|
||||
@@ -124,49 +217,94 @@ func pluginGitHubReleaseAuthConfigured(plugin Plugin, auth []AuthConfig) bool {
|
||||
}
|
||||
|
||||
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 nil
|
||||
return false, nil
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(item.Type)) {
|
||||
case "", AuthTypeNone:
|
||||
return nil
|
||||
return false, nil
|
||||
case AuthTypeBearer:
|
||||
token, errToken := envValueRequired(item.TokenEnv, "token-env")
|
||||
if errToken != nil {
|
||||
return errToken
|
||||
return false, errToken
|
||||
}
|
||||
headers.Set("Authorization", "Bearer "+token)
|
||||
case AuthTypeBasic:
|
||||
username, errUsername := envValueRequired(item.UsernameEnv, "username-env")
|
||||
if errUsername != nil {
|
||||
return errUsername
|
||||
return false, errUsername
|
||||
}
|
||||
password, errPassword := envValueRequired(item.PasswordEnv, "password-env")
|
||||
if errPassword != nil {
|
||||
return errPassword
|
||||
return false, errPassword
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||
headers.Set("Authorization", "Basic "+encoded)
|
||||
case AuthTypeHeader:
|
||||
if strings.TrimSpace(item.HeaderName) == "" {
|
||||
return fmt.Errorf("plugin store auth missing header-name")
|
||||
return false, fmt.Errorf("plugin store auth missing header-name")
|
||||
}
|
||||
value, errValue := envValueRequired(item.HeaderValueEnv, "header-value-env")
|
||||
if errValue != nil {
|
||||
return errValue
|
||||
return false, errValue
|
||||
}
|
||||
headers.Set(item.HeaderName, value)
|
||||
case AuthTypeGitHubToken:
|
||||
token, errToken := envValueRequired(item.TokenEnv, "token-env")
|
||||
if errToken != nil {
|
||||
return errToken
|
||||
return false, errToken
|
||||
}
|
||||
headers.Set("Authorization", "Bearer "+token)
|
||||
default:
|
||||
return fmt.Errorf("unsupported plugin store auth type %q", item.Type)
|
||||
return false, fmt.Errorf("unsupported plugin store auth type %q", item.Type)
|
||||
}
|
||||
return nil
|
||||
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 {
|
||||
@@ -174,6 +312,9 @@ func validatePluginStoreRequestURL(auth []AuthConfig, requestURL string, kind st
|
||||
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")
|
||||
}
|
||||
@@ -188,6 +329,19 @@ func allowInsecurePluginStoreURL(auth []AuthConfig, requestURL string, kind stri
|
||||
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))
|
||||
@@ -203,6 +357,64 @@ func matchingAuthConfig(auth []AuthConfig, requestURL string, kind string) (Auth
|
||||
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 == "" {
|
||||
|
||||
@@ -3,11 +3,16 @@ 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) {
|
||||
@@ -225,3 +230,174 @@ func TestPluginStoreAuthHeaderIsAppliedToMatchingRedirect(t *testing.T) {
|
||||
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:[email protected]/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:[email protected]/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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ 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"
|
||||
@@ -20,10 +22,12 @@ const maxPluginStoreRedirects = 10
|
||||
type HTTPDoer = httpfetch.Doer
|
||||
|
||||
type Client struct {
|
||||
HTTPClient HTTPDoer
|
||||
RegistryURL string
|
||||
UserAgent string
|
||||
Auth []AuthConfig
|
||||
HTTPClient HTTPDoer
|
||||
RegistryURL string
|
||||
UserAgent string
|
||||
Auth []AuthConfig
|
||||
ResolvedAuth []ResolvedAuthConfig
|
||||
ResolvedAuthExpiresAt time.Time
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
@@ -132,6 +136,9 @@ func (c Client) releaseAssetAPIAuthenticated(apiURL string) bool {
|
||||
if apiURL == "" {
|
||||
return false
|
||||
}
|
||||
if item, ok := matchingResolvedAuthConfig(c.ResolvedAuth, apiURL, RequestKindArtifact); ok {
|
||||
return resolvedAuthConfigured(item)
|
||||
}
|
||||
return AuthConfigured(c.Auth, apiURL, RequestKindArtifact)
|
||||
}
|
||||
|
||||
@@ -141,14 +148,26 @@ func (c Client) get(ctx context.Context, requestURL string, accept string, kind
|
||||
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()},
|
||||
}
|
||||
if errAuth := applyPluginStoreAuth(headers, c.Auth, currentURL, kind); errAuth != nil {
|
||||
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
|
||||
}
|
||||
@@ -166,7 +185,7 @@ func (c Client) get(ctx context.Context, requestURL string, accept string, kind
|
||||
currentURL = nextURL
|
||||
continue
|
||||
}
|
||||
return readPluginStoreResponse(resp, maxSize)
|
||||
return readPluginStoreResponse(resp, maxSize, authenticated)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +214,7 @@ func pluginStoreGetNoRedirect(ctx context.Context, client HTTPDoer, requestURL s
|
||||
req.Header = headers.Clone()
|
||||
resp, errDo := pluginStoreNoRedirectClient(client).Do(req)
|
||||
if errDo != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", errDo)
|
||||
return nil, pluginStoreRequestError(requestURL, errDo)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -240,13 +259,16 @@ func pluginStoreRedirectURL(resp *http.Response, requestURL string) (string, err
|
||||
return next.String(), nil
|
||||
}
|
||||
|
||||
func readPluginStoreResponse(resp *http.Response, maxSize int64) ([]byte, error) {
|
||||
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)))
|
||||
}
|
||||
@@ -264,6 +286,23 @@ func readPluginStoreResponse(resp *http.Response, maxSize int64) ([]byte, error)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:[email protected]/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)
|
||||
}
|
||||
}
|
||||
@@ -403,6 +403,35 @@ func TestDownloadAssetUsesAPIURLWhenAuthMatchesArtifact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -44,15 +44,15 @@ func ManifestFromPlugin(source Source, plugin Plugin) (Manifest, error) {
|
||||
}
|
||||
switch PluginInstallType(plugin) {
|
||||
case InstallTypeDirect:
|
||||
return Manifest{
|
||||
manifest := manifestFromPlugin(source, plugin, Manifest{
|
||||
SchemaVersion: SchemaVersionV2,
|
||||
ID: strings.TrimSpace(plugin.ID),
|
||||
Version: strings.TrimSpace(plugin.Version),
|
||||
SourceID: strings.TrimSpace(source.ID),
|
||||
SourceName: strings.TrimSpace(source.Name),
|
||||
SourceURL: strings.TrimSpace(source.URL),
|
||||
Install: InstallPlan{Type: InstallTypeDirect},
|
||||
}, nil
|
||||
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:
|
||||
@@ -118,7 +118,10 @@ func (m Manifest) Validate() error {
|
||||
plan := NormalizeInstallPlan(m.Install)
|
||||
plan.Type = InstallTypeDirect
|
||||
if len(plan.Artifacts) > 0 {
|
||||
return ValidateInstallPlan(plan)
|
||||
if errValidate := ValidateInstallPlan(plan); errValidate != nil {
|
||||
return errValidate
|
||||
}
|
||||
return validatePinnedArtifactURLs(plan.Artifacts)
|
||||
}
|
||||
return validateManifestSourceURL(m.SourceURL)
|
||||
case InstallTypeGitHubRelease:
|
||||
@@ -144,6 +147,22 @@ func (m Manifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/homeplugins"
|
||||
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -32,10 +34,45 @@ func (s *Service) syncHomePlugins(ctx context.Context, cfg *config.Config) (home
|
||||
}
|
||||
s.homePluginSyncMu.Unlock()
|
||||
}
|
||||
report, errSync := homeplugins.SyncWithReport(ctx, cfg, s.pluginHost)
|
||||
if !cfg.Plugins.Enabled {
|
||||
return homeplugins.CompletedSyncReport(homeplugins.CurrentPlatform(), nil), syncKey, false, nil
|
||||
}
|
||||
installedVersions, errInstalled := homeplugins.InstalledVersions(cfg)
|
||||
if errInstalled != nil {
|
||||
return homeplugins.CompletedSyncReport(homeplugins.CurrentPlatform(), errInstalled), syncKey, false, errInstalled
|
||||
}
|
||||
platform := homeplugins.CurrentPlatform()
|
||||
request := sdkpluginstore.PluginSyncRequest{
|
||||
SchemaVersion: sdkpluginstore.PluginSyncSchemaVersion,
|
||||
GOOS: platform.GOOS,
|
||||
GOARCH: platform.GOARCH,
|
||||
InstalledVersions: installedVersions,
|
||||
}
|
||||
defer request.Clear()
|
||||
response, errFetch := s.fetchHomePluginSync(ctx, request)
|
||||
if errors.Is(errFetch, home.ErrPluginSyncUnsupported) {
|
||||
response.Clear()
|
||||
report, errSync := homeplugins.SyncWithReport(ctx, cfg, s.pluginHost)
|
||||
return report, syncKey, true, errSync
|
||||
}
|
||||
if errFetch != nil {
|
||||
return homeplugins.CompletedSyncReport(platform, errFetch), syncKey, false, errFetch
|
||||
}
|
||||
defer response.Clear()
|
||||
report, errSync := homeplugins.SyncResolvedWithReport(ctx, cfg, response.Items, response.ExpiresAt, request.InstalledVersions, s.pluginHost)
|
||||
return report, syncKey, true, errSync
|
||||
}
|
||||
|
||||
func (s *Service) fetchHomePluginSync(ctx context.Context, request sdkpluginstore.PluginSyncRequest) (sdkpluginstore.PluginSyncResponse, error) {
|
||||
if s.homePluginSyncFetch != nil {
|
||||
return s.homePluginSyncFetch(ctx, request)
|
||||
}
|
||||
if s.homeClient == nil {
|
||||
return sdkpluginstore.PluginSyncResponse{}, fmt.Errorf("home client is unavailable")
|
||||
}
|
||||
return s.homeClient.GetPluginSync(ctx, request)
|
||||
}
|
||||
|
||||
func (s *Service) markHomePluginsSynced(syncKey string) {
|
||||
if s == nil || strings.TrimSpace(syncKey) == "" {
|
||||
return
|
||||
@@ -108,7 +145,7 @@ func homePluginSyncKey(cfg *config.Config) string {
|
||||
return ""
|
||||
}
|
||||
hash := sha256.New()
|
||||
_, _ = fmt.Fprintf(hash, "enabled=%t\ndir=%s\n", cfg.Plugins.Enabled, strings.TrimSpace(cfg.Plugins.Dir))
|
||||
_, _ = fmt.Fprintf(hash, "enabled=%t\ndir=%s\nsync-revision=%d\n", cfg.Plugins.Enabled, strings.TrimSpace(cfg.Plugins.Dir), cfg.Plugins.SyncRevision)
|
||||
ids := make([]string, 0, len(cfg.Plugins.Configs))
|
||||
for id := range cfg.Plugins.Configs {
|
||||
ids = append(ids, id)
|
||||
|
||||
@@ -2,10 +2,13 @@ package cliproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -15,13 +18,19 @@ func TestSyncHomePluginsSkipsUnchangedSignature(t *testing.T) {
|
||||
cfg.Plugins.Enabled = true
|
||||
cfg.Plugins.Configs = map[string]config.PluginInstanceConfig{}
|
||||
|
||||
service := &Service{}
|
||||
_, key, didSync, errSync := service.syncHomePlugins(context.Background(), cfg)
|
||||
service := &Service{homePluginSyncFetch: func(context.Context, sdkpluginstore.PluginSyncRequest) (sdkpluginstore.PluginSyncResponse, error) {
|
||||
return sdkpluginstore.PluginSyncResponse{
|
||||
SchemaVersion: sdkpluginstore.PluginSyncSchemaVersion,
|
||||
ExpiresAt: time.Now().UTC().Add(time.Minute),
|
||||
Items: []sdkpluginstore.PluginSyncItem{},
|
||||
}, nil
|
||||
}}
|
||||
report, key, didSync, errSync := service.syncHomePlugins(context.Background(), cfg)
|
||||
if errSync != nil {
|
||||
t.Fatalf("syncHomePlugins() error = %v", errSync)
|
||||
}
|
||||
if !didSync || key == "" {
|
||||
t.Fatalf("syncHomePlugins() didSync=%v key=%q, want first sync with key", didSync, key)
|
||||
if !didSync || key == "" || !report.OK {
|
||||
t.Fatalf("syncHomePlugins() didSync=%v key=%q report=%+v, want reportable empty plan", didSync, key, report)
|
||||
}
|
||||
service.markHomePluginsSynced(key)
|
||||
|
||||
@@ -34,6 +43,104 @@ func TestSyncHomePluginsSkipsUnchangedSignature(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncHomePluginsFetchFailureReturnsFailureReport(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Home.Enabled = true
|
||||
cfg.Plugins.Enabled = true
|
||||
cfg.Plugins.Configs = map[string]config.PluginInstanceConfig{}
|
||||
wantErr := errors.New("plugin sync unavailable")
|
||||
service := &Service{homePluginSyncFetch: func(context.Context, sdkpluginstore.PluginSyncRequest) (sdkpluginstore.PluginSyncResponse, error) {
|
||||
return sdkpluginstore.PluginSyncResponse{}, wantErr
|
||||
}}
|
||||
|
||||
report, key, didSync, errSync := service.syncHomePlugins(context.Background(), cfg)
|
||||
if !errors.Is(errSync, wantErr) {
|
||||
t.Fatalf("syncHomePlugins() error = %v, want %v", errSync, wantErr)
|
||||
}
|
||||
if didSync {
|
||||
t.Fatalf("syncHomePlugins() didSync = true, want false before a plan is available")
|
||||
}
|
||||
if key == "" {
|
||||
t.Fatal("syncHomePlugins() key is empty")
|
||||
}
|
||||
if report.SchemaVersion != 1 || report.Task != "plugin-sync" || report.OK || report.Error != wantErr.Error() {
|
||||
t.Fatalf("syncHomePlugins() report = %#v, want reportable fetch failure", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncHomePluginsFallsBackForUnsupportedHomeProtocol(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Home.Enabled = true
|
||||
cfg.Plugins.Enabled = true
|
||||
cfg.Plugins.Dir = t.TempDir()
|
||||
cfg.Plugins.Configs = map[string]config.PluginInstanceConfig{}
|
||||
service := &Service{homePluginSyncFetch: func(context.Context, sdkpluginstore.PluginSyncRequest) (sdkpluginstore.PluginSyncResponse, error) {
|
||||
return sdkpluginstore.PluginSyncResponse{}, home.ErrPluginSyncUnsupported
|
||||
}}
|
||||
|
||||
report, key, didSync, errSync := service.syncHomePlugins(context.Background(), cfg)
|
||||
if errSync != nil {
|
||||
t.Fatalf("syncHomePlugins() error = %v", errSync)
|
||||
}
|
||||
if !didSync || key == "" {
|
||||
t.Fatalf("syncHomePlugins() didSync=%v key=%q, want legacy fallback", didSync, key)
|
||||
}
|
||||
if !report.OK || report.Task != "plugin-sync" {
|
||||
t.Fatalf("syncHomePlugins() report = %#v, want successful legacy sync", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncHomePluginsSkipsFetchWhenPluginsDisabled(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Home.Enabled = true
|
||||
cfg.Plugins.Configs = map[string]config.PluginInstanceConfig{}
|
||||
fetchCalls := 0
|
||||
service := &Service{homePluginSyncFetch: func(context.Context, sdkpluginstore.PluginSyncRequest) (sdkpluginstore.PluginSyncResponse, error) {
|
||||
fetchCalls++
|
||||
return sdkpluginstore.PluginSyncResponse{}, errors.New("fetch should not be called")
|
||||
}}
|
||||
|
||||
report, key, didSync, errSync := service.syncHomePlugins(context.Background(), cfg)
|
||||
if errSync != nil {
|
||||
t.Fatalf("syncHomePlugins() error = %v", errSync)
|
||||
}
|
||||
if didSync || fetchCalls != 0 {
|
||||
t.Fatalf("syncHomePlugins() didSync=%v fetchCalls=%d, want disabled skip", didSync, fetchCalls)
|
||||
}
|
||||
if key == "" || report.Task != "plugin-sync" || !report.OK {
|
||||
t.Fatalf("disabled sync key/report = %q/%#v, want reportable disabled status", key, report)
|
||||
}
|
||||
if service.homePluginSyncKey != "" {
|
||||
t.Fatalf("homePluginSyncKey = %q, want caller to mark after reporting", service.homePluginSyncKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncHomePluginsSkipsDisabledReportWhenUnchanged(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Home.Enabled = true
|
||||
cfg.Plugins.Configs = map[string]config.PluginInstanceConfig{}
|
||||
service := &Service{homePluginSyncFetch: func(context.Context, sdkpluginstore.PluginSyncRequest) (sdkpluginstore.PluginSyncResponse, error) {
|
||||
return sdkpluginstore.PluginSyncResponse{}, errors.New("fetch should not be called")
|
||||
}}
|
||||
|
||||
report, key, didSync, errSync := service.syncHomePlugins(context.Background(), cfg)
|
||||
if errSync != nil {
|
||||
t.Fatalf("syncHomePlugins() error = %v", errSync)
|
||||
}
|
||||
if didSync || key == "" || report.Task != "plugin-sync" || !report.OK {
|
||||
t.Fatalf("syncHomePlugins() didSync=%v key=%q report=%#v, want reportable disabled status", didSync, key, report)
|
||||
}
|
||||
service.markHomePluginsSynced(key)
|
||||
|
||||
report, gotKey, didSync, errSync := service.syncHomePlugins(context.Background(), cfg)
|
||||
if errSync != nil {
|
||||
t.Fatalf("syncHomePlugins(second) error = %v", errSync)
|
||||
}
|
||||
if didSync || gotKey != key || report.Task != "" {
|
||||
t.Fatalf("syncHomePlugins(second) didSync=%v key=%q report=%#v, want skipped unchanged disabled status", didSync, gotKey, report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHomeOverlayWarnsOnRuntimePluginSyncFailure(t *testing.T) {
|
||||
base := &config.Config{}
|
||||
base.Home.Enabled = true
|
||||
@@ -101,3 +208,27 @@ func TestStartHomeSubscriberDoesNotPreMarkPluginSync(t *testing.T) {
|
||||
t.Fatalf("homePluginSyncKey = %q, want empty before a successful plugin sync", service.homePluginSyncKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomePluginSyncKeyIncludesCredentialRevision(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Home.Enabled = true
|
||||
cfg.Plugins.Enabled = true
|
||||
cfg.Plugins.Configs = map[string]config.PluginInstanceConfig{}
|
||||
first := homePluginSyncKey(cfg)
|
||||
cfg.Plugins.SyncRevision = 2
|
||||
second := homePluginSyncKey(cfg)
|
||||
if first == second {
|
||||
t.Fatalf("homePluginSyncKey() unchanged after sync revision update: %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceHomeRuntimeConfigClearsStoreAuth(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Plugins.StoreAuth = []sdkpluginstore.AuthConfig{{
|
||||
Match: "https://downloads.example/", Type: sdkpluginstore.AuthTypeBearer, TokenEnv: "PLUGIN_TOKEN",
|
||||
}}
|
||||
forceHomeRuntimeConfig(cfg)
|
||||
if cfg.Plugins.StoreAuth != nil {
|
||||
t.Fatalf("Plugins.StoreAuth = %#v, want nil in Home mode", cfg.Plugins.StoreAuth)
|
||||
}
|
||||
}
|
||||
|
||||
+18
-11
@@ -31,6 +31,7 @@ import (
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
|
||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -105,11 +106,12 @@ type Service struct {
|
||||
// wsGateway manages websocket Gemini providers.
|
||||
wsGateway *wsrelay.Manager
|
||||
|
||||
homeClient *home.Client
|
||||
homeCancel context.CancelFunc
|
||||
homeLogForwarder *logging.HomeAppLogForwarder
|
||||
homePluginSyncMu sync.Mutex
|
||||
homePluginSyncKey string
|
||||
homeClient *home.Client
|
||||
homeCancel context.CancelFunc
|
||||
homeLogForwarder *logging.HomeAppLogForwarder
|
||||
homePluginSyncMu sync.Mutex
|
||||
homePluginSyncKey string
|
||||
homePluginSyncFetch func(context.Context, sdkpluginstore.PluginSyncRequest) (sdkpluginstore.PluginSyncResponse, error)
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -1418,6 +1420,7 @@ func forceHomeRuntimeConfig(cfg *config.Config) {
|
||||
cfg.WebsocketAuth = false
|
||||
cfg.RemoteManagement.AllowRemote = false
|
||||
cfg.RemoteManagement.DisableControlPanel = true
|
||||
cfg.Plugins.StoreAuth = nil
|
||||
}
|
||||
|
||||
func (s *Service) applyHomeOverlay(remoteCfg *config.Config) {
|
||||
@@ -1446,21 +1449,25 @@ func (s *Service) applyHomeOverlayContext(ctx context.Context, remoteCfg *config
|
||||
merged.Port = baseCfg.Port
|
||||
merged.TLS = baseCfg.TLS
|
||||
merged.Home = baseCfg.Home
|
||||
storeAuth := merged.Plugins.StoreAuth
|
||||
forceHomeRuntimeConfig(&merged)
|
||||
syncCfg := merged
|
||||
syncCfg.Plugins.StoreAuth = storeAuth
|
||||
|
||||
logHomeConfigChanges(baseCfg, &merged)
|
||||
report, syncKey, didSync, errSync := s.syncHomePlugins(ctx, &merged)
|
||||
if didSync {
|
||||
if errSync != nil {
|
||||
log.Warnf("failed to sync home plugins: %v", errSync)
|
||||
}
|
||||
report, syncKey, didSync, errSync := s.syncHomePlugins(ctx, &syncCfg)
|
||||
if errSync != nil {
|
||||
log.Warnf("failed to sync home plugins: %v", errSync)
|
||||
}
|
||||
s.applyConfigUpdate(&merged)
|
||||
var errLoad error
|
||||
if didSync {
|
||||
errLoad := homeplugins.MarkLoadResults(&report, s.pluginHost)
|
||||
errLoad = homeplugins.MarkLoadResults(&report, s.pluginHost)
|
||||
if errLoad != nil {
|
||||
log.Warnf("failed to load home plugins after config update: %v", errLoad)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(report.Task) != "" {
|
||||
s.reportHomePluginStatus(ctx, &merged, report)
|
||||
if errSync == nil && errLoad == nil {
|
||||
s.markHomePluginsSynced(syncKey)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
internalpluginstore "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginstore"
|
||||
)
|
||||
@@ -29,6 +30,8 @@ const (
|
||||
AuthTypeBasic = internalpluginstore.AuthTypeBasic
|
||||
AuthTypeHeader = internalpluginstore.AuthTypeHeader
|
||||
AuthTypeGitHubToken = internalpluginstore.AuthTypeGitHubToken
|
||||
|
||||
PluginSyncSchemaVersion = internalpluginstore.PluginSyncSchemaVersion
|
||||
)
|
||||
|
||||
type Source = internalpluginstore.Source
|
||||
@@ -44,6 +47,11 @@ type Artifact = internalpluginstore.Artifact
|
||||
type Platform = internalpluginstore.Platform
|
||||
type Manifest = internalpluginstore.Manifest
|
||||
type AuthConfig = internalpluginstore.AuthConfig
|
||||
type Secret = internalpluginstore.Secret
|
||||
type ResolvedAuthConfig = internalpluginstore.ResolvedAuthConfig
|
||||
type PluginSyncRequest = internalpluginstore.PluginSyncRequest
|
||||
type PluginSyncItem = internalpluginstore.PluginSyncItem
|
||||
type PluginSyncResponse = internalpluginstore.PluginSyncResponse
|
||||
|
||||
type HTTPDoer interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
@@ -70,6 +78,28 @@ func NewClientWithAuth(httpClient HTTPDoer, registryURL string, auth []AuthConfi
|
||||
}}
|
||||
}
|
||||
|
||||
func NewClientWithResolvedAuth(httpClient HTTPDoer, registryURL string, auth []ResolvedAuthConfig) Client {
|
||||
return NewClientWithResolvedAuthExpiry(httpClient, registryURL, auth, time.Time{})
|
||||
}
|
||||
|
||||
func NewClientWithResolvedAuthExpiry(httpClient HTTPDoer, registryURL string, auth []ResolvedAuthConfig, expiresAt time.Time) Client {
|
||||
return Client{inner: internalpluginstore.Client{
|
||||
HTTPClient: httpClient,
|
||||
RegistryURL: strings.TrimSpace(registryURL),
|
||||
ResolvedAuth: auth,
|
||||
ResolvedAuthExpiresAt: expiresAt,
|
||||
}}
|
||||
}
|
||||
|
||||
func (c *Client) ClearAuth() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
internalpluginstore.ClearResolvedAuthConfigs(c.inner.ResolvedAuth)
|
||||
c.inner.ResolvedAuth = nil
|
||||
c.inner.ResolvedAuthExpiresAt = time.Time{}
|
||||
}
|
||||
|
||||
func DefaultSource() Source {
|
||||
return internalpluginstore.DefaultSource()
|
||||
}
|
||||
@@ -98,10 +128,30 @@ func PluginArtifacts(plugin Plugin) []Artifact {
|
||||
return internalpluginstore.PluginArtifacts(plugin)
|
||||
}
|
||||
|
||||
func SelectArtifact(plan InstallPlan, goos string, goarch string) (Artifact, error) {
|
||||
return internalpluginstore.SelectArtifact(plan, goos, goarch)
|
||||
}
|
||||
|
||||
func GitHubRepositoryParts(repository string) (string, string, error) {
|
||||
return internalpluginstore.GitHubRepositoryParts(repository)
|
||||
}
|
||||
|
||||
func NormalizeAuthConfigs(auth []AuthConfig) []AuthConfig {
|
||||
return internalpluginstore.NormalizeAuthConfigs(auth)
|
||||
}
|
||||
|
||||
func ClearResolvedAuthConfigs(auth []ResolvedAuthConfig) {
|
||||
internalpluginstore.ClearResolvedAuthConfigs(auth)
|
||||
}
|
||||
|
||||
func ResolvedAuthForRequest(auth []ResolvedAuthConfig, requestURL string, kind string) (ResolvedAuthConfig, bool) {
|
||||
return internalpluginstore.ResolvedAuthForRequest(auth, requestURL, kind)
|
||||
}
|
||||
|
||||
func ValidateResolvedAuthConfig(auth ResolvedAuthConfig) error {
|
||||
return internalpluginstore.ValidateResolvedAuthConfig(auth)
|
||||
}
|
||||
|
||||
func AuthConfigured(auth []AuthConfig, requestURL string, kind string) bool {
|
||||
return internalpluginstore.AuthConfigured(auth, requestURL, kind)
|
||||
}
|
||||
|
||||
@@ -83,8 +83,28 @@ func TestManifestFromPluginBuildsDirectManifest(t *testing.T) {
|
||||
if manifest.SchemaVersion != SchemaVersionV2 || manifest.InstallType() != InstallTypeDirect || manifest.ReleaseTag != "" {
|
||||
t.Fatalf("manifest = %#v, want v2 direct without release tag", manifest)
|
||||
}
|
||||
if manifest.SourceURL != DefaultRegistryURL || len(manifest.Install.Artifacts) != 0 {
|
||||
t.Fatalf("manifest source/artifacts = %q/%d, want source URL without artifacts", manifest.SourceURL, len(manifest.Install.Artifacts))
|
||||
if manifest.SourceURL != DefaultRegistryURL || len(manifest.Install.Artifacts) != 1 {
|
||||
t.Fatalf("manifest source/artifacts = %q/%d, want source URL and one pinned artifact", manifest.SourceURL, len(manifest.Install.Artifacts))
|
||||
}
|
||||
artifact := manifest.Install.Artifacts[0]
|
||||
if artifact.GOOS != "linux" || artifact.GOARCH != "amd64" || artifact.URL != "https://downloads.example/sample-provider.zip" {
|
||||
t.Fatalf("manifest artifact = %#v, want pinned linux/amd64 artifact", artifact)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestFromPluginRejectsArtifactQuery(t *testing.T) {
|
||||
_, errManifest := ManifestFromPlugin(DefaultSource(), Plugin{
|
||||
ID: "sample-provider", Name: "Sample Provider", Description: "Sample", Author: "tester", Version: "1.0.0",
|
||||
Install: InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{{
|
||||
GOOS: "linux", GOARCH: "amd64", URL: "https://downloads.example/sample.zip?X-Amz-Signature=secret",
|
||||
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
}}},
|
||||
})
|
||||
if errManifest == nil {
|
||||
t.Fatal("ManifestFromPlugin() error = nil, want query rejection")
|
||||
}
|
||||
if strings.Contains(errManifest.Error(), "secret") {
|
||||
t.Fatalf("ManifestFromPlugin() error leaked query value: %v", errManifest)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user