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