Skip to content

Commit 99f0404

Browse files
committed
✨ --gem-server: Configurable Gem Server URL
Added --gem-server flag to allow users to specify a custom gem server for fetching gem metadata instead of using the hardcoded default. # CLI changes - Added --gem-server URL option in Cyclonedx::BomBuilder command-line parser - Stores custom server URL in @options[:gem_server] for use during BOM generation - When specified, passes the custom gem_server to get_gem() calls - Defaults to gem.coop when not specified, maintaining backward compatibility # Core implementation - Modified Cyclonedx::BomHelpers.get_gem to accept optional gem_server parameter - Defaults to 'https://gem.coop' when nil - Strips trailing slashes from gem_server URLs for consistency - Constructs gem metadata API URL using provided server - Updated get_gem call in bom_builder.rb (line 222) to pass @options[:gem_server] # Tests Unit tests (spec/cyclonedx/bom_helpers_spec.rb): - Validates default behavior uses gem.coop when gem_server is not provided or nil - Verifies custom gem server URLs are used correctly - Tests trailing slash removal from custom server URLs - Confirms rubygems.org works as a custom server - Maintains existing error handling tests Cucumber tests (features/gem_server.feature): - Validates default gem.coop behavior when --gem-server not specified - Tests custom gem server with https://rubygems.org - Tests custom gem server with trailing slash normalization - Verifies help text displays the --gem-server option # Use cases Users can now: - Use private gem servers: --gem-server https://internal.company.com - Use rubygems.org directly: --gem-server https://rubygems.org - Use alternate public mirrors - Default to gem.coop without any configuration change Signed-off-by: Peter H. Boling <[email protected]>
1 parent b1ab078 commit 99f0404

File tree

9 files changed

+200
-48
lines changed

9 files changed

+200
-48
lines changed

.rubocop_todo.yml

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This configuration was generated by
22
# `rubocop --auto-gen-config`
3-
# on 2025-10-30 06:45:50 UTC using RuboCop version 1.81.6.
3+
# on 2025-12-19 21:12:16 UTC using RuboCop version 1.81.6.
44
# The point is for the user to remove these configuration records
55
# one by one as the offenses are removed from the code base.
66
# Note that changes in the inspected code, or installation of new
@@ -20,36 +20,51 @@ Layout/ExtraSpacing:
2020
Exclude:
2121
- 'cyclonedx-ruby.gemspec'
2222

23-
# Offense count: 4
23+
# Offense count: 1
24+
Lint/NoReturnInBeginEndBlocks:
25+
Exclude:
26+
- 'lib/cyclonedx/bom_helpers.rb'
27+
28+
# Offense count: 1
29+
Lint/StructNewOverride:
30+
Exclude:
31+
- 'spec/cyclonedx/component_enrichment_spec.rb'
32+
33+
# Offense count: 6
2434
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
2535
Metrics/AbcSize:
26-
Max: 68
36+
Max: 100
2737

28-
# Offense count: 4
38+
# Offense count: 12
2939
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
3040
# AllowedMethods: refine
3141
Metrics/BlockLength:
32-
Max: 38
42+
Max: 83
3343

3444
# Offense count: 1
3545
# Configuration parameters: CountComments, CountAsOne.
3646
Metrics/ClassLength:
37-
Max: 129
47+
Max: 195
3848

39-
# Offense count: 1
49+
# Offense count: 5
4050
# Configuration parameters: AllowedMethods, AllowedPatterns.
4151
Metrics/CyclomaticComplexity:
42-
Max: 9
52+
Max: 20
4353

44-
# Offense count: 7
54+
# Offense count: 9
4555
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
4656
Metrics/MethodLength:
47-
Max: 69
57+
Max: 108
4858

4959
# Offense count: 1
60+
# Configuration parameters: CountComments, CountAsOne.
61+
Metrics/ModuleLength:
62+
Max: 175
63+
64+
# Offense count: 5
5065
# Configuration parameters: AllowedMethods, AllowedPatterns.
5166
Metrics/PerceivedComplexity:
52-
Max: 12
67+
Max: 25
5368

5469
# Offense count: 4
5570
# Configuration parameters: AllowedConstants.
@@ -67,7 +82,7 @@ Style/MixinUsage:
6782
Exclude:
6883
- 'lib/cyclonedx_deprecated.rb'
6984

70-
# Offense count: 1
85+
# Offense count: 2
7186
# This cop supports unsafe autocorrection (--autocorrect-all).
7287
# Configuration parameters: EnforcedStyle.
7388
# SupportedStyles: literals, strict
@@ -95,7 +110,7 @@ Style/RedundantRegexpEscape:
95110
- 'features/step_definitions/json_bom_matching.rb'
96111
- 'features/step_definitions/xml_bom_matching.rb'
97112

98-
# Offense count: 41
113+
# Offense count: 42
99114
# This cop supports safe autocorrection (--autocorrect).
100115
# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline.
101116
# SupportedStyles: single_quotes, double_quotes
@@ -114,7 +129,15 @@ Style/StringLiterals:
114129
Style/SymbolArray:
115130
EnforcedStyle: brackets
116131

