Skip to main content

provenant/parsers/
uv_lock.rs

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