github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/os/rpm/rpm_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 rpm_test
    16  
    17  import (
    18  	"io"
    19  	"io/fs"
    20  	"os"
    21  	"path"
    22  	"path/filepath"
    23  	"runtime"
    24  	"sort"
    25  	"strings"
    26  	"testing"
    27  	"time"
    28  
    29  	"github.com/google/go-cmp/cmp"
    30  	"github.com/google/go-cmp/cmp/cmpopts"
    31  	"github.com/google/osv-scalibr/extractor"
    32  	"github.com/google/osv-scalibr/extractor/filesystem"
    33  	"github.com/google/osv-scalibr/extractor/filesystem/internal/units"
    34  	"github.com/google/osv-scalibr/extractor/filesystem/os/rpm"
    35  	rpmmeta "github.com/google/osv-scalibr/extractor/filesystem/os/rpm/metadata"
    36  	"github.com/google/osv-scalibr/extractor/filesystem/simplefileapi"
    37  	scalibrfs "github.com/google/osv-scalibr/fs"
    38  	"github.com/google/osv-scalibr/purl"
    39  	"github.com/google/osv-scalibr/stats"
    40  	"github.com/google/osv-scalibr/testing/fakefs"
    41  	"github.com/google/osv-scalibr/testing/testcollector"
    42  )
    43  
    44  func TestFileRequired(t *testing.T) {
    45  	// supported OSes
    46  	if runtime.GOOS == "windows" {
    47  		t.Skipf("Test skipped, OS unsupported: %v", runtime.GOOS)
    48  	}
    49  
    50  	tests := []struct {
    51  		name             string
    52  		path             string
    53  		fileSizeBytes    int64
    54  		maxFileSizeBytes int64
    55  		wantRequired     bool
    56  		wantResultMetric stats.FileRequiredResult
    57  	}{
    58  		// BDB
    59  		{path: "usr/lib/sysimage/rpm/Packages", wantRequired: true},
    60  		{path: "var/lib/rpm/Packages", wantRequired: true},
    61  		{path: "usr/share/rpm/Packages", wantRequired: true},
    62  		// NDB
    63  		{path: "usr/lib/sysimage/rpm/Packages.db", wantRequired: true},
    64  		{path: "var/lib/rpm/Packages.db", wantRequired: true},
    65  		{path: "usr/share/rpm/Packages.db", wantRequired: true},
    66  		// SQLite3
    67  		{path: "usr/lib/sysimage/rpm/rpmdb.sqlite", wantRequired: true},
    68  		{path: "var/lib/rpm/rpmdb.sqlite", wantRequired: true},
    69  		{path: "usr/share/rpm/rpmdb.sqlite", wantRequired: true},
    70  		// invalid
    71  		{path: "rpm/rpmdb.sqlite", wantRequired: false},
    72  		{path: "rpm/Packages.db", wantRequired: false},
    73  		{path: "rpm/Packages", wantRequired: false},
    74  		{path: "foo/var/lib/rpm/rpmdb.sqlite", wantRequired: false},
    75  		{path: "foo/var/lib/rpm/Packages", wantRequired: false},
    76  		{path: "/rpm/rpmdb.sqlite", wantRequired: false},
    77  		{path: "/rpm/Packages.db", wantRequired: false},
    78  		{path: "/rpm/Packages", wantRequired: false},
    79  		{path: "/foo/var/lib/rpm/rpmdb.sqlite", wantRequired: false},
    80  		{path: "/foo/var/lib/rpm/Packages", wantRequired: false},
    81  		// File size limits
    82  		{
    83  			name:             "Packages file required if file size < max file size",
    84  			path:             "usr/lib/sysimage/rpm/Packages",
    85  			fileSizeBytes:    100 * units.KiB,
    86  			maxFileSizeBytes: 1000 * units.KiB,
    87  			wantRequired:     true,
    88  			wantResultMetric: stats.FileRequiredResultOK,
    89  		},
    90  		{
    91  			name:             "Packages file required if file size == max file size",
    92  			path:             "usr/lib/sysimage/rpm/Packages",
    93  			fileSizeBytes:    1000 * units.KiB,
    94  			maxFileSizeBytes: 1000 * units.KiB,
    95  			wantRequired:     true,
    96  			wantResultMetric: stats.FileRequiredResultOK,
    97  		},
    98  		{
    99  			name:             "Packages file not required if file size > max file size",
   100  			path:             "usr/lib/sysimage/rpm/Packages",
   101  			fileSizeBytes:    1000 * units.KiB,
   102  			maxFileSizeBytes: 100 * units.KiB,
   103  			wantRequired:     false,
   104  			wantResultMetric: stats.FileRequiredResultSizeLimitExceeded,
   105  		},
   106  		{
   107  			name:             "Packages file required if max file size set to 0",
   108  			path:             "usr/lib/sysimage/rpm/Packages",
   109  			fileSizeBytes:    100 * units.KiB,
   110  			maxFileSizeBytes: 0,
   111  			wantRequired:     true,
   112  			wantResultMetric: stats.FileRequiredResultOK,
   113  		},
   114  	}
   115  
   116  	for _, tt := range tests {
   117  		desc := tt.name
   118  		if desc == "" {
   119  			desc = tt.path
   120  		}
   121  
   122  		t.Run(desc, func(t *testing.T) {
   123  			collector := testcollector.New()
   124  			var e filesystem.Extractor = rpm.New(rpm.Config{
   125  				Stats:            collector,
   126  				MaxFileSizeBytes: tt.maxFileSizeBytes,
   127  			})
   128  
   129  			// Set a default file size if not specified.
   130  			fileSizeBytes := tt.fileSizeBytes
   131  			if fileSizeBytes == 0 {
   132  				fileSizeBytes = 1000
   133  			}
   134  
   135  			isRequired := e.FileRequired(simplefileapi.New(tt.path, fakefs.FakeFileInfo{
   136  				FileName: filepath.Base(tt.path),
   137  				FileMode: fs.ModePerm,
   138  				FileSize: fileSizeBytes,
   139  			}))
   140  			if isRequired != tt.wantRequired {
   141  				t.Fatalf("FileRequired(%s): got %v, want %v", tt.path, isRequired, tt.wantRequired)
   142  			}
   143  
   144  			wantResultMetric := tt.wantResultMetric
   145  			if wantResultMetric == "" && tt.wantRequired {
   146  				wantResultMetric = stats.FileRequiredResultOK
   147  			}
   148  			gotResultMetric := collector.FileRequiredResult(tt.path)
   149  			if wantResultMetric != "" && gotResultMetric != wantResultMetric {
   150  				t.Errorf("FileRequired(%s) recorded result metric %v, want result metric %v", tt.path, gotResultMetric, wantResultMetric)
   151  			}
   152  		})
   153  	}
   154  }
   155  
   156  const fedora38 = `NAME="Fedora Linux"
   157  VERSION="38 (Container Image)"
   158  ID=fedora
   159  VERSION_ID=38
   160  VERSION_CODENAME=""
   161  PLATFORM_ID="platform:f38"
   162  PRETTY_NAME="Fedora Linux 38 (Container Image)"
   163  CPE_NAME="cpe:/o:fedoraproject:fedora:38"
   164  DEFAULT_HOSTNAME="fedora"
   165  REDHAT_BUGZILLA_PRODUCT="Fedora"
   166  REDHAT_BUGZILLA_PRODUCT_VERSION=38
   167  REDHAT_SUPPORT_PRODUCT="Fedora"
   168  REDHAT_SUPPORT_PRODUCT_VERSION=38
   169  SUPPORT_END=2024-05-14
   170  VARIANT="Container Image"`
   171  
   172  func TestExtract(t *testing.T) {
   173  	// supported OSes
   174  	if runtime.GOOS == "windows" {
   175  		t.Skipf("Test skipped, OS unsupported: %v", runtime.GOOS)
   176  	}
   177  
   178  	tests := []struct {
   179  		name       string
   180  		path       string
   181  		osrelease  string
   182  		timeoutval time.Duration
   183  		// rpm -qa --qf "%{NAME}@%{VERSION}-%{RELEASE}\n" |sort |head -n 3
   184  		wantPackages []*extractor.Package
   185  		// rpm -qa | wc -l
   186  		wantResults      int
   187  		wantErr          error
   188  		wantResultMetric stats.FileExtractedResult
   189  	}{
   190  		{
   191  			name: "opensuse/leap:15.5_Packages.db_file_(NDB)",
   192  			// docker run --rm --entrypoint cat opensuse/leap:15.5 /var/lib/rpm/Packages.db > third_party/scalibr/extractor/filesystem/os/rpm/testdata/Packages.db
   193  			path:             "testdata/Packages.db",
   194  			osrelease:        fedora38,
   195  			wantResultMetric: stats.FileExtractedResultSuccess,
   196  			wantPackages: []*extractor.Package{
   197  				{
   198  					Locations: []string{"testdata/Packages.db"},
   199  					Name:      "aaa_base",
   200  					Version:   "84.87+git20180409.04c9dae-150300.10.3.1",
   201  					PURLType:  purl.TypeRPM,
   202  					Metadata: &rpmmeta.Metadata{
   203  						PackageName:  "aaa_base",
   204  						Epoch:        0,
   205  						SourceRPM:    "aaa_base-84.87+git20180409.04c9dae-150300.10.3.1.src.rpm",
   206  						OSID:         "fedora",
   207  						OSVersionID:  "38",
   208  						OSName:       "Fedora Linux",
   209  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   210  						Vendor:       "SUSE LLC <https://www.suse.com/>",
   211  						Architecture: "x86_64",
   212  					},
   213  					Licenses: []string{"GPL-2.0+"},
   214  				},
   215  				{
   216  					Locations: []string{"testdata/Packages.db"},
   217  					Name:      "bash",
   218  					Version:   "4.4-150400.25.22",
   219  					PURLType:  purl.TypeRPM,
   220  					Metadata: &rpmmeta.Metadata{
   221  						PackageName:  "bash",
   222  						Epoch:        0,
   223  						OSName:       "Fedora Linux",
   224  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   225  						SourceRPM:    "bash-4.4-150400.25.22.src.rpm",
   226  						OSID:         "fedora",
   227  						OSVersionID:  "38",
   228  						Vendor:       "SUSE LLC <https://www.suse.com/>",
   229  						Architecture: "x86_64",
   230  					},
   231  					Licenses: []string{"GPL-3.0-or-later"},
   232  				},
   233  				{
   234  					Locations: []string{"testdata/Packages.db"},
   235  					Name:      "bash-sh",
   236  					Version:   "4.4-150400.25.22",
   237  					PURLType:  purl.TypeRPM,
   238  					Metadata: &rpmmeta.Metadata{
   239  						PackageName:  "bash-sh",
   240  						Epoch:        0,
   241  						SourceRPM:    "bash-4.4-150400.25.22.src.rpm",
   242  						OSID:         "fedora",
   243  						OSVersionID:  "38",
   244  						OSName:       "Fedora Linux",
   245  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   246  						Vendor:       "SUSE LLC <https://www.suse.com/>",
   247  						Architecture: "x86_64",
   248  					},
   249  					Licenses: []string{"GPL-3.0-or-later"},
   250  				},
   251  			},
   252  			wantResults: 137,
   253  		},
   254  		{
   255  			name: "CentOS_7.9.2009_Packages_file_(Berkley_DB)",
   256  			// docker run --rm --entrypoint cat centos:centos7.9.2009 /var/lib/rpm/Packages > third_party/scalibr/extractor/filesystem/os/rpm/testdata/Packages
   257  			path:             "testdata/Packages",
   258  			osrelease:        fedora38,
   259  			wantResultMetric: stats.FileExtractedResultSuccess,
   260  			wantPackages: []*extractor.Package{
   261  				{
   262  					Locations: []string{"testdata/Packages"},
   263  					Name:      "acl",
   264  					Version:   "2.2.51-15.el7",
   265  					PURLType:  purl.TypeRPM,
   266  					Metadata: &rpmmeta.Metadata{
   267  						PackageName:  "acl",
   268  						Epoch:        0,
   269  						SourceRPM:    "acl-2.2.51-15.el7.src.rpm",
   270  						OSID:         "fedora",
   271  						OSVersionID:  "38",
   272  						OSName:       "Fedora Linux",
   273  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   274  						Vendor:       "CentOS",
   275  						Architecture: "x86_64",
   276  					},
   277  					Licenses: []string{"GPLv2+"},
   278  				},
   279  				{
   280  					Locations: []string{"testdata/Packages"},
   281  					Name:      "audit-libs",
   282  					Version:   "2.8.5-4.el7",
   283  					PURLType:  purl.TypeRPM,
   284  					Metadata: &rpmmeta.Metadata{
   285  						PackageName:  "audit-libs",
   286  						Epoch:        0,
   287  						SourceRPM:    "audit-2.8.5-4.el7.src.rpm",
   288  						OSID:         "fedora",
   289  						OSVersionID:  "38",
   290  						OSName:       "Fedora Linux",
   291  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   292  						Vendor:       "CentOS",
   293  						Architecture: "x86_64",
   294  					},
   295  					Licenses: []string{"LGPLv2+"},
   296  				},
   297  				{
   298  					Locations: []string{"testdata/Packages"},
   299  					Name:      "basesystem",
   300  					Version:   "10.0-7.el7.centos",
   301  					PURLType:  purl.TypeRPM,
   302  					Metadata: &rpmmeta.Metadata{
   303  						PackageName:  "basesystem",
   304  						Epoch:        0,
   305  						SourceRPM:    "basesystem-10.0-7.el7.centos.src.rpm",
   306  						OSID:         "fedora",
   307  						OSVersionID:  "38",
   308  						OSName:       "Fedora Linux",
   309  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   310  						Vendor:       "CentOS",
   311  						Architecture: "noarch",
   312  					},
   313  					Licenses: []string{"Public Domain"},
   314  				},
   315  			},
   316  			wantResults: 148,
   317  		},
   318  		{
   319  			name:             "file not found",
   320  			path:             "testdata/foobar",
   321  			wantPackages:     nil,
   322  			wantResults:      0,
   323  			wantErr:          os.ErrNotExist,
   324  			wantResultMetric: stats.FileExtractedResultErrorUnknown,
   325  		},
   326  		{
   327  			name:             "empty",
   328  			path:             "testdata/empty.sqlite",
   329  			wantPackages:     nil,
   330  			wantResults:      0,
   331  			wantErr:          io.EOF,
   332  			wantResultMetric: stats.FileExtractedResultErrorUnknown,
   333  		},
   334  		{
   335  			name:             "invalid",
   336  			path:             "testdata/invalid",
   337  			wantPackages:     nil,
   338  			wantResults:      0,
   339  			wantErr:          io.ErrUnexpectedEOF,
   340  			wantResultMetric: stats.FileExtractedResultErrorUnknown,
   341  		},
   342  		{
   343  			name:             "corrupt db times out",
   344  			path:             "testdata/timeout/Packages",
   345  			timeoutval:       1 * time.Second,
   346  			wantPackages:     nil,
   347  			wantResults:      0,
   348  			wantErr:          cmpopts.AnyError,
   349  			wantResultMetric: stats.FileExtractedResultErrorUnknown,
   350  		},
   351  		{
   352  			name: "RockyLinux_9.2.20230513_rpmdb.sqlite_file_(sqlite3)",
   353  			// docker run --rm --entrypoint cat rockylinux:9.2.20230513 /var/lib/rpm/rpmdb.sqlite > third_party/scalibr/extractor/filesystem/os/rpm/testdata/rpmdb.sqlite
   354  			path:             "testdata/rpmdb.sqlite",
   355  			osrelease:        fedora38,
   356  			wantResultMetric: stats.FileExtractedResultSuccess,
   357  			wantPackages: []*extractor.Package{
   358  				{
   359  					Locations: []string{"testdata/rpmdb.sqlite"},
   360  					Name:      "alternatives",
   361  					Version:   "1.20-2.el9",
   362  					PURLType:  purl.TypeRPM,
   363  					Metadata: &rpmmeta.Metadata{
   364  						PackageName:  "alternatives",
   365  						Epoch:        0,
   366  						SourceRPM:    "chkconfig-1.20-2.el9.src.rpm",
   367  						OSID:         "fedora",
   368  						OSVersionID:  "38",
   369  						OSName:       "Fedora Linux",
   370  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   371  						Vendor:       "Rocky Enterprise Software Foundation",
   372  						Architecture: "x86_64",
   373  					},
   374  					Licenses: []string{"GPLv2"},
   375  				},
   376  				{
   377  					Locations: []string{"testdata/rpmdb.sqlite"},
   378  					Name:      "audit-libs",
   379  					Version:   "3.0.7-103.el9",
   380  					PURLType:  purl.TypeRPM,
   381  					Metadata: &rpmmeta.Metadata{
   382  						PackageName:  "audit-libs",
   383  						Epoch:        0,
   384  						SourceRPM:    "audit-3.0.7-103.el9.src.rpm",
   385  						OSID:         "fedora",
   386  						OSVersionID:  "38",
   387  						OSName:       "Fedora Linux",
   388  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   389  						Vendor:       "Rocky Enterprise Software Foundation",
   390  						Architecture: "x86_64",
   391  					},
   392  					Licenses: []string{"LGPLv2+"},
   393  				},
   394  				{
   395  					Locations: []string{"testdata/rpmdb.sqlite"},
   396  					Name:      "basesystem",
   397  					Version:   "11-13.el9",
   398  					PURLType:  purl.TypeRPM,
   399  					Metadata: &rpmmeta.Metadata{
   400  						PackageName:  "basesystem",
   401  						Epoch:        0,
   402  						SourceRPM:    "basesystem-11-13.el9.src.rpm",
   403  						OSID:         "fedora",
   404  						OSVersionID:  "38",
   405  						OSName:       "Fedora Linux",
   406  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   407  						Vendor:       "Rocky Enterprise Software Foundation",
   408  						Architecture: "noarch",
   409  					},
   410  					Licenses: []string{"Public Domain"},
   411  				},
   412  			},
   413  			wantResults: 141,
   414  		},
   415  		{
   416  			name: "osrelease:_no_version_id",
   417  			// docker run --rm --entrypoint cat rockylinux:9.2.20230513 /var/lib/rpm/rpmdb.sqlite > third_party/scalibr/extractor/filesystem/os/rpm/testdata/rpmdb.sqlite
   418  			path: "testdata/rpmdb.sqlite",
   419  			osrelease: `ID=fedora
   420  			BUILD_ID=asdf`,
   421  			wantResultMetric: stats.FileExtractedResultSuccess,
   422  			wantPackages: []*extractor.Package{
   423  				{
   424  					Locations: []string{"testdata/rpmdb.sqlite"},
   425  					Name:      "alternatives",
   426  					Version:   "1.20-2.el9",
   427  					PURLType:  purl.TypeRPM,
   428  					Metadata: &rpmmeta.Metadata{
   429  						PackageName:  "alternatives",
   430  						Epoch:        0,
   431  						SourceRPM:    "chkconfig-1.20-2.el9.src.rpm",
   432  						OSID:         "fedora",
   433  						OSBuildID:    "asdf",
   434  						Vendor:       "Rocky Enterprise Software Foundation",
   435  						Architecture: "x86_64",
   436  					},
   437  					Licenses: []string{"GPLv2"},
   438  				},
   439  				{
   440  					Locations: []string{"testdata/rpmdb.sqlite"},
   441  					Name:      "audit-libs",
   442  					Version:   "3.0.7-103.el9",
   443  					PURLType:  purl.TypeRPM,
   444  					Metadata: &rpmmeta.Metadata{
   445  						PackageName:  "audit-libs",
   446  						Epoch:        0,
   447  						SourceRPM:    "audit-3.0.7-103.el9.src.rpm",
   448  						OSID:         "fedora",
   449  						OSBuildID:    "asdf",
   450  						Vendor:       "Rocky Enterprise Software Foundation",
   451  						Architecture: "x86_64",
   452  					},
   453  					Licenses: []string{"LGPLv2+"},
   454  				},
   455  				{
   456  					Locations: []string{"testdata/rpmdb.sqlite"},
   457  					Name:      "basesystem",
   458  					Version:   "11-13.el9",
   459  					PURLType:  purl.TypeRPM,
   460  					Metadata: &rpmmeta.Metadata{
   461  						PackageName:  "basesystem",
   462  						Epoch:        0,
   463  						SourceRPM:    "basesystem-11-13.el9.src.rpm",
   464  						OSID:         "fedora",
   465  						OSBuildID:    "asdf",
   466  						Vendor:       "Rocky Enterprise Software Foundation",
   467  						Architecture: "noarch",
   468  					},
   469  					Licenses: []string{"Public Domain"},
   470  				},
   471  			},
   472  			wantResults: 141,
   473  		},
   474  		{
   475  			name: "custom_rpm",
   476  			// https://www.redhat.com/sysadmin/create-rpm-package
   477  			path: "testdata/Packages_epoch",
   478  			osrelease: `NAME=Fedora
   479  			VERSION="32 (Container Image)"
   480  			ID=fedora
   481  			VERSION_ID=32
   482  			VERSION_CODENAME=""
   483  			PLATFORM_ID="platform:f32"
   484  			PRETTY_NAME="Fedora 32 (Container Image)"
   485  			CPE_NAME="cpe:/o:fedoraproject:fedora:32"`,
   486  			wantResultMetric: stats.FileExtractedResultSuccess,
   487  			wantPackages: []*extractor.Package{
   488  				{
   489  					Locations: []string{"testdata/Packages"},
   490  					Name:      "hello",
   491  					Version:   "0.0.1-rls",
   492  					PURLType:  purl.TypeRPM,
   493  					Metadata: &rpmmeta.Metadata{
   494  						PackageName:  "hello",
   495  						Epoch:        1,
   496  						SourceRPM:    "hello-0.0.1-rls.src.rpm",
   497  						OSID:         "fedora",
   498  						OSName:       "Fedora",
   499  						OSPrettyName: "Fedora 32 (Container Image)",
   500  						OSVersionID:  "32",
   501  						Architecture: "x86_64",
   502  					},
   503  					Licenses: []string{"GPL"},
   504  				},
   505  			},
   506  			wantResults: 1,
   507  		},
   508  	}
   509  
   510  	for _, tt := range tests {
   511  		t.Run(tt.name, func(t *testing.T) {
   512  			d := t.TempDir()
   513  			createOsRelease(t, d, tt.osrelease)
   514  
   515  			// Copy files to a temp directory, as sqlite can't open them directly.
   516  			tmpPath, err := CopyFileToTempDir(t, tt.path, d)
   517  			if err != nil {
   518  				t.Fatalf("CopyFileToTempDir(%s) error: %v\n", tt.path, err)
   519  			}
   520  
   521  			info, err := os.Stat(tmpPath)
   522  			if err != nil && !os.IsNotExist(err) {
   523  				t.Fatalf("Failed to stat test file: %v", err)
   524  			}
   525  
   526  			collector := testcollector.New()
   527  			var e filesystem.Extractor = rpm.New(rpm.Config{
   528  				Stats:   collector,
   529  				Timeout: tt.timeoutval,
   530  			})
   531  
   532  			input := &filesystem.ScanInput{
   533  				FS:   scalibrfs.DirFS(filepath.Dir(tmpPath)),
   534  				Path: filepath.Base(tmpPath),
   535  				Root: filepath.Dir(tmpPath),
   536  				Info: info,
   537  			}
   538  			got, err := e.Extract(t.Context(), input)
   539  			if !cmp.Equal(err, tt.wantErr, cmpopts.EquateErrors()) {
   540  				t.Fatalf("Extract(%+v) error: got %v, want %v\n", tmpPath, err, tt.wantErr)
   541  			}
   542  
   543  			// Update location with the temp path.
   544  			for _, p := range tt.wantPackages {
   545  				p.Locations = []string{filepath.Base(tmpPath)}
   546  			}
   547  
   548  			pkgs := got.Packages
   549  			sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Name < pkgs[j].Name })
   550  			gotFirst3 := pkgs[:min(len(pkgs), 3)]
   551  			if diff := cmp.Diff(tt.wantPackages, gotFirst3); diff != "" {
   552  				t.Errorf("Extract(%s) (-want +got):\n%s", tmpPath, diff)
   553  			}
   554  
   555  			if len(pkgs) != tt.wantResults {
   556  				t.Errorf("Extract(%s): got %d results, want %d\n", tmpPath, len(pkgs), tt.wantResults)
   557  			}
   558  
   559  			gotResultMetric := collector.FileExtractedResult(filepath.Base(tmpPath))
   560  			if tt.wantResultMetric != "" && gotResultMetric != tt.wantResultMetric {
   561  				t.Errorf("Extract(%s) recorded result metric %v, want result metric %v", tmpPath, gotResultMetric, tt.wantResultMetric)
   562  			}
   563  
   564  			var wantFileSize int64
   565  			if info != nil {
   566  				wantFileSize = info.Size()
   567  			}
   568  			gotFileSizeMetric := collector.FileExtractedFileSize(filepath.Base(tmpPath))
   569  			if gotFileSizeMetric != wantFileSize {
   570  				t.Errorf("Extract(%s) recorded file size %v, want file size %v", tmpPath, gotFileSizeMetric, wantFileSize)
   571  			}
   572  		})
   573  	}
   574  }
   575  
   576  func TestExtract_VirtualFilesystem(t *testing.T) {
   577  	// supported OSes
   578  	if runtime.GOOS == "windows" {
   579  		t.Skipf("Test skipped, OS unsupported: %v", runtime.GOOS)
   580  	}
   581  
   582  	tests := []struct {
   583  		name       string
   584  		path       string
   585  		osrelease  string
   586  		timeoutval time.Duration
   587  		// rpm -qa --qf "%{NAME}@%{VERSION}-%{RELEASE}\n" |sort |head -n 3
   588  		wantPackages []*extractor.Package
   589  		// rpm -qa | wc -l
   590  		wantResults int
   591  		wantErr     error
   592  	}{
   593  		{
   594  			name: "opensuse/leap:15.5_Packages.db_file_(NDB)",
   595  			// docker run --rm --entrypoint cat opensuse/leap:15.5 /var/lib/rpm/Packages.db > third_party/scalibr/extractor/filesystem/os/rpm/testdata/Packages.db
   596  			path:      "testdata/Packages.db",
   597  			osrelease: fedora38,
   598  			wantPackages: []*extractor.Package{
   599  				{
   600  					Locations: []string{"testdata/Packages.db"},
   601  					Name:      "aaa_base",
   602  					Version:   "84.87+git20180409.04c9dae-150300.10.3.1",
   603  					PURLType:  purl.TypeRPM,
   604  					Metadata: &rpmmeta.Metadata{
   605  						PackageName:  "aaa_base",
   606  						Epoch:        0,
   607  						SourceRPM:    "aaa_base-84.87+git20180409.04c9dae-150300.10.3.1.src.rpm",
   608  						OSID:         "fedora",
   609  						OSVersionID:  "38",
   610  						OSName:       "Fedora Linux",
   611  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   612  						Vendor:       "SUSE LLC <https://www.suse.com/>",
   613  						Architecture: "x86_64",
   614  					},
   615  					Licenses: []string{"GPL-2.0+"},
   616  				},
   617  				{
   618  					Locations: []string{"testdata/Packages.db"},
   619  					Name:      "bash",
   620  					Version:   "4.4-150400.25.22",
   621  					PURLType:  purl.TypeRPM,
   622  					Metadata: &rpmmeta.Metadata{
   623  						PackageName:  "bash",
   624  						Epoch:        0,
   625  						OSName:       "Fedora Linux",
   626  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   627  						SourceRPM:    "bash-4.4-150400.25.22.src.rpm",
   628  						OSID:         "fedora",
   629  						OSVersionID:  "38",
   630  						Vendor:       "SUSE LLC <https://www.suse.com/>",
   631  						Architecture: "x86_64",
   632  					},
   633  					Licenses: []string{"GPL-3.0-or-later"},
   634  				},
   635  				{
   636  					Locations: []string{"testdata/Packages.db"},
   637  					Name:      "bash-sh",
   638  					Version:   "4.4-150400.25.22",
   639  					PURLType:  purl.TypeRPM,
   640  					Metadata: &rpmmeta.Metadata{
   641  						PackageName:  "bash-sh",
   642  						Epoch:        0,
   643  						SourceRPM:    "bash-4.4-150400.25.22.src.rpm",
   644  						OSID:         "fedora",
   645  						OSVersionID:  "38",
   646  						OSName:       "Fedora Linux",
   647  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   648  						Vendor:       "SUSE LLC <https://www.suse.com/>",
   649  						Architecture: "x86_64",
   650  					},
   651  					Licenses: []string{"GPL-3.0-or-later"},
   652  				},
   653  			},
   654  			wantResults: 137,
   655  		},
   656  		{
   657  			name: "CentOS_7.9.2009_Packages_file_(Berkley_DB)",
   658  			// docker run --rm --entrypoint cat centos:centos7.9.2009 /var/lib/rpm/Packages > third_party/scalibr/extractor/filesystem/os/rpm/testdata/Packages
   659  			path:      "testdata/Packages",
   660  			osrelease: fedora38,
   661  			wantPackages: []*extractor.Package{
   662  				{
   663  					Locations: []string{"testdata/Packages"},
   664  					Name:      "acl",
   665  					Version:   "2.2.51-15.el7",
   666  					PURLType:  purl.TypeRPM,
   667  					Metadata: &rpmmeta.Metadata{
   668  						PackageName:  "acl",
   669  						Epoch:        0,
   670  						SourceRPM:    "acl-2.2.51-15.el7.src.rpm",
   671  						OSID:         "fedora",
   672  						OSVersionID:  "38",
   673  						OSName:       "Fedora Linux",
   674  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   675  						Vendor:       "CentOS",
   676  						Architecture: "x86_64",
   677  					},
   678  					Licenses: []string{"GPLv2+"},
   679  				},
   680  				{
   681  					Locations: []string{"testdata/Packages"},
   682  					Name:      "audit-libs",
   683  					Version:   "2.8.5-4.el7",
   684  					PURLType:  purl.TypeRPM,
   685  					Metadata: &rpmmeta.Metadata{
   686  						PackageName:  "audit-libs",
   687  						Epoch:        0,
   688  						SourceRPM:    "audit-2.8.5-4.el7.src.rpm",
   689  						OSID:         "fedora",
   690  						OSVersionID:  "38",
   691  						OSName:       "Fedora Linux",
   692  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   693  						Vendor:       "CentOS",
   694  						Architecture: "x86_64",
   695  					},
   696  					Licenses: []string{"LGPLv2+"},
   697  				},
   698  				{
   699  					Locations: []string{"testdata/Packages"},
   700  					Name:      "basesystem",
   701  					Version:   "10.0-7.el7.centos",
   702  					PURLType:  purl.TypeRPM,
   703  					Metadata: &rpmmeta.Metadata{
   704  						PackageName:  "basesystem",
   705  						Epoch:        0,
   706  						SourceRPM:    "basesystem-10.0-7.el7.centos.src.rpm",
   707  						OSID:         "fedora",
   708  						OSVersionID:  "38",
   709  						OSName:       "Fedora Linux",
   710  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   711  						Vendor:       "CentOS",
   712  						Architecture: "noarch",
   713  					},
   714  					Licenses: []string{"Public Domain"},
   715  				},
   716  			},
   717  			wantResults: 148,
   718  		},
   719  		{
   720  			name: "RockyLinux_9.2.20230513_rpmdb.sqlite_file_(sqlite3)",
   721  			// docker run --rm --entrypoint cat rockylinux:9.2.20230513 /var/lib/rpm/rpmdb.sqlite > third_party/scalibr/extractor/filesystem/os/rpm/testdata/rpmdb.sqlite
   722  			path:      "testdata/rpmdb.sqlite",
   723  			osrelease: fedora38,
   724  			wantPackages: []*extractor.Package{
   725  				{
   726  					Locations: []string{"testdata/rpmdb.sqlite"},
   727  					Name:      "alternatives",
   728  					Version:   "1.20-2.el9",
   729  					PURLType:  purl.TypeRPM,
   730  					Metadata: &rpmmeta.Metadata{
   731  						PackageName:  "alternatives",
   732  						Epoch:        0,
   733  						SourceRPM:    "chkconfig-1.20-2.el9.src.rpm",
   734  						OSID:         "fedora",
   735  						OSVersionID:  "38",
   736  						OSName:       "Fedora Linux",
   737  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   738  						Vendor:       "Rocky Enterprise Software Foundation",
   739  						Architecture: "x86_64",
   740  					},
   741  					Licenses: []string{"GPLv2"},
   742  				},
   743  				{
   744  					Locations: []string{"testdata/rpmdb.sqlite"},
   745  					Name:      "audit-libs",
   746  					Version:   "3.0.7-103.el9",
   747  					PURLType:  purl.TypeRPM,
   748  					Metadata: &rpmmeta.Metadata{
   749  						PackageName:  "audit-libs",
   750  						Epoch:        0,
   751  						SourceRPM:    "audit-3.0.7-103.el9.src.rpm",
   752  						OSID:         "fedora",
   753  						OSVersionID:  "38",
   754  						OSName:       "Fedora Linux",
   755  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   756  						Vendor:       "Rocky Enterprise Software Foundation",
   757  						Architecture: "x86_64",
   758  					},
   759  					Licenses: []string{"LGPLv2+"},
   760  				},
   761  				{
   762  					Locations: []string{"testdata/rpmdb.sqlite"},
   763  					Name:      "basesystem",
   764  					Version:   "11-13.el9",
   765  					PURLType:  purl.TypeRPM,
   766  					Metadata: &rpmmeta.Metadata{
   767  						PackageName:  "basesystem",
   768  						Epoch:        0,
   769  						SourceRPM:    "basesystem-11-13.el9.src.rpm",
   770  						OSID:         "fedora",
   771  						OSVersionID:  "38",
   772  						OSName:       "Fedora Linux",
   773  						OSPrettyName: "Fedora Linux 38 (Container Image)",
   774  						Vendor:       "Rocky Enterprise Software Foundation",
   775  						Architecture: "noarch",
   776  					},
   777  					Licenses: []string{"Public Domain"},
   778  				},
   779  			},
   780  			wantResults: 141,
   781  		},
   782  		{
   783  			name: "custom_rpm",
   784  			// https://www.redhat.com/sysadmin/create-rpm-package
   785  			path: "testdata/Packages_epoch",
   786  			osrelease: `NAME=Fedora
   787  			VERSION="32 (Container Image)"
   788  			ID=fedora
   789  			VERSION_ID=32
   790  			VERSION_CODENAME=""
   791  			PLATFORM_ID="platform:f32"
   792  			PRETTY_NAME="Fedora 32 (Container Image)"
   793  			CPE_NAME="cpe:/o:fedoraproject:fedora:32"`,
   794  
   795  			wantPackages: []*extractor.Package{
   796  				{
   797  					Locations: []string{"testdata/Packages_epoch"},
   798  					Name:      "hello",
   799  					Version:   "0.0.1-rls",
   800  					PURLType:  purl.TypeRPM,
   801  					Metadata: &rpmmeta.Metadata{
   802  						PackageName:  "hello",
   803  						Epoch:        1,
   804  						SourceRPM:    "hello-0.0.1-rls.src.rpm",
   805  						OSID:         "fedora",
   806  						OSName:       "Fedora",
   807  						OSPrettyName: "Fedora 32 (Container Image)",
   808  						OSVersionID:  "32",
   809  						Architecture: "x86_64",
   810  					},
   811  					Licenses: []string{"GPL"},
   812  				},
   813  			},
   814  			wantResults: 1,
   815  		},
   816  		{
   817  			name:         "empty",
   818  			path:         "testdata/empty.sqlite",
   819  			wantPackages: nil,
   820  			wantResults:  0,
   821  			wantErr:      io.EOF,
   822  		},
   823  		{
   824  			name:         "invalid",
   825  			path:         "testdata/invalid",
   826  			wantPackages: nil,
   827  			wantResults:  0,
   828  			wantErr:      io.ErrUnexpectedEOF,
   829  		},
   830  	}
   831  
   832  	for _, tt := range tests {
   833  		t.Run(tt.name, func(t *testing.T) {
   834  			d := t.TempDir()
   835  			createOsRelease(t, d, tt.osrelease)
   836  
   837  			// Need to record scalibr files found in /tmp before the rpm extractor runs, as it may create
   838  			// some. This is needed to compare the files found after the extractor runs.
   839  			filesInTmpWant := scalibrFilesInTmp(t)
   840  
   841  			r, err := os.Open(tt.path)
   842  			defer func() {
   843  				if err = r.Close(); err != nil {
   844  					t.Errorf("Close(): %v", err)
   845  				}
   846  			}()
   847  			if err != nil {
   848  				t.Fatal(err)
   849  			}
   850  
   851  			info, err := os.Stat(tt.path)
   852  			if err != nil {
   853  				t.Fatalf("Failed to stat test file: %v", err)
   854  			}
   855  
   856  			input := &filesystem.ScanInput{
   857  				FS: scalibrfs.DirFS(d), Path: tt.path, Reader: r, Info: info,
   858  			}
   859  
   860  			got, err := rpm.New(rpm.Config{}).Extract(t.Context(), input)
   861  			if !cmp.Equal(err, tt.wantErr, cmpopts.EquateErrors()) {
   862  				t.Fatalf("Extract(%+v) error: got %v, want %v\n", tt.path, err, tt.wantErr)
   863  			}
   864  
   865  			pkgs := got.Packages
   866  			sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Name < pkgs[j].Name })
   867  			gotFirst3 := pkgs[:min(len(pkgs), 3)]
   868  			if diff := cmp.Diff(tt.wantPackages, gotFirst3); diff != "" {
   869  				t.Errorf("Extract(%s) (-want +got):\n%s", tt.path, diff)
   870  			}
   871  
   872  			if len(pkgs) != tt.wantResults {
   873  				t.Errorf("Extract(%s): got %d results, want %d\n", tt.path, len(pkgs), tt.wantResults)
   874  			}
   875  
   876  			// Check that no scalibr files remain in /tmp.
   877  			filesInTmpGot := scalibrFilesInTmp(t)
   878  			less := func(a, b string) bool { return a < b }
   879  			if diff := cmp.Diff(filesInTmpWant, filesInTmpGot, cmpopts.SortSlices(less)); diff != "" {
   880  				t.Errorf("returned unexpected diff (-want +got):\n%s", diff)
   881  			}
   882  		})
   883  	}
   884  }
   885  
   886  // CopyFileToTempDir copies the passed in file to a temporary directory, then returns the new file path.
   887  func CopyFileToTempDir(t *testing.T, filepath, root string) (string, error) {
   888  	t.Helper()
   889  
   890  	filename := path.Base(filepath)
   891  	newfile := path.Join(root, filename)
   892  
   893  	bytes, err := os.ReadFile(filepath)
   894  	if os.IsNotExist(err) {
   895  		return newfile, nil
   896  	}
   897  	if err != nil {
   898  		return "", err
   899  	}
   900  	if err := os.WriteFile(newfile, bytes, 0400); err != nil {
   901  		return "", err
   902  	}
   903  	return newfile, nil
   904  }
   905  
   906  func createOsRelease(t *testing.T, root string, content string) {
   907  	t.Helper()
   908  	_ = os.MkdirAll(filepath.Join(root, "etc"), 0755)
   909  	err := os.WriteFile(filepath.Join(root, "etc/os-release"), []byte(content), 0644)
   910  	if err != nil {
   911  		t.Fatalf("write to %s: %v\n", filepath.Join(root, "etc/os-release"), err)
   912  	}
   913  }
   914  
   915  // scalibrFilesInTmp returns the list of filenames in /tmp that start with "scalibr-".
   916  func scalibrFilesInTmp(t *testing.T) []string {
   917  	t.Helper()
   918  
   919  	filenames := []string{}
   920  	files, err := os.ReadDir(os.TempDir())
   921  	if err != nil {
   922  		t.Fatalf("os.ReadDir('%q') error: %v", os.TempDir(), err)
   923  	}
   924  
   925  	for _, f := range files {
   926  		name := f.Name()
   927  		if strings.HasPrefix(name, "scalibr-") {
   928  			filenames = append(filenames, f.Name())
   929  		}
   930  	}
   931  	return filenames
   932  }