github.com/google/osv-scalibr@v0.4.1/guidedremediation/internal/strategy/relax/relaxer/npm.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 relaxer
    16  
    17  import (
    18  	"context"
    19  	"slices"
    20  
    21  	"deps.dev/util/resolve"
    22  	"deps.dev/util/semver"
    23  	"github.com/google/osv-scalibr/guidedremediation/upgrade"
    24  	"github.com/google/osv-scalibr/log"
    25  )
    26  
    27  // NpmRelaxer implements RequirementRelaxer for npm.
    28  type NpmRelaxer struct{}
    29  
    30  // Relax attempts to relax import requirement.
    31  // Returns the newly relaxed import and true it was successful.
    32  // If unsuccessful, it returns the original import and false.
    33  func (r NpmRelaxer) Relax(ctx context.Context, cl resolve.Client, req resolve.RequirementVersion, config upgrade.Config) (resolve.RequirementVersion, bool) {
    34  	configLevel := config.Get(req.Name)
    35  	if configLevel == upgrade.None {
    36  		return req, false
    37  	}
    38  
    39  	c, err := semver.NPM.ParseConstraint(req.Version)
    40  	if err != nil {
    41  		// The specified version is not a valid semver constraint
    42  		// Check if it's a version tag (usually 'latest') by seeing if there are matching versions
    43  		vks, err := cl.MatchingVersions(ctx, req.VersionKey)
    44  		if err != nil || len(vks) == 0 { // no matches, cannot relax
    45  			return req, false
    46  		}
    47  		// Use the first matching version (there should only be one) as a pinned version
    48  		c, err = semver.NPM.ParseConstraint(vks[0].Version)
    49  		if err != nil {
    50  			return req, false
    51  		}
    52  	}
    53  
    54  	// Get all the concrete versions of the package
    55  	allVKs, err := cl.Versions(ctx, req.PackageKey)
    56  	if err != nil {
    57  		return req, false
    58  	}
    59  	var vers []*semver.Version
    60  	for _, vk := range allVKs {
    61  		if vk.VersionType != resolve.Concrete {
    62  			continue
    63  		}
    64  		sv, err := semver.NPM.Parse(vk.Version)
    65  		if err != nil {
    66  			log.Warnf("failed to parse npm version %s: %v", vk.Version, err)
    67  			continue
    68  		}
    69  		vers = append(vers, sv)
    70  	}
    71  	slices.SortFunc(vers, func(a *semver.Version, b *semver.Version) int {
    72  		return a.Compare(b)
    73  	})
    74  
    75  	// Find the versions on either side of the upper boundary of the requirement
    76  	var lastIdx int   // highest version matching constraint
    77  	nextIdx := -1     // next version outside of range, preferring non-prerelease
    78  	nextIsPre := true // if the next version is a prerelease version
    79  	for lastIdx = len(vers) - 1; lastIdx >= 0; lastIdx-- {
    80  		v := vers[lastIdx]
    81  		if c.MatchVersion(v) { // found the upper bound, stop iterating
    82  			break
    83  		}
    84  
    85  		// Want to prefer non-prerelease versions, so only select one if we haven't seen any non-prerelease versions
    86  		if !v.IsPrerelease() || nextIsPre {
    87  			nextIdx = lastIdx
    88  			nextIsPre = v.IsPrerelease()
    89  		}
    90  	}
    91  
    92  	// Didn't find any higher versions of the package
    93  	if nextIdx == -1 {
    94  		return req, false
    95  	}
    96  
    97  	// No versions match the existing constraint, something is wrong
    98  	if lastIdx == -1 {
    99  		return req, false
   100  	}
   101  
   102  	// Our desired relaxation ordering is
   103  	// 1.2.3 -> 1.2.* -> 1.*.* -> 2.*.* -> 3.*.* -> ...
   104  	// But we want to use npm-like version specifiers e.g.
   105  	// 1.2.3 -> ~1.2.4 -> ^1.4.5 -> ^2.6.7 -> ^3.8.9 -> ...
   106  	// using the latest versions of the ranges
   107  
   108  	cmpVer := vers[lastIdx]
   109  	_, diff := cmpVer.Difference(vers[nextIdx])
   110  	if !configLevel.Allows(diff) {
   111  		return req, false
   112  	}
   113  	if diff == semver.DiffMajor {
   114  		// Want to step only one major version at a time
   115  		// Instead of looking for a difference larger than major,
   116  		// we want to look for a major version bump from the first next version
   117  		cmpVer = vers[nextIdx]
   118  		diff = semver.DiffMinor
   119  	}
   120  
   121  	// Find the highest version with the same difference
   122  	best := vers[nextIdx]
   123  	for i := nextIdx + 1; i < len(vers); i++ {
   124  		_, d := cmpVer.Difference(vers[i])
   125  		// If we've exceeded our allowed upgrade level, stop looking.
   126  		if !configLevel.Allows(d) {
   127  			break
   128  		}
   129  
   130  		// DiffMajor < DiffMinor < DiffPatch < DiffPrerelease
   131  		// So if d is less than the original diff, it represents a larger change
   132  		if d < diff {
   133  			break
   134  		}
   135  		if !vers[i].IsPrerelease() || nextIsPre {
   136  			best = vers[i]
   137  		}
   138  	}
   139  
   140  	if diff == semver.DiffPatch {
   141  		req.Version = "~" + best.String()
   142  	} else {
   143  		req.Version = "^" + best.String()
   144  	}
   145  
   146  	return req, true
   147  }