Skip to main content

provenant/parsers/nuget/
project_file.rs

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