github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/os/apk/apk_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 apk_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/apk"
    30  	apkmeta "github.com/google/osv-scalibr/extractor/filesystem/os/apk/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  func TestFileRequired(t *testing.T) {
    41  	tests := []struct {
    42  		name             string
    43  		path             string
    44  		fileSizeBytes    int64
    45  		maxFileSizeBytes int64
    46  		wantRequired     bool
    47  		wantResultMetric stats.FileRequiredResult
    48  	}{
    49  		{
    50  			name:             "installed file",
    51  			path:             "lib/apk/db/installed",
    52  			wantRequired:     true,
    53  			wantResultMetric: stats.FileRequiredResultOK,
    54  		},
    55  		{
    56  			name:             "installed file in /usr",
    57  			path:             "usr/lib/apk/db/installed",
    58  			wantRequired:     true,
    59  			wantResultMetric: stats.FileRequiredResultOK,
    60  		},
    61  		{
    62  			name:             "installed file in /var",
    63  			path:             "var/lib/apk/db/installed",
    64  			wantRequired:     true,
    65  			wantResultMetric: stats.FileRequiredResultOK,
    66  		},
    67  		{
    68  			name:         "sub file",
    69  			path:         "lib/apk/db/installed/test",
    70  			wantRequired: false,
    71  		},
    72  		{
    73  			name:         "inside other dir",
    74  			path:         "foo/lib/apk/db/installed",
    75  			wantRequired: false,
    76  		},
    77  		{
    78  			name:             "installed file required if file size < max file size",
    79  			path:             "lib/apk/db/installed",
    80  			fileSizeBytes:    100 * units.KiB,
    81  			maxFileSizeBytes: 1000 * units.KiB,
    82  			wantRequired:     true,
    83  			wantResultMetric: stats.FileRequiredResultOK,
    84  		},
    85  		{
    86  			name:             "installed file required if file size == max file size",
    87  			path:             "lib/apk/db/installed",
    88  			fileSizeBytes:    100 * units.KiB,
    89  			maxFileSizeBytes: 100 * units.KiB,
    90  			wantRequired:     true,
    91  			wantResultMetric: stats.FileRequiredResultOK,
    92  		},
    93  		{
    94  			name:             "installed file not required if file size > max file size",
    95  			path:             "lib/apk/db/installed",
    96  			fileSizeBytes:    1000 * units.KiB,
    97  			maxFileSizeBytes: 100 * units.KiB,
    98  			wantRequired:     false,
    99  			wantResultMetric: stats.FileRequiredResultSizeLimitExceeded,
   100  		},
   101  		{
   102  			name:             "installed file required if max file size set to 0",
   103  			path:             "lib/apk/db/installed",
   104  			fileSizeBytes:    100 * units.KiB,
   105  			maxFileSizeBytes: 0,
   106  			wantRequired:     true,
   107  			wantResultMetric: stats.FileRequiredResultOK,
   108  		},
   109  	}
   110  
   111  	for _, tt := range tests {
   112  		t.Run(tt.name, func(t *testing.T) {
   113  			collector := testcollector.New()
   114  			var e filesystem.Extractor = apk.New(apk.Config{
   115  				Stats:            collector,
   116  				MaxFileSizeBytes: tt.maxFileSizeBytes,
   117  			})
   118  
   119  			// Set a default file size if not specified.
   120  			fileSizeBytes := tt.fileSizeBytes
   121  			if fileSizeBytes == 0 {
   122  				fileSizeBytes = 1000
   123  			}
   124  
   125  			isRequired := e.FileRequired(simplefileapi.New(tt.path, fakefs.FakeFileInfo{
   126  				FileName: filepath.Base(tt.path),
   127  				FileMode: fs.ModePerm,
   128  				FileSize: fileSizeBytes,
   129  			}))
   130  			if isRequired != tt.wantRequired {
   131  				t.Fatalf("FileRequired(%s): got %v, want %v", tt.path, isRequired, tt.wantRequired)
   132  			}
   133  
   134  			gotResultMetric := collector.FileRequiredResult(tt.path)
   135  			if tt.wantResultMetric != "" && gotResultMetric != tt.wantResultMetric {
   136  				t.Errorf("FileRequired(%s) recorded result metric %v, want result metric %v", tt.path, gotResultMetric, tt.wantResultMetric)
   137  			}
   138  		})
   139  	}
   140  }
   141  
   142  const alpine = `NAME="Alpine Linux"
   143  ID=alpine
   144  VERSION_ID=3.18.0
   145  PRETTY_NAME="Alpine Linux v3.18"
   146  HOME_URL="https://alpinelinux.org/"
   147  BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues"`
   148  
   149  func TestExtract(t *testing.T) {
   150  	tests := []struct {
   151  		name             string
   152  		path             string
   153  		osrelease        string
   154  		wantPackages     []*extractor.Package
   155  		wantErr          error
   156  		wantResultMetric stats.FileExtractedResult
   157  	}{
   158  		{
   159  			name:      "alpine latest",
   160  			path:      "testdata/installed",
   161  			osrelease: alpine,
   162  			wantPackages: []*extractor.Package{
   163  				getPackage("testdata/installed", "alpine-baselayout", "alpine-baselayout", "3.4.3-r1", "alpine", "3.18.0", "Natanael Copa <ncopa@alpinelinux.org>", "x86_64", "GPL-2.0-only", "65502ca9379dd29d1ac4b0bf0dcf03a3dd1b324a"),
   164  				getPackage("testdata/installed", "alpine-baselayout-data", "alpine-baselayout", "3.4.3-r1", "alpine", "3.18.0", "Natanael Copa <ncopa@alpinelinux.org>", "x86_64", "GPL-2.0-only", "65502ca9379dd29d1ac4b0bf0dcf03a3dd1b324a"),
   165  				getPackage("testdata/installed", "alpine-keys", "alpine-keys", "2.4-r1", "alpine", "3.18.0", "Natanael Copa <ncopa@alpinelinux.org>", "x86_64", "MIT", "aab68f8c9ab434a46710de8e12fb3206e2930a59"),
   166  				getPackage("testdata/installed", "apk-tools", "apk-tools", "2.14.0-r0", "alpine", "3.18.0", "Natanael Copa <ncopa@alpinelinux.org>", "x86_64", "GPL-2.0-only", "028d34f678a5386c3dc488cc3b62467c7a9d1a0b"),
   167  				getPackage("testdata/installed", "busybox", "busybox", "1.36.0-r9", "alpine", "3.18.0", "Sören Tempel <soeren+alpine@soeren-tempel.net>", "x86_64", "GPL-2.0-only", "b5c719c244319df3c72ab1f1ee994c2143cab7f0"),
   168  				getPackage("testdata/installed", "busybox-binsh", "busybox", "1.36.0-r9", "alpine", "3.18.0", "Sören Tempel <soeren+alpine@soeren-tempel.net>", "x86_64", "GPL-2.0-only", "b5c719c244319df3c72ab1f1ee994c2143cab7f0"),
   169  				getPackage("testdata/installed", "ca-certificates-bundle", "ca-certificates", "20230506-r0", "alpine", "3.18.0", "Natanael Copa <ncopa@alpinelinux.org>", "x86_64", "MPL-2.0 AND MIT", "59534a02716a92a10d177a118c34066162eff4a6"),
   170  				getPackage("testdata/installed", "libc-utils", "libc-dev", "0.7.2-r5", "alpine", "3.18.0", "Natanael Copa <ncopa@alpinelinux.org>", "x86_64", "BSD-2-Clause AND BSD-3-Clause", "988f183cc9d6699930c3e18ccf4a9e36010afb56"),
   171  				getPackage("testdata/installed", "libcrypto3", "openssl", "3.1.0-r4", "alpine", "3.18.0", "Ariadne Conill <ariadne@dereferenced.org>", "x86_64", "Apache-2.0", "730b75e01c670e3dba5d6c05420b5f605edb6201"),
   172  				getPackage("testdata/installed", "libssl3", "openssl", "3.1.0-r4", "alpine", "3.18.0", "Ariadne Conill <ariadne@dereferenced.org>", "x86_64", "Apache-2.0", "730b75e01c670e3dba5d6c05420b5f605edb6201"),
   173  				getPackage("testdata/installed", "musl", "musl", "1.2.4-r0", "alpine", "3.18.0", "Timo Teräs <timo.teras@iki.fi>", "x86_64", "MIT", "b0d8a9d948174e28a4aefcee4ef6be872225ccce"),
   174  				getPackage("testdata/installed", "musl-utils", "musl", "1.2.4-r0", "alpine", "3.18.0", "Timo Teräs <timo.teras@iki.fi>", "x86_64", "MIT AND BSD-2-Clause AND GPL-2.0-or-later", "b0d8a9d948174e28a4aefcee4ef6be872225ccce"),
   175  				getPackage("testdata/installed", "scanelf", "pax-utils", "1.3.7-r1", "alpine", "3.18.0", "Natanael Copa <ncopa@alpinelinux.org>", "x86_64", "GPL-2.0-only", "84a227baf001b6e0208e3352b294e4d7a40e93de"),
   176  				getPackage("testdata/installed", "ssl_client", "busybox", "1.36.0-r9", "alpine", "3.18.0", "Sören Tempel <soeren+alpine@soeren-tempel.net>", "x86_64", "GPL-2.0-only", "b5c719c244319df3c72ab1f1ee994c2143cab7f0"),
   177  				getPackage("testdata/installed", "zlib", "zlib", "1.2.13-r1", "alpine", "3.18.0", "Natanael Copa <ncopa@alpinelinux.org>", "x86_64", "Zlib", "84a227baf001b6e0208e3352b294e4d7a40e93de"),
   178  			},
   179  			wantResultMetric: stats.FileExtractedResultSuccess,
   180  		},
   181  		{
   182  			name:      "origin not set",
   183  			path:      "testdata/no-origin",
   184  			osrelease: alpine,
   185  			wantPackages: []*extractor.Package{
   186  				getPackage("testdata/no-origin", "pkgname", "", "1.2.3", "alpine", "3.18.0", "", "x86_64", "GPL-2.0-only", ""),
   187  			},
   188  			wantResultMetric: stats.FileExtractedResultSuccess,
   189  		},
   190  		{
   191  			name:             "empty",
   192  			path:             "testdata/empty",
   193  			wantPackages:     []*extractor.Package{},
   194  			wantResultMetric: stats.FileExtractedResultSuccess,
   195  		},
   196  		{
   197  			name:             "invalid",
   198  			path:             "testdata/invalid",
   199  			wantPackages:     nil,
   200  			wantErr:          cmpopts.AnyError,
   201  			wantResultMetric: stats.FileExtractedResultErrorUnknown,
   202  		},
   203  		{
   204  			name: "osrelease_openwrt",
   205  			path: "testdata/single",
   206  			osrelease: `ID=openwrt
   207  			VERSION_ID=1.2.3`,
   208  			wantPackages: []*extractor.Package{
   209  				getPackage("testdata/single", "alpine-baselayout-data", "alpine-baselayout", "3.4.3-r1", "openwrt", "1.2.3", "Natanael Copa <ncopa@alpinelinux.org>", "x86_64", "GPL-2.0-only", "65502ca9379dd29d1ac4b0bf0dcf03a3dd1b324a"),
   210  			},
   211  			wantResultMetric: stats.FileExtractedResultSuccess,
   212  		},
   213  		{
   214  			name:      "osrelease no version",
   215  			path:      "testdata/single",
   216  			osrelease: "ID=openwrt",
   217  			wantPackages: []*extractor.Package{
   218  				getPackage("testdata/single", "alpine-baselayout-data", "alpine-baselayout", "3.4.3-r1", "openwrt", "", "Natanael Copa <ncopa@alpinelinux.org>", "x86_64", "GPL-2.0-only", "65502ca9379dd29d1ac4b0bf0dcf03a3dd1b324a"),
   219  			},
   220  			wantResultMetric: stats.FileExtractedResultSuccess,
   221  		},
   222  		{
   223  			name:      "no osrelease",
   224  			path:      "testdata/single",
   225  			osrelease: "",
   226  			wantPackages: []*extractor.Package{
   227  				getPackage("testdata/single", "alpine-baselayout-data", "alpine-baselayout", "3.4.3-r1", "", "", "Natanael Copa <ncopa@alpinelinux.org>", "x86_64", "GPL-2.0-only", "65502ca9379dd29d1ac4b0bf0dcf03a3dd1b324a"),
   228  			},
   229  			wantResultMetric: stats.FileExtractedResultSuccess,
   230  		},
   231  		{
   232  			name:      "different arch",
   233  			path:      "testdata/different-arch",
   234  			osrelease: "",
   235  			wantPackages: []*extractor.Package{
   236  				getPackage("testdata/different-arch", "alpine-baselayout-data", "alpine-baselayout", "3.4.3-r1", "", "", "Natanael Copa <ncopa@alpinelinux.org>", "noarch", "GPL-2.0-only", "65502ca9379dd29d1ac4b0bf0dcf03a3dd1b324a"),
   237  			},
   238  			wantResultMetric: stats.FileExtractedResultSuccess,
   239  		},
   240  	}
   241  
   242  	for _, tt := range tests {
   243  		t.Run(tt.name, func(t *testing.T) {
   244  			collector := testcollector.New()
   245  			var e filesystem.Extractor = apk.New(apk.Config{
   246  				Stats: collector,
   247  			})
   248  
   249  			d := t.TempDir()
   250  			createOsRelease(t, d, tt.osrelease)
   251  
   252  			r, err := os.Open(tt.path)
   253  			defer func() {
   254  				if err = r.Close(); err != nil {
   255  					t.Errorf("Close(): %v", err)
   256  				}
   257  			}()
   258  			if err != nil {
   259  				t.Fatal(err)
   260  			}
   261  
   262  			info, err := os.Stat(tt.path)
   263  			if err != nil {
   264  				t.Fatalf("Failed to stat test file: %v", err)
   265  			}
   266  
   267  			input := &filesystem.ScanInput{
   268  				FS:     scalibrfs.DirFS(d),
   269  				Path:   tt.path,
   270  				Reader: r,
   271  				Root:   d,
   272  				Info:   info,
   273  			}
   274  
   275  			got, err := e.Extract(t.Context(), input)
   276  			if !cmp.Equal(err, tt.wantErr, cmpopts.EquateErrors()) {
   277  				t.Fatalf("Extract(%+v) error: got %v, want %v\n", tt.name, err, tt.wantErr)
   278  			}
   279  
   280  			ignoreOrder := cmpopts.SortSlices(func(a, b any) bool {
   281  				return fmt.Sprintf("%+v", a) < fmt.Sprintf("%+v", b)
   282  			})
   283  			wantInv := inventory.Inventory{Packages: tt.wantPackages}
   284  			if diff := cmp.Diff(wantInv, got, ignoreOrder); diff != "" {
   285  				t.Errorf("Extract(%s) (-want +got):\n%s", tt.path, diff)
   286  			}
   287  
   288  			gotResultMetric := collector.FileExtractedResult(tt.path)
   289  			if tt.wantResultMetric != "" && gotResultMetric != tt.wantResultMetric {
   290  				t.Errorf("Extract(%s) recorded result metric %v, want result metric %v", tt.path, gotResultMetric, tt.wantResultMetric)
   291  			}
   292  
   293  			gotFileSizeMetric := collector.FileExtractedFileSize(tt.path)
   294  			if gotFileSizeMetric != info.Size() {
   295  				t.Errorf("Extract(%s) recorded file size %v, want file size %v", tt.path, gotFileSizeMetric, info.Size())
   296  			}
   297  		})
   298  	}
   299  }
   300  
   301  func getPackage(path, pkgName, origin, version, osID, osVersionID, maintainer, arch, license string, commit string) *extractor.Package {
   302  	p := &extractor.Package{
   303  		Locations: []string{path},
   304  		Name:      pkgName,
   305  		Version:   version,
   306  		PURLType:  purl.TypeApk,
   307  		Metadata: &apkmeta.Metadata{
   308  			PackageName:  pkgName,
   309  			OriginName:   origin,
   310  			OSID:         osID,
   311  			OSVersionID:  osVersionID,
   312  			Maintainer:   maintainer,
   313  			Architecture: arch,
   314  		},
   315  		Licenses: []string{license},
   316  	}
   317  	if commit != "" {
   318  		p.SourceCode = &extractor.SourceCodeIdentifier{
   319  			Commit: commit,
   320  		}
   321  	}
   322  	return p
   323  }
   324  
   325  func createOsRelease(t *testing.T, root string, content string) {
   326  	t.Helper()
   327  	_ = os.MkdirAll(filepath.Join(root, "etc"), 0755)
   328  	err := os.WriteFile(filepath.Join(root, "etc/os-release"), []byte(content), 0644)
   329  	if err != nil {
   330  		t.Fatalf("write to %s: %v\n", filepath.Join(root, "etc/os-release"), err)
   331  	}
   332  }