117-
# Offense count: 7
132+
# Offense count: 1
133+
# This cop supports unsafe autocorrection (--autocorrect-all).
134+
# Configuration parameters: AllowMethodsWithArguments, AllowedMethods, AllowedPatterns, AllowComments.
135+
# AllowedMethods: define_method
136+
Style/SymbolProc:
137+
Exclude:
138+
- 'lib/cyclonedx/bom_helpers.rb'
139+
140+
# Offense count: 17
118141
# This cop supports safe autocorrection (--autocorrect).
119142
# Configuration parameters: AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings.
120143
# URISchemes: http, https

features/gem_server.feature

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
Feature: Custom Gem Server
2+
3+
The `cyclonedx-ruby` command should allow users to specify a custom gem server
4+
to fetch gem metadata from, instead of using the default gem.coop server.
5+
6+
Scenario: Use default gem server (gem.coop)
7+
Given I use a fixture named "simple"
8+
And I run `cyclonedx-ruby --path .`
9+
Then the output should contain:
10+
"""
11+
5 gems were written to BOM located at ./bom.xml
12+
"""
13+
And a file named "bom.xml" should exist
14+
And the generated XML BOM file "bom.xml" matches "bom.xml.expected"
15+
16+
Scenario: Use custom gem server
17+
Given I use a fixture named "simple"
18+
And I run `cyclonedx-ruby --path . --gem-server https://rubygems.org`
19+
Then the output should contain:
20+
"""
21+
5 gems were written to BOM located at ./bom.xml
22+
"""
23+
And a file named "bom.xml" should exist
24+
25+
Scenario: Use custom gem server with trailing slash
26+
Given I use a fixture named "simple"
27+
And I run `cyclonedx-ruby --path . --gem-server https://rubygems.org/`
28+
Then the output should contain:
29+
"""
30+
5 gems were written to BOM located at ./bom.xml
31+
"""
32+
And a file named "bom.xml" should exist
33+
34+
Scenario: Help shows gem-server option
35+
Given I run `cyclonedx-ruby --help`
36+
Then the output should contain:
37+
"""
38+
--gem-server URL Gem server URL to fetch gem metadata (default: https://gem.coop)
39+
"""
40+

