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