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