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:
hkfires
2026-07-17 08:09:05 +08:00
parent 106270bea6
commit 5243b38ab7
20 changed files with 1811 additions and 70 deletions
+48 -9
View File
@@ -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])
}
}
+2
View File
@@ -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"`
}
+10
View File
@@ -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
View File
@@ -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 == "" {
+159
View File
@@ -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
+176 -4
View File
@@ -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
}
+249
View File
@@ -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
View File
@@ -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 == "" {
+176
View File
@@ -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)
}
}
+47 -8
View File
@@ -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
+110
View File
@@ -0,0 +1,110 @@
package pluginstore
import (
"fmt"
"net/url"
"strings"
"time"
)
const PluginSyncSchemaVersion = 1
type PluginSyncRequest struct {
SchemaVersion int `json:"schema_version"`
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
InstalledVersions map[string]string `json:"installed_versions,omitempty"`
}
func (r *PluginSyncRequest) Clear() {
if r == nil {
return
}
clear(r.InstalledVersions)
r.InstalledVersions = nil
}
type PluginSyncItem struct {
Manifest Manifest `json:"manifest"`
Auth []ResolvedAuthConfig `json:"auth,omitempty"`
}
func (i *PluginSyncItem) Clear() {
if i == nil {
return
}
ClearResolvedAuthConfigs(i.Auth)
i.Auth = nil
i.Manifest = Manifest{}
}
type PluginSyncResponse struct {
SchemaVersion int `json:"schema_version"`
ExpiresAt time.Time `json:"expires_at"`
Items []PluginSyncItem `json:"items"`
}
func (r *PluginSyncResponse) Validate(now time.Time) error {
if r == nil {
return fmt.Errorf("plugin sync response is nil")
}
if r.SchemaVersion != PluginSyncSchemaVersion {
return fmt.Errorf("unsupported plugin sync schema_version %d", r.SchemaVersion)
}
if r.ExpiresAt.IsZero() {
return fmt.Errorf("plugin sync response missing expires_at")
}
if !now.Before(r.ExpiresAt) {
return fmt.Errorf("plugin sync response expired")
}
seen := make(map[string]struct{}, len(r.Items))
for index := range r.Items {
item := &r.Items[index]
if errManifest := item.Manifest.Validate(); errManifest != nil {
return fmt.Errorf("plugin sync item %d: %w", index, errManifest)
}
if errURLs := validatePluginSyncManifestURLs(item.Manifest); errURLs != nil {
return fmt.Errorf("plugin sync item %d: %w", index, errURLs)
}
id := strings.TrimSpace(item.Manifest.ID)
if _, exists := seen[id]; exists {
return fmt.Errorf("plugin sync response contains duplicate plugin %q", id)
}
seen[id] = struct{}{}
for authIndex := range item.Auth {
if errAuth := ValidateResolvedAuthConfig(item.Auth[authIndex]); errAuth != nil {
return fmt.Errorf("plugin sync item %d auth %d: %w", index, authIndex, errAuth)
}
}
}
return nil
}
func validatePluginSyncManifestURLs(manifest Manifest) error {
if manifest.InstallType() != InstallTypeDirect {
return nil
}
plan := NormalizeInstallPlan(manifest.Install)
if len(plan.Artifacts) == 0 {
return fmt.Errorf("direct plugin sync manifest requires pinned artifacts")
}
for index, artifact := range plan.Artifacts {
parsed, errParse := url.Parse(strings.TrimSpace(artifact.URL))
if errParse != nil || !strings.EqualFold(parsed.Scheme, "https") {
return fmt.Errorf("direct plugin sync artifact %d must use https", index)
}
}
return nil
}
func (r *PluginSyncResponse) Clear() {
if r == nil {
return
}
for index := range r.Items {
r.Items[index].Clear()
}
r.Items = nil
r.ExpiresAt = time.Time{}
r.SchemaVersion = 0
}
+161
View File
@@ -0,0 +1,161 @@
package pluginstore
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
)
func TestPluginSyncResponseValidatesAndClearsResolvedAuth(t *testing.T) {
response := PluginSyncResponse{
SchemaVersion: PluginSyncSchemaVersion,
ExpiresAt: time.Now().UTC().Add(time.Minute),
Items: []PluginSyncItem{{
Manifest: Manifest{
SchemaVersion: SchemaVersionV2,
ID: "sample",
Version: "1.0.0",
Install: InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{{
GOOS: "linux", GOARCH: "amd64", URL: "https://downloads.example/sample.zip",
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}}},
},
Auth: []ResolvedAuthConfig{{
Match: "https://downloads.example/", Type: AuthTypeBearer, Token: Secret("temporary-token"),
}},
}},
}
if errValidate := response.Validate(time.Now().UTC()); errValidate != nil {
t.Fatalf("Validate() error = %v", errValidate)
}
backing := response.Items[0].Auth[0].Token
response.Clear()
for index, value := range backing {
if value != 0 {
t.Fatalf("token byte %d = %d, want zero", index, value)
}
}
if response.Items != nil || !response.ExpiresAt.IsZero() || response.SchemaVersion != 0 {
t.Fatalf("Clear() left response state: %#v", response)
}
}
func TestPluginSyncResponseJSONKeepsSecretsOutOfPlainText(t *testing.T) {
response := PluginSyncResponse{
SchemaVersion: PluginSyncSchemaVersion,
ExpiresAt: time.Now().UTC().Add(time.Minute),
Items: []PluginSyncItem{{Auth: []ResolvedAuthConfig{{Token: Secret("temporary-token")}}}},
}
raw, errMarshal := json.Marshal(response)
if errMarshal != nil {
t.Fatalf("Marshal() error = %v", errMarshal)
}
if bytes.Contains(raw, []byte("temporary-token")) {
t.Fatalf("Marshal() exposed token as plain text: %s", raw)
}
var decoded PluginSyncResponse
if errUnmarshal := json.Unmarshal(raw, &decoded); errUnmarshal != nil {
t.Fatalf("Unmarshal() error = %v", errUnmarshal)
}
if got := string(decoded.Items[0].Auth[0].Token); got != "temporary-token" {
t.Fatalf("decoded token = %q, want temporary-token", got)
}
decoded.Clear()
}
func TestPluginSyncResponseRejectsExpiredPlan(t *testing.T) {
response := PluginSyncResponse{SchemaVersion: PluginSyncSchemaVersion, ExpiresAt: time.Now().UTC().Add(-time.Second)}
if errValidate := response.Validate(time.Now().UTC()); errValidate == nil {
t.Fatal("Validate() error = nil, want expired response")
}
}
func TestPluginSyncResponseRejectsInsecureResolvedAuthMatch(t *testing.T) {
response := PluginSyncResponse{
SchemaVersion: PluginSyncSchemaVersion,
ExpiresAt: time.Now().UTC().Add(time.Minute),
Items: []PluginSyncItem{{
Manifest: Manifest{
SchemaVersion: SchemaVersionV2, ID: "sample", Version: "1.0.0",
Install: InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{{
GOOS: "linux", GOARCH: "amd64", URL: "https://downloads.example/sample.zip",
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}}},
},
Auth: []ResolvedAuthConfig{{Match: "http://downloads.example/", Type: AuthTypeBearer, Token: Secret("token")}},
}},
}
defer response.Clear()
if errValidate := response.Validate(time.Now().UTC()); errValidate == nil {
t.Fatal("Validate() error = nil, want insecure auth match rejection")
}
}
func TestPluginSyncResponseRejectsHTTPArtifact(t *testing.T) {
response := PluginSyncResponse{
SchemaVersion: PluginSyncSchemaVersion,
ExpiresAt: time.Now().UTC().Add(time.Minute),
Items: []PluginSyncItem{{
Manifest: Manifest{
SchemaVersion: SchemaVersionV2, ID: "sample", Version: "1.0.0",
Install: InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{{
GOOS: "linux", GOARCH: "amd64", URL: "http://downloads.example/sample.zip",
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}}},
},
}},
}
defer response.Clear()
if errValidate := response.Validate(time.Now().UTC()); errValidate == nil {
t.Fatal("Validate() error = nil, want HTTP artifact rejection")
}
}
func TestPluginSyncResponseRejectsHTTPArtifactWithResolvedAuth(t *testing.T) {
response := PluginSyncResponse{
SchemaVersion: PluginSyncSchemaVersion,
ExpiresAt: time.Now().UTC().Add(time.Minute),
Items: []PluginSyncItem{{
Manifest: Manifest{
SchemaVersion: SchemaVersionV2, ID: "sample", Version: "1.0.0",
Install: InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{{
GOOS: "linux", GOARCH: "amd64", URL: "http://downloads.example/sample.zip",
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}}},
},
Auth: []ResolvedAuthConfig{{
Match: "https://downloads.example/", ApplyTo: []string{RequestKindArtifact}, Type: AuthTypeBearer, Token: Secret("token"),
}},
}},
}
defer response.Clear()
if errValidate := response.Validate(time.Now().UTC()); errValidate == nil {
t.Fatal("Validate() error = nil, want HTTP artifact rejection")
}
}
func TestPluginSyncResponseRejectsArtifactURLCredentials(t *testing.T) {
response := PluginSyncResponse{
SchemaVersion: PluginSyncSchemaVersion,
ExpiresAt: time.Now().UTC().Add(time.Minute),
Items: []PluginSyncItem{{
Manifest: Manifest{
SchemaVersion: SchemaVersionV2, ID: "sample", Version: "1.0.0",
Install: InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{{
GOOS: "linux", GOARCH: "amd64", URL: "https://user:[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)
}
}
+29
View File
@@ -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"
+27 -8
View File
@@ -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 == "" {
+39 -2
View File
@@ -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)
+135 -4
View File
@@ -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
View File
@@ -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)
+50
View File
@@ -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)
}
+22 -2
View File
@@ -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)
}
}