1use std::collections::HashMap;
29use std::path::Path;
30
31use crate::parser_warn as warn;
32use packageurl::PackageUrl;
33use toml::Value as TomlValue;
34use toml::map::Map as TomlMap;
35
36use crate::models::{
37 DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage, Sha256Digest,
38};
39use crate::parsers::python::{build_pypi_urls, read_toml_file};
40use crate::parsers::utils::{capped_iteration_limit, truncate_field};
41
42use super::PackageParser;
43use super::metadata::ParserMetadata;
44
45const FIELD_PACKAGE: &str = "package";
46const FIELD_METADATA: &str = "metadata";
47const FIELD_NAME: &str = "name";
48const FIELD_VERSION: &str = "version";
49const FIELD_PYTHON_VERSIONS: &str = "python-versions";
50const FIELD_DEPENDENCIES: &str = "dependencies";
51const FIELD_EXTRAS: &str = "extras";
52const FIELD_LOCK_VERSION: &str = "lock-version";
53
54pub struct PoetryLockParser;
58
59impl PackageParser for PoetryLockParser {
60 const PACKAGE_TYPE: PackageType = PackageType::Pypi;
61
62 fn metadata() -> Vec<ParserMetadata> {
63 vec![ParserMetadata {
64 description: "Poetry lockfile",
65 file_patterns: &["**/poetry.lock"],
66 package_type: "pypi",
67 primary_language: "Python",
68 documentation_url: Some(
69 "https://python-poetry.org/docs/basic-usage/#installing-with-poetrylock",
70 ),
71 }]
72 }
73
74 fn is_match(path: &Path) -> bool {
75 path.file_name()
76 .and_then(|name| name.to_str())
77 .map(|name| name == "poetry.lock")
78 .unwrap_or(false)
79 }
80
81 fn extract_packages(path: &Path) -> Vec<PackageData> {
82 let toml_content = match read_toml_file(path) {
83 Ok(content) => content,
84 Err(e) => {
85 warn!("Failed to read poetry.lock at {:?}: {}", path, e);
86 return vec![default_package_data()];
87 }
88 };
89
90 vec![parse_poetry_lock(&toml_content)]
91 }
92}
93
94fn parse_poetry_lock(toml_content: &TomlValue) -> PackageData {
95 let packages = toml_content
96 .get(FIELD_PACKAGE)
97 .and_then(|value| value.as_array())
98 .cloned()
99 .unwrap_or_default();
100
101 let metadata = toml_content
102 .get(FIELD_METADATA)
103 .and_then(|value| value.as_table());
104
105 let mut dependencies = Vec::new();
106 let limit = capped_iteration_limit(packages.len(), "poetry.lock packages");
107 for package in packages.iter().take(limit) {
108 if let Some(package_table) = package.as_table()
109 && let Some(dependency) = build_dependency_from_package(package_table)
110 {
111 dependencies.push(dependency);
112 }
113 }
114
115 PackageData {
116 package_type: Some(PoetryLockParser::PACKAGE_TYPE),
117 namespace: None,
118 name: None,
119 version: None,
120 qualifiers: None,
121 subpath: None,
122 primary_language: Some("Python".to_string()),
123 description: None,
124 release_date: None,
125 parties: Vec::new(),
126 keywords: Vec::new(),
127 homepage_url: None,
128 download_url: None,
129 size: None,
130 sha1: None,
131 md5: None,
132 sha256: None,
133 sha512: None,
134 bug_tracking_url: None,
135 code_view_url: None,
136 vcs_url: None,
137 copyright: None,
138 holder: None,
139 declared_license_expression: None,
140 declared_license_expression_spdx: None,
141 license_detections: Vec::new(),
142 other_license_expression: None,
143 other_license_expression_spdx: None,
144 other_license_detections: Vec::new(),
145 extracted_license_statement: None,
146 notice_text: None,
147 source_packages: Vec::new(),
148 file_references: Vec::new(),
149 is_private: false,
150 is_virtual: false,
151 extra_data: build_metadata_extra_data(metadata),
152 dependencies,
153 repository_homepage_url: None,
154 repository_download_url: None,
155 api_data_url: None,
156 datasource_id: Some(DatasourceId::PypiPoetryLock),
157 purl: None,
158 }
159}
160
161fn build_metadata_extra_data(
162 metadata: Option<&TomlMap<String, TomlValue>>,
163) -> Option<HashMap<String, serde_json::Value>> {
164 let mut extra_data = HashMap::new();
165
166 if let Some(metadata) = metadata {
167 if let Some(python_versions) = metadata
168 .get(FIELD_PYTHON_VERSIONS)
169 .and_then(|value| value.as_str())
170 && !python_versions.is_empty()
171 {
172 extra_data.insert(
173 "python_version".to_string(),
174 serde_json::Value::String(truncate_field(python_versions.to_string())),
175 );
176 }
177
178 if let Some(lock_version) = metadata.get(FIELD_LOCK_VERSION) {
179 let lock_version = lock_version
180 .as_str()
181 .map(|value| value.to_string())
182 .or_else(|| lock_version.as_integer().map(|value| value.to_string()));
183
184 if let Some(lock_version) = lock_version
185 && !lock_version.is_empty()
186 {
187 extra_data.insert(
188 "lock_version".to_string(),
189 serde_json::Value::String(truncate_field(lock_version)),
190 );
191 }
192 }
193 }
194
195 if extra_data.is_empty() {
196 None
197 } else {
198 Some(extra_data)
199 }
200}
201
202fn build_dependency_from_package(package_table: &TomlMap<String, TomlValue>) -> Option<Dependency> {
203 let name = package_table
204 .get(FIELD_NAME)
205 .and_then(|value| value.as_str())
206 .map(normalize_pypi_name)
207 .map(truncate_field)?;
208
209 let version = package_table
210 .get(FIELD_VERSION)
211 .and_then(|value| value.as_str())
212 .map(|value| truncate_field(value.to_string()))?;
213
214 let purl = create_pypi_purl(&name, Some(&version));
215
216 let resolved_package = build_resolved_package(package_table, &name, &version);
217
218 let poetry_optional = package_table
219 .get("optional")
220 .and_then(|value| value.as_bool())
221 .unwrap_or(false);
222
223 let extra_data = Some(HashMap::from([(
224 "poetry_optional".to_string(),
225 serde_json::Value::Bool(poetry_optional),
226 )]));
227
228 Some(Dependency {
229 purl,
230 extracted_requirement: None,
231 scope: None,
232 is_runtime: None,
233 is_optional: None,
234 is_pinned: Some(true),
235 is_direct: None,
236 resolved_package: Some(Box::new(resolved_package)),
237 extra_data,
238 })
239}
240
241fn build_resolved_package(
242 package_table: &TomlMap<String, TomlValue>,
243 name: &str,
244 version: &str,
245) -> ResolvedPackage {
246 let dependencies = extract_package_dependencies(package_table);
247
248 let urls = build_pypi_urls(Some(name), Some(version));
249
250 let repository_homepage_url = urls.repository_homepage_url.map(truncate_field);
251 let repository_download_url = urls.repository_download_url.map(truncate_field);
252 let api_data_url = urls.api_data_url.map(truncate_field);
253 let purl = urls.purl.map(truncate_field);
254
255 let sha256 = extract_sha256_from_files(package_table);
257
258 ResolvedPackage {
259 primary_language: Some("Python".to_string()),
260 download_url: None,
261 sha1: None,
262 sha256: sha256.and_then(|h| Sha256Digest::from_hex(&h).ok()),
263 sha512: None,
264 md5: None,
265 is_virtual: true,
266 extra_data: None,
267 dependencies,
268 repository_homepage_url,
269 repository_download_url,
270 api_data_url,
271 datasource_id: Some(DatasourceId::PypiPoetryLock),
272 purl,
273 ..ResolvedPackage::new(
274 PoetryLockParser::PACKAGE_TYPE,
275 String::new(),
276 truncate_field(name.to_string()),
277 truncate_field(version.to_string()),
278 )
279 }
280}
281
282fn extract_package_dependencies(package_table: &TomlMap<String, TomlValue>) -> Vec<Dependency> {
283 let mut dependencies = Vec::new();
284
285 if let Some(dep_table) = package_table
286 .get(FIELD_DEPENDENCIES)
287 .and_then(|value| value.as_table())
288 {
289 let limit = capped_iteration_limit(dep_table.len(), "poetry.lock package dependencies");
290 for (dep_name, dep_value) in dep_table.iter().take(limit) {
291 if let Some(dependency) = build_dependency_from_table(dep_name, dep_value) {
292 dependencies.push(dependency);
293 }
294 }
295 }
296
297 if let Some(extras_table) = package_table
298 .get(FIELD_EXTRAS)
299 .and_then(|value| value.as_table())
300 {
301 let extras_limit = capped_iteration_limit(extras_table.len(), "poetry.lock package extras");
302 for (extra_name, extra_values) in extras_table.iter().take(extras_limit) {
303 if let Some(extra_list) = extra_values.as_array() {
304 let extra_limit =
305 capped_iteration_limit(extra_list.len(), "poetry.lock package extra list");
306 for extra in extra_list.iter().take(extra_limit) {
307 if let Some(spec) = extra.as_str()
308 && let Some(dependency) = build_dependency_from_extra(extra_name, spec)
309 {
310 dependencies.push(dependency);
311 }
312 }
313 }
314 }
315 }
316
317 dependencies
318}
319
320fn build_dependency_from_table(dep_name: &str, dep_value: &TomlValue) -> Option<Dependency> {
321 let (requirement, is_optional) = match dep_value {
322 TomlValue::String(value) => (Some(truncate_field(value.to_string())), false),
323 TomlValue::Table(table) => (
324 table
325 .get(FIELD_VERSION)
326 .and_then(|value| value.as_str())
327 .map(|value| truncate_field(value.to_string())),
328 table
329 .get("optional")
330 .and_then(|value| value.as_bool())
331 .unwrap_or(false),
332 ),
333 _ => (None, false),
334 };
335
336 let normalized_name = normalize_pypi_name(dep_name);
337 let purl = create_pypi_purl(&normalized_name, None);
338
339 Some(Dependency {
340 purl,
341 extracted_requirement: requirement,
342 scope: Some(truncate_field(FIELD_DEPENDENCIES.to_string())),
343 is_runtime: Some(true),
344 is_optional: Some(is_optional),
345 is_pinned: Some(false),
346 is_direct: Some(true),
347 resolved_package: None,
348 extra_data: None,
349 })
350}
351
352fn build_dependency_from_extra(extra_name: &str, spec: &str) -> Option<Dependency> {
353 let (name, requirement) = parse_poetry_dependency_spec(spec)?;
354 let purl = create_pypi_purl(&name, None);
355
356 Some(Dependency {
357 purl,
358 extracted_requirement: requirement,
359 scope: Some(truncate_field(extra_name.to_string())),
360 is_runtime: None,
361 is_optional: Some(true),
362 is_pinned: Some(false),
363 is_direct: Some(true),
364 resolved_package: None,
365 extra_data: None,
366 })
367}
368
369fn parse_poetry_dependency_spec(spec: &str) -> Option<(String, Option<String>)> {
370 let trimmed = spec.trim();
371 if trimmed.is_empty() {
372 return None;
373 }
374
375 if let Some(paren_pos) = trimmed.find(" (") {
376 let name_part = trimmed[..paren_pos].trim();
377 let requirement_part = trimmed[paren_pos + 2..].trim();
378 let requirement = requirement_part.trim_end_matches(')').trim();
379 if name_part.is_empty() {
380 return None;
381 }
382 let normalized_name = truncate_field(normalize_pypi_name(name_part));
383 let requirement = if requirement.is_empty() {
384 None
385 } else {
386 Some(truncate_field(requirement.to_string()))
387 };
388 return Some((normalized_name, requirement));
389 }
390
391 Some((truncate_field(normalize_pypi_name(trimmed)), None))
392}
393
394fn normalize_pypi_name(name: &str) -> String {
395 name.trim().to_ascii_lowercase()
396}
397
398fn create_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
399 if name.contains('[') || name.contains(']') {
400 return Some(truncate_field(build_manual_pypi_purl(name, version)));
401 }
402
403 if let Ok(mut purl) = PackageUrl::new(PoetryLockParser::PACKAGE_TYPE.as_str(), name) {
404 if let Some(version) = version
405 && purl.with_version(version).is_err()
406 {
407 return None;
408 }
409 return Some(truncate_field(purl.to_string()));
410 }
411
412 Some(truncate_field(build_manual_pypi_purl(name, version)))
413}
414
415fn build_manual_pypi_purl(name: &str, version: Option<&str>) -> String {
416 let encoded_name = encode_pypi_name(name);
417 let mut purl = format!("pkg:pypi/{}", encoded_name);
418 if let Some(version) = version
419 && !version.is_empty()
420 {
421 purl.push('@');
422 purl.push_str(version);
423 }
424 purl
425}
426
427fn encode_pypi_name(name: &str) -> String {
428 name.replace('[', "%5b").replace(']', "%5d")
429}
430
431fn extract_sha256_from_files(package_table: &TomlMap<String, TomlValue>) -> Option<String> {
432 package_table
433 .get("files")
434 .and_then(|files| files.as_array())
435 .and_then(|files_array| files_array.first())
436 .and_then(|first_file| first_file.as_table())
437 .and_then(|file_table| file_table.get("hash"))
438 .and_then(|hash_value| hash_value.as_str())
439 .and_then(|hash_str| {
440 hash_str
441 .strip_prefix("sha256:")
442 .map(|s| truncate_field(s.to_string()))
443 })
444}
445
446fn default_package_data() -> PackageData {
447 PackageData {
448 package_type: Some(PoetryLockParser::PACKAGE_TYPE),
449 primary_language: Some("Python".to_string()),
450 datasource_id: Some(DatasourceId::PypiPoetryLock),
451 ..Default::default()
452 }
453}