diff --git a/cmd/server/main.go b/cmd/server/main.go index 81c37cd7..5df6342c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) + 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 errHomePlugins != nil { - log.Errorf("failed to fetch plugins from home: %v", errHomePlugins) + log.Errorf("failed to sync plugins from home: %v", errHomePlugins) } - if errReportPlugins != nil { - log.Warnf("failed to report home plugin sync status: %v", errReportPlugins) + 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 { diff --git a/internal/api/handlers/management/plugin_store_test.go b/internal/api/handlers/management/plugin_store_test.go index 478f9697..3b3e881b 100644 --- a/internal/api/handlers/management/plugin_store_test.go +++ b/internal/api/handlers/management/plugin_store_test.go @@ -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]) } } diff --git a/internal/config/config.go b/internal/config/config.go index 0647fb8f..bcbe250f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` } diff --git a/internal/config/plugin_config_test.go b/internal/config/plugin_config_test.go index e1a81d6b..0d30cfca 100644 --- a/internal/config/plugin_config_test.go +++ b/internal/config/plugin_config_test.go @@ -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: diff --git a/internal/home/client.go b/internal/home/client.go index 83c0c44e..e2487856 100644 --- a/internal/home/client.go +++ b/internal/home/client.go @@ -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 == "" { diff --git a/internal/home/client_test.go b/internal/home/client_test.go index 8a5845d0..bf7568cb 100644 --- a/internal/home/client_test.go +++ b/internal/home/client_test.go @@ -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 ", 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 diff --git a/internal/homeplugins/sync.go b/internal/homeplugins/sync.go index 8ed2f16d..9cf5c3e1 100644 --- a/internal/homeplugins/sync.go +++ b/internal/homeplugins/sync.go @@ -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 } diff --git a/internal/homeplugins/sync_test.go b/internal/homeplugins/sync_test.go index 8a636515..60b91348 100644 --- a/internal/homeplugins/sync_test.go +++ b/internal/homeplugins/sync_test.go @@ -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", "") diff --git a/internal/pluginstore/auth.go b/internal/pluginstore/auth.go index 72d16a72..110c4eff 100644 --- a/internal/pluginstore/auth.go +++ b/internal/pluginstore/auth.go @@ -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 == "" { diff --git a/internal/pluginstore/auth_test.go b/internal/pluginstore/auth_test.go index 07ea25be..7dfe2e65 100644 --- a/internal/pluginstore/auth_test.go +++ b/internal/pluginstore/auth_test.go @@ -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:password@downloads.example/plugin.zip?trace=private-value#section" + cause := context.Canceled + errRequest := pluginStoreRequestError(requestURL, &url.Error{URL: requestURL, Err: cause}) + if strings.Contains(errRequest.Error(), "private-value") || strings.Contains(errRequest.Error(), "section") || strings.Contains(errRequest.Error(), "trace=") || strings.Contains(errRequest.Error(), "password") || strings.Contains(errRequest.Error(), "user@") { + t.Fatalf("pluginStoreRequestError() leaked URL query or fragment: %v", errRequest) + } + if !strings.Contains(errRequest.Error(), "https://downloads.example/plugin.zip") { + t.Fatalf("pluginStoreRequestError() = %v, want sanitized URL", errRequest) + } + if !errors.Is(errRequest, cause) { + t.Fatalf("errors.Is(pluginStoreRequestError(), context.Canceled) = false") + } +} + +func TestPluginStoreRequestURLRejectsCredentials(t *testing.T) { + errValidate := validatePluginStoreRequestURL(nil, "https://user:password@downloads.example/plugin.zip", RequestKindArtifact) + if errValidate == nil { + t.Fatal("validatePluginStoreRequestURL() error = nil, want URL credentials rejection") + } + if strings.Contains(errValidate.Error(), "password") { + t.Fatalf("validatePluginStoreRequestURL() error leaked URL credentials: %v", errValidate) + } +} + +func TestResolvedAuthExpiryRejectsAuthenticatedRequest(t *testing.T) { + auth := []ResolvedAuthConfig{{ + Match: "https://downloads.example/private/", + ApplyTo: []string{RequestKindArtifact}, + Type: AuthTypeBearer, + Token: Secret("temporary-token"), + }} + now := time.Now().UTC() + client := Client{ + HTTPClient: failingHTTPDoer{}, + ResolvedAuth: auth, + ResolvedAuthExpiresAt: now.Add(-time.Second), + } + _, errDownload := client.DownloadArtifact(context.Background(), Artifact{ + GOOS: "linux", + GOARCH: "amd64", + URL: "https://downloads.example/private/plugin.zip", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }) + if errDownload == nil || !strings.Contains(errDownload.Error(), "resolved auth expired") { + t.Fatalf("DownloadArtifact() error = %v, want resolved auth expiry", errDownload) + } +} diff --git a/internal/pluginstore/github.go b/internal/pluginstore/github.go index 2db6299e..8e52a7ef 100644 --- a/internal/pluginstore/github.go +++ b/internal/pluginstore/github.go @@ -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 diff --git a/internal/pluginstore/home_sync.go b/internal/pluginstore/home_sync.go new file mode 100644 index 00000000..a0c79919 --- /dev/null +++ b/internal/pluginstore/home_sync.go @@ -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 +} diff --git a/internal/pluginstore/home_sync_test.go b/internal/pluginstore/home_sync_test.go new file mode 100644 index 00000000..752ba6ad --- /dev/null +++ b/internal/pluginstore/home_sync_test.go @@ -0,0 +1,161 @@ +package pluginstore + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" +) + +func TestPluginSyncResponseValidatesAndClearsResolvedAuth(t *testing.T) { + response := PluginSyncResponse{ + SchemaVersion: PluginSyncSchemaVersion, + ExpiresAt: time.Now().UTC().Add(time.Minute), + Items: []PluginSyncItem{{ + Manifest: Manifest{ + SchemaVersion: SchemaVersionV2, + ID: "sample", + Version: "1.0.0", + Install: InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{{ + GOOS: "linux", GOARCH: "amd64", URL: "https://downloads.example/sample.zip", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }}}, + }, + Auth: []ResolvedAuthConfig{{ + Match: "https://downloads.example/", Type: AuthTypeBearer, Token: Secret("temporary-token"), + }}, + }}, + } + if errValidate := response.Validate(time.Now().UTC()); errValidate != nil { + t.Fatalf("Validate() error = %v", errValidate) + } + backing := response.Items[0].Auth[0].Token + response.Clear() + for index, value := range backing { + if value != 0 { + t.Fatalf("token byte %d = %d, want zero", index, value) + } + } + if response.Items != nil || !response.ExpiresAt.IsZero() || response.SchemaVersion != 0 { + t.Fatalf("Clear() left response state: %#v", response) + } +} + +func TestPluginSyncResponseJSONKeepsSecretsOutOfPlainText(t *testing.T) { + response := PluginSyncResponse{ + SchemaVersion: PluginSyncSchemaVersion, + ExpiresAt: time.Now().UTC().Add(time.Minute), + Items: []PluginSyncItem{{Auth: []ResolvedAuthConfig{{Token: Secret("temporary-token")}}}}, + } + raw, errMarshal := json.Marshal(response) + if errMarshal != nil { + t.Fatalf("Marshal() error = %v", errMarshal) + } + if bytes.Contains(raw, []byte("temporary-token")) { + t.Fatalf("Marshal() exposed token as plain text: %s", raw) + } + var decoded PluginSyncResponse + if errUnmarshal := json.Unmarshal(raw, &decoded); errUnmarshal != nil { + t.Fatalf("Unmarshal() error = %v", errUnmarshal) + } + if got := string(decoded.Items[0].Auth[0].Token); got != "temporary-token" { + t.Fatalf("decoded token = %q, want temporary-token", got) + } + decoded.Clear() +} + +func TestPluginSyncResponseRejectsExpiredPlan(t *testing.T) { + response := PluginSyncResponse{SchemaVersion: PluginSyncSchemaVersion, ExpiresAt: time.Now().UTC().Add(-time.Second)} + if errValidate := response.Validate(time.Now().UTC()); errValidate == nil { + t.Fatal("Validate() error = nil, want expired response") + } +} + +func TestPluginSyncResponseRejectsInsecureResolvedAuthMatch(t *testing.T) { + response := PluginSyncResponse{ + SchemaVersion: PluginSyncSchemaVersion, + ExpiresAt: time.Now().UTC().Add(time.Minute), + Items: []PluginSyncItem{{ + Manifest: Manifest{ + SchemaVersion: SchemaVersionV2, ID: "sample", Version: "1.0.0", + Install: InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{{ + GOOS: "linux", GOARCH: "amd64", URL: "https://downloads.example/sample.zip", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }}}, + }, + Auth: []ResolvedAuthConfig{{Match: "http://downloads.example/", Type: AuthTypeBearer, Token: Secret("token")}}, + }}, + } + defer response.Clear() + if errValidate := response.Validate(time.Now().UTC()); errValidate == nil { + t.Fatal("Validate() error = nil, want insecure auth match rejection") + } +} + +func TestPluginSyncResponseRejectsHTTPArtifact(t *testing.T) { + response := PluginSyncResponse{ + SchemaVersion: PluginSyncSchemaVersion, + ExpiresAt: time.Now().UTC().Add(time.Minute), + Items: []PluginSyncItem{{ + Manifest: Manifest{ + SchemaVersion: SchemaVersionV2, ID: "sample", Version: "1.0.0", + Install: InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{{ + GOOS: "linux", GOARCH: "amd64", URL: "http://downloads.example/sample.zip", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }}}, + }, + }}, + } + defer response.Clear() + if errValidate := response.Validate(time.Now().UTC()); errValidate == nil { + t.Fatal("Validate() error = nil, want HTTP artifact rejection") + } +} + +func TestPluginSyncResponseRejectsHTTPArtifactWithResolvedAuth(t *testing.T) { + response := PluginSyncResponse{ + SchemaVersion: PluginSyncSchemaVersion, + ExpiresAt: time.Now().UTC().Add(time.Minute), + Items: []PluginSyncItem{{ + Manifest: Manifest{ + SchemaVersion: SchemaVersionV2, ID: "sample", Version: "1.0.0", + Install: InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{{ + GOOS: "linux", GOARCH: "amd64", URL: "http://downloads.example/sample.zip", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }}}, + }, + Auth: []ResolvedAuthConfig{{ + Match: "https://downloads.example/", ApplyTo: []string{RequestKindArtifact}, Type: AuthTypeBearer, Token: Secret("token"), + }}, + }}, + } + defer response.Clear() + if errValidate := response.Validate(time.Now().UTC()); errValidate == nil { + t.Fatal("Validate() error = nil, want HTTP artifact rejection") + } +} + +func TestPluginSyncResponseRejectsArtifactURLCredentials(t *testing.T) { + response := PluginSyncResponse{ + SchemaVersion: PluginSyncSchemaVersion, + ExpiresAt: time.Now().UTC().Add(time.Minute), + Items: []PluginSyncItem{{ + Manifest: Manifest{ + SchemaVersion: SchemaVersionV2, ID: "sample", Version: "1.0.0", + Install: InstallPlan{Type: InstallTypeDirect, Artifacts: []Artifact{{ + GOOS: "linux", GOARCH: "amd64", URL: "https://user:password@downloads.example/sample.zip", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }}}, + }, + }}, + } + defer response.Clear() + errValidate := response.Validate(time.Now().UTC()) + if errValidate == nil { + t.Fatal("Validate() error = nil, want artifact URL credentials rejection") + } + if strings.Contains(errValidate.Error(), "password") { + t.Fatalf("Validate() error leaked URL credentials: %v", errValidate) + } +} diff --git a/internal/pluginstore/install_test.go b/internal/pluginstore/install_test.go index 24358f62..282f231b 100644 --- a/internal/pluginstore/install_test.go +++ b/internal/pluginstore/install_test.go @@ -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" diff --git a/internal/pluginstore/manifest.go b/internal/pluginstore/manifest.go index 919990aa..0ed66833 100644 --- a/internal/pluginstore/manifest.go +++ b/internal/pluginstore/manifest.go @@ -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 == "" { diff --git a/sdk/cliproxy/home_plugins.go b/sdk/cliproxy/home_plugins.go index 813165c3..3b6f2769 100644 --- a/sdk/cliproxy/home_plugins.go +++ b/sdk/cliproxy/home_plugins.go @@ -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) diff --git a/sdk/cliproxy/home_plugins_test.go b/sdk/cliproxy/home_plugins_test.go index f9c84a07..263a7556 100644 --- a/sdk/cliproxy/home_plugins_test.go +++ b/sdk/cliproxy/home_plugins_test.go @@ -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) + } +} diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 61c6dad8..e711cdf5 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -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) diff --git a/sdk/pluginstore/pluginstore.go b/sdk/pluginstore/pluginstore.go index 74841bf5..8c5d40de 100644 --- a/sdk/pluginstore/pluginstore.go +++ b/sdk/pluginstore/pluginstore.go @@ -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) } diff --git a/sdk/pluginstore/pluginstore_test.go b/sdk/pluginstore/pluginstore_test.go index 4262950d..3bbca50b 100644 --- a/sdk/pluginstore/pluginstore_test.go +++ b/sdk/pluginstore/pluginstore_test.go @@ -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) } }