github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/os/cos/cos_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 cos_test
    16  
    17  import (
    18  	"fmt"
    19  	"io/fs"
    20  	"os"
    21  	"path/filepath"
    22  	"testing"
    23  
    24  	"github.com/google/go-cmp/cmp"
    25  	"github.com/google/go-cmp/cmp/cmpopts"
    26  	"github.com/google/osv-scalibr/extractor"
    27  	"github.com/google/osv-scalibr/extractor/filesystem"
    28  	"github.com/google/osv-scalibr/extractor/filesystem/internal/units"
    29  	"github.com/google/osv-scalibr/extractor/filesystem/os/cos"
    30  	cosmeta "github.com/google/osv-scalibr/extractor/filesystem/os/cos/metadata"
    31  	"github.com/google/osv-scalibr/extractor/filesystem/simplefileapi"
    32  	scalibrfs "github.com/google/osv-scalibr/fs"
    33  	"github.com/google/osv-scalibr/inventory"
    34  	"github.com/google/osv-scalibr/purl"
    35  	"github.com/google/osv-scalibr/stats"
    36  	"github.com/google/osv-scalibr/testing/fakefs"
    37  	"github.com/google/osv-scalibr/testing/testcollector"
    38  )
    39  
    40  const (
    41  	cosOSRlease = `NAME="Container-Optimized OS"
    42  	ID=cos
    43  	VERSION=101
    44  	VERSION_ID=101`
    45  	cosOSRleaseNoVersionID = `NAME="Container-Optimized OS"
    46  	ID=cos
    47  	VERSION=101`
    48  	cosOSRleaseNoVersions = `NAME="Container-Optimized OS"
    49  	ID=cos`
    50  )
    51  
    52  func TestFileRequired(t *testing.T) {
    53  	tests := []struct {
    54  		name             string
    55  		path             string
    56  		fileSizeBytes    int64
    57  		maxFileSizeBytes int64
    58  		wantRequired     bool
    59  		wantResultMetric stats.FileRequiredResult
    60  	}{
    61  		{
    62  			name:             "package info",
    63  			path:             "etc/cos-package-info.json",
    64  			wantRequired:     true,
    65  			wantResultMetric: stats.FileRequiredResultOK,
    66  		}, {
    67  			name:         "not a package info file",
    68  			path:         "some/other/file.json",
    69  			wantRequired: false,
    70  		}, {
    71  			name:             "package info required if file size < max file size",
    72  			path:             "etc/cos-package-info.json",
    73  			fileSizeBytes:    100 * units.KiB,
    74  			maxFileSizeBytes: 1000 * units.KiB,
    75  			wantRequired:     true,
    76  			wantResultMetric: stats.FileRequiredResultOK,
    77  		}, {
    78  			name:             "package info required if file size == max file size",
    79  			path:             "etc/cos-package-info.json",
    80  			fileSizeBytes:    1000 * units.KiB,
    81  			maxFileSizeBytes: 1000 * units.KiB,
    82  			wantRequired:     true,
    83  			wantResultMetric: stats.FileRequiredResultOK,
    84  		}, {
    85  			name:             "package info not required if file size > max file size",
    86  			path:             "etc/cos-package-info.json",
    87  			fileSizeBytes:    1000 * units.KiB,
    88  			maxFileSizeBytes: 100 * units.KiB,
    89  			wantRequired:     false,
    90  			wantResultMetric: stats.FileRequiredResultSizeLimitExceeded,
    91  		}, {
    92  			name:             "package info required if max file size set to 0",
    93  			path:             "etc/cos-package-info.json",
    94  			fileSizeBytes:    100 * units.KiB,
    95  			maxFileSizeBytes: 0,
    96  			wantRequired:     true,
    97  			wantResultMetric: stats.FileRequiredResultOK,
    98  		},
    99  	}
   100  
   101  	for _, tt := range tests {
   102  		t.Run(tt.name, func(t *testing.T) {
   103  			collector := testcollector.New()
   104  			var e filesystem.Extractor = cos.New(cos.Config{
   105  				Stats:            collector,
   106  				MaxFileSizeBytes: tt.maxFileSizeBytes,
   107  			})
   108  
   109  			// Set a default file size if not specified.
   110  			fileSizeBytes := tt.fileSizeBytes
   111  			if fileSizeBytes == 0 {
   112  				fileSizeBytes = 1000
   113  			}
   114  
   115  			isRequired := e.FileRequired(simplefileapi.New(tt.path, fakefs.FakeFileInfo{
   116  				FileName: filepath.Base(tt.path),
   117  				FileMode: fs.ModePerm,
   118  				FileSize: fileSizeBytes,
   119  			}))
   120  			if isRequired != tt.wantRequired {
   121  				t.Fatalf("FileRequired(%s): got %v, want %v", tt.path, isRequired, tt.wantRequired)
   122  			}
   123  
   124  			gotResultMetric := collector.FileRequiredResult(tt.path)
   125  			if tt.wantResultMetric != "" && gotResultMetric != tt.wantResultMetric {
   126  				t.Errorf("FileRequired(%s) recorded result metric %v, want result metric %v", tt.path, gotResultMetric, tt.wantResultMetric)
   127  			}
   128  		})
   129  	}
   130  }
   131  
   132  func TestExtract(t *testing.T) {
   133  	tests := []struct {
   134  		name             string
   135  		path             string
   136  		osrelease        string
   137  		wantPackages     []*extractor.Package
   138  		wantErr          error
   139  		wantResultMetric stats.FileExtractedResult
   140  	}{
   141  		{
   142  			name:             "invalid",
   143  			path:             "testdata/invalid",
   144  			osrelease:        cosOSRlease,
   145  			wantErr:          cmpopts.AnyError,
   146  			wantResultMetric: stats.FileExtractedResultErrorUnknown,
   147  		},
   148  		{
   149  			name:             "empty",
   150  			path:             "testdata/empty.json",
   151  			osrelease:        cosOSRlease,
   152  			wantPackages:     []*extractor.Package{},
   153  			wantResultMetric: stats.FileExtractedResultSuccess,
   154  		},
   155  		{
   156  			name:      "single",
   157  			path:      "testdata/single.json",
   158  			osrelease: cosOSRlease,
   159  			wantPackages: []*extractor.Package{
   160  				{
   161  					Name:      "python-exec",
   162  					Version:   "17162.336.16",
   163  					PURLType:  purl.TypeCOS,
   164  					Locations: []string{"testdata/single.json"},
   165  					Metadata: &cosmeta.Metadata{
   166  						Name:          "python-exec",
   167  						Version:       "17162.336.16",
   168  						Category:      "dev-lang",
   169  						OSVersion:     "101",
   170  						OSVersionID:   "101",
   171  						EbuildVersion: "2.0.1-r1",
   172  					},
   173  				},
   174  			},
   175  			wantResultMetric: stats.FileExtractedResultSuccess,
   176  		},
   177  		{
   178  			name:      "multiple",
   179  			path:      "testdata/multiple.json",
   180  			osrelease: cosOSRlease,
   181  			wantPackages: []*extractor.Package{
   182  				{
   183  					Name:      "python-exec",
   184  					Version:   "17162.336.16",
   185  					PURLType:  purl.TypeCOS,
   186  					Locations: []string{"testdata/multiple.json"},
   187  					Metadata: &cosmeta.Metadata{
   188  						Name:          "python-exec",
   189  						Version:       "17162.336.16",
   190  						Category:      "dev-lang",
   191  						OSVersion:     "101",
   192  						OSVersionID:   "101",
   193  						EbuildVersion: "2.0.1-r1",
   194  					},
   195  				},
   196  				{
   197  					Name:      "zlib",
   198  					Version:   "17162.336.17",
   199  					PURLType:  purl.TypeCOS,
   200  					Locations: []string{"testdata/multiple.json"},
   201  					Metadata: &cosmeta.Metadata{
   202  						Name:          "zlib",
   203  						Version:       "17162.336.17",
   204  						Category:      "sys-libs",
   205  						OSVersion:     "101",
   206  						OSVersionID:   "101",
   207  						EbuildVersion: "1.2.11-r5",
   208  					},
   209  				},
   210  				{
   211  					Name:      "baselayout",
   212  					Version:   "17162.336.18",
   213  					PURLType:  purl.TypeCOS,
   214  					Locations: []string{"testdata/multiple.json"},
   215  					Metadata: &cosmeta.Metadata{
   216  						Name:          "baselayout",
   217  						Version:       "17162.336.18",
   218  						Category:      "sys-apps",
   219  						OSVersion:     "101",
   220  						OSVersionID:   "101",
   221  						EbuildVersion: "2.2-r2",
   222  					},
   223  				},
   224  				{
   225  					Name:      "ncurses",
   226  					Version:   "17162.336.19",
   227  					PURLType:  purl.TypeCOS,
   228  					Locations: []string{"testdata/multiple.json"},
   229  					Metadata: &cosmeta.Metadata{
   230  						Name:          "ncurses",
   231  						Version:       "17162.336.19",
   232  						Category:      "sys-libs",
   233  						OSVersion:     "101",
   234  						OSVersionID:   "101",
   235  						EbuildVersion: "6.4_p20230424",
   236  					},
   237  				},
   238  			},
   239  			wantResultMetric: stats.FileExtractedResultSuccess,
   240  		},
   241  		{
   242  			name:      "no version ID",
   243  			path:      "testdata/single.json",
   244  			osrelease: cosOSRleaseNoVersionID,
   245  			wantPackages: []*extractor.Package{
   246  				{
   247  					Name:      "python-exec",
   248  					Version:   "17162.336.16",
   249  					PURLType:  purl.TypeCOS,
   250  					Locations: []string{"testdata/single.json"},
   251  					Metadata: &cosmeta.Metadata{
   252  						Name:          "python-exec",
   253  						Version:       "17162.336.16",
   254  						Category:      "dev-lang",
   255  						OSVersion:     "101",
   256  						EbuildVersion: "2.0.1-r1",
   257  					},
   258  				},
   259  			},
   260  		},
   261  		{
   262  			name:      "no version or version ID",
   263  			path:      "testdata/single.json",
   264  			osrelease: cosOSRleaseNoVersions,
   265  			wantPackages: []*extractor.Package{
   266  				{
   267  					Name:      "python-exec",
   268  					Version:   "17162.336.16",
   269  					PURLType:  purl.TypeCOS,
   270  					Locations: []string{"testdata/single.json"},
   271  					Metadata: &cosmeta.Metadata{
   272  						Name:          "python-exec",
   273  						Version:       "17162.336.16",
   274  						Category:      "dev-lang",
   275  						EbuildVersion: "2.0.1-r1",
   276  					},
   277  				},
   278  			},
   279  			wantResultMetric: stats.FileExtractedResultSuccess,
   280  		},
   281  	}
   282  
   283  	for _, tt := range tests {
   284  		t.Run(tt.name, func(t *testing.T) {
   285  			collector := testcollector.New()
   286  			var e filesystem.Extractor = cos.New(cos.Config{
   287  				Stats: collector,
   288  			})
   289  
   290  			d := t.TempDir()
   291  			createOsRelease(t, d, tt.osrelease)
   292  
   293  			r, err := os.Open(tt.path)
   294  			defer func() {
   295  				if err = r.Close(); err != nil {
   296  					t.Errorf("Close(): %v", err)
   297  				}
   298  			}()
   299  			if err != nil {
   300  				t.Fatal(err)
   301  			}
   302  
   303  			info, err := os.Stat(tt.path)
   304  			if err != nil {
   305  				t.Fatalf("Failed to stat test file: %v", err)
   306  			}
   307  
   308  			input := &filesystem.ScanInput{
   309  				FS:     scalibrfs.DirFS(d),
   310  				Path:   tt.path,
   311  				Reader: r,
   312  				Root:   d,
   313  				Info:   info,
   314  			}
   315  
   316  			got, err := e.Extract(t.Context(), input)
   317  			if !cmp.Equal(err, tt.wantErr, cmpopts.EquateErrors()) {
   318  				t.Fatalf("Extract(%+v) error: got %v, want %v\n", tt.path, err, tt.wantErr)
   319  			}
   320  
   321  			ignoreOrder := cmpopts.SortSlices(func(a, b any) bool {
   322  				return fmt.Sprintf("%+v", a) < fmt.Sprintf("%+v", b)
   323  			})
   324  			wantInv := inventory.Inventory{Packages: tt.wantPackages}
   325  			if diff := cmp.Diff(wantInv, got, ignoreOrder); diff != "" {
   326  				t.Errorf("Extract(%s) (-want +got):\n%s", tt.path, diff)
   327  			}
   328  
   329  			gotResultMetric := collector.FileExtractedResult(tt.path)
   330  			if tt.wantResultMetric != "" && gotResultMetric != tt.wantResultMetric {
   331  				t.Errorf("Extract(%s) recorded result metric %v, want result metric %v", tt.path, gotResultMetric, tt.wantResultMetric)
   332  			}
   333  
   334  			gotFileSizeMetric := collector.FileExtractedFileSize(tt.path)
   335  			if gotFileSizeMetric != info.Size() {
   336  				t.Errorf("Extract(%s) recorded file size %v, want file size %v", tt.path, gotFileSizeMetric, info.Size())
   337  			}
   338  		})
   339  	}
   340  }
   341  
   342  func createOsRelease(t *testing.T, root string, content string) {
   343  	t.Helper()
   344  	_ = os.MkdirAll(filepath.Join(root, "etc"), 0755)
   345  	err := os.WriteFile(filepath.Join(root, "etc/os-release"), []byte(content), 0644)
   346  	if err != nil {
   347  		t.Fatalf("write to %s: %v\n", filepath.Join(root, "etc/os-release"), err)
   348  	}
   349  }