github.com/google/osv-scalibr@v0.4.1/clients/datasource/npm_registry.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 datasource
    16  
    17  import (
    18  	"context"
    19  	"errors"
    20  	"fmt"
    21  	"io"
    22  	"maps"
    23  	"net/http"
    24  	"slices"
    25  	"sync"
    26  	"time"
    27  
    28  	"github.com/tidwall/gjson"
    29  )
    30  
    31  // NPMRegistryAPIClient defines a client to fetch metadata from a NPM registry.
    32  type NPMRegistryAPIClient struct {
    33  	// Registries from the npmrc config
    34  	// This should only be written to when the client is first being created.
    35  	// Other functions should not modify it & it is not covered by the mutex.
    36  	registries NPMRegistryConfig
    37  
    38  	// cache fields
    39  	mu             sync.Mutex
    40  	cacheTimestamp *time.Time // If set, this means we loaded from a cache
    41  	details        *RequestCache[string, npmRegistryPackageDetails]
    42  }
    43  
    44  // NewNPMRegistryAPIClient returns a new NPMRegistryAPIClient.
    45  // projectDir is the directory (on disk) to read the project-level .npmrc config file from (for registries).
    46  func NewNPMRegistryAPIClient(projectDir string) (*NPMRegistryAPIClient, error) {
    47  	registryConfig, err := LoadNPMRegistryConfig(projectDir)
    48  	if err != nil {
    49  		return nil, err
    50  	}
    51  	return &NPMRegistryAPIClient{
    52  		registries: registryConfig,
    53  		details:    NewRequestCache[string, npmRegistryPackageDetails](),
    54  	}, nil
    55  }
    56  
    57  // Versions returns all the known versions and tags of a given npm package
    58  func (c *NPMRegistryAPIClient) Versions(ctx context.Context, pkg string) (NPMRegistryVersions, error) {
    59  	pkgDetails, err := c.getPackageDetails(ctx, pkg)
    60  	if err != nil {
    61  		return NPMRegistryVersions{}, err
    62  	}
    63  
    64  	return NPMRegistryVersions{
    65  		Versions: slices.AppendSeq(make([]string, 0, len(pkgDetails.Versions)), maps.Keys(pkgDetails.Versions)),
    66  		Tags:     pkgDetails.Tags,
    67  	}, nil
    68  }
    69  
    70  // Dependencies returns all the defined dependencies of the given version of an npm package
    71  func (c *NPMRegistryAPIClient) Dependencies(ctx context.Context, pkg, version string) (NPMRegistryDependencies, error) {
    72  	pkgDetails, err := c.getPackageDetails(ctx, pkg)
    73  	if err != nil {
    74  		return NPMRegistryDependencies{}, err
    75  	}
    76  
    77  	if deps, ok := pkgDetails.Versions[version]; ok {
    78  		return deps, nil
    79  	}
    80  
    81  	return NPMRegistryDependencies{}, fmt.Errorf("no version %s for package %s", version, pkg)
    82  }
    83  
    84  // FullJSON returns the entire npm registry JSON data for a given package version
    85  func (c *NPMRegistryAPIClient) FullJSON(ctx context.Context, pkg, version string) (gjson.Result, error) {
    86  	return c.get(ctx, pkg, version)
    87  }
    88  
    89  func (c *NPMRegistryAPIClient) get(ctx context.Context, urlComponents ...string) (gjson.Result, error) {
    90  	resp, err := c.registries.MakeRequest(ctx, http.DefaultClient, urlComponents...)
    91  	if err != nil {
    92  		return gjson.Result{}, err
    93  	}
    94  
    95  	defer resp.Body.Close()
    96  	if resp.StatusCode != http.StatusOK {
    97  		return gjson.Result{}, errors.New(resp.Status)
    98  	}
    99  
   100  	body, err := io.ReadAll(resp.Body)
   101  	if err != nil {
   102  		return gjson.Result{}, err
   103  	}
   104  
   105  	res := gjson.ParseBytes(body)
   106  
   107  	return res, nil
   108  }
   109  
   110  func (c *NPMRegistryAPIClient) getPackageDetails(ctx context.Context, pkg string) (npmRegistryPackageDetails, error) {
   111  	return c.details.Get(pkg, func() (npmRegistryPackageDetails, error) {
   112  		jsonData, err := c.get(ctx, pkg)
   113  		if err != nil {
   114  			return npmRegistryPackageDetails{}, err
   115  		}
   116  
   117  		versions := make(map[string]NPMRegistryDependencies)
   118  		for v, data := range jsonData.Get("versions").Map() {
   119  			versions[v] = NPMRegistryDependencies{
   120  				Dependencies:         jsonToStringMap(data.Get("dependencies")),
   121  				DevDependencies:      jsonToStringMap(data.Get("devDependencies")),
   122  				PeerDependencies:     jsonToStringMap(data.Get("peerDependencies")),
   123  				OptionalDependencies: jsonToStringMap(data.Get("optionalDependencies")),
   124  				BundleDependencies:   jsonToStringSlice(data.Get("bundleDependencies")),
   125  			}
   126  		}
   127  
   128  		return npmRegistryPackageDetails{
   129  			Versions: versions,
   130  			Tags:     jsonToStringMap(jsonData.Get("dist-tags")),
   131  		}, nil
   132  	})
   133  }
   134  
   135  func jsonToStringSlice(v gjson.Result) []string {
   136  	arr := v.Array()
   137  	if len(arr) == 0 {
   138  		return nil
   139  	}
   140  	strs := make([]string, len(arr))
   141  	for i, s := range arr {
   142  		strs[i] = s.String()
   143  	}
   144  
   145  	return strs
   146  }
   147  
   148  func jsonToStringMap(v gjson.Result) map[string]string {
   149  	mp := v.Map()
   150  	if len(mp) == 0 {
   151  		return nil
   152  	}
   153  	strs := make(map[string]string)
   154  	for k, s := range mp {
   155  		strs[k] = s.String()
   156  	}
   157  
   158  	return strs
   159  }
   160  
   161  type npmRegistryPackageDetails struct {
   162  	// Only cache the info needed for the DependencyClient
   163  	Versions map[string]NPMRegistryDependencies
   164  	Tags     map[string]string
   165  }
   166  
   167  // NPMRegistryVersions holds the versions and tags of a package, from the npm API.
   168  type NPMRegistryVersions struct {
   169  	Versions []string
   170  	Tags     map[string]string
   171  }
   172  
   173  // NPMRegistryDependencies holds the dependencies of a package version, from the npm API.
   174  type NPMRegistryDependencies struct {
   175  	Dependencies         map[string]string
   176  	DevDependencies      map[string]string
   177  	PeerDependencies     map[string]string
   178  	OptionalDependencies map[string]string
   179  	BundleDependencies   []string
   180  }