github.com/google/osv-scalibr@v0.4.1/guidedremediation/internal/manifest/python/requirements.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
    16  
    17  import (
    18  	"context"
    19  	"errors"
    20  	"fmt"
    21  	"io"
    22  	"path/filepath"
    23  	"strings"
    24  
    25  	"deps.dev/util/pypi"
    26  	"deps.dev/util/resolve"
    27  	"github.com/google/osv-scalibr/extractor/filesystem"
    28  	"github.com/google/osv-scalibr/extractor/filesystem/language/python/requirements"
    29  	scalibrfs "github.com/google/osv-scalibr/fs"
    30  	"github.com/google/osv-scalibr/guidedremediation/internal/manifest"
    31  	"github.com/google/osv-scalibr/guidedremediation/result"
    32  	"github.com/google/osv-scalibr/guidedremediation/strategy"
    33  	"github.com/google/osv-scalibr/log"
    34  )
    35  
    36  type requirementsReadWriter struct{}
    37  
    38  // GetRequirementsReadWriter returns a ReadWriter for requirements.txt manifest files.
    39  func GetRequirementsReadWriter() (manifest.ReadWriter, error) {
    40  	return requirementsReadWriter{}, nil
    41  }
    42  
    43  // System returns the ecosystem of this ReadWriter.
    44  func (r requirementsReadWriter) System() resolve.System {
    45  	return resolve.PyPI
    46  }
    47  
    48  // SupportedStrategies returns the remediation strategies supported for this manifest.
    49  func (r requirementsReadWriter) SupportedStrategies() []strategy.Strategy {
    50  	return []strategy.Strategy{strategy.StrategyRelax}
    51  }
    52  
    53  // Read parses the manifest from the given file.
    54  func (r requirementsReadWriter) Read(path string, fsys scalibrfs.FS) (manifest.Manifest, error) {
    55  	path = filepath.ToSlash(path)
    56  	f, err := fsys.Open(path)
    57  	if err != nil {
    58  		return nil, err
    59  	}
    60  	defer f.Close()
    61  
    62  	inv, err := requirements.NewDefault().Extract(context.Background(), &filesystem.ScanInput{
    63  		FS:     fsys,
    64  		Path:   path,
    65  		Root:   filepath.Dir(path),
    66  		Reader: f,
    67  	})
    68  	if err != nil {
    69  		return nil, err
    70  	}
    71  
    72  	var reqs []resolve.RequirementVersion
    73  	for _, pkg := range inv.Packages {
    74  		m := pkg.Metadata.(*requirements.Metadata)
    75  		if len(m.HashCheckingModeValues) > 0 {
    76  			return nil, errors.New("requirements file in hash checking mode not supported as manifest")
    77  		}
    78  		d, err := pypi.ParseDependency(m.Requirement)
    79  		if err != nil {
    80  			return nil, err
    81  		}
    82  		reqs = append(reqs, resolve.RequirementVersion{
    83  			VersionKey: resolve.VersionKey{
    84  				PackageKey: resolve.PackageKey{
    85  					System: resolve.PyPI,
    86  					Name:   pkg.Name,
    87  				},
    88  				Version:     d.Constraint,
    89  				VersionType: resolve.Requirement,
    90  			},
    91  		})
    92  	}
    93  
    94  	return &pythonManifest{
    95  		filePath: path,
    96  		root: resolve.Version{
    97  			VersionKey: resolve.VersionKey{
    98  				PackageKey: resolve.PackageKey{
    99  					System: resolve.PyPI,
   100  					Name:   "rootproject",
   101  				},
   102  				VersionType: resolve.Concrete,
   103  				Version:     "1.0.0",
   104  			},
   105  		},
   106  		requirements: reqs,
   107  		groups:       make(map[manifest.RequirementKey][]string),
   108  	}, nil
   109  }
   110  
   111  // Write writes the manifest after applying the patches to outputPath.
   112  func (r requirementsReadWriter) Write(original manifest.Manifest, fsys scalibrfs.FS, patches []result.Patch, outputPath string) error {
   113  	return write(fsys, original.FilePath(), outputPath, patches, updateRequirements)
   114  }
   115  
   116  // updateRequirements takes an io.Reader representing the requirements.txt file
   117  // and a map of package names to their new version constraints, returns the
   118  // file with the updated requirements as a string.
   119  func updateRequirements(reader io.Reader, requirements []TokenizedRequirements) (string, error) {
   120  	data, err := io.ReadAll(reader)
   121  	if err != nil {
   122  		return "", fmt.Errorf("error reading requirements: %w", err)
   123  	}
   124  
   125  	var sb strings.Builder
   126  	for _, line := range strings.SplitAfter(string(data), "\n") {
   127  		if strings.TrimSpace(line) == "" {
   128  			sb.WriteString(line)
   129  			continue
   130  		}
   131  
   132  		reqLine := line
   133  		// We should trim the comments so they are not part of the requirement.
   134  		if i := strings.Index(reqLine, "#"); i != -1 {
   135  			reqLine = reqLine[:i]
   136  		}
   137  		if strings.TrimSpace(reqLine) == "" {
   138  			sb.WriteString(line)
   139  			continue
   140  		}
   141  
   142  		d, err := pypi.ParseDependency(reqLine)
   143  		if err != nil {
   144  			log.Warnf("failed to parse Python dependency %s: %v", line, err)
   145  			sb.WriteString(line)
   146  			continue
   147  		}
   148  
   149  		newReq, ok := findTokenizedRequirement(requirements, d.Name, tokenizeRequirement(d.Constraint))
   150  		if !ok {
   151  			// We don't need to update the requirement of this dependency.
   152  			sb.WriteString(line)
   153  			continue
   154  		}
   155  		sb.WriteString(replaceRequirement(line, newReq))
   156  	}
   157  
   158  	return sb.String(), nil
   159  }