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);