github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/os/winget/winget_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 winget
    16  
    17  import (
    18  	"context"
    19  	"database/sql"
    20  	"fmt"
    21  	"io/fs"
    22  	"path/filepath"
    23  	"testing"
    24  
    25  	"github.com/google/go-cmp/cmp"
    26  	"github.com/google/osv-scalibr/extractor"
    27  	"github.com/google/osv-scalibr/extractor/filesystem/os/winget/metadata"
    28  	"github.com/google/osv-scalibr/extractor/filesystem/simplefileapi"
    29  	"github.com/google/osv-scalibr/purl"
    30  	"github.com/google/osv-scalibr/testing/fakefs"
    31  	_ "modernc.org/sqlite"
    32  )
    33  
    34  func TestFileRequired(t *testing.T) {
    35  	tests := []struct {
    36  		name string
    37  		path string
    38  		want bool
    39  	}{
    40  		{
    41  			name: "WingetInstalledDB_ReturnsTrue",
    42  			path: "/Users/test/AppData/Local/Packages/Microsoft.DesktopAppInstaller_8wekyb3d8bbwe/LocalState/Microsoft.Winget.Source_8wekyb3d8bbwe/installed.db",
    43  			want: true,
    44  		},
    45  		{
    46  			name: "StoreEdgeFDInstalledDB_ReturnsTrue",
    47  			path: "/Users/test/AppData/Local/Packages/Microsoft.DesktopAppInstaller_8wekyb3d8bbwe/LocalState/StoreEdgeFD/installed.db",
    48  			want: true,
    49  		},
    50  		{
    51  			name: "StateRepositoryMachine_ReturnsTrue",
    52  			path: "/ProgramData/Microsoft/Windows/AppRepository/StateRepository-Machine.srd",
    53  			want: true,
    54  		},
    55  		{
    56  			name: "RandomSQLiteFile_ReturnsFalse",
    57  			path: "/some/random/path/database.db",
    58  			want: false,
    59  		},
    60  		{
    61  			name: "WingetDBWrongPath_ReturnsFalse",
    62  			path: "/wrong/path/installed.db",
    63  			want: false,
    64  		},
    65  	}
    66  
    67  	wingetExtractor := NewDefault()
    68  	for _, tt := range tests {
    69  		t.Run(tt.name, func(t *testing.T) {
    70  			api := simplefileapi.New(tt.path, fakefs.FakeFileInfo{
    71  				FileName: filepath.Base(tt.path),
    72  				FileMode: fs.ModePerm,
    73  				FileSize: 1000,
    74  			})
    75  			got := wingetExtractor.FileRequired(api)
    76  			if got != tt.want {
    77  				t.Errorf("FileRequired() = %v, want %v", got, tt.want)
    78  			}
    79  		})
    80  	}
    81  }
    82  
    83  func TestExtract(t *testing.T) {
    84  	tests := []struct {
    85  		name    string
    86  		setupDB func(string) error
    87  		want    []*extractor.Package
    88  		wantErr bool
    89  	}{
    90  		{
    91  			name: "ValidDatabase_ReturnsPackages",
    92  			setupDB: func(dbPath string) error {
    93  				return createTestDatabase(dbPath, []TestPackage{
    94  					{
    95  						ID:       "Git.Git",
    96  						Name:     "Git",
    97  						Version:  "2.50.1",
    98  						Moniker:  "git",
    99  						Channel:  "",
   100  						Tags:     []string{"git", "vcs"},
   101  						Commands: []string{"git"},
   102  					},
   103  					{
   104  						ID:       "Microsoft.VisualStudioCode",
   105  						Name:     "Microsoft Visual Studio Code",
   106  						Version:  "1.103.1",
   107  						Moniker:  "vscode",
   108  						Channel:  "stable",
   109  						Tags:     []string{"developer-tools", "editor"},
   110  						Commands: []string{"code"},
   111  					},
   112  				})
   113  			},
   114  			want: []*extractor.Package{
   115  				{
   116  					Name:      "Git.Git",
   117  					Version:   "2.50.1",
   118  					PURLType:  purl.TypeWinget,
   119  					Locations: []string{"test.db"},
   120  					Metadata: &metadata.Metadata{
   121  						Name:     "Git",
   122  						ID:       "Git.Git",
   123  						Version:  "2.50.1",
   124  						Moniker:  "git",
   125  						Channel:  "",
   126  						Tags:     []string{"git", "vcs"},
   127  						Commands: []string{"git"},
   128  					},
   129  				},
   130  				{
   131  					Name:      "Microsoft.VisualStudioCode",
   132  					Version:   "1.103.1",
   133  					PURLType:  purl.TypeWinget,
   134  					Locations: []string{"test.db"},
   135  					Metadata: &metadata.Metadata{
   136  						Name:     "Microsoft Visual Studio Code",
   137  						ID:       "Microsoft.VisualStudioCode",
   138  						Version:  "1.103.1",
   139  						Moniker:  "vscode",
   140  						Channel:  "stable",
   141  						Tags:     []string{"developer-tools", "editor"},
   142  						Commands: []string{"code"},
   143  					},
   144  				},
   145  			},
   146  			wantErr: false,
   147  		},
   148  		{
   149  			name: "EmptyDatabase_ReturnsEmpty",
   150  			setupDB: func(dbPath string) error {
   151  				return createTestDatabase(dbPath, []TestPackage{})
   152  			},
   153  			want:    nil,
   154  			wantErr: false,
   155  		},
   156  		{
   157  			name: "InvalidDatabase_ReturnsError",
   158  			setupDB: func(dbPath string) error {
   159  				db, err := sql.Open("sqlite", dbPath)
   160  				if err != nil {
   161  					return err
   162  				}
   163  				defer db.Close()
   164  				ctx := context.Background()
   165  				_, err = db.ExecContext(ctx, "CREATE TABLE test (id INTEGER)")
   166  				return err
   167  			},
   168  			want:    nil,
   169  			wantErr: true,
   170  		},
   171  	}
   172  
   173  	for _, tt := range tests {
   174  		t.Run(tt.name, func(t *testing.T) {
   175  			tmpDir := t.TempDir()
   176  			dbPath := filepath.Join(tmpDir, "test.db")
   177  
   178  			if err := tt.setupDB(dbPath); err != nil {
   179  				t.Fatalf("Failed to setup test database: %v", err)
   180  			}
   181  
   182  			wingetExtractor := NewDefault()
   183  
   184  			// Create a custom Extract method that bypasses GetRealPath for testing
   185  			got, err := func() ([]*extractor.Package, error) {
   186  				db, err := sql.Open("sqlite", dbPath)
   187  				if err != nil {
   188  					return nil, fmt.Errorf("failed to open Winget database %s: %w", dbPath, err)
   189  				}
   190  				defer db.Close()
   191  
   192  				ctx := context.Background()
   193  				ext := wingetExtractor.(*Extractor)
   194  				if err := ext.validateDatabase(ctx, db); err != nil {
   195  					return nil, fmt.Errorf("invalid Winget database %s: %w", dbPath, err)
   196  				}
   197  
   198  				packages, err := ext.extractPackages(ctx, db)
   199  				if err != nil {
   200  					return nil, fmt.Errorf("failed to extract packages from %s: %w", dbPath, err)
   201  				}
   202  
   203  				var extPackages []*extractor.Package
   204  				for _, pkg := range packages {
   205  					if err := ctx.Err(); err != nil {
   206  						return nil, fmt.Errorf("%s halted due to context error: %w", wingetExtractor.Name(), err)
   207  					}
   208  
   209  					extPkg := &extractor.Package{
   210  						Name:      pkg.ID,
   211  						Version:   pkg.Version,
   212  						PURLType:  purl.TypeWinget,
   213  						Locations: []string{"test.db"},
   214  						Metadata: &metadata.Metadata{
   215  							Name:     pkg.Name,
   216  							ID:       pkg.ID,
   217  							Version:  pkg.Version,
   218  							Moniker:  pkg.Moniker,
   219  							Channel:  pkg.Channel,
   220  							Tags:     pkg.Tags,
   221  							Commands: pkg.Commands,
   222  						},
   223  					}
   224  					extPackages = append(extPackages, extPkg)
   225  				}
   226  
   227  				return extPackages, nil
   228  			}()
   229  			if (err != nil) != tt.wantErr {
   230  				t.Errorf("Extract() error = %v, wantErr %v", err, tt.wantErr)
   231  				return
   232  			}
   233  
   234  			if !tt.wantErr {
   235  				if diff := cmp.Diff(tt.want, got); diff != "" {
   236  					t.Errorf("Extract() packages mismatch (-want +got):\n%s", diff)
   237  				}
   238  			}
   239  		})
   240  	}
   241  }
   242  
   243  func TestExtractorInterface(t *testing.T) {
   244  	wingetExtractor := NewDefault()
   245  
   246  	if wingetExtractor.Name() != Name {
   247  		t.Errorf("Name() = %v, want %v", wingetExtractor.Name(), Name)
   248  	}
   249  
   250  	if wingetExtractor.Version() != 0 {
   251  		t.Errorf("Version() = %v, want %v", wingetExtractor.Version(), 0)
   252  	}
   253  
   254  	caps := wingetExtractor.Requirements()
   255  	if caps.OS != 2 { // OSWindows = 2
   256  		t.Errorf("Requirements().OS = %v, want 2 (OSWindows)", caps.OS)
   257  	}
   258  
   259  	if caps.RunningSystem {
   260  		t.Error("Requirements().RunningSystem should be false for filesystem extractor")
   261  	}
   262  }
   263  
   264  // TestPackage represents a test package for database creation
   265  type TestPackage struct {
   266  	ID       string
   267  	Name     string
   268  	Version  string
   269  	Moniker  string
   270  	Channel  string
   271  	Tags     []string
   272  	Commands []string
   273  }
   274  
   275  // createTestDatabase creates a SQLite database with the Winget schema and test data
   276  func createTestDatabase(dbPath string, packages []TestPackage) error {
   277  	db, err := sql.Open("sqlite", dbPath)
   278  	if err != nil {
   279  		return err
   280  	}
   281  	defer db.Close()
   282  
   283  	ctx := context.Background()
   284  
   285  	// Create schema
   286  	schema := `
   287  	CREATE TABLE [metadata](
   288  		[name] TEXT PRIMARY KEY NOT NULL,
   289  		[value] TEXT NOT NULL);
   290  	CREATE TABLE [ids](rowid INTEGER PRIMARY KEY, [id] TEXT NOT NULL);
   291  	CREATE UNIQUE INDEX [ids_pkindex] ON [ids]([id]);
   292  	CREATE TABLE [names](rowid INTEGER PRIMARY KEY, [name] TEXT NOT NULL);
   293  	CREATE UNIQUE INDEX [names_pkindex] ON [names]([name]);
   294  	CREATE TABLE [monikers](rowid INTEGER PRIMARY KEY, [moniker] TEXT NOT NULL);
   295  	CREATE UNIQUE INDEX [monikers_pkindex] ON [monikers]([moniker]);
   296  	CREATE TABLE [versions](rowid INTEGER PRIMARY KEY, [version] TEXT NOT NULL);
   297  	CREATE UNIQUE INDEX [versions_pkindex] ON [versions]([version]);
   298  	CREATE TABLE [channels](rowid INTEGER PRIMARY KEY, [channel] TEXT NOT NULL);
   299  	CREATE UNIQUE INDEX [channels_pkindex] ON [channels]([channel]);
   300  	CREATE TABLE [manifest](rowid INTEGER PRIMARY KEY, [id] INT64 NOT NULL, [name] INT64 NOT NULL, [moniker] INT64 NOT NULL, [version] INT64 NOT NULL, [channel] INT64 NOT NULL, [pathpart] INT64 NOT NULL, hash BLOB, arp_min_version INT64, arp_max_version INT64);
   301  	CREATE TABLE [tags](rowid INTEGER PRIMARY KEY, [tag] TEXT NOT NULL);
   302  	CREATE UNIQUE INDEX [tags_pkindex] ON [tags]([tag]);
   303  	CREATE TABLE [tags_map]([manifest] INT64 NOT NULL, [tag] INT64 NOT NULL, PRIMARY KEY([tag], [manifest])) WITHOUT ROWID;
   304  	CREATE TABLE [commands](rowid INTEGER PRIMARY KEY, [command] TEXT NOT NULL);
   305  	CREATE UNIQUE INDEX [commands_pkindex] ON [commands]([command]);
   306  	CREATE TABLE [commands_map]([manifest] INT64 NOT NULL, [command] INT64 NOT NULL, PRIMARY KEY([command], [manifest])) WITHOUT ROWID;
   307  	`
   308  
   309  	_, err = db.ExecContext(ctx, schema)
   310  	if err != nil {
   311  		return err
   312  	}
   313  
   314  	// Insert test data
   315  	for i, pkg := range packages {
   316  		manifestID := i + 1
   317  
   318  		// Insert lookup table values
   319  		_, err = db.ExecContext(ctx, "INSERT INTO ids (rowid, id) VALUES (?, ?)", manifestID, pkg.ID)
   320  		if err != nil {
   321  			return err
   322  		}
   323  
   324  		_, err = db.ExecContext(ctx, "INSERT INTO names (rowid, name) VALUES (?, ?)", manifestID, pkg.Name)
   325  		if err != nil {
   326  			return err
   327  		}
   328  
   329  		_, err = db.ExecContext(ctx, "INSERT INTO monikers (rowid, moniker) VALUES (?, ?)", manifestID, pkg.Moniker)
   330  		if err != nil {
   331  			return err
   332  		}
   333  
   334  		_, err = db.ExecContext(ctx, "INSERT INTO versions (rowid, version) VALUES (?, ?)", manifestID, pkg.Version)
   335  		if err != nil {
   336  			return err
   337  		}
   338  
   339  		_, err = db.ExecContext(ctx, "INSERT INTO channels (rowid, channel) VALUES (?, ?)", manifestID, pkg.Channel)
   340  		if err != nil {
   341  			return err
   342  		}
   343  
   344  		// Insert manifest
   345  		_, err = db.ExecContext(ctx, "INSERT INTO manifest (rowid, id, name, moniker, version, channel, pathpart) VALUES (?, ?, ?, ?, ?, ?, ?)",
   346  			manifestID, manifestID, manifestID, manifestID, manifestID, manifestID, -1)
   347  		if err != nil {
   348  			return err
   349  		}
   350  
   351  		// Insert tags
   352  		for j, tag := range pkg.Tags {
   353  			tagID := i*100 + j + 1
   354  			_, err = db.ExecContext(ctx, "INSERT INTO tags (rowid, tag) VALUES (?, ?)", tagID, tag)
   355  			if err != nil {
   356  				return err
   357  			}
   358  			_, err = db.ExecContext(ctx, "INSERT INTO tags_map (manifest, tag) VALUES (?, ?)", manifestID, tagID)
   359  			if err != nil {
   360  				return err
   361  			}
   362  		}
   363  
   364  		// Insert commands
   365  		for j, command := range pkg.Commands {
   366  			commandID := i*100 + j + 1
   367  			_, err = db.ExecContext(ctx, "INSERT INTO commands (rowid, command) VALUES (?, ?)", commandID, command)
   368  			if err != nil {
   369  				return err
   370  			}
   371  			_, err = db.ExecContext(ctx, "INSERT INTO commands_map (manifest, command) VALUES (?, ?)", manifestID, commandID)
   372  			if err != nil {
   373  				return err
   374  			}
   375  		}
   376  	}
   377  
   378  	return nil
   379  }