Skip to main content

provenant/parsers/
uv_lock.rs

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