Skip to content

Commit 14714af

Browse files
committed
✨ --validate
# CLI and validation - Added --validate and --validate-file flags in Cyclonedx::BomBuilder. - After writing the BOM, if --validate is set, validate JSON via JSON Schema and XML via XSD with local files under schema/. - Added logic to validate an existing file with --validate --validate-file <path>, inferring format from extension unless --format is provided.</path> - In validate-only mode, project path isn’t required. # Validation helpers - Added Cyclonedx::BomHelpers.validate_bom_content(content, format, spec_version) which: - For JSON: uses json_schemer to validate against bom-<ver>.schema.json.</ver> - For XML: uses Nokogiri::XML::Schema with bom-<ver>.xsd.</ver> - Uses local schemas at schema/ and surfaces compact error messages; returns non-zero exit on failure. # Dependencies - Added json_schemer (~> 2.2) to cyclonedx-ruby.gemspec. - Required json_schemer in lib/cyclonedx/ruby.rb. # Cucumber tests - Updated features/help.feature to show the new flags. - Added features/validate.feature: - Validate XML BOM succeeds. - Validate JSON BOM succeeds. - Validate fails for invalid XML BOM (corrupts namespace and expects exit 1). # Small extras - Infer format from file extension when using --validate-file and no --format provided. Signed-off-by: Peter H. Boling <[email protected]>
1 parent 9171184 commit 14714af

File tree

9 files changed

+199
-36
lines changed

9 files changed

+199
-36
lines changed

Gemfile.lock

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ PATH
44
cyclonedx-ruby (1.2.0)
55
activesupport (~> 7.0)
66
json (~> 2.6)
7+
json_schemer (~> 2.2)
78
nokogiri (~> 1.15)
89
ostruct (~> 0.5.5)
910
rest-client (~> 2.0)
@@ -75,12 +76,18 @@ GEM
7576
ffi (1.17.2-x86_64-darwin)
7677
ffi (1.17.2-x86_64-linux-gnu)
7778
ffi (1.17.2-x86_64-linux-musl)
79+
hana (1.3.7)
7880
http-accept (1.7.0)
7981
http-cookie (1.1.0)
8082
domain_name (~> 0.5)
8183
i18n (1.14.7)
8284
concurrent-ruby (~> 1.0)
8385
json (2.15.2)
86+
json_schemer (2.4.0)
87+
bigdecimal
88+
hana (~> 1.3)
89+
regexp_parser (~> 2.0)
90+
simpleidn (~> 0.2)
8491
language_server-protocol (3.17.0.5)
8592
lint_roller (1.1.0)
8693
logger (1.7.0)
@@ -90,13 +97,9 @@ GEM
9097
mime-types-data (~> 3.2025, >= 3.2025.0507)
9198
mime-types-data (3.2025.0924)
9299
mini_mime (1.1.5)
93-
mini_portile2 (2.8.9)
94100
minitest (5.26.0)
95101
multi_test (1.1.0)
96102
netrc (0.11.0)
97-
nokogiri (1.18.10)
98-
mini_portile2 (~> 2.8.2)
99-
racc (~> 1.4)
100103
nokogiri (1.18.10-aarch64-linux-gnu)
101104
racc (~> 1.4)
102105
nokogiri (1.18.10-aarch64-linux-musl)
@@ -163,6 +166,7 @@ GEM
163166
simplecov_json_formatter (~> 0.1)
164167
simplecov-html (0.13.2)
165168
simplecov_json_formatter (0.1.4)
169+
simpleidn (0.2.3)
166170
sys-uname (1.4.1)
167171
ffi (~> 1.1)
168172
memoist3 (~> 1.0.0)
@@ -179,7 +183,6 @@ PLATFORMS
179183
arm-linux-gnu
180184
arm-linux-musl
181185
arm64-darwin
182-
ruby
183186
x86_64-darwin
184187
x86_64-linux-gnu
185188
x86_64-linux-musl

cucumber.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
default: --publish-quiet
1+
default: --publish-quiet --format progress features
2+

cyclonedx-ruby.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ Gem::Specification.new do |spec|
6464
spec.add_dependency('ostruct', '~> 0.5.5')
6565
spec.add_dependency('rest-client', '~> 2.0')
6666
spec.add_dependency('activesupport', '~> 7.0')
67+
spec.add_dependency('json_schemer', '~> 2.2')
6768
spec.add_development_dependency 'rake', '~> 13'
6869
spec.add_development_dependency 'rspec', '~> 3.12'
6970
spec.add_development_dependency 'cucumber', '~> 10.1', '>= 10.1.1'

