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 }