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 }