github.com/google/osv-scalibr@v0.4.1/guidedremediation/internal/manifest/python/python.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 python provides the manifest parsing and writing for Python requirements.txt.
    16  package python
    17  
    18  import (
    19  	"fmt"
    20  	"io"
    21  	"os"
    22  	"path/filepath"
    23  	"slices"
    24  	"strings"
    25  	"unicode"
    26  
    27  	"deps.dev/util/resolve"
    28  	scalibrfs "github.com/google/osv-scalibr/fs"
    29  	"github.com/google/osv-scalibr/guidedremediation/internal/manifest"
    30  	"github.com/google/osv-scalibr/guidedremediation/result"
    31  )
    32  
    33  type pythonManifest struct {
    34  	filePath     string
    35  	root         resolve.Version
    36  	requirements []resolve.RequirementVersion
    37  	groups       map[manifest.RequirementKey][]string
    38  }
    39  
    40  // FilePath returns the path to the manifest file.
    41  func (m *pythonManifest) FilePath() string {
    42  	return m.filePath
    43  }
    44  
    45  // Root returns the Version representing this package.
    46  func (m *pythonManifest) Root() resolve.Version {
    47  	return m.root
    48  }
    49  
    50  // System returns the ecosystem of this manifest.
    51  func (m *pythonManifest) System() resolve.System {
    52  	return resolve.PyPI
    53  }
    54  
    55  // Requirements returns all direct requirements (including dev).
    56  func (m *pythonManifest) Requirements() []resolve.RequirementVersion {
    57  	return m.requirements
    58  }
    59  
    60  // Groups returns the dependency groups that the direct requirements belong to.
    61  func (m *pythonManifest) Groups() map[manifest.RequirementKey][]string {
    62  	return m.groups
    63  }
    64  
    65  // LocalManifests returns Manifests of any local packages.
    66  func (m *pythonManifest) LocalManifests() []manifest.Manifest {
    67  	return nil
    68  }
    69  
    70  // EcosystemSpecific returns any ecosystem-specific information for this manifest.
    71  func (m *pythonManifest) EcosystemSpecific() any {
    72  	return nil
    73  }
    74  
    75  // PatchRequirement modifies the manifest's requirements to include the new requirement version.
    76  func (m *pythonManifest) PatchRequirement(req resolve.RequirementVersion) error {
    77  	for i, oldReq := range m.requirements {
    78  		if oldReq.PackageKey == req.PackageKey {
    79  			m.requirements[i] = req
    80  			return nil
    81  		}
    82  	}
    83  	return fmt.Errorf("package %s not found in manifest", req.Name)
    84  }
    85  
    86  // Clone returns a copy of this manifest that is safe to modify.
    87  func (m *pythonManifest) Clone() manifest.Manifest {
    88  	clone := &pythonManifest{
    89  		filePath:     m.filePath,
    90  		root:         m.root,
    91  		requirements: slices.Clone(m.requirements),
    92  	}
    93  	clone.root.AttrSet = m.root.Clone()
    94  
    95  	return clone
    96  }
    97  
    98  // Define the possible operators, ordered by length descending
    99  // to ensure correct matching (e.g., "==" before "=" or ">=" before ">").
   100  var operators = []string{
   101  	"==", // Equal
   102  	"!=", // Not equal
   103  	">=", // Greater than or equal
   104  	"<=", // Less than or equal
   105  	"~=", // Compatible release
   106  	">",  // Greater than
   107  	"<",  // Less than
   108  }
   109  
   110  // VersionConstraint represents a single parsed requirement constraint,
   111  // consisting of an operator (e.g., "==", ">=") and a version number.
   112  type VersionConstraint struct {
   113  	operator string
   114  	version  string
   115  }
   116  
   117  // tokenizeVersionSpecifier takes a single Python version specifier string (e.g., ">=2.32.4,<3.0.0")
   118  // and breaks it down into individual version constraints. Each constraint is
   119  // represented by a VersionConstraint struct.
   120  func tokenizeRequirement(requirement string) []VersionConstraint {
   121  	if requirement == "" {
   122  		return []VersionConstraint{}
   123  	}
   124  
   125  	var tokenized []VersionConstraint
   126  	for constraint := range strings.SplitSeq(requirement, ",") {
   127  		constraint = strings.TrimSpace(constraint)
   128  		if constraint == "" {
   129  			continue
   130  		}
   131  
   132  		for _, op := range operators {
   133  			if strings.HasPrefix(constraint, op) {
   134  				tokenized = append(tokenized, VersionConstraint{
   135  					operator: op,
   136  					version:  strings.TrimSpace(constraint[len(op):]),
   137  				})
   138  				break
   139  			}
   140  		}
   141  	}
   142  
   143  	return tokenized
   144  }
   145  
   146  // formatConstraints converts a slice of VersionConstraint structs into a
   147  // comma-separated string suitable for a requirements.txt file.
   148  //
   149  // If space is true:
   150  // - A space will be inserted between the operator and the version;
   151  // - A space will be inserted after the comma between constraints.
   152  func formatConstraints(constraints []VersionConstraint, space bool) string {
   153  	if len(constraints) == 0 {
   154  		return ""
   155  	}
   156  
   157  	var parts []string
   158  	for _, vc := range constraints {
   159  		if space {
   160  			parts = append(parts, vc.operator+" "+vc.version)
   161  		} else {
   162  			parts = append(parts, vc.operator+vc.version)
   163  		}
   164  	}
   165  
   166  	if space {
   167  		return strings.Join(parts, ", ") // Add space after comma
   168  	}
   169  	return strings.Join(parts, ",")
   170  }
   171  
   172  // findFirstOperatorIndex finds the index of the first appearance of any operator
   173  // from the given list within a string. It returns -1 if no operator is found.
   174  func findFirstOperatorIndex(s string) int {
   175  	firstIndex := -1
   176  
   177  	for _, op := range operators {
   178  		index := strings.Index(s, op)
   179  		if index != -1 {
   180  			// If an operator is found, check if its index is smaller than
   181  			// the current smallest index found so far.
   182  			if firstIndex == -1 || index < firstIndex {
   183  				firstIndex = index
   184  			}
   185  		}
   186  	}
   187  	return firstIndex
   188  }
   189  
   190  // replaceRequirement takes a full requirement string and replaces its version
   191  // specifier with a new one. It preserves the package name, surrounding whitespace,
   192  // and any post-requirement markers (e.g. comments or environment markers).
   193  func replaceRequirement(req string, newReq []VersionConstraint) string {
   194  	var sb strings.Builder
   195  	opIndex := findFirstOperatorIndex(req)
   196  	if opIndex < 0 {
   197  		// No operator is found.
   198  		return req
   199  	}
   200  	sb.WriteString(req[:opIndex])
   201  
   202  	// If the byte before the operator is a space, assume space is needed when constructing requirements.
   203  	extraSpace := req[opIndex-1] == ' '
   204  	sb.WriteString(formatConstraints(newReq, extraSpace))
   205  
   206  	index := strings.Index(req, ";")
   207  	if index < 0 {
   208  		index = strings.Index(req, "#")
   209  	}
   210  	if index >= 0 {
   211  		for i := index - 1; i >= 0; i-- {
   212  			// Copy the space between requirements and post-requirements.
   213  			if req[i] != ' ' {
   214  				break
   215  			}
   216  			sb.WriteByte(' ')
   217  		}
   218  		sb.WriteString(req[index:])
   219  	} else {
   220  		// Copy space characters if nothing meaningful is found.
   221  		spaceIndex := -1
   222  		for i := len(req) - 1; i >= 0; i-- {
   223  			if !unicode.IsSpace(rune(req[i])) {
   224  				spaceIndex = i
   225  				break
   226  			}
   227  		}
   228  		sb.WriteString(req[spaceIndex+1:])
   229  	}
   230  	return sb.String()
   231  }
   232  
   233  // TokenizedRequirements represents a change from one version constraint to another,
   234  // with each constraint broken down into a slice of VersionConstraint structs.
   235  type TokenizedRequirements struct {
   236  	Name        string
   237  	VersionFrom []VersionConstraint
   238  	VersionTo   []VersionConstraint
   239  }
   240  
   241  // findTokenizedRequirement searches for a requirement in a slice of TokenizedRequirements
   242  // that matches the given package name and original version constraints.
   243  func findTokenizedRequirement(requirements []TokenizedRequirements, name string, from []VersionConstraint) ([]VersionConstraint, bool) {
   244  	for _, req := range requirements {
   245  		if name == req.Name && slices.Equal(req.VersionFrom, from) {
   246  			return req.VersionTo, true
   247  		}
   248  	}
   249  	return nil, false
   250  }
   251  
   252  // write is a generic helper function that orchestrates the patching of a manifest file.
   253  // It reads the original manifest from inputPath, processes the required changes from patches,
   254  // and delegates the content modification to the provided update function. The resulting
   255  // patched content is then written to outputPath.
   256  func write(fsys scalibrfs.FS, inputPath, outputPath string, patches []result.Patch, update func(reader io.Reader, requirements []TokenizedRequirements) (string, error)) error {
   257  	f, err := fsys.Open(inputPath)
   258  	if err != nil {
   259  		return err
   260  	}
   261  	defer f.Close()
   262  
   263  	requirements := []TokenizedRequirements{}
   264  	for _, patch := range patches {
   265  		for _, req := range patch.PackageUpdates {
   266  			requirements = append(requirements, TokenizedRequirements{
   267  				Name:        req.Name,
   268  				VersionFrom: tokenizeRequirement(req.VersionFrom),
   269  				VersionTo:   tokenizeRequirement(req.VersionTo),
   270  			})
   271  		}
   272  	}
   273  
   274  	output, err := update(f, requirements)
   275  	if err != nil {
   276  		return err
   277  	}
   278  
   279  	// Write the patched manifest to the output path.
   280  	if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
   281  		return err
   282  	}
   283  	if err := os.WriteFile(outputPath, []byte(output), 0644); err != nil {
   284  		return err
   285  	}
   286  
   287  	return nil
   288  }