github.com/google/osv-scalibr@v0.4.1/guidedremediation/internal/manifest/python/poetry.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  // Copyright 2025 Google LLC
    16  //
    17  // Licensed under the Apache License, Version 2.0 (the "License");
    18  // you may not use this file except in compliance with the License.
    19  // You may obtain a copy of the License at
    20  //
    21  //	http://www.apache.org/licenses/LICENSE-2.0
    22  //
    23  // Unless required by applicable law or agreed to in writing, software
    24  // distributed under the License is distributed on an "AS IS" BASIS,
    25  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    26  // See the License for the specific language governing permissions and
    27  // limitations under the License.
    28  
    29  package python
    30  
    31  import (
    32  	"fmt"
    33  	"io"
    34  	"path/filepath"
    35  	"strings"
    36  
    37  	"deps.dev/util/pypi"
    38  	"deps.dev/util/resolve"
    39  	"deps.dev/util/resolve/dep"
    40  	"github.com/BurntSushi/toml"
    41  	scalibrfs "github.com/google/osv-scalibr/fs"
    42  	"github.com/google/osv-scalibr/guidedremediation/internal/manifest"
    43  	"github.com/google/osv-scalibr/guidedremediation/result"
    44  	"github.com/google/osv-scalibr/guidedremediation/strategy"
    45  	"github.com/google/osv-scalibr/log"
    46  )
    47  
    48  // pyProject is a struct that represents the contents of a pyproject.toml file.
    49  type pyProject struct {
    50  	Project project `toml:"project"`
    51  }
    52  
    53  // project is a struct that represents the [project] section of a pyproject.toml file.
    54  type project struct {
    55  	Name                 string              `toml:"name"`
    56  	Version              string              `toml:"version"`
    57  	Dependencies         []string            `toml:"dependencies"`
    58  	OptionalDependencies map[string][]string `toml:"optional-dependencies"`
    59  }
    60  
    61  type poetryReadWriter struct{}
    62  
    63  // GetPoetryReadWriter returns a ReadWriter for pyproject.toml manifest files.
    64  func GetPoetryReadWriter() (manifest.ReadWriter, error) {
    65  	return poetryReadWriter{}, nil
    66  }
    67  
    68  // System returns the ecosystem of this ReadWriter.
    69  func (r poetryReadWriter) System() resolve.System {
    70  	return resolve.PyPI
    71  }
    72  
    73  // SupportedStrategies returns the remediation strategies supported for this manifest.
    74  func (r poetryReadWriter) SupportedStrategies() []strategy.Strategy {
    75  	return []strategy.Strategy{strategy.StrategyRelax}
    76  }
    77  
    78  // parseDependencies parses a slice of dependency strings from a pyproject.toml file,
    79  // converting them into a slice of resolve.RequirementVersion.
    80  func parseDependencies(deps []string, optional bool) []resolve.RequirementVersion {
    81  	var reqs []resolve.RequirementVersion
    82  	if deps == nil {
    83  		return reqs
    84  	}
    85  
    86  	for _, reqStr := range deps {
    87  		d, err := pypi.ParseDependency(reqStr)
    88  		if err != nil {
    89  			log.Warnf("failed to parse python dependency in pyproject.toml %q: %v", reqStr, err)
    90  			continue
    91  		}
    92  
    93  		var dt dep.Type
    94  		if optional {
    95  			dt.AddAttr(dep.Opt, "")
    96  		}
    97  		reqs = append(reqs, resolve.RequirementVersion{
    98  			VersionKey: resolve.VersionKey{
    99  				PackageKey: resolve.PackageKey{
   100  					System: resolve.PyPI,
   101  					Name:   d.Name,
   102  				},
   103  				Version:     d.Constraint,
   104  				VersionType: resolve.Requirement,
   105  			},
   106  			Type: dt,
   107  		})
   108  	}
   109  	return reqs
   110  }
   111  
   112  // Read parses the manifest from the given file.
   113  func (r poetryReadWriter) Read(path string, fsys scalibrfs.FS) (manifest.Manifest, error) {
   114  	path = filepath.ToSlash(path)
   115  	f, err := fsys.Open(path)
   116  	if err != nil {
   117  		return nil, err
   118  	}
   119  	defer f.Close()
   120  
   121  	var proj pyProject
   122  	if _, err := toml.NewDecoder(f).Decode(&proj); err != nil {
   123  		return nil, fmt.Errorf("failed to unmarshal pyproject.toml: %w", err)
   124  	}
   125  
   126  	allReqs := []resolve.RequirementVersion{}
   127  	groups := make(map[manifest.RequirementKey][]string)
   128  
   129  	// Dependencies
   130  	allReqs = append(allReqs, parseDependencies(proj.Project.Dependencies, false)...)
   131  
   132  	// Optional dependencies
   133  	for groupName, deps := range proj.Project.OptionalDependencies {
   134  		groupReqs := parseDependencies(deps, true)
   135  		allReqs = append(allReqs, groupReqs...)
   136  		for _, r := range groupReqs {
   137  			key := manifest.RequirementKey(r.PackageKey)
   138  			groups[key] = append(groups[key], groupName)
   139  		}
   140  	}
   141  
   142  	return &pythonManifest{
   143  		filePath: path,
   144  		root: resolve.Version{
   145  			VersionKey: resolve.VersionKey{
   146  				PackageKey: resolve.PackageKey{
   147  					System: resolve.PyPI,
   148  					Name:   proj.Project.Name,
   149  				},
   150  				VersionType: resolve.Concrete,
   151  				Version:     proj.Project.Version,
   152  			},
   153  		},
   154  		requirements: allReqs,
   155  		groups:       groups,
   156  	}, nil
   157  }
   158  
   159  // Write writes the manifest after applying the patches to outputPath.
   160  func (r poetryReadWriter) Write(original manifest.Manifest, fsys scalibrfs.FS, patches []result.Patch, outputPath string) error {
   161  	return write(fsys, original.FilePath(), outputPath, patches, updatePyproject)
   162  }
   163  
   164  // updatePyproject takes an io.Reader representing the pyproject.toml file
   165  // and a map of package names to their new version constraints, returns the
   166  // file with the updated requirements as a string.
   167  func updatePyproject(reader io.Reader, requirements []TokenizedRequirements) (string, error) {
   168  	data, err := io.ReadAll(reader)
   169  	if err != nil {
   170  		return "", fmt.Errorf("error reading requirements: %w", err)
   171  	}
   172  	content := string(data)
   173  
   174  	var proj pyProject
   175  	if _, err := toml.Decode(content, &proj); err != nil {
   176  		return "", fmt.Errorf("failed to unmarshal pyproject.toml: %w", err)
   177  	}
   178  
   179  	updateDeps := func(deps []string) {
   180  		for _, req := range deps {
   181  			d, err := pypi.ParseDependency(req)
   182  			if err != nil {
   183  				log.Warnf("failed to parse Python dependency %s: %v", req, err)
   184  				continue
   185  			}
   186  
   187  			newReq, ok := findTokenizedRequirement(requirements, d.Name, tokenizeRequirement(d.Constraint))
   188  			if ok {
   189  				updatedReq := replaceRequirement(req, newReq)
   190  				content = strings.Replace(content, `"`+req+`"`, `"`+updatedReq+`"`, 1)
   191  			}
   192  		}
   193  	}
   194  
   195  	updateDeps(proj.Project.Dependencies)
   196  	for _, deps := range proj.Project.OptionalDependencies {
   197  		updateDeps(deps)
   198  	}
   199  
   200  	return content, nil
   201  }