@@ -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,8 +62,24 @@ 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 
4680
81+     private 
82+ 
4783    def  self . setup ( path ) 
4884      @options  =  { } 
4985      OptionParser . new  do  |opts |
@@ -64,6 +100,12 @@ def self.setup(path)
64100        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 |
65101          @options [ :spec_version ]  =  spec_version 
66102        end 
103+         opts . on ( '--validate' ,  'Validate the produced BOM against the selected CycloneDX schema' )  do 
104+           @options [ :validate ]  =  true 
105+         end 
106+         opts . on ( '--validate-file PATH' ,  'Validate an existing BOM file instead of generating one' )  do  |path |
107+           @options [ :validate_file ]  =  path 
108+         end 
67109        opts . on_tail ( '-h' ,  '--help' ,  'Show help message' )  do 
68110          puts  opts 
69111          exit 
@@ -86,26 +128,31 @@ def self.setup(path)
86128      licenses_file  =  File . read ( licenses_path ) 
87129      @licenses_list  =  JSON . parse ( licenses_file ) 
88130
89-       if  @options [ :path ] . nil? 
90-         @logger . error ( 'missing path to project directory' ) 
91-         abort 
92-       end 
131+       # If only validating a file, project path is optional; otherwise require 
132+       if  @options [ :validate_file ] . nil?  || !@options [ :validate ] 
133+         if  @options [ :path ] . nil? 
134+           @logger . error ( 'missing path to project directory' ) 
135+           abort 
136+         end 
93137
94-       unless  File . directory? ( @options [ :path ] ) 
95-         @logger . error ( "path provided is not a valid directory. path provided was: #{ @options [ :path ] }  " ) 
96-         abort 
138+         unless  File . directory? ( @options [ :path ] ) 
139+           @logger . error ( "path provided is not a valid directory. path provided was: #{ @options [ :path ] }  " ) 
140+           abort 
141+         end 
97142      end 
98143
99144      # Normalize to an absolute project path to avoid relative path issues later 
100-       @project_path  =  File . expand_path ( @options [ :path ] ) 
145+       @project_path  =  File . expand_path ( @options [ :path ] )   if   @options [ :path ] 
101146      @provided_path  =  @options [ :path ] 
102147
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 
148+       if  @project_path 
149+         begin 
150+           @logger . info ( "Changing directory to Ruby project directory located at #{ @provided_path }  " ) 
151+           Dir . chdir  @project_path 
152+         rescue  StandardError  =>  e 
153+           @logger . error ( "Unable to change directory to Ruby project directory located at #{ @provided_path }  . #{ e . message }  : #{ Array ( e . backtrace ) . join ( "\n " ) }  " ) 
154+           abort 
155+         end 
109156      end 
110157
111158      if  @options [ :bom_output_format ] . nil? 
@@ -132,20 +179,22 @@ def self.setup(path)
132179                         @options [ :bom_file_path ] 
133180                       end 
134181
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 
182+       @logger . info ( "BOM will be written to #{ @bom_file_path }  " )  if  @project_path 
183+ 
184+       if  @project_path 
185+         begin 
186+           # Use absolute path so it's correct regardless of current working directory 
187+           gemfile_path  =  File . join ( @project_path ,  'Gemfile.lock' ) 
188+           # Compute display path for logs: './Gemfile.lock' when provided path is '.', else '<provided>/Gemfile.lock' 
189+           display_gemfile_path  =  ( @provided_path  == '.'  ? './Gemfile.lock'  : File . join ( @provided_path ,  'Gemfile.lock' ) ) 
190+           @logger . info ( "Parsing specs from #{ display_gemfile_path }  ..." ) 
191+           gemfile_contents  =  File . read ( gemfile_path ) 
192+           @specs  =  Bundler ::LockfileParser . new ( gemfile_contents ) . specs 
193+           @logger . info ( 'Specs successfully parsed!' ) 
194+         rescue  StandardError  =>  e 
195+           @logger . error ( "Unable to parse specs from #{ gemfile_path }  . #{ e . message }  : #{ Array ( e . backtrace ) . join ( "\n " ) }  " ) 
196+           abort 
197+         end 
149198      end 
150199    end 
151200
0 commit comments