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 }