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