github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/os/snap/snap_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 snap_test
    16  
    17  import (
    18  	"fmt"
    19  	"io/fs"
    20  	"os"
    21  	"path/filepath"
    22  	"runtime"
    23  	"slices"
    24  	"testing"
    25  
    26  	"github.com/google/go-cmp/cmp"
    27  	"github.com/google/go-cmp/cmp/cmpopts"
    28  	"github.com/google/osv-scalibr/extractor"
    29  	"github.com/google/osv-scalibr/extractor/filesystem"
    30  	"github.com/google/osv-scalibr/extractor/filesystem/internal/units"
    31  	"github.com/google/osv-scalibr/extractor/filesystem/os/snap"
    32  	snapmeta "github.com/google/osv-scalibr/extractor/filesystem/os/snap/metadata"
    33  	"github.com/google/osv-scalibr/extractor/filesystem/simplefileapi"
    34  	scalibrfs "github.com/google/osv-scalibr/fs"
    35  	"github.com/google/osv-scalibr/inventory"
    36  	"github.com/google/osv-scalibr/purl"
    37  	"github.com/google/osv-scalibr/stats"
    38  	"github.com/google/osv-scalibr/testing/fakefs"
    39  	"github.com/google/osv-scalibr/testing/testcollector"
    40  )
    41  
    42  const DebianBookworm = `PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
    43  NAME="Debian GNU/Linux"
    44  VERSION_ID="12"
    45  VERSION="12 (bookworm)"
    46  VERSION_CODENAME=bookworm
    47  ID=debian`
    48  
    49  func TestFileRequired(t *testing.T) {
    50  	// supported OSes
    51  	if !slices.Contains([]string{"linux"}, runtime.GOOS) {
    52  		t.Skipf("Test skipped, OS unsupported: %v", runtime.GOOS)
    53  	}
    54  
    55  	tests := []struct {
    56  		name             string
    57  		path             string
    58  		fileSizeBytes    int64
    59  		maxFileSizeBytes int64
    60  		wantRequired     bool
    61  		wantResultMetric stats.FileRequiredResult
    62  	}{
    63  		{
    64  			name:             "package info",
    65  			path:             "snap/core/current/meta/snap.yaml",
    66  			wantRequired:     true,
    67  			wantResultMetric: stats.FileRequiredResultOK,
    68  		}, {
    69  			name:         "not a snap yaml file",
    70  			path:         "some/other/file.yaml",
    71  			wantRequired: false,
    72  		}, {
    73  			name:         "missing revision in path",
    74  			path:         "snap/core/meta/snap.yaml",
    75  			wantRequired: false,
    76  		}, {
    77  			name:         "missing name in path",
    78  			path:         "snap/current/meta/snap.yaml",
    79  			wantRequired: false,
    80  		}, {
    81  			name:         "extra dirs in path",
    82  			path:         "snap/core/current/extra/meta/snap.yaml",
    83  			wantRequired: false,
    84  		}, {
    85  			name:             "snap.yaml required if file size < max file size",
    86  			path:             "snap/core/current/meta/snap.yaml",
    87  			fileSizeBytes:    100 * units.KiB,
    88  			maxFileSizeBytes: 1000 * units.KiB,
    89  			wantRequired:     true,
    90  			wantResultMetric: stats.FileRequiredResultOK,
    91  		}, {
    92  			name:             "snap.yaml required if file size == max file size",
    93  			path:             "snap/core/current/meta/snap.yaml",
    94  			fileSizeBytes:    1000 * units.KiB,
    95  			maxFileSizeBytes: 1000 * units.KiB,
    96  			wantRequired:     true,
    97  			wantResultMetric: stats.FileRequiredResultOK,
    98  		}, {
    99  			name:             "snap.yaml not required if file size > max file size",
   100  			path:             "snap/core/current/meta/snap.yaml",
   101  			fileSizeBytes:    1000 * units.KiB,
   102  			maxFileSizeBytes: 100 * units.KiB,
   103  			wantRequired:     false,
   104  			wantResultMetric: stats.FileRequiredResultSizeLimitExceeded,
   105  		}, {
   106  			name:             "snap.yaml required if max file size set to 0",
   107  			path:             "snap/core/current/meta/snap.yaml",
   108  			fileSizeBytes:    100 * units.KiB,
   109  			maxFileSizeBytes: 0,
   110  			wantRequired:     true,
   111  			wantResultMetric: stats.FileRequiredResultOK,
   112  		},
   113  	}
   114  
   115  	for _, tt := range tests {
   116  		t.Run(tt.name, func(t *testing.T) {
   117  			collector := testcollector.New()
   118  			var e filesystem.Extractor = snap.New(snap.Config{
   119  				Stats:            collector,
   120  				MaxFileSizeBytes: tt.maxFileSizeBytes,
   121  			})
   122  
   123  			// Set a default file size if not specified.
   124  			fileSizeBytes := tt.fileSizeBytes
   125  			if fileSizeBytes == 0 {
   126  				fileSizeBytes = 1000
   127  			}
   128  
   129  			isRequired := e.FileRequired(simplefileapi.New(tt.path, fakefs.FakeFileInfo{
   130  				FileName: filepath.Base(tt.path),
   131  				FileMode: fs.ModePerm,
   132  				FileSize: fileSizeBytes,
   133  			}))
   134  			if isRequired != tt.wantRequired {
   135  				t.Fatalf("FileRequired(%s): got %v, want %v", tt.path, isRequired, tt.wantRequired)
   136  			}
   137  
   138  			gotResultMetric := collector.FileRequiredResult(tt.path)
   139  			if tt.wantResultMetric != "" && gotResultMetric != tt.wantResultMetric {
   140  				t.Errorf("FileRequired(%s) recorded result metric %v, want result metric %v", tt.path, gotResultMetric, tt.wantResultMetric)
   141  			}
   142  		})
   143  	}
   144  }
   145  
   146  func TestExtract(t *testing.T) {
   147  	// supported OSes
   148  	if !slices.Contains([]string{"linux"}, runtime.GOOS) {
   149  		t.Skipf("Test skipped, OS unsupported: %v", runtime.GOOS)
   150  	}
   151  
   152  	tests := []struct {
   153  		name             string
   154  		path             string
   155  		osrelease        string
   156  		wantPackages     []*extractor.Package
   157  		wantErr          error
   158  		wantResultMetric stats.FileExtractedResult
   159  	}{
   160  		{
   161  			name:             "invalid",
   162  			path:             "testdata/invalid",
   163  			osrelease:        DebianBookworm,
   164  			wantErr:          cmpopts.AnyError,
   165  			wantResultMetric: stats.FileExtractedResultErrorUnknown,
   166  		},
   167  		{
   168  			name:      "valid yaml with single arch",
   169  			path:      "testdata/single-arch.yaml",
   170  			osrelease: DebianBookworm,
   171  			wantPackages: []*extractor.Package{
   172  				{
   173  					Name:      "core",
   174  					Version:   "16-2.61.4-20240607",
   175  					PURLType:  purl.TypeSnap,
   176  					Locations: []string{"testdata/single-arch.yaml"},
   177  					Metadata: &snapmeta.Metadata{
   178  						Name:              "core",
   179  						Version:           "16-2.61.4-20240607",
   180  						Grade:             "stable",
   181  						Type:              "os",
   182  						Architectures:     []string{"amd64"},
   183  						OSID:              "debian",
   184  						OSVersionCodename: "bookworm",
   185  						OSVersionID:       "12",
   186  					},
   187  				},
   188  			},
   189  			wantResultMetric: stats.FileExtractedResultSuccess,
   190  		},
   191  		{
   192  			name:      "valid yaml with multiple arch",
   193  			path:      "testdata/multi-arch.yaml",
   194  			osrelease: DebianBookworm,
   195  			wantPackages: []*extractor.Package{
   196  				{
   197  					Name:      "core",
   198  					Version:   "16-2.61.4-20240607",
   199  					PURLType:  purl.TypeSnap,
   200  					Locations: []string{"testdata/multi-arch.yaml"},
   201  					Metadata: &snapmeta.Metadata{
   202  						Name:              "core",
   203  						Version:           "16-2.61.4-20240607",
   204  						Grade:             "stable",
   205  						Type:              "os",
   206  						Architectures:     []string{"amd64", "arm64"},
   207  						OSID:              "debian",
   208  						OSVersionCodename: "bookworm",
   209  						OSVersionID:       "12",
   210  					},
   211  				},
   212  			},
   213  			wantResultMetric: stats.FileExtractedResultSuccess,
   214  		},
   215  		{
   216  			name:             "yaml missing name",
   217  			path:             "testdata/missing-name.yaml",
   218  			osrelease:        DebianBookworm,
   219  			wantErr:          cmpopts.AnyError,
   220  			wantResultMetric: stats.FileExtractedResultErrorUnknown,
   221  		},
   222  		{
   223  			name:             "yaml missing version",
   224  			path:             "testdata/missing-version.yaml",
   225  			osrelease:        DebianBookworm,
   226  			wantErr:          cmpopts.AnyError,
   227  			wantResultMetric: stats.FileExtractedResultErrorUnknown,
   228  		},
   229  	}
   230  
   231  	for _, tt := range tests {
   232  		t.Run(tt.name, func(t *testing.T) {
   233  			collector := testcollector.New()
   234  			var e filesystem.Extractor = snap.New(snap.Config{
   235  				Stats: collector,
   236  			})
   237  
   238  			d := t.TempDir()
   239  			createOsRelease(t, d, tt.osrelease)
   240  
   241  			r, err := os.Open(tt.path)
   242  			defer func() {
   243  				if err = r.Close(); err != nil {
   244  					t.Errorf("Close(): %v", err)
   245  				}
   246  			}()
   247  			if err != nil {
   248  				t.Fatal(err)
   249  			}
   250  
   251  			info, err := os.Stat(tt.path)
   252  			if err != nil {
   253  				t.Fatalf("Failed to stat test file: %v", err)
   254  			}
   255  
   256  			input := &filesystem.ScanInput{
   257  				FS:     scalibrfs.DirFS(d),
   258  				Path:   tt.path,
   259  				Reader: r,
   260  				Root:   d,
   261  				Info:   info,
   262  			}
   263  
   264  			got, err := e.Extract(t.Context(), input)
   265  			if !cmp.Equal(err, tt.wantErr, cmpopts.EquateErrors()) {
   266  				t.Fatalf("Extract(%+v) error: got %v, want %v\n", tt.path, err, tt.wantErr)
   267  			}
   268  
   269  			ignoreOrder := cmpopts.SortSlices(func(a, b any) bool {
   270  				return fmt.Sprintf("%+v", a) < fmt.Sprintf("%+v", b)
   271  			})
   272  			wantInv := inventory.Inventory{Packages: tt.wantPackages}
   273  			if diff := cmp.Diff(wantInv, got, ignoreOrder); diff != "" {
   274  				t.Errorf("Extract(%s) (-want +got):\n%s", tt.path, diff)
   275  			}
   276  
   277  			gotResultMetric := collector.FileExtractedResult(tt.path)
   278  			if tt.wantResultMetric != "" && gotResultMetric != tt.wantResultMetric {
   279  				t.Errorf("Extract(%s) recorded result metric %v, want result metric %v", tt.path, gotResultMetric, tt.wantResultMetric)
   280  			}
   281  
   282  			gotFileSizeMetric := collector.FileExtractedFileSize(tt.path)
   283  			if gotFileSizeMetric != info.Size() {
   284  				t.Errorf("Extract(%s) recorded file size %v, want file size %v", tt.path, gotFileSizeMetric, info.Size())
   285  			}
   286  		})
   287  	}
   288  }
   289  
   290  func createOsRelease(t *testing.T, root string, content string) {
   291  	t.Helper()
   292  	_ = os.MkdirAll(filepath.Join(root, "etc"), 0755)
   293  	err := os.WriteFile(filepath.Join(root, "etc/os-release"), []byte(content), 0644)
   294  	if err != nil {
   295  		t.Fatalf("write to %s: %v\n", filepath.Join(root, "etc/os-release"), err)
   296  	}
   297  }