Skip to main content

provenant/parsers/nuget/
project_file.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::fs::File;
6use std::io::BufReader;
7use std::path::Path;
8
9use crate::models::{Dependency, PackageData, PackageType};
10use crate::parser_warn as warn;
11use quick_xml::Reader;
12use quick_xml::events::Event;
13
14use super::super::PackageParser;
15use super::super::license_normalization::{
16    empty_declared_license_data, normalize_spdx_declared_license,
17};
18use super::super::utils::{MAX_ITERATION_COUNT, truncate_field};
19use super::utils::{resolve_bool_property_reference, resolve_string_property_reference};
20use super::{
21    PROJECT_FILE_EXTENSIONS, build_nuget_party, build_nuget_purl, build_nuget_urls,
22    check_file_size, default_package_data, insert_extra_string, project_file_datasource_id,
23};
24
25#[derive(Default)]
26struct ProjectReferenceData {
27    name: Option<String>,
28    version: Option<String>,
29    version_override: Option<String>,
30    condition: Option<String>,
31}
32
33pub struct PackageReferenceProjectParser;
34
35impl PackageParser for PackageReferenceProjectParser {
36    const PACKAGE_TYPE: PackageType = PackageType::Nuget;
37
38    fn is_match(path: &Path) -> bool {
39        path.extension()
40            .and_then(|ext| ext.to_str())
41            .is_some_and(|ext| PROJECT_FILE_EXTENSIONS.contains(&ext))
42    }
43
44    fn extract_packages(path: &Path) -> Vec<PackageData> {
45        let Some(datasource_id) = project_file_datasource_id(path) else {
46            return vec![default_package_data(None)];
47        };
48
49        if let Err(e) = check_file_size(path) {
50            warn!("{}", e);
51            return vec![default_package_data(Some(datasource_id))];
52        }
53
54        let file = match File::open(path) {
55            Ok(file) => file,
56            Err(e) => {
57                warn!("Failed to open project file at {:?}: {}", path, e);
58                return vec![default_package_data(Some(datasource_id))];
59            }
60        };
61
62        let reader = BufReader::new(file);
63        let mut xml_reader = Reader::from_reader(reader);
64        xml_reader.config_mut().trim_text(true);
65
66        let mut name = None;
67        let filename_stem = path
68            .file_stem()
69            .and_then(|stem| stem.to_str())
70            .map(|stem| stem.to_string());
71        let mut assembly_name = None;
72        let mut version = None;
73        let mut description = None;
74        let mut homepage_url = None;
75        let mut authors = None;
76        let mut repository_url = None;
77        let mut repository_type = None;
78        let mut repository_branch = None;
79        let mut repository_commit = None;
80        let mut extracted_license_statement = None;
81        let mut license_type = None;
82        let mut copyright = None;
83        let mut readme_file = None;
84        let mut icon_file = None;
85        let mut package_references = Vec::new();
86        let mut project_properties = HashMap::new();
87
88        let mut buf = Vec::new();
89        let mut current_element = String::new();
90        let mut in_property_group = false;
91        let mut current_property_group_condition = None;
92        let mut current_item_group_condition = None;
93        let mut current_package_reference: Option<ProjectReferenceData> = None;
94        let mut iteration_count: usize = 0;
95
96        loop {
97            iteration_count += 1;
98            if iteration_count > MAX_ITERATION_COUNT {
99                warn!(
100                    "Iteration limit exceeded in project file at {:?}; stopping at {} items",
101                    path, MAX_ITERATION_COUNT
102                );
103                break;
104            }
105            match xml_reader.read_event_into(&mut buf) {
106                Ok(Event::Start(e)) => {
107                    let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
108                    current_element = tag_name.clone();
109
110                    match tag_name.as_str() {
111                        "PropertyGroup" => {
112                            in_property_group = true;
113                            current_property_group_condition = e
114                                .attributes()
115                                .filter_map(|a| a.ok())
116                                .find(|attr| attr.key.as_ref() == b"Condition")
117                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
118                        }
119                        "ItemGroup" => {
120                            current_item_group_condition = e
121                                .attributes()
122                                .filter_map(|a| a.ok())
123                                .find(|attr| attr.key.as_ref() == b"Condition")
124                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
125                        }
126                        "PackageReference" => {
127                            let name = e
128                                .attributes()
129                                .filter_map(|a| a.ok())
130                                .find(|attr| matches!(attr.key.as_ref(), b"Include" | b"Update"))
131                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
132                            let version = e
133                                .attributes()
134                                .filter_map(|a| a.ok())
135                                .find(|attr| attr.key.as_ref() == b"Version")
136                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
137                            let version_override = e
138                                .attributes()
139                                .filter_map(|a| a.ok())
140                                .find(|attr| attr.key.as_ref() == b"VersionOverride")
141                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
142                            let condition = e
143                                .attributes()
144                                .filter_map(|a| a.ok())
145                                .find(|attr| attr.key.as_ref() == b"Condition")
146                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok())
147                                .or_else(|| current_item_group_condition.clone());
148
149                            current_package_reference = Some(ProjectReferenceData {
150                                name,
151                                version,
152                                version_override,
153                                condition,
154                            });
155                        }
156                        _ => {}
157                    }
158                }
159                Ok(Event::Empty(e)) => {
160                    let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
161
162                    if tag_name == "PackageReference" {
163                        let name = e
164                            .attributes()
165                            .filter_map(|a| a.ok())
166                            .find(|attr| matches!(attr.key.as_ref(), b"Include" | b"Update"))
167                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
168                        let version = e
169                            .attributes()
170                            .filter_map(|a| a.ok())
171                            .find(|attr| attr.key.as_ref() == b"Version")
172                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
173                        let version_override = e
174                            .attributes()
175                            .filter_map(|a| a.ok())
176                            .find(|attr| attr.key.as_ref() == b"VersionOverride")
177                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
178                        let condition = e
179                            .attributes()
180                            .filter_map(|a| a.ok())
181                            .find(|attr| attr.key.as_ref() == b"Condition")
182                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok())
183                            .or_else(|| current_item_group_condition.clone());
184
185                        package_references.push(ProjectReferenceData {
186                            name,
187                            version,
188                            version_override,
189                            condition,
190                        });
191                    }
192                }
193                Ok(Event::Text(e)) => {
194                    let text = e.decode().ok().map(|s| s.trim().to_string());
195                    let Some(text) = text.filter(|value| !value.is_empty()) else {
196                        buf.clear();
197                        continue;
198                    };
199
200                    if current_package_reference.is_some() {
201                        if current_element.as_str() == "Version"
202                            && let Some(reference) = &mut current_package_reference
203                        {
204                            reference.version = Some(text);
205                        } else if current_element.as_str() == "VersionOverride"
206                            && let Some(reference) = &mut current_package_reference
207                        {
208                            reference.version_override = Some(text);
209                        }
210                    } else if in_property_group && current_property_group_condition.is_none() {
211                        project_properties.insert(current_element.clone(), text.clone());
212                        match current_element.as_str() {
213                            "PackageId" => name = Some(text),
214                            "AssemblyName" => assembly_name = Some(text),
215                            "Version" if version.is_none() => version = Some(text),
216                            "PackageVersion" => version = Some(text),
217                            "Description" => description = Some(text),
218                            "PackageProjectUrl" | "ProjectUrl" => homepage_url = Some(text),
219                            "Authors" => authors = Some(text),
220                            "RepositoryUrl" => repository_url = Some(text),
221                            "RepositoryType" => repository_type = Some(text),
222                            "RepositoryBranch" => repository_branch = Some(text),
223                            "RepositoryCommit" => repository_commit = Some(text),
224                            "PackageLicenseExpression" => {
225                                extracted_license_statement = Some(text);
226                                license_type = Some("expression".to_string());
227                            }
228                            "PackageLicenseFile" => {
229                                extracted_license_statement = Some(text);
230                                license_type = Some("file".to_string());
231                            }
232                            "PackageReadmeFile" => readme_file = Some(text),
233                            "PackageIcon" => icon_file = Some(text),
234                            "Copyright" => copyright = Some(text),
235                            _ => {}
236                        }
237                    }
238                }
239                Ok(Event::End(e)) => {
240                    let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
241
242                    match tag_name.as_str() {
243                        "PropertyGroup" => {
244                            in_property_group = false;
245                            current_property_group_condition = None;
246                        }
247                        "ItemGroup" => current_item_group_condition = None,
248                        "PackageReference" => {
249                            if let Some(reference) = current_package_reference.take() {
250                                package_references.push(reference);
251                            }
252                        }
253                        _ => {}
254                    }
255
256                    current_element.clear();
257                }
258                Ok(Event::Eof) => break,
259                Err(e) => {
260                    warn!("Error parsing project file at {:?}: {}", path, e);
261                    return vec![default_package_data(Some(datasource_id))];
262                }
263                _ => {}
264            }
265
266            buf.clear();
267        }
268
269        let resolve_project_property = |value: Option<String>| {
270            value.map(|raw| {
271                let resolved =
272                    resolve_string_property_reference(&raw, &project_properties).unwrap_or(raw);
273                truncate_field(resolved)
274            })
275        };
276
277        let name = resolve_project_property(name)
278            .or(resolve_project_property(assembly_name))
279            .or(filename_stem);
280        let version = resolve_project_property(version);
281        let description = resolve_project_property(description);
282        let homepage_url = resolve_project_property(homepage_url);
283        let authors = resolve_project_property(authors);
284        let repository_url = resolve_project_property(repository_url);
285        let repository_type = resolve_project_property(repository_type);
286        let repository_branch = resolve_project_property(repository_branch);
287        let repository_commit = resolve_project_property(repository_commit);
288        let extracted_license_statement = resolve_project_property(extracted_license_statement);
289        let copyright = resolve_project_property(copyright);
290        let readme_file = resolve_project_property(readme_file);
291        let icon_file = resolve_project_property(icon_file);
292        let vcs_url = repository_url.map(|url| match repository_type {
293            Some(repo_type) if !repo_type.trim().is_empty() => format!("{}+{}", repo_type, url),
294            _ => url,
295        });
296        let dependencies = package_references
297            .into_iter()
298            .filter_map(|reference| {
299                build_project_file_dependency(
300                    reference.name,
301                    reference.version,
302                    reference.version_override,
303                    reference.condition,
304                    &project_properties,
305                )
306            })
307            .collect::<Vec<_>>();
308        let (repository_homepage_url, repository_download_url, api_data_url) =
309            build_nuget_urls(name.as_deref(), version.as_deref());
310
311        let mut parties = Vec::new();
312        if let Some(authors) = authors {
313            parties.push(build_nuget_party("author", authors));
314        }
315
316        let mut extra_data = serde_json::Map::new();
317        insert_extra_string(&mut extra_data, "license_type", license_type.clone());
318        if license_type.as_deref() == Some("file") {
319            insert_extra_string(
320                &mut extra_data,
321                "license_file",
322                extracted_license_statement.clone(),
323            );
324        }
325        insert_extra_string(&mut extra_data, "repository_branch", repository_branch);
326        insert_extra_string(&mut extra_data, "repository_commit", repository_commit);
327        insert_extra_string(&mut extra_data, "readme_file", readme_file);
328        insert_extra_string(&mut extra_data, "icon_file", icon_file);
329        if let Some(value) = project_properties
330            .get("CentralPackageVersionOverrideEnabled")
331            .cloned()
332        {
333            extra_data.insert(
334                "central_package_version_override_enabled_raw".to_string(),
335                serde_json::Value::String(value),
336            );
337        }
338        if let Some(value) = resolve_bool_property_reference(
339            project_properties
340                .get("CentralPackageVersionOverrideEnabled")
341                .map(String::as_str),
342            &project_properties,
343        ) {
344            extra_data.insert(
345                "central_package_version_override_enabled".to_string(),
346                serde_json::Value::Bool(value),
347            );
348        }
349
350        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
351            if license_type.as_deref() == Some("expression") {
352                normalize_spdx_declared_license(extracted_license_statement.as_deref())
353            } else {
354                empty_declared_license_data()
355            };
356
357        vec![PackageData {
358            datasource_id: Some(datasource_id),
359            package_type: Some(Self::PACKAGE_TYPE),
360            name: name.clone().map(truncate_field),
361            version: version.clone().map(truncate_field),
362            purl: build_nuget_purl(name.as_deref(), version.as_deref()),
363            description: description.map(truncate_field),
364            homepage_url: homepage_url.map(truncate_field),
365            parties,
366            dependencies,
367            declared_license_expression,
368            declared_license_expression_spdx,
369            license_detections,
370            extracted_license_statement: extracted_license_statement.map(truncate_field),
371            copyright: copyright.map(truncate_field),
372            vcs_url: vcs_url.map(truncate_field),
373            extra_data: if extra_data.is_empty() {
374                None
375            } else {
376                Some(extra_data.into_iter().collect())
377            },
378            repository_homepage_url,
379            repository_download_url,
380            api_data_url,
381            ..default_package_data(Some(datasource_id))
382        }]
383    }
384}
385
386fn build_project_file_dependency(
387    name: Option<String>,
388    version: Option<String>,
389    version_override: Option<String>,
390    condition: Option<String>,
391    project_properties: &HashMap<String, String>,
392) -> Option<Dependency> {
393    let name = name?.trim().to_string();
394    if name.is_empty() {
395        return None;
396    }
397
398    let mut extra_data = serde_json::Map::new();
399    insert_extra_string(&mut extra_data, "condition", condition);
400    let resolved_version = version
401        .as_deref()
402        .and_then(|value| resolve_string_property_reference(value, project_properties))
403        .or(version.clone());
404    let resolved_version_override = version_override
405        .as_deref()
406        .and_then(|value| resolve_string_property_reference(value, project_properties));
407
408    insert_extra_string(
409        &mut extra_data,
410        "version_override",
411        version_override.clone(),
412    );
413    insert_extra_string(
414        &mut extra_data,
415        "version_override_resolved",
416        resolved_version_override,
417    );
418
419    Some(Dependency {
420        purl: build_nuget_purl(Some(&name), None),
421        extracted_requirement: resolved_version,
422        scope: None,
423        is_runtime: Some(true),
424        is_optional: Some(false),
425        is_pinned: Some(false),
426        is_direct: Some(true),
427        resolved_package: None,
428        extra_data: if extra_data.is_empty() {
429            None
430        } else {
431            Some(extra_data.into_iter().collect())
432        },
433    })
434}
435
436crate::register_parser!(
437    ".NET PackageReference C# project file",
438    &["**/*.csproj"],
439    "nuget",
440    "C#",
441    Some(
442        "https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files"
443    ),
444);
445
446crate::register_parser!(
447    ".NET PackageReference Visual Basic project file",
448    &["**/*.vbproj"],
449    "nuget",
450    "Visual Basic .NET",
451    Some(
452        "https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files"
453    ),
454);
455
456crate::register_parser!(
457    ".NET PackageReference F# project file",
458    &["**/*.fsproj"],
459    "nuget",
460    "F#",
461    Some(
462        "https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files"
463    ),
464);