github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/language/ruby/gemspec/gemspec_test.go (about)

     1  // Copyright 2025 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package gemspec_test
    16  
    17  import (
    18  	"io/fs"
    19  	"os"
    20  	"path/filepath"
    21  	"testing"
    22  
    23  	"github.com/google/go-cmp/cmp"
    24  	"github.com/google/go-cmp/cmp/cmpopts"
    25  	"github.com/google/osv-scalibr/extractor"
    26  	"github.com/google/osv-scalibr/extractor/filesystem"
    27  	"github.com/google/osv-scalibr/extractor/filesystem/internal/units"
    28  	"github.com/google/osv-scalibr/extractor/filesystem/language/ruby/gemspec"
    29  	"github.com/google/osv-scalibr/extractor/filesystem/simplefileapi"
    30  	scalibrfs "github.com/google/osv-scalibr/fs"
    31  	"github.com/google/osv-scalibr/inventory"
    32  	"github.com/google/osv-scalibr/purl"
    33  	"github.com/google/osv-scalibr/stats"
    34  	"github.com/google/osv-scalibr/testing/fakefs"
    35  	"github.com/google/osv-scalibr/testing/testcollector"
    36  )
    37  
    38  func TestFileRequired(t *testing.T) {
    39  	tests := []struct {
    40  		name             string
    41  		path             string
    42  		fileSizeBytes    int64
    43  		maxFileSizeBytes int64
    44  		wantRequired     bool
    45  		wantResultMetric stats.FileRequiredResult
    46  	}{
    47  		{
    48  			name:             "yaml gemspec",
    49  			path:             "testdata/yaml-0.2.1.gemspec",
    50  			wantRequired:     true,
    51  			wantResultMetric: stats.FileRequiredResultOK,
    52  		},
    53  		{
    54  			name:         "ruby file",
    55  			path:         "testdata/test.rb",
    56  			wantRequired: false,
    57  		},
    58  		{
    59  			name:             "yaml gemspec required if file size < max file size",
    60  			path:             "testdata/yaml-0.2.1.gemspec",
    61  			fileSizeBytes:    100 * units.KiB,
    62  			maxFileSizeBytes: 1000 * units.KiB,
    63  			wantRequired:     true,
    64  			wantResultMetric: stats.FileRequiredResultOK,
    65  		},
    66  		{
    67  			name:             "yaml gemspec required if file size == max file size",
    68  			path:             "testdata/yaml-0.2.1.gemspec",
    69  			fileSizeBytes:    1000 * units.KiB,
    70  			maxFileSizeBytes: 1000 * units.KiB,
    71  			wantRequired:     true,
    72  			wantResultMetric: stats.FileRequiredResultOK,
    73  		},
    74  		{
    75  			name:             "yaml gemspec not required if file size > max file size",
    76  			path:             "testdata/yaml-0.2.1.gemspec",
    77  			fileSizeBytes:    1000 * units.KiB,
    78  			maxFileSizeBytes: 100 * units.KiB,
    79  			wantRequired:     false,
    80  			wantResultMetric: stats.FileRequiredResultSizeLimitExceeded,
    81  		},
    82  		{
    83  			name:             "yaml gemspec required if max file size set to 0",
    84  			path:             "testdata/yaml-0.2.1.gemspec",
    85  			fileSizeBytes:    1000 * units.KiB,
    86  			maxFileSizeBytes: 0,
    87  			wantRequired:     true,
    88  			wantResultMetric: stats.FileRequiredResultOK,
    89  		},
    90  	}
    91  
    92  	for _, test := range tests {
    93  		t.Run(test.name, func(t *testing.T) {
    94  			collector := testcollector.New()
    95  			var e filesystem.Extractor = gemspec.New(
    96  				gemspec.Config{
    97  					Stats:            collector,
    98  					MaxFileSizeBytes: test.maxFileSizeBytes,
    99  				},
   100  			)
   101  
   102  			// Set default size if not provided.
   103  			fileSizeBytes := test.fileSizeBytes
   104  			if fileSizeBytes == 0 {
   105  				fileSizeBytes = 100 * units.KiB
   106  			}
   107  
   108  			isRequired := e.FileRequired(simplefileapi.New(test.path, fakefs.FakeFileInfo{
   109  				FileName: filepath.Base(test.path),
   110  				FileMode: fs.ModePerm,
   111  				FileSize: fileSizeBytes,
   112  			}))
   113  			if isRequired != test.wantRequired {
   114  				t.Fatalf("FileRequired(%s): got %v, want %v", test.path, isRequired, test.wantRequired)
   115  			}
   116  
   117  			gotResultMetric := collector.FileRequiredResult(test.path)
   118  			if gotResultMetric != test.wantResultMetric {
   119  				t.Errorf("FileRequired(%s) recorded result metric %v, want result metric %v", test.path, gotResultMetric, test.wantResultMetric)
   120  			}
   121  		})
   122  	}
   123  }
   124  
   125  func TestExtract(t *testing.T) {
   126  	tests := []struct {
   127  		name             string
   128  		path             string
   129  		wantPackages     []*extractor.Package
   130  		wantErr          error
   131  		wantResultMetric stats.FileExtractedResult
   132  	}{
   133  		{
   134  			name: "yaml_gemspec",
   135  			path: "testdata/yaml-0.2.1.gemspec",
   136  			wantPackages: []*extractor.Package{
   137  				{
   138  					Name:      "yaml",
   139  					Version:   "0.2.1",
   140  					PURLType:  purl.TypeGem,
   141  					Locations: []string{"testdata/yaml-0.2.1.gemspec"},
   142  				},
   143  			},
   144  			wantResultMetric: stats.FileExtractedResultSuccess,
   145  		},
   146  		{
   147  			name: "rss_gemspec",
   148  			path: "testdata/rss-0.2.9.gemspec",
   149  			wantPackages: []*extractor.Package{
   150  				{
   151  					Name:      "rss",
   152  					Version:   "0.2.9",
   153  					PURLType:  purl.TypeGem,
   154  					Locations: []string{"testdata/rss-0.2.9.gemspec"},
   155  				},
   156  			},
   157  			wantResultMetric: stats.FileExtractedResultSuccess,
   158  		},
   159  		{
   160  			name: "version constant gemspec",
   161  			path: "testdata/version_constant/version_constant.gemspec",
   162  			wantPackages: []*extractor.Package{
   163  				{
   164  					Name:      "example_app",
   165  					Version:   "1.2.3",
   166  					PURLType:  purl.TypeGem,
   167  					Locations: []string{"testdata/version_constant/version_constant.gemspec"},
   168  				},
   169  			},
   170  			wantResultMetric: stats.FileExtractedResultSuccess,
   171  		},
   172  		{
   173  			name: "version constant with freeze",
   174  			path: "testdata/version_constant_freeze/version_constant_freeze.gemspec",
   175  			wantPackages: []*extractor.Package{
   176  				{
   177  					Name:      "example_app_freeze",
   178  					Version:   "2.3.4",
   179  					PURLType:  purl.TypeGem,
   180  					Locations: []string{"testdata/version_constant_freeze/version_constant_freeze.gemspec"},
   181  				},
   182  			},
   183  			wantResultMetric: stats.FileExtractedResultSuccess,
   184  		},
   185  		{
   186  			name: "version inline constant",
   187  			path: "testdata/version_inline.gemspec",
   188  			wantPackages: []*extractor.Package{
   189  				{
   190  					Name:      "example_inline",
   191  					Version:   "3.0.0",
   192  					PURLType:  purl.TypeGem,
   193  					Locations: []string{"testdata/version_inline.gemspec"},
   194  				},
   195  			},
   196  			wantResultMetric: stats.FileExtractedResultSuccess,
   197  		},
   198  		{
   199  			name: "version constant via File.join",
   200  			path: "testdata/version_constant_join/version_constant_join.gemspec",
   201  			wantPackages: []*extractor.Package{
   202  				{
   203  					Name:      "example_app_join",
   204  					Version:   "4.5.6",
   205  					PURLType:  purl.TypeGem,
   206  					Locations: []string{"testdata/version_constant_join/version_constant_join.gemspec"},
   207  				},
   208  			},
   209  			wantResultMetric: stats.FileExtractedResultSuccess,
   210  		},
   211  		{
   212  			name: "version constant via File.join multiline",
   213  			path: "testdata/version_constant_join_multiline/version_constant_join_multiline.gemspec",
   214  			wantPackages: []*extractor.Package{
   215  				{
   216  					Name:      "example_app_join_multiline",
   217  					Version:   "7.8.9",
   218  					PURLType:  purl.TypeGem,
   219  					Locations: []string{"testdata/version_constant_join_multiline/version_constant_join_multiline.gemspec"},
   220  				},
   221  			},
   222  			wantResultMetric: stats.FileExtractedResultSuccess,
   223  		},
   224  		{
   225  			name: "version constant via File.expand_path",
   226  			path: "testdata/version_constant_expand/version_constant_expand.gemspec",
   227  			wantPackages: []*extractor.Package{
   228  				{
   229  					Name:      "example_app_expand",
   230  					Version:   "5.6.7",
   231  					PURLType:  purl.TypeGem,
   232  					Locations: []string{"testdata/version_constant_expand/version_constant_expand.gemspec"},
   233  				},
   234  			},
   235  			wantResultMetric: stats.FileExtractedResultSuccess,
   236  		},
   237  		{
   238  			name: "version constant via File.dirname",
   239  			path: "testdata/version_constant_dirname/version_constant_dirname.gemspec",
   240  			wantPackages: []*extractor.Package{
   241  				{
   242  					Name:      "example_app_dirname",
   243  					Version:   "8.9.0",
   244  					PURLType:  purl.TypeGem,
   245  					Locations: []string{"testdata/version_constant_dirname/version_constant_dirname.gemspec"},
   246  				},
   247  			},
   248  			wantResultMetric: stats.FileExtractedResultSuccess,
   249  		},
   250  		{
   251  			name: "version constant via require",
   252  			path: "testdata/version_constant_require/version_constant_require.gemspec",
   253  			wantPackages: []*extractor.Package{
   254  				{
   255  					Name:      "example_app_require",
   256  					Version:   "0.9.9",
   257  					PURLType:  purl.TypeGem,
   258  					Locations: []string{"testdata/version_constant_require/version_constant_require.gemspec"},
   259  				},
   260  			},
   261  			wantResultMetric: stats.FileExtractedResultSuccess,
   262  		},
   263  		{
   264  			name: "version constant via conditional require",
   265  			path: "testdata/version_constant_conditional/version_constant_conditional.gemspec",
   266  			wantPackages: []*extractor.Package{
   267  				{
   268  					Name:      "example_app_conditional",
   269  					Version:   "9.0.1",
   270  					PURLType:  purl.TypeGem,
   271  					Locations: []string{"testdata/version_constant_conditional/version_constant_conditional.gemspec"},
   272  				},
   273  			},
   274  			wantResultMetric: stats.FileExtractedResultSuccess,
   275  		},
   276  		{
   277  			name: "version constant via nested File.expand_path and File.join",
   278  			path: "testdata/version_constant_expand_join/version_constant_expand_join.gemspec",
   279  			wantPackages: []*extractor.Package{
   280  				{
   281  					Name:      "example_app_expand_join",
   282  					Version:   "6.7.8",
   283  					PURLType:  purl.TypeGem,
   284  					Locations: []string{"testdata/version_constant_expand_join/version_constant_expand_join.gemspec"},
   285  				},
   286  			},
   287  			wantResultMetric: stats.FileExtractedResultSuccess,
   288  		},
   289  		{
   290  			name:             "invalid gemspec",
   291  			path:             "testdata/invalid.gemspec",
   292  			wantErr:          cmpopts.AnyError,
   293  			wantResultMetric: stats.FileExtractedResultErrorUnknown,
   294  		},
   295  		{
   296  			name:             "version constant missing definition",
   297  			path:             "testdata/version_constant_missing/version_constant_missing.gemspec",
   298  			wantErr:          cmpopts.AnyError,
   299  			wantResultMetric: stats.FileExtractedResultErrorUnknown,
   300  		},
   301  		{
   302  			name:             "empty gemspec",
   303  			path:             "testdata/empty.gemspec",
   304  			wantPackages:     nil,
   305  			wantResultMetric: stats.FileExtractedResultSuccess,
   306  		},
   307  		{
   308  			name:             "bad definition gemspec",
   309  			path:             "testdata/badspec.gemspec",
   310  			wantPackages:     nil,
   311  			wantResultMetric: stats.FileExtractedResultSuccess,
   312  		},
   313  		{
   314  			name: "version constant class",
   315  			path: "testdata/version_constant_class/version_constant_class.gemspec",
   316  			wantPackages: []*extractor.Package{
   317  				{
   318  					Name:      "example_app",
   319  					Version:   "3.0.0",
   320  					PURLType:  purl.TypeGem,
   321  					Locations: []string{"testdata/version_constant_class/version_constant_class.gemspec"},
   322  				},
   323  			},
   324  			wantResultMetric: stats.FileExtractedResultSuccess,
   325  		},
   326  		{
   327  			name: "version constant different casing",
   328  			path: "testdata/version_constant_different_casing/version_constant_different_casing.gemspec",
   329  			wantPackages: []*extractor.Package{
   330  				{
   331  					Name:      "example_app",
   332  					Version:   "4.0.0",
   333  					PURLType:  purl.TypeGem,
   334  					Locations: []string{"testdata/version_constant_different_casing/version_constant_different_casing.gemspec"},
   335  				},
   336  			},
   337  			wantResultMetric: stats.FileExtractedResultSuccess,
   338  		},
   339  		{
   340  			name:             "version method not supported",
   341  			path:             "testdata/version_method/version_method.gemspec",
   342  			wantErr:          cmpopts.AnyError,
   343  			wantResultMetric: stats.FileExtractedResultErrorUnknown,
   344  		},
   345  		{
   346  			name:             "load path not supported",
   347  			path:             "testdata/version_load_path/version_load_path.gemspec",
   348  			wantErr:          cmpopts.AnyError,
   349  			wantResultMetric: stats.FileExtractedResultErrorUnknown,
   350  		},
   351  	}
   352  
   353  	for _, test := range tests {
   354  		t.Run(test.name, func(t *testing.T) {
   355  			collector := testcollector.New()
   356  			var e filesystem.Extractor = gemspec.New(gemspec.Config{Stats: collector})
   357  
   358  			r, err := os.Open(test.path)
   359  			defer func() {
   360  				if err = r.Close(); err != nil {
   361  					t.Errorf("Close(): %v", err)
   362  				}
   363  			}()
   364  			if err != nil {
   365  				t.Fatal(err)
   366  			}
   367  
   368  			info, err := os.Stat(test.path)
   369  			if err != nil {
   370  				t.Fatalf("Failed to stat test file: %v", err)
   371  			}
   372  
   373  			input := &filesystem.ScanInput{FS: scalibrfs.DirFS("."), Path: test.path, Reader: r, Info: info}
   374  			got, err := e.Extract(t.Context(), input)
   375  			if !cmp.Equal(err, test.wantErr, cmpopts.EquateErrors()) {
   376  				t.Fatalf("Extract(%+v) error: got %v, want %v\n", test.name, err, test.wantErr)
   377  			}
   378  
   379  			var want inventory.Inventory
   380  			if test.wantPackages != nil {
   381  				want = inventory.Inventory{Packages: test.wantPackages}
   382  			}
   383  
   384  			if diff := cmp.Diff(want, got); diff != "" {
   385  				t.Errorf("Extract(%+v) diff (-want +got):\n%s", test.name, diff)
   386  			}
   387  
   388  			gotResultMetric := collector.FileExtractedResult(test.path)
   389  			if gotResultMetric != test.wantResultMetric {
   390  				t.Errorf("Extract(%s) recorded result metric %v, want result metric %v", test.path, gotResultMetric, test.wantResultMetric)
   391  			}
   392  
   393  			gotFileSizeMetric := collector.FileExtractedFileSize(test.path)
   394  			if gotFileSizeMetric != info.Size() {
   395  				t.Errorf("Extract(%s) recorded file size %v, want file size %v", test.path, gotFileSizeMetric, info.Size())
   396  			}
   397  		})
   398  	}
   399  }