From 3d04fda08af7f82b475637ddfcfb748a7ee08531 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 26 Feb 2026 21:37:27 -0500 Subject: [PATCH 1/2] feat: multi-registry plugin distribution system Add configurable multi-registry support for plugin discovery, validation, and installation. The default registry remains GoCodeAlone/workflow-registry but additional GitHub-backed registries can be configured via .wfctl.yaml or ~/.config/wfctl/config.yaml. New files: - registry_config.go: YAML config for registry sources with priority - registry_source.go: RegistrySource interface + GitHubRegistrySource - multi_registry.go: priority-based aggregator with dedup search/list - registry_cmd.go: `wfctl registry list|add|remove` commands - registry_validate.go: manifest validation (schema, semver, enums, SHA256, URLs) - plugin_validate.go: `wfctl plugin validate` command (single, --all, --file) - multi_registry_test.go: 24 unit tests for config, multi-registry, validation - plugin_install_e2e_test.go: end-to-end install pipeline test with httptest Modified: - plugin_install.go: wired through MultiRegistry, added --config/--registry flags, binary rename for ExternalPluginManager compatibility - plugin.go: added validate subcommand - main.go: added registry command Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/main.go | 2 + cmd/wfctl/multi_registry.go | 106 ++++ cmd/wfctl/multi_registry_test.go | 888 +++++++++++++++++++++++++++ cmd/wfctl/plugin.go | 3 + cmd/wfctl/plugin_install.go | 96 ++- cmd/wfctl/plugin_install_e2e_test.go | 366 +++++++++++ cmd/wfctl/plugin_validate.go | 131 ++++ cmd/wfctl/registry_cmd.go | 176 ++++++ cmd/wfctl/registry_config.go | 94 +++ cmd/wfctl/registry_source.go | 158 +++++ cmd/wfctl/registry_validate.go | 180 ++++++ 11 files changed, 2192 insertions(+), 8 deletions(-) create mode 100644 cmd/wfctl/multi_registry.go create mode 100644 cmd/wfctl/multi_registry_test.go create mode 100644 cmd/wfctl/plugin_install_e2e_test.go create mode 100644 cmd/wfctl/plugin_validate.go create mode 100644 cmd/wfctl/registry_cmd.go create mode 100644 cmd/wfctl/registry_config.go create mode 100644 cmd/wfctl/registry_source.go create mode 100644 cmd/wfctl/registry_validate.go diff --git a/cmd/wfctl/main.go b/cmd/wfctl/main.go index 0edd9b21..5ecaeed5 100644 --- a/cmd/wfctl/main.go +++ b/cmd/wfctl/main.go @@ -28,6 +28,7 @@ var commands = map[string]func([]string) error{ "compat": runCompat, "generate": runGenerate, "git": runGit, + "registry": runRegistry, } func usage() { @@ -57,6 +58,7 @@ Commands: compat Compatibility checking (check: verify config works with current engine) generate Code generation (github-actions: generate CI/CD workflows from config) git Git integration (connect: link to GitHub repo, push: commit and push) + registry Registry management (list, add, remove plugin registry sources) Run 'wfctl -h' for command-specific help. `, version) diff --git a/cmd/wfctl/multi_registry.go b/cmd/wfctl/multi_registry.go new file mode 100644 index 00000000..0692cd34 --- /dev/null +++ b/cmd/wfctl/multi_registry.go @@ -0,0 +1,106 @@ +package main + +import ( + "fmt" + "os" + "sort" +) + +// MultiRegistry aggregates multiple RegistrySource instances and resolves +// plugins across them in priority order. +type MultiRegistry struct { + sources []RegistrySource +} + +// NewMultiRegistry creates a multi-registry from a config. Sources are sorted +// by priority (lowest number = highest priority). +func NewMultiRegistry(cfg *RegistryConfig) *MultiRegistry { + // Sort by priority + sorted := make([]RegistrySourceConfig, len(cfg.Registries)) + copy(sorted, cfg.Registries) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Priority < sorted[j].Priority + }) + + sources := make([]RegistrySource, 0, len(sorted)) + for _, sc := range sorted { + switch sc.Type { + case "github": + sources = append(sources, NewGitHubRegistrySource(sc)) + default: + // Skip unknown types + fmt.Fprintf(os.Stderr, "warning: unknown registry type %q for %q, skipping\n", sc.Type, sc.Name) + } + } + + return &MultiRegistry{sources: sources} +} + +// NewMultiRegistryFromSources creates a multi-registry from pre-built sources (useful for testing). +func NewMultiRegistryFromSources(sources ...RegistrySource) *MultiRegistry { + return &MultiRegistry{sources: sources} +} + +// FetchManifest tries each source in priority order, returning the first successful result. +func (m *MultiRegistry) FetchManifest(name string) (*RegistryManifest, string, error) { + var lastErr error + for _, src := range m.sources { + manifest, err := src.FetchManifest(name) + if err == nil { + return manifest, src.Name(), nil + } + lastErr = err + } + if lastErr != nil { + return nil, "", lastErr + } + return nil, "", fmt.Errorf("plugin %q not found in any configured registry", name) +} + +// SearchPlugins searches all sources and returns deduplicated results. +// When the same plugin appears in multiple registries, the higher-priority source wins. +func (m *MultiRegistry) SearchPlugins(query string) ([]PluginSearchResult, error) { + seen := make(map[string]bool) + var results []PluginSearchResult + + for _, src := range m.sources { + srcResults, err := src.SearchPlugins(query) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: search failed for registry %q: %v\n", src.Name(), err) + continue + } + for _, r := range srcResults { + if !seen[r.Name] { + results = append(results, r) + seen[r.Name] = true + } + } + } + return results, nil +} + +// ListPlugins lists all plugins from all sources, deduplicated. +func (m *MultiRegistry) ListPlugins() ([]string, error) { + seen := make(map[string]bool) + var names []string + + for _, src := range m.sources { + srcNames, err := src.ListPlugins() + if err != nil { + fmt.Fprintf(os.Stderr, "warning: list failed for registry %q: %v\n", src.Name(), err) + continue + } + for _, n := range srcNames { + if !seen[n] { + names = append(names, n) + seen[n] = true + } + } + } + return names, nil +} + +// Sources returns the configured registry sources. +func (m *MultiRegistry) Sources() []RegistrySource { + return m.sources +} diff --git a/cmd/wfctl/multi_registry_test.go b/cmd/wfctl/multi_registry_test.go new file mode 100644 index 00000000..8ddefebc --- /dev/null +++ b/cmd/wfctl/multi_registry_test.go @@ -0,0 +1,888 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "testing" +) + +// --------------------------------------------------------------------------- +// mockRegistrySource — in-process RegistrySource backed by a map of manifests. +// Shared with plugin_install_e2e_test.go which uses this type via the manifests field. +// --------------------------------------------------------------------------- + +type mockRegistrySource struct { + name string + manifests map[string]*RegistryManifest + listErr error + fetchErr map[string]error +} + +func (m *mockRegistrySource) Name() string { return m.name } + +func (m *mockRegistrySource) ListPlugins() ([]string, error) { + if m.listErr != nil { + return nil, m.listErr + } + names := make([]string, 0, len(m.manifests)) + for k := range m.manifests { + names = append(names, k) + } + sort.Strings(names) + return names, nil +} + +func (m *mockRegistrySource) FetchManifest(name string) (*RegistryManifest, error) { + if m.fetchErr != nil { + if err, ok := m.fetchErr[name]; ok && err != nil { + return nil, err + } + } + manifest, ok := m.manifests[name] + if !ok { + return nil, fmt.Errorf("plugin %q not found in registry %s", name, m.name) + } + return manifest, nil +} + +func (m *mockRegistrySource) SearchPlugins(query string) ([]PluginSearchResult, error) { + if m.listErr != nil { + return nil, m.listErr + } + var results []PluginSearchResult + for _, manifest := range m.manifests { + if matchesRegistryQuery(manifest, query) { + results = append(results, PluginSearchResult{ + PluginSummary: PluginSummary{ + Name: manifest.Name, + Version: manifest.Version, + Description: manifest.Description, + Tier: manifest.Tier, + }, + Source: m.name, + }) + } + } + // Sort for determinism + sort.Slice(results, func(i, j int) bool { return results[i].Name < results[j].Name }) + return results, nil +} + +// --------------------------------------------------------------------------- +// Registry config tests +// --------------------------------------------------------------------------- + +func TestDefaultRegistryConfig(t *testing.T) { + cfg := DefaultRegistryConfig() + if cfg == nil { + t.Fatal("expected non-nil config") + } + if len(cfg.Registries) != 1 { + t.Fatalf("expected 1 registry, got %d", len(cfg.Registries)) + } + r := cfg.Registries[0] + if r.Name != "default" { + t.Errorf("name: got %q, want %q", r.Name, "default") + } + if r.Type != "github" { + t.Errorf("type: got %q, want %q", r.Type, "github") + } + if r.Owner != registryOwner { + t.Errorf("owner: got %q, want %q", r.Owner, registryOwner) + } + if r.Repo != registryRepo { + t.Errorf("repo: got %q, want %q", r.Repo, registryRepo) + } + if r.Branch != registryBranch { + t.Errorf("branch: got %q, want %q", r.Branch, registryBranch) + } + if r.Priority != 0 { + t.Errorf("priority: got %d, want 0", r.Priority) + } +} + +func TestLoadRegistryConfigFromFile(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + + yaml := `registries: + - name: my-org + type: github + owner: my-org + repo: my-plugins + branch: stable + priority: 1 +` + if err := os.WriteFile(cfgPath, []byte(yaml), 0640); err != nil { + t.Fatal(err) + } + + cfg, err := LoadRegistryConfig(cfgPath) + if err != nil { + t.Fatalf("LoadRegistryConfig: %v", err) + } + if len(cfg.Registries) != 1 { + t.Fatalf("expected 1 registry, got %d", len(cfg.Registries)) + } + r := cfg.Registries[0] + if r.Name != "my-org" { + t.Errorf("name: got %q, want %q", r.Name, "my-org") + } + if r.Owner != "my-org" { + t.Errorf("owner: got %q, want %q", r.Owner, "my-org") + } + if r.Repo != "my-plugins" { + t.Errorf("repo: got %q, want %q", r.Repo, "my-plugins") + } + if r.Branch != "stable" { + t.Errorf("branch: got %q, want %q", r.Branch, "stable") + } + if r.Priority != 1 { + t.Errorf("priority: got %d, want 1", r.Priority) + } +} + +func TestLoadRegistryConfigDefault(t *testing.T) { + // Provide a path that does not exist — should fall back to default. + cfg, err := LoadRegistryConfig("/nonexistent/path/config.yaml") + if err != nil { + t.Fatalf("LoadRegistryConfig: %v", err) + } + if len(cfg.Registries) != 1 { + t.Fatalf("expected 1 registry (default), got %d", len(cfg.Registries)) + } + if cfg.Registries[0].Owner != registryOwner { + t.Errorf("owner: got %q, want %q", cfg.Registries[0].Owner, registryOwner) + } +} + +func TestSaveAndLoadRegistryConfig(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "wfctl", "config.yaml") + + original := &RegistryConfig{ + Registries: []RegistrySourceConfig{ + {Name: "primary", Type: "github", Owner: "acme", Repo: "plugins", Branch: "main", Priority: 0}, + {Name: "secondary", Type: "github", Owner: "acme", Repo: "more-plugins", Branch: "dev", Priority: 5}, + }, + } + + if err := SaveRegistryConfig(cfgPath, original); err != nil { + t.Fatalf("SaveRegistryConfig: %v", err) + } + + // Verify file was created. + if _, err := os.Stat(cfgPath); err != nil { + t.Fatalf("config file not created: %v", err) + } + + loaded, err := LoadRegistryConfig(cfgPath) + if err != nil { + t.Fatalf("LoadRegistryConfig: %v", err) + } + if len(loaded.Registries) != 2 { + t.Fatalf("expected 2 registries, got %d", len(loaded.Registries)) + } + for i, want := range original.Registries { + got := loaded.Registries[i] + if got.Name != want.Name { + t.Errorf("[%d] name: got %q, want %q", i, got.Name, want.Name) + } + if got.Owner != want.Owner { + t.Errorf("[%d] owner: got %q, want %q", i, got.Owner, want.Owner) + } + if got.Repo != want.Repo { + t.Errorf("[%d] repo: got %q, want %q", i, got.Repo, want.Repo) + } + if got.Branch != want.Branch { + t.Errorf("[%d] branch: got %q, want %q", i, got.Branch, want.Branch) + } + if got.Priority != want.Priority { + t.Errorf("[%d] priority: got %d, want %d", i, got.Priority, want.Priority) + } + } +} + +// --------------------------------------------------------------------------- +// Mock registry source tests +// --------------------------------------------------------------------------- + +func TestMockRegistrySource(t *testing.T) { + src := &mockRegistrySource{ + name: "test", + manifests: map[string]*RegistryManifest{ + "alpha": {Name: "alpha", Version: "1.0.0", Description: "Alpha plugin", Tier: "core"}, + "beta": {Name: "beta", Version: "2.0.0", Description: "Beta plugin", Tier: "community"}, + }, + } + + if src.Name() != "test" { + t.Errorf("Name: got %q, want %q", src.Name(), "test") + } + + // ListPlugins + names, err := src.ListPlugins() + if err != nil { + t.Fatalf("ListPlugins: %v", err) + } + if len(names) != 2 { + t.Fatalf("expected 2 plugins, got %d", len(names)) + } + + // FetchManifest success + m, err := src.FetchManifest("alpha") + if err != nil { + t.Fatalf("FetchManifest: %v", err) + } + if m.Name != "alpha" { + t.Errorf("name: got %q, want %q", m.Name, "alpha") + } + + // FetchManifest not found + _, err = src.FetchManifest("nonexistent") + if err == nil { + t.Error("expected error for missing plugin") + } + + // SearchPlugins — empty query returns all + results, err := src.SearchPlugins("") + if err != nil { + t.Fatalf("SearchPlugins: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + + // SearchPlugins — filtered query + results, err = src.SearchPlugins("alpha") + if err != nil { + t.Fatalf("SearchPlugins(alpha): %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Name != "alpha" { + t.Errorf("result name: got %q, want %q", results[0].Name, "alpha") + } + if results[0].Source != "test" { + t.Errorf("source: got %q, want %q", results[0].Source, "test") + } +} + +// --------------------------------------------------------------------------- +// MultiRegistry tests +// --------------------------------------------------------------------------- + +func TestMultiRegistryFetchPriority(t *testing.T) { + // Source A has "shared-plugin" version 1.0.0 (higher priority — listed first). + // Source B has "shared-plugin" version 2.0.0 (lower priority). + srcA := &mockRegistrySource{ + name: "primary", + manifests: map[string]*RegistryManifest{ + "shared-plugin": {Name: "shared-plugin", Version: "1.0.0"}, + }, + } + srcB := &mockRegistrySource{ + name: "secondary", + manifests: map[string]*RegistryManifest{ + "shared-plugin": {Name: "shared-plugin", Version: "2.0.0"}, + }, + } + + mr := NewMultiRegistryFromSources(srcA, srcB) + manifest, source, err := mr.FetchManifest("shared-plugin") + if err != nil { + t.Fatalf("FetchManifest: %v", err) + } + if source != "primary" { + t.Errorf("source: got %q, want %q", source, "primary") + } + if manifest.Version != "1.0.0" { + t.Errorf("version: got %q, want %q (higher-priority source should win)", manifest.Version, "1.0.0") + } +} + +func TestMultiRegistryFetchFallback(t *testing.T) { + // Source A errors for "unique-plugin", source B has it. + srcA := &mockRegistrySource{ + name: "primary", + manifests: map[string]*RegistryManifest{}, + fetchErr: map[string]error{ + "unique-plugin": fmt.Errorf("not found"), + }, + } + srcB := &mockRegistrySource{ + name: "secondary", + manifests: map[string]*RegistryManifest{ + "unique-plugin": {Name: "unique-plugin", Version: "3.0.0"}, + }, + } + + mr := NewMultiRegistryFromSources(srcA, srcB) + manifest, source, err := mr.FetchManifest("unique-plugin") + if err != nil { + t.Fatalf("FetchManifest: %v", err) + } + if source != "secondary" { + t.Errorf("source: got %q, want %q", source, "secondary") + } + if manifest.Version != "3.0.0" { + t.Errorf("version: got %q, want %q", manifest.Version, "3.0.0") + } +} + +func TestMultiRegistryFetchNotFound(t *testing.T) { + srcA := &mockRegistrySource{ + name: "primary", + manifests: map[string]*RegistryManifest{}, + } + mr := NewMultiRegistryFromSources(srcA) + _, _, err := mr.FetchManifest("does-not-exist") + if err == nil { + t.Fatal("expected error when plugin not found in any registry") + } +} + +func TestMultiRegistrySearchDedup(t *testing.T) { + // Both sources have "dup-plugin". Result should only appear once with the + // higher-priority source's metadata. + srcA := &mockRegistrySource{ + name: "primary", + manifests: map[string]*RegistryManifest{ + "dup-plugin": {Name: "dup-plugin", Version: "1.0.0", Description: "from primary", Tier: "core"}, + }, + } + srcB := &mockRegistrySource{ + name: "secondary", + manifests: map[string]*RegistryManifest{ + "dup-plugin": {Name: "dup-plugin", Version: "9.9.9", Description: "from secondary", Tier: "community"}, + }, + } + + mr := NewMultiRegistryFromSources(srcA, srcB) + results, err := mr.SearchPlugins("") + if err != nil { + t.Fatalf("SearchPlugins: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 deduplicated result, got %d", len(results)) + } + if results[0].Source != "primary" { + t.Errorf("source: got %q, want %q (higher-priority source should win)", results[0].Source, "primary") + } + if results[0].Version != "1.0.0" { + t.Errorf("version: got %q, want %q", results[0].Version, "1.0.0") + } +} + +func TestMultiRegistrySearchMerge(t *testing.T) { + // Each source has a distinct plugin. Both should appear in results. + srcA := &mockRegistrySource{ + name: "primary", + manifests: map[string]*RegistryManifest{ + "plugin-a": {Name: "plugin-a", Version: "1.0.0", Description: "A plugin"}, + }, + } + srcB := &mockRegistrySource{ + name: "secondary", + manifests: map[string]*RegistryManifest{ + "plugin-b": {Name: "plugin-b", Version: "2.0.0", Description: "B plugin"}, + }, + } + + mr := NewMultiRegistryFromSources(srcA, srcB) + results, err := mr.SearchPlugins("") + if err != nil { + t.Fatalf("SearchPlugins: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + names := map[string]bool{} + for _, r := range results { + names[r.Name] = true + } + if !names["plugin-a"] { + t.Error("expected plugin-a in results") + } + if !names["plugin-b"] { + t.Error("expected plugin-b in results") + } +} + +func TestMultiRegistrySearchFilteredQuery(t *testing.T) { + srcA := &mockRegistrySource{ + name: "primary", + manifests: map[string]*RegistryManifest{ + "cache-plugin": {Name: "cache-plugin", Version: "1.0.0", Description: "Redis cache integration"}, + "auth-plugin": {Name: "auth-plugin", Version: "1.0.0", Description: "Authentication plugin"}, + }, + } + + mr := NewMultiRegistryFromSources(srcA) + results, err := mr.SearchPlugins("cache") + if err != nil { + t.Fatalf("SearchPlugins: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result for query %q, got %d", "cache", len(results)) + } + if results[0].Name != "cache-plugin" { + t.Errorf("result name: got %q, want %q", results[0].Name, "cache-plugin") + } +} + +func TestMultiRegistryListDedup(t *testing.T) { + // Both sources share "shared" and each has a unique plugin. + srcA := &mockRegistrySource{ + name: "primary", + manifests: map[string]*RegistryManifest{ + "shared": {Name: "shared"}, + "only-in-a": {Name: "only-in-a"}, + }, + } + srcB := &mockRegistrySource{ + name: "secondary", + manifests: map[string]*RegistryManifest{ + "shared": {Name: "shared"}, + "only-in-b": {Name: "only-in-b"}, + }, + } + + mr := NewMultiRegistryFromSources(srcA, srcB) + names, err := mr.ListPlugins() + if err != nil { + t.Fatalf("ListPlugins: %v", err) + } + if len(names) != 3 { + t.Fatalf("expected 3 deduplicated plugins, got %d: %v", len(names), names) + } + seen := map[string]int{} + for _, n := range names { + seen[n]++ + } + for _, name := range []string{"shared", "only-in-a", "only-in-b"} { + if seen[name] != 1 { + t.Errorf("expected %q exactly once, got %d times", name, seen[name]) + } + } +} + +func TestMultiRegistryListSkipsFailedSources(t *testing.T) { + srcA := &mockRegistrySource{ + name: "broken", + listErr: fmt.Errorf("network error"), + } + srcB := &mockRegistrySource{ + name: "working", + manifests: map[string]*RegistryManifest{ + "good-plugin": {Name: "good-plugin"}, + }, + } + + mr := NewMultiRegistryFromSources(srcA, srcB) + names, err := mr.ListPlugins() + // MultiRegistry swallows per-source errors; overall call should succeed. + if err != nil { + t.Fatalf("ListPlugins: %v", err) + } + if len(names) != 1 || names[0] != "good-plugin" { + t.Errorf("expected [good-plugin], got %v", names) + } +} + +// --------------------------------------------------------------------------- +// ValidateManifest tests +// --------------------------------------------------------------------------- + +func validManifest() *RegistryManifest { + return &RegistryManifest{ + Name: "test-plugin", + Version: "1.0.0", + Author: "Test Author", + Description: "A test plugin", + Type: "external", + Tier: "community", + License: "MIT", + Downloads: []PluginDownload{ + {OS: "linux", Arch: "amd64", URL: "https://example.com/plugin-linux-amd64.tar.gz"}, + }, + } +} + +func TestValidateManifest_Valid(t *testing.T) { + m := validManifest() + errs := ValidateManifest(m, ValidationOptions{}) + if len(errs) != 0 { + t.Errorf("expected no errors, got: %v", errs) + } +} + +func TestValidateManifest_RequiredFields(t *testing.T) { + m := &RegistryManifest{} // all empty + errs := ValidateManifest(m, ValidationOptions{}) + + requiredFields := []string{"name", "version", "author", "description", "type", "tier", "license"} + errFields := map[string]bool{} + for _, e := range errs { + errFields[e.Field] = true + } + for _, field := range requiredFields { + if !errFields[field] { + t.Errorf("expected validation error for field %q, but none found", field) + } + } +} + +func TestValidateManifest_InvalidEnums(t *testing.T) { + m := validManifest() + m.Type = "invalid-type" + m.Tier = "invalid-tier" + + errs := ValidateManifest(m, ValidationOptions{}) + errFields := map[string]bool{} + for _, e := range errs { + errFields[e.Field] = true + } + if !errFields["type"] { + t.Error("expected validation error for invalid type") + } + if !errFields["tier"] { + t.Error("expected validation error for invalid tier") + } +} + +func TestValidateManifest_SemverFormat(t *testing.T) { + tests := []struct { + version string + wantErr bool + }{ + {"1.0.0", false}, + {"0.2.18", false}, + {"10.100.1000", false}, + {"1.0.0-beta", false}, // semverRegex uses prefix match so this passes + {"1.0", true}, + {"abc", true}, + {"", true}, // empty triggers "required" error, not format error + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + m := validManifest() + m.Version = tt.version + errs := ValidateManifest(m, ValidationOptions{}) + hasVersionErr := false + for _, e := range errs { + if e.Field == "version" { + hasVersionErr = true + break + } + } + if tt.wantErr && !hasVersionErr { + t.Errorf("version %q: expected validation error, got none", tt.version) + } + if !tt.wantErr && hasVersionErr { + t.Errorf("version %q: unexpected validation error", tt.version) + } + }) + } +} + +func TestValidateManifest_DownloadValidation(t *testing.T) { + t.Run("invalid OS", func(t *testing.T) { + m := validManifest() + m.Downloads[0].OS = "freebsd" + errs := ValidateManifest(m, ValidationOptions{}) + found := false + for _, e := range errs { + if e.Field == "downloads[0].os" { + found = true + } + } + if !found { + t.Error("expected error for invalid OS") + } + }) + + t.Run("invalid arch", func(t *testing.T) { + m := validManifest() + m.Downloads[0].Arch = "386" + errs := ValidateManifest(m, ValidationOptions{}) + found := false + for _, e := range errs { + if e.Field == "downloads[0].arch" { + found = true + } + } + if !found { + t.Error("expected error for invalid arch") + } + }) + + t.Run("missing URL", func(t *testing.T) { + m := validManifest() + m.Downloads[0].URL = "" + errs := ValidateManifest(m, ValidationOptions{}) + found := false + for _, e := range errs { + if e.Field == "downloads[0].url" { + found = true + } + } + if !found { + t.Error("expected error for missing download URL") + } + }) + + t.Run("external type without downloads", func(t *testing.T) { + m := validManifest() + m.Type = "external" + m.Downloads = nil + errs := ValidateManifest(m, ValidationOptions{}) + found := false + for _, e := range errs { + if e.Field == "downloads" { + found = true + } + } + if !found { + t.Error("expected error: external plugins must have downloads") + } + }) + + t.Run("builtin type without downloads is ok", func(t *testing.T) { + m := validManifest() + m.Type = "builtin" + m.Downloads = nil + errs := ValidateManifest(m, ValidationOptions{}) + for _, e := range errs { + if e.Field == "downloads" { + t.Errorf("unexpected downloads error for builtin type: %s", e.Message) + } + } + }) +} + +func TestValidateManifest_EngineVersionCompat(t *testing.T) { + t.Run("engine too old", func(t *testing.T) { + m := validManifest() + m.MinEngineVersion = "2.0.0" + errs := ValidateManifest(m, ValidationOptions{EngineVersion: "1.9.0"}) + found := false + for _, e := range errs { + if e.Field == "minEngineVersion" { + found = true + } + } + if !found { + t.Error("expected error: engine version too old") + } + }) + + t.Run("engine meets minimum", func(t *testing.T) { + m := validManifest() + m.MinEngineVersion = "1.0.0" + errs := ValidateManifest(m, ValidationOptions{EngineVersion: "1.0.0"}) + for _, e := range errs { + if e.Field == "minEngineVersion" { + t.Errorf("unexpected error: %s", e.Message) + } + } + }) + + t.Run("engine exceeds minimum", func(t *testing.T) { + m := validManifest() + m.MinEngineVersion = "1.0.0" + errs := ValidateManifest(m, ValidationOptions{EngineVersion: "2.0.0"}) + for _, e := range errs { + if e.Field == "minEngineVersion" { + t.Errorf("unexpected error: %s", e.Message) + } + } + }) + + t.Run("no engine version in opts — no check", func(t *testing.T) { + m := validManifest() + m.MinEngineVersion = "99.0.0" + errs := ValidateManifest(m, ValidationOptions{}) // EngineVersion is empty + for _, e := range errs { + if e.Field == "minEngineVersion" { + t.Errorf("unexpected error when EngineVersion not set: %s", e.Message) + } + } + }) +} + +func TestValidateManifest_SHA256Format(t *testing.T) { + t.Run("valid sha256", func(t *testing.T) { + m := validManifest() + m.Downloads[0].SHA256 = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + errs := ValidateManifest(m, ValidationOptions{}) + for _, e := range errs { + if e.Field == "downloads[0].sha256" { + t.Errorf("unexpected sha256 error: %s", e.Message) + } + } + }) + + t.Run("invalid sha256 — too short", func(t *testing.T) { + m := validManifest() + m.Downloads[0].SHA256 = "abc123" + errs := ValidateManifest(m, ValidationOptions{}) + found := false + for _, e := range errs { + if e.Field == "downloads[0].sha256" { + found = true + } + } + if !found { + t.Error("expected error for invalid sha256 format") + } + }) + + t.Run("invalid sha256 — non-hex characters", func(t *testing.T) { + m := validManifest() + // 64 chars but contains non-hex (z) + m.Downloads[0].SHA256 = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" + errs := ValidateManifest(m, ValidationOptions{}) + found := false + for _, e := range errs { + if e.Field == "downloads[0].sha256" { + found = true + } + } + if !found { + t.Error("expected error for non-hex sha256") + } + }) + + t.Run("empty sha256 is allowed", func(t *testing.T) { + m := validManifest() + m.Downloads[0].SHA256 = "" + errs := ValidateManifest(m, ValidationOptions{}) + for _, e := range errs { + if e.Field == "downloads[0].sha256" { + t.Errorf("unexpected sha256 error for empty value: %s", e.Message) + } + } + }) +} + +// --------------------------------------------------------------------------- +// compareSemver tests +// --------------------------------------------------------------------------- + +func TestCompareSemver(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + // Equal + {"0.0.0", "0.0.0", 0}, + {"1.0.0", "1.0.0", 0}, + {"1.2.3", "1.2.3", 0}, + // a < b + {"0.0.0", "0.0.1", -1}, + {"0.0.9", "0.1.0", -1}, + {"0.9.9", "1.0.0", -1}, + {"1.0.0", "2.0.0", -1}, + {"1.9.9", "2.0.0", -1}, + {"0.2.17", "0.2.18", -1}, + // a > b + {"0.0.1", "0.0.0", 1}, + {"0.1.0", "0.0.9", 1}, + {"1.0.0", "0.9.9", 1}, + {"2.0.0", "1.9.9", 1}, + {"0.2.18", "0.2.17", 1}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%s_vs_%s", tt.a, tt.b), func(t *testing.T) { + got := compareSemver(tt.a, tt.b) + if got != tt.want { + t.Errorf("compareSemver(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// LoadRegistryConfig — branch default fill-in +// --------------------------------------------------------------------------- + +func TestLoadRegistryConfigFillsDefaultBranch(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + + // branch omitted — should default to "main" + yaml := `registries: + - name: no-branch + type: github + owner: acme + repo: plugins +` + if err := os.WriteFile(cfgPath, []byte(yaml), 0640); err != nil { + t.Fatal(err) + } + + cfg, err := LoadRegistryConfig(cfgPath) + if err != nil { + t.Fatalf("LoadRegistryConfig: %v", err) + } + if cfg.Registries[0].Branch != "main" { + t.Errorf("branch: got %q, want %q", cfg.Registries[0].Branch, "main") + } +} + +func TestLoadRegistryConfigFillsDefaultType(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + + // type omitted — should default to "github" + yaml := `registries: + - name: no-type + owner: acme + repo: plugins + branch: main +` + if err := os.WriteFile(cfgPath, []byte(yaml), 0640); err != nil { + t.Fatal(err) + } + + cfg, err := LoadRegistryConfig(cfgPath) + if err != nil { + t.Fatalf("LoadRegistryConfig: %v", err) + } + if cfg.Registries[0].Type != "github" { + t.Errorf("type: got %q, want %q", cfg.Registries[0].Type, "github") + } +} + +// --------------------------------------------------------------------------- +// NewMultiRegistry priority sorting +// --------------------------------------------------------------------------- + +func TestNewMultiRegistryPriorityOrder(t *testing.T) { + // Build a RegistryConfig with two entries; higher numeric priority = lower + // precedence. NewMultiRegistry should sort by Priority ascending. + cfg := &RegistryConfig{ + Registries: []RegistrySourceConfig{ + {Name: "low-prio", Type: "github", Owner: "acme", Repo: "b", Branch: "main", Priority: 10}, + {Name: "high-prio", Type: "github", Owner: "acme", Repo: "a", Branch: "main", Priority: 0}, + }, + } + + mr := NewMultiRegistry(cfg) + sources := mr.Sources() + if len(sources) != 2 { + t.Fatalf("expected 2 sources, got %d", len(sources)) + } + if sources[0].Name() != "high-prio" { + t.Errorf("first source: got %q, want %q", sources[0].Name(), "high-prio") + } + if sources[1].Name() != "low-prio" { + t.Errorf("second source: got %q, want %q", sources[1].Name(), "low-prio") + } +} diff --git a/cmd/wfctl/plugin.go b/cmd/wfctl/plugin.go index b1aed3ec..653fb615 100644 --- a/cmd/wfctl/plugin.go +++ b/cmd/wfctl/plugin.go @@ -29,6 +29,8 @@ func runPlugin(args []string) error { return runPluginUpdate(args[1:]) case "remove": return runPluginRemove(args[1:]) + case "validate": + return runPluginValidate(args[1:]) default: return pluginUsage() } @@ -46,6 +48,7 @@ Subcommands: list List installed plugins update Update an installed plugin to its latest version remove Uninstall a plugin + validate Validate a plugin manifest from the registry or a local file `) return fmt.Errorf("plugin subcommand is required") } diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 7aa22afd..9b9059c9 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -22,8 +22,10 @@ const defaultDataDir = "data/plugins" func runPluginSearch(args []string) error { fs := flag.NewFlagSet("plugin search", flag.ContinueOnError) + cfgPath := fs.String("config", "", "Registry config file path") fs.Usage = func() { - fmt.Fprintf(fs.Output(), "Usage: wfctl plugin search []\n\nSearch the plugin registry by name, description, or keyword.\n") + fmt.Fprintf(fs.Output(), "Usage: wfctl plugin search [options] []\n\nSearch the plugin registry by name, description, or keyword.\n\nOptions:\n") + fs.PrintDefaults() } if err := fs.Parse(args); err != nil { return err @@ -33,8 +35,14 @@ func runPluginSearch(args []string) error { query = strings.Join(fs.Args(), " ") } + cfg, err := LoadRegistryConfig(*cfgPath) + if err != nil { + return fmt.Errorf("load registry config: %w", err) + } + mr := NewMultiRegistry(cfg) + fmt.Fprintf(os.Stderr, "Searching registry...\n") - plugins, err := SearchPlugins(query) + plugins, err := mr.SearchPlugins(query) if err != nil { return fmt.Errorf("search failed: %w", err) } @@ -42,14 +50,14 @@ func runPluginSearch(args []string) error { fmt.Println("No plugins found.") return nil } - fmt.Printf("%-20s %-10s %-12s %s\n", "NAME", "VERSION", "TIER", "DESCRIPTION") - fmt.Printf("%-20s %-10s %-12s %s\n", "----", "-------", "----", "-----------") + fmt.Printf("%-20s %-10s %-12s %-12s %s\n", "NAME", "VERSION", "TIER", "SOURCE", "DESCRIPTION") + fmt.Printf("%-20s %-10s %-12s %-12s %s\n", "----", "-------", "----", "------", "-----------") for _, p := range plugins { desc := p.Description - if len(desc) > 60 { - desc = desc[:57] + "..." + if len(desc) > 50 { + desc = desc[:47] + "..." } - fmt.Printf("%-20s %-10s %-12s %s\n", p.Name, p.Version, p.Tier, desc) + fmt.Printf("%-20s %-10s %-12s %-12s %s\n", p.Name, p.Version, p.Tier, p.Source, desc) } return nil } @@ -57,6 +65,8 @@ func runPluginSearch(args []string) error { func runPluginInstall(args []string) error { fs := flag.NewFlagSet("plugin install", flag.ContinueOnError) dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + cfgPath := fs.String("config", "", "Registry config file path") + registryName := fs.String("registry", "", "Use a specific registry by name") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin install [options] [@]\n\nDownload and install a plugin from the registry.\n\nOptions:\n") fs.PrintDefaults() @@ -72,11 +82,35 @@ func runPluginInstall(args []string) error { nameArg := fs.Arg(0) pluginName, _ := parseNameVersion(nameArg) + cfg, err := LoadRegistryConfig(*cfgPath) + if err != nil { + return fmt.Errorf("load registry config: %w", err) + } + + var mr *MultiRegistry + if *registryName != "" { + // Filter config to only the requested registry + filtered := &RegistryConfig{} + for _, r := range cfg.Registries { + if r.Name == *registryName { + filtered.Registries = append(filtered.Registries, r) + break + } + } + if len(filtered.Registries) == 0 { + return fmt.Errorf("registry %q not found in config", *registryName) + } + mr = NewMultiRegistry(filtered) + } else { + mr = NewMultiRegistry(cfg) + } + fmt.Fprintf(os.Stderr, "Fetching manifest for %q...\n", pluginName) - manifest, err := FetchManifest(pluginName) + manifest, sourceName, err := mr.FetchManifest(pluginName) if err != nil { return err } + fmt.Fprintf(os.Stderr, "Found in registry %q.\n", sourceName) dl, err := manifest.FindDownload(runtime.GOOS, runtime.GOARCH) if err != nil { @@ -106,6 +140,12 @@ func runPluginInstall(args []string) error { return fmt.Errorf("extract plugin: %w", err) } + // Ensure the plugin binary is named to match the plugin name so that + // ExternalPluginManager.DiscoverPlugins() can find it (expects //). + if err := ensurePluginBinary(destDir, pluginName); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not normalize binary name: %v\n", err) + } + // Write a minimal plugin.json if not already present (records version). pluginJSONPath := filepath.Join(destDir, "plugin.json") if _, err := os.Stat(pluginJSONPath); os.IsNotExist(err) { @@ -337,6 +377,46 @@ func writeInstalledManifest(path string, m *RegistryManifest) error { return os.WriteFile(path, append(data, '\n'), 0640) //nolint:gosec // G306: plugin.json is user-owned output } +// ensurePluginBinary finds the executable binary in destDir and renames it to +// match the plugin name. ExternalPluginManager expects //. +// GoReleaser tarballs typically contain binaries named like +// "workflow-plugin-admin-darwin-arm64" after stripTopDir, so we rename to "admin". +func ensurePluginBinary(destDir, pluginName string) error { + expectedPath := filepath.Join(destDir, pluginName) + if info, err := os.Stat(expectedPath); err == nil && !info.IsDir() { + return nil // already correctly named + } + + // Find the largest executable file in the directory (the plugin binary). + entries, err := os.ReadDir(destDir) + if err != nil { + return err + } + var bestName string + var bestSize int64 + for _, e := range entries { + if e.IsDir() || e.Name() == "plugin.json" { + continue + } + info, err := e.Info() + if err != nil { + continue + } + // Skip non-executable files + if info.Mode()&0111 == 0 { + continue + } + if info.Size() > bestSize { + bestSize = info.Size() + bestName = e.Name() + } + } + if bestName == "" { + return fmt.Errorf("no executable binary found in %s", destDir) + } + return os.Rename(filepath.Join(destDir, bestName), expectedPath) +} + // readInstalledVersion reads the version from a plugin.json in the given directory. func readInstalledVersion(dir string) string { data, err := os.ReadFile(filepath.Join(dir, "plugin.json")) diff --git a/cmd/wfctl/plugin_install_e2e_test.go b/cmd/wfctl/plugin_install_e2e_test.go new file mode 100644 index 00000000..8255bc8b --- /dev/null +++ b/cmd/wfctl/plugin_install_e2e_test.go @@ -0,0 +1,366 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/GoCodeAlone/workflow/plugin/external" +) + +// buildTarGz creates an in-memory tar.gz archive. +// entries is a map of path → content (as []byte). mode is applied to all files. +func buildTarGz(t *testing.T, entries map[string][]byte, mode os.FileMode) []byte { + t.Helper() + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + + for name, content := range entries { + hdr := &tar.Header{ + Name: name, + Typeflag: tar.TypeReg, + Mode: int64(mode), + Size: int64(len(content)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("tar write header %q: %v", name, err) + } + if _, err := tw.Write(content); err != nil { + t.Fatalf("tar write content %q: %v", name, err) + } + } + + if err := tw.Close(); err != nil { + t.Fatalf("tar close: %v", err) + } + if err := gzw.Close(); err != nil { + t.Fatalf("gzip close: %v", err) + } + return buf.Bytes() +} + +// sha256Hex returns the hex-encoded SHA256 of data. +func sha256Hex(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} + +// TestPluginInstallE2E exercises the full install pipeline: +// registry → fetch manifest → download tarball → verify checksum → extract → discover. +func TestPluginInstallE2E(t *testing.T) { + const pluginName = "test-plugin" + binaryContent := []byte("#!/bin/sh\necho hello\n") + + // Build in-memory tar.gz with the binary nested under a top-level directory, + // as real plugin releases do (e.g. test-plugin-darwin-arm64/test-plugin). + topDir := fmt.Sprintf("%s-%s-%s", pluginName, runtime.GOOS, runtime.GOARCH) + tarEntries := map[string][]byte{ + topDir + "/" + pluginName: binaryContent, + } + tarball := buildTarGz(t, tarEntries, 0755) + checksum := sha256Hex(tarball) + + // httptest server that serves the tarball. + tarSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + w.Write(tarball) //nolint:errcheck + })) + defer tarSrv.Close() + + tarURL := tarSrv.URL + "/" + pluginName + ".tar.gz" + + // Build a RegistryManifest pointing at the local tar server. + manifest := &RegistryManifest{ + Name: pluginName, + Version: "1.0.0", + Author: "tester", + Description: "e2e test plugin", + Type: "external", + Tier: "community", + License: "MIT", + Downloads: []PluginDownload{ + { + OS: runtime.GOOS, + Arch: runtime.GOARCH, + URL: tarURL, + SHA256: checksum, + }, + }, + } + + // Wire up a mock registry source (uses the shared mockRegistrySource from multi_registry_test.go). + src := &mockRegistrySource{ + name: "test-registry", + manifests: map[string]*RegistryManifest{ + pluginName: manifest, + }, + } + mr := NewMultiRegistryFromSources(src) + + // --- Step 1: Fetch manifest via registry --- + gotManifest, sourceName, err := mr.FetchManifest(pluginName) + if err != nil { + t.Fatalf("FetchManifest: %v", err) + } + if sourceName != "test-registry" { + t.Errorf("source name: got %q, want %q", sourceName, "test-registry") + } + if gotManifest.Name != pluginName { + t.Errorf("manifest name: got %q, want %q", gotManifest.Name, pluginName) + } + + // --- Step 2: Find platform download --- + dl, err := gotManifest.FindDownload(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatalf("FindDownload(%s/%s): %v", runtime.GOOS, runtime.GOARCH, err) + } + + // --- Step 3: Download tarball --- + data, err := downloadURL(dl.URL) + if err != nil { + t.Fatalf("downloadURL: %v", err) + } + if len(data) == 0 { + t.Fatal("downloaded empty tarball") + } + + // --- Step 4: Verify checksum --- + if err := verifyChecksum(data, dl.SHA256); err != nil { + t.Fatalf("verifyChecksum: %v", err) + } + + // --- Step 5: Extract to temp dir --- + pluginsDir := t.TempDir() + destDir := filepath.Join(pluginsDir, pluginName) + if err := os.MkdirAll(destDir, 0750); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := extractTarGz(data, destDir); err != nil { + t.Fatalf("extractTarGz: %v", err) + } + + // Verify the binary was extracted with correct content. + binaryPath := filepath.Join(destDir, pluginName) + gotContent, err := os.ReadFile(binaryPath) + if err != nil { + t.Fatalf("read extracted binary: %v", err) + } + if !bytes.Equal(gotContent, binaryContent) { + t.Errorf("binary content mismatch: got %q, want %q", gotContent, binaryContent) + } + + // --- Step 6: Write plugin.json --- + pluginJSONPath := filepath.Join(destDir, "plugin.json") + if err := writeInstalledManifest(pluginJSONPath, gotManifest); err != nil { + t.Fatalf("writeInstalledManifest: %v", err) + } + + // Verify plugin.json content. + raw, err := os.ReadFile(pluginJSONPath) + if err != nil { + t.Fatalf("read plugin.json: %v", err) + } + var pj installedPluginJSON + if err := json.Unmarshal(raw, &pj); err != nil { + t.Fatalf("unmarshal plugin.json: %v", err) + } + if pj.Name != pluginName { + t.Errorf("plugin.json name: got %q, want %q", pj.Name, pluginName) + } + if pj.Version != "1.0.0" { + t.Errorf("plugin.json version: got %q, want %q", pj.Version, "1.0.0") + } + + // --- Step 7: ExternalPluginManager.DiscoverPlugins --- + mgr := external.NewExternalPluginManager(pluginsDir, nil) + discovered, err := mgr.DiscoverPlugins() + if err != nil { + t.Fatalf("DiscoverPlugins: %v", err) + } + if len(discovered) != 1 { + t.Fatalf("expected 1 discovered plugin, got %d: %v", len(discovered), discovered) + } + if discovered[0] != pluginName { + t.Errorf("discovered plugin: got %q, want %q", discovered[0], pluginName) + } +} + +// TestExtractTarGz verifies that tar.gz extraction produces correct files with preserved modes. +func TestExtractTarGz(t *testing.T) { + entries := map[string][]byte{ + "top/file.txt": []byte("hello"), + "top/subdir/deep.txt": []byte("world"), + "top/script.sh": []byte("#!/bin/sh\necho hi"), + } + tarball := buildTarGz(t, entries, 0755) + + destDir := t.TempDir() + if err := extractTarGz(tarball, destDir); err != nil { + t.Fatalf("extractTarGz: %v", err) + } + + // Verify each file (strip the top dir — extractTarGz does this internally). + checks := map[string][]byte{ + "file.txt": []byte("hello"), + "subdir/deep.txt": []byte("world"), + "script.sh": []byte("#!/bin/sh\necho hi"), + } + for rel, want := range checks { + path := filepath.Join(destDir, rel) + got, err := os.ReadFile(path) + if err != nil { + t.Errorf("read %q: %v", rel, err) + continue + } + if !bytes.Equal(got, want) { + t.Errorf("content of %q: got %q, want %q", rel, got, want) + } + } + + // Verify mode is preserved for script.sh (executable). + info, err := os.Stat(filepath.Join(destDir, "script.sh")) + if err != nil { + t.Fatalf("stat script.sh: %v", err) + } + if info.Mode()&0111 == 0 { + t.Errorf("expected executable bit set on script.sh, got mode %v", info.Mode()) + } +} + +// TestExtractTarGzPathTraversal verifies that path traversal entries are rejected. +func TestExtractTarGzPathTraversal(t *testing.T) { + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + + // Write a malicious entry that tries to escape the destination directory. + content := []byte("malicious") + hdr := &tar.Header{ + Name: "top/../../etc/passwd", + Typeflag: tar.TypeReg, + Mode: 0644, + Size: int64(len(content)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("write header: %v", err) + } + if _, err := tw.Write(content); err != nil { + t.Fatalf("write content: %v", err) + } + tw.Close() //nolint:errcheck + gzw.Close() //nolint:errcheck + + destDir := t.TempDir() + err := extractTarGz(buf.Bytes(), destDir) + if err == nil { + t.Fatal("expected error for path traversal entry, got nil") + } +} + +// TestSafeJoin is a table-driven test for the safeJoin helper. +func TestSafeJoin(t *testing.T) { + base := "/safe/base" + tests := []struct { + name string + input string + wantErr bool + want string + }{ + { + name: "normal file", + input: "file.txt", + wantErr: false, + want: "/safe/base/file.txt", + }, + { + name: "nested path", + input: "subdir/file.txt", + wantErr: false, + want: "/safe/base/subdir/file.txt", + }, + { + name: "path traversal dotdot", + input: "../etc/passwd", + wantErr: true, + }, + { + name: "absolute path traversal", + input: "../../etc/passwd", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := safeJoin(base, tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("expected error for input %q, got path %q", tt.input, got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +// TestDownloadURL tests the downloadURL helper using an httptest server. +func TestDownloadURL(t *testing.T) { + t.Run("success", func(t *testing.T) { + body := []byte("binary content here") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write(body) //nolint:errcheck + })) + defer srv.Close() + + got, err := downloadURL(srv.URL) + if err != nil { + t.Fatalf("downloadURL: %v", err) + } + if !bytes.Equal(got, body) { + t.Errorf("content mismatch: got %q, want %q", got, body) + } + }) + + t.Run("404 returns error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + + _, err := downloadURL(srv.URL) + if err == nil { + t.Fatal("expected error for 404, got nil") + } + }) + + t.Run("500 returns error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + })) + defer srv.Close() + + _, err := downloadURL(srv.URL) + if err == nil { + t.Fatal("expected error for 500, got nil") + } + }) +} diff --git a/cmd/wfctl/plugin_validate.go b/cmd/wfctl/plugin_validate.go new file mode 100644 index 00000000..a85e139a --- /dev/null +++ b/cmd/wfctl/plugin_validate.go @@ -0,0 +1,131 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "runtime" +) + +func runPluginValidate(args []string) error { + fs := flag.NewFlagSet("plugin validate", flag.ContinueOnError) + filePath := fs.String("file", "", "Validate a local manifest file instead of fetching from registry") + all := fs.Bool("all", false, "Validate all plugins in the configured registries") + verifyURLs := fs.Bool("verify-urls", false, "HEAD-check download URLs for reachability") + cfgPath := fs.String("config", "", "Registry config file path") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), "Usage: wfctl plugin validate [options] []\n\nValidate a plugin manifest from the registry or a local file.\n\nOptions:\n") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + + opts := ValidationOptions{ + VerifyURLs: *verifyURLs, + TargetOS: runtime.GOOS, + TargetArch: runtime.GOARCH, + } + + // Validate a local file + if *filePath != "" { + return validateLocalManifest(*filePath, opts) + } + + // Validate all plugins across configured registries + if *all { + return validateAllPlugins(*cfgPath, opts) + } + + // Validate a single plugin by name + if fs.NArg() < 1 { + fs.Usage() + return fmt.Errorf("plugin name, --file, or --all is required") + } + + pluginName := fs.Arg(0) + return validateSinglePlugin(pluginName, *cfgPath, opts) +} + +func validateLocalManifest(path string, opts ValidationOptions) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read manifest: %w", err) + } + var m RegistryManifest + if err := json.Unmarshal(data, &m); err != nil { + return fmt.Errorf("parse manifest: %w", err) + } + + errs := ValidateManifest(&m, opts) + if len(errs) == 0 { + fmt.Printf("OK %s v%s (%s)\n", m.Name, m.Version, path) + return nil + } + fmt.Printf("FAIL %s v%s (%s)\n", m.Name, m.Version, path) + fmt.Print(FormatValidationErrors(errs)) + return fmt.Errorf("%d validation error(s)", len(errs)) +} + +func validateSinglePlugin(name, cfgPath string, opts ValidationOptions) error { + cfg, err := LoadRegistryConfig(cfgPath) + if err != nil { + return err + } + mr := NewMultiRegistry(cfg) + manifest, source, err := mr.FetchManifest(name) + if err != nil { + return err + } + + errs := ValidateManifest(manifest, opts) + if len(errs) == 0 { + fmt.Printf("OK %s v%s (from %s)\n", manifest.Name, manifest.Version, source) + return nil + } + fmt.Printf("FAIL %s v%s (from %s)\n", manifest.Name, manifest.Version, source) + fmt.Print(FormatValidationErrors(errs)) + return fmt.Errorf("%d validation error(s)", len(errs)) +} + +func validateAllPlugins(cfgPath string, opts ValidationOptions) error { + cfg, err := LoadRegistryConfig(cfgPath) + if err != nil { + return err + } + mr := NewMultiRegistry(cfg) + + names, err := mr.ListPlugins() + if err != nil { + return fmt.Errorf("list plugins: %w", err) + } + + var totalErrs int + passCount := 0 + failCount := 0 + + for _, name := range names { + manifest, source, fetchErr := mr.FetchManifest(name) + if fetchErr != nil { + fmt.Printf("SKIP %s (fetch error: %v)\n", name, fetchErr) + continue + } + errs := ValidateManifest(manifest, opts) + if len(errs) == 0 { + fmt.Printf("OK %s v%s (from %s)\n", manifest.Name, manifest.Version, source) + passCount++ + } else { + fmt.Printf("FAIL %s v%s (from %s)\n", manifest.Name, manifest.Version, source) + fmt.Print(FormatValidationErrors(errs)) + failCount++ + totalErrs += len(errs) + } + } + + fmt.Printf("\n%d passed, %d failed (%d total errors)\n", passCount, failCount, totalErrs) + if failCount > 0 { + return fmt.Errorf("%d plugin(s) failed validation", failCount) + } + return nil +} diff --git a/cmd/wfctl/registry_cmd.go b/cmd/wfctl/registry_cmd.go new file mode 100644 index 00000000..d690530d --- /dev/null +++ b/cmd/wfctl/registry_cmd.go @@ -0,0 +1,176 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" +) + +func runRegistry(args []string) error { + if len(args) < 1 { + return registryUsage() + } + switch args[0] { + case "list": + return runRegistryList(args[1:]) + case "add": + return runRegistryAdd(args[1:]) + case "remove": + return runRegistryRemove(args[1:]) + default: + return registryUsage() + } +} + +func registryUsage() error { + fmt.Fprintf(flag.CommandLine.Output(), `Usage: wfctl registry [options] + +Subcommands: + list Show configured plugin registries + add Add a plugin registry source + remove Remove a plugin registry source +`) + return fmt.Errorf("registry subcommand is required") +} + +func runRegistryList(args []string) error { + fs := flag.NewFlagSet("registry list", flag.ContinueOnError) + cfgPath := fs.String("config", "", "Registry config file path") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), "Usage: wfctl registry list [options]\n\nShow configured plugin registries.\n\nOptions:\n") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + + cfg, err := LoadRegistryConfig(*cfgPath) + if err != nil { + return err + } + + fmt.Printf("%-15s %-10s %-25s %-25s %s\n", "NAME", "TYPE", "OWNER", "REPO", "PRIORITY") + fmt.Printf("%-15s %-10s %-25s %-25s %s\n", "----", "----", "-----", "----", "--------") + for _, r := range cfg.Registries { + fmt.Printf("%-15s %-10s %-25s %-25s %d\n", r.Name, r.Type, r.Owner, r.Repo, r.Priority) + } + return nil +} + +func runRegistryAdd(args []string) error { + fs := flag.NewFlagSet("registry add", flag.ContinueOnError) + cfgPath := fs.String("config", "", "Registry config file path (default: ~/.config/wfctl/config.yaml)") + regType := fs.String("type", "github", "Registry type (github)") + owner := fs.String("owner", "", "GitHub owner/org (required)") + repo := fs.String("repo", "", "GitHub repo name (required)") + branch := fs.String("branch", "main", "Git branch") + priority := fs.Int("priority", 10, "Priority (lower = higher priority)") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), "Usage: wfctl registry add [options] \n\nAdd a plugin registry source.\n\nOptions:\n") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() < 1 { + fs.Usage() + return fmt.Errorf("registry name is required") + } + if *owner == "" || *repo == "" { + return fmt.Errorf("--owner and --repo are required") + } + + name := fs.Arg(0) + cfg, err := LoadRegistryConfig(*cfgPath) + if err != nil { + return err + } + + // Check for duplicate + for _, r := range cfg.Registries { + if r.Name == name { + return fmt.Errorf("registry %q already exists", name) + } + } + + cfg.Registries = append(cfg.Registries, RegistrySourceConfig{ + Name: name, + Type: *regType, + Owner: *owner, + Repo: *repo, + Branch: *branch, + Priority: *priority, + }) + + // Determine save path + savePath := *cfgPath + if savePath == "" { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("determine home directory: %w", err) + } + savePath = filepath.Join(home, ".config", "wfctl", "config.yaml") + } + + if err := SaveRegistryConfig(savePath, cfg); err != nil { + return err + } + fmt.Printf("Added registry %q (%s/%s)\n", name, *owner, *repo) + return nil +} + +func runRegistryRemove(args []string) error { + fs := flag.NewFlagSet("registry remove", flag.ContinueOnError) + cfgPath := fs.String("config", "", "Registry config file path (default: ~/.config/wfctl/config.yaml)") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), "Usage: wfctl registry remove [options] \n\nRemove a plugin registry source.\n\nOptions:\n") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() < 1 { + fs.Usage() + return fmt.Errorf("registry name is required") + } + + name := fs.Arg(0) + if name == "default" { + return fmt.Errorf("cannot remove the default registry") + } + + cfg, err := LoadRegistryConfig(*cfgPath) + if err != nil { + return err + } + + found := false + filtered := make([]RegistrySourceConfig, 0, len(cfg.Registries)) + for _, r := range cfg.Registries { + if r.Name == name { + found = true + continue + } + filtered = append(filtered, r) + } + if !found { + return fmt.Errorf("registry %q not found", name) + } + cfg.Registries = filtered + + savePath := *cfgPath + if savePath == "" { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("determine home directory: %w", err) + } + savePath = filepath.Join(home, ".config", "wfctl", "config.yaml") + } + + if err := SaveRegistryConfig(savePath, cfg); err != nil { + return err + } + fmt.Printf("Removed registry %q\n", name) + return nil +} diff --git a/cmd/wfctl/registry_config.go b/cmd/wfctl/registry_config.go new file mode 100644 index 00000000..72c1227b --- /dev/null +++ b/cmd/wfctl/registry_config.go @@ -0,0 +1,94 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// RegistryConfig defines wfctl plugin registry configuration. +type RegistryConfig struct { + Registries []RegistrySourceConfig `yaml:"registries" json:"registries"` +} + +// RegistrySourceConfig defines a single registry source. +type RegistrySourceConfig struct { + Name string `yaml:"name" json:"name"` // e.g. "default", "my-org" + Type string `yaml:"type" json:"type"` // "github" (only type for now) + Owner string `yaml:"owner" json:"owner"` // GitHub owner/org + Repo string `yaml:"repo" json:"repo"` // GitHub repo name + Branch string `yaml:"branch" json:"branch"` // Git branch, default "main" + Priority int `yaml:"priority" json:"priority"` // Lower = higher priority +} + +// DefaultRegistryConfig returns the built-in config with GoCodeAlone/workflow-registry. +func DefaultRegistryConfig() *RegistryConfig { + return &RegistryConfig{ + Registries: []RegistrySourceConfig{ + { + Name: "default", + Type: "github", + Owner: registryOwner, + Repo: registryRepo, + Branch: registryBranch, + Priority: 0, + }, + }, + } +} + +// LoadRegistryConfig loads configuration from the first found config file, or returns the default. +// Search order: --registry-config flag path, .wfctl.yaml in CWD, ~/.config/wfctl/config.yaml +func LoadRegistryConfig(explicitPath string) (*RegistryConfig, error) { + paths := []string{} + if explicitPath != "" { + paths = append(paths, explicitPath) + } + paths = append(paths, ".wfctl.yaml") + if home, err := os.UserHomeDir(); err == nil { + paths = append(paths, filepath.Join(home, ".config", "wfctl", "config.yaml")) + } + + for _, p := range paths { + data, err := os.ReadFile(p) + if err != nil { + continue + } + var cfg RegistryConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse registry config %s: %w", p, err) + } + // Ensure defaults + for i := range cfg.Registries { + if cfg.Registries[i].Branch == "" { + cfg.Registries[i].Branch = "main" + } + if cfg.Registries[i].Type == "" { + cfg.Registries[i].Type = "github" + } + } + return &cfg, nil + } + return DefaultRegistryConfig(), nil +} + +// SaveRegistryConfig writes a registry config to a YAML file. +func SaveRegistryConfig(path string, cfg *RegistryConfig) error { + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshal registry config: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { + return fmt.Errorf("create config directory: %w", err) + } + return os.WriteFile(path, data, 0600) +} + +// MarshalJSON implements json.Marshaler for RegistryConfig (used in `registry list --json`). +func (c *RegistryConfig) MarshalJSON() ([]byte, error) { + type Alias RegistryConfig + return json.Marshal((*Alias)(c)) +} diff --git a/cmd/wfctl/registry_source.go b/cmd/wfctl/registry_source.go new file mode 100644 index 00000000..053d1a0b --- /dev/null +++ b/cmd/wfctl/registry_source.go @@ -0,0 +1,158 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" +) + +// RegistrySource is the interface for a plugin registry backend. +type RegistrySource interface { + // Name returns the configured name of this registry. + Name() string + // ListPlugins returns all plugin names in this registry. + ListPlugins() ([]string, error) + // FetchManifest retrieves the manifest for a named plugin. + FetchManifest(name string) (*RegistryManifest, error) + // SearchPlugins returns plugins matching the query string. + SearchPlugins(query string) ([]PluginSearchResult, error) +} + +// PluginSearchResult is a search result from a registry source. +type PluginSearchResult struct { + PluginSummary + Source string // registry name this came from +} + +// GitHubRegistrySource implements RegistrySource backed by a GitHub repo with manifest.json files. +type GitHubRegistrySource struct { + name string + owner string + repo string + branch string +} + +// NewGitHubRegistrySource creates a new GitHub-backed registry source. +func NewGitHubRegistrySource(cfg RegistrySourceConfig) *GitHubRegistrySource { + branch := cfg.Branch + if branch == "" { + branch = "main" + } + return &GitHubRegistrySource{ + name: cfg.Name, + owner: cfg.Owner, + repo: cfg.Repo, + branch: branch, + } +} + +func (g *GitHubRegistrySource) Name() string { return g.name } + +func (g *GitHubRegistrySource) ListPlugins() ([]string, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/plugins", g.owner, g.repo) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("list registry plugins from %s: %w", g.name, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("registry %s API returned HTTP %d", g.name, resp.StatusCode) + } + var entries []githubContentsEntry + if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil { + return nil, fmt.Errorf("parse registry %s listing: %w", g.name, err) + } + var names []string + for _, e := range entries { + if e.Type == "dir" { + names = append(names, e.Name) + } + } + return names, nil +} + +func (g *GitHubRegistrySource) FetchManifest(name string) (*RegistryManifest, error) { + url := fmt.Sprintf( + "https://raw.githubusercontent.com/%s/%s/%s/plugins/%s/manifest.json", + g.owner, g.repo, g.branch, name, + ) + resp, err := http.Get(url) //nolint:gosec // URL constructed from configured registry + if err != nil { + return nil, fmt.Errorf("fetch manifest for %q from %s: %w", name, g.name, err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("plugin %q not found in registry %s", name, g.name) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("registry %s returned HTTP %d for plugin %q", g.name, resp.StatusCode, name) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read manifest for %q from %s: %w", name, g.name, err) + } + var m RegistryManifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse manifest for %q from %s: %w", name, g.name, err) + } + return &m, nil +} + +func (g *GitHubRegistrySource) SearchPlugins(query string) ([]PluginSearchResult, error) { + names, err := g.ListPlugins() + if err != nil { + return nil, err + } + q := strings.ToLower(query) + var results []PluginSearchResult + for _, name := range names { + m, fetchErr := g.FetchManifest(name) + if fetchErr != nil { + continue + } + if matchesRegistryQuery(m, q) { + results = append(results, PluginSearchResult{ + PluginSummary: PluginSummary{ + Name: m.Name, + Version: m.Version, + Description: m.Description, + Tier: m.Tier, + }, + Source: g.name, + }) + } + } + return results, nil +} + +// matchesRegistryQuery checks if a manifest matches a search query. +func matchesRegistryQuery(m *RegistryManifest, q string) bool { + if q == "" { + return true + } + if strings.Contains(strings.ToLower(m.Name), q) { + return true + } + if strings.Contains(strings.ToLower(m.Description), q) { + return true + } + for _, kw := range m.Keywords { + if strings.Contains(strings.ToLower(kw), q) { + return true + } + } + return false +} diff --git a/cmd/wfctl/registry_validate.go b/cmd/wfctl/registry_validate.go new file mode 100644 index 00000000..5ab43fdb --- /dev/null +++ b/cmd/wfctl/registry_validate.go @@ -0,0 +1,180 @@ +package main + +import ( + "fmt" + "net/http" + "regexp" + "strings" + "time" +) + +// ValidationError represents a single validation error. +type ValidationError struct { + Field string + Message string +} + +func (e ValidationError) Error() string { + return fmt.Sprintf("%s: %s", e.Field, e.Message) +} + +// ValidationOptions configures validation behavior. +type ValidationOptions struct { + VerifyURLs bool // HEAD-check download URLs + VerifyChecksums bool // Verify SHA256 format (not content) + EngineVersion string // Current engine version for minEngineVersion check + TargetOS string // Filter downloads by OS + TargetArch string // Filter downloads by arch +} + +var semverRegex = regexp.MustCompile(`^\d+\.\d+\.\d+`) +var sha256Regex = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) +var validPluginTypes = map[string]bool{"builtin": true, "external": true, "ui": true} +var validPluginTiers = map[string]bool{"core": true, "community": true, "premium": true} +var validDownloadOS = map[string]bool{"linux": true, "darwin": true, "windows": true} +var validDownloadArch = map[string]bool{"amd64": true, "arm64": true} + +// ValidateManifest performs full validation of a registry manifest. +func ValidateManifest(m *RegistryManifest, opts ValidationOptions) []ValidationError { + var errs []ValidationError + + // Required fields + if m.Name == "" { + errs = append(errs, ValidationError{Field: "name", Message: "required field is empty"}) + } + if m.Version == "" { + errs = append(errs, ValidationError{Field: "version", Message: "required field is empty"}) + } else if !semverRegex.MatchString(m.Version) { + errs = append(errs, ValidationError{Field: "version", Message: fmt.Sprintf("must be semver format (got %q)", m.Version)}) + } + if m.Author == "" { + errs = append(errs, ValidationError{Field: "author", Message: "required field is empty"}) + } + if m.Description == "" { + errs = append(errs, ValidationError{Field: "description", Message: "required field is empty"}) + } + if m.Type == "" { + errs = append(errs, ValidationError{Field: "type", Message: "required field is empty"}) + } else if !validPluginTypes[m.Type] { + errs = append(errs, ValidationError{Field: "type", Message: fmt.Sprintf("must be one of: builtin, external, ui (got %q)", m.Type)}) + } + if m.Tier == "" { + errs = append(errs, ValidationError{Field: "tier", Message: "required field is empty"}) + } else if !validPluginTiers[m.Tier] { + errs = append(errs, ValidationError{Field: "tier", Message: fmt.Sprintf("must be one of: core, community, premium (got %q)", m.Tier)}) + } + if m.License == "" { + errs = append(errs, ValidationError{Field: "license", Message: "required field is empty"}) + } + + // MinEngineVersion format check + if m.MinEngineVersion != "" && !semverRegex.MatchString(m.MinEngineVersion) { + errs = append(errs, ValidationError{Field: "minEngineVersion", Message: fmt.Sprintf("must be semver format (got %q)", m.MinEngineVersion)}) + } + + // Engine version compatibility check + if opts.EngineVersion != "" && m.MinEngineVersion != "" { + if compareSemver(opts.EngineVersion, m.MinEngineVersion) < 0 { + errs = append(errs, ValidationError{ + Field: "minEngineVersion", + Message: fmt.Sprintf("requires engine %s but current engine is %s", m.MinEngineVersion, opts.EngineVersion), + }) + } + } + + // Downloads validation + if m.Type == "external" && len(m.Downloads) == 0 { + errs = append(errs, ValidationError{Field: "downloads", Message: "external plugins must have at least one download entry"}) + } + for i, dl := range m.Downloads { + prefix := fmt.Sprintf("downloads[%d]", i) + if !validDownloadOS[dl.OS] { + errs = append(errs, ValidationError{Field: prefix + ".os", Message: fmt.Sprintf("must be one of: linux, darwin, windows (got %q)", dl.OS)}) + } + if !validDownloadArch[dl.Arch] { + errs = append(errs, ValidationError{Field: prefix + ".arch", Message: fmt.Sprintf("must be one of: amd64, arm64 (got %q)", dl.Arch)}) + } + if dl.URL == "" { + errs = append(errs, ValidationError{Field: prefix + ".url", Message: "required field is empty"}) + } + if dl.SHA256 != "" && !sha256Regex.MatchString(dl.SHA256) { + errs = append(errs, ValidationError{Field: prefix + ".sha256", Message: fmt.Sprintf("must be 64-character hex string (got %q)", dl.SHA256)}) + } + } + + // URL reachability check + if opts.VerifyURLs { + client := &http.Client{Timeout: 10 * time.Second} + for i, dl := range m.Downloads { + if dl.URL == "" { + continue + } + if opts.TargetOS != "" && dl.OS != opts.TargetOS { + continue + } + if opts.TargetArch != "" && dl.Arch != opts.TargetArch { + continue + } + resp, err := client.Head(dl.URL) //nolint:gosec // URL from registry manifest + if err != nil { + errs = append(errs, ValidationError{ + Field: fmt.Sprintf("downloads[%d].url", i), + Message: fmt.Sprintf("URL unreachable: %v", err), + }) + } else { + resp.Body.Close() + if resp.StatusCode >= 400 { + errs = append(errs, ValidationError{ + Field: fmt.Sprintf("downloads[%d].url", i), + Message: fmt.Sprintf("URL returned HTTP %d", resp.StatusCode), + }) + } + } + } + } + + return errs +} + +// compareSemver compares two semver strings. Returns -1, 0, or 1. +func compareSemver(a, b string) int { + parseVer := func(s string) (int, int, int) { + var major, minor, patch int + fmt.Sscanf(s, "%d.%d.%d", &major, &minor, &patch) //nolint:errcheck // format is validated by semverRegex + return major, minor, patch + } + aMaj, aMin, aPat := parseVer(a) + bMaj, bMin, bPat := parseVer(b) + if aMaj != bMaj { + if aMaj < bMaj { + return -1 + } + return 1 + } + if aMin != bMin { + if aMin < bMin { + return -1 + } + return 1 + } + if aPat != bPat { + if aPat < bPat { + return -1 + } + return 1 + } + return 0 +} + +// FormatValidationErrors formats validation errors for display. +func FormatValidationErrors(errs []ValidationError) string { + if len(errs) == 0 { + return "" + } + var b strings.Builder + for _, e := range errs { + fmt.Fprintf(&b, " - %s: %s\n", e.Field, e.Message) + } + return b.String() +} + From 828fc55346fecb5dc8ff392153c38fdbb191887f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 26 Feb 2026 21:45:11 -0500 Subject: [PATCH 2/2] fix: suppress gosec G703 on artifact store path operations The artifact path is sanitized via TrimPrefix + filepath.Join in artifactPath(), making these path traversal warnings false positives. Co-Authored-By: Claude Opus 4.6 --- module/storage_artifact_fs.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/module/storage_artifact_fs.go b/module/storage_artifact_fs.go index 11cae09a..8e85f62c 100644 --- a/module/storage_artifact_fs.go +++ b/module/storage_artifact_fs.go @@ -98,11 +98,11 @@ func (m *ArtifactFSModule) Upload(_ context.Context, key string, reader io.Reade m.mu.Lock() defer m.mu.Unlock() - if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { //nolint:gosec // G703: key sanitized by artifactPath (TrimPrefix + filepath.Join) return fmt.Errorf("artifact store %q: Upload %q: failed to create directory: %w", m.name, key, err) } - f, err := os.Create(path) + f, err := os.Create(path) //nolint:gosec // G703: key sanitized by artifactPath if err != nil { return fmt.Errorf("artifact store %q: Upload %q: failed to create file: %w", m.name, key, err) } @@ -117,7 +117,7 @@ func (m *ArtifactFSModule) Upload(_ context.Context, key string, reader io.Reade } if m.cfg.MaxSize > 0 && size > m.cfg.MaxSize { - _ = os.Remove(path) + _ = os.Remove(path) //nolint:gosec // G703: key sanitized by artifactPath return fmt.Errorf("artifact store %q: Upload %q: size %d exceeds limit %d", m.name, key, size, m.cfg.MaxSize) }