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