From 260fda33cea2ac69e49bb4ff97927fa24b4162c3 Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Sat, 29 Aug 2026 17:48:14 +0530 Subject: [PATCH 1/2] Add Chef metadata and Berksfile support --- README.md | 24 ++ benchmark_test.go | 3 + imports.go | 1 + internal/chef/chef.go | 475 ++++++++++++++++++++++++++++++++++++ internal/chef/chef_test.go | 291 ++++++++++++++++++++++ internal/core/types.go | 28 +++ manifests.go | 24 +- manifests_test.go | 56 ++++- testdata/chef/Berksfile | 12 + testdata/chef/metadata.json | 10 + testdata/chef/metadata.rb | 7 + 11 files changed, 928 insertions(+), 3 deletions(-) create mode 100644 internal/chef/chef.go create mode 100644 internal/chef/chef_test.go create mode 100644 testdata/chef/Berksfile create mode 100644 testdata/chef/metadata.json create mode 100644 testdata/chef/metadata.rb diff --git a/README.md b/README.md index b02fdc1..86b2bdb 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ func main() { | brew | Brewfile | Brewfile.lock.json | | cargo | Cargo.toml | Cargo.lock | | carthage | Cartfile, Cartfile.private | Cartfile.resolved | +| chef | metadata.rb, metadata.json, Berksfile | | | clojars | project.clj | | | cocoapods | Podfile, *.podspec | Podfile.lock | | composer | composer.json | composer.lock | @@ -235,6 +236,7 @@ type Dependency struct { Direct bool // True if declared directly, false if transitive PURL string // Package URL (pkg:ecosystem/name@version) RegistryURL string // Source registry URL (if non-default) + Source Source // Explicit Git, path, or ecosystem source override } ``` @@ -257,6 +259,7 @@ type Declaration struct { Direct bool // Direct rather than generated or transitive PURL string // Versionless Package URL Location string // Opaque parser-defined identity within the manifest + Source Source // Explicit source override as written } ``` @@ -272,6 +275,23 @@ ecosystem. `Direct` distinguishes explicit requirements from generated or transitive entries when the source format records that distinction, such as `go.mod`. +### Source + +```go +type Source struct { + Kind SourceKind // registry, git, path, or github + Value string // Literal URL, path, or ecosystem coordinate +} +``` + +`Source` records source syntax without claiming that dependency resolution +used that location. Manifest-level source configuration is preserved in +`ParseResult.Sources` in declaration order. An explicit dependency override is +stored on both its `Dependency` and `Declaration`; dependencies with no +override have an empty `Source`. `RegistryURL` remains reserved for resolved or +otherwise attributable package registries and is not used for Git repositories +or local paths. + Parsers that do not preserve source locations leave `Declarations` empty. Declarations are available for `package.json`, Cargo manifests, `go.mod`, Python requirements files, `pyproject.toml`, GitHub Actions workflows, `gleam.toml`, @@ -292,6 +312,7 @@ type ParseResult struct { LicenseFile string // manifest-relative path to a declared license file Dependencies []Dependency Declarations []Declaration + Sources []Source // Ordered manifest-level source declarations } ``` @@ -299,6 +320,9 @@ 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. +Chef cookbook PURLs remain empty while `chef` is only a candidate Package URL +type without accepted name and namespace rules. + ### Vendor Discovery ```go diff --git a/benchmark_test.go b/benchmark_test.go index 727cd5d..4060d81 100644 --- a/benchmark_test.go +++ b/benchmark_test.go @@ -21,6 +21,7 @@ var benchmarkFixtures = map[string][]string{ "testdata/npm/deno.lock", "testdata/npm/bun.lock", "testdata/vcpkg/vcpkg.json", + "testdata/chef/metadata.json", }, // YAML parsers "yaml": { @@ -60,6 +61,8 @@ var benchmarkFixtures = map[string][]string{ "testdata/maven/build.gradle", "testdata/hackage/example.cabal", "testdata/cpan/cpanfile", + "testdata/chef/metadata.rb", + "testdata/chef/Berksfile", }, } diff --git a/imports.go b/imports.go index e4efe63..c851dfb 100644 --- a/imports.go +++ b/imports.go @@ -9,6 +9,7 @@ import ( _ "github.com/git-pkgs/manifests/internal/brew" _ "github.com/git-pkgs/manifests/internal/cargo" _ "github.com/git-pkgs/manifests/internal/carthage" + _ "github.com/git-pkgs/manifests/internal/chef" _ "github.com/git-pkgs/manifests/internal/clojure" _ "github.com/git-pkgs/manifests/internal/cocoapods" _ "github.com/git-pkgs/manifests/internal/composer" diff --git a/internal/chef/chef.go b/internal/chef/chef.go new file mode 100644 index 0000000..58faa32 --- /dev/null +++ b/internal/chef/chef.go @@ -0,0 +1,475 @@ +// Package chef parses Chef cookbook manifests without evaluating Ruby. +package chef + +import ( + "encoding/json" + "net/url" + "sort" + "strings" + + "github.com/git-pkgs/manifests/internal/core" +) + +func init() { + core.Register("chef", core.Manifest, &metadataRubyParser{}, core.ExactMatch("metadata.rb")) + core.Register("chef", core.Manifest, &metadataJSONParser{}, core.ExactMatch("metadata.json")) + core.Register("chef", core.Manifest, &berksfileParser{}, core.ExactMatch("Berksfile")) +} + +type metadataRubyParser struct{} + +func (p *metadataRubyParser) Parse(_ string, content []byte) (*core.Result, error) { + result := &core.Result{} + locations := make(map[string]int) + for _, statement := range rubyStatements(content) { + call, ok := parseRubyCall(statement) + if !ok { + continue + } + switch call.name { + case "name": + if call.hasExactPositionalCount(1) && call.positional[0] != "" { + result.Name = call.positional[0] + } + case "version": + if call.hasExactPositionalCount(1) && call.positional[0] != "" { + result.Version = call.positional[0] + } + case "license": + if call.hasExactPositionalCount(1) && call.positional[0] != "" { + result.Licenses = []string{call.positional[0]} + } + case "depends": + if len(call.positional) == 0 || call.positional[0] == "" || len(call.keywords) != 0 { + continue + } + appendChefDependency(result, locations, "depends", call.positional, core.Source{}) + } + } + return result, nil +} + +type metadataJSONParser struct{} + +type metadataJSONDocument struct { + Name json.RawMessage `json:"name"` + Version json.RawMessage `json:"version"` + License json.RawMessage `json:"license"` + Dependencies map[string]json.RawMessage `json:"dependencies"` +} + +func (p *metadataJSONParser) Parse(filename string, content []byte) (*core.Result, error) { + var document metadataJSONDocument + if err := json.Unmarshal(content, &document); err != nil { + return nil, &core.ParseError{Filename: filename, Err: err} + } + + result := &core.Result{} + result.Name, _ = decodeJSONString(document.Name) + result.Version, _ = decodeJSONString(document.Version) + if license, ok := decodeJSONString(document.License); ok && license != "" { + result.Licenses = []string{license} + } + + names := make([]string, 0, len(document.Dependencies)) + for name := range document.Dependencies { + names = append(names, name) + } + sort.Strings(names) + locations := make(map[string]int) + for _, name := range names { + if name == "" { + continue + } + constraints, ok := decodeJSONConstraints(document.Dependencies[name]) + if !ok { + continue + } + appendChefDependency(result, locations, "dependencies", append([]string{name}, constraints...), core.Source{}) + } + return result, nil +} + +func decodeJSONString(raw json.RawMessage) (string, bool) { + if len(raw) == 0 { + return "", false + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", false + } + return value, true +} + +func decodeJSONConstraints(raw json.RawMessage) ([]string, bool) { + if value, ok := decodeJSONString(raw); ok { + return []string{value}, true + } + var values []string + if len(raw) == 0 || json.Unmarshal(raw, &values) != nil { + return nil, false + } + return values, true +} + +type berksfileParser struct{} + +func (p *berksfileParser) Parse(_ string, content []byte) (*core.Result, error) { + result := &core.Result{} + locations := make(map[string]int) + for _, statement := range rubyStatements(content) { + call, ok := parseRubyCall(statement) + if !ok { + continue + } + switch call.name { + case "source": + if len(call.positional) != 1 || call.positional[0] == "" { + continue + } + result.Sources = append(result.Sources, core.Source{ + Kind: core.SourceRegistry, + Value: call.positional[0], + }) + case "cookbook": + if len(call.positional) == 0 || len(call.positional) > 2 || call.positional[0] == "" { + continue + } + source, ok := cookbookSource(call.keywords) + if !ok { + continue + } + appendChefDependency(result, locations, "cookbooks", call.positional, source) + case "metadata": + // Deliberately ignored. Evaluating this directive would read an + // adjacent cookbook and make Parse impure. + } + } + return result, nil +} + +func cookbookSource(keywords map[string]string) (core.Source, bool) { + type sourceKey struct { + name string + kind core.SourceKind + } + keys := []sourceKey{ + {name: "git", kind: core.SourceGit}, + {name: "path", kind: core.SourcePath}, + {name: "github", kind: core.SourceGitHub}, + } + var source core.Source + selected := "" + for _, key := range keys { + value, ok := keywords[key.name] + if !ok { + continue + } + if selected != "" || value == "" { + return core.Source{}, false + } + selected = key.name + source.Kind = key.kind + source.Value = value + } + // Other literal options such as branch, tag, ref, and rel are accepted so + // the source location is retained from common multiline Berksfile + // declarations. They do not alter the raw coordinate represented by Source. + return source, true +} + +func appendChefDependency( + result *core.Result, + locations map[string]int, + prefix string, + values []string, + source core.Source, +) { + name := values[0] + version := strings.Join(values[1:], ", ") + result.Dependencies = append(result.Dependencies, core.Dependency{ + Name: name, + Version: version, + Scope: core.Runtime, + Direct: true, + Source: source, + }) + location := core.NextLocation(locations, prefix+"/"+url.PathEscape(name)) + result.Declarations = append(result.Declarations, core.Declaration{ + Name: name, + Version: version, + Scope: core.Runtime, + Direct: true, + Location: location, + Source: source, + }) +} + +type rubyCall struct { + name string + positional []string + keywords map[string]string +} + +func (call rubyCall) hasExactPositionalCount(count int) bool { + return len(call.positional) == count && len(call.keywords) == 0 +} + +// rubyStatements splits the supported single-line Ruby DSL and comma- or +// parenthesis-continued calls. Comments are removed only outside strings. +// Unterminated strings are abandoned at the physical newline so one malformed +// dynamic declaration cannot hide later valid declarations. +func rubyStatements(content []byte) []string { + statements := make([]string, 0, strings.Count(string(content), "\n")+1) + var statement strings.Builder + var quote byte + escaped := false + comment := false + parentheses := 0 + + flush := func() { + trimmed := strings.TrimSpace(statement.String()) + if trimmed != "" { + statements = append(statements, trimmed) + } + statement.Reset() + quote = 0 + escaped = false + comment = false + parentheses = 0 + } + + for _, character := range content { + if character == '\n' { + if quote != 0 { + flush() + continue + } + comment = false + if parentheses > 0 || lastNonSpaceByte(statement.String()) == ',' { + statement.WriteByte(' ') + } else { + flush() + } + continue + } + if comment { + continue + } + if quote != 0 { + statement.WriteByte(character) + if escaped { + escaped = false + } else if character == '\\' { + escaped = true + } else if character == quote { + quote = 0 + } + continue + } + switch character { + case '\'', '"': + quote = character + statement.WriteByte(character) + case '#': + comment = true + case '(': + parentheses++ + statement.WriteByte(character) + case ')': + parentheses-- + statement.WriteByte(character) + default: + statement.WriteByte(character) + } + } + flush() + return statements +} + +func lastNonSpaceByte(value string) byte { + for index := len(value) - 1; index >= 0; index-- { + if !isRubySpace(value[index]) { + return value[index] + } + } + return 0 +} + +// parseRubyCall accepts only a method name followed by literal string +// positional arguments and literal string keyword arguments. Any remaining +// Ruby expression makes the whole declaration ineligible. +func parseRubyCall(statement string) (rubyCall, bool) { + position := 0 + skipRubySpace(statement, &position) + start := position + for position < len(statement) && isRubyIdentifierByte(statement[position]) { + position++ + } + if position == start { + return rubyCall{}, false + } + call := rubyCall{name: statement[start:position]} + if position < len(statement) && !isRubySpace(statement[position]) && statement[position] != '(' { + return rubyCall{}, false + } + skipRubySpace(statement, &position) + parenthesized := position < len(statement) && statement[position] == '(' + if parenthesized { + position++ + } + + for { + skipRubySpace(statement, &position) + if parenthesized && position < len(statement) && statement[position] == ')' { + position++ + skipRubySpace(statement, &position) + return call, position == len(statement) + } + if position == len(statement) { + return call, !parenthesized + } + + if statement[position] == '\'' || statement[position] == '"' { + value, ok := parseRubyString(statement, &position) + if !ok { + return rubyCall{}, false + } + call.positional = append(call.positional, value) + } else { + key, value, ok := parseRubyKeyword(statement, &position) + if !ok { + return rubyCall{}, false + } + if call.keywords == nil { + call.keywords = make(map[string]string) + } + if _, duplicate := call.keywords[key]; duplicate { + return rubyCall{}, false + } + call.keywords[key] = value + } + + skipRubySpace(statement, &position) + if position == len(statement) { + return call, !parenthesized + } + if parenthesized && statement[position] == ')' { + continue + } + if statement[position] != ',' { + return rubyCall{}, false + } + position++ + skipRubySpace(statement, &position) + if position == len(statement) { + return call, !parenthesized + } + } +} + +func parseRubyKeyword(statement string, position *int) (string, string, bool) { + start := *position + var key string + if statement[*position] == ':' { + *position++ + identifierStart := *position + for *position < len(statement) && isRubyIdentifierByte(statement[*position]) { + *position++ + } + if *position == identifierStart { + return "", "", false + } + key = statement[identifierStart:*position] + skipRubySpace(statement, position) + if !strings.HasPrefix(statement[*position:], "=>") { + *position = start + return "", "", false + } + *position += 2 + } else { + for *position < len(statement) && isRubyIdentifierByte(statement[*position]) { + *position++ + } + if *position == start { + return "", "", false + } + key = statement[start:*position] + skipRubySpace(statement, position) + if *position >= len(statement) || statement[*position] != ':' { + *position = start + return "", "", false + } + *position++ + } + skipRubySpace(statement, position) + if *position >= len(statement) || (statement[*position] != '\'' && statement[*position] != '"') { + *position = start + return "", "", false + } + value, ok := parseRubyString(statement, position) + if !ok { + *position = start + return "", "", false + } + return key, value, true +} + +func parseRubyString(statement string, position *int) (string, bool) { + quote := statement[*position] + *position++ + var value strings.Builder + for *position < len(statement) { + character := statement[*position] + *position++ + if character == quote { + return value.String(), true + } + if character == '\\' { + if *position >= len(statement) { + return "", false + } + next := statement[*position] + *position++ + if quote == '\'' && next != '\'' && next != '\\' { + value.WriteByte('\\') + value.WriteByte(next) + continue + } + switch next { + case 'n': + value.WriteByte('\n') + case 'r': + value.WriteByte('\r') + case 't': + value.WriteByte('\t') + default: + value.WriteByte(next) + } + continue + } + if quote == '"' && character == '#' && *position < len(statement) { + switch statement[*position] { + case '{', '$', '@': + return "", false + } + } + value.WriteByte(character) + } + return "", false +} + +func skipRubySpace(value string, position *int) { + for *position < len(value) && isRubySpace(value[*position]) { + *position++ + } +} + +func isRubySpace(character byte) bool { + return character == ' ' || character == '\t' || character == '\r' || character == '\n' +} + +func isRubyIdentifierByte(character byte) bool { + return character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || + character >= '0' && character <= '9' || character == '_' +} diff --git a/internal/chef/chef_test.go b/internal/chef/chef_test.go new file mode 100644 index 0000000..cb47cc9 --- /dev/null +++ b/internal/chef/chef_test.go @@ -0,0 +1,291 @@ +package chef + +import ( + "errors" + "os" + "slices" + "testing" + + "github.com/git-pkgs/manifests/internal/core" +) + +func TestMetadataRubyParser(t *testing.T) { + t.Parallel() + + content := readChefFixture(t, "../../testdata/chef/metadata.rb") + result, err := (&metadataRubyParser{}).Parse("metadata.rb", content) + if err != nil { + t.Fatal(err) + } + assertChefIdentity(t, result) + assertChefDependencies(t, result, map[string]string{ + "apt": "", + "ntp": "~> 3.0", + "users": ">= 5.0, < 9.0", + }) +} + +func TestMetadataJSONParser(t *testing.T) { + t.Parallel() + + content := readChefFixture(t, "../../testdata/chef/metadata.json") + result, err := (&metadataJSONParser{}).Parse("metadata.json", content) + if err != nil { + t.Fatal(err) + } + assertChefIdentity(t, result) + assertChefDependencies(t, result, map[string]string{ + "apt": "", + "ntp": "~> 3.0", + "users": ">= 5.0, < 9.0", + }) + if got := dependencyNames(result.Dependencies); !slices.Equal(got, []string{"apt", "ntp", "users"}) { + t.Errorf("dependency order = %v, want sorted JSON object keys", got) + } +} + +func TestMetadataJSONParserSkipsNonliteralFields(t *testing.T) { + t.Parallel() + + content := []byte(`{ + "name": {"dynamic": true}, + "version": 2, + "license": ["MIT"], + "dependencies": { + "valid": ">= 1.0", + "also-valid": [">= 2.0", "< 3.0"], + "invalid": {"constraint": "~> 1.0"} + } +}`) + result, err := (&metadataJSONParser{}).Parse("metadata.json", content) + if err != nil { + t.Fatal(err) + } + if result.Name != "" || result.Version != "" || len(result.Licenses) != 0 { + t.Errorf("identity = %q %q %v, want empty", result.Name, result.Version, result.Licenses) + } + assertChefDependencies(t, result, map[string]string{ + "also-valid": ">= 2.0, < 3.0", + "valid": ">= 1.0", + }) +} + +func TestMetadataJSONParserRejectsMalformedJSON(t *testing.T) { + t.Parallel() + + _, err := (&metadataJSONParser{}).Parse("metadata.json", []byte(`{"name":`)) + var parseError *core.ParseError + if !errors.As(err, &parseError) { + t.Fatalf("error = %v, want ParseError", err) + } +} + +func TestMetadataRubyParserSkipsDynamicExpressions(t *testing.T) { + t.Parallel() + + content := []byte(` +name cookbook_name +version(version_from_env) +license "MIT-#{suffix}" +license "MIT-#@suffix" +name "#$cookbook_name" +depends dependency_name +depends "concatenated" + suffix +depends("method", requirement()) +depends "interpolated-#{name}" +depends "unterminated + +name 'literal_name' +version "1.2.3" +license('MIT') +depends "literal_dependency", "~> 4.0" # retained after dynamic lines +`) + result, err := (&metadataRubyParser{}).Parse("metadata.rb", content) + if err != nil { + t.Fatal(err) + } + if result.Name != "literal_name" || result.Version != "1.2.3" || + !slices.Equal(result.Licenses, []string{"MIT"}) { + t.Errorf("identity = %q %q %v", result.Name, result.Version, result.Licenses) + } + assertChefDependencies(t, result, map[string]string{"literal_dependency": "~> 4.0"}) +} + +func TestBerksfileParser(t *testing.T) { + t.Parallel() + + content := readChefFixture(t, "../../testdata/chef/Berksfile") + result, err := (&berksfileParser{}).Parse("Berksfile", content) + if err != nil { + t.Fatal(err) + } + if len(result.Sources) != 2 { + t.Fatalf("sources = %+v, want 2", result.Sources) + } + if result.Sources[0].Kind != core.SourceRegistry || + result.Sources[0].Value != "https://supermarket.chef.io" || + result.Sources[1].Value != "https://supermarket.example.test" { + t.Errorf("sources = %+v, want ordered public and private registries", result.Sources) + } + assertChefDependencies(t, result, map[string]string{ + "ntp": "<= 1.0.0", + "mysql": "", + "company_base": "", + "local_users": "", + "github_cookbook": "", + }) + + dependencies := indexChefDependencies(result.Dependencies) + assertChefSource(t, dependencies["company_base"].Source, core.SourceGit, + "https://github.com/example/company_base.git") + assertChefSource(t, dependencies["local_users"].Source, core.SourcePath, + "../local_users") + assertChefSource(t, dependencies["github_cookbook"].Source, core.SourceGitHub, + "example/github_cookbook") + if dependencies["ntp"].Source.Kind != "" || dependencies["mysql"].Source.Kind != "" { + t.Errorf("registry dependencies claim explicit sources: %+v", dependencies) + } + for _, declaration := range result.Declarations { + dependency := dependencies[declaration.Name] + if declaration.Source.Kind != dependency.Source.Kind || declaration.Source.Value != dependency.Source.Value { + t.Errorf("declaration source = %+v, dependency source = %+v", declaration.Source, dependency.Source) + } + } +} + +func TestBerksfileParserSupportsHashRocketAndLiteralSourceOptions(t *testing.T) { + t.Parallel() + + content := []byte(` +source "https://private.example.test", ssl_verify: "false" +cookbook 'legacy', :git => 'https://example.test/legacy.git', :ref => 'abc123' +`) + result, err := (&berksfileParser{}).Parse("Berksfile", content) + if err != nil { + t.Fatal(err) + } + if len(result.Sources) != 1 || result.Sources[0].Value != "https://private.example.test" { + t.Fatalf("sources = %+v", result.Sources) + } + if len(result.Dependencies) != 1 { + t.Fatalf("dependencies = %+v", result.Dependencies) + } + assertChefSource(t, result.Dependencies[0].Source, core.SourceGit, + "https://example.test/legacy.git") +} + +func TestBerksfileParserSkipsDynamicCallsAndMetadata(t *testing.T) { + t.Parallel() + + content := []byte(` +source ENV.fetch("CHEF_SOURCE") +source "https://#{host}" +cookbook cookbook_name +cookbook "dynamic-git", git: repository_url +cookbook "ambiguous", git: "https://example.test/a.git", path: "../a" +metadata +metadata path: './cookbook' + +source "https://literal.example.test" +cookbook "literal", path: "../literal" +`) + result, err := (&berksfileParser{}).Parse("Berksfile", content) + if err != nil { + t.Fatal(err) + } + if len(result.Sources) != 1 || result.Sources[0].Value != "https://literal.example.test" { + t.Errorf("sources = %+v", result.Sources) + } + assertChefDependencies(t, result, map[string]string{"literal": ""}) +} + +func TestRubyStringsPreserveLiteralHashesAndEscapes(t *testing.T) { + t.Parallel() + + content := []byte(` +depends 'hash#name', '~> 1.0' # comment +depends "escaped\#{name}", "line\nconstraint" +depends 'single\q', '>= 2.0' +`) + result, err := (&metadataRubyParser{}).Parse("metadata.rb", content) + if err != nil { + t.Fatal(err) + } + assertChefDependencies(t, result, map[string]string{ + "hash#name": "~> 1.0", + "escaped#{name}": "line\nconstraint", + "single\\q": ">= 2.0", + }) +} + +func readChefFixture(t *testing.T, path string) []byte { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return content +} + +func assertChefIdentity(t *testing.T, result *core.Result) { + t.Helper() + if result.Name != "example_cookbook" || result.Version != "2.4.1" || + !slices.Equal(result.Licenses, []string{"Apache-2.0"}) { + t.Errorf("identity = %q %q %v, want example_cookbook 2.4.1 Apache-2.0", + result.Name, result.Version, result.Licenses) + } +} + +func assertChefDependencies(t *testing.T, result *core.Result, want map[string]string) { + t.Helper() + if len(result.Dependencies) != len(want) { + t.Fatalf("dependencies = %+v, want %v", result.Dependencies, want) + } + if len(result.Declarations) != len(want) { + t.Fatalf("declarations = %+v, want %d", result.Declarations, len(want)) + } + seen := make(map[string]bool, len(result.Dependencies)) + for _, dependency := range result.Dependencies { + version, ok := want[dependency.Name] + if !ok { + t.Errorf("unexpected dependency %+v", dependency) + continue + } + if seen[dependency.Name] { + t.Errorf("duplicate dependency %q", dependency.Name) + } + seen[dependency.Name] = true + if dependency.Version != version || dependency.Scope != core.Runtime || !dependency.Direct || + dependency.RegistryURL != "" { + t.Errorf("dependency = %+v, want version %q direct runtime without registry", dependency, version) + } + } +} + +func indexChefDependencies(dependencies []core.Dependency) map[string]core.Dependency { + indexed := make(map[string]core.Dependency, len(dependencies)) + for _, dependency := range dependencies { + indexed[dependency.Name] = dependency + } + return indexed +} + +func dependencyNames(dependencies []core.Dependency) []string { + names := make([]string, 0, len(dependencies)) + for _, dependency := range dependencies { + names = append(names, dependency.Name) + } + return names +} + +func assertChefSource( + t *testing.T, + source core.Source, + kind core.SourceKind, + value string, +) { + t.Helper() + if source.Kind != kind || source.Value != value { + t.Errorf("source = %+v, want kind %q value %q", source, kind, value) + } +} diff --git a/internal/core/types.go b/internal/core/types.go index e4617f8..b979f0b 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -22,6 +22,25 @@ const ( Optional Scope = "optional" ) +// SourceKind identifies the kind of location named by a source declaration. +// The value describes syntax in the manifest, not a resolved package source. +type SourceKind string + +const ( + SourceRegistry SourceKind = "registry" + SourceGit SourceKind = "git" + SourcePath SourceKind = "path" + SourceGitHub SourceKind = "github" +) + +// Source preserves an explicit source declaration without claiming that a +// dependency was resolved from it. Value is the literal URL, path, or +// ecosystem-specific coordinate. +type Source struct { + Kind SourceKind + Value string +} + // Dependency represents a parsed dependency from a manifest or lockfile. type Dependency struct { Name string @@ -33,6 +52,10 @@ type Dependency struct { Direct bool PURL string RegistryURL string + // Source is set only when this dependency has an explicit source override. + // It is intentionally separate from RegistryURL because paths and Git + // repositories are not package registries. + Source Source } // Declaration is a dependency-like reference at a stable logical location @@ -50,6 +73,7 @@ type Declaration struct { // it when a file can contain references from more than one ecosystem. PURL string Location string + Source Source } // Result is the output of a single parser. @@ -67,6 +91,10 @@ type Result struct { LicenseFile string Dependencies []Dependency Declarations []Declaration + // Sources preserves manifest-level source declarations in source order. + // These entries are configuration, not evidence that any dependency was + // resolved from a particular source. + Sources []Source } // Parser is the interface implemented by all manifest parsers. diff --git a/manifests.go b/manifests.go index c911e1d..46e5cc5 100644 --- a/manifests.go +++ b/manifests.go @@ -22,8 +22,9 @@ import ( // Re-export types from internal/core for public API. type ( - Kind = core.Kind - Scope = core.Scope + Kind = core.Kind + Scope = core.Scope + SourceKind = core.SourceKind ) // Dependency represents a parsed dependency. Its Integrity field is an opaque @@ -34,6 +35,10 @@ type Dependency = core.Dependency // location in a manifest. Location is ecosystem-specific and opaque. type Declaration = core.Declaration +// Source preserves a literal manifest source declaration. It does not report +// a resolved package location. +type Source = core.Source + // Re-export constants. const ( Manifest Kind = core.Manifest @@ -46,6 +51,11 @@ const ( Test Scope = core.Test Build Scope = core.Build Optional Scope = core.Optional + + SourceRegistry SourceKind = core.SourceRegistry + SourceGit SourceKind = core.SourceGit + SourcePath SourceKind = core.SourcePath + SourceGitHub SourceKind = core.SourceGitHub ) // ParseResult contains the parsed dependencies from a manifest or lockfile. @@ -71,6 +81,9 @@ type ParseResult struct { // their logical locations. Unlike Dependencies, these entries are not // merged, inherited, or otherwise resolved into an effective model. Declarations []Declaration + // Sources preserves manifest-level source declarations in source order. + // A source declaration does not imply that any dependency resolved there. + Sources []Source } // Options configures Parse. @@ -131,6 +144,7 @@ func Parse(filename string, content []byte, opts ...Options) (*ParseResult, erro LicenseFile: res.LicenseFile, Dependencies: res.Dependencies, Declarations: res.Declarations, + Sources: res.Sources, }, nil } @@ -145,6 +159,12 @@ func declarationPURL(ecosystem string, declaration core.Declaration) string { // makePURL creates a Package URL for a dependency. func makePURL(ecosystem, name, version, registryURL string) string { + // Chef is still a candidate PURL type without accepted name or namespace + // rules. Keep identities empty instead of inventing a mapping that callers + // could mistake for a standardized PURL. + if ecosystem == "chef" { + return "" + } return purl.BuildPURLString(ecosystem, name, version, registryURL) } diff --git a/manifests_test.go b/manifests_test.go index 48bad8f..6e41ed9 100644 --- a/manifests_test.go +++ b/manifests_test.go @@ -29,6 +29,9 @@ func TestParseAllEcosystems(t *testing.T) { {"pypi requirements.txt", "testdata/pypi/requirements.txt", "pypi", Manifest}, {"maven pom.xml", "testdata/maven/pom.xml", "maven", Manifest}, {"nuget central packages", "testdata/nuget/Directory.Packages.props", "nuget", Manifest}, + {"chef metadata.rb", "testdata/chef/metadata.rb", "chef", Manifest}, + {"chef metadata.json", "testdata/chef/metadata.json", "chef", Manifest}, + {"chef Berksfile", "testdata/chef/Berksfile", "chef", Manifest}, {"composer composer.json", "testdata/composer/composer.json", "composer", Manifest}, {"composer composer.lock", "testdata/composer/composer.lock", "composer", Lockfile}, } @@ -74,13 +77,64 @@ 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", "chef"} { if !slices.Contains(got, want) { t.Errorf("Ecosystems() missing %q", want) } } } +func TestChefManifestRegistrationAndCandidatePURLs(t *testing.T) { + t.Parallel() + + for _, filename := range []string{"metadata.rb", "metadata.json", "Berksfile"} { + ecosystem, kind, ok := Identify("cookbooks/example/" + filename) + if !ok || ecosystem != "chef" || kind != Manifest { + t.Errorf("Identify(%q) = %q, %q, %v; want chef manifest", filename, ecosystem, kind, ok) + } + } + + content, err := os.ReadFile("testdata/chef/Berksfile") + if err != nil { + t.Fatal(err) + } + result, err := Parse("Berksfile", content) + if err != nil { + t.Fatal(err) + } + if len(result.Sources) != 2 || result.Sources[0].Value != "https://supermarket.chef.io" || + result.Sources[1].Value != "https://supermarket.example.test" { + t.Errorf("sources = %+v, want ordered Berksfile sources", result.Sources) + } + for _, dependency := range result.Dependencies { + if dependency.PURL != "" { + t.Errorf("Chef candidate PURL = %q, want empty", dependency.PURL) + } + } + for _, declaration := range result.Declarations { + if declaration.PURL != "" { + t.Errorf("Chef candidate declaration PURL = %q, want empty", declaration.PURL) + } + } +} + +func TestBerksfileMetadataDirectiveDoesNotReadFSRoot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + metadata := []byte("name 'must_not_be_loaded'\ndepends 'must_not_be_loaded'\n") + if err := os.WriteFile(filepath.Join(root, "metadata.rb"), metadata, 0o600); err != nil { + t.Fatal(err) + } + result, err := Parse("Berksfile", []byte("metadata\n"), Options{FSRoot: root}) + if err != nil { + t.Fatal(err) + } + if result.Name != "" || len(result.Dependencies) != 0 || len(result.Declarations) != 0 { + t.Errorf("bare metadata loaded adjacent file: %+v", result) + } +} + func TestMavenDeclarationPURLs(t *testing.T) { content := []byte(` diff --git a/testdata/chef/Berksfile b/testdata/chef/Berksfile new file mode 100644 index 0000000..6834965 --- /dev/null +++ b/testdata/chef/Berksfile @@ -0,0 +1,12 @@ +source "https://supermarket.chef.io" +source('https://supermarket.example.test') # private Supermarket + +metadata +cookbook "ntp", "<= 1.0.0" +cookbook('mysql') +cookbook "company_base", git: "https://github.com/example/company_base.git", tag: "v2.0.0" +cookbook 'local_users', path: '../local_users' +cookbook 'github_cookbook', + github: 'example/github_cookbook', + branch: 'main', + rel: 'cookbooks/github_cookbook' diff --git a/testdata/chef/metadata.json b/testdata/chef/metadata.json new file mode 100644 index 0000000..78841bf --- /dev/null +++ b/testdata/chef/metadata.json @@ -0,0 +1,10 @@ +{ + "name": "example_cookbook", + "version": "2.4.1", + "license": "Apache-2.0", + "dependencies": { + "apt": "", + "ntp": "~> 3.0", + "users": [">= 5.0", "< 9.0"] + } +} diff --git a/testdata/chef/metadata.rb b/testdata/chef/metadata.rb new file mode 100644 index 0000000..0a4cacd --- /dev/null +++ b/testdata/chef/metadata.rb @@ -0,0 +1,7 @@ +name "example_cookbook" +version('2.4.1') +license 'Apache-2.0' # SPDX expression + +depends "apt" +depends('ntp', '~> 3.0') +depends "users", ">= 5.0", "< 9.0" From e956e31e95d9bcd2b7edabe44cfc7cd7dbffc830 Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Sat, 29 Aug 2026 19:17:36 +0530 Subject: [PATCH 2/2] Address Chef parser review feedback --- README.md | 4 ++++ internal/chef/chef.go | 2 +- manifests.go | 6 ++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 86b2bdb..9f66941 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,10 @@ discarding successful discoveries. ## Types +This module is pre-v1 and exported result structs may gain additive metadata +fields in minor releases. Use keyed composite literals when constructing +`Dependency`, `Declaration`, or `ParseResult` values. + ### Dependency ```go diff --git a/internal/chef/chef.go b/internal/chef/chef.go index 58faa32..da546f1 100644 --- a/internal/chef/chef.go +++ b/internal/chef/chef.go @@ -220,7 +220,7 @@ func (call rubyCall) hasExactPositionalCount(count int) bool { // Unterminated strings are abandoned at the physical newline so one malformed // dynamic declaration cannot hide later valid declarations. func rubyStatements(content []byte) []string { - statements := make([]string, 0, strings.Count(string(content), "\n")+1) + statements := make([]string, 0, core.EstimateDeps(len(content))) var statement strings.Builder var quote byte escaped := false diff --git a/manifests.go b/manifests.go index 46e5cc5..e3ecc49 100644 --- a/manifests.go +++ b/manifests.go @@ -29,10 +29,14 @@ type ( // Dependency represents a parsed dependency. Its Integrity field is an opaque // verification value whose digest encoding depends on the source format. +// Before v1, callers constructing values should use keyed fields so additive +// metadata fields remain source-compatible. type Dependency = core.Dependency // Declaration represents a dependency-like reference at a stable logical // location in a manifest. Location is ecosystem-specific and opaque. +// Before v1, callers constructing values should use keyed fields so additive +// metadata fields remain source-compatible. type Declaration = core.Declaration // Source preserves a literal manifest source declaration. It does not report @@ -59,6 +63,8 @@ const ( ) // ParseResult contains the parsed dependencies from a manifest or lockfile. +// Before v1, callers constructing values should use keyed fields so additive +// metadata fields remain source-compatible. type ParseResult struct { Ecosystem string Kind Kind