Skip to main content

provenant/parsers/
pipfile_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 Pipfile.lock lockfiles.
8//!
9//! Extracts resolved dependency information from Pipfile.lock files which store
10//! locked dependency versions for Python projects using pipenv.
11//!
12//! # Supported Formats
13//! - Pipfile.lock (JSON-based lockfile with per-environment dependency sections)
14//!
15//! # Key Features
16//! - Dependency extraction from default and develop sections
17//! - Runtime vs develop scope tracking from lock section membership; Pipfile.lock's
18//!   `default`/`develop` sections are the full flattened closure, so `is_direct` and
19//!   `is_optional` are left unset for the lock (only the Pipfile manifest proves
20//!   `is_direct`)
21//! - Exact version resolution with pinned artifact hashes surfaced as dependency `hash_options`
22//! - Package URL (purl) generation for PyPI packages
23//! - Markers and extras dependency handling
24//!
25//! # Implementation Notes
26//! - Uses JSON parsing via `serde_json` and TOML for secondary parsing
27//! - All lockfile versions are pinned (`is_pinned: Some(true)`)
28//! - Graceful error handling with `warn!()` logs
29//! - Integrates with Python parser utilities for PyPI URL building
30
31use std::collections::HashMap;
32use std::path::Path;
33
34use crate::parser_warn as warn;
35use packageurl::PackageUrl;
36use serde_json::Value as JsonValue;
37use toml::Value as TomlValue;
38use toml::map::Map as TomlMap;
39
40use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Sha256Digest};
41use crate::parsers::python::read_toml_file;
42use crate::parsers::utils::{CappedIterExt, read_file_to_string, truncate_field};
43
44use super::PackageParser;
45use super::metadata::ParserMetadata;
46
47const FIELD_META: &str = "_meta";
48const FIELD_HASH: &str = "hash";
49const FIELD_SHA256: &str = "sha256";
50const FIELD_DEFAULT: &str = "default";
51const FIELD_DEVELOP: &str = "develop";
52const FIELD_VERSION: &str = "version";
53const FIELD_HASHES: &str = "hashes";
54
55const FIELD_PACKAGES: &str = "packages";
56const FIELD_DEV_PACKAGES: &str = "dev-packages";
57const FIELD_REQUIRES: &str = "requires";
58const FIELD_SOURCE: &str = "source";
59const FIELD_PYTHON_VERSION: &str = "python_version";
60
61/// Pipenv lockfile and manifest parser for Pipfile.lock and Pipfile files.
62///
63/// Extracts Python package dependencies from Pipenv-managed projects, supporting
64/// both locked versions (Pipfile.lock) and declared dependencies (Pipfile).
65pub struct PipfileLockParser;
66
67impl PackageParser for PipfileLockParser {
68    const PACKAGE_TYPE: PackageType = PackageType::Pypi;
69
70    fn metadata() -> Vec<ParserMetadata> {
71        vec![ParserMetadata {
72            description: "Pipenv lockfile and manifest",
73            file_patterns: &["**/Pipfile.lock", "**/Pipfile"],
74            package_type: "pypi",
75            primary_language: "Python",
76            documentation_url: Some("https://github.com/pypa/pipfile"),
77        }]
78    }
79
80    fn is_match(path: &Path) -> bool {
81        path.file_name()
82            .and_then(|name| name.to_str())
83            .map(|name| name == "Pipfile.lock" || name == "Pipfile")
84            .unwrap_or(false)
85    }
86
87    fn extract_packages(path: &Path) -> Vec<PackageData> {
88        vec![match path.file_name().and_then(|name| name.to_str()) {
89            Some("Pipfile.lock") => extract_from_pipfile_lock(path),
90            Some("Pipfile") => extract_from_pipfile(path),
91            _ => default_package_data(None),
92        }]
93    }
94}
95
96fn extract_from_pipfile_lock(path: &Path) -> PackageData {
97    let content = match read_file_to_string(path, None) {
98        Ok(content) => content,
99        Err(e) => {
100            warn!("Failed to read Pipfile.lock at {:?}: {}", path, e);
101            return default_package_data(Some(DatasourceId::PipfileLock));
102        }
103    };
104
105    let json_content: JsonValue = match serde_json::from_str(&content) {
106        Ok(content) => content,
107        Err(e) => {
108            warn!("Failed to parse Pipfile.lock at {:?}: {}", path, e);
109            return default_package_data(Some(DatasourceId::PipfileLock));
110        }
111    };
112
113    parse_pipfile_lock(&json_content)
114}
115
116fn parse_pipfile_lock(json_content: &JsonValue) -> PackageData {
117    let mut package_data = default_package_data(Some(DatasourceId::PipfileLock));
118    package_data.sha256 = extract_lockfile_sha256(json_content);
119
120    let meta = json_content
121        .get(FIELD_META)
122        .and_then(|value| value.as_object());
123    let pipfile_spec = meta.and_then(|value| value.get("pipfile-spec"));
124    let sources = meta.and_then(|value| value.get("sources"));
125    let requires = meta.and_then(|value| value.get("requires"));
126    let _ = (pipfile_spec, sources, requires);
127
128    let default_deps = extract_lockfile_dependencies(json_content, FIELD_DEFAULT, "install", true);
129    let develop_deps = extract_lockfile_dependencies(json_content, FIELD_DEVELOP, "develop", false);
130    package_data.dependencies = [default_deps, develop_deps].concat();
131
132    package_data
133}
134
135fn extract_lockfile_sha256(json_content: &JsonValue) -> Option<Sha256Digest> {
136    json_content
137        .get(FIELD_META)
138        .and_then(|meta| meta.get(FIELD_HASH))
139        .and_then(|hash| hash.get(FIELD_SHA256))
140        .and_then(|value| value.as_str())
141        .and_then(|s| Sha256Digest::from_hex(s).ok())
142}
143
144fn extract_lockfile_dependencies(
145    json_content: &JsonValue,
146    section: &str,
147    scope: &str,
148    is_runtime: bool,
149) -> Vec<Dependency> {
150    let mut dependencies = Vec::new();
151
152    if let Some(section_map) = json_content
153        .get(section)
154        .and_then(|value| value.as_object())
155    {
156        for (name, value) in section_map.iter().capped("Pipfile.lock section packages") {
157            if let Some(dependency) = build_lockfile_dependency(name, value, scope, is_runtime) {
158                dependencies.push(dependency);
159            }
160        }
161    }
162
163    dependencies
164}
165
166fn build_lockfile_dependency(
167    name: &str,
168    value: &JsonValue,
169    scope: &str,
170    is_runtime: bool,
171) -> Option<Dependency> {
172    let normalized_name = normalize_pypi_name(name);
173    let requirement = extract_lockfile_requirement(value)?;
174    let version = strip_pipfile_lock_version(&requirement);
175    let purl = create_pypi_purl(&normalized_name, version.as_deref());
176
177    Some(Dependency {
178        purl,
179        extracted_requirement: Some(truncate_field(requirement)),
180        scope: Some(scope.to_string()),
181        is_runtime: Some(is_runtime),
182        is_optional: None,
183        is_pinned: Some(true),
184        is_direct: None,
185        resolved_package: None,
186        extra_data: build_lockfile_dependency_extra_data(value),
187    })
188}
189
190/// Surface the per-package pinned artifact hashes (the `hashes` array in each
191/// Pipfile.lock entry) as `hash_options`, mirroring the requirements.txt parser.
192///
193/// These are the expected hashes of the upstream wheels/sdists this lock pins,
194/// which is distinct from the scanned lockfile's own content hash. Returns
195/// `None` when no hashes are present so dependencies without pins stay clean.
196fn build_lockfile_dependency_extra_data(value: &JsonValue) -> Option<HashMap<String, JsonValue>> {
197    let hashes = extract_lockfile_hashes(value);
198    if hashes.is_empty() {
199        return None;
200    }
201    let mut extra_data = HashMap::new();
202    extra_data.insert(
203        "hash_options".to_string(),
204        JsonValue::Array(hashes.into_iter().map(JsonValue::String).collect()),
205    );
206    Some(extra_data)
207}
208
209/// Collect the raw pinned hash strings (e.g. `"sha256:..."`) from a Pipfile.lock
210/// entry's `hashes` array, preserving the algorithm prefix as requirements.txt does.
211fn extract_lockfile_hashes(value: &JsonValue) -> Vec<String> {
212    value
213        .get(FIELD_HASHES)
214        .and_then(|hashes_value| hashes_value.as_array())
215        .map(|hash_values| {
216            hash_values
217                .iter()
218                .filter_map(|hash_value| hash_value.as_str())
219                .map(|hash| truncate_field(hash.to_string()))
220                .collect()
221        })
222        .unwrap_or_default()
223}
224
225fn extract_lockfile_requirement(value: &JsonValue) -> Option<String> {
226    match value {
227        JsonValue::String(spec) => Some(truncate_field(spec.to_string())),
228        JsonValue::Object(map) => map
229            .get(FIELD_VERSION)
230            .and_then(|version| version.as_str())
231            .map(|version| truncate_field(version.to_string())),
232        _ => None,
233    }
234}
235
236fn strip_pipfile_lock_version(requirement: &str) -> Option<String> {
237    let trimmed = requirement.trim();
238    if let Some(stripped) = trimmed.strip_prefix("==") {
239        let version = stripped.trim();
240        if version.is_empty() {
241            None
242        } else {
243            Some(truncate_field(version.to_string()))
244        }
245    } else {
246        None
247    }
248}
249
250fn extract_from_pipfile(path: &Path) -> PackageData {
251    let toml_content = match read_toml_file(path) {
252        Ok(content) => content,
253        Err(e) => {
254            warn!("Failed to read Pipfile at {:?}: {}", path, e);
255            return default_package_data(Some(DatasourceId::Pipfile));
256        }
257    };
258
259    parse_pipfile(&toml_content)
260}
261
262fn parse_pipfile(toml_content: &TomlValue) -> PackageData {
263    let mut package_data = default_package_data(Some(DatasourceId::Pipfile));
264
265    let packages = toml_content
266        .get(FIELD_PACKAGES)
267        .and_then(|value| value.as_table());
268    let dev_packages = toml_content
269        .get(FIELD_DEV_PACKAGES)
270        .and_then(|value| value.as_table());
271
272    let mut dependencies = Vec::new();
273    if let Some(packages) = packages {
274        dependencies.extend(extract_pipfile_dependencies(packages, "install", true));
275    }
276    if let Some(dev_packages) = dev_packages {
277        dependencies.extend(extract_pipfile_dependencies(dev_packages, "develop", false));
278    }
279
280    package_data.dependencies = dependencies;
281    package_data.extra_data = build_pipfile_extra_data(toml_content);
282
283    package_data
284}
285
286fn extract_pipfile_dependencies(
287    packages: &TomlMap<String, TomlValue>,
288    scope: &str,
289    is_runtime: bool,
290) -> Vec<Dependency> {
291    let mut dependencies = Vec::new();
292
293    for (name, value) in packages.iter().capped("Pipfile packages") {
294        if let Some(dependency) = build_pipfile_dependency(name, value, scope, is_runtime) {
295            dependencies.push(dependency);
296        }
297    }
298
299    dependencies
300}
301
302fn build_pipfile_dependency(
303    name: &str,
304    value: &TomlValue,
305    scope: &str,
306    is_runtime: bool,
307) -> Option<Dependency> {
308    let normalized_name = normalize_pypi_name(name);
309    let requirement = extract_pipfile_requirement(value);
310    if requirement.is_none() && is_non_registry_dependency(value) {
311        return None;
312    }
313    let requirement = requirement?;
314    let purl = create_pypi_purl(&normalized_name, None);
315
316    Some(Dependency {
317        purl,
318        extracted_requirement: Some(truncate_field(requirement)),
319        scope: Some(scope.to_string()),
320        is_runtime: Some(is_runtime),
321        is_optional: None,
322        is_pinned: Some(false),
323        is_direct: Some(true),
324        resolved_package: None,
325        extra_data: None,
326    })
327}
328
329fn extract_pipfile_requirement(value: &TomlValue) -> Option<String> {
330    match value {
331        TomlValue::String(spec) => Some(truncate_field(spec.to_string())),
332        TomlValue::Boolean(true) => Some("*".to_string()),
333        TomlValue::Table(table) => table
334            .get(FIELD_VERSION)
335            .and_then(|version| version.as_str())
336            .map(|version| truncate_field(version.to_string())),
337        _ => None,
338    }
339}
340
341fn is_non_registry_dependency(value: &TomlValue) -> bool {
342    let table = match value {
343        TomlValue::Table(table) => table,
344        _ => return false,
345    };
346
347    ["git", "path", "file", "url", "hg", "svn"]
348        .iter()
349        .any(|key| table.contains_key(*key))
350}
351
352fn build_pipfile_extra_data(
353    toml_content: &TomlValue,
354) -> Option<HashMap<String, serde_json::Value>> {
355    let mut extra_data = HashMap::new();
356
357    if let Some(requires_table) = toml_content
358        .get(FIELD_REQUIRES)
359        .and_then(|value| value.as_table())
360        && let Some(python_version) = requires_table
361            .get(FIELD_PYTHON_VERSION)
362            .and_then(|value| value.as_str())
363    {
364        extra_data.insert(
365            FIELD_PYTHON_VERSION.to_string(),
366            serde_json::Value::String(truncate_field(python_version.to_string())),
367        );
368    }
369
370    if let Some(source_value) = toml_content.get(FIELD_SOURCE)
371        && let Some(sources) = parse_pipfile_sources(source_value)
372    {
373        extra_data.insert("sources".to_string(), sources);
374    }
375
376    if extra_data.is_empty() {
377        None
378    } else {
379        Some(extra_data)
380    }
381}
382
383fn parse_pipfile_sources(source_value: &TomlValue) -> Option<serde_json::Value> {
384    match source_value {
385        TomlValue::Array(sources) => {
386            let mut json_sources = Vec::new();
387            for source in sources {
388                if let Some(table) = source.as_table() {
389                    let mut json_map = serde_json::Map::new();
390                    if let Some(name) = table.get("name").and_then(|value| value.as_str()) {
391                        json_map.insert(
392                            "name".to_string(),
393                            serde_json::Value::String(truncate_field(name.to_string())),
394                        );
395                    }
396                    if let Some(url) = table.get("url").and_then(|value| value.as_str()) {
397                        json_map.insert(
398                            "url".to_string(),
399                            serde_json::Value::String(truncate_field(url.to_string())),
400                        );
401                    }
402                    if let Some(verify_ssl) =
403                        table.get("verify_ssl").and_then(|value| value.as_bool())
404                    {
405                        json_map.insert(
406                            "verify_ssl".to_string(),
407                            serde_json::Value::Bool(verify_ssl),
408                        );
409                    }
410                    json_sources.push(serde_json::Value::Object(json_map));
411                }
412            }
413
414            Some(serde_json::Value::Array(json_sources))
415        }
416        TomlValue::Table(table) => {
417            let mut json_map = serde_json::Map::new();
418            for (key, value) in table {
419                match value {
420                    TomlValue::String(value) => {
421                        json_map.insert(
422                            key.to_string(),
423                            serde_json::Value::String(truncate_field(value.to_string())),
424                        );
425                    }
426                    TomlValue::Boolean(value) => {
427                        json_map.insert(key.to_string(), serde_json::Value::Bool(*value));
428                    }
429                    _ => {}
430                }
431            }
432            Some(serde_json::Value::Object(json_map))
433        }
434        _ => None,
435    }
436}
437
438fn normalize_pypi_name(name: &str) -> String {
439    truncate_field(name.trim().to_ascii_lowercase())
440}
441
442fn create_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
443    let mut purl = PackageUrl::new(PipfileLockParser::PACKAGE_TYPE.as_str(), name).ok()?;
444    if let Some(version) = version
445        && purl.with_version(version).is_err()
446    {
447        return None;
448    }
449
450    Some(purl.to_string())
451}
452
453fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
454    PackageData {
455        package_type: Some(PipfileLockParser::PACKAGE_TYPE),
456        primary_language: Some("Python".to_string()),
457        datasource_id,
458        ..Default::default()
459    }
460}