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//! - Dependency groups support (main, dev, etc.) via scope field
17//! - Dependency resolution with exact versions
18//! - Package URL (purl) generation for PyPI packages
19//! - Extra dependencies and optional package handling
20//!
21//! # Implementation Notes
22//! - Uses TOML parsing via `toml` crate
23//! - All lockfile versions are pinned (`is_pinned: Some(true)`)
24//! - Graceful error handling with `warn!()` logs
25//! - Integrates with Python parser utilities for PyPI URL building
26
27use 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
53/// Poetry lockfile parser for poetry.lock files.
54///
55/// Extracts pinned Python package dependencies from Poetry-managed projects.
56pub 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    // Extract sha256 hash from files array (first file's hash)
255    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    // This dependency table entry only proves that the enclosing locked package declares
339    // a version constraint on `dep_name`. It does not prove whether that edge is a
340    // runtime-vs-dev dependency of the scanned project, nor whether it is direct relative
341    // to the project's own manifest (poetry.lock carries no root-project marker), so those
342    // booleans stay unset rather than guessed.
343    Some(Dependency {
344        purl,
345        extracted_requirement: requirement,
346        scope: Some(truncate_field(FIELD_DEPENDENCIES.to_string())),
347        is_runtime: None,
348        is_optional: Some(is_optional),
349        is_pinned: Some(false),
350        is_direct: None,
351        resolved_package: None,
352        extra_data: None,
353    })
354}
355
356fn build_dependency_from_extra(extra_name: &str, spec: &str) -> Option<Dependency> {
357    let (name, requirement) = parse_poetry_dependency_spec(spec)?;
358    let purl = create_pypi_purl(&name, None);
359
360    // `is_optional` is provable: this entry only exists because it belongs to a named
361    // extra. Whether it is a direct dependency of the scanned project's own manifest is
362    // not provable from poetry.lock alone, so `is_direct` stays unset.
363    Some(Dependency {
364        purl,
365        extracted_requirement: requirement,
366        scope: Some(truncate_field(extra_name.to_string())),
367        is_runtime: None,
368        is_optional: Some(true),
369        is_pinned: Some(false),
370        is_direct: None,
371        resolved_package: None,
372        extra_data: None,
373    })
374}
375
376fn parse_poetry_dependency_spec(spec: &str) -> Option<(String, Option<String>)> {
377    let trimmed = spec.trim();
378    if trimmed.is_empty() {
379        return None;
380    }
381
382    if let Some(paren_pos) = trimmed.find(" (") {
383        let name_part = trimmed[..paren_pos].trim();
384        let requirement_part = trimmed[paren_pos + 2..].trim();
385        let requirement = requirement_part.trim_end_matches(')').trim();
386        if name_part.is_empty() {
387            return None;
388        }
389        let normalized_name = truncate_field(normalize_pypi_name(name_part));
390        let requirement = if requirement.is_empty() {
391            None
392        } else {
393            Some(truncate_field(requirement.to_string()))
394        };
395        return Some((normalized_name, requirement));
396    }
397
398    Some((truncate_field(normalize_pypi_name(trimmed)), None))
399}
400
401fn normalize_pypi_name(name: &str) -> String {
402    name.trim().to_ascii_lowercase()
403}
404
405fn create_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
406    if name.contains('[') || name.contains(']') {
407        return Some(truncate_field(build_manual_pypi_purl(name, version)));
408    }
409
410    if let Ok(mut purl) = PackageUrl::new(PoetryLockParser::PACKAGE_TYPE.as_str(), name) {
411        if let Some(version) = version
412            && purl.with_version(version).is_err()
413        {
414            return None;
415        }
416        return Some(truncate_field(purl.to_string()));
417    }
418
419    Some(truncate_field(build_manual_pypi_purl(name, version)))
420}
421
422fn build_manual_pypi_purl(name: &str, version: Option<&str>) -> String {
423    let encoded_name = encode_pypi_name(name);
424    let mut purl = format!("pkg:pypi/{}", encoded_name);
425    if let Some(version) = version
426        && !version.is_empty()
427    {
428        purl.push('@');
429        purl.push_str(version);
430    }
431    purl
432}
433
434fn encode_pypi_name(name: &str) -> String {
435    name.replace('[', "%5b").replace(']', "%5d")
436}
437
438fn extract_sha256_from_files(package_table: &TomlMap<String, TomlValue>) -> Option<String> {
439    package_table
440        .get("files")
441        .and_then(|files| files.as_array())
442        .and_then(|files_array| files_array.first())
443        .and_then(|first_file| first_file.as_table())
444        .and_then(|file_table| file_table.get("hash"))
445        .and_then(|hash_value| hash_value.as_str())
446        .and_then(|hash_str| {
447            hash_str
448                .strip_prefix("sha256:")
449                .map(|s| truncate_field(s.to_string()))
450        })
451}
452
453fn default_package_data() -> PackageData {
454    PackageData {
455        package_type: Some(PoetryLockParser::PACKAGE_TYPE),
456        primary_language: Some("Python".to_string()),
457        datasource_id: Some(DatasourceId::PypiPoetryLock),
458        ..Default::default()
459    }
460}