github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/language/javascript/bunlock/bunlock.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 bunlock extracts bun.lock files
    16  package bunlock
    17  
    18  import (
    19  	"context"
    20  	"encoding/json"
    21  	"errors"
    22  	"fmt"
    23  	"io"
    24  	"path/filepath"
    25  	"slices"
    26  	"strings"
    27  
    28  	"github.com/google/osv-scalibr/extractor"
    29  	"github.com/google/osv-scalibr/extractor/filesystem"
    30  	"github.com/google/osv-scalibr/extractor/filesystem/osv"
    31  	"github.com/google/osv-scalibr/inventory"
    32  	"github.com/google/osv-scalibr/plugin"
    33  	"github.com/google/osv-scalibr/purl"
    34  	"github.com/tidwall/jsonc"
    35  )
    36  
    37  const (
    38  	// Name is the unique name of this extractor.
    39  	Name = "javascript/bunlock"
    40  )
    41  
    42  type bunLockfile struct {
    43  	Version  int              `json:"lockfileVersion"`
    44  	Packages map[string][]any `json:"packages"`
    45  }
    46  
    47  // Extractor extracts npm packages from bun.lock files.
    48  type Extractor struct{}
    49  
    50  // New returns a new instance of the extractor.
    51  func New() filesystem.Extractor { return &Extractor{} }
    52  
    53  // Name of the extractor.
    54  func (e Extractor) Name() string { return Name }
    55  
    56  // Version of the extractor.
    57  func (e Extractor) Version() int { return 0 }
    58  
    59  // Requirements of the extractor.
    60  func (e Extractor) Requirements() *plugin.Capabilities {
    61  	return &plugin.Capabilities{}
    62  }
    63  
    64  // FileRequired returns true if the specified file matches bun lockfile patterns.
    65  func (e Extractor) FileRequired(api filesystem.FileAPI) bool {
    66  	path := api.Path()
    67  	if filepath.Base(path) != "bun.lock" {
    68  		return false
    69  	}
    70  	// Skip lockfiles inside node_modules directories since the packages they list aren't
    71  	// necessarily installed by the root project. We instead use the more specific top-level
    72  	// lockfile for the root project dependencies.
    73  	dir := filepath.ToSlash(filepath.Dir(path))
    74  	return !slices.Contains(strings.Split(dir, "/"), "node_modules")
    75  }
    76  
    77  // structurePackageDetails returns the name, version, and commit of a package
    78  // specified as a tuple in a bun.lock
    79  func structurePackageDetails(pkgs []any) (string, string, string, error) {
    80  	if len(pkgs) == 0 {
    81  		return "", "", "", errors.New("empty package tuple")
    82  	}
    83  
    84  	str, ok := pkgs[0].(string)
    85  
    86  	if !ok {
    87  		return "", "", "", errors.New("first element of package tuple is not a string")
    88  	}
    89  
    90  	str, isScoped := strings.CutPrefix(str, "@")
    91  	name, version, _ := strings.Cut(str, "@")
    92  
    93  	if isScoped {
    94  		name = "@" + name
    95  	}
    96  
    97  	version, commit, _ := strings.Cut(version, "#")
    98  
    99  	// bun.lock does not track both the commit and version,
   100  	// so if we have a commit then we don't have a version
   101  	if commit != "" {
   102  		version = ""
   103  	}
   104  
   105  	// file dependencies do not have a semantic version recorded
   106  	if strings.HasPrefix(version, "file:") {
   107  		version = ""
   108  	}
   109  
   110  	return name, version, commit, nil
   111  }
   112  
   113  // Extract extracts packages from bun.lock files passed through the scan input.
   114  func (e Extractor) Extract(ctx context.Context, input *filesystem.ScanInput) (inventory.Inventory, error) {
   115  	var parsedLockfile *bunLockfile
   116  
   117  	b, err := io.ReadAll(input.Reader)
   118  
   119  	if err != nil {
   120  		return inventory.Inventory{}, fmt.Errorf("could not extract: %w", err)
   121  	}
   122  
   123  	if err := json.Unmarshal(jsonc.ToJSON(b), &parsedLockfile); err != nil {
   124  		return inventory.Inventory{}, fmt.Errorf("could not extract %w", err)
   125  	}
   126  
   127  	packages := make([]*extractor.Package, 0, len(parsedLockfile.Packages))
   128  
   129  	var errs []error
   130  
   131  	for key, pkg := range parsedLockfile.Packages {
   132  		name, version, commit, err := structurePackageDetails(pkg)
   133  
   134  		if err != nil {
   135  			errs = append(errs, fmt.Errorf("could not extract '%s': %w", key, err))
   136  
   137  			continue
   138  		}
   139  
   140  		packages = append(packages, &extractor.Package{
   141  			Name:     name,
   142  			Version:  version,
   143  			PURLType: purl.TypeNPM,
   144  			SourceCode: &extractor.SourceCodeIdentifier{
   145  				Commit: commit,
   146  			},
   147  			Metadata: osv.DepGroupMetadata{
   148  				DepGroupVals: []string{},
   149  			},
   150  			Locations: []string{input.Path},
   151  		})
   152  	}
   153  
   154  	return inventory.Inventory{Packages: packages}, errors.Join(errs...)
   155  }