exe/cyclonedx-ruby

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33

44
if ENV.fetch("MIMIC_NEXT_MAJOR_VERSION", "false").casecmp?("true")
55
require 'cyclonedx/ruby'
6-
Cyclonedx::BomBuilder.build(ARGV[0])
6+
path_arg = ARGV[0]
7+
path_arg = nil if path_arg&.start_with?('-')
8+
Cyclonedx::BomBuilder.build(path_arg)
79
else
810
require 'bom_builder'
9-
Bombuilder.build(ARGV[0])
11+
path_arg = ARGV[0]
12+
path_arg = nil if path_arg&.start_with?('-')
13+
Bombuilder.build(path_arg)
1014
end

features/help.feature

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,7 @@ Scenario: Generate help on demand
1313
-o, --output bom_file_path (Optional) Path to output the bom.xml file to
1414
-f, --format bom_output_format (Optional) Output format for bom. Currently support xml (default) and json.
1515
-s, --spec-version version (Optional) CycloneDX spec version to target (default: 1.7). Supported: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7
16+
--validate Validate the produced BOM against the selected CycloneDX schema
17+
--validate-file PATH Validate an existing BOM file instead of generating one
1618
-h, --help Show help message
1719
"""

features/validate.feature

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
Feature: Validate generated BOM against CycloneDX schema
2+
3+
Scenario: Validate XML BOM succeeds
4+
Given I use a fixture named "simple"
5+
And I run `cyclonedx-ruby --path . --format xml --validate`
6+
Then the output should contain:
7+
"""
8+
5 gems were written to BOM located at ./bom.xml
9+
"""
10+
And a file named "bom.xml" should exist
11+
12+
Scenario: Validate JSON BOM succeeds
13+
Given I use a fixture named "simple"
14+
And I run `cyclonedx-ruby --path . --format json --validate`
15+
Then the output should contain:
16+
"""
17+
5 gems were written to BOM located at ./bom.json
18+
"""
19+
And a file named "bom.json" should exist
20+
21+
Scenario: Validate fails for invalid XML BOM
22+
Given I use a fixture named "simple"
23+
And I run `cyclonedx-ruby --path . --format xml`
24+
Then a file named "bom.xml" should exist
25+
When I run `sh -lc "sed -i 's|http://cyclonedx.org/schema/bom/1.7|http://cyclonedx.org/schema/bom/9.9|' bom.xml"`
26+
And I run `cyclonedx-ruby --validate --validate-file bom.xml --spec-version 1.7`
27+
Then the exit status should be 1
28+
29+
Scenario: Validate existing XML BOM succeeds
30+
Given I use a fixture named "simple"
31+
And I run `cyclonedx-ruby --path . --format xml`
32+
Then a file named "bom.xml" should exist
33+
When I run `cyclonedx-ruby --validate --validate-file bom.xml --spec-version 1.7`
34+
Then the output should contain:
35+
"""
36+
Validation succeeded for bom.xml (spec 1.7)
37+
"""
38+
39+
Scenario: Validate existing JSON BOM succeeds
40+
Given I use a fixture named "simple"
41+
And I run `cyclonedx-ruby --path . --format json`
42+
Then a file named "bom.json" should exist
43+
When I run `cyclonedx-ruby --validate --validate-file bom.json --spec-version 1.7`
44+
Then the output should contain:
45+
"""
46+
Validation succeeded for bom.json (spec 1.7)
47+
"""

lib/cyclonedx/bom_builder.rb

Lines changed: 76 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,26 @@ class BomBuilder
1010
def self.build(path)
1111
original_working_directory = Dir.pwd
1212
setup(path)
13+
14+
# If asked to validate an existing file, do not generate a new one
15+
if @options[:validate] && @options[:validate_file]
16+
content = begin
17+
File.read(@options[:validate_file])
18+
rescue StandardError => e
19+
@logger.error("Unable to read file for validation: #{@options[:validate_file]}. #{e.message}")
20+
exit(1)
21+
end
22+
# Use explicitly provided format if set, otherwise infer from file extension
23+
format = @options[:bom_output_format] || infer_format_from_path(@options[:validate_file])
24+
success, message = validate_bom_content(content, format, @spec_version)
25+
unless success
26+
@logger.error(message)
27+
exit(1)
28+
end
29+
puts "Validation succeeded for #{@options[:validate_file]} (spec #{@spec_version})" unless @options[:verbose]
30+
return
31+
end
32+
1333
specs_list
1434
bom = build_bom(@gems, @bom_output_format, @spec_version)
1535

@@ -42,7 +62,22 @@ def self.build(path)
4262
@logger.error("Unable to write BOM to #{@bom_file_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
4363
abort
4464
end
65+
66+
if @options[:validate]
67+
success, message = validate_bom_content(bom, @bom_output_format, @spec_version)
68+
unless success
69+
@logger.error(message)
70+
exit(1)
71+
end
72+
@logger.info("BOM validation succeeded for spec #{@spec_version}") if @options[:verbose]
73+
end
74+
end
75+
76+
# Infer format from file extension when not explicitly provided
77+
def self.infer_format_from_path(path)
78+
File.extname(path).downcase == '.json' ? 'json' : 'xml'
4579
end
80+
4681
private
4782
def self.setup(path)
4883
@options = {}
@@ -64,6 +99,12 @@ def self.setup(path)
6499
opts.on('-s', '--spec-version version', '(Optional) CycloneDX spec version to target (default: 1.7). Supported: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7') do |spec_version|
65100
@options[:spec_version] = spec_version
66101
end
102+
opts.on('--validate', 'Validate the produced BOM against the selected CycloneDX schema') do
103+
@options[:validate] = true
104+
end
105+
opts.on('--validate-file PATH', 'Validate an existing BOM file instead of generating one') do |path|
106+
@options[:validate_file] = path
107+
end
67108
opts.on_tail('-h', '--help', 'Show help message') do
68109
puts opts
69110
exit
@@ -86,26 +127,31 @@ def self.setup(path)
86127
licenses_file = File.read(licenses_path)
87128
@licenses_list = JSON.parse(licenses_file)
88129

89-
if @options[:path].nil?
90-
@logger.error('missing path to project directory')
91-
abort
92-
end
130+
# If only validating a file, project path is optional; otherwise require
131+
if @options[:validate_file].nil? || !@options[:validate]
132+
if @options[:path].nil?
133+
@logger.error('missing path to project directory')
134+
abort
135+
end
93136

94-
unless File.directory?(@options[:path])
95-
@logger.error("path provided is not a valid directory. path provided was: #{@options[:path]}")
96-
abort
137+
unless File.directory?(@options[:path])
138+
@logger.error("path provided is not a valid directory. path provided was: #{@options[:path]}")
139+
abort
140+
end
97141
end
98142

99143
# Normalize to an absolute project path to avoid relative path issues later
100-
@project_path = File.expand_path(@options[:path])
144+
@project_path = File.expand_path(@options[:path]) if @options[:path]
101145
@provided_path = @options[:path]
102146

103-
begin
104-
@logger.info("Changing directory to Ruby project directory located at #{@provided_path}")
105-
Dir.chdir @project_path
106-
rescue StandardError => e
107-
@logger.error("Unable to change directory to Ruby project directory located at #{@provided_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
108-
abort
147+
if @project_path
148+
begin
149+
@logger.info("Changing directory to Ruby project directory located at #{@provided_path}")
150+
Dir.chdir @project_path
151+
rescue StandardError => e
152+
@logger.error("Unable to change directory to Ruby project directory located at #{@provided_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
153+
abort
154+
end
109155
end
110156

111157
if @options[:bom_output_format].nil?
@@ -132,20 +178,22 @@ def self.setup(path)
132178
@options[:bom_file_path]
133179
end
134180

135-
@logger.info("BOM will be written to #{@bom_file_path}")
136-
137-
begin
138-
# Use absolute path so it's correct regardless of current working directory
139-
gemfile_path = File.join(@project_path, 'Gemfile.lock')
140-
# Compute display path for logs: './Gemfile.lock' when provided path is '.', else '<provided>/Gemfile.lock'
141-
display_gemfile_path = (@provided_path == '.' ? './Gemfile.lock' : File.join(@provided_path, 'Gemfile.lock'))
142-
@logger.info("Parsing specs from #{display_gemfile_path}...")
143-
gemfile_contents = File.read(gemfile_path)
144-
@specs = Bundler::LockfileParser.new(gemfile_contents).specs
145-
@logger.info('Specs successfully parsed!')
146-
rescue StandardError => e
147-
@logger.error("Unable to parse specs from #{gemfile_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
148-
abort
181+
@logger.info("BOM will be written to #{@bom_file_path}") if @project_path
182+
183+
if @project_path
184+
begin
185+
# Use absolute path so it's correct regardless of current working directory
186+
gemfile_path = File.join(@project_path, 'Gemfile.lock')
187+
# Compute display path for logs: './Gemfile.lock' when provided path is '.', else '<provided>/Gemfile.lock'
188+
display_gemfile_path = (@provided_path == '.' ? './Gemfile.lock' : File.join(@provided_path, 'Gemfile.lock'))
189+
@logger.info("Parsing specs from #{display_gemfile_path}...")
190+
gemfile_contents = File.read(gemfile_path)
191+
@specs = Bundler::LockfileParser.new(gemfile_contents).specs
192+
@logger.info('Specs successfully parsed!')
193+
rescue StandardError => e
194+
@logger.error("Unable to parse specs from #{gemfile_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
195+
abort
196+
end
149197
end
150198
end
151199

lib/cyclonedx/bom_helpers.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,62 @@ def build_bom_xml(gems, spec_version)
106106
builder.to_xml
107107
end
108108

109+
# Validate content against the selected CycloneDX schema (local files, offline)
110+
# Returns [true, nil] on success; [false, "message"] on failure
111+
def validate_bom_content(content, format, spec_version)
112+
schema_dir = File.expand_path("../../schema", __dir__)
113+
case format
114+
when 'json'
115+
schema_path = File.join(schema_dir, "bom-#{spec_version}.schema.json")
116+
begin
117+
schema = JSON.parse(File.read(schema_path))
118+
resolver = lambda do |uri|
119+
begin
120+
u = uri.is_a?(URI) ? uri : URI.parse(uri.to_s)
121+
basename = File.basename(u.path.to_s)
122+
local_path = File.join(schema_dir, basename)
123+
return JSON.parse(File.read(local_path)) if File.exist?(local_path)
124+
rescue StandardError
125+
# fall through to unknown ref handling in schemer
126+
end
127+
nil
128+
end
129+
schemer = JSONSchemer.schema(schema, ref_resolver: resolver)
130+
data = JSON.parse(content)
131+
errors = schemer.validate(data).to_a
132+
return [true, nil] if errors.empty?
133+
# Build a compact error message
134+
msgs = errors.first(5).map do |e|
135+
path = Array(e['data_pointer']).join
136+
"#{e['type']}: #{e['message']} at #{path}"
137+
end
138+
[false, "JSON schema validation failed (#{errors.size} errors). First: #{msgs.join('; ')}"]
139+
rescue Errno::ENOENT
140+
[false, "JSON schema not found at #{schema_path}"]
141+
rescue StandardError => e
142+
[false, "JSON schema validation error: #{e.class}: #{e.message}"]
143+
end
144+
else
145+
schema_path = File.join(schema_dir, "bom-#{spec_version}.xsd")
146+
begin
147+
# Use local XML catalog to resolve imports like http://cyclonedx.org/schema/spdx
148+
previous_catalog = ENV['XML_CATALOG_FILES']
149+
ENV['XML_CATALOG_FILES'] = File.join(schema_dir, 'xmlcatalog.xml')
150+
xsd = Nokogiri::XML::Schema(File.read(schema_path))
151+
doc = Nokogiri::XML(content) { |cfg| cfg.nonet }
152+
errors = xsd.validate(doc)
153+
return [true, nil] if errors.empty?
154+
[false, "XML schema validation failed: #{errors.first.message}"]
155+
rescue Errno::ENOENT
156+
[false, "XML schema not found at #{schema_path}"]
157+
rescue StandardError => e
158+
[false, "XML schema validation error: #{e.class}: #{e.message}"]
159+
ensure
160+
ENV['XML_CATALOG_FILES'] = previous_catalog
161+
end
162+
end
163+
end
164+
109165
def get_gem(name, version)
110166
url = "https://rubygems.org/api/v1/versions/#{name}.json"
111167
begin

lib/cyclonedx/ruby.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
require 'rest_client'
1212
require 'securerandom'
1313
require 'active_support/core_ext/hash'
14+
require 'json_schemer'
1415

1516
# This gem
1617
require_relative "ruby/version"

0 commit comments

Comments
 (0)