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 mut fallback_name = path
68            .file_stem()
69            .and_then(|stem| stem.to_str())
70            .map(|stem| stem.to_string());
71        let mut version = None;
72        let mut description = None;
73        let mut homepage_url = None;
74        let mut authors = None;
75        let mut repository_url = None;
76        let mut repository_type = None;
77        let mut repository_branch = None;
78        let mut repository_commit = None;
79        let mut extracted_license_statement = None;
80        let mut license_type = None;
81        let mut copyright = None;
82        let mut readme_file = None;
83        let mut icon_file = None;
84        let mut package_references = Vec::new();
85        let mut project_properties = HashMap::new();
86
87        let mut buf = Vec::new();
88        let mut current_element = String::new();
89        let mut in_property_group = false;
90        let mut current_property_group_condition = None;
91        let mut current_item_group_condition = None;
92        let mut current_package_reference: Option<ProjectReferenceData> = None;
93        let mut iteration_count: usize = 0;
94
95        loop {
96            iteration_count += 1;
97            if iteration_count > MAX_ITERATION_COUNT {
98                warn!(
99                    "Iteration limit exceeded in project file at {:?}; stopping at {} items",
100                    path, MAX_ITERATION_COUNT
101                );
102                break;
103            }
104            match xml_reader.read_event_into(&mut buf) {
105                Ok(Event::Start(e)) => {
106                    let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
107                    current_element = tag_name.clone();
108
109                    match tag_name.as_str() {
110                        "PropertyGroup" => {
111                            in_property_group = true;
112                            current_property_group_condition = e
113                                .attributes()
114                                .filter_map(|a| a.ok())
115                                .find(|attr| attr.key.as_ref() == b"Condition")
116                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
117                        }
118                        "ItemGroup" => {
119                            current_item_group_condition = e
120                                .attributes()
121                                .filter_map(|a| a.ok())
122                                .find(|attr| attr.key.as_ref() == b"Condition")
123                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
124                        }
125                        "PackageReference" => {
126                            let name = e
127                                .attributes()
128                                .filter_map(|a| a.ok())
129                                .find(|attr| matches!(attr.key.as_ref(), b"Include" | b"Update"))
130                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
131                            let version = e
132                                .attributes()
133                                .filter_map(|a| a.ok())
134                                .find(|attr| attr.key.as_ref() == b"Version")
135                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
136                            let version_override = e
137                                .attributes()
138                                .filter_map(|a| a.ok())
139                                .find(|attr| attr.key.as_ref() == b"VersionOverride")
140                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
141                            let condition = e
142                                .attributes()
143                                .filter_map(|a| a.ok())
144                                .find(|attr| attr.key.as_ref() == b"Condition")
145                                .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok())
146                                .or_else(|| current_item_group_condition.clone());
147
148                            current_package_reference = Some(ProjectReferenceData {
149                                name,
150                                version,
151                                version_override,
152                                condition,
153                            });
154                        }
155                        _ => {}
156                    }
157                }
158                Ok(Event::Empty(e)) => {
159                    let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
160
161                    if tag_name == "PackageReference" {
162                        let name = e
163                            .attributes()
164                            .filter_map(|a| a.ok())
165                            .find(|attr| matches!(attr.key.as_ref(), b"Include" | b"Update"))
166                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
167                        let version = e
168                            .attributes()
169                            .filter_map(|a| a.ok())
170                            .find(|attr| attr.key.as_ref() == b"Version")
171                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
172                        let version_override = e
173                            .attributes()
174                            .filter_map(|a| a.ok())
175                            .find(|attr| attr.key.as_ref() == b"VersionOverride")
176                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
177                        let condition = e
178                            .attributes()
179                            .filter_map(|a| a.ok())
180                            .find(|attr| attr.key.as_ref() == b"Condition")
181                            .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok())
182                            .or_else(|| current_item_group_condition.clone());
183
184                        package_references.push(ProjectReferenceData {
185                            name,
186                            version,
187                            version_override,
188                            condition,
189                        });
190                    }
191                }
192                Ok(Event::Text(e)) => {
193                    let text = e.decode().ok().map(|s| s.trim().to_string());
194                    let Some(text) = text.filter(|value| !value.is_empty()) else {
195                        buf.clear();
196                        continue;
197                    };
198
199                    if current_package_reference.is_some() {
200                        if current_element.as_str() == "Version"
201                            && let Some(reference) = &mut current_package_reference
202                        {
203                            reference.version = Some(text);
204                        } else if current_element.as_str() == "VersionOverride"
205                            && let Some(reference) = &mut current_package_reference
206                        {
207                            reference.version_override = Some(text);
208                        }
209                    } else if in_property_group && current_property_group_condition.is_none() {
210                        project_properties.insert(current_element.clone(), text.clone());
211                        match current_element.as_str() {
212                            "PackageId" => name = Some(text),
213                            "AssemblyName" if fallback_name.is_none() => fallback_name = Some(text),
214                            "Version" if version.is_none() => version = Some(text),
215                            "PackageVersion" => version = Some(text),
216                            "Description" => description = Some(text),
217                            "PackageProjectUrl" | "ProjectUrl" => homepage_url = Some(text),
218                            "Authors" => authors = Some(text),
219                            "RepositoryUrl" => repository_url = Some(text),
220                            "RepositoryType" => repository_type = Some(text),
221                            "RepositoryBranch" => repository_branch = Some(text),
222                            "RepositoryCommit" => repository_commit = Some(text),
223                            "PackageLicenseExpression" => {
224                                extracted_license_statement = Some(text);
225                                license_type = Some("expression".to_string());
226                            }
227                            "PackageLicenseFile" => {
228                                extracted_license_statement = Some(text);
229                                license_type = Some("file".to_string());
230                            }
231                            "PackageReadmeFile" => readme_file = Some(text),
232                            "PackageIcon" => icon_file = Some(text),
233                            "Copyright" => copyright = Some(text),
234                            _ => {}
235                        }
236                    }
237                }
238                Ok(Event::End(e)) => {
239                    let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
240
241                    match tag_name.as_str() {
242                        "PropertyGroup" => {
243                            in_property_group = false;
244                            current_property_group_condition = None;
245                        }
246                        "ItemGroup" => current_item_group_condition = None,
247                        "PackageReference" => {
248                            if let Some(reference) = current_package_reference.take() {
249                                package_references.push(reference);
250                            }
251                        }
252                        _ => {}
253                    }
254
255                    current_element.clear();
256                }
257                Ok(Event::Eof) => break,
258                Err(e) => {
259                    warn!("Error parsing project file at {:?}: {}", path, e);
260                    return vec![default_package_data(Some(datasource_id))];
261                }
262                _ => {}
263            }
264
265            buf.clear();
266        }
267
268        let name = name.or(fallback_name);
269        let vcs_url = repository_url.map(|url| match repository_type {
270            Some(repo_type) if !repo_type.trim().is_empty() => format!("{}+{}", repo_type, url),
271            _ => url,
272        });
273        let dependencies = package_references
274            .into_iter()
275            .filter_map(|reference| {
276                build_project_file_dependency(
277                    reference.name,
278                    reference.version,
279                    reference.version_override,
280                    reference.condition,
281                    &project_properties,
282                )
283            })
284            .collect::<Vec<_>>();
285        let (repository_homepage_url, repository_download_url, api_data_url) =
286            build_nuget_urls(name.as_deref(), version.as_deref());
287
288        let mut parties = Vec::new();
289        if let Some(authors) = authors {
290            parties.push(build_nuget_party("author", authors));
291        }
292
293        let mut extra_data = serde_json::Map::new();
294        insert_extra_string(&mut extra_data, "license_type", license_type.clone());
295        if license_type.as_deref() == Some("file") {
296            insert_extra_string(
297                &mut extra_data,
298                "license_file",
299                extracted_license_statement.clone(),
300            );
301        }
302        insert_extra_string(&mut extra_data, "repository_branch", repository_branch);
303        insert_extra_string(&mut extra_data, "repository_commit", repository_commit);
304        insert_extra_string(&mut extra_data, "readme_file", readme_file);
305        insert_extra_string(&mut extra_data, "icon_file", icon_file);
306        if let Some(value) = project_properties
307            .get("CentralPackageVersionOverrideEnabled")
308            .cloned()
309        {
310            extra_data.insert(
311                "central_package_version_override_enabled_raw".to_string(),
312                serde_json::Value::String(value),
313            );
314        }
315        if let Some(value) = resolve_bool_property_reference(
316            project_properties
317                .get("CentralPackageVersionOverrideEnabled")
318                .map(String::as_str),
319            &project_properties,
320        ) {
321            extra_data.insert(
322                "central_package_version_override_enabled".to_string(),
323                serde_json::Value::Bool(value),
324            );
325        }
326
327        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
328            if license_type.as_deref() == Some("expression") {
329                normalize_spdx_declared_license(extracted_license_statement.as_deref())
330            } else {
331                empty_declared_license_data()
332            };
333
334        vec![PackageData {
335            datasource_id: Some(datasource_id),
336            package_type: Some(Self::PACKAGE_TYPE),
337            name: name.clone().map(truncate_field),
338            version: version.clone().map(truncate_field),
339            purl: build_nuget_purl(name.as_deref(), version.as_deref()),
340            description: description.map(truncate_field),
341            homepage_url: homepage_url.map(truncate_field),
342            parties,
343            dependencies,
344            declared_license_expression,
345            declared_license_expression_spdx,
346            license_detections,
347            extracted_license_statement: extracted_license_statement.map(truncate_field),
348            copyright: copyright.map(truncate_field),
349            vcs_url: vcs_url.map(truncate_field),
350            extra_data: if extra_data.is_empty() {
351                None
352            } else {
353                Some(extra_data.into_iter().collect())
354            },
355            repository_homepage_url,
356            repository_download_url,
357            api_data_url,
358            ..default_package_data(Some(datasource_id))
359        }]
360    }
361}
362
363fn build_project_file_dependency(
364    name: Option<String>,
365    version: Option<String>,
366    version_override: Option<String>,
367    condition: Option<String>,
368    project_properties: &HashMap<String, String>,
369) -> Option<Dependency> {
370    let name = name?.trim().to_string();
371    if name.is_empty() {
372        return None;
373    }
374
375    let mut extra_data = serde_json::Map::new();
376    insert_extra_string(&mut extra_data, "condition", condition);
377    insert_extra_string(
378        &mut extra_data,
379        "version_override",
380        version_override.clone(),
381    );
382    insert_extra_string(
383        &mut extra_data,
384        "version_override_resolved",
385        version_override
386            .as_deref()
387            .and_then(|value| resolve_string_property_reference(value, project_properties)),
388    );
389
390    Some(Dependency {
391        purl: build_nuget_purl(Some(&name), None),
392        extracted_requirement: version,
393        scope: None,
394        is_runtime: Some(true),
395        is_optional: Some(false),
396        is_pinned: Some(false),
397        is_direct: Some(true),
398        resolved_package: None,
399        extra_data: if extra_data.is_empty() {
400            None
401        } else {
402            Some(extra_data.into_iter().collect())
403        },
404    })
405}
406
407crate::register_parser!(
408    ".NET PackageReference C# project file",
409    &["**/*.csproj"],
410    "nuget",
411    "C#",
412    Some(
413        "https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files"
414    ),
415);
416
417crate::register_parser!(
418    ".NET PackageReference Visual Basic project file",
419    &["**/*.vbproj"],
420    "nuget",
421    "Visual Basic .NET",
422    Some(
423        "https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files"
424    ),
425);
426
427crate::register_parser!(
428    ".NET PackageReference F# project file",
429    &["**/*.fsproj"],
430    "nuget",
431    "F#",
432    Some(
433        "https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files"
434    ),
435);