features/help.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ Scenario: Generate help on demand
1616
--validate PATH Validate an existing BOM file instead of generating one
1717
--include-metadata Include metadata.tools identifying cyclonedx-ruby as the producer
1818
--enrich-components Include bom-ref and publisher fields on components (uses purl and first author)
19+
--gem-server URL Gem server URL to fetch gem metadata (default: https://gem.coop)
1920
-h, --help Show help message
2021
"""

lib/cyclonedx/bom_builder.rb

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,6 @@ def self.infer_format_from_path(path)
7777
File.extname(path).downcase == '.json' ? 'json' : 'xml'
7878
end
7979

80-
private
81-
8280
def self.setup(path)
8381
@options = {}
8482
OptionParser.new do |opts|
@@ -108,6 +106,9 @@ def self.setup(path)
108106
opts.on('--enrich-components', 'Include bom-ref and publisher fields on components (uses purl and first author)') do
109107
@options[:enrich_components] = true
110108
end
109+
opts.on('--gem-server URL', 'Gem server URL to fetch gem metadata (default: https://gem.coop)') do |gem_server|
110+
@options[:gem_server] = gem_server
111+
end
111112
opts.on_tail('-h', '--help', 'Show help message') do
112113
puts opts
113114
exit
@@ -192,20 +193,20 @@ def self.setup(path)
192193

193194
@logger.info("BOM will be written to #{@bom_file_path}") if @project_path
194195

195-
if @project_path
196-
begin
197-
# Use absolute path so it's correct regardless of current working directory
198-
gemfile_path = File.join(@project_path, 'Gemfile.lock')
199-
# Compute display path for logs: './Gemfile.lock' when provided path is '.', else '<provided>/Gemfile.lock'
200-
display_gemfile_path = (@provided_path == '.' ? './Gemfile.lock' : File.join(@provided_path, 'Gemfile.lock'))
201-
@logger.info("Parsing specs from #{display_gemfile_path}...")
202-
gemfile_contents = File.read(gemfile_path)
203-
@specs = Bundler::LockfileParser.new(gemfile_contents).specs
204-
@logger.info('Specs successfully parsed!')
205-
rescue StandardError => e
206-
@logger.error("Unable to parse specs from #{gemfile_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
207-
abort
208-
end
196+
return unless @project_path
197+
198+
begin
199+
# Use absolute path so it's correct regardless of current working directory
200+
gemfile_path = File.join(@project_path, 'Gemfile.lock')
201+
# Compute display path for logs: './Gemfile.lock' when provided path is '.', else '<provided>/Gemfile.lock'
202+
display_gemfile_path = (@provided_path == '.' ? './Gemfile.lock' : File.join(@provided_path, 'Gemfile.lock'))
203+
@logger.info("Parsing specs from #{display_gemfile_path}...")
204+
gemfile_contents = File.read(gemfile_path)
205+
@specs = Bundler::LockfileParser.new(gemfile_contents).specs
206+
@logger.info('Specs successfully parsed!')
207+
rescue StandardError => e
208+
@logger.error("Unable to parse specs from #{gemfile_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
209+
abort
209210
end
210211
end
211212

@@ -216,7 +217,7 @@ def self.specs_list
216217
object.name = dependency.name
217218
object.version = dependency.version
218219
object.purl = purl(object.name, object.version)
219-
gem = get_gem(object.name, object.version, @logger)
220+
gem = get_gem(object.name, object.version, @logger, @options[:gem_server])
220221
next if gem.nil?
221222

222223
if gem['licenses']&.length&.positive?

lib/cyclonedx/bom_component.rb

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def hash_val(include_enrichment: false)
3131

3232
if include_enrichment
3333
# Add bom-ref using the purl when present
34-
component_hash[:"bom-ref"] = @purl if @purl && !@purl.to_s.empty?
34+
component_hash[:'bom-ref'] = @purl if @purl && !@purl.to_s.empty?
3535
# Add publisher using first author if present
3636
author = fetch('author')
3737
if author && !author.to_s.strip.empty?
@@ -41,18 +41,18 @@ def hash_val(include_enrichment: false)
4141
end
4242

4343
if fetch('license_id')
44-
component_hash[:"licenses"] = [
44+
component_hash[:licenses] = [
4545
{
46-
"license": {
47-
"id": fetch('license_id')
46+
license: {
47+
id: fetch('license_id')
4848
}
4949
}
5050
]
5151
elsif fetch('license_name')
52-
component_hash[:"licenses"] = [
52+
component_hash[:licenses] = [
5353
{
54-
"license": {
55-
"name": fetch('license_name')
54+
license: {
55+
name: fetch('license_name')
5656
}
5757
}
5858
]
@@ -68,8 +68,6 @@ def fetch(key)
6868
@gem[key]
6969
elsif @gem.respond_to?(key)
7070
@gem.public_send(key)
71-
else
72-
nil
7371
end
7472
end
7573
end

lib/cyclonedx/bom_helpers.rb

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,6 @@ def _get(obj, key)
6363
obj[key]
6464
elsif obj.respond_to?(key)
6565
obj.public_send(key)
66-
else
67-
nil
6866
end
6967
end
7068

@@ -172,7 +170,7 @@ def build_bom_xml(gems, spec_version, include_metadata: false, include_enrichmen
172170
# Validate content against the selected CycloneDX schema (local files, offline)
173171
# Returns [true, nil] on success; [false, "message"] on failure
174172
def validate_bom_content(content, format, spec_version)
175-
schema_dir = File.expand_path("../../schema", __dir__)
173+
schema_dir = File.expand_path('../../schema', __dir__)
176174
case format
177175
when 'json'
178176
schema_path = File.join(schema_dir, "bom-#{spec_version}.schema.json")
@@ -193,6 +191,7 @@ def validate_bom_content(content, format, spec_version)
193191
data = JSON.parse(content)
194192
errors = schemer.validate(data).to_a
195193
return [true, nil] if errors.empty?
194+
196195
# Build a compact error message
197196
msgs = errors.first(5).map do |e|
198197
path = Array(e['data_pointer']).join
@@ -208,12 +207,13 @@ def validate_bom_content(content, format, spec_version)
208207
schema_path = File.join(schema_dir, "bom-#{spec_version}.xsd")
209208
begin
210209
# Use local XML catalog to resolve imports like http://cyclonedx.org/schema/spdx
211-
previous_catalog = ENV['XML_CATALOG_FILES']
210+
previous_catalog = ENV.fetch('XML_CATALOG_FILES', nil)
212211
ENV['XML_CATALOG_FILES'] = File.join(schema_dir, 'xmlcatalog.xml')
213212
xsd = Nokogiri::XML::Schema(File.read(schema_path))
214213
doc = Nokogiri::XML(content) { |cfg| cfg.nonet }
215214
errors = xsd.validate(doc)
216215
return [true, nil] if errors.empty?
216+
217217
[false, "XML schema validation failed: #{errors.first.message}"]
218218
rescue Errno::ENOENT
219219
[false, "XML schema not found at #{schema_path}"]
@@ -225,8 +225,11 @@ def validate_bom_content(content, format, spec_version)
225225
end
226226
end
227227

228-
def get_gem(name, version, logger)
229-
url = "https://gem.coop/api/v1/versions/#{name}.json"
228+
def get_gem(name, version, logger, gem_server = nil)
229+
gem_server ||= 'https://gem.coop'
230+
# Remove trailing slash if present
231+
gem_server = gem_server.chomp('/')
232+
url = "#{gem_server}/api/v1/versions/#{name}.json"
230233
begin
231234
RestClient.proxy = ENV.fetch('http_proxy', nil)
232235
response = RestClient::Request.execute(method: :get, url: url, read_timeout: 2, open_timeout: 2)

0 commit comments

Comments
 (0)