From 681714df8c5728ad77116fab4ec5be5ea5b1b72f Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Wed, 10 Sep 2025 18:03:30 -0600 Subject: [PATCH 1/7] Ruby dependency license scanning support via Gemfile.lock. - Implements https://github.com/apache/skywalking/issues/7744 - Library projects (with a *.gemspec in the same directory as Gemfile.lock) ignore development dependencies and include runtime dependencies and their transitives. - App projects (no *.gemspec) include both runtime and development dependencies from Gemfile.lock. - Will only work if Gemfile.lock is committed to version control, but this is the official recommendation of RubyGems: - https://bundler.io/guides/faq.html#using-gemfiles-inside-gems - License resolution honors user overrides/exclusions and may query the RubyGems API when necessary, with proper support for handling of various status codes. - Documentation updated (README.md) - Ruby setup and GitHub Actions example are in
tag to reduce noise --- README.md | 36 ++ pkg/deps/resolve.go | 1 + pkg/deps/ruby.go | 409 ++++++++++++++++++ pkg/deps/ruby_test.go | 120 +++++ pkg/deps/testdata/ruby/app/Gemfile.lock | 17 + pkg/deps/testdata/ruby/library/Gemfile.lock | 17 + pkg/deps/testdata/ruby/library/sample.gemspec | 11 + 7 files changed, 611 insertions(+) create mode 100644 pkg/deps/ruby.go create mode 100644 pkg/deps/ruby_test.go create mode 100644 pkg/deps/testdata/ruby/app/Gemfile.lock create mode 100644 pkg/deps/testdata/ruby/library/Gemfile.lock create mode 100644 pkg/deps/testdata/ruby/library/sample.gemspec diff --git a/README.md b/README.md index c55e8f77..d31cc54f 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ dependency: - Cargo.toml # If this is a rust project. - package.json # If this is a npm project. - go.mod # If this is a Go project. + - Gemfile.lock # If this is a Ruby project (Bundler). Ensure Gemfile.lock is committed. ``` #### Check License Headers @@ -102,6 +103,41 @@ To check dependencies license in GitHub Actions, add a step in your GitHub workf # flags: # optional: Extra flags appended to the command, for example, `--summary=path/to/template.tmpl` ``` +
+ + Ruby projects (Bundler) + +License-Eye can resolve Ruby dependencies and their licenses directly from Gemfile.lock. + +Rules applied: +- If a .gemspec file exists in the same directory as Gemfile.lock, the project is treated as a library and development dependencies are ignored. Runtime dependencies (and their transitives) are included. +- If no .gemspec is present, the project is treated as an app and all dependencies from Gemfile.lock are considered (both runtime and development). + +Requirements: +- Commit Gemfile.lock to version control so License-Eye can read the locked dependency graph. +- For libraries, ensure the .gemspec is present in the same directory as Gemfile.lock. + +Minimal config snippet: + +```yaml +dependency: + files: + - Gemfile.lock +``` + +GitHub Actions example: + +```yaml +- name: Check Ruby dependencies' licenses + uses: apache/skywalking-eyes/dependency@main + with: + config: .licenserc.yaml +``` + +Note: License-Eye may query the RubyGems API to determine licenses when they are not specified in your configuration. Ensure the workflow has network access. + +
+ ### Docker Image For Bash, users can execute the following command, diff --git a/pkg/deps/resolve.go b/pkg/deps/resolve.go index fc2f5627..2551b6a8 100644 --- a/pkg/deps/resolve.go +++ b/pkg/deps/resolve.go @@ -32,6 +32,7 @@ var Resolvers = []Resolver{ new(MavenPomResolver), new(JarResolver), new(CargoTomlResolver), + new(GemfileLockResolver), } func Resolve(config *ConfigDeps, report *Report) error { diff --git a/pkg/deps/ruby.go b/pkg/deps/ruby.go new file mode 100644 index 00000000..d90d1124 --- /dev/null +++ b/pkg/deps/ruby.go @@ -0,0 +1,409 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package deps + +import ( + "bufio" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +// GemfileLockResolver resolves Ruby dependencies from Gemfile.lock +// It determines project type by the presence of a *.gemspec file in the same directory as Gemfile.lock. +// - Library projects (with gemspec): ignore development dependencies; include only runtime deps and their transitive closure. +// - App projects (no gemspec): include all dependencies in Gemfile.lock. +// Licenses are fetched from RubyGems API unless overridden by user config. +// See issue description for detailed rules. + +type GemfileLockResolver struct { + Resolver +} + +func (r *GemfileLockResolver) CanResolve(file string) bool { + base := filepath.Base(file) + return base == "Gemfile.lock" +} + +func (r *GemfileLockResolver) Resolve(lockfile string, config *ConfigDeps, report *Report) error { + dir := filepath.Dir(lockfile) + + content, err := os.ReadFile(lockfile) + if err != nil { + return err + } + + // Parse lockfile into specs graph and top-level dependencies + specs, deps, err := parseGemfileLock(string(content)) + if err != nil { + return err + } + + isLibrary := hasGemspec(dir) + + var roots []string + if isLibrary { + // Extract runtime dependencies from gemspec(s) + runtimeRoots, err := runtimeDepsFromGemspecs(dir) + if err != nil { + return err + } + if len(runtimeRoots) == 0 { + // Fallback: if not found, use DEPENDENCIES from lockfile + roots = deps + } else { + roots = runtimeRoots + } + } else { + // App: all declared dependencies are relevant + roots = deps + } + + // Compute the set of included gems + include := reachable(specs, roots) + // For app without explicit deps (rare), include all specs + if len(roots) == 0 { + for name := range specs { + include[name] = struct{}{} + } + } + + // Resolve licenses for included gems + for name := range include { + version := specs[name].Version + if exclude, _ := config.IsExcluded(name, version); exclude { + continue + } + if l, ok := config.GetUserConfiguredLicense(name, version); ok { + report.Resolve(&Result{Dependency: name, LicenseSpdxID: l, Version: version}) + continue + } + + licenseID, err := fetchRubyGemsLicense(name, version) + if err != nil || licenseID == "" { + report.Skip(&Result{Dependency: name, LicenseSpdxID: Unknown, Version: version}) + continue + } + report.Resolve(&Result{Dependency: name, LicenseSpdxID: licenseID, Version: version}) + } + + return nil +} + +// -------- Parsing Gemfile.lock -------- + +type gemSpec struct { + Name string + Version string + Deps []string +} + +type gemGraph map[string]*gemSpec + +var ( + lockSpecHeader = regexp.MustCompile(`^\s{4}([a-zA-Z0-9_\-]+) \(([^)]+)\)`) // rake (13.0.6) + lockDepLine = regexp.MustCompile(`^\s{6}([a-zA-Z0-9_\-]+)(?:\s|$)`) // activesupport (~> 6.1) +) + +func parseGemfileLock(s string) (graph gemGraph, roots []string, err error) { + scanner := bufio.NewScanner(strings.NewReader(s)) + scanner.Split(bufio.ScanLines) + graph = make(gemGraph) + + inSpecs := false + inDeps := false + var current *gemSpec + + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "GEM") { + inSpecs = true + inDeps = false + current = nil + continue + } + if strings.HasPrefix(line, "DEPENDENCIES") { + inSpecs = false + inDeps = true + current = nil + continue + } + if strings.TrimSpace(line) == "specs:" && inSpecs { + // just a marker + continue + } + + if inSpecs { + if m := lockSpecHeader.FindStringSubmatch(line); len(m) == 3 { + name := m[1] + version := m[2] + current = &gemSpec{Name: name, Version: version} + graph[name] = current + continue + } + if current != nil { + if m := lockDepLine.FindStringSubmatch(line); len(m) == 2 { + depName := m[1] + current.Deps = append(current.Deps, depName) + } + } + continue + } + + if inDeps { + trim := strings.TrimSpace(line) + if trim == "" || strings.HasPrefix(trim, "BUNDLED WITH") { + inDeps = false + continue + } + // dependency line: byebug (~> 11.1) + root := trim + if i := strings.Index(root, " "); i >= 0 { + root = root[:i] + } + // ignore comments and platforms + if root != "" && !strings.HasPrefix(root, "#") { + roots = append(roots, root) + } + continue + } + } + if err := scanner.Err(); err != nil { + return nil, nil, err + } + return graph, roots, nil +} + +func hasGemspec(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".gemspec") { + return true + } + } + return false +} + +var gemspecRuntimeRe = regexp.MustCompile(`(?m)\badd_(?:runtime_)?dependency\s*\(?\s*["']([^"']+)["']`) + +func runtimeDepsFromGemspecs(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + runtime := make(map[string]struct{}) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".gemspec") { + continue + } + b, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return nil, err + } + for _, m := range gemspecRuntimeRe.FindAllStringSubmatch(string(b), -1) { + if len(m) == 2 { + runtime[m[1]] = struct{}{} + } + } + } + res := make([]string, 0, len(runtime)) + for k := range runtime { + res = append(res, k) + } + return res, nil +} + +func reachable(graph gemGraph, roots []string) map[string]struct{} { + vis := make(map[string]struct{}) + var dfs func(string) + dfs = func(n string) { + if _, ok := vis[n]; ok { + return + } + if _, ok := graph[n]; !ok { + // unknown in specs, still include the root + vis[n] = struct{}{} + return + } + vis[n] = struct{}{} + for _, c := range graph[n].Deps { + dfs(c) + } + } + for _, r := range roots { + dfs(r) + } + return vis +} + +// -------- License resolution via RubyGems API -------- + +type rubyGemsVersionInfo struct { + Licenses []string `json:"licenses"` + License string `json:"license"` +} + +func fetchRubyGemsLicense(name, version string) (string, error) { + // Prefer version-specific API + url := fmt.Sprintf("https://rubygems.org/api/v2/rubygems/%s/versions/%s.json", name, version) + licenseID, err := fetchRubyGemsLicenseFrom(url) + if err == nil && licenseID != "" { + return licenseID, nil + } + // Fallback to latest info + url = fmt.Sprintf("https://rubygems.org/api/v1/gems/%s.json", name) + return fetchRubyGemsLicenseFrom(url) +} + +var httpClientRuby = &http.Client{Timeout: 10 * time.Second} + +func fetchRubyGemsLicenseFrom(url string) (string, error) { + const maxAttempts = 3 + backoff := 1 * time.Second + + for attempt := 1; attempt <= maxAttempts; attempt++ { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "skywalking-eyes/License-Eye (+https://github.com/apache/skywalking-eyes)") + req.Header.Set("Accept", "application/json") + + resp, err := httpClientRuby.Do(req) // #nosec G107 + if err != nil { + if attempt == maxAttempts { + return "", err + } + time.Sleep(backoff) + backoff *= 2 + continue + } + + // Ensure body is always closed + func() { + defer resp.Body.Close() + + switch { + case resp.StatusCode == http.StatusOK: + var info rubyGemsVersionInfo + dec := json.NewDecoder(resp.Body) + if err = dec.Decode(&info); err != nil { + break + } + var items []string + if len(info.Licenses) > 0 { + items = info.Licenses + } else if info.License != "" { + items = []string{info.License} + } + for i := range items { + items[i] = strings.TrimSpace(items[i]) + } + m := make(map[string]struct{}) + for _, it := range items { + if it == "" { + continue + } + m[it] = struct{}{} + } + if len(m) == 0 { + err = nil + // empty license info + return + } + var out []string + for k := range m { + out = append(out, k) + } + slicesSort(out) + // Return successfully + err = nil + url = strings.Join(out, " OR ") + return + + case resp.StatusCode == http.StatusNotFound: + // Treat as no license info available + err = nil + return + + case resp.StatusCode == http.StatusTooManyRequests || (resp.StatusCode >= 500 && resp.StatusCode <= 599): + // Respect Retry-After if present (in seconds) + ra := strings.TrimSpace(resp.Header.Get("Retry-After")) + if ra != "" { + if secs, parseErr := strconv.Atoi(ra); parseErr == nil { + wait := time.Duration(secs) * time.Second + if wait > 10*time.Second { + wait = 10 * time.Second + } + time.Sleep(wait) + } else { + time.Sleep(backoff) + } + } else { + time.Sleep(backoff) + } + backoff *= 2 + // Mark a retryable error by setting err non-nil so outer loop continues + err = fmt.Errorf("retryable status: %s", resp.Status) + return + + default: + err = fmt.Errorf("unexpected status: %s", resp.Status) + return + } + }() + + // Decide based on err and what we set in the closure + if err == nil { + // For 200 OK with parsed license, we smuggled the result in url variable; for 404 we return "". + // Detect if url was replaced by license string by checking it doesn't start with http. + if !strings.HasPrefix(url, "http") { + return url, nil + } + // 404 case or empty license + return "", nil + } + + // If retryable and attempts left, continue; otherwise return error + if attempt == maxAttempts { + return "", err + } + } + return "", nil +} + +// small helper to sort string slice without importing sort here to keep imports aligned with style used in this package +func slicesSort(ss []string) { + // simple insertion sort for small slices + for i := 1; i < len(ss); i++ { + j := i + for j > 0 && ss[j-1] > ss[j] { + ss[j-1], ss[j] = ss[j], ss[j-1] + j-- + } + } +} diff --git a/pkg/deps/ruby_test.go b/pkg/deps/ruby_test.go new file mode 100644 index 00000000..889a73da --- /dev/null +++ b/pkg/deps/ruby_test.go @@ -0,0 +1,120 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package deps_test + +import ( + "bufio" + "embed" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/apache/skywalking-eyes/pkg/deps" +) + +func writeFileRuby(fileName, content string) error { + file, err := os.OpenFile(fileName, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0777) + if err != nil { + return err + } + defer func() { _ = file.Close() }() + + write := bufio.NewWriter(file) + _, err = write.WriteString(content) + if err != nil { + return err + } + _ = write.Flush() + return nil +} + +func ensureDirRuby(dirName string) error { + return os.MkdirAll(dirName, 0777) +} + +//go:embed testdata/ruby/**/* +var rubyTestAssets embed.FS + +func copyRuby(assetDir, destination string) error { + return fs.WalkDir(rubyTestAssets, assetDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + filename := filepath.Join(destination, strings.Replace(path, assetDir, "", 1)) + if err := ensureDirRuby(filepath.Dir(filename)); err != nil { + return err + } + content, err := rubyTestAssets.ReadFile(path) + if err != nil { + return err + } + return writeFileRuby(filename, string(content)) + }) +} + +func TestRubyGemfileLockResolver(t *testing.T) { + resolver := new(deps.GemfileLockResolver) + + // App case: include all specs (3) + { + tmp := t.TempDir() + if err := copyRuby("testdata/ruby/app", tmp); err != nil { + t.Fatal(err) + } + lock := filepath.Join(tmp, "Gemfile.lock") + if !resolver.CanResolve(lock) { + t.Fatalf("GemfileLockResolver cannot resolve %s", lock) + } + cfg := &deps.ConfigDeps{Files: []string{lock}, Licenses: []*deps.ConfigDepLicense{ + {Name: "rake", Version: "13.0.6", License: "MIT"}, + {Name: "rspec", Version: "3.10.0", License: "MIT"}, + {Name: "rspec-core", Version: "3.10.1", License: "MIT"}, + }} + report := deps.Report{} + if err := resolver.Resolve(lock, cfg, &report); err != nil { + t.Fatal(err) + } + if len(report.Resolved)+len(report.Skipped) != 3 { + t.Fatalf("expected 3 dependencies, got %d", len(report.Resolved)+len(report.Skipped)) + } + } + + // Library case: only runtime deps reachable from gemspec (1: rake) + { + tmp := t.TempDir() + if err := copyRuby("testdata/ruby/library", tmp); err != nil { + t.Fatal(err) + } + lock := filepath.Join(tmp, "Gemfile.lock") + cfg := &deps.ConfigDeps{Files: []string{lock}, Licenses: []*deps.ConfigDepLicense{ + {Name: "rake", Version: "13.0.6", License: "MIT"}, + }} + report := deps.Report{} + if err := resolver.Resolve(lock, cfg, &report); err != nil { + t.Fatal(err) + } + if len(report.Resolved)+len(report.Skipped) != 1 { + t.Fatalf("expected 1 dependency for library, got %d", len(report.Resolved)+len(report.Skipped)) + } + } +} diff --git a/pkg/deps/testdata/ruby/app/Gemfile.lock b/pkg/deps/testdata/ruby/app/Gemfile.lock new file mode 100644 index 00000000..7d6478b6 --- /dev/null +++ b/pkg/deps/testdata/ruby/app/Gemfile.lock @@ -0,0 +1,17 @@ +GEM + remote: https://rubygems.org/ + specs: + rake (13.0.6) + rspec (3.10.0) + rspec-core (~> 3.10) + rspec-core (3.10.1) + +PLATFORMS + ruby + +DEPENDENCIES + rake + rspec + +BUNDLED WITH + 2.4.10 diff --git a/pkg/deps/testdata/ruby/library/Gemfile.lock b/pkg/deps/testdata/ruby/library/Gemfile.lock new file mode 100644 index 00000000..7d6478b6 --- /dev/null +++ b/pkg/deps/testdata/ruby/library/Gemfile.lock @@ -0,0 +1,17 @@ +GEM + remote: https://rubygems.org/ + specs: + rake (13.0.6) + rspec (3.10.0) + rspec-core (~> 3.10) + rspec-core (3.10.1) + +PLATFORMS + ruby + +DEPENDENCIES + rake + rspec + +BUNDLED WITH + 2.4.10 diff --git a/pkg/deps/testdata/ruby/library/sample.gemspec b/pkg/deps/testdata/ruby/library/sample.gemspec new file mode 100644 index 00000000..173a25bd --- /dev/null +++ b/pkg/deps/testdata/ruby/library/sample.gemspec @@ -0,0 +1,11 @@ +Gem::Specification.new do |spec| + spec.name = "sample" + spec.version = "0.1.0" + spec.summary = "Sample gem" + spec.description = "Sample" + spec.authors = ["Test"] + spec.files = [] + + spec.add_runtime_dependency 'rake', '>= 13.0' + spec.add_development_dependency 'rspec', '~> 3.10' +end From d74e85ebd7b533933a2e4758aee188fdb400b92d Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Wed, 10 Sep 2025 18:19:03 -0600 Subject: [PATCH 2/7] Address lint error - Error: pkg/deps/ruby.go:289:15: httpNoBody: http.NoBody should be preferred to the nil request body (gocritic req, err := http.NewRequest(http.MethodGet, url, nil) --- pkg/deps/ruby.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/deps/ruby.go b/pkg/deps/ruby.go index d90d1124..93efe92d 100644 --- a/pkg/deps/ruby.go +++ b/pkg/deps/ruby.go @@ -286,7 +286,7 @@ func fetchRubyGemsLicenseFrom(url string) (string, error) { backoff := 1 * time.Second for attempt := 1; attempt <= maxAttempts; attempt++ { - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequest(http.MethodGet, url, http.NoBody) if err != nil { return "", err } From 02855a01d9c43a2af17580fdb0acb99880de25dd Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Wed, 10 Sep 2025 18:21:04 -0600 Subject: [PATCH 3/7] Address lint error - Error: pkg/deps/ruby.go:284:1: cyclomatic complexity 24 of func `fetchRubyGemsLicenseFrom` is high (> 20) (gocyclo) func fetchRubyGemsLicenseFrom(url string) (string, error) { ^ --- pkg/deps/ruby.go | 164 ++++++++++++++++++++++++----------------------- 1 file changed, 85 insertions(+), 79 deletions(-) diff --git a/pkg/deps/ruby.go b/pkg/deps/ruby.go index 93efe92d..9d844a96 100644 --- a/pkg/deps/ruby.go +++ b/pkg/deps/ruby.go @@ -21,6 +21,7 @@ import ( "bufio" "encoding/json" "fmt" + "io" "net/http" "os" "path/filepath" @@ -303,99 +304,104 @@ func fetchRubyGemsLicenseFrom(url string) (string, error) { continue } - // Ensure body is always closed - func() { - defer resp.Body.Close() + license, wait, retry, hErr := handleRubyGemsResponse(resp) + _ = resp.Body.Close() - switch { - case resp.StatusCode == http.StatusOK: - var info rubyGemsVersionInfo - dec := json.NewDecoder(resp.Body) - if err = dec.Decode(&info); err != nil { - break - } - var items []string - if len(info.Licenses) > 0 { - items = info.Licenses - } else if info.License != "" { - items = []string{info.License} - } - for i := range items { - items[i] = strings.TrimSpace(items[i]) - } - m := make(map[string]struct{}) - for _, it := range items { - if it == "" { - continue - } - m[it] = struct{}{} - } - if len(m) == 0 { - err = nil - // empty license info - return - } - var out []string - for k := range m { - out = append(out, k) - } - slicesSort(out) - // Return successfully - err = nil - url = strings.Join(out, " OR ") - return - - case resp.StatusCode == http.StatusNotFound: - // Treat as no license info available - err = nil - return - - case resp.StatusCode == http.StatusTooManyRequests || (resp.StatusCode >= 500 && resp.StatusCode <= 599): - // Respect Retry-After if present (in seconds) - ra := strings.TrimSpace(resp.Header.Get("Retry-After")) - if ra != "" { - if secs, parseErr := strconv.Atoi(ra); parseErr == nil { - wait := time.Duration(secs) * time.Second - if wait > 10*time.Second { - wait = 10 * time.Second - } - time.Sleep(wait) - } else { - time.Sleep(backoff) - } + if hErr != nil { + if retry && attempt < maxAttempts { + if wait > 0 { + time.Sleep(wait) } else { time.Sleep(backoff) } backoff *= 2 - // Mark a retryable error by setting err non-nil so outer loop continues - err = fmt.Errorf("retryable status: %s", resp.Status) - return + continue + } + return "", hErr + } - default: - err = fmt.Errorf("unexpected status: %s", resp.Status) - return + if retry { // safety branch, normally handled above when hErr != nil + if attempt == maxAttempts { + return "", fmt.Errorf("max attempts reached") } - }() - - // Decide based on err and what we set in the closure - if err == nil { - // For 200 OK with parsed license, we smuggled the result in url variable; for 404 we return "". - // Detect if url was replaced by license string by checking it doesn't start with http. - if !strings.HasPrefix(url, "http") { - return url, nil + if wait > 0 { + time.Sleep(wait) + } else { + time.Sleep(backoff) } - // 404 case or empty license - return "", nil + backoff *= 2 + continue } - // If retryable and attempts left, continue; otherwise return error - if attempt == maxAttempts { - return "", err - } + return license, nil } return "", nil } +func handleRubyGemsResponse(resp *http.Response) (string, time.Duration, bool, error) { + switch { + case resp.StatusCode == http.StatusOK: + license, err := parseRubyGemsLicenseJSON(resp.Body) + return license, 0, false, err + case resp.StatusCode == http.StatusNotFound: + // Treat as no license info available + return "", 0, false, nil + case resp.StatusCode == http.StatusTooManyRequests || (resp.StatusCode >= 500 && resp.StatusCode <= 599): + wait := retryAfterDuration(resp.Header.Get("Retry-After")) + return "", wait, true, fmt.Errorf("retryable status: %s", resp.Status) + default: + return "", 0, false, fmt.Errorf("unexpected status: %s", resp.Status) + } +} + +func parseRubyGemsLicenseJSON(r io.Reader) (string, error) { + var info rubyGemsVersionInfo + dec := json.NewDecoder(r) + if err := dec.Decode(&info); err != nil { + return "", err + } + var items []string + if len(info.Licenses) > 0 { + items = info.Licenses + } else if info.License != "" { + items = []string{info.License} + } + for i := range items { + items[i] = strings.TrimSpace(items[i]) + } + m := make(map[string]struct{}) + for _, it := range items { + if it == "" { + continue + } + m[it] = struct{}{} + } + if len(m) == 0 { + return "", nil + } + var out []string + for k := range m { + out = append(out, k) + } + slicesSort(out) + return strings.Join(out, " OR "), nil +} + +func retryAfterDuration(v string) time.Duration { + v = strings.TrimSpace(v) + if v == "" { + return 0 + } + if secs, err := strconv.Atoi(v); err == nil { + wait := time.Duration(secs) * time.Second + if wait > 10*time.Second { + wait = 10 * time.Second + } + return wait + } + return 0 +} + // small helper to sort string slice without importing sort here to keep imports aligned with style used in this package func slicesSort(ss []string) { // simple insertion sort for small slices From 76db1777c700352c0d28e7a8667e602309ab4ee6 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Wed, 10 Sep 2025 23:26:09 -0600 Subject: [PATCH 4/7] License in file header --- pkg/deps/testdata/ruby/library/sample.gemspec | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/pkg/deps/testdata/ruby/library/sample.gemspec b/pkg/deps/testdata/ruby/library/sample.gemspec index 173a25bd..9ff595dd 100644 --- a/pkg/deps/testdata/ruby/library/sample.gemspec +++ b/pkg/deps/testdata/ruby/library/sample.gemspec @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + Gem::Specification.new do |spec| spec.name = "sample" spec.version = "0.1.0" From 96af87f9be4ca87ece2712f91ad6105b62198d68 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Wed, 10 Sep 2025 23:35:17 -0600 Subject: [PATCH 5/7] Fix markdown HTML formatting --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index d31cc54f..c1946f88 100644 --- a/README.md +++ b/README.md @@ -104,8 +104,7 @@ To check dependencies license in GitHub Actions, add a step in your GitHub workf ```
- - Ruby projects (Bundler) +Ruby projects (Bundler) License-Eye can resolve Ruby dependencies and their licenses directly from Gemfile.lock. From 7be2d58d14dbd774c16baa2a71ff926ab662d5f2 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Wed, 10 Sep 2025 23:40:36 -0600 Subject: [PATCH 6/7] Address lint error - Error: pkg/deps/ruby.go:341:1: unnamedResult: consider giving a name to these results (gocritic) func handleRubyGemsResponse(resp *http.Response) (string, time.Duration, bool, error) { --- pkg/deps/ruby.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/deps/ruby.go b/pkg/deps/ruby.go index 9d844a96..5bbe5a53 100644 --- a/pkg/deps/ruby.go +++ b/pkg/deps/ruby.go @@ -338,7 +338,7 @@ func fetchRubyGemsLicenseFrom(url string) (string, error) { return "", nil } -func handleRubyGemsResponse(resp *http.Response) (string, time.Duration, bool, error) { +func handleRubyGemsResponse(resp *http.Response) (license string, wait time.Duration, retry bool, err error) { switch { case resp.StatusCode == http.StatusOK: license, err := parseRubyGemsLicenseJSON(resp.Body) From d992b25556fb41cd874a25cd6669a50f34dc246a Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Wed, 10 Sep 2025 23:53:46 -0600 Subject: [PATCH 7/7] Ignore gemfile.lock files for license headers --- .licenserc.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.licenserc.yaml b/.licenserc.yaml index 8acde6a0..8f3576b0 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -76,6 +76,8 @@ header: # `header` section is configurations for source codes license header. - "**/assets/assets.gen.go" - "docs/**.svg" - "pkg/gitignore/dir.go" + - "pkg/deps/testdata/ruby/app/Gemfile.lock" + - "pkg/deps/testdata/ruby/library/Gemfile.lock" comment: on-failure # on what condition license-eye will comment on the pull request, `on-failure`, `always`, `never`.