1use 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
61pub 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
190fn 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
209fn 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}