github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/os/winget/winget.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 extracts installed packages from Windows Package Manager (Winget) database.
    16  package winget
    17  
    18  import (
    19  	"context"
    20  	"database/sql"
    21  	"errors"
    22  	"fmt"
    23  	"os"
    24  	"path/filepath"
    25  	"strings"
    26  
    27  	"github.com/google/osv-scalibr/extractor"
    28  	"github.com/google/osv-scalibr/extractor/filesystem"
    29  	"github.com/google/osv-scalibr/extractor/filesystem/os/winget/metadata"
    30  	"github.com/google/osv-scalibr/inventory"
    31  	"github.com/google/osv-scalibr/plugin"
    32  	"github.com/google/osv-scalibr/purl"
    33  	_ "modernc.org/sqlite" // Import sqlite driver
    34  )
    35  
    36  const (
    37  	// Name is the unique identifier for the Winget extractor.
    38  	Name = "os/winget"
    39  )
    40  
    41  // Extractor extracts installed packages from Windows Package Manager databases.
    42  type Extractor struct{}
    43  
    44  // New creates a new Winget extractor instance.
    45  func New() filesystem.Extractor {
    46  	return &Extractor{}
    47  }
    48  
    49  // NewDefault creates a new Winget extractor with default configuration.
    50  func NewDefault() filesystem.Extractor {
    51  	return New()
    52  }
    53  
    54  // Name returns the unique identifier for this extractor.
    55  func (e Extractor) Name() string { return Name }
    56  
    57  // Version returns the version of this extractor.
    58  func (e Extractor) Version() int { return 0 }
    59  
    60  // Requirements returns the system requirements for this extractor.
    61  func (e Extractor) Requirements() *plugin.Capabilities {
    62  	return &plugin.Capabilities{OS: plugin.OSWindows}
    63  }
    64  
    65  // FileRequired determines if the given file should be processed by this extractor.
    66  func (e Extractor) FileRequired(api filesystem.FileAPI) bool {
    67  	path := api.Path()
    68  	normalized := filepath.ToSlash(path)
    69  
    70  	// Check if this is a Winget database file
    71  	if strings.HasSuffix(normalized, "/installed.db") &&
    72  		(strings.Contains(normalized, "/Microsoft.DesktopAppInstaller_8wekyb3d8bbwe/") ||
    73  			strings.Contains(normalized, "/StoreEdgeFD/")) {
    74  		return true
    75  	}
    76  
    77  	// Check for system-wide repository database
    78  	if strings.HasSuffix(normalized, "/StateRepository-Machine.srd") &&
    79  		strings.Contains(normalized, "/AppRepository/") {
    80  		return true
    81  	}
    82  
    83  	return false
    84  }
    85  
    86  // Package represents a package extracted from the Winget database.
    87  type Package struct {
    88  	ID       string
    89  	Name     string
    90  	Version  string
    91  	Moniker  string
    92  	Channel  string
    93  	Tags     []string
    94  	Commands []string
    95  }
    96  
    97  // Extract extracts packages from a Winget database file.
    98  func (e *Extractor) Extract(ctx context.Context, input *filesystem.ScanInput) (inventory.Inventory, error) {
    99  	absPath, err := input.GetRealPath()
   100  	if err != nil {
   101  		return inventory.Inventory{}, fmt.Errorf("GetRealPath(%v): %w", input, err)
   102  	}
   103  
   104  	if input.Root == "" {
   105  		// The file got copied to a temporary dir, remove it at the end.
   106  		defer func() {
   107  			dir := filepath.Dir(absPath)
   108  			if err := os.RemoveAll(dir); err != nil {
   109  				fmt.Printf("Warning: failed to clean up temporary directory %s: %v\n", dir, err)
   110  			}
   111  		}()
   112  	}
   113  
   114  	db, err := sql.Open("sqlite", absPath)
   115  	if err != nil {
   116  		return inventory.Inventory{}, fmt.Errorf("failed to open Winget database %s: %w", absPath, err)
   117  	}
   118  	defer db.Close()
   119  
   120  	if err := e.validateDatabase(ctx, db); err != nil {
   121  		return inventory.Inventory{}, fmt.Errorf("invalid Winget database %s: %w", absPath, err)
   122  	}
   123  
   124  	packages, err := e.extractPackages(ctx, db)
   125  	if err != nil {
   126  		return inventory.Inventory{}, fmt.Errorf("failed to extract packages from %s: %w", absPath, err)
   127  	}
   128  
   129  	var extPackages []*extractor.Package
   130  	for _, pkg := range packages {
   131  		// Return if canceled or exceeding deadline
   132  		if err := ctx.Err(); err != nil {
   133  			return inventory.Inventory{}, fmt.Errorf("%s halted due to context error: %w", e.Name(), err)
   134  		}
   135  
   136  		extPkg := &extractor.Package{
   137  			Name:      pkg.ID,
   138  			Version:   pkg.Version,
   139  			PURLType:  purl.TypeWinget,
   140  			Locations: []string{input.Path},
   141  			Metadata: &metadata.Metadata{
   142  				Name:     pkg.Name,
   143  				ID:       pkg.ID,
   144  				Version:  pkg.Version,
   145  				Moniker:  pkg.Moniker,
   146  				Channel:  pkg.Channel,
   147  				Tags:     pkg.Tags,
   148  				Commands: pkg.Commands,
   149  			},
   150  		}
   151  		extPackages = append(extPackages, extPkg)
   152  	}
   153  
   154  	return inventory.Inventory{Packages: extPackages}, nil
   155  }
   156  
   157  func (e *Extractor) validateDatabase(ctx context.Context, db *sql.DB) error {
   158  	var tableName string
   159  	err := db.QueryRowContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name='manifest'").Scan(&tableName)
   160  	if err != nil {
   161  		if err == sql.ErrNoRows {
   162  			return errors.New("database does not contain manifest table")
   163  		}
   164  		return fmt.Errorf("failed to query database schema: %w", err)
   165  	}
   166  	return nil
   167  }
   168  
   169  func (e *Extractor) extractPackages(ctx context.Context, db *sql.DB) ([]*Package, error) {
   170  	query := `
   171  	SELECT 
   172  		i.id as package_id,
   173  		n.name as package_name,
   174  		v.version as package_version,
   175  		m.moniker as package_moniker,
   176  		c.channel as channel,
   177  		GROUP_CONCAT(DISTINCT t.tag) as tags,
   178  		GROUP_CONCAT(DISTINCT cmd.command) as commands
   179  	FROM manifest man
   180  	JOIN ids i ON man.id = i.rowid
   181  	JOIN names n ON man.name = n.rowid  
   182  	JOIN versions v ON man.version = v.rowid
   183  	JOIN monikers m ON man.moniker = m.rowid
   184  	JOIN channels c ON man.channel = c.rowid
   185  	LEFT JOIN tags_map tm ON man.rowid = tm.manifest
   186  	LEFT JOIN tags t ON tm.tag = t.rowid
   187  	LEFT JOIN commands_map cm ON man.rowid = cm.manifest
   188  	LEFT JOIN commands cmd ON cm.command = cmd.rowid
   189  	GROUP BY man.rowid
   190  	`
   191  
   192  	rows, err := db.QueryContext(ctx, query)
   193  	if err != nil {
   194  		return nil, fmt.Errorf("failed to query packages: %w", err)
   195  	}
   196  	defer rows.Close()
   197  
   198  	var packages []*Package
   199  	for rows.Next() {
   200  		// Return if canceled or exceeding deadline
   201  		if err := ctx.Err(); err != nil {
   202  			return packages, fmt.Errorf("winget extractor halted due to context error: %w", err)
   203  		}
   204  
   205  		var pkg Package
   206  		var tagsStr, commandsStr sql.NullString
   207  
   208  		err := rows.Scan(
   209  			&pkg.ID,
   210  			&pkg.Name,
   211  			&pkg.Version,
   212  			&pkg.Moniker,
   213  			&pkg.Channel,
   214  			&tagsStr,
   215  			&commandsStr,
   216  		)
   217  		if err != nil {
   218  			return nil, fmt.Errorf("failed to scan package row: %w", err)
   219  		}
   220  
   221  		if tagsStr.Valid && tagsStr.String != "" {
   222  			pkg.Tags = strings.Split(tagsStr.String, ",")
   223  		}
   224  		if commandsStr.Valid && commandsStr.String != "" {
   225  			pkg.Commands = strings.Split(commandsStr.String, ",")
   226  		}
   227  
   228  		packages = append(packages, &pkg)
   229  	}
   230  
   231  	if err := rows.Err(); err != nil {
   232  		return nil, fmt.Errorf("error iterating package rows: %w", err)
   233  	}
   234  
   235  	return packages, nil
   236  }