Skip to main content

provenant/parsers/
poetry_lock.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7//! Parser for Poetry poetry.lock lockfiles.
8//!
9//! Extracts resolved dependency information from Poetry lockfiles which use TOML format
10//! to store resolved versions and metadata for Python dependencies.
11//!
12//! # Supported Formats
13//! - poetry.lock (TOML-based lockfile with package metadata)
14//!
15//! # Key Features
16//! - Direct vs transitive dependency tracking via `is_direct` flag
17//! - Dependency groups support (main, dev, etc.) via scope field
18//! - Dependency resolution with exact versions
19//! - Package URL (purl) generation for PyPI packages
20//! - Extra dependencies and optional package handling
21//!
22//! # Implementation Notes
23//! - Uses TOML parsing via `toml` crate
24//! - All lockfile versions are pinned (`is_pinned: Some(true)`)
25//! - Graceful error handling with `warn!()` logs
26//! - Integrates with Python parser utilities for PyPI URL building
27
28use 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
54/// Poetry lockfile parser for poetry.lock files.
55///
56/// Extracts pinned Python package dependencies from Poetry-managed projects.
57pub 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    // Extract sha256 hash from files array (first file's hash)
256    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}