diff --git a/README.md b/README.md index 33aa733..b2f7fd7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # registries -Go library for fetching package metadata from registry APIs. Supports 25 ecosystems with a unified interface. Also provides sub-packages for HTTP client usage (`client/`) and streaming artifact downloads (`fetch/`). +Go library for fetching package metadata from registry APIs. Supports 26 ecosystems with a unified interface. Also provides sub-packages for HTTP client usage (`client/`) and streaming artifact downloads (`fetch/`). ## Installation @@ -182,6 +182,7 @@ import _ "github.com/git-pkgs/registries/all" | LuaRocks | `luarocks` | https://luarocks.org | | Nimble | `nimble` | https://nimble.directory | | Haxelib | `haxelib` | https://lib.haxe.org | +| Helm | `helm` | URL required | | Homebrew | `brew` | https://formulae.brew.sh | | Deno | `deno` | https://apiland.deno.dev | | Terraform | `terraform` | https://registry.terraform.io | diff --git a/all/all.go b/all/all.go index d9822f3..8e9577a 100644 --- a/all/all.go +++ b/all/all.go @@ -9,7 +9,7 @@ // // // Now all ecosystems are available // ecosystems := registries.SupportedEcosystems() -// // ["brew", "cargo", "clojars", "cocoapods", "composer", "conda", "cpan", "cran", "deno", "dub", "elm", "gem", "golang", "hackage", "haxelib", "hex", "julia", "luarocks", "maven", "nimble", "npm", "nuget", "pub", "pypi", "terraform"] +// // ["brew", "cargo", "clojars", "cocoapods", "composer", "conda", "cpan", "cran", "deno", "dub", "elm", "gem", "golang", "hackage", "haxelib", "helm", "hex", "julia", "luarocks", "maven", "nimble", "npm", "nuget", "pub", "pypi", "terraform"] package all import ( @@ -25,6 +25,7 @@ import ( _ "github.com/git-pkgs/registries/internal/golang" _ "github.com/git-pkgs/registries/internal/hackage" _ "github.com/git-pkgs/registries/internal/haxelib" + _ "github.com/git-pkgs/registries/internal/helm" _ "github.com/git-pkgs/registries/internal/hex" _ "github.com/git-pkgs/registries/internal/homebrew" _ "github.com/git-pkgs/registries/internal/julia" diff --git a/go.mod b/go.mod index e6c18ef..3a96ef7 100644 --- a/go.mod +++ b/go.mod @@ -7,13 +7,14 @@ require ( github.com/git-pkgs/pom v0.1.5 github.com/git-pkgs/purl v0.1.15 github.com/git-pkgs/spdx v0.3.0 + github.com/git-pkgs/vers v0.3.0 github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 github.com/rubyist/circuitbreaker v2.2.1+incompatible + go.yaml.in/yaml/v3 v3.0.5 ) require ( github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect - github.com/git-pkgs/vers v0.3.0 // indirect github.com/github/go-spdx/v2 v2.7.0 // indirect github.com/package-url/packageurl-go v0.1.6 // indirect github.com/peterbourgon/g2s v0.0.0-20170223122336-d4e7ad98afea // indirect diff --git a/go.sum b/go.sum index 345b46b..65e7a67 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/rubyist/circuitbreaker v2.2.1+incompatible h1:KUKd/pV8Geg77+8LNDwdow6 github.com/rubyist/circuitbreaker v2.2.1+incompatible/go.mod h1:Ycs3JgJADPuzJDwffe12k6BZT8hxVi6lFK+gWYJLN4A= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= diff --git a/internal/core/registry.go b/internal/core/registry.go index b2e535a..77bf229 100644 --- a/internal/core/registry.go +++ b/internal/core/registry.go @@ -61,6 +61,9 @@ func New(ecosystem string, baseURL string, client *Client) (Registry, error) { / if baseURL == "" { baseURL = defaultURL } + if baseURL == "" { + return nil, fmt.Errorf("no registry URL configured for ecosystem: %s", ecosystem) + } if client == nil { client = DefaultClient() diff --git a/internal/helm/helm.go b/internal/helm/helm.go new file mode 100644 index 0000000..23f98ab --- /dev/null +++ b/internal/helm/helm.go @@ -0,0 +1,407 @@ +// Package helm provides a registry client for Helm HTTP repositories. +package helm + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/url" + "path" + "strings" + "sync" + "time" + + "github.com/git-pkgs/registries/internal/core" + "github.com/git-pkgs/vers" + "go.yaml.in/yaml/v3" +) + +const ( + DefaultURL = "" + ecosystem = "helm" +) + +func init() { + core.Register(ecosystem, DefaultURL, func(baseURL string, client *core.Client) core.Registry { + return New(baseURL, client) + }) +} + +type Registry struct { + indexURL string + client *core.Client + urls *URLs +} + +func New(baseURL string, client *core.Client) *Registry { + if client == nil { + client = core.DefaultClient() + } + + indexURL := buildIndexURL(baseURL) + return &Registry{ + indexURL: indexURL, + client: client, + urls: &URLs{indexURL: indexURL, downloads: make(map[string]map[string]string)}, + } +} + +func (r *Registry) Ecosystem() string { + return ecosystem +} + +func (r *Registry) URLs() core.URLBuilder { //nolint:ireturn + return r.urls +} + +type indexFile struct { + APIVersion string `yaml:"apiVersion"` + Generated time.Time `yaml:"generated"` + Entries map[string][]chartVersion `yaml:"entries"` + PublicKeys []string `yaml:"publicKeys"` + Annotations map[string]string `yaml:"annotations"` +} + +type chartVersion struct { + Name string `json:"name,omitempty" yaml:"name"` + Home string `json:"home,omitempty" yaml:"home"` + Sources []string `json:"sources,omitempty" yaml:"sources"` + Version string `json:"version,omitempty" yaml:"version"` + Description string `json:"description,omitempty" yaml:"description"` + Keywords []string `json:"keywords,omitempty" yaml:"keywords"` + Maintainers []maintainerInfo `json:"maintainers,omitempty" yaml:"maintainers"` + Icon string `json:"icon,omitempty" yaml:"icon"` + APIVersion string `json:"apiVersion,omitempty" yaml:"apiVersion"` + Condition string `json:"condition,omitempty" yaml:"condition"` + Tags string `json:"tags,omitempty" yaml:"tags"` + AppVersion string `json:"appVersion,omitempty" yaml:"appVersion"` + Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated"` + Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations"` + KubeVersion string `json:"kubeVersion,omitempty" yaml:"kubeVersion"` + Dependencies []dependencyInfo `json:"dependencies,omitempty" yaml:"dependencies"` + Type string `json:"type,omitempty" yaml:"type"` + URLs []string `json:"urls,omitempty" yaml:"urls"` + Created time.Time `json:"created,omitempty" yaml:"created"` + Removed bool `json:"removed,omitempty" yaml:"removed"` + Digest string `json:"digest,omitempty" yaml:"digest"` + Checksum string `json:"checksum,omitempty" yaml:"checksum"` + resolvedURLs []string +} + +type maintainerInfo struct { + Name string `json:"name,omitempty" yaml:"name"` + Email string `json:"email,omitempty" yaml:"email"` + URL string `json:"url,omitempty" yaml:"url"` +} + +type dependencyInfo struct { + Name string `json:"name" yaml:"name"` + Version string `json:"version,omitempty" yaml:"version"` + Repository string `json:"repository,omitempty" yaml:"repository"` + Condition string `json:"condition,omitempty" yaml:"condition"` + Tags []string `json:"tags,omitempty" yaml:"tags"` + Enabled bool `json:"enabled,omitempty" yaml:"enabled"` + ImportValues []any `json:"import-values,omitempty" yaml:"import-values"` + Alias string `json:"alias,omitempty" yaml:"alias"` +} + +func (r *Registry) FetchPackage(ctx context.Context, name string) (*core.Package, error) { + versions, err := r.fetchChart(ctx, name) + if err != nil { + return nil, err + } + + entry, latestVersion := packageEntry(versions) + packageName := entry.Name + if packageName == "" { + packageName = name + } + + return &core.Package{ + Name: packageName, + Description: entry.Description, + Homepage: entry.Home, + Repository: core.ExtractRepoURL(entry.Sources), + Keywords: entry.Keywords, + LatestVersion: latestVersion, + Metadata: packageMetadata(entry), + }, nil +} + +func (r *Registry) FetchVersions(ctx context.Context, name string) ([]core.Version, error) { + entries, err := r.fetchChart(ctx, name) + if err != nil { + return nil, err + } + + versions := make([]core.Version, 0, len(entries)) + for _, entry := range entries { + metadata := map[string]any{ + "urls": entry.resolvedURLs, + } + if len(entry.resolvedURLs) > 0 { + metadata["download_url"] = entry.resolvedURLs[0] + } + if len(entry.Dependencies) > 0 { + metadata["dependencies"] = entry.Dependencies + } + + versions = append(versions, core.Version{ + Number: entry.Version, + PublishedAt: entry.Created, + Integrity: formatIntegrity(entry.Digest), + Status: versionStatus(entry), + Metadata: metadata, + }) + } + + return versions, nil +} + +func (r *Registry) FetchDependencies(ctx context.Context, name, version string) ([]core.Dependency, error) { + entries, err := r.fetchChart(ctx, name) + if err != nil { + return nil, err + } + + entry := findVersion(entries, version) + if entry == nil { + return nil, &core.NotFoundError{Ecosystem: ecosystem, Name: name, Version: version} + } + + dependencies := make([]core.Dependency, len(entry.Dependencies)) + for i, dependency := range entry.Dependencies { + dependencies[i] = core.Dependency{ + Name: dependency.Name, + Requirements: dependency.Version, + Scope: core.Runtime, + } + } + + return dependencies, nil +} + +func (r *Registry) FetchMaintainers(ctx context.Context, name string) ([]core.Maintainer, error) { + versions, err := r.fetchChart(ctx, name) + if err != nil { + return nil, err + } + + entry, _ := packageEntry(versions) + maintainers := make([]core.Maintainer, len(entry.Maintainers)) + for i, maintainer := range entry.Maintainers { + maintainers[i] = core.Maintainer{ + Name: maintainer.Name, + Email: maintainer.Email, + URL: maintainer.URL, + } + } + + return maintainers, nil +} + +func (r *Registry) fetchChart(ctx context.Context, name string) ([]chartVersion, error) { + index, err := r.fetchIndex(ctx) + if err != nil { + return nil, err + } + + versions, ok := index.Entries[name] + if !ok || len(versions) == 0 { + return nil, &core.NotFoundError{Ecosystem: ecosystem, Name: name} + } + return versions, nil +} + +func (r *Registry) fetchIndex(ctx context.Context) (*indexFile, error) { + body, err := r.client.GetBody(ctx, r.indexURL) + if err != nil { + return nil, err + } + + var index indexFile + if err := yaml.Unmarshal(body, &index); err != nil { + return nil, fmt.Errorf("parsing Helm index: %w", err) + } + if index.APIVersion == "" { + return nil, fmt.Errorf("parsing Helm index: missing apiVersion") + } + if index.Entries == nil { + return nil, fmt.Errorf("parsing Helm index: missing entries") + } + + downloads := make(map[string]map[string]string, len(index.Entries)) + for name, versions := range index.Entries { + packageDownloads := make(map[string]string, len(versions)) + for i := range versions { + resolved, err := resolveChartURLs(r.indexURL, versions[i].URLs) + if err != nil { + return nil, fmt.Errorf("resolving Helm chart URLs for %s %s: %w", name, versions[i].Version, err) + } + versions[i].resolvedURLs = resolved + if len(resolved) > 0 { + if _, exists := packageDownloads[versions[i].Version]; !exists { + packageDownloads[versions[i].Version] = resolved[0] + } + } + } + index.Entries[name] = versions + downloads[name] = packageDownloads + } + r.urls.setDownloads(downloads) + + return &index, nil +} + +func buildIndexURL(baseURL string) string { + parsed, err := url.Parse(baseURL) + if err != nil { + return strings.TrimSuffix(baseURL, "/") + "/index.yaml" + } + if path.Base(parsed.Path) != "index.yaml" { + parsed.Path = strings.TrimSuffix(parsed.Path, "/") + "/index.yaml" + parsed.RawPath = "" + } + return parsed.String() +} + +func resolveChartURLs(indexURL string, chartURLs []string) ([]string, error) { + base, err := url.Parse(indexURL) + if err != nil { + return nil, err + } + + resolved := make([]string, 0, len(chartURLs)) + for _, chartURL := range chartURLs { + if chartURL == "" { + continue + } + reference, err := url.Parse(chartURL) + if err != nil { + continue + } + resolved = append(resolved, base.ResolveReference(reference).String()) + } + return resolved, nil +} + +func packageEntry(versions []chartVersion) (*chartVersion, string) { + var latest *chartVersion + for i := range versions { + entry := &versions[i] + if entry.Deprecated || entry.Removed || entry.Version == "" { + continue + } + if latest == nil || vers.Compare(entry.Version, latest.Version) > 0 { + latest = entry + } + } + if latest != nil { + return latest, latest.Version + } + return &versions[0], "" +} + +func packageMetadata(entry *chartVersion) map[string]any { + metadata := make(map[string]any) + if len(entry.Sources) > 0 { + metadata["sources"] = entry.Sources + } + if len(entry.Maintainers) > 0 { + metadata["maintainers"] = entry.Maintainers + } + if entry.Icon != "" { + metadata["icon"] = entry.Icon + } + if entry.APIVersion != "" { + metadata["apiVersion"] = entry.APIVersion + } + if entry.Condition != "" { + metadata["condition"] = entry.Condition + } + if entry.Tags != "" { + metadata["tags"] = entry.Tags + } + if entry.AppVersion != "" { + metadata["appVersion"] = entry.AppVersion + } + if entry.Deprecated { + metadata["deprecated"] = true + } + if len(entry.Annotations) > 0 { + metadata["annotations"] = entry.Annotations + } + if entry.KubeVersion != "" { + metadata["kubeVersion"] = entry.KubeVersion + } + if len(entry.Dependencies) > 0 { + metadata["dependencies"] = entry.Dependencies + } + if entry.Type != "" { + metadata["type"] = entry.Type + } + return metadata +} + +func findVersion(versions []chartVersion, version string) *chartVersion { + for i := range versions { + if versions[i].Version == version { + return &versions[i] + } + } + return nil +} + +func versionStatus(entry chartVersion) core.VersionStatus { + if entry.Removed { + return core.StatusYanked + } + if entry.Deprecated { + return core.StatusDeprecated + } + return core.StatusNone +} + +func formatIntegrity(digest string) string { + if value, ok := strings.CutPrefix(digest, "sha256:"); ok { + return "sha256-" + value + } + if decoded, err := hex.DecodeString(digest); err == nil && len(decoded) == sha256.Size { + return "sha256-" + digest + } + return digest +} + +type URLs struct { + indexURL string + mu sync.RWMutex + downloads map[string]map[string]string +} + +func (u *URLs) Registry(name, version string) string { + return u.indexURL +} + +func (u *URLs) Download(name, version string) string { + u.mu.RLock() + defer u.mu.RUnlock() + return u.downloads[name][version] +} + +func (u *URLs) Documentation(name, version string) string { + return "" +} + +func (u *URLs) PURL(name, version string) string { + if version != "" { + return fmt.Sprintf("pkg:helm/%s@%s", name, version) + } + return fmt.Sprintf("pkg:helm/%s", name) +} + +func (u *URLs) setDownloads(downloads map[string]map[string]string) { + u.mu.Lock() + defer u.mu.Unlock() + u.downloads = downloads +} diff --git a/internal/helm/helm_test.go b/internal/helm/helm_test.go new file mode 100644 index 0000000..35208c7 --- /dev/null +++ b/internal/helm/helm_test.go @@ -0,0 +1,421 @@ +package helm + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/git-pkgs/registries/client" + "github.com/git-pkgs/registries/internal/core" +) + +const testIndex = `apiVersion: v1 +generated: 2026-08-10T12:00:00Z +entries: + demo: + - name: demo + version: 3.0.0 + description: Deprecated demo chart + deprecated: true + checksum: old-checksum-is-not-a-digest + urls: + - charts/demo-3.0.0.tgz + created: 2026-08-03T12:00:00Z + - name: demo + version: 1.5.0 + description: Older demo chart + urls: + - charts/demo-1.5.0.tgz + created: 2026-08-01T12:00:00Z + digest: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + - name: demo + version: 2.5.0 + description: Removed demo chart + removed: true + urls: + - charts/demo-2.5.0.tgz + created: 2026-08-04T12:00:00Z + - name: demo + version: 2.0.0 + description: Active demo chart + home: https://example.com/demo + sources: + - https://github.com/example/demo.git + - https://gitlab.com/example/demo-mirror + keywords: + - demo + - kubernetes + maintainers: + - name: Example Maintainer + email: maintainer@example.com + url: https://example.com/maintainer + icon: https://example.com/demo.svg + apiVersion: v2 + condition: demo.enabled + tags: backend + appVersion: "12.3.0" + annotations: + example.com/channel: stable + kubeVersion: ">= 1.28.0" + dependencies: + - name: redis + version: "~17.0.0" + repository: https://charts.example.com/dependencies + alias: cache + condition: redis.enabled + tags: + - database + enabled: true + import-values: + - child: exports + parent: imports + - name: common + version: ">=2.0.0" + repository: file://../common + type: application + urls: + - charts/demo-2.0.0.tgz + - https://cdn.example.com/demo-2.0.0.tgz + - http://username:password@downloads.example.com/demo-2.0.0.tgz + created: 2026-08-02T12:34:56Z + digest: sha256:abcdef123456 + minimal: + - name: minimal + version: 0.1.0 +` + +func TestFetchPackage(t *testing.T) { + server := indexServer(t, "/repository/index.yaml", testIndex) + defer server.Close() + + registry := New(server.URL+"/repository", core.DefaultClient()) + pkg, err := registry.FetchPackage(context.Background(), "demo") + if err != nil { + t.Fatalf("FetchPackage failed: %v", err) + } + + if pkg.Name != "demo" { + t.Errorf("Name = %q, want demo", pkg.Name) + } + if pkg.Description != "Active demo chart" { + t.Errorf("Description = %q, want active chart description", pkg.Description) + } + if pkg.Homepage != "https://example.com/demo" { + t.Errorf("Homepage = %q, want https://example.com/demo", pkg.Homepage) + } + if pkg.Repository != "https://github.com/example/demo" { + t.Errorf("Repository = %q, want normalized source repository", pkg.Repository) + } + if pkg.LatestVersion != "2.0.0" { + t.Errorf("LatestVersion = %q, want 2.0.0", pkg.LatestVersion) + } + if len(pkg.Keywords) != 2 || pkg.Keywords[1] != "kubernetes" { + t.Errorf("Keywords = %v, want chart keywords", pkg.Keywords) + } + + if pkg.Metadata["appVersion"] != "12.3.0" { + t.Errorf("Metadata appVersion = %v, want 12.3.0", pkg.Metadata["appVersion"]) + } + if pkg.Metadata["apiVersion"] != "v2" { + t.Errorf("Metadata apiVersion = %v, want v2", pkg.Metadata["apiVersion"]) + } + if pkg.Metadata["kubeVersion"] != ">= 1.28.0" { + t.Errorf("Metadata kubeVersion = %v, want constraint", pkg.Metadata["kubeVersion"]) + } + if pkg.Metadata["type"] != "application" { + t.Errorf("Metadata type = %v, want application", pkg.Metadata["type"]) + } + annotations, ok := pkg.Metadata["annotations"].(map[string]string) + if !ok || annotations["example.com/channel"] != "stable" { + t.Errorf("Metadata annotations = %#v, want stable channel", pkg.Metadata["annotations"]) + } + sources, ok := pkg.Metadata["sources"].([]string) + if !ok || len(sources) != 2 { + t.Errorf("Metadata sources = %#v, want both source URLs", pkg.Metadata["sources"]) + } +} + +func TestFetchVersions(t *testing.T) { + server := indexServer(t, "/repository/index.yaml", testIndex) + defer server.Close() + + registry := New(server.URL+"/repository", core.DefaultClient()) + versions, err := registry.FetchVersions(context.Background(), "demo") + if err != nil { + t.Fatalf("FetchVersions failed: %v", err) + } + if len(versions) != 4 { + t.Fatalf("got %d versions, want 4", len(versions)) + } + + byNumber := make(map[string]core.Version, len(versions)) + for _, version := range versions { + byNumber[version.Number] = version + } + + if byNumber["3.0.0"].Status != core.StatusDeprecated { + t.Errorf("3.0.0 status = %q, want deprecated", byNumber["3.0.0"].Status) + } + if byNumber["3.0.0"].Integrity != "" { + t.Errorf("3.0.0 integrity = %q, deprecated checksum must be ignored", byNumber["3.0.0"].Integrity) + } + if byNumber["2.5.0"].Status != core.StatusYanked { + t.Errorf("2.5.0 status = %q, want yanked", byNumber["2.5.0"].Status) + } + wantBareDigest := "sha256-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + if byNumber["1.5.0"].Integrity != wantBareDigest { + t.Errorf("1.5.0 integrity = %q, want %q", byNumber["1.5.0"].Integrity, wantBareDigest) + } + + active := byNumber["2.0.0"] + if active.Integrity != "sha256-abcdef123456" { + t.Errorf("2.0.0 integrity = %q, want normalized digest", active.Integrity) + } + wantCreated := time.Date(2026, time.August, 2, 12, 34, 56, 0, time.UTC) + if !active.PublishedAt.Equal(wantCreated) { + t.Errorf("2.0.0 PublishedAt = %v, want %v", active.PublishedAt, wantCreated) + } + + urls, ok := active.Metadata["urls"].([]string) + if !ok { + t.Fatalf("urls metadata has type %T, want []string", active.Metadata["urls"]) + } + wantRelative := server.URL + "/repository/charts/demo-2.0.0.tgz" + if len(urls) != 3 || urls[0] != wantRelative || urls[1] != "https://cdn.example.com/demo-2.0.0.tgz" || urls[2] != "http://username:password@downloads.example.com/demo-2.0.0.tgz" { + t.Errorf("resolved URLs = %v, want relative and absolute artifact URLs", urls) + } + if got := registry.URLs().Download("demo", "2.0.0"); got != wantRelative { + t.Errorf("Download URL = %q, want %q", got, wantRelative) + } + + dependencies, ok := active.Metadata["dependencies"].([]dependencyInfo) + if !ok || len(dependencies) != 2 { + t.Fatalf("dependencies metadata = %#v, want two dependencies", active.Metadata["dependencies"]) + } + if dependencies[0].Repository != "https://charts.example.com/dependencies" || dependencies[0].Alias != "cache" { + t.Errorf("dependency metadata = %#v, want repository and alias", dependencies[0]) + } + if dependencies[0].Condition != "redis.enabled" || len(dependencies[0].Tags) != 1 || len(dependencies[0].ImportValues) != 1 { + t.Errorf("dependency metadata = %#v, want condition, tags, and import values", dependencies[0]) + } +} + +func TestFetchDependencies(t *testing.T) { + server := indexServer(t, "/index.yaml", testIndex) + defer server.Close() + + registry := New(server.URL, core.DefaultClient()) + dependencies, err := registry.FetchDependencies(context.Background(), "demo", "2.0.0") + if err != nil { + t.Fatalf("FetchDependencies failed: %v", err) + } + if len(dependencies) != 2 { + t.Fatalf("got %d dependencies, want 2", len(dependencies)) + } + if dependencies[0].Name != "redis" || dependencies[0].Requirements != "~17.0.0" { + t.Errorf("first dependency = %#v, want redis version constraint", dependencies[0]) + } + if dependencies[0].Scope != core.Runtime { + t.Errorf("first dependency scope = %q, want runtime", dependencies[0].Scope) + } +} + +func TestFetchMaintainers(t *testing.T) { + server := indexServer(t, "/index.yaml", testIndex) + defer server.Close() + + registry := New(server.URL, core.DefaultClient()) + maintainers, err := registry.FetchMaintainers(context.Background(), "demo") + if err != nil { + t.Fatalf("FetchMaintainers failed: %v", err) + } + if len(maintainers) != 1 { + t.Fatalf("got %d maintainers, want 1", len(maintainers)) + } + if maintainers[0].Name != "Example Maintainer" || maintainers[0].Email != "maintainer@example.com" || maintainers[0].URL != "https://example.com/maintainer" { + t.Errorf("maintainer = %#v, want name, email, and URL", maintainers[0]) + } +} + +func TestMissingOptionalFields(t *testing.T) { + server := indexServer(t, "/index.yaml", testIndex) + defer server.Close() + + registry := New(server.URL, core.DefaultClient()) + pkg, err := registry.FetchPackage(context.Background(), "minimal") + if err != nil { + t.Fatalf("FetchPackage failed: %v", err) + } + if pkg.Name != "minimal" || pkg.LatestVersion != "0.1.0" { + t.Errorf("package = %#v, want minimal chart", pkg) + } + + versions, err := registry.FetchVersions(context.Background(), "minimal") + if err != nil { + t.Fatalf("FetchVersions failed: %v", err) + } + if len(versions) != 1 || versions[0].Number != "0.1.0" { + t.Fatalf("versions = %#v, want minimal version", versions) + } + urls, ok := versions[0].Metadata["urls"].([]string) + if !ok || len(urls) != 0 { + t.Errorf("urls metadata = %#v, want empty []string", versions[0].Metadata["urls"]) + } +} + +func TestNotFoundLookups(t *testing.T) { + server := indexServer(t, "/index.yaml", testIndex) + defer server.Close() + + registry := New(server.URL, core.DefaultClient()) + _, err := registry.FetchPackage(context.Background(), "missing") + assertNotFound(t, err, "missing", "") + + _, err = registry.FetchDependencies(context.Background(), "demo", "9.9.9") + assertNotFound(t, err, "demo", "9.9.9") +} + +func TestMalformedIndex(t *testing.T) { + tests := []struct { + name string + index string + }{ + {"invalid YAML", "apiVersion: v1\nentries: ["}, + {"missing apiVersion", "entries: {}\n"}, + {"missing entries", "apiVersion: v1\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := indexServer(t, "/index.yaml", tt.index) + defer server.Close() + + registry := New(server.URL, core.DefaultClient()) + if _, err := registry.FetchVersions(context.Background(), "demo"); err == nil { + t.Fatal("FetchVersions succeeded with malformed index") + } + }) + } +} + +func TestExactIndexURL(t *testing.T) { + server := indexServer(t, "/custom/index.yaml", testIndex) + defer server.Close() + + registry := New(server.URL+"/custom/index.yaml", core.DefaultClient()) + if _, err := registry.FetchVersions(context.Background(), "minimal"); err != nil { + t.Fatalf("FetchVersions failed: %v", err) + } + if got := registry.URLs().Registry("minimal", ""); got != server.URL+"/custom/index.yaml" { + t.Errorf("Registry URL = %q, want exact index URL", got) + } +} + +func TestMalformedChartURLIsSkipped(t *testing.T) { + index := `apiVersion: v1 +entries: + demo: + - name: demo + version: 1.0.0 + urls: + - "http://[invalid" + - charts/demo-1.0.0.tgz +` + server := indexServer(t, "/index.yaml", index) + defer server.Close() + + registry := New(server.URL, core.DefaultClient()) + versions, err := registry.FetchVersions(context.Background(), "demo") + if err != nil { + t.Fatalf("FetchVersions failed: %v", err) + } + urls, ok := versions[0].Metadata["urls"].([]string) + want := server.URL + "/charts/demo-1.0.0.tgz" + if !ok || len(urls) != 1 || urls[0] != want { + t.Errorf("resolved URLs = %#v, want [%q]", versions[0].Metadata["urls"], want) + } + if got := registry.URLs().Download("demo", "1.0.0"); got != want { + t.Errorf("Download URL = %q, want %q", got, want) + } +} + +func TestCustomAuthenticationClient(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer chart-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(testIndex)) + })) + defer server.Close() + + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + request = request.Clone(request.Context()) + request.Header.Set("Authorization", "Bearer chart-token") + return http.DefaultTransport.RoundTrip(request) + }) + customClient := client.NewClient(client.WithTransport(transport)) + registry := New(server.URL, customClient) + if _, err := registry.FetchPackage(context.Background(), "demo"); err != nil { + t.Fatalf("FetchPackage with custom authentication failed: %v", err) + } +} + +func TestURLBuilder(t *testing.T) { + registry := New("https://charts.example.com/repository", nil) + urls := registry.URLs() + + if got := urls.Registry("demo", "2.0.0"); got != "https://charts.example.com/repository/index.yaml" { + t.Errorf("Registry URL = %q, want index URL", got) + } + if got := urls.Download("demo", "2.0.0"); got != "" { + t.Errorf("Download URL before fetching index = %q, want empty", got) + } + if got := urls.Documentation("demo", "2.0.0"); got != "" { + t.Errorf("Documentation URL = %q, want empty", got) + } + if got := urls.PURL("demo", "2.0.0"); got != "pkg:helm/demo@2.0.0" { + t.Errorf("version PURL = %q, want pkg:helm/demo@2.0.0", got) + } + if got := urls.PURL("demo", ""); got != "pkg:helm/demo" { + t.Errorf("package PURL = %q, want pkg:helm/demo", got) + } +} + +func TestEcosystem(t *testing.T) { + if got := New("https://charts.example.com", nil).Ecosystem(); got != ecosystem { + t.Errorf("Ecosystem = %q, want %q", got, ecosystem) + } +} + +func indexServer(t *testing.T, expectedPath, index string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != expectedPath { + t.Errorf("request path = %q, want %q", r.URL.Path, expectedPath) + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(index)) + })) +} + +func assertNotFound(t *testing.T, err error, name, version string) { + t.Helper() + var notFound *core.NotFoundError + if !errors.As(err, ¬Found) { + t.Fatalf("error = %v, want NotFoundError", err) + } + if notFound.Ecosystem != ecosystem || notFound.Name != name || notFound.Version != version { + t.Errorf("NotFoundError = %#v, want ecosystem %q, name %q, version %q", notFound, ecosystem, name, version) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} diff --git a/registries_test.go b/registries_test.go index bcc0a1e..18033f0 100644 --- a/registries_test.go +++ b/registries_test.go @@ -15,7 +15,7 @@ import ( func TestSupportedEcosystems(t *testing.T) { ecosystems := registries.SupportedEcosystems() - expected := []string{"brew", "cargo", "clojars", "cocoapods", "composer", "conda", "cpan", "cran", "deno", "dub", "elm", "gem", "golang", "hackage", "haxelib", "hex", "julia", "luarocks", "maven", "nimble", "npm", "nuget", "pub", "pypi", "terraform"} + expected := []string{"brew", "cargo", "clojars", "cocoapods", "composer", "conda", "cpan", "cran", "deno", "dub", "elm", "gem", "golang", "hackage", "haxelib", "helm", "hex", "julia", "luarocks", "maven", "nimble", "npm", "nuget", "pub", "pypi", "terraform"} sort.Strings(ecosystems) if len(ecosystems) != len(expected) { @@ -59,6 +59,7 @@ func TestNew(t *testing.T) { {"haxelib", false}, {"deno", false}, {"terraform", false}, + {"helm", true}, {"unknown", true}, } @@ -102,6 +103,7 @@ func TestDefaultURL(t *testing.T) { {"haxelib", "https://lib.haxe.org"}, {"deno", "https://apiland.deno.dev"}, {"terraform", "https://registry.terraform.io"}, + {"helm", ""}, } for _, tt := range tests { @@ -114,6 +116,21 @@ func TestDefaultURL(t *testing.T) { } } +func TestNewWithRequiredCustomURL(t *testing.T) { + reg, err := registries.New("helm", "https://charts.example.com", nil) + if err != nil { + t.Fatalf("New failed: %v", err) + } + if reg.Ecosystem() != "helm" { + t.Errorf("Ecosystem = %q, want helm", reg.Ecosystem()) + } + + _, err = registries.New("helm", "", nil) + if err == nil || err.Error() != "no registry URL configured for ecosystem: helm" { + t.Errorf("New without URL error = %v, want clear missing URL error", err) + } +} + func TestIntegration(t *testing.T) { // Test with a mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {