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