From 29c7852a54a15dc56b7ac3a70ed8fba59eed328f Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 16 Aug 2026 22:17:51 +0100 Subject: [PATCH] Add Helm Chart.yaml and Chart.lock support --- README.md | 5 + imports.go | 1 + internal/core/types.go | 5 +- internal/helm/helm.go | 71 ++++++++++++ internal/helm/helm_test.go | 193 +++++++++++++++++++++++++++++++ manifests.go | 14 ++- manifests_test.go | 88 +++++++++++++- testdata/helm/Chart.lock | 21 ++++ testdata/helm/Chart.yaml | 27 +++++ testdata/helm/minimal/Chart.yaml | 3 + 10 files changed, 424 insertions(+), 4 deletions(-) create mode 100644 internal/helm/helm.go create mode 100644 internal/helm/helm_test.go create mode 100644 testdata/helm/Chart.lock create mode 100644 testdata/helm/Chart.yaml create mode 100644 testdata/helm/minimal/Chart.yaml diff --git a/README.md b/README.md index 23c8021..056cb88 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ func main() { | guix | manifest.scm | | | hackage | *.cabal | stack.yaml.lock, cabal.config, cabal.project.freeze | | haxelib | haxelib.json | | +| helm | Chart.yaml | Chart.lock | | hex | mix.exs, gleam.toml | mix.lock, rebar.lock | | julia | Project.toml, REQUIRE | Manifest.toml | | lean | lakefile.toml, lakefile.lean | lake-manifest.json | @@ -96,6 +97,7 @@ func main() { | deno.lock | | ✓ | | | | Gemfile.lock | ✓ | ✓ | | ✓ | | Cargo.lock | ✓ | ✓ | | | +| Chart.lock | ✓ | | | ✓ | | poetry.lock | ✓ | ✓ | ✓ | | | Pipfile.lock | ✓ | ✓ | ✓ | | | pdm.lock | | ✓ | ✓ | | @@ -256,6 +258,7 @@ type ParseResult struct { Version string // the package's own version, when declared Licenses []string // raw declared license values LicenseFile string // manifest-relative path to a declared license file + Digest string // file-level verification value, when present Dependencies []Dependency Declarations []Declaration } @@ -265,6 +268,8 @@ type ParseResult struct { `Licenses` contains decoded values as declared by the manifest; it does not normalize them into SPDX expressions. `LicenseFile` is populated when a format explicitly identifies a license file. Both are empty for formats without license metadata. +`Digest` contains a file-level verification value when the format defines one. For `Chart.lock`, it covers the dependency declarations from `Chart.yaml` and is separate from each dependency's `Integrity` value. + ### Vendor Discovery ```go diff --git a/imports.go b/imports.go index e4efe63..41669f4 100644 --- a/imports.go +++ b/imports.go @@ -28,6 +28,7 @@ import ( _ "github.com/git-pkgs/manifests/internal/guix" _ "github.com/git-pkgs/manifests/internal/hackage" _ "github.com/git-pkgs/manifests/internal/haxelib" + _ "github.com/git-pkgs/manifests/internal/helm" _ "github.com/git-pkgs/manifests/internal/hex" _ "github.com/git-pkgs/manifests/internal/ips" _ "github.com/git-pkgs/manifests/internal/julia" diff --git a/internal/core/types.go b/internal/core/types.go index 075ab79..a369fe2 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -59,7 +59,10 @@ type Result struct { // normalization. Licenses []string // LicenseFile is a manifest-relative path to a declared license file. - LicenseFile string + LicenseFile string + // Digest is a file-level verification value whose meaning is defined by + // the manifest format. It does not apply to individual dependencies. + Digest string Dependencies []Dependency Declarations []Declaration } diff --git a/internal/helm/helm.go b/internal/helm/helm.go new file mode 100644 index 0000000..acde6ac --- /dev/null +++ b/internal/helm/helm.go @@ -0,0 +1,71 @@ +package helm + +import ( + "github.com/git-pkgs/manifests/internal/core" + "gopkg.in/yaml.v3" +) + +func init() { + core.Register("helm", core.Manifest, &chartParser{}, core.ExactMatch("Chart.yaml")) + core.Register("helm", core.Lockfile, &chartLockParser{}, core.ExactMatch("Chart.lock")) +} + +type chartParser struct{} + +type chartMetadata struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Dependencies []chartDependency `yaml:"dependencies"` +} + +type chartDependency struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Repository string `yaml:"repository"` +} + +func (p *chartParser) Parse(filename string, content []byte) (*core.Result, error) { + var chart chartMetadata + if err := yaml.Unmarshal(content, &chart); err != nil { + return nil, &core.ParseError{Filename: filename, Err: err} + } + + return &core.Result{ + Name: chart.Name, + Version: chart.Version, + Dependencies: helmDependencies(chart.Dependencies), + }, nil +} + +type chartLockParser struct{} + +type chartLock struct { + Dependencies []chartDependency `yaml:"dependencies"` + Digest string `yaml:"digest"` +} + +func (p *chartLockParser) Parse(filename string, content []byte) (*core.Result, error) { + var lock chartLock + if err := yaml.Unmarshal(content, &lock); err != nil { + return nil, &core.ParseError{Filename: filename, Err: err} + } + + return &core.Result{ + Digest: lock.Digest, + Dependencies: helmDependencies(lock.Dependencies), + }, nil +} + +func helmDependencies(entries []chartDependency) []core.Dependency { + dependencies := make([]core.Dependency, 0, len(entries)) + for _, entry := range entries { + dependencies = append(dependencies, core.Dependency{ + Name: entry.Name, + Version: entry.Version, + Scope: core.Runtime, + Direct: true, + RegistryURL: entry.Repository, + }) + } + return dependencies +} diff --git a/internal/helm/helm_test.go b/internal/helm/helm_test.go new file mode 100644 index 0000000..6351b8d --- /dev/null +++ b/internal/helm/helm_test.go @@ -0,0 +1,193 @@ +package helm + +import ( + "errors" + "os" + "reflect" + "strings" + "testing" + + "github.com/git-pkgs/manifests/internal/core" +) + +func TestChart(t *testing.T) { + content, err := os.ReadFile("../../testdata/helm/Chart.yaml") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + + result, err := (&chartParser{}).Parse("Chart.yaml", content) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if result.Name != "example-chart" { + t.Errorf("Name = %q, want %q", result.Name, "example-chart") + } + if result.Version != "1.2.3" { + t.Errorf("Version = %q, want %q", result.Version, "1.2.3") + } + + want := map[string]struct { + version string + repository string + }{ + "postgresql": {"~12.1.9", "https://charts.bitnami.com/bitnami"}, + "redis": {"^17.3.0", "oci://registry-1.docker.io/bitnamicharts"}, + "metrics-server": {">=3.8.0 <4.0.0", "@internal"}, + "common": {"1.x.x", "alias:partner"}, + "local-chart": {"0.1.0", "file://../local-chart"}, + "plugin-chart": {"2.0.0", "s3://company-charts"}, + } + if len(result.Dependencies) != len(want) { + t.Fatalf("Dependencies has %d entries, want %d", len(result.Dependencies), len(want)) + } + seen := make(map[string]bool, len(result.Dependencies)) + for _, dependency := range result.Dependencies { + expected, ok := want[dependency.Name] + if !ok { + t.Errorf("unexpected dependency: %+v", dependency) + continue + } + seen[dependency.Name] = true + if dependency.Version != expected.version { + t.Errorf("%s Version = %q, want %q", dependency.Name, dependency.Version, expected.version) + } + if dependency.RegistryURL != expected.repository { + t.Errorf("%s RegistryURL = %q, want %q", dependency.Name, dependency.RegistryURL, expected.repository) + } + if dependency.Scope != core.Runtime || !dependency.Direct { + t.Errorf("unexpected dependency metadata: %+v", dependency) + } + } + for name := range want { + if !seen[name] { + t.Errorf("missing dependency %q", name) + } + } +} + +func TestChartWithoutDependencies(t *testing.T) { + content, err := os.ReadFile("../../testdata/helm/minimal/Chart.yaml") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + + result, err := (&chartParser{}).Parse("Chart.yaml", content) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if result.Name != "minimal" || result.Version != "0.1.0" { + t.Errorf("package identity = %q %q, want minimal 0.1.0", result.Name, result.Version) + } + if len(result.Dependencies) != 0 { + t.Errorf("Dependencies = %+v, want none", result.Dependencies) + } +} + +func TestChartLock(t *testing.T) { + content, err := os.ReadFile("../../testdata/helm/Chart.lock") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + + result, err := (&chartLockParser{}).Parse("Chart.lock", content) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if result.Digest != "sha256:8ca45f73ae3f6170a09b64a967006e98e13cd91eb51e5ab0599bb87296c7df0a" { + t.Errorf("Digest = %q", result.Digest) + } + + want := map[string]struct { + version string + repository string + }{ + "postgresql": {"12.1.15", "https://charts.bitnami.com/bitnami"}, + "redis": {"17.3.7", "oci://registry-1.docker.io/bitnamicharts"}, + "metrics-server": {"3.12.2", "@internal"}, + "common": {"1.17.1", "alias:partner"}, + "local-chart": {"0.1.0", "file://../local-chart"}, + "plugin-chart": {"2.0.0", "s3://company-charts"}, + } + if len(result.Dependencies) != len(want) { + t.Fatalf("Dependencies has %d entries, want %d", len(result.Dependencies), len(want)) + } + seen := make(map[string]bool, len(result.Dependencies)) + for _, dependency := range result.Dependencies { + expected, ok := want[dependency.Name] + if !ok { + t.Errorf("unexpected dependency: %+v", dependency) + continue + } + seen[dependency.Name] = true + if dependency.Version != expected.version || dependency.RegistryURL != expected.repository { + t.Errorf("unexpected dependency: %+v", dependency) + } + if dependency.Scope != core.Runtime || !dependency.Direct || dependency.Integrity != "" { + t.Errorf("unexpected dependency metadata: %+v", dependency) + } + } + for name := range want { + if !seen[name] { + t.Errorf("missing dependency %q", name) + } + } + + changedGenerated := strings.Replace( + string(content), + `generated: "2021-05-02T15:07:22.1099921+02:00"`, + `generated: "2026-08-16T09:00:00Z"`, + 1, + ) + if changedGenerated == string(content) { + t.Fatal("generated timestamp was not replaced") + } + changedResult, err := (&chartLockParser{}).Parse("Chart.lock", []byte(changedGenerated)) + if err != nil { + t.Fatalf("Parse with changed generated timestamp: %v", err) + } + if !reflect.DeepEqual(changedResult, result) { + t.Errorf("generated timestamp changed result:\n got %+v\nwant %+v", changedResult, result) + } +} + +func TestChartLockMissingOptionalFields(t *testing.T) { + content := []byte("dependencies:\n- name: bundled\n version: 1.2.3\n") + result, err := (&chartLockParser{}).Parse("Chart.lock", content) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if result.Digest != "" { + t.Errorf("Digest = %q, want empty", result.Digest) + } + if len(result.Dependencies) != 1 { + t.Fatalf("Dependencies has %d entries, want 1", len(result.Dependencies)) + } + dependency := result.Dependencies[0] + if dependency.Name != "bundled" || dependency.Version != "1.2.3" || dependency.RegistryURL != "" { + t.Errorf("unexpected dependency: %+v", dependency) + } +} + +func TestMalformedYAML(t *testing.T) { + tests := []struct { + name string + filename string + parser core.Parser + }{ + {name: "chart", filename: "Chart.yaml", parser: &chartParser{}}, + {name: "lock", filename: "Chart.lock", parser: &chartLockParser{}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := test.parser.Parse(test.filename, []byte("dependencies: [")) + if err == nil { + t.Fatal("Parse returned nil error") + } + var parseError *core.ParseError + if !errors.As(err, &parseError) { + t.Errorf("error = %T, want *core.ParseError", err) + } + }) + } +} diff --git a/manifests.go b/manifests.go index 44ac56c..5adfe1e 100644 --- a/manifests.go +++ b/manifests.go @@ -65,7 +65,10 @@ type ParseResult struct { Licenses []string // LicenseFile is a manifest-relative path to a license file when the // format declares one instead of, or as well as, an expression. - LicenseFile string + LicenseFile string + // Digest is a file-level verification value whose meaning is defined by + // the manifest format. It does not apply to individual dependencies. + Digest string Dependencies []Dependency // Declarations holds source-level references when the parser preserves // their logical locations. Unlike Dependencies, these entries are not @@ -116,7 +119,13 @@ func Parse(filename string, content []byte, opts ...Options) (*ParseResult, erro if kind == Lockfile || kind == Supplement { version = res.Dependencies[i].Version } - res.Dependencies[i].PURL = makePURL(eco, res.Dependencies[i].Name, version, res.Dependencies[i].RegistryURL) + registryURL := res.Dependencies[i].RegistryURL + if eco == "helm" { + // Helm repositories stay in RegistryURL. The pkg:helm mapping does + // not define repository_url as a qualifier. + registryURL = "" + } + res.Dependencies[i].PURL = makePURL(eco, res.Dependencies[i].Name, version, registryURL) } for i := range res.Declarations { res.Declarations[i].PURL = makePURL(eco, res.Declarations[i].Name, "", "") @@ -129,6 +138,7 @@ func Parse(filename string, content []byte, opts ...Options) (*ParseResult, erro Version: res.Version, Licenses: res.Licenses, LicenseFile: res.LicenseFile, + Digest: res.Digest, Dependencies: res.Dependencies, Declarations: res.Declarations, }, nil diff --git a/manifests_test.go b/manifests_test.go index 4df0919..241d828 100644 --- a/manifests_test.go +++ b/manifests_test.go @@ -30,6 +30,8 @@ func TestParseAllEcosystems(t *testing.T) { {"maven pom.xml", "testdata/maven/pom.xml", "maven", Manifest}, {"composer composer.json", "testdata/composer/composer.json", "composer", Manifest}, {"composer composer.lock", "testdata/composer/composer.lock", "composer", Lockfile}, + {"helm Chart.yaml", "testdata/helm/Chart.yaml", "helm", Manifest}, + {"helm Chart.lock", "testdata/helm/Chart.lock", "helm", Lockfile}, } for _, tc := range testCases { @@ -73,7 +75,7 @@ func TestEcosystems(t *testing.T) { seen[e] = true } - for _, want := range []string{"npm", "gem", "cargo", "golang", "pypi", "maven"} { + for _, want := range []string{"npm", "gem", "cargo", "golang", "pypi", "maven", "helm"} { if !slices.Contains(got, want) { t.Errorf("Ecosystems() missing %q", want) } @@ -446,6 +448,10 @@ func TestIdentifyFiles(t *testing.T) { {".github/workflows/ci.yml", "github-actions", Manifest, true}, {".github/workflows/actions.lock", "github-actions", Lockfile, true}, + // helm + {"Chart.yaml", "helm", Manifest, true}, + {"Chart.lock", "helm", Lockfile, true}, + // unknown {"unknown.txt", "", "", false}, {"random.file", "", "", false}, @@ -547,6 +553,86 @@ func TestPURL(t *testing.T) { t.Error("express dependency not found") } +func TestHelmPURLsAndDigest(t *testing.T) { + chartContent, err := os.ReadFile("testdata/helm/Chart.yaml") + if err != nil { + t.Fatalf("read chart fixture: %v", err) + } + chart, err := Parse("Chart.yaml", chartContent) + if err != nil { + t.Fatalf("parse Chart.yaml: %v", err) + } + if chart.Name != "example-chart" || chart.Version != "1.2.3" { + t.Errorf("chart identity = %q %q, want example-chart 1.2.3", chart.Name, chart.Version) + } + + chartDependencies := make(map[string]Dependency, len(chart.Dependencies)) + for _, dependency := range chart.Dependencies { + chartDependencies[dependency.Name] = dependency + } + wantChartPURLs := map[string]string{ + "postgresql": "pkg:helm/postgresql", + "redis": "pkg:helm/redis", + "metrics-server": "pkg:helm/metrics-server", + "common": "pkg:helm/common", + "local-chart": "pkg:helm/local-chart", + "plugin-chart": "pkg:helm/plugin-chart", + } + for name, want := range wantChartPURLs { + dependency, ok := chartDependencies[name] + if !ok { + t.Errorf("Chart.yaml missing dependency %q", name) + continue + } + if dependency.PURL != want { + t.Errorf("Chart.yaml %s PURL = %q, want %q", name, dependency.PURL, want) + } + } + postgresql := chartDependencies["postgresql"] + if postgresql.RegistryURL != "https://charts.bitnami.com/bitnami" { + t.Errorf("Chart.yaml postgresql RegistryURL = %q", postgresql.RegistryURL) + } + + lockContent, err := os.ReadFile("testdata/helm/Chart.lock") + if err != nil { + t.Fatalf("read lock fixture: %v", err) + } + lock, err := Parse("Chart.lock", lockContent) + if err != nil { + t.Fatalf("parse Chart.lock: %v", err) + } + if lock.Digest != "sha256:8ca45f73ae3f6170a09b64a967006e98e13cd91eb51e5ab0599bb87296c7df0a" { + t.Errorf("Chart.lock Digest = %q", lock.Digest) + } + + lockDependencies := make(map[string]Dependency, len(lock.Dependencies)) + for _, dependency := range lock.Dependencies { + lockDependencies[dependency.Name] = dependency + } + wantLockPURLs := map[string]string{ + "postgresql": "pkg:helm/postgresql@12.1.15", + "redis": "pkg:helm/redis@17.3.7", + "metrics-server": "pkg:helm/metrics-server@3.12.2", + "common": "pkg:helm/common@1.17.1", + "local-chart": "pkg:helm/local-chart@0.1.0", + "plugin-chart": "pkg:helm/plugin-chart@2.0.0", + } + for name, want := range wantLockPURLs { + dependency, ok := lockDependencies[name] + if !ok { + t.Errorf("Chart.lock missing dependency %q", name) + continue + } + if dependency.PURL != want { + t.Errorf("Chart.lock %s PURL = %q, want %q", name, dependency.PURL, want) + } + } + postgresql = lockDependencies["postgresql"] + if postgresql.Integrity != "" { + t.Errorf("Chart.lock postgresql Integrity = %q, want empty", postgresql.Integrity) + } +} + func TestParsePEP508ParenthesizedRequirements(t *testing.T) { content, err := os.ReadFile("testdata/pypi/pep508-parenthesized/pyproject.toml") if err != nil { diff --git a/testdata/helm/Chart.lock b/testdata/helm/Chart.lock new file mode 100644 index 0000000..c6485e2 --- /dev/null +++ b/testdata/helm/Chart.lock @@ -0,0 +1,21 @@ +dependencies: + - name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 12.1.15 + - name: redis + repository: oci://registry-1.docker.io/bitnamicharts + version: 17.3.7 + - name: metrics-server + repository: "@internal" + version: 3.12.2 + - name: common + repository: alias:partner + version: 1.17.1 + - name: local-chart + repository: file://../local-chart + version: 0.1.0 + - name: plugin-chart + repository: s3://company-charts + version: 2.0.0 +digest: sha256:8ca45f73ae3f6170a09b64a967006e98e13cd91eb51e5ab0599bb87296c7df0a +generated: "2021-05-02T15:07:22.1099921+02:00" diff --git a/testdata/helm/Chart.yaml b/testdata/helm/Chart.yaml new file mode 100644 index 0000000..2d379c3 --- /dev/null +++ b/testdata/helm/Chart.yaml @@ -0,0 +1,27 @@ +apiVersion: v2 +name: example-chart +version: 1.2.3 +dependencies: + - name: postgresql + alias: database + version: ~12.1.9 + repository: https://charts.bitnami.com/bitnami + condition: database.enabled + tags: + - database + - production + - name: redis + version: ^17.3.0 + repository: oci://registry-1.docker.io/bitnamicharts + - name: metrics-server + version: ">=3.8.0 <4.0.0" + repository: "@internal" + - name: common + version: 1.x.x + repository: alias:partner + - name: local-chart + version: 0.1.0 + repository: file://../local-chart + - name: plugin-chart + version: 2.0.0 + repository: s3://company-charts diff --git a/testdata/helm/minimal/Chart.yaml b/testdata/helm/minimal/Chart.yaml new file mode 100644 index 0000000..84b28f5 --- /dev/null +++ b/testdata/helm/minimal/Chart.yaml @@ -0,0 +1,3 @@ +apiVersion: v2 +name: minimal +version: 0.1.0