1use 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
57pub 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
186fn 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
205fn 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}