Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -96,6 +97,7 @@ func main() {
| deno.lock | | ✓ | | |
| Gemfile.lock | ✓ | ✓ | | ✓ |
| Cargo.lock | ✓ | ✓ | | |
| Chart.lock | ✓ | | | ✓ |
| poetry.lock | ✓ | ✓ | ✓ | |
| Pipfile.lock | ✓ | ✓ | ✓ | |
| pdm.lock | | ✓ | ✓ | |
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion internal/core/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
71 changes: 71 additions & 0 deletions internal/helm/helm.go
Original file line number Diff line number Diff line change
@@ -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
}
193 changes: 193 additions & 0 deletions internal/helm/helm_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
14 changes: 12 additions & 2 deletions manifests.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, "", "")
Expand All @@ -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
Expand Down
Loading