Skip to main content

provenant/parsers/
pipfile_lock.rs

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