1use std::collections::{HashMap, HashSet, VecDeque};
7use std::path::Path;
8
9use crate::parser_warn as warn;
10use packageurl::PackageUrl;
11use serde_json::Value as JsonValue;
12use toml::Value as TomlValue;
13use toml::map::Map as TomlMap;
14
15use crate::models::{
16 DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage, Sha256Digest,
17};
18use crate::parsers::python::read_toml_file;
19use crate::parsers::utils::{
20 MAX_ITERATION_COUNT, RecursionGuard, capped_iteration_limit, truncate_field,
21};
22
23use super::PackageParser;
24
25const FIELD_PACKAGE: &str = "package";
26const FIELD_NAME: &str = "name";
27const FIELD_VERSION: &str = "version";
28const FIELD_SOURCE: &str = "source";
29const FIELD_DEPENDENCIES: &str = "dependencies";
30const FIELD_OPTIONAL_DEPENDENCIES: &str = "optional-dependencies";
31const FIELD_DEV_DEPENDENCIES: &str = "dev-dependencies";
32const FIELD_METADATA: &str = "metadata";
33const FIELD_REQUIRES_DIST: &str = "requires-dist";
34const FIELD_REQUIRES_DEV: &str = "requires-dev";
35const FIELD_METADATA_OPTIONAL_DEPENDENCIES: &str = "optional-dependencies";
36const FIELD_MARKER: &str = "marker";
37const FIELD_EXTRA: &str = "extra";
38const FIELD_SPECIFIER: &str = "specifier";
39const FIELD_REVISION: &str = "revision";
40const FIELD_REQUIRES_PYTHON: &str = "requires-python";
41const FIELD_RESOLUTION_MARKERS: &str = "resolution-markers";
42const FIELD_MANIFEST: &str = "manifest";
43
44pub struct UvLockParser;
45
46#[derive(Clone, Debug, Default)]
47struct DirectDependencyInfo {
48 extracted_requirement: Option<String>,
49 scope: Option<String>,
50 is_runtime: bool,
51 is_optional: bool,
52 extra_data: Option<HashMap<String, JsonValue>>,
53 source_key: Option<String>,
54}
55
56#[derive(Clone, Debug)]
57struct DependencyEdge {
58 name: String,
59 extracted_requirement: Option<String>,
60 scope: Option<String>,
61 is_runtime: bool,
62 is_optional: bool,
63 source_key: Option<String>,
64 extra_data: Option<HashMap<String, JsonValue>>,
65}
66
67impl PackageParser for UvLockParser {
68 const PACKAGE_TYPE: PackageType = PackageType::Pypi;
69
70 fn is_match(path: &Path) -> bool {
71 path.file_name()
72 .and_then(|name| name.to_str())
73 .is_some_and(|name| name == "uv.lock")
74 }
75
76 fn extract_packages(path: &Path) -> Vec<PackageData> {
77 let toml_content = match read_toml_file(path) {
78 Ok(content) => content,
79 Err(e) => {
80 warn!("Failed to read uv.lock at {:?}: {}", path, e);
81 return vec![default_package_data()];
82 }
83 };
84
85 vec![parse_uv_lock(&toml_content)]
86 }
87
88 fn metadata() -> Vec<super::metadata::ParserMetadata> {
89 vec![super::metadata::ParserMetadata {
90 description: "uv lockfile",
91 file_patterns: &["**/uv.lock"],
92 package_type: "pypi",
93 primary_language: "Python",
94 documentation_url: Some("https://docs.astral.sh/uv/concepts/projects/layout/"),
95 }]
96 }
97}
98
99fn parse_uv_lock(toml_content: &TomlValue) -> PackageData {
100 let packages = toml_content
101 .get(FIELD_PACKAGE)
102 .and_then(TomlValue::as_array)
103 .cloned()
104 .unwrap_or_default();
105
106 if packages.is_empty() {
107 return default_package_data();
108 }
109
110 let packages_limit = capped_iteration_limit(packages.len(), "uv.lock package tables");
111 let package_tables: Vec<&TomlMap<String, TomlValue>> = packages
112 .iter()
113 .take(packages_limit)
114 .filter_map(TomlValue::as_table)
115 .collect();
116
117 if package_tables.is_empty() {
118 return default_package_data();
119 }
120
121 let root_index = find_root_package_index(&package_tables);
122 let package_lookup = build_package_lookup(&package_tables);
123
124 let direct_infos = root_index
125 .and_then(|index| package_tables.get(index).copied())
126 .map(collect_root_direct_dependencies)
127 .unwrap_or_default();
128
129 let runtime_roots: Vec<(String, Option<String>)> = direct_infos
130 .iter()
131 .filter(|(_, info)| info.is_runtime)
132 .map(|(name, info)| (name.clone(), info.source_key.clone()))
133 .collect();
134 let dev_roots: Vec<(String, Option<String>)> = direct_infos
135 .iter()
136 .filter(|(_, info)| !info.is_runtime && !info.is_optional)
137 .map(|(name, info)| (name.clone(), info.source_key.clone()))
138 .collect();
139 let optional_roots: Vec<(String, Option<String>)> = direct_infos
140 .iter()
141 .filter(|(_, info)| info.is_optional)
142 .map(|(name, info)| (name.clone(), info.source_key.clone()))
143 .collect();
144
145 let runtime_reachable =
146 collect_reachable_packages(&package_tables, &package_lookup, &runtime_roots, false);
147 let dev_reachable =
148 collect_reachable_packages(&package_tables, &package_lookup, &dev_roots, true);
149 let optional_reachable =
150 collect_reachable_packages(&package_tables, &package_lookup, &optional_roots, true);
151
152 let mut package_data = default_package_data();
153 package_data.extra_data = build_lock_extra_data(toml_content);
154
155 if let Some(index) = root_index
156 && let Some(root_table) = package_tables.get(index)
157 {
158 package_data.name = root_table
159 .get(FIELD_NAME)
160 .and_then(TomlValue::as_str)
161 .map(normalize_pypi_name);
162 package_data.version = root_table
163 .get(FIELD_VERSION)
164 .and_then(TomlValue::as_str)
165 .map(|value| truncate_field(value.to_string()));
166 package_data.is_virtual =
167 package_source_table(root_table).is_some_and(|source| source.contains_key("virtual"));
168 package_data.purl = package_data
169 .name
170 .as_deref()
171 .and_then(|name| create_pypi_purl(name, package_data.version.as_deref()));
172 }
173
174 package_data.dependencies = package_tables
175 .iter()
176 .enumerate()
177 .filter(|(index, _)| Some(*index) != root_index)
178 .filter_map(|(_, package_table)| {
179 build_top_level_dependency(
180 package_table,
181 root_index.is_none(),
182 &direct_infos,
183 &runtime_reachable,
184 &dev_reachable,
185 &optional_reachable,
186 &package_lookup,
187 )
188 })
189 .collect();
190
191 package_data
192}
193
194fn build_top_level_dependency(
195 package_table: &TomlMap<String, TomlValue>,
196 no_root_package: bool,
197 direct_infos: &HashMap<String, DirectDependencyInfo>,
198 runtime_reachable: &HashSet<String>,
199 dev_reachable: &HashSet<String>,
200 optional_reachable: &HashSet<String>,
201 package_lookup: &HashMap<String, Vec<usize>>,
202) -> Option<Dependency> {
203 let name = package_table
204 .get(FIELD_NAME)
205 .and_then(TomlValue::as_str)
206 .map(normalize_pypi_name)?;
207 let version = package_table
208 .get(FIELD_VERSION)
209 .and_then(TomlValue::as_str)
210 .map(|value| truncate_field(value.to_string()))?;
211
212 let direct_info = direct_infos.get(&name);
213 let is_direct = direct_info.is_some();
214 let is_runtime = if no_root_package {
215 true
216 } else if let Some(info) = direct_info {
217 info.is_runtime
218 } else if runtime_reachable.contains(&name) {
219 true
220 } else {
221 !dev_reachable.contains(&name) && !optional_reachable.contains(&name)
222 };
223 let is_optional = direct_info.is_some_and(|info| info.is_optional)
224 || (!is_direct && optional_reachable.contains(&name) && !runtime_reachable.contains(&name));
225
226 Some(Dependency {
227 purl: create_pypi_purl(&name, Some(&version)).map(truncate_field),
228 extracted_requirement: direct_info
229 .and_then(|info| info.extracted_requirement.clone())
230 .map(truncate_field),
231 scope: direct_info
232 .and_then(|info| info.scope.clone())
233 .map(truncate_field),
234 is_runtime: Some(is_runtime),
235 is_optional: Some(is_optional),
236 is_pinned: Some(true),
237 is_direct: Some(is_direct),
238 resolved_package: Some(Box::new(build_resolved_package(
239 package_table,
240 package_lookup,
241 ))),
242 extra_data: direct_info.and_then(|info| info.extra_data.clone()),
243 })
244}
245
246fn build_resolved_package(
247 package_table: &TomlMap<String, TomlValue>,
248 package_lookup: &HashMap<String, Vec<usize>>,
249) -> ResolvedPackage {
250 let name = package_table
251 .get(FIELD_NAME)
252 .and_then(TomlValue::as_str)
253 .map(normalize_pypi_name)
254 .unwrap_or_default();
255 let version = package_table
256 .get(FIELD_VERSION)
257 .and_then(TomlValue::as_str)
258 .map(|value| truncate_field(value.to_string()))
259 .unwrap_or_default();
260
261 let (_, repository_download_url, api_data_url, purl) =
262 build_pypi_urls(Some(&name), Some(&version));
263 let repository_homepage_url =
264 Some(truncate_field(format!("https://pypi.org/project/{}", name)));
265 let (download_url, sha256) = extract_artifact_metadata(package_table);
266
267 let download_url = download_url.map(truncate_field);
268
269 ResolvedPackage {
270 primary_language: Some("Python".to_string()),
271 download_url,
272 sha1: None,
273 sha256: sha256.and_then(|h| Sha256Digest::from_hex(&h).ok()),
274 sha512: None,
275 md5: None,
276 is_virtual: true,
277 extra_data: build_package_extra_data(package_table),
278 dependencies: collect_package_dependency_edges(package_table)
279 .into_iter()
280 .map(|edge| edge_to_dependency(edge, package_lookup))
281 .collect(),
282 repository_homepage_url,
283 repository_download_url: repository_download_url.map(truncate_field),
284 api_data_url: api_data_url.map(truncate_field),
285 datasource_id: Some(DatasourceId::PypiUvLock),
286 purl: purl.map(truncate_field),
287 ..ResolvedPackage::new(UvLockParser::PACKAGE_TYPE, String::new(), name, version)
288 }
289}
290
291fn edge_to_dependency(
292 edge: DependencyEdge,
293 package_lookup: &HashMap<String, Vec<usize>>,
294) -> Dependency {
295 let is_pinned = edge
296 .source_key
297 .as_ref()
298 .map(|_| !package_lookup.contains_key(&edge.name))
299 .unwrap_or(false);
300
301 Dependency {
302 purl: create_pypi_purl(&edge.name, None).map(truncate_field),
303 extracted_requirement: edge.extracted_requirement.map(truncate_field),
304 scope: edge.scope.map(truncate_field),
305 is_runtime: Some(edge.is_runtime),
306 is_optional: Some(edge.is_optional),
307 is_pinned: Some(is_pinned),
308 is_direct: Some(true),
309 resolved_package: None,
310 extra_data: edge.extra_data,
311 }
312}
313
314fn collect_root_direct_dependencies(
315 root_table: &TomlMap<String, TomlValue>,
316) -> HashMap<String, DirectDependencyInfo> {
317 let mut infos = HashMap::new();
318 let metadata = root_table.get(FIELD_METADATA).and_then(TomlValue::as_table);
319 let runtime_requirements = metadata
320 .and_then(|metadata| metadata.get(FIELD_REQUIRES_DIST))
321 .map(parse_requirement_metadata_array)
322 .unwrap_or_default();
323 let dev_requirements = metadata
324 .and_then(|metadata| metadata.get(FIELD_REQUIRES_DEV))
325 .and_then(TomlValue::as_table)
326 .map(parse_requirement_metadata_table)
327 .unwrap_or_default();
328 let optional_requirements = metadata
329 .and_then(|metadata| metadata.get(FIELD_METADATA_OPTIONAL_DEPENDENCIES))
330 .and_then(TomlValue::as_table)
331 .map(parse_requirement_metadata_table)
332 .unwrap_or_default();
333
334 for edge in collect_dependency_edges_from_array(
335 root_table
336 .get(FIELD_DEPENDENCIES)
337 .and_then(TomlValue::as_array),
338 None,
339 true,
340 false,
341 runtime_requirements.get("__runtime__"),
342 ) {
343 merge_direct_dependency_info(&mut infos, edge);
344 }
345
346 if let Some(optional_table) = root_table
347 .get(FIELD_OPTIONAL_DEPENDENCIES)
348 .and_then(TomlValue::as_table)
349 {
350 let optional_limit = capped_iteration_limit(
351 optional_table.len(),
352 "uv.lock root optional-dependency groups",
353 );
354 for (group, value) in optional_table.iter().take(optional_limit) {
355 let requirement_map = optional_requirements.get(group);
356 let edges = collect_dependency_edges_from_array(
357 value.as_array(),
358 Some(group.to_string()),
359 false,
360 true,
361 requirement_map,
362 );
363 let edges_limit =
364 capped_iteration_limit(edges.len(), "uv.lock root optional-dependency edges");
365 for edge in edges.into_iter().take(edges_limit) {
366 merge_direct_dependency_info(&mut infos, edge);
367 }
368 }
369 }
370
371 if let Some(dev_table) = root_table
372 .get(FIELD_DEV_DEPENDENCIES)
373 .and_then(TomlValue::as_table)
374 {
375 let dev_limit =
376 capped_iteration_limit(dev_table.len(), "uv.lock root dev-dependency groups");
377 for (group, value) in dev_table.iter().take(dev_limit) {
378 let requirement_map = dev_requirements.get(group);
379 let edges = collect_dependency_edges_from_array(
380 value.as_array(),
381 Some(group.to_string()),
382 false,
383 false,
384 requirement_map,
385 );
386 let edges_limit =
387 capped_iteration_limit(edges.len(), "uv.lock root dev-dependency edges");
388 for edge in edges.into_iter().take(edges_limit) {
389 merge_direct_dependency_info(&mut infos, edge);
390 }
391 }
392 }
393
394 infos
395}
396
397fn merge_direct_dependency_info(
398 infos: &mut HashMap<String, DirectDependencyInfo>,
399 edge: DependencyEdge,
400) {
401 let name = edge.name.clone();
402 let new_info = direct_info_from_edge(edge);
403
404 if let Some(existing) = infos.get_mut(&name) {
405 existing.is_runtime |= new_info.is_runtime;
406 existing.is_optional &= new_info.is_optional;
407
408 if existing.extracted_requirement.is_none() {
409 existing.extracted_requirement = new_info.extracted_requirement.clone();
410 }
411
412 existing.scope = merge_scope(existing.scope.as_ref(), new_info.scope.as_ref());
413 existing.extra_data =
414 merge_optional_json_maps(existing.extra_data.take(), new_info.extra_data);
415
416 if existing.source_key != new_info.source_key {
417 existing.source_key = None;
418 }
419 } else {
420 infos.insert(name, new_info);
421 }
422}
423
424fn merge_scope(current: Option<&String>, new: Option<&String>) -> Option<String> {
425 match (current, new) {
426 (None, None) => None,
427 (None, Some(_)) | (Some(_), None) => None,
428 (Some(left), Some(right)) if left == right => Some(left.clone()),
429 _ => None,
430 }
431}
432
433fn merge_optional_json_maps(
434 current: Option<HashMap<String, JsonValue>>,
435 new: Option<HashMap<String, JsonValue>>,
436) -> Option<HashMap<String, JsonValue>> {
437 match (current, new) {
438 (None, None) => None,
439 (Some(map), None) | (None, Some(map)) => Some(map),
440 (Some(mut current), Some(new)) => {
441 for (key, value) in new {
442 current.entry(key).or_insert(value);
443 }
444 Some(current)
445 }
446 }
447}
448
449fn direct_info_from_edge(edge: DependencyEdge) -> DirectDependencyInfo {
450 DirectDependencyInfo {
451 extracted_requirement: edge.extracted_requirement,
452 scope: edge.scope,
453 is_runtime: edge.is_runtime,
454 is_optional: edge.is_optional,
455 extra_data: edge.extra_data,
456 source_key: edge.source_key,
457 }
458}
459
460fn collect_package_dependency_edges(
461 package_table: &TomlMap<String, TomlValue>,
462) -> Vec<DependencyEdge> {
463 let mut edges = Vec::new();
464
465 edges.extend(collect_dependency_edges_from_array(
466 package_table
467 .get(FIELD_DEPENDENCIES)
468 .and_then(TomlValue::as_array),
469 None,
470 true,
471 false,
472 None,
473 ));
474
475 if let Some(optional_table) = package_table
476 .get(FIELD_OPTIONAL_DEPENDENCIES)
477 .and_then(TomlValue::as_table)
478 {
479 let optional_limit = capped_iteration_limit(
480 optional_table.len(),
481 "uv.lock package optional-dependency groups",
482 );
483 for (group, value) in optional_table.iter().take(optional_limit) {
484 let group_edges = collect_dependency_edges_from_array(
485 value.as_array(),
486 Some(group.to_string()),
487 false,
488 true,
489 None,
490 );
491 let edges_limit = capped_iteration_limit(
492 group_edges.len(),
493 "uv.lock package optional-dependency edges",
494 );
495 edges.extend(group_edges.into_iter().take(edges_limit));
496 }
497 }
498
499 if let Some(dev_table) = package_table
500 .get(FIELD_DEV_DEPENDENCIES)
501 .and_then(TomlValue::as_table)
502 {
503 let dev_limit =
504 capped_iteration_limit(dev_table.len(), "uv.lock package dev-dependency groups");
505 for (group, value) in dev_table.iter().take(dev_limit) {
506 let group_edges = collect_dependency_edges_from_array(
507 value.as_array(),
508 Some(group.to_string()),
509 false,
510 false,
511 None,
512 );
513 let edges_limit =
514 capped_iteration_limit(group_edges.len(), "uv.lock package dev-dependency edges");
515 edges.extend(group_edges.into_iter().take(edges_limit));
516 }
517 }
518
519 edges
520}
521
522fn collect_dependency_edges_from_array(
523 values: Option<&Vec<TomlValue>>,
524 scope: Option<String>,
525 is_runtime: bool,
526 is_optional: bool,
527 requirement_map: Option<&HashMap<String, String>>,
528) -> Vec<DependencyEdge> {
529 values
530 .into_iter()
531 .flatten()
532 .filter_map(|value| {
533 build_dependency_edge(
534 value,
535 scope.clone(),
536 is_runtime,
537 is_optional,
538 requirement_map,
539 )
540 })
541 .collect()
542}
543
544fn build_dependency_edge(
545 value: &TomlValue,
546 scope: Option<String>,
547 is_runtime: bool,
548 is_optional: bool,
549 requirement_map: Option<&HashMap<String, String>>,
550) -> Option<DependencyEdge> {
551 let table = value.as_table()?;
552 let name = table
553 .get(FIELD_NAME)
554 .and_then(TomlValue::as_str)
555 .map(normalize_pypi_name)?;
556
557 let mut extra_data = HashMap::new();
558 if let Some(marker) = table.get(FIELD_MARKER).and_then(TomlValue::as_str) {
559 extra_data.insert(
560 FIELD_MARKER.to_string(),
561 JsonValue::String(marker.to_string()),
562 );
563 }
564 if let Some(extra_value) = table.get(FIELD_EXTRA) {
565 let json_value = toml_value_to_json(extra_value);
566 extra_data.insert(FIELD_EXTRA.to_string(), json_value);
567 }
568
569 let source_key = table
570 .get(FIELD_SOURCE)
571 .and_then(TomlValue::as_table)
572 .and_then(source_table_key);
573 if let Some(source) = table.get(FIELD_SOURCE) {
574 extra_data.insert(FIELD_SOURCE.to_string(), toml_value_to_json(source));
575 }
576
577 let extracted_requirement = requirement_map
578 .and_then(|map| map.get(&name).cloned().map(truncate_field))
579 .or_else(|| {
580 table
581 .get(FIELD_SPECIFIER)
582 .and_then(TomlValue::as_str)
583 .map(|value| truncate_field(value.to_string()))
584 });
585
586 Some(DependencyEdge {
587 name,
588 extracted_requirement,
589 scope,
590 is_runtime,
591 is_optional,
592 source_key,
593 extra_data: (!extra_data.is_empty()).then_some(extra_data),
594 })
595}
596
597fn parse_requirement_metadata_array(value: &TomlValue) -> HashMap<String, HashMap<String, String>> {
598 let mut grouped = HashMap::new();
599 let runtime = value
600 .as_array()
601 .map(|values| parse_requirement_entries(values))
602 .unwrap_or_default();
603 grouped.insert("__runtime__".to_string(), runtime);
604 grouped
605}
606
607fn parse_requirement_metadata_table(
608 table: &TomlMap<String, TomlValue>,
609) -> HashMap<String, HashMap<String, String>> {
610 table
611 .iter()
612 .map(|(group, value)| {
613 (
614 group.to_string(),
615 value
616 .as_array()
617 .map(|values| parse_requirement_entries(values))
618 .unwrap_or_default(),
619 )
620 })
621 .collect()
622}
623
624fn parse_requirement_entries(values: &[TomlValue]) -> HashMap<String, String> {
625 let limit = capped_iteration_limit(values.len(), "uv.lock requirement entries");
626 values
627 .iter()
628 .take(limit)
629 .filter_map(|value| {
630 let table = value.as_table()?;
631 let name = table
632 .get(FIELD_NAME)
633 .and_then(TomlValue::as_str)
634 .map(normalize_pypi_name)?;
635 let specifier = table
636 .get(FIELD_SPECIFIER)
637 .and_then(TomlValue::as_str)
638 .map(|value| truncate_field(value.to_string()))?;
639 Some((name, specifier))
640 })
641 .collect()
642}
643
644fn collect_reachable_packages(
645 package_tables: &[&TomlMap<String, TomlValue>],
646 package_lookup: &HashMap<String, Vec<usize>>,
647 roots: &[(String, Option<String>)],
648 include_non_runtime_edges: bool,
649) -> HashSet<String> {
650 let mut visited = HashSet::new();
651 let mut queue: VecDeque<(String, Option<String>)> = roots.iter().cloned().collect();
652 let mut iterations: usize = 0;
653
654 while let Some((name, source_key)) = queue.pop_front() {
655 iterations += 1;
656 if iterations > MAX_ITERATION_COUNT {
657 warn!(
658 "collect_reachable_packages exceeded MAX_ITERATION_COUNT ({})",
659 MAX_ITERATION_COUNT
660 );
661 break;
662 }
663 let Some(index) =
664 match_package_index(package_tables, package_lookup, &name, source_key.as_deref())
665 else {
666 continue;
667 };
668
669 let Some(package_table) = package_tables.get(index) else {
670 continue;
671 };
672
673 let package_name = package_table
674 .get(FIELD_NAME)
675 .and_then(TomlValue::as_str)
676 .map(normalize_pypi_name)
677 .unwrap_or(name);
678
679 if !visited.insert(package_name.clone()) {
680 continue;
681 }
682
683 let edges = if include_non_runtime_edges {
684 collect_package_dependency_edges(package_table)
685 } else {
686 collect_dependency_edges_from_array(
687 package_table
688 .get(FIELD_DEPENDENCIES)
689 .and_then(TomlValue::as_array),
690 None,
691 true,
692 false,
693 None,
694 )
695 };
696
697 for edge in edges {
698 queue.push_back((edge.name, edge.source_key));
699 }
700 }
701
702 visited
703}
704
705fn build_package_lookup(
706 package_tables: &[&TomlMap<String, TomlValue>],
707) -> HashMap<String, Vec<usize>> {
708 let mut lookup: HashMap<String, Vec<usize>> = HashMap::new();
709 for (index, package_table) in package_tables.iter().enumerate() {
710 if let Some(name) = package_table
711 .get(FIELD_NAME)
712 .and_then(TomlValue::as_str)
713 .map(normalize_pypi_name)
714 {
715 lookup.entry(name).or_default().push(index);
716 }
717 }
718 lookup
719}
720
721fn match_package_index(
722 package_tables: &[&TomlMap<String, TomlValue>],
723 package_lookup: &HashMap<String, Vec<usize>>,
724 name: &str,
725 source_key: Option<&str>,
726) -> Option<usize> {
727 let candidates = package_lookup.get(name)?;
728 if candidates.len() == 1 {
729 return candidates.first().copied();
730 }
731
732 let source_key = source_key?;
733 candidates.iter().copied().find(|index| {
734 package_tables
735 .get(*index)
736 .and_then(|table| package_source_table(table))
737 .and_then(source_table_key)
738 .as_deref()
739 == Some(source_key)
740 })
741}
742
743fn find_root_package_index(package_tables: &[&TomlMap<String, TomlValue>]) -> Option<usize> {
744 if let Some(index) = package_tables.iter().position(|table| {
745 package_source_table(table)
746 .and_then(local_source_path)
747 .is_some_and(|path| path == ".")
748 }) {
749 return Some(index);
750 }
751
752 package_tables.iter().position(|table| {
753 package_source_table(table)
754 .is_some_and(|source| source.contains_key("editable") || source.contains_key("virtual"))
755 })
756}
757
758fn local_source_path(source_table: &TomlMap<String, TomlValue>) -> Option<&str> {
759 source_table
760 .get("virtual")
761 .and_then(TomlValue::as_str)
762 .or_else(|| source_table.get("editable").and_then(TomlValue::as_str))
763}
764
765fn build_lock_extra_data(toml_content: &TomlValue) -> Option<HashMap<String, JsonValue>> {
766 let mut extra_data = HashMap::new();
767
768 if let Some(version) = toml_content
769 .get(FIELD_VERSION)
770 .and_then(TomlValue::as_integer)
771 {
772 extra_data.insert(
773 "lockfile_version".to_string(),
774 JsonValue::String(version.to_string()),
775 );
776 }
777
778 if let Some(revision) = toml_content
779 .get(FIELD_REVISION)
780 .and_then(TomlValue::as_integer)
781 {
782 extra_data.insert(
783 FIELD_REVISION.to_string(),
784 JsonValue::String(revision.to_string()),
785 );
786 }
787
788 if let Some(requires_python) = toml_content
789 .get(FIELD_REQUIRES_PYTHON)
790 .and_then(TomlValue::as_str)
791 {
792 extra_data.insert(
793 "requires_python".to_string(),
794 JsonValue::String(requires_python.to_string()),
795 );
796 }
797
798 if let Some(markers) = toml_content.get(FIELD_RESOLUTION_MARKERS) {
799 extra_data.insert(
800 FIELD_RESOLUTION_MARKERS.to_string(),
801 toml_value_to_json(markers),
802 );
803 }
804
805 if let Some(manifest) = toml_content.get(FIELD_MANIFEST) {
806 extra_data.insert(FIELD_MANIFEST.to_string(), toml_value_to_json(manifest));
807 }
808
809 (!extra_data.is_empty()).then_some(extra_data)
810}
811
812fn build_package_extra_data(
813 package_table: &TomlMap<String, TomlValue>,
814) -> Option<HashMap<String, JsonValue>> {
815 let mut extra_data = HashMap::new();
816
817 if let Some(source) = package_table.get(FIELD_SOURCE) {
818 extra_data.insert(FIELD_SOURCE.to_string(), toml_value_to_json(source));
819 }
820
821 if let Some(metadata) = package_table.get(FIELD_METADATA) {
822 extra_data.insert(FIELD_METADATA.to_string(), toml_value_to_json(metadata));
823 }
824
825 (!extra_data.is_empty()).then_some(extra_data)
826}
827
828fn extract_artifact_metadata(
829 package_table: &TomlMap<String, TomlValue>,
830) -> (Option<String>, Option<String>) {
831 if let Some(sdist_table) = package_table.get("sdist").and_then(TomlValue::as_table) {
832 let download_url = sdist_table
833 .get("url")
834 .and_then(TomlValue::as_str)
835 .map(|value| truncate_field(value.to_string()));
836 let sha256 = sdist_table
837 .get("hash")
838 .and_then(TomlValue::as_str)
839 .and_then(strip_sha256_prefix);
840 if download_url.is_some() || sha256.is_some() {
841 return (download_url, sha256);
842 }
843 }
844
845 let wheel_table = package_table
846 .get("wheels")
847 .and_then(TomlValue::as_array)
848 .and_then(|wheels| wheels.first())
849 .and_then(TomlValue::as_table);
850
851 let download_url = wheel_table
852 .and_then(|table| table.get("url"))
853 .and_then(TomlValue::as_str)
854 .map(|value| truncate_field(value.to_string()));
855 let sha256 = wheel_table
856 .and_then(|table| table.get("hash"))
857 .and_then(TomlValue::as_str)
858 .and_then(strip_sha256_prefix);
859
860 (download_url, sha256)
861}
862
863fn strip_sha256_prefix(value: &str) -> Option<String> {
864 value.strip_prefix("sha256:").map(|hash| hash.to_string())
865}
866
867fn package_source_table(
868 package_table: &TomlMap<String, TomlValue>,
869) -> Option<&TomlMap<String, TomlValue>> {
870 package_table
871 .get(FIELD_SOURCE)
872 .and_then(TomlValue::as_table)
873}
874
875fn source_table_key(source_table: &TomlMap<String, TomlValue>) -> Option<String> {
876 ["registry", "editable", "virtual", "git"]
877 .into_iter()
878 .find_map(|key| {
879 source_table
880 .get(key)
881 .and_then(TomlValue::as_str)
882 .map(|value| format!("{}:{}", key, value))
883 })
884}
885
886fn build_pypi_urls(
887 name: Option<&str>,
888 version: Option<&str>,
889) -> (
890 Option<String>,
891 Option<String>,
892 Option<String>,
893 Option<String>,
894) {
895 let repository_homepage_url =
896 name.map(|value| truncate_field(format!("https://pypi.org/project/{}", value)));
897
898 let repository_download_url = name.and_then(|value| {
899 version.map(|ver| {
900 truncate_field(format!(
901 "https://pypi.org/packages/source/{}/{}/{}-{}.tar.gz",
902 &value[..1.min(value.len())],
903 value,
904 value,
905 ver
906 ))
907 })
908 });
909
910 let api_data_url = name.map(|value| {
911 if let Some(ver) = version {
912 truncate_field(format!("https://pypi.org/pypi/{}/{}/json", value, ver))
913 } else {
914 truncate_field(format!("https://pypi.org/pypi/{}/json", value))
915 }
916 });
917
918 let purl = name.and_then(|value| create_pypi_purl(value, version));
919
920 (
921 repository_homepage_url,
922 repository_download_url,
923 api_data_url,
924 purl,
925 )
926}
927
928fn normalize_pypi_name(name: &str) -> String {
929 truncate_field(name.trim().to_ascii_lowercase())
930}
931
932fn create_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
933 if name.contains('[') || name.contains(']') {
934 return Some(truncate_field(build_manual_pypi_purl(name, version)));
935 }
936
937 if let Ok(mut purl) = PackageUrl::new(UvLockParser::PACKAGE_TYPE.as_str(), name) {
938 if let Some(version) = version
939 && purl.with_version(version).is_err()
940 {
941 return None;
942 }
943 return Some(truncate_field(purl.to_string()));
944 }
945
946 Some(truncate_field(build_manual_pypi_purl(name, version)))
947}
948
949fn build_manual_pypi_purl(name: &str, version: Option<&str>) -> String {
950 let encoded_name = name.replace('[', "%5b").replace(']', "%5d");
951 let mut purl = format!("pkg:pypi/{}", encoded_name);
952 if let Some(version) = version
953 && !version.is_empty()
954 {
955 purl.push('@');
956 purl.push_str(version);
957 }
958 purl
959}
960
961fn toml_value_to_json(value: &TomlValue) -> JsonValue {
962 toml_value_to_json_recursive(value, &mut RecursionGuard::depth_only())
963}
964
965fn toml_value_to_json_recursive(value: &TomlValue, guard: &mut RecursionGuard<()>) -> JsonValue {
966 if guard.descend() {
967 warn!("toml_value_to_json exceeded recursion depth limit");
968 return JsonValue::Null;
969 }
970
971 let result = match value {
972 TomlValue::String(value) => JsonValue::String(value.clone()),
973 TomlValue::Integer(value) => JsonValue::String(value.to_string()),
974 TomlValue::Float(value) => JsonValue::String(value.to_string()),
975 TomlValue::Boolean(value) => JsonValue::Bool(*value),
976 TomlValue::Datetime(value) => JsonValue::String(value.to_string()),
977 TomlValue::Array(values) => JsonValue::Array(
978 values
979 .iter()
980 .map(|v| toml_value_to_json_recursive(v, guard))
981 .collect(),
982 ),
983 TomlValue::Table(values) => JsonValue::Object(
984 values
985 .iter()
986 .map(|(key, value)| (key.clone(), toml_value_to_json_recursive(value, guard)))
987 .collect(),
988 ),
989 };
990 guard.ascend();
991 result
992}
993
994fn default_package_data() -> PackageData {
995 PackageData {
996 package_type: Some(UvLockParser::PACKAGE_TYPE),
997 primary_language: Some("Python".to_string()),
998 datasource_id: Some(DatasourceId::PypiUvLock),
999 ..Default::default()
1000 }
1001}