Skip to main content

provenant/parsers/
nuget.rs

1//! Parser for NuGet package manifests and configuration files.
2//!
3//! Extracts package metadata and dependencies from .NET/NuGet ecosystem files:
4//! - packages.config (legacy .NET Framework format)
5//! - .nuspec (NuGet package specification)
6//! - packages.lock.json (NuGet lock file)
7//! - .nupkg (NuGet package archive — metadata extraction)
8//!
9//! # Supported Formats
10//! - packages.config (XML)
11//! - *.nuspec (XML)
12//! - packages.lock.json (JSON)
13//! - *.nupkg (ZIP archive with .nuspec inside)
14//!
15//! # Key Features
16//! - Dependency extraction with targetFramework support
17//! - Dependency groups by framework version
18//! - Package URL (purl) generation
19//!
20//! # Implementation Notes
21//! - Uses quick-xml for XML parsing
22//! - Graceful error handling with warn!()
23//! - No unwrap/expect in library code
24
25use std::collections::{HashMap, HashSet};
26use std::fs::File;
27use std::io::{BufReader, Read};
28use std::path::{Path, PathBuf};
29
30use crate::parser_warn as warn;
31use packageurl::PackageUrl;
32use quick_xml::Reader;
33use quick_xml::events::Event;
34
35use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
36
37use super::PackageParser;
38use super::license_normalization::{empty_declared_license_data, normalize_spdx_declared_license};
39
40const PROJECT_FILE_EXTENSIONS: [&str; 3] = ["csproj", "vbproj", "fsproj"];
41
42#[derive(Default)]
43struct RepositoryMetadata {
44    vcs_url: Option<String>,
45    branch: Option<String>,
46    commit: Option<String>,
47}
48
49fn build_nuget_party(role: &str, name: String) -> Party {
50    Party {
51        r#type: Some("person".to_string()),
52        role: Some(role.to_string()),
53        name: Some(name),
54        email: None,
55        url: None,
56        organization: None,
57        organization_url: None,
58        timezone: None,
59    }
60}
61
62fn insert_extra_string(
63    extra_data: &mut serde_json::Map<String, serde_json::Value>,
64    key: &str,
65    value: Option<String>,
66) {
67    if let Some(value) = value
68        .map(|v| v.trim().to_string())
69        .filter(|v| !v.is_empty())
70    {
71        extra_data.insert(key.to_string(), serde_json::Value::String(value));
72    }
73}
74
75fn parse_repository_metadata(element: &quick_xml::events::BytesStart) -> RepositoryMetadata {
76    let mut repo_type = None;
77    let mut repo_url = None;
78    let mut branch = None;
79    let mut commit = None;
80
81    for attr in element.attributes().filter_map(|a| a.ok()) {
82        match attr.key.as_ref() {
83            b"type" => repo_type = String::from_utf8(attr.value.to_vec()).ok(),
84            b"url" => repo_url = String::from_utf8(attr.value.to_vec()).ok(),
85            b"branch" => branch = String::from_utf8(attr.value.to_vec()).ok(),
86            b"commit" => commit = String::from_utf8(attr.value.to_vec()).ok(),
87            _ => {}
88        }
89    }
90
91    RepositoryMetadata {
92        vcs_url: repo_url.map(|url| match repo_type {
93            Some(vcs_type) if !vcs_type.trim().is_empty() => format!("{}+{}", vcs_type, url),
94            _ => url,
95        }),
96        branch,
97        commit,
98    }
99}
100
101fn build_nuget_urls(
102    name: Option<&str>,
103    version: Option<&str>,
104) -> (Option<String>, Option<String>, Option<String>) {
105    let repository_homepage_url = name.and_then(|name| {
106        version.map(|version| format!("https://www.nuget.org/packages/{}/{}", name, version))
107    });
108
109    let repository_download_url = name.and_then(|name| {
110        version.map(|version| format!("https://www.nuget.org/api/v2/package/{}/{}", name, version))
111    });
112
113    let api_data_url = name.and_then(|name| {
114        version.map(|version| {
115            format!(
116                "https://api.nuget.org/v3/registration3/{}/{}.json",
117                name.to_lowercase(),
118                version
119            )
120        })
121    });
122
123    (
124        repository_homepage_url,
125        repository_download_url,
126        api_data_url,
127    )
128}
129
130fn build_nuget_purl(name: Option<&str>, version: Option<&str>) -> Option<String> {
131    let name = name?;
132    let mut package_url = PackageUrl::new("nuget", name).ok()?;
133
134    if let Some(version) = version {
135        package_url.with_version(version).ok()?;
136    }
137
138    Some(package_url.to_string())
139}
140
141fn project_file_datasource_id(path: &Path) -> Option<DatasourceId> {
142    match path.extension().and_then(|ext| ext.to_str()) {
143        Some("csproj") => Some(DatasourceId::NugetCsproj),
144        Some("vbproj") => Some(DatasourceId::NugetVbproj),
145        Some("fsproj") => Some(DatasourceId::NugetFsproj),
146        _ => None,
147    }
148}
149
150fn build_nuget_description(
151    summary: Option<&str>,
152    description: Option<&str>,
153    title: Option<&str>,
154    name: Option<&str>,
155) -> Option<String> {
156    let summary = summary.map(|s| s.trim()).filter(|s| !s.is_empty());
157    let description = description.map(|s| s.trim()).filter(|s| !s.is_empty());
158    let title = title.map(|s| s.trim()).filter(|s| !s.is_empty());
159
160    let mut result = match (summary, description) {
161        (None, None) => return None,
162        (Some(s), None) => s.to_string(),
163        (None, Some(d)) => d.to_string(),
164        (Some(s), Some(d)) => {
165            if d.contains(s) {
166                d.to_string()
167            } else {
168                format!("{}\n{}", s, d)
169            }
170        }
171    };
172
173    if let Some(t) = title {
174        if let Some(n) = name {
175            if t != n {
176                result = format!("{}\n{}", t, result);
177            }
178        } else {
179            result = format!("{}\n{}", t, result);
180        }
181    }
182
183    Some(result)
184}
185
186/// Parser for packages.config (legacy .NET Framework format)
187pub struct PackagesConfigParser;
188
189impl PackageParser for PackagesConfigParser {
190    const PACKAGE_TYPE: PackageType = PackageType::Nuget;
191
192    fn is_match(path: &Path) -> bool {
193        path.file_name()
194            .and_then(|name| name.to_str())
195            .is_some_and(|name| name == "packages.config")
196    }
197
198    fn extract_packages(path: &Path) -> Vec<PackageData> {
199        let file = match File::open(path) {
200            Ok(f) => f,
201            Err(e) => {
202                warn!("Failed to open packages.config at {:?}: {}", path, e);
203                return vec![default_package_data(Some(
204                    DatasourceId::NugetPackagesConfig,
205                ))];
206            }
207        };
208
209        let reader = BufReader::new(file);
210        let mut xml_reader = Reader::from_reader(reader);
211        xml_reader.config_mut().trim_text(true);
212
213        let mut dependencies = Vec::new();
214        let mut buf = Vec::new();
215
216        loop {
217            match xml_reader.read_event_into(&mut buf) {
218                Ok(Event::Empty(e)) if e.name().as_ref() == b"package" => {
219                    if let Some(dep) = parse_packages_config_package(&e) {
220                        dependencies.push(dep);
221                    }
222                }
223                Ok(Event::Eof) => break,
224                Err(e) => {
225                    warn!("Error parsing packages.config at {:?}: {}", path, e);
226                    return vec![default_package_data(Some(
227                        DatasourceId::NugetPackagesConfig,
228                    ))];
229                }
230                _ => {}
231            }
232            buf.clear();
233        }
234
235        vec![PackageData {
236            datasource_id: Some(DatasourceId::NugetPackagesConfig),
237            package_type: Some(Self::PACKAGE_TYPE),
238            dependencies,
239            ..default_package_data(Some(DatasourceId::NugetPackagesConfig))
240        }]
241    }
242}
243
244/// Parser for .nuspec files (NuGet package specification)
245pub struct NuspecParser;
246
247impl PackageParser for NuspecParser {
248    const PACKAGE_TYPE: PackageType = PackageType::Nuget;
249
250    fn is_match(path: &Path) -> bool {
251        path.extension()
252            .and_then(|ext| ext.to_str())
253            .is_some_and(|ext| ext == "nuspec")
254    }
255
256    fn extract_packages(path: &Path) -> Vec<PackageData> {
257        let file = match File::open(path) {
258            Ok(f) => f,
259            Err(e) => {
260                warn!("Failed to open .nuspec at {:?}: {}", path, e);
261                return vec![default_package_data(Some(DatasourceId::NugetNuspec))];
262            }
263        };
264
265        let reader = BufReader::new(file);
266        let mut xml_reader = Reader::from_reader(reader);
267        xml_reader.config_mut().trim_text(true);
268
269        let mut name = None;
270        let mut version = None;
271        let mut summary = None;
272        let mut description = None;
273        let mut title = None;
274        let mut homepage_url = None;
275        let mut parties = Vec::new();
276        let mut dependencies = Vec::new();
277        let mut extracted_license_statement = None;
278        let mut license_type = None;
279        let mut copyright = None;
280        let mut vcs_url = None;
281        let mut repository_branch = None;
282        let mut repository_commit = None;
283
284        let mut buf = Vec::new();
285        let mut current_element = String::new();
286        let mut in_metadata = false;
287        let mut in_dependencies = false;
288        let mut current_group_framework = None;
289
290        loop {
291            match xml_reader.read_event_into(&mut buf) {
292                Ok(Event::Start(e)) => {
293                    let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
294                    current_element = tag_name.clone();
295
296                    if tag_name == "metadata" {
297                        in_metadata = true;
298                    } else if tag_name == "dependencies" && in_metadata {
299                        in_dependencies = true;
300                    } else if tag_name == "group" && in_dependencies {
301                        current_group_framework = e
302                            .attributes()
303                            .filter_map(|a| a.ok())
304                            .find(|attr| attr.key.as_ref() == b"targetFramework")
305                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
306                    } else if tag_name == "repository" && in_metadata {
307                        let repository = parse_repository_metadata(&e);
308                        vcs_url = repository.vcs_url;
309                        repository_branch = repository.branch;
310                        repository_commit = repository.commit;
311                    } else if tag_name == "license" && in_metadata {
312                        license_type = e
313                            .attributes()
314                            .filter_map(|a| a.ok())
315                            .find(|attr| attr.key.as_ref() == b"type")
316                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
317                    }
318                }
319                Ok(Event::Empty(e)) => {
320                    let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
321
322                    if tag_name == "dependency" && in_dependencies {
323                        if let Some(dep) =
324                            parse_nuspec_dependency(&e, current_group_framework.as_deref())
325                        {
326                            dependencies.push(dep);
327                        }
328                    } else if tag_name == "repository" && in_metadata {
329                        let repository = parse_repository_metadata(&e);
330                        vcs_url = repository.vcs_url;
331                        repository_branch = repository.branch;
332                        repository_commit = repository.commit;
333                    }
334                }
335                Ok(Event::Text(e)) => {
336                    if !in_metadata {
337                        continue;
338                    }
339
340                    let text = e.decode().ok().map(|s| s.trim().to_string());
341                    if let Some(text) = text.filter(|s| !s.is_empty()) {
342                        match current_element.as_str() {
343                            "id" => name = Some(text),
344                            "version" => version = Some(text),
345                            "summary" => summary = Some(text),
346                            "description" => description = Some(text),
347                            "title" => title = Some(text),
348                            "projectUrl" => homepage_url = Some(text),
349                            "authors" => {
350                                parties.push(build_nuget_party("author", text));
351                            }
352                            "owners" => {
353                                parties.push(build_nuget_party("owner", text));
354                            }
355                            "license" => {
356                                extracted_license_statement = Some(text);
357                            }
358                            "licenseUrl" => {
359                                if extracted_license_statement.is_none() {
360                                    extracted_license_statement = Some(text);
361                                }
362                            }
363                            "copyright" => copyright = Some(text),
364                            _ => {}
365                        }
366                    }
367                }
368                Ok(Event::End(e)) => {
369                    let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
370
371                    if tag_name == "metadata" {
372                        in_metadata = false;
373                    } else if tag_name == "dependencies" {
374                        in_dependencies = false;
375                    } else if tag_name == "group" {
376                        current_group_framework = None;
377                    }
378
379                    current_element.clear();
380                }
381                Ok(Event::Eof) => break,
382                Err(e) => {
383                    warn!("Error parsing .nuspec at {:?}: {}", path, e);
384                    return vec![default_package_data(Some(DatasourceId::NugetNuspec))];
385                }
386                _ => {}
387            }
388            buf.clear();
389        }
390
391        // Build description from summary, description, and title fields
392        // Following Python ScanCode's build_description logic
393        let final_description = build_nuget_description(
394            summary.as_deref(),
395            description.as_deref(),
396            title.as_deref(),
397            name.as_deref(),
398        );
399
400        let (repository_homepage_url, repository_download_url, api_data_url) =
401            build_nuget_urls(name.as_deref(), version.as_deref());
402
403        let purl = build_nuget_purl(name.as_deref(), version.as_deref());
404
405        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
406            if license_type.as_deref() == Some("expression") {
407                normalize_spdx_declared_license(extracted_license_statement.as_deref())
408            } else {
409                empty_declared_license_data()
410            };
411
412        let holder = None;
413
414        let mut extra_data = serde_json::Map::new();
415        insert_extra_string(&mut extra_data, "license_type", license_type.clone());
416        if license_type.as_deref() == Some("file") {
417            insert_extra_string(
418                &mut extra_data,
419                "license_file",
420                extracted_license_statement.clone(),
421            );
422        }
423        insert_extra_string(&mut extra_data, "repository_branch", repository_branch);
424        insert_extra_string(&mut extra_data, "repository_commit", repository_commit);
425
426        vec![PackageData {
427            datasource_id: Some(DatasourceId::NugetNuspec),
428            package_type: Some(Self::PACKAGE_TYPE),
429            name,
430            version,
431            purl,
432            description: final_description,
433            homepage_url,
434            parties,
435            dependencies,
436            declared_license_expression,
437            declared_license_expression_spdx,
438            license_detections,
439            extracted_license_statement,
440            copyright,
441            holder,
442            vcs_url,
443            extra_data: if extra_data.is_empty() {
444                None
445            } else {
446                Some(extra_data.into_iter().collect())
447            },
448            repository_homepage_url,
449            repository_download_url,
450            api_data_url,
451            ..default_package_data(Some(DatasourceId::NugetNuspec))
452        }]
453    }
454}
455
456fn parse_packages_config_package(element: &quick_xml::events::BytesStart) -> Option<Dependency> {
457    let mut id = None;
458    let mut version = None;
459    let mut target_framework = None;
460
461    for attr in element.attributes().filter_map(|a| a.ok()) {
462        match attr.key.as_ref() {
463            b"id" => id = String::from_utf8(attr.value.to_vec()).ok(),
464            b"version" => version = String::from_utf8(attr.value.to_vec()).ok(),
465            b"targetFramework" => target_framework = String::from_utf8(attr.value.to_vec()).ok(),
466            _ => {}
467        }
468    }
469
470    let name = id?;
471    let purl = PackageUrl::new("nuget", &name).ok().map(|p| p.to_string());
472
473    Some(Dependency {
474        purl,
475        extracted_requirement: version,
476        scope: target_framework,
477        is_runtime: Some(true),
478        is_optional: Some(false),
479        is_pinned: Some(true),
480        is_direct: Some(true),
481        resolved_package: None,
482        extra_data: None,
483    })
484}
485
486fn parse_nuspec_dependency(
487    element: &quick_xml::events::BytesStart,
488    framework: Option<&str>,
489) -> Option<Dependency> {
490    let mut id = None;
491    let mut version = None;
492    let mut include = None;
493    let mut exclude = None;
494
495    for attr in element.attributes().filter_map(|a| a.ok()) {
496        match attr.key.as_ref() {
497            b"id" => id = String::from_utf8(attr.value.to_vec()).ok(),
498            b"version" => version = String::from_utf8(attr.value.to_vec()).ok(),
499            b"include" => include = String::from_utf8(attr.value.to_vec()).ok(),
500            b"exclude" => exclude = String::from_utf8(attr.value.to_vec()).ok(),
501            _ => {}
502        }
503    }
504
505    let name = id?;
506    let purl = PackageUrl::new("nuget", &name).ok().map(|p| p.to_string());
507
508    let mut extra_data = serde_json::Map::new();
509    if let Some(fw) = framework {
510        extra_data.insert(
511            "framework".to_string(),
512            serde_json::Value::String(fw.to_string()),
513        );
514    }
515    if let Some(inc) = include {
516        extra_data.insert("include".to_string(), serde_json::Value::String(inc));
517    }
518    if let Some(exc) = exclude {
519        extra_data.insert("exclude".to_string(), serde_json::Value::String(exc));
520    }
521
522    Some(Dependency {
523        purl,
524        extracted_requirement: version,
525        scope: Some("dependency".to_string()),
526        is_runtime: Some(true),
527        is_optional: Some(false),
528        is_pinned: Some(false),
529        is_direct: Some(true),
530        resolved_package: None,
531        extra_data: if extra_data.is_empty() {
532            None
533        } else {
534            Some(extra_data.into_iter().collect())
535        },
536    })
537}
538
539fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
540    PackageData {
541        package_type: Some(PackagesConfigParser::PACKAGE_TYPE),
542        datasource_id,
543        ..Default::default()
544    }
545}
546
547const MAX_ARCHIVE_SIZE: u64 = 100 * 1024 * 1024; // 100MB
548const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024; // 50MB
549const MAX_COMPRESSION_RATIO: f64 = 100.0; // 100:1
550
551/// Parser for packages.lock.json (NuGet lock file)
552pub struct PackagesLockParser;
553
554impl PackageParser for PackagesLockParser {
555    const PACKAGE_TYPE: PackageType = PackageType::Nuget;
556
557    fn is_match(path: &Path) -> bool {
558        path.file_name()
559            .and_then(|name| name.to_str())
560            .is_some_and(|name| name.ends_with("packages.lock.json"))
561    }
562
563    fn extract_packages(path: &Path) -> Vec<PackageData> {
564        let file = match File::open(path) {
565            Ok(f) => f,
566            Err(e) => {
567                warn!("Failed to open packages.lock.json at {:?}: {}", path, e);
568                return vec![default_package_data(Some(DatasourceId::NugetPackagesLock))];
569            }
570        };
571
572        let parsed: serde_json::Value = match serde_json::from_reader(file) {
573            Ok(v) => v,
574            Err(e) => {
575                warn!("Failed to parse packages.lock.json at {:?}: {}", path, e);
576                return vec![default_package_data(Some(DatasourceId::NugetPackagesLock))];
577            }
578        };
579
580        let mut dependencies = Vec::new();
581
582        if let Some(deps_obj) = parsed.get("dependencies").and_then(|v| v.as_object()) {
583            for (target_framework, packages) in deps_obj {
584                if let Some(packages_obj) = packages.as_object() {
585                    for (package_name, package_info) in packages_obj {
586                        if let Some(info_obj) = package_info.as_object() {
587                            let version = info_obj
588                                .get("resolved")
589                                .and_then(|v| v.as_str())
590                                .map(|s| s.to_string());
591
592                            let requested = info_obj
593                                .get("requested")
594                                .and_then(|v| v.as_str())
595                                .map(|s| s.to_string());
596
597                            let package_type = info_obj.get("type").and_then(|v| v.as_str());
598
599                            let is_direct = match package_type {
600                                Some("Direct") => Some(true),
601                                Some("Transitive") => Some(false),
602                                _ => None,
603                            };
604
605                            let purl = version.as_ref().and_then(|v| {
606                                PackageUrl::new("nuget", package_name).ok().map(|mut p| {
607                                    let _ = p.with_version(v);
608                                    p.to_string()
609                                })
610                            });
611
612                            let mut extra_data = serde_json::Map::new();
613                            extra_data.insert(
614                                "target_framework".to_string(),
615                                serde_json::Value::String(target_framework.clone()),
616                            );
617
618                            if let Some(content_hash) =
619                                info_obj.get("contentHash").and_then(|v| v.as_str())
620                            {
621                                extra_data.insert(
622                                    "content_hash".to_string(),
623                                    serde_json::Value::String(content_hash.to_string()),
624                                );
625                            }
626
627                            dependencies.push(Dependency {
628                                purl,
629                                extracted_requirement: requested.or(version),
630                                scope: Some(target_framework.clone()),
631                                is_runtime: Some(true),
632                                is_optional: Some(false),
633                                is_pinned: Some(true),
634                                is_direct,
635                                resolved_package: None,
636                                extra_data: if extra_data.is_empty() {
637                                    None
638                                } else {
639                                    Some(extra_data.into_iter().collect())
640                                },
641                            });
642                        }
643                    }
644                }
645            }
646        }
647
648        vec![PackageData {
649            datasource_id: Some(DatasourceId::NugetPackagesLock),
650            package_type: Some(Self::PACKAGE_TYPE),
651            dependencies,
652            ..default_package_data(Some(DatasourceId::NugetPackagesLock))
653        }]
654    }
655}
656
657pub struct DotNetDepsJsonParser;
658
659impl PackageParser for DotNetDepsJsonParser {
660    const PACKAGE_TYPE: PackageType = PackageType::Nuget;
661
662    fn is_match(path: &Path) -> bool {
663        path.file_name()
664            .and_then(|name| name.to_str())
665            .is_some_and(|name| name.ends_with(".deps.json"))
666    }
667
668    fn extract_packages(path: &Path) -> Vec<PackageData> {
669        let file = match File::open(path) {
670            Ok(file) => file,
671            Err(e) => {
672                warn!("Failed to open .deps.json at {:?}: {}", path, e);
673                return vec![default_package_data(Some(DatasourceId::NugetDepsJson))];
674            }
675        };
676
677        let parsed: serde_json::Value = match serde_json::from_reader(file) {
678            Ok(value) => value,
679            Err(e) => {
680                warn!("Failed to parse .deps.json at {:?}: {}", path, e);
681                return vec![default_package_data(Some(DatasourceId::NugetDepsJson))];
682            }
683        };
684
685        vec![parse_dotnet_deps_json(&parsed, path)]
686    }
687}
688
689fn parse_dotnet_deps_json(parsed: &serde_json::Value, path: &Path) -> PackageData {
690    let Some(libraries) = parsed.get("libraries").and_then(|value| value.as_object()) else {
691        return default_package_data(Some(DatasourceId::NugetDepsJson));
692    };
693
694    let Some((selected_target_name, selected_target)) = select_deps_target(parsed) else {
695        return default_package_data(Some(DatasourceId::NugetDepsJson));
696    };
697
698    let root_key = select_root_library_key(path, libraries, &selected_target);
699    let root_dependencies = root_key
700        .as_deref()
701        .and_then(|root_key| selected_target.get(root_key))
702        .and_then(|value| value.get("dependencies"))
703        .and_then(|value| value.as_object())
704        .cloned()
705        .unwrap_or_default();
706
707    let mut dependencies = Vec::new();
708    for (library_key, target_entry) in &selected_target {
709        if root_key.as_deref() == Some(library_key.as_str()) {
710            continue;
711        }
712
713        let Some((name, version)) = split_library_key(library_key) else {
714            continue;
715        };
716        let Some(library_metadata) = libraries
717            .get(library_key)
718            .and_then(|value| value.as_object())
719        else {
720            continue;
721        };
722
723        let mut extra_data = serde_json::Map::new();
724        extra_data.insert(
725            "target_name".to_string(),
726            serde_json::Value::String(selected_target_name.clone()),
727        );
728
729        for field in [
730            "type",
731            "sha512",
732            "path",
733            "hashPath",
734            "runtimeStoreManifestName",
735        ] {
736            if let Some(value) = library_metadata.get(field) {
737                extra_data.insert(field.to_string(), value.clone());
738            }
739        }
740
741        if let Some(value) = library_metadata.get("serviceable") {
742            extra_data.insert("serviceable".to_string(), value.clone());
743        }
744
745        if let Some(object) = target_entry.as_object() {
746            for field in ["runtime", "native", "runtimeTargets", "resources"] {
747                if let Some(value) = object.get(field) {
748                    extra_data.insert(field.to_string(), value.clone());
749                }
750            }
751            if let Some(value) = object.get("compileOnly") {
752                extra_data.insert("compileOnly".to_string(), value.clone());
753            }
754        }
755
756        let is_direct = if root_key.is_some() {
757            Some(root_dependencies.contains_key(name))
758        } else {
759            None
760        };
761
762        let compile_only = target_entry
763            .get("compileOnly")
764            .and_then(|value| value.as_bool())
765            .unwrap_or(false);
766
767        dependencies.push(Dependency {
768            purl: build_nuget_purl(Some(name), Some(version)),
769            extracted_requirement: Some(version.to_string()),
770            scope: Some(selected_target_name.clone()),
771            is_runtime: Some(!compile_only),
772            is_optional: Some(compile_only),
773            is_pinned: Some(true),
774            is_direct,
775            resolved_package: None,
776            extra_data: if extra_data.is_empty() {
777                None
778            } else {
779                Some(extra_data.into_iter().collect())
780            },
781        });
782    }
783
784    let mut package_data = if let Some(root_key) = root_key {
785        let (name, version) = split_library_key(&root_key).unwrap_or(("", ""));
786        let mut package = default_package_data(Some(DatasourceId::NugetDepsJson));
787        package.name = (!name.is_empty()).then(|| name.to_string());
788        package.version = (!version.is_empty()).then(|| version.to_string());
789        package.purl = build_nuget_purl(package.name.as_deref(), package.version.as_deref());
790        let (repository_homepage_url, repository_download_url, api_data_url) =
791            build_nuget_urls(package.name.as_deref(), package.version.as_deref());
792        package.repository_homepage_url = repository_homepage_url;
793        package.repository_download_url = repository_download_url;
794        package.api_data_url = api_data_url;
795        package
796    } else {
797        let mut package = default_package_data(Some(DatasourceId::NugetDepsJson));
798        let file_stem = path
799            .file_name()
800            .and_then(|name| name.to_str())
801            .and_then(|name| name.strip_suffix(".deps.json"))
802            .filter(|name| !name.trim().is_empty())
803            .map(|name| name.to_string());
804        package.name = file_stem.clone();
805        package.purl = build_nuget_purl(file_stem.as_deref(), None);
806        package
807    };
808
809    let mut extra_data = serde_json::Map::new();
810    if let Some(runtime_target) = parsed
811        .get("runtimeTarget")
812        .and_then(|value| value.as_object())
813    {
814        if let Some(name) = runtime_target.get("name").and_then(|value| value.as_str()) {
815            extra_data.insert(
816                "runtime_target_name".to_string(),
817                serde_json::Value::String(name.to_string()),
818            );
819            if let Some((framework, runtime_identifier)) = name.split_once('/') {
820                extra_data.insert(
821                    "target_framework".to_string(),
822                    serde_json::Value::String(framework.to_string()),
823                );
824                extra_data.insert(
825                    "runtime_identifier".to_string(),
826                    serde_json::Value::String(runtime_identifier.to_string()),
827                );
828            } else {
829                extra_data.insert(
830                    "target_framework".to_string(),
831                    serde_json::Value::String(name.to_string()),
832                );
833            }
834        }
835        if let Some(signature) = runtime_target.get("signature") {
836            extra_data.insert("runtime_signature".to_string(), signature.clone());
837        }
838    } else {
839        extra_data.insert(
840            "target_name".to_string(),
841            serde_json::Value::String(selected_target_name.clone()),
842        );
843        if let Some((framework, runtime_identifier)) = selected_target_name.split_once('/') {
844            extra_data.insert(
845                "target_framework".to_string(),
846                serde_json::Value::String(framework.to_string()),
847            );
848            extra_data.insert(
849                "runtime_identifier".to_string(),
850                serde_json::Value::String(runtime_identifier.to_string()),
851            );
852        } else {
853            extra_data.insert(
854                "target_framework".to_string(),
855                serde_json::Value::String(selected_target_name.clone()),
856            );
857        }
858    }
859
860    package_data.dependencies = dependencies;
861    package_data.extra_data = if extra_data.is_empty() {
862        None
863    } else {
864        Some(extra_data.into_iter().collect())
865    };
866    package_data
867}
868
869fn select_deps_target(
870    parsed: &serde_json::Value,
871) -> Option<(String, serde_json::Map<String, serde_json::Value>)> {
872    let targets = parsed.get("targets")?.as_object()?;
873
874    if let Some(runtime_target_name) = parsed
875        .get("runtimeTarget")
876        .and_then(|value| value.get("name"))
877        .and_then(|value| value.as_str())
878        && let Some(target) = targets
879            .get(runtime_target_name)
880            .and_then(|value| value.as_object())
881    {
882        return Some((runtime_target_name.to_string(), target.clone()));
883    }
884
885    if let Some((name, value)) = targets
886        .iter()
887        .find(|(name, value)| name.contains('/') && value.is_object())
888        && let Some(target) = value.as_object()
889    {
890        return Some((name.clone(), target.clone()));
891    }
892
893    targets.iter().find_map(|(name, value)| {
894        value
895            .as_object()
896            .map(|target| (name.clone(), target.clone()))
897    })
898}
899
900fn select_root_library_key(
901    path: &Path,
902    libraries: &serde_json::Map<String, serde_json::Value>,
903    target: &serde_json::Map<String, serde_json::Value>,
904) -> Option<String> {
905    let base_name = path
906        .file_name()
907        .and_then(|name| name.to_str())
908        .and_then(|name| name.strip_suffix(".deps.json"));
909
910    let project_keys: Vec<String> = target
911        .keys()
912        .filter(|key| {
913            libraries
914                .get(*key)
915                .and_then(|value| value.get("type"))
916                .and_then(|value| value.as_str())
917                == Some("project")
918        })
919        .cloned()
920        .collect();
921
922    if let Some(base_name) = base_name
923        && let Some(matched) = project_keys.iter().find(|key| {
924            split_library_key(key)
925                .map(|(name, _)| name.eq_ignore_ascii_case(base_name))
926                .unwrap_or(false)
927        })
928    {
929        return Some(matched.clone());
930    }
931
932    project_keys.into_iter().next()
933}
934
935fn split_library_key(key: &str) -> Option<(&str, &str)> {
936    key.rsplit_once('/')
937}
938
939#[derive(Default)]
940struct ProjectReferenceData {
941    name: Option<String>,
942    version: Option<String>,
943    version_override: Option<String>,
944    condition: Option<String>,
945}
946
947#[derive(Default)]
948struct CentralPackagePropsData {
949    dependencies: Vec<Dependency>,
950    properties: HashMap<String, String>,
951    import_projects: Vec<String>,
952    manage_package_versions_centrally: Option<bool>,
953    central_package_transitive_pinning_enabled: Option<bool>,
954    central_package_version_override_enabled: Option<bool>,
955}
956
957pub struct ProjectJsonParser;
958
959impl PackageParser for ProjectJsonParser {
960    const PACKAGE_TYPE: PackageType = PackageType::Nuget;
961
962    fn is_match(path: &Path) -> bool {
963        path.file_name()
964            .and_then(|name| name.to_str())
965            .is_some_and(|name| name == "project.json")
966    }
967
968    fn extract_packages(path: &Path) -> Vec<PackageData> {
969        let file = match File::open(path) {
970            Ok(file) => file,
971            Err(e) => {
972                warn!("Failed to open project.json at {:?}: {}", path, e);
973                return vec![default_package_data(Some(DatasourceId::NugetProjectJson))];
974            }
975        };
976
977        let parsed: serde_json::Value = match serde_json::from_reader(file) {
978            Ok(value) => value,
979            Err(e) => {
980                warn!("Failed to parse project.json at {:?}: {}", path, e);
981                return vec![default_package_data(Some(DatasourceId::NugetProjectJson))];
982            }
983        };
984
985        vec![parse_project_json_manifest(&parsed)]
986    }
987}
988
989pub struct ProjectLockJsonParser;
990
991impl PackageParser for ProjectLockJsonParser {
992    const PACKAGE_TYPE: PackageType = PackageType::Nuget;
993
994    fn is_match(path: &Path) -> bool {
995        path.file_name()
996            .and_then(|name| name.to_str())
997            .is_some_and(|name| name == "project.lock.json")
998    }
999
1000    fn extract_packages(path: &Path) -> Vec<PackageData> {
1001        let file = match File::open(path) {
1002            Ok(file) => file,
1003            Err(e) => {
1004                warn!("Failed to open project.lock.json at {:?}: {}", path, e);
1005                return vec![default_package_data(Some(
1006                    DatasourceId::NugetProjectLockJson,
1007                ))];
1008            }
1009        };
1010
1011        let parsed: serde_json::Value = match serde_json::from_reader(file) {
1012            Ok(value) => value,
1013            Err(e) => {
1014                warn!("Failed to parse project.lock.json at {:?}: {}", path, e);
1015                return vec![default_package_data(Some(
1016                    DatasourceId::NugetProjectLockJson,
1017                ))];
1018            }
1019        };
1020
1021        vec![parse_project_lock_manifest(&parsed)]
1022    }
1023}
1024
1025pub struct PackageReferenceProjectParser;
1026
1027impl PackageParser for PackageReferenceProjectParser {
1028    const PACKAGE_TYPE: PackageType = PackageType::Nuget;
1029
1030    fn is_match(path: &Path) -> bool {
1031        path.extension()
1032            .and_then(|ext| ext.to_str())
1033            .is_some_and(|ext| PROJECT_FILE_EXTENSIONS.contains(&ext))
1034    }
1035
1036    fn extract_packages(path: &Path) -> Vec<PackageData> {
1037        let Some(datasource_id) = project_file_datasource_id(path) else {
1038            return vec![default_package_data(None)];
1039        };
1040
1041        let file = match File::open(path) {
1042            Ok(file) => file,
1043            Err(e) => {
1044                warn!("Failed to open project file at {:?}: {}", path, e);
1045                return vec![default_package_data(Some(datasource_id))];
1046            }
1047        };
1048
1049        let reader = BufReader::new(file);
1050        let mut xml_reader = Reader::from_reader(reader);
1051        xml_reader.config_mut().trim_text(true);
1052
1053        let mut name = None;
1054        let mut fallback_name = path
1055            .file_stem()
1056            .and_then(|stem| stem.to_str())
1057            .map(|stem| stem.to_string());
1058        let mut version = None;
1059        let mut description = None;
1060        let mut homepage_url = None;
1061        let mut authors = None;
1062        let mut repository_url = None;
1063        let mut repository_type = None;
1064        let mut repository_branch = None;
1065        let mut repository_commit = None;
1066        let mut extracted_license_statement = None;
1067        let mut license_type = None;
1068        let mut copyright = None;
1069        let mut readme_file = None;
1070        let mut icon_file = None;
1071        let mut package_references = Vec::new();
1072        let mut project_properties = HashMap::new();
1073
1074        let mut buf = Vec::new();
1075        let mut current_element = String::new();
1076        let mut in_property_group = false;
1077        let mut current_property_group_condition = None;
1078        let mut current_item_group_condition = None;
1079        let mut current_package_reference: Option<ProjectReferenceData> = None;
1080
1081        loop {
1082            match xml_reader.read_event_into(&mut buf) {
1083                Ok(Event::Start(e)) => {
1084                    let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
1085                    current_element = tag_name.clone();
1086
1087                    match tag_name.as_str() {
1088                        "PropertyGroup" => {
1089                            in_property_group = true;
1090                            current_property_group_condition = e
1091                                .attributes()
1092                                .filter_map(|a| a.ok())
1093                                .find(|attr| attr.key.as_ref() == b"Condition")
1094                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
1095                        }
1096                        "ItemGroup" => {
1097                            current_item_group_condition = e
1098                                .attributes()
1099                                .filter_map(|a| a.ok())
1100                                .find(|attr| attr.key.as_ref() == b"Condition")
1101                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
1102                        }
1103                        "PackageReference" => {
1104                            let name = e
1105                                .attributes()
1106                                .filter_map(|a| a.ok())
1107                                .find(|attr| matches!(attr.key.as_ref(), b"Include" | b"Update"))
1108                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
1109                            let version = e
1110                                .attributes()
1111                                .filter_map(|a| a.ok())
1112                                .find(|attr| attr.key.as_ref() == b"Version")
1113                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
1114                            let version_override = e
1115                                .attributes()
1116                                .filter_map(|a| a.ok())
1117                                .find(|attr| attr.key.as_ref() == b"VersionOverride")
1118                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
1119                            let condition = e
1120                                .attributes()
1121                                .filter_map(|a| a.ok())
1122                                .find(|attr| attr.key.as_ref() == b"Condition")
1123                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok())
1124                                .or_else(|| current_item_group_condition.clone());
1125
1126                            current_package_reference = Some(ProjectReferenceData {
1127                                name,
1128                                version,
1129                                version_override,
1130                                condition,
1131                            });
1132                        }
1133                        _ => {}
1134                    }
1135                }
1136                Ok(Event::Empty(e)) => {
1137                    let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
1138
1139                    if tag_name == "PackageReference" {
1140                        let name = e
1141                            .attributes()
1142                            .filter_map(|a| a.ok())
1143                            .find(|attr| matches!(attr.key.as_ref(), b"Include" | b"Update"))
1144                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
1145                        let version = e
1146                            .attributes()
1147                            .filter_map(|a| a.ok())
1148                            .find(|attr| attr.key.as_ref() == b"Version")
1149                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
1150                        let version_override = e
1151                            .attributes()
1152                            .filter_map(|a| a.ok())
1153                            .find(|attr| attr.key.as_ref() == b"VersionOverride")
1154                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
1155                        let condition = e
1156                            .attributes()
1157                            .filter_map(|a| a.ok())
1158                            .find(|attr| attr.key.as_ref() == b"Condition")
1159                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok())
1160                            .or_else(|| current_item_group_condition.clone());
1161
1162                        package_references.push(ProjectReferenceData {
1163                            name,
1164                            version,
1165                            version_override,
1166                            condition,
1167                        });
1168                    }
1169                }
1170                Ok(Event::Text(e)) => {
1171                    let text = e.decode().ok().map(|s| s.trim().to_string());
1172                    let Some(text) = text.filter(|value| !value.is_empty()) else {
1173                        buf.clear();
1174                        continue;
1175                    };
1176
1177                    if current_package_reference.is_some() {
1178                        if current_element.as_str() == "Version"
1179                            && let Some(reference) = &mut current_package_reference
1180                        {
1181                            reference.version = Some(text);
1182                        } else if current_element.as_str() == "VersionOverride"
1183                            && let Some(reference) = &mut current_package_reference
1184                        {
1185                            reference.version_override = Some(text);
1186                        }
1187                    } else if in_property_group && current_property_group_condition.is_none() {
1188                        project_properties.insert(current_element.clone(), text.clone());
1189                        match current_element.as_str() {
1190                            "PackageId" => name = Some(text),
1191                            "AssemblyName" if fallback_name.is_none() => fallback_name = Some(text),
1192                            "Version" if version.is_none() => version = Some(text),
1193                            "PackageVersion" => version = Some(text),
1194                            "Description" => description = Some(text),
1195                            "PackageProjectUrl" | "ProjectUrl" => homepage_url = Some(text),
1196                            "Authors" => authors = Some(text),
1197                            "RepositoryUrl" => repository_url = Some(text),
1198                            "RepositoryType" => repository_type = Some(text),
1199                            "RepositoryBranch" => repository_branch = Some(text),
1200                            "RepositoryCommit" => repository_commit = Some(text),
1201                            "PackageLicenseExpression" => {
1202                                extracted_license_statement = Some(text);
1203                                license_type = Some("expression".to_string());
1204                            }
1205                            "PackageLicenseFile" => {
1206                                extracted_license_statement = Some(text);
1207                                license_type = Some("file".to_string());
1208                            }
1209                            "PackageReadmeFile" => readme_file = Some(text),
1210                            "PackageIcon" => icon_file = Some(text),
1211                            "Copyright" => copyright = Some(text),
1212                            _ => {}
1213                        }
1214                    }
1215                }
1216                Ok(Event::End(e)) => {
1217                    let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
1218
1219                    match tag_name.as_str() {
1220                        "PropertyGroup" => {
1221                            in_property_group = false;
1222                            current_property_group_condition = None;
1223                        }
1224                        "ItemGroup" => current_item_group_condition = None,
1225                        "PackageReference" => {
1226                            if let Some(reference) = current_package_reference.take() {
1227                                package_references.push(reference);
1228                            }
1229                        }
1230                        _ => {}
1231                    }
1232
1233                    current_element.clear();
1234                }
1235                Ok(Event::Eof) => break,
1236                Err(e) => {
1237                    warn!("Error parsing project file at {:?}: {}", path, e);
1238                    return vec![default_package_data(Some(datasource_id))];
1239                }
1240                _ => {}
1241            }
1242
1243            buf.clear();
1244        }
1245
1246        let name = name.or(fallback_name);
1247        let vcs_url = repository_url.map(|url| match repository_type {
1248            Some(repo_type) if !repo_type.trim().is_empty() => format!("{}+{}", repo_type, url),
1249            _ => url,
1250        });
1251        let dependencies = package_references
1252            .into_iter()
1253            .filter_map(|reference| {
1254                build_project_file_dependency(
1255                    reference.name,
1256                    reference.version,
1257                    reference.version_override,
1258                    reference.condition,
1259                    &project_properties,
1260                )
1261            })
1262            .collect::<Vec<_>>();
1263        let (repository_homepage_url, repository_download_url, api_data_url) =
1264            build_nuget_urls(name.as_deref(), version.as_deref());
1265
1266        let mut parties = Vec::new();
1267        if let Some(authors) = authors {
1268            parties.push(build_nuget_party("author", authors));
1269        }
1270
1271        let mut extra_data = serde_json::Map::new();
1272        insert_extra_string(&mut extra_data, "license_type", license_type.clone());
1273        if license_type.as_deref() == Some("file") {
1274            insert_extra_string(
1275                &mut extra_data,
1276                "license_file",
1277                extracted_license_statement.clone(),
1278            );
1279        }
1280        insert_extra_string(&mut extra_data, "repository_branch", repository_branch);
1281        insert_extra_string(&mut extra_data, "repository_commit", repository_commit);
1282        insert_extra_string(&mut extra_data, "readme_file", readme_file);
1283        insert_extra_string(&mut extra_data, "icon_file", icon_file);
1284        if let Some(value) = project_properties
1285            .get("CentralPackageVersionOverrideEnabled")
1286            .cloned()
1287        {
1288            extra_data.insert(
1289                "central_package_version_override_enabled_raw".to_string(),
1290                serde_json::Value::String(value),
1291            );
1292        }
1293        if let Some(value) = resolve_bool_property_reference(
1294            project_properties
1295                .get("CentralPackageVersionOverrideEnabled")
1296                .map(String::as_str),
1297            &project_properties,
1298        ) {
1299            extra_data.insert(
1300                "central_package_version_override_enabled".to_string(),
1301                serde_json::Value::Bool(value),
1302            );
1303        }
1304
1305        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
1306            if license_type.as_deref() == Some("expression") {
1307                normalize_spdx_declared_license(extracted_license_statement.as_deref())
1308            } else {
1309                empty_declared_license_data()
1310            };
1311
1312        vec![PackageData {
1313            datasource_id: Some(datasource_id),
1314            package_type: Some(Self::PACKAGE_TYPE),
1315            name: name.clone(),
1316            version: version.clone(),
1317            purl: build_nuget_purl(name.as_deref(), version.as_deref()),
1318            description,
1319            homepage_url,
1320            parties,
1321            dependencies,
1322            declared_license_expression,
1323            declared_license_expression_spdx,
1324            license_detections,
1325            extracted_license_statement,
1326            copyright,
1327            vcs_url,
1328            extra_data: if extra_data.is_empty() {
1329                None
1330            } else {
1331                Some(extra_data.into_iter().collect())
1332            },
1333            repository_homepage_url,
1334            repository_download_url,
1335            api_data_url,
1336            ..default_package_data(Some(datasource_id))
1337        }]
1338    }
1339}
1340
1341fn parse_project_json_manifest(parsed: &serde_json::Value) -> PackageData {
1342    let name = parsed
1343        .get("name")
1344        .and_then(|value| value.as_str())
1345        .map(|value| value.to_string());
1346    let version = parsed
1347        .get("version")
1348        .and_then(|value| value.as_str())
1349        .map(|value| value.to_string());
1350    let description = parsed
1351        .get("description")
1352        .and_then(|value| value.as_str())
1353        .map(|value| value.to_string());
1354    let homepage_url = parsed
1355        .get("projectUrl")
1356        .and_then(|value| value.as_str())
1357        .map(|value| value.to_string());
1358    let extracted_license_statement = parsed
1359        .get("license")
1360        .or_else(|| parsed.get("licenseUrl"))
1361        .and_then(|value| value.as_str())
1362        .map(|value| value.to_string());
1363
1364    let mut parties = Vec::new();
1365    if let Some(authors) = parsed.get("authors") {
1366        let author_name = if let Some(value) = authors.as_str() {
1367            Some(value.to_string())
1368        } else {
1369            authors.as_array().map(|entries| {
1370                entries
1371                    .iter()
1372                    .filter_map(|entry| entry.as_str())
1373                    .collect::<Vec<_>>()
1374                    .join(", ")
1375            })
1376        };
1377
1378        if let Some(author_name) = author_name.filter(|value| !value.is_empty()) {
1379            parties.push(build_nuget_party("author", author_name));
1380        }
1381    }
1382
1383    let mut dependencies = Vec::new();
1384
1385    if let Some(root_dependencies) = parsed
1386        .get("dependencies")
1387        .and_then(|value| value.as_object())
1388    {
1389        for (dependency_name, dependency_spec) in root_dependencies {
1390            if let Some(dependency) =
1391                parse_project_json_dependency(dependency_name, dependency_spec, None)
1392            {
1393                dependencies.push(dependency);
1394            }
1395        }
1396    }
1397
1398    if let Some(frameworks) = parsed.get("frameworks").and_then(|value| value.as_object()) {
1399        for (framework, framework_value) in frameworks {
1400            let Some(framework_dependencies) = framework_value
1401                .get("dependencies")
1402                .and_then(|value| value.as_object())
1403            else {
1404                continue;
1405            };
1406
1407            for (dependency_name, dependency_spec) in framework_dependencies {
1408                if let Some(dependency) = parse_project_json_dependency(
1409                    dependency_name,
1410                    dependency_spec,
1411                    Some(framework.clone()),
1412                ) {
1413                    dependencies.push(dependency);
1414                }
1415            }
1416        }
1417    }
1418
1419    let (repository_homepage_url, repository_download_url, api_data_url) =
1420        build_nuget_urls(name.as_deref(), version.as_deref());
1421
1422    PackageData {
1423        datasource_id: Some(DatasourceId::NugetProjectJson),
1424        package_type: Some(PackageType::Nuget),
1425        name: name.clone(),
1426        version: version.clone(),
1427        purl: build_nuget_purl(name.as_deref(), version.as_deref()),
1428        description,
1429        homepage_url,
1430        parties,
1431        dependencies,
1432        extracted_license_statement,
1433        repository_homepage_url,
1434        repository_download_url,
1435        api_data_url,
1436        ..default_package_data(Some(DatasourceId::NugetProjectJson))
1437    }
1438}
1439
1440fn parse_project_json_dependency(
1441    dependency_name: &str,
1442    dependency_spec: &serde_json::Value,
1443    scope: Option<String>,
1444) -> Option<Dependency> {
1445    let mut extra_data = serde_json::Map::new();
1446
1447    let requirement = match dependency_spec {
1448        serde_json::Value::String(version) => Some(version.clone()),
1449        serde_json::Value::Object(object) => {
1450            let requirement = object
1451                .get("version")
1452                .and_then(|value| value.as_str())
1453                .map(|value| value.to_string());
1454            insert_extra_string(
1455                &mut extra_data,
1456                "include",
1457                object
1458                    .get("include")
1459                    .and_then(|value| value.as_str())
1460                    .map(|value| value.to_string()),
1461            );
1462            insert_extra_string(
1463                &mut extra_data,
1464                "exclude",
1465                object
1466                    .get("exclude")
1467                    .and_then(|value| value.as_str())
1468                    .map(|value| value.to_string()),
1469            );
1470            insert_extra_string(
1471                &mut extra_data,
1472                "type",
1473                object
1474                    .get("type")
1475                    .and_then(|value| value.as_str())
1476                    .map(|value| value.to_string()),
1477            );
1478            requirement
1479        }
1480        _ => return None,
1481    };
1482
1483    Some(Dependency {
1484        purl: build_nuget_purl(Some(dependency_name), None),
1485        extracted_requirement: requirement,
1486        scope,
1487        is_runtime: Some(true),
1488        is_optional: Some(false),
1489        is_pinned: Some(false),
1490        is_direct: Some(true),
1491        resolved_package: None,
1492        extra_data: if extra_data.is_empty() {
1493            None
1494        } else {
1495            Some(extra_data.into_iter().collect())
1496        },
1497    })
1498}
1499
1500fn parse_project_lock_manifest(parsed: &serde_json::Value) -> PackageData {
1501    let mut dependencies = Vec::new();
1502
1503    if let Some(groups) = parsed
1504        .get("projectFileDependencyGroups")
1505        .and_then(|value| value.as_object())
1506    {
1507        for (framework, entries) in groups {
1508            let Some(entries) = entries.as_array() else {
1509                continue;
1510            };
1511
1512            for entry in entries.iter().filter_map(|value| value.as_str()) {
1513                if let Some(dependency) = parse_project_lock_dependency(
1514                    entry,
1515                    (!framework.is_empty()).then(|| framework.clone()),
1516                ) {
1517                    dependencies.push(dependency);
1518                }
1519            }
1520        }
1521    }
1522
1523    PackageData {
1524        datasource_id: Some(DatasourceId::NugetProjectLockJson),
1525        package_type: Some(PackageType::Nuget),
1526        dependencies,
1527        ..default_package_data(Some(DatasourceId::NugetProjectLockJson))
1528    }
1529}
1530
1531fn parse_project_lock_dependency(entry: &str, scope: Option<String>) -> Option<Dependency> {
1532    let trimmed = entry.trim();
1533    if trimmed.is_empty() {
1534        return None;
1535    }
1536
1537    let mut parts = trimmed.split_whitespace();
1538    let name = parts.next()?;
1539    let requirement = parts.collect::<Vec<_>>().join(" ");
1540
1541    Some(Dependency {
1542        purl: build_nuget_purl(Some(name), None),
1543        extracted_requirement: (!requirement.is_empty()).then_some(requirement),
1544        scope,
1545        is_runtime: Some(true),
1546        is_optional: Some(false),
1547        is_pinned: Some(false),
1548        is_direct: Some(true),
1549        resolved_package: None,
1550        extra_data: None,
1551    })
1552}
1553
1554fn build_project_file_dependency(
1555    name: Option<String>,
1556    version: Option<String>,
1557    version_override: Option<String>,
1558    condition: Option<String>,
1559    project_properties: &HashMap<String, String>,
1560) -> Option<Dependency> {
1561    let name = name?.trim().to_string();
1562    if name.is_empty() {
1563        return None;
1564    }
1565
1566    let mut extra_data = serde_json::Map::new();
1567    insert_extra_string(&mut extra_data, "condition", condition);
1568    insert_extra_string(
1569        &mut extra_data,
1570        "version_override",
1571        version_override.clone(),
1572    );
1573    insert_extra_string(
1574        &mut extra_data,
1575        "version_override_resolved",
1576        version_override
1577            .as_deref()
1578            .and_then(|value| resolve_string_property_reference(value, project_properties)),
1579    );
1580
1581    Some(Dependency {
1582        purl: build_nuget_purl(Some(&name), None),
1583        extracted_requirement: version,
1584        scope: None,
1585        is_runtime: Some(true),
1586        is_optional: Some(false),
1587        is_pinned: Some(false),
1588        is_direct: Some(true),
1589        resolved_package: None,
1590        extra_data: if extra_data.is_empty() {
1591            None
1592        } else {
1593            Some(extra_data.into_iter().collect())
1594        },
1595    })
1596}
1597
1598#[derive(Default)]
1599struct CentralPackageVersionData {
1600    name: Option<String>,
1601    version: Option<String>,
1602    condition: Option<String>,
1603}
1604
1605#[derive(Default)]
1606struct RawCentralPackagePropsData {
1607    package_versions: Vec<CentralPackageVersionData>,
1608    property_values: HashMap<String, String>,
1609    import_projects: Vec<String>,
1610    manage_package_versions_centrally: Option<String>,
1611    central_package_transitive_pinning_enabled: Option<String>,
1612    central_package_version_override_enabled: Option<String>,
1613}
1614
1615#[derive(Default)]
1616struct RawBuildPropsData {
1617    property_values: HashMap<String, String>,
1618    import_projects: Vec<String>,
1619    manage_package_versions_centrally: Option<String>,
1620    central_package_transitive_pinning_enabled: Option<String>,
1621    central_package_version_override_enabled: Option<String>,
1622}
1623
1624#[derive(Default)]
1625struct BuildPropsData {
1626    property_values: HashMap<String, String>,
1627    import_projects: Vec<String>,
1628    manage_package_versions_centrally: Option<bool>,
1629    central_package_transitive_pinning_enabled: Option<bool>,
1630    central_package_version_override_enabled: Option<bool>,
1631}
1632
1633fn build_directory_packages_dependency(
1634    name: Option<String>,
1635    version: Option<String>,
1636    raw_version: Option<String>,
1637    condition: Option<String>,
1638) -> Option<Dependency> {
1639    let name = name?.trim().to_string();
1640    if name.is_empty() {
1641        return None;
1642    }
1643    let version = version
1644        .map(|value| value.trim().to_string())
1645        .filter(|value| !value.is_empty())?;
1646
1647    let mut extra_data = serde_json::Map::new();
1648    insert_extra_string(&mut extra_data, "condition", condition);
1649    insert_extra_string(&mut extra_data, "version_expression", raw_version);
1650
1651    Some(Dependency {
1652        purl: build_nuget_purl(Some(&name), None),
1653        extracted_requirement: Some(version),
1654        scope: Some("package_version".to_string()),
1655        is_runtime: Some(true),
1656        is_optional: Some(false),
1657        is_pinned: Some(false),
1658        is_direct: Some(true),
1659        resolved_package: None,
1660        extra_data: if extra_data.is_empty() {
1661            None
1662        } else {
1663            Some(extra_data.into_iter().collect())
1664        },
1665    })
1666}
1667
1668fn resolve_directory_packages_props(
1669    path: &Path,
1670    visited: &mut HashSet<PathBuf>,
1671) -> Result<CentralPackagePropsData, String> {
1672    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
1673    if !visited.insert(canonical.clone()) {
1674        return Ok(CentralPackagePropsData::default());
1675    }
1676
1677    let raw = parse_directory_packages_props_file(path)?;
1678    let mut merged = CentralPackagePropsData::default();
1679
1680    for import_project in &raw.import_projects {
1681        let Some(import_path) =
1682            resolve_import_project_for_directory_packages(path, import_project, &HashMap::new())
1683        else {
1684            continue;
1685        };
1686        let imported = resolve_directory_packages_props(&import_path, visited)?;
1687        merge_central_package_props(&mut merged, imported);
1688    }
1689
1690    merged.import_projects.extend(raw.import_projects.clone());
1691    merged.properties.extend(raw.property_values.clone());
1692
1693    if let Some(value) = resolve_bool_property_reference(
1694        raw.manage_package_versions_centrally.as_deref(),
1695        &merged.properties,
1696    ) {
1697        merged.manage_package_versions_centrally = Some(value);
1698    }
1699    if let Some(value) = resolve_bool_property_reference(
1700        raw.central_package_transitive_pinning_enabled.as_deref(),
1701        &merged.properties,
1702    ) {
1703        merged.central_package_transitive_pinning_enabled = Some(value);
1704    }
1705    if let Some(value) = resolve_bool_property_reference(
1706        raw.central_package_version_override_enabled.as_deref(),
1707        &merged.properties,
1708    ) {
1709        merged.central_package_version_override_enabled = Some(value);
1710    }
1711
1712    for entry in raw.package_versions {
1713        let resolved_version =
1714            resolve_optional_property_value(entry.version.as_deref(), &merged.properties);
1715        if let Some(dependency) = build_directory_packages_dependency(
1716            entry.name,
1717            resolved_version,
1718            entry.version,
1719            entry.condition,
1720        ) {
1721            replace_matching_dependency_group(
1722                &mut merged.dependencies,
1723                std::slice::from_ref(&dependency),
1724            );
1725            merged.dependencies.push(dependency);
1726        }
1727    }
1728
1729    Ok(merged)
1730}
1731
1732fn resolve_directory_build_props(
1733    path: &Path,
1734    visited: &mut HashSet<PathBuf>,
1735) -> Result<BuildPropsData, String> {
1736    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
1737    if !visited.insert(canonical.clone()) {
1738        return Ok(BuildPropsData::default());
1739    }
1740
1741    let raw = parse_directory_build_props_file(path)?;
1742    let mut merged = BuildPropsData::default();
1743
1744    for import_project in &raw.import_projects {
1745        let Some(import_path) =
1746            resolve_import_project_for_directory_build(path, import_project, &HashMap::new())
1747        else {
1748            continue;
1749        };
1750        let imported = resolve_directory_build_props(&import_path, visited)?;
1751        merge_build_props_data(&mut merged, imported);
1752    }
1753
1754    merged.import_projects.extend(raw.import_projects.clone());
1755    merged.property_values.extend(raw.property_values.clone());
1756
1757    if let Some(value) = resolve_bool_property_reference(
1758        raw.manage_package_versions_centrally.as_deref(),
1759        &merged.property_values,
1760    ) {
1761        merged.manage_package_versions_centrally = Some(value);
1762    }
1763    if let Some(value) = resolve_bool_property_reference(
1764        raw.central_package_transitive_pinning_enabled.as_deref(),
1765        &merged.property_values,
1766    ) {
1767        merged.central_package_transitive_pinning_enabled = Some(value);
1768    }
1769    if let Some(value) = resolve_bool_property_reference(
1770        raw.central_package_version_override_enabled.as_deref(),
1771        &merged.property_values,
1772    ) {
1773        merged.central_package_version_override_enabled = Some(value);
1774    }
1775
1776    Ok(merged)
1777}
1778
1779fn parse_directory_packages_props_file(path: &Path) -> Result<RawCentralPackagePropsData, String> {
1780    let file = File::open(path).map_err(|e| {
1781        format!(
1782            "Failed to open Directory.Packages.props at {:?}: {}",
1783            path, e
1784        )
1785    })?;
1786
1787    let reader = BufReader::new(file);
1788    let mut xml_reader = Reader::from_reader(reader);
1789    xml_reader.config_mut().trim_text(true);
1790
1791    let mut raw = RawCentralPackagePropsData::default();
1792    let mut buf = Vec::new();
1793    let mut current_element = String::new();
1794    let mut current_property_group_condition = None;
1795    let mut current_item_group_condition = None;
1796    let mut current_package_version: Option<CentralPackageVersionData> = None;
1797
1798    loop {
1799        match xml_reader.read_event_into(&mut buf) {
1800            Ok(Event::Start(e)) => {
1801                let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
1802                current_element = tag_name.clone();
1803
1804                match tag_name.as_str() {
1805                    "ItemGroup" => {
1806                        current_item_group_condition = e
1807                            .attributes()
1808                            .filter_map(|a| a.ok())
1809                            .find(|attr| attr.key.as_ref() == b"Condition")
1810                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
1811                    }
1812                    "PackageVersion" => {
1813                        let name = e
1814                            .attributes()
1815                            .filter_map(|a| a.ok())
1816                            .find(|attr| matches!(attr.key.as_ref(), b"Include" | b"Update"))
1817                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
1818                        let version = e
1819                            .attributes()
1820                            .filter_map(|a| a.ok())
1821                            .find(|attr| attr.key.as_ref() == b"Version")
1822                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
1823                        let condition = e
1824                            .attributes()
1825                            .filter_map(|a| a.ok())
1826                            .find(|attr| attr.key.as_ref() == b"Condition")
1827                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok())
1828                            .or_else(|| current_item_group_condition.clone());
1829
1830                        current_package_version = Some(CentralPackageVersionData {
1831                            name,
1832                            version,
1833                            condition,
1834                        });
1835                    }
1836                    "PropertyGroup" => {
1837                        current_property_group_condition = e
1838                            .attributes()
1839                            .filter_map(|a| a.ok())
1840                            .find(|attr| attr.key.as_ref() == b"Condition")
1841                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
1842                    }
1843                    _ => {}
1844                }
1845            }
1846            Ok(Event::Empty(e)) => {
1847                let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
1848                if tag_name == "PackageVersion" {
1849                    let name = e
1850                        .attributes()
1851                        .filter_map(|a| a.ok())
1852                        .find(|attr| matches!(attr.key.as_ref(), b"Include" | b"Update"))
1853                        .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
1854                    let version = e
1855                        .attributes()
1856                        .filter_map(|a| a.ok())
1857                        .find(|attr| attr.key.as_ref() == b"Version")
1858                        .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
1859                    let condition = e
1860                        .attributes()
1861                        .filter_map(|a| a.ok())
1862                        .find(|attr| attr.key.as_ref() == b"Condition")
1863                        .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok())
1864                        .or_else(|| current_item_group_condition.clone());
1865
1866                    raw.package_versions.push(CentralPackageVersionData {
1867                        name,
1868                        version,
1869                        condition,
1870                    });
1871                } else if tag_name == "Import"
1872                    && let Some(project) = e
1873                        .attributes()
1874                        .filter_map(|a| a.ok())
1875                        .find(|attr| attr.key.as_ref() == b"Project")
1876                        .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok())
1877                    && !e
1878                        .attributes()
1879                        .filter_map(|a| a.ok())
1880                        .any(|attr| attr.key.as_ref() == b"Condition")
1881                    && is_supported_directory_packages_import(&project)
1882                {
1883                    raw.import_projects.push(project.trim().to_string());
1884                }
1885            }
1886            Ok(Event::Text(e)) => {
1887                let text = e.decode().ok().map(|s| s.trim().to_string());
1888                let Some(text) = text.filter(|value| !value.is_empty()) else {
1889                    buf.clear();
1890                    continue;
1891                };
1892
1893                if current_package_version.is_some() {
1894                    if current_element.as_str() == "Version"
1895                        && let Some(entry) = &mut current_package_version
1896                    {
1897                        entry.version = Some(text);
1898                    }
1899                } else if current_property_group_condition.is_none() {
1900                    raw.property_values
1901                        .insert(current_element.clone(), text.clone());
1902                    match current_element.as_str() {
1903                        "ManagePackageVersionsCentrally" => {
1904                            raw.manage_package_versions_centrally = Some(text)
1905                        }
1906                        "CentralPackageTransitivePinningEnabled" => {
1907                            raw.central_package_transitive_pinning_enabled = Some(text)
1908                        }
1909                        "CentralPackageVersionOverrideEnabled" => {
1910                            raw.central_package_version_override_enabled = Some(text)
1911                        }
1912                        _ => {}
1913                    }
1914                }
1915            }
1916            Ok(Event::End(e)) => {
1917                let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
1918
1919                match tag_name.as_str() {
1920                    "PropertyGroup" => current_property_group_condition = None,
1921                    "ItemGroup" => current_item_group_condition = None,
1922                    "PackageVersion" => {
1923                        if let Some(entry) = current_package_version.take() {
1924                            raw.package_versions.push(entry);
1925                        }
1926                    }
1927                    _ => {}
1928                }
1929
1930                current_element.clear();
1931            }
1932            Ok(Event::Eof) => break,
1933            Err(e) => {
1934                return Err(format!(
1935                    "Error parsing Directory.Packages.props at {:?}: {}",
1936                    path, e
1937                ));
1938            }
1939            _ => {}
1940        }
1941
1942        buf.clear();
1943    }
1944
1945    Ok(raw)
1946}
1947
1948fn parse_directory_build_props_file(path: &Path) -> Result<RawBuildPropsData, String> {
1949    let file = File::open(path)
1950        .map_err(|e| format!("Failed to open Directory.Build.props at {:?}: {}", path, e))?;
1951
1952    let reader = BufReader::new(file);
1953    let mut xml_reader = Reader::from_reader(reader);
1954    xml_reader.config_mut().trim_text(true);
1955
1956    let mut raw = RawBuildPropsData::default();
1957    let mut buf = Vec::new();
1958    let mut current_element = String::new();
1959    let mut in_property_group = false;
1960    let mut current_property_group_condition = None;
1961
1962    loop {
1963        match xml_reader.read_event_into(&mut buf) {
1964            Ok(Event::Start(e)) => {
1965                let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
1966                current_element = tag_name.clone();
1967                if tag_name == "PropertyGroup" {
1968                    in_property_group = true;
1969                    current_property_group_condition = e
1970                        .attributes()
1971                        .filter_map(|a| a.ok())
1972                        .find(|attr| attr.key.as_ref() == b"Condition")
1973                        .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
1974                }
1975            }
1976            Ok(Event::Empty(e)) => {
1977                let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
1978                if tag_name == "Import"
1979                    && let Some(project) = e
1980                        .attributes()
1981                        .filter_map(|a| a.ok())
1982                        .find(|attr| attr.key.as_ref() == b"Project")
1983                        .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok())
1984                    && !e
1985                        .attributes()
1986                        .filter_map(|a| a.ok())
1987                        .any(|attr| attr.key.as_ref() == b"Condition")
1988                    && is_supported_directory_build_import(&project)
1989                {
1990                    raw.import_projects.push(project.trim().to_string());
1991                }
1992            }
1993            Ok(Event::Text(e)) => {
1994                let text = e.decode().ok().map(|s| s.trim().to_string());
1995                let Some(text) = text.filter(|value| !value.is_empty()) else {
1996                    buf.clear();
1997                    continue;
1998                };
1999
2000                if in_property_group && current_property_group_condition.is_none() {
2001                    raw.property_values
2002                        .insert(current_element.clone(), text.clone());
2003                    match current_element.as_str() {
2004                        "ManagePackageVersionsCentrally" => {
2005                            raw.manage_package_versions_centrally = Some(text)
2006                        }
2007                        "CentralPackageTransitivePinningEnabled" => {
2008                            raw.central_package_transitive_pinning_enabled = Some(text)
2009                        }
2010                        "CentralPackageVersionOverrideEnabled" => {
2011                            raw.central_package_version_override_enabled = Some(text)
2012                        }
2013                        _ => {}
2014                    }
2015                }
2016            }
2017            Ok(Event::End(e)) => {
2018                let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
2019                if tag_name == "PropertyGroup" {
2020                    in_property_group = false;
2021                    current_property_group_condition = None;
2022                }
2023                current_element.clear();
2024            }
2025            Ok(Event::Eof) => break,
2026            Err(e) => {
2027                return Err(format!(
2028                    "Error parsing Directory.Build.props at {:?}: {}",
2029                    path, e
2030                ));
2031            }
2032            _ => {}
2033        }
2034
2035        buf.clear();
2036    }
2037
2038    Ok(raw)
2039}
2040
2041fn build_directory_packages_package_data(
2042    data: CentralPackagePropsData,
2043    raw: RawCentralPackagePropsData,
2044) -> PackageData {
2045    let mut extra_data = serde_json::Map::new();
2046    if !data.properties.is_empty() {
2047        extra_data.insert(
2048            "property_values".to_string(),
2049            serde_json::Value::Object(
2050                data.properties
2051                    .iter()
2052                    .map(|(key, value)| (key.clone(), serde_json::Value::String(value.clone())))
2053                    .collect(),
2054            ),
2055        );
2056    }
2057    if let Some(value) = data.manage_package_versions_centrally {
2058        extra_data.insert(
2059            "manage_package_versions_centrally".to_string(),
2060            serde_json::Value::Bool(value),
2061        );
2062    }
2063    if let Some(value) = data.central_package_transitive_pinning_enabled {
2064        extra_data.insert(
2065            "central_package_transitive_pinning_enabled".to_string(),
2066            serde_json::Value::Bool(value),
2067        );
2068    }
2069    if let Some(value) = data.central_package_version_override_enabled {
2070        extra_data.insert(
2071            "central_package_version_override_enabled".to_string(),
2072            serde_json::Value::Bool(value),
2073        );
2074    }
2075    if !data.import_projects.is_empty() {
2076        extra_data.insert(
2077            "import_projects".to_string(),
2078            serde_json::Value::Array(
2079                data.import_projects
2080                    .into_iter()
2081                    .map(serde_json::Value::String)
2082                    .collect(),
2083            ),
2084        );
2085    }
2086    extra_data.insert(
2087        "package_versions".to_string(),
2088        serde_json::Value::Array(
2089            raw.package_versions
2090                .into_iter()
2091                .map(|entry| {
2092                    serde_json::json!({
2093                        "name": entry.name,
2094                        "version": entry.version,
2095                        "condition": entry.condition,
2096                    })
2097                })
2098                .collect(),
2099        ),
2100    );
2101
2102    PackageData {
2103        datasource_id: Some(DatasourceId::NugetDirectoryPackagesProps),
2104        package_type: Some(PackageType::Nuget),
2105        dependencies: data.dependencies,
2106        extra_data: if extra_data.is_empty() {
2107            None
2108        } else {
2109            Some(extra_data.into_iter().collect())
2110        },
2111        ..default_package_data(Some(DatasourceId::NugetDirectoryPackagesProps))
2112    }
2113}
2114
2115fn build_directory_build_props_package_data(
2116    data: BuildPropsData,
2117    _raw: RawBuildPropsData,
2118) -> PackageData {
2119    let mut extra_data = serde_json::Map::new();
2120    if !data.property_values.is_empty() {
2121        extra_data.insert(
2122            "property_values".to_string(),
2123            serde_json::Value::Object(
2124                data.property_values
2125                    .iter()
2126                    .map(|(key, value)| (key.clone(), serde_json::Value::String(value.clone())))
2127                    .collect(),
2128            ),
2129        );
2130    }
2131    if let Some(value) = data.manage_package_versions_centrally {
2132        extra_data.insert(
2133            "manage_package_versions_centrally".to_string(),
2134            serde_json::Value::Bool(value),
2135        );
2136    }
2137    if let Some(value) = data.central_package_transitive_pinning_enabled {
2138        extra_data.insert(
2139            "central_package_transitive_pinning_enabled".to_string(),
2140            serde_json::Value::Bool(value),
2141        );
2142    }
2143    if let Some(value) = data.central_package_version_override_enabled {
2144        extra_data.insert(
2145            "central_package_version_override_enabled".to_string(),
2146            serde_json::Value::Bool(value),
2147        );
2148    }
2149    if !data.import_projects.is_empty() {
2150        extra_data.insert(
2151            "import_projects".to_string(),
2152            serde_json::Value::Array(
2153                data.import_projects
2154                    .into_iter()
2155                    .map(serde_json::Value::String)
2156                    .collect(),
2157            ),
2158        );
2159    }
2160
2161    PackageData {
2162        datasource_id: Some(DatasourceId::NugetDirectoryBuildProps),
2163        package_type: Some(PackageType::Nuget),
2164        extra_data: if extra_data.is_empty() {
2165            None
2166        } else {
2167            Some(extra_data.into_iter().collect())
2168        },
2169        ..default_package_data(Some(DatasourceId::NugetDirectoryBuildProps))
2170    }
2171}
2172
2173fn merge_central_package_props(
2174    target: &mut CentralPackagePropsData,
2175    source: CentralPackagePropsData,
2176) {
2177    target.import_projects.extend(source.import_projects);
2178    target.properties.extend(source.properties);
2179    if target.manage_package_versions_centrally.is_none() {
2180        target.manage_package_versions_centrally = source.manage_package_versions_centrally;
2181    }
2182    if target.central_package_transitive_pinning_enabled.is_none() {
2183        target.central_package_transitive_pinning_enabled =
2184            source.central_package_transitive_pinning_enabled;
2185    }
2186    if target.central_package_version_override_enabled.is_none() {
2187        target.central_package_version_override_enabled =
2188            source.central_package_version_override_enabled;
2189    }
2190    replace_matching_dependency_group(&mut target.dependencies, &source.dependencies);
2191    target.dependencies.extend(source.dependencies);
2192}
2193
2194fn replace_matching_dependency_group(target: &mut Vec<Dependency>, source: &[Dependency]) {
2195    if source.is_empty() {
2196        return;
2197    }
2198
2199    let source_keys = source.iter().map(dependency_key).collect::<Vec<_>>();
2200    target.retain(|candidate| {
2201        !source_keys
2202            .iter()
2203            .any(|key| *key == dependency_key(candidate))
2204    });
2205}
2206
2207fn dependency_key(dependency: &Dependency) -> (Option<String>, Option<String>, Option<String>) {
2208    (
2209        dependency.purl.clone(),
2210        dependency.scope.clone(),
2211        dependency
2212            .extra_data
2213            .as_ref()
2214            .and_then(|data| data.get("condition"))
2215            .and_then(|value| value.as_str())
2216            .map(ToOwned::to_owned),
2217    )
2218}
2219
2220fn is_supported_directory_packages_import(project: &str) -> bool {
2221    let trimmed = project.trim();
2222    if trimmed.is_empty() {
2223        return false;
2224    }
2225
2226    if is_get_path_of_file_above_import(trimmed) {
2227        return true;
2228    }
2229
2230    let candidate = PathBuf::from(trimmed);
2231    candidate.file_name().and_then(|name| name.to_str()) == Some("Directory.Packages.props")
2232}
2233
2234fn is_supported_directory_build_import(project: &str) -> bool {
2235    let trimmed = project.trim();
2236    if trimmed.is_empty() {
2237        return false;
2238    }
2239
2240    if is_get_path_of_file_above_build_import(trimmed) {
2241        return true;
2242    }
2243
2244    let candidate = PathBuf::from(trimmed);
2245    candidate.file_name().and_then(|name| name.to_str()) == Some("Directory.Build.props")
2246}
2247
2248fn is_get_path_of_file_above_import(project: &str) -> bool {
2249    let normalized = project.replace(' ', "");
2250    normalized
2251        == "$([MSBuild]::GetPathOfFileAbove(Directory.Packages.props,$(MSBuildThisFileDirectory)..))"
2252}
2253
2254fn is_get_path_of_file_above_build_import(project: &str) -> bool {
2255    let normalized = project.replace(' ', "");
2256    normalized
2257        == "$([MSBuild]::GetPathOfFileAbove(Directory.Build.props,$(MSBuildThisFileDirectory)..))"
2258}
2259
2260fn resolve_import_project_for_directory_build(
2261    current_path: &Path,
2262    project: &str,
2263    known_props_paths: &HashMap<PathBuf, &PackageData>,
2264) -> Option<PathBuf> {
2265    let trimmed = project.trim();
2266    if is_get_path_of_file_above_build_import(trimmed) {
2267        let start_dir = current_path.parent()?.parent()?;
2268        for ancestor in start_dir.ancestors() {
2269            let candidate = ancestor.join("Directory.Build.props");
2270            if known_props_paths.is_empty() {
2271                if candidate.exists() {
2272                    return Some(candidate);
2273                }
2274            } else if known_props_paths.contains_key(&candidate) {
2275                return Some(candidate);
2276            }
2277        }
2278        return None;
2279    }
2280
2281    if !is_supported_directory_build_import(trimmed) {
2282        return None;
2283    }
2284
2285    let candidate = PathBuf::from(trimmed);
2286    if candidate.is_absolute() {
2287        if known_props_paths.is_empty() {
2288            candidate.exists().then_some(candidate)
2289        } else {
2290            known_props_paths
2291                .contains_key(&candidate)
2292                .then_some(candidate)
2293        }
2294    } else {
2295        let resolved = current_path.parent()?.join(candidate);
2296        if known_props_paths.is_empty() {
2297            resolved.exists().then_some(resolved)
2298        } else {
2299            known_props_paths
2300                .contains_key(&resolved)
2301                .then_some(resolved)
2302        }
2303    }
2304}
2305
2306fn merge_build_props_data(target: &mut BuildPropsData, source: BuildPropsData) {
2307    target.import_projects.extend(source.import_projects);
2308    target.property_values.extend(source.property_values);
2309    if target.manage_package_versions_centrally.is_none() {
2310        target.manage_package_versions_centrally = source.manage_package_versions_centrally;
2311    }
2312    if target.central_package_transitive_pinning_enabled.is_none() {
2313        target.central_package_transitive_pinning_enabled =
2314            source.central_package_transitive_pinning_enabled;
2315    }
2316    if target.central_package_version_override_enabled.is_none() {
2317        target.central_package_version_override_enabled =
2318            source.central_package_version_override_enabled;
2319    }
2320}
2321
2322fn resolve_import_project_for_directory_packages(
2323    current_path: &Path,
2324    project: &str,
2325    known_props_paths: &HashMap<PathBuf, &PackageData>,
2326) -> Option<PathBuf> {
2327    let trimmed = project.trim();
2328    if is_get_path_of_file_above_import(trimmed) {
2329        let start_dir = current_path.parent()?.parent()?;
2330        for ancestor in start_dir.ancestors() {
2331            let candidate = ancestor.join("Directory.Packages.props");
2332            if known_props_paths.is_empty() {
2333                if candidate.exists() {
2334                    return Some(candidate);
2335                }
2336            } else if known_props_paths.contains_key(&candidate) {
2337                return Some(candidate);
2338            }
2339        }
2340        return None;
2341    }
2342
2343    if !is_supported_directory_packages_import(trimmed) {
2344        return None;
2345    }
2346
2347    let candidate = PathBuf::from(trimmed);
2348    if candidate.is_absolute() {
2349        if known_props_paths.is_empty() {
2350            candidate.exists().then_some(candidate)
2351        } else {
2352            known_props_paths
2353                .contains_key(&candidate)
2354                .then_some(candidate)
2355        }
2356    } else {
2357        let resolved = current_path.parent()?.join(candidate);
2358        if known_props_paths.is_empty() {
2359            resolved.exists().then_some(resolved)
2360        } else {
2361            known_props_paths
2362                .contains_key(&resolved)
2363                .then_some(resolved)
2364        }
2365    }
2366}
2367
2368fn resolve_string_property_reference(
2369    value: &str,
2370    properties: &HashMap<String, String>,
2371) -> Option<String> {
2372    let trimmed = value.trim();
2373    if let Some(property_name) = trimmed
2374        .strip_prefix("$(")
2375        .and_then(|value| value.strip_suffix(')'))
2376    {
2377        properties.get(property_name).cloned()
2378    } else {
2379        Some(trimmed.to_string())
2380    }
2381}
2382
2383fn resolve_bool_property_reference(
2384    value: Option<&str>,
2385    properties: &HashMap<String, String>,
2386) -> Option<bool> {
2387    let resolved = resolve_string_property_reference(value?, properties)?;
2388    Some(resolved.eq_ignore_ascii_case("true"))
2389}
2390
2391fn resolve_optional_property_value(
2392    value: Option<&str>,
2393    properties: &HashMap<String, String>,
2394) -> Option<String> {
2395    let value = value?.trim();
2396    if value.is_empty() {
2397        return None;
2398    }
2399
2400    if value.starts_with("$(") && value.ends_with(')') {
2401        resolve_string_property_reference(value, properties)
2402    } else {
2403        Some(value.to_string())
2404    }
2405}
2406
2407pub struct CentralPackageManagementPropsParser;
2408
2409pub struct DirectoryBuildPropsParser;
2410
2411impl PackageParser for DirectoryBuildPropsParser {
2412    const PACKAGE_TYPE: PackageType = PackageType::Nuget;
2413
2414    fn is_match(path: &Path) -> bool {
2415        path.file_name().and_then(|name| name.to_str()) == Some("Directory.Build.props")
2416    }
2417
2418    fn extract_packages(path: &Path) -> Vec<PackageData> {
2419        vec![match (
2420            resolve_directory_build_props(path, &mut HashSet::new()),
2421            parse_directory_build_props_file(path),
2422        ) {
2423            (Ok(data), Ok(raw)) => build_directory_build_props_package_data(data, raw),
2424            (Err(e), _) | (_, Err(e)) => {
2425                warn!("Error parsing Directory.Build.props at {:?}: {}", path, e);
2426                default_package_data(Some(DatasourceId::NugetDirectoryBuildProps))
2427            }
2428        }]
2429    }
2430}
2431
2432impl PackageParser for CentralPackageManagementPropsParser {
2433    const PACKAGE_TYPE: PackageType = PackageType::Nuget;
2434
2435    fn is_match(path: &Path) -> bool {
2436        path.file_name().and_then(|name| name.to_str()) == Some("Directory.Packages.props")
2437    }
2438
2439    fn extract_packages(path: &Path) -> Vec<PackageData> {
2440        vec![match (
2441            resolve_directory_packages_props(path, &mut HashSet::new()),
2442            parse_directory_packages_props_file(path),
2443        ) {
2444            (Ok(data), Ok(raw)) => build_directory_packages_package_data(data, raw),
2445            (Err(e), _) | (_, Err(e)) => {
2446                warn!(
2447                    "Error parsing Directory.Packages.props at {:?}: {}",
2448                    path, e
2449                );
2450                default_package_data(Some(DatasourceId::NugetDirectoryPackagesProps))
2451            }
2452        }]
2453    }
2454}
2455
2456/// Parser for .nupkg files (NuGet package archives)
2457pub struct NupkgParser;
2458
2459impl PackageParser for NupkgParser {
2460    const PACKAGE_TYPE: PackageType = PackageType::Nuget;
2461
2462    fn is_match(path: &Path) -> bool {
2463        path.extension()
2464            .and_then(|ext| ext.to_str())
2465            .is_some_and(|ext| ext == "nupkg")
2466    }
2467
2468    fn extract_packages(path: &Path) -> Vec<PackageData> {
2469        vec![match extract_nupkg_archive(path) {
2470            Ok(data) => data,
2471            Err(e) => {
2472                warn!("Failed to extract .nupkg at {:?}: {}", path, e);
2473                default_package_data(Some(DatasourceId::NugetNupkg))
2474            }
2475        }]
2476    }
2477}
2478
2479fn extract_nupkg_archive(path: &Path) -> Result<PackageData, String> {
2480    use std::fs;
2481    use zip::ZipArchive;
2482
2483    let file_metadata =
2484        fs::metadata(path).map_err(|e| format!("Failed to read file metadata: {}", e))?;
2485    let archive_size = file_metadata.len();
2486
2487    if archive_size > MAX_ARCHIVE_SIZE {
2488        return Err(format!(
2489            "Archive too large: {} bytes (limit: {} bytes)",
2490            archive_size, MAX_ARCHIVE_SIZE
2491        ));
2492    }
2493
2494    let file = File::open(path).map_err(|e| format!("Failed to open archive: {}", e))?;
2495    let mut archive =
2496        ZipArchive::new(file).map_err(|e| format!("Failed to read ZIP archive: {}", e))?;
2497
2498    for i in 0..archive.len() {
2499        let content = {
2500            let mut entry = archive
2501                .by_index(i)
2502                .map_err(|e| format!("Failed to read ZIP entry: {}", e))?;
2503
2504            let entry_name = entry.name().to_string();
2505            if !entry_name.ends_with(".nuspec") {
2506                continue;
2507            }
2508
2509            let entry_size = entry.size();
2510            if entry_size > MAX_FILE_SIZE {
2511                return Err(format!(
2512                    ".nuspec too large: {} bytes (limit: {} bytes)",
2513                    entry_size, MAX_FILE_SIZE
2514                ));
2515            }
2516
2517            let compressed_size = entry.compressed_size();
2518            if compressed_size > 0 {
2519                let ratio = entry_size as f64 / compressed_size as f64;
2520                if ratio > MAX_COMPRESSION_RATIO {
2521                    return Err(format!(
2522                        "Suspicious compression ratio: {:.2}:1 (limit: {:.0}:1)",
2523                        ratio, MAX_COMPRESSION_RATIO
2524                    ));
2525                }
2526            }
2527
2528            let mut content = String::new();
2529            entry
2530                .read_to_string(&mut content)
2531                .map_err(|e| format!("Failed to read .nuspec: {}", e))?;
2532            content
2533        };
2534
2535        let mut package_data = parse_nuspec_content(&content)?;
2536
2537        let license_file = package_data.extra_data.as_ref().and_then(|extra| {
2538            extra
2539                .get("license_file")
2540                .and_then(|value| value.as_str())
2541                .map(|value| value.to_string())
2542        });
2543
2544        if let Some(license_file) = license_file
2545            && let Some(license_text) = read_nupkg_license_file(&mut archive, &license_file)?
2546        {
2547            package_data.extracted_license_statement = Some(license_text);
2548        }
2549
2550        return Ok(package_data);
2551    }
2552
2553    Err("No .nuspec file found in archive".to_string())
2554}
2555
2556fn read_nupkg_license_file(
2557    archive: &mut zip::ZipArchive<File>,
2558    license_file: &str,
2559) -> Result<Option<String>, String> {
2560    let normalized_target = license_file.replace('\\', "/");
2561
2562    for i in 0..archive.len() {
2563        let mut entry = archive
2564            .by_index(i)
2565            .map_err(|e| format!("Failed to read ZIP entry: {}", e))?;
2566        let entry_name = entry.name().replace('\\', "/");
2567
2568        if entry_name != normalized_target
2569            && !entry_name.ends_with(&format!("/{}", normalized_target))
2570        {
2571            continue;
2572        }
2573
2574        let entry_size = entry.size();
2575        if entry_size > MAX_FILE_SIZE {
2576            return Err(format!(
2577                "License file too large: {} bytes (limit: {} bytes)",
2578                entry_size, MAX_FILE_SIZE
2579            ));
2580        }
2581
2582        let mut content = Vec::new();
2583        entry
2584            .read_to_end(&mut content)
2585            .map_err(|e| format!("Failed to read license file from archive: {}", e))?;
2586
2587        return Ok(Some(String::from_utf8_lossy(&content).to_string()));
2588    }
2589
2590    Ok(None)
2591}
2592
2593fn parse_nuspec_content(content: &str) -> Result<PackageData, String> {
2594    use quick_xml::Reader;
2595
2596    let mut xml_reader = Reader::from_str(content);
2597    xml_reader.config_mut().trim_text(true);
2598
2599    let mut name = None;
2600    let mut version = None;
2601    let mut description = None;
2602    let mut homepage_url = None;
2603    let mut parties = Vec::new();
2604    let mut dependencies = Vec::new();
2605    let mut extracted_license_statement = None;
2606    let mut license_type = None;
2607    let mut copyright = None;
2608    let mut vcs_url = None;
2609    let mut repository_branch = None;
2610    let mut repository_commit = None;
2611
2612    let mut buf = Vec::new();
2613    let mut current_element = String::new();
2614    let mut in_metadata = false;
2615    let mut in_dependencies = false;
2616    let mut current_group_framework = None;
2617
2618    loop {
2619        match xml_reader.read_event_into(&mut buf) {
2620            Ok(Event::Start(e)) => {
2621                let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
2622                current_element = tag_name.clone();
2623
2624                if tag_name == "metadata" {
2625                    in_metadata = true;
2626                } else if tag_name == "dependencies" && in_metadata {
2627                    in_dependencies = true;
2628                } else if tag_name == "group" && in_dependencies {
2629                    current_group_framework = e
2630                        .attributes()
2631                        .filter_map(|a| a.ok())
2632                        .find(|attr| attr.key.as_ref() == b"targetFramework")
2633                        .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
2634                } else if tag_name == "repository" && in_metadata {
2635                    let repository = parse_repository_metadata(&e);
2636                    vcs_url = repository.vcs_url;
2637                    repository_branch = repository.branch;
2638                    repository_commit = repository.commit;
2639                } else if tag_name == "license" && in_metadata {
2640                    license_type = e
2641                        .attributes()
2642                        .filter_map(|a| a.ok())
2643                        .find(|attr| attr.key.as_ref() == b"type")
2644                        .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
2645                }
2646            }
2647            Ok(Event::Empty(e)) => {
2648                let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
2649
2650                if tag_name == "dependency" && in_dependencies {
2651                    if let Some(dep) =
2652                        parse_nuspec_dependency(&e, current_group_framework.as_deref())
2653                    {
2654                        dependencies.push(dep);
2655                    }
2656                } else if tag_name == "repository" && in_metadata {
2657                    let repository = parse_repository_metadata(&e);
2658                    vcs_url = repository.vcs_url;
2659                    repository_branch = repository.branch;
2660                    repository_commit = repository.commit;
2661                }
2662            }
2663            Ok(Event::Text(e)) => {
2664                if !in_metadata {
2665                    continue;
2666                }
2667
2668                let text = e.decode().ok().map(|s| s.trim().to_string());
2669                if let Some(text) = text.filter(|s| !s.is_empty()) {
2670                    match current_element.as_str() {
2671                        "id" => name = Some(text),
2672                        "version" => version = Some(text),
2673                        "description" => description = Some(text),
2674                        "projectUrl" => homepage_url = Some(text),
2675                        "authors" => {
2676                            parties.push(build_nuget_party("author", text));
2677                        }
2678                        "owners" => {
2679                            parties.push(build_nuget_party("owner", text));
2680                        }
2681                        "license" => {
2682                            extracted_license_statement = Some(text);
2683                        }
2684                        "licenseUrl" => {
2685                            if extracted_license_statement.is_none() {
2686                                extracted_license_statement = Some(text);
2687                            }
2688                        }
2689                        "copyright" => copyright = Some(text),
2690                        _ => {}
2691                    }
2692                }
2693            }
2694            Ok(Event::End(e)) => {
2695                let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
2696
2697                if tag_name == "metadata" {
2698                    in_metadata = false;
2699                } else if tag_name == "dependencies" {
2700                    in_dependencies = false;
2701                } else if tag_name == "group" {
2702                    current_group_framework = None;
2703                }
2704
2705                current_element.clear();
2706            }
2707            Ok(Event::Eof) => break,
2708            Err(e) => {
2709                return Err(format!("XML parsing error: {}", e));
2710            }
2711            _ => {}
2712        }
2713        buf.clear();
2714    }
2715
2716    let (repository_homepage_url, repository_download_url, api_data_url) =
2717        build_nuget_urls(name.as_deref(), version.as_deref());
2718
2719    let (declared_license_expression, declared_license_expression_spdx, license_detections) =
2720        if license_type.as_deref() == Some("expression") {
2721            normalize_spdx_declared_license(extracted_license_statement.as_deref())
2722        } else {
2723            empty_declared_license_data()
2724        };
2725
2726    let holder = None;
2727
2728    let mut extra_data = serde_json::Map::new();
2729    insert_extra_string(&mut extra_data, "license_type", license_type.clone());
2730    if license_type.as_deref() == Some("file") {
2731        insert_extra_string(
2732            &mut extra_data,
2733            "license_file",
2734            extracted_license_statement.clone(),
2735        );
2736    }
2737    insert_extra_string(&mut extra_data, "repository_branch", repository_branch);
2738    insert_extra_string(&mut extra_data, "repository_commit", repository_commit);
2739
2740    Ok(PackageData {
2741        datasource_id: Some(DatasourceId::NugetNupkg),
2742        package_type: Some(NupkgParser::PACKAGE_TYPE),
2743        name,
2744        version,
2745        description,
2746        homepage_url,
2747        parties,
2748        dependencies,
2749        declared_license_expression,
2750        declared_license_expression_spdx,
2751        license_detections,
2752        extracted_license_statement,
2753        copyright,
2754        holder,
2755        vcs_url,
2756        extra_data: if extra_data.is_empty() {
2757            None
2758        } else {
2759            Some(extra_data.into_iter().collect())
2760        },
2761        repository_homepage_url,
2762        repository_download_url,
2763        api_data_url,
2764        ..default_package_data(Some(DatasourceId::NugetNupkg))
2765    })
2766}
2767
2768crate::register_parser!(
2769    ".NET Directory.Build.props property source",
2770    &["**/Directory.Build.props"],
2771    "nuget",
2772    "C#",
2773    Some(
2774        "https://learn.microsoft.com/en-us/visualstudio/msbuild/customize-by-directory?view=vs-2022"
2775    ),
2776);
2777
2778crate::register_parser!(
2779    ".NET Directory.Packages.props central package management manifest",
2780    &["**/Directory.Packages.props"],
2781    "nuget",
2782    "C#",
2783    Some("https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management"),
2784);
2785
2786crate::register_parser!(
2787    ".NET packages.config manifest",
2788    &["**/packages.config"],
2789    "nuget",
2790    "C#",
2791    Some("https://learn.microsoft.com/en-us/nuget/reference/packages-config"),
2792);
2793
2794crate::register_parser!(
2795    ".NET .nuspec package specification",
2796    &["**/*.nuspec"],
2797    "nuget",
2798    "C#",
2799    Some("https://learn.microsoft.com/en-us/nuget/reference/nuspec"),
2800);
2801
2802crate::register_parser!(
2803    ".NET packages.lock.json lockfile",
2804    &["**/packages.lock.json"],
2805    "nuget",
2806    "C#",
2807    Some(
2808        "https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files#locking-dependencies"
2809    ),
2810);
2811
2812crate::register_parser!(
2813    ".NET project.json manifest",
2814    &["**/project.json"],
2815    "nuget",
2816    "C#",
2817    Some("https://learn.microsoft.com/en-us/nuget/archive/project-json"),
2818);
2819
2820crate::register_parser!(
2821    ".NET project.lock.json lockfile",
2822    &["**/project.lock.json"],
2823    "nuget",
2824    "C#",
2825    Some("https://learn.microsoft.com/en-us/nuget/archive/project-json"),
2826);
2827
2828crate::register_parser!(
2829    ".NET .deps.json runtime dependency graph",
2830    &["**/*.deps.json"],
2831    "nuget",
2832    "C#",
2833    Some("https://learn.microsoft.com/en-us/dotnet/core/dependency-loading/default-probing"),
2834);
2835
2836crate::register_parser!(
2837    ".NET PackageReference C# project file",
2838    &["**/*.csproj"],
2839    "nuget",
2840    "C#",
2841    Some(
2842        "https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files"
2843    ),
2844);
2845
2846crate::register_parser!(
2847    ".NET PackageReference Visual Basic project file",
2848    &["**/*.vbproj"],
2849    "nuget",
2850    "Visual Basic .NET",
2851    Some(
2852        "https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files"
2853    ),
2854);
2855
2856crate::register_parser!(
2857    ".NET PackageReference F# project file",
2858    &["**/*.fsproj"],
2859    "nuget",
2860    "F#",
2861    Some(
2862        "https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files"
2863    ),
2864);
2865
2866crate::register_parser!(
2867    ".NET .nupkg package archive",
2868    &["**/*.nupkg"],
2869    "nuget",
2870    "C#",
2871    Some("https://learn.microsoft.com/en-us/nuget/create-packages/creating-a-package"),
2872);