github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/os/nix/nix.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 nix extracts packages from the Nix store directory.
    16  package nix
    17  
    18  import (
    19  	"context"
    20  	"fmt"
    21  	"regexp"
    22  	"strings"
    23  
    24  	"github.com/google/osv-scalibr/extractor"
    25  	"github.com/google/osv-scalibr/extractor/filesystem"
    26  	nixmeta "github.com/google/osv-scalibr/extractor/filesystem/os/nix/metadata"
    27  	"github.com/google/osv-scalibr/extractor/filesystem/os/osrelease"
    28  	"github.com/google/osv-scalibr/inventory"
    29  	"github.com/google/osv-scalibr/log"
    30  	"github.com/google/osv-scalibr/plugin"
    31  	"github.com/google/osv-scalibr/purl"
    32  )
    33  
    34  const (
    35  	// Name is the unique name of this extractor.
    36  	Name = "os/nix"
    37  )
    38  
    39  // Extractor extracts packages from the nix store directory.
    40  type Extractor struct {
    41  	// visitedDir tracks already visited directories.
    42  	visitedDir map[string]bool
    43  }
    44  
    45  // New returns a new instance of the extractor.
    46  func New() filesystem.Extractor {
    47  	return &Extractor{
    48  		visitedDir: make(map[string]bool),
    49  	}
    50  }
    51  
    52  // Name of the extractor.
    53  func (e Extractor) Name() string { return Name }
    54  
    55  // Version of the extractor.
    56  func (e Extractor) Version() int { return 0 }
    57  
    58  // Requirements of the extractor.
    59  func (e Extractor) Requirements() *plugin.Capabilities { return &plugin.Capabilities{} }
    60  
    61  // FileRequired returns true if a given path corresponds to a unique, unprocessed
    62  // directory under the nixStoreDir path.
    63  func (e *Extractor) FileRequired(api filesystem.FileAPI) bool {
    64  	path := api.Path()
    65  
    66  	if !strings.HasPrefix(path, "nix/store/") {
    67  		return false
    68  	}
    69  
    70  	pathParts := strings.Split(path, "/")
    71  	if len(pathParts) <= 3 {
    72  		return false
    73  	}
    74  
    75  	// e.g.
    76  	// path: nix/store/1ddf3x30m0z6kknmrmapsc7liz8npi1w-perl-5.38.2/bin/ptar
    77  	// uniquePath: 1ddf3x30m0z6kknmrmapsc7liz8npi1w-perl-5.38.2
    78  	uniquePath := pathParts[2]
    79  
    80  	// Optimization note: In case this plugin becomes too heavy in terms of CPU
    81  	// or memory, consider optimizing by storing only the last processed
    82  	// package name as a string, assuming files are walked using DFS.
    83  
    84  	// Check if the uniquePath has already been processed
    85  	if _, exists := e.visitedDir[uniquePath]; exists {
    86  		return false
    87  	}
    88  
    89  	e.visitedDir[uniquePath] = true
    90  
    91  	return true
    92  }
    93  
    94  var packageStoreRegex = regexp.MustCompile(`^([a-zA-Z0-9]{32})-([a-zA-Z0-9.-]+)-([0-9.]+)(?:-(\S+))?$`)
    95  var packageStoreUnstableRegex = regexp.MustCompile(`^([a-zA-Z0-9]{32})-([a-zA-Z0-9.-]+)-(unstable-[0-9]{4}-[0-9]{2}-[0-9]{2})$`)
    96  
    97  // Extract extracts packages from the filenames of the directories in the nix
    98  // store path.
    99  func (e Extractor) Extract(ctx context.Context, input *filesystem.ScanInput) (inventory.Inventory, error) {
   100  	// Check for cancellation or timeout.
   101  	if err := ctx.Err(); err != nil {
   102  		return inventory.Inventory{}, fmt.Errorf("%s halted due to context error: %w", e.Name(), err)
   103  	}
   104  
   105  	m, err := osrelease.GetOSRelease(input.FS)
   106  	if err != nil {
   107  		log.Errorf("osrelease.GetOSRelease(): %v", err)
   108  	}
   109  
   110  	pkgs := strings.Split(input.Path, "/")[2]
   111  
   112  	var matches []string
   113  	if strings.Contains(pkgs, "unstable") {
   114  		matches = packageStoreUnstableRegex.FindStringSubmatch(pkgs)
   115  	} else {
   116  		matches = packageStoreRegex.FindStringSubmatch(pkgs)
   117  	}
   118  
   119  	if len(matches) == 0 {
   120  		return inventory.Inventory{}, nil
   121  	}
   122  
   123  	pkgHash := matches[1]
   124  	pkgName := matches[2]
   125  	pkgVersion := matches[3]
   126  	if pkgHash == "" || pkgName == "" || pkgVersion == "" {
   127  		log.Warnf("NIX package name/version/hash is empty (name: %v, version: %v, hash: %v)", pkgName, pkgVersion, pkgHash)
   128  		return inventory.Inventory{}, nil
   129  	}
   130  
   131  	p := &extractor.Package{
   132  		Name:     pkgName,
   133  		Version:  pkgVersion,
   134  		PURLType: purl.TypeNix,
   135  		Metadata: &nixmeta.Metadata{
   136  			PackageName:       pkgName,
   137  			PackageVersion:    pkgVersion,
   138  			PackageHash:       pkgHash,
   139  			OSID:              m["ID"],
   140  			OSVersionCodename: m["VERSION_CODENAME"],
   141  			OSVersionID:       m["VERSION_ID"],
   142  		},
   143  		Locations: []string{input.Path},
   144  	}
   145  
   146  	if len(matches) > 4 {
   147  		pkgOutput := matches[4]
   148  		p.Metadata.(*nixmeta.Metadata).PackageOutput = pkgOutput
   149  	}
   150  
   151  	return inventory.Inventory{Packages: []*extractor.Package{p}}, nil
   152  }