1use 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
58pub 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
187fn 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
206fn 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}