1use std::fs::File;
8use std::io::BufReader;
9use std::path::Path;
10
11use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
12use crate::parser_warn as warn;
13use packageurl::PackageUrl;
14use quick_xml::Reader;
15use quick_xml::events::Event;
16
17use super::super::PackageParser;
18use super::super::license_normalization::{
19 empty_declared_license_data, normalize_spdx_declared_license,
20};
21use super::super::utils::{MAX_ITERATION_COUNT, truncate_field};
22use super::{
23 build_nuget_description, build_nuget_party, build_nuget_purl, build_nuget_urls,
24 check_file_size, default_package_data, insert_extra_string, parse_repository_metadata,
25};
26
27pub struct NuspecParser;
28
29impl PackageParser for NuspecParser {
30 const PACKAGE_TYPE: PackageType = PackageType::Nuget;
31
32 fn is_match(path: &Path) -> bool {
33 path.extension()
34 .and_then(|ext| ext.to_str())
35 .is_some_and(|ext| ext == "nuspec")
36 }
37
38 fn extract_packages(path: &Path) -> Vec<PackageData> {
39 if let Err(e) = check_file_size(path) {
40 warn!("{}", e);
41 return vec![default_package_data(Some(DatasourceId::NugetNuspec))];
42 }
43
44 let file = match File::open(path) {
45 Ok(f) => f,
46 Err(e) => {
47 warn!("Failed to open .nuspec at {:?}: {}", path, e);
48 return vec![default_package_data(Some(DatasourceId::NugetNuspec))];
49 }
50 };
51
52 let reader = BufReader::new(file);
53 let mut xml_reader = Reader::from_reader(reader);
54 xml_reader.config_mut().trim_text(true);
55
56 let mut name = None;
57 let mut version = None;
58 let mut summary = None;
59 let mut description = None;
60 let mut title = None;
61 let mut homepage_url = None;
62 let mut parties = Vec::new();
63 let mut dependencies = Vec::new();
64 let mut extracted_license_statement = None;
65 let mut license_type = None;
66 let mut copyright = None;
67 let mut vcs_url = None;
68 let mut repository_branch = None;
69 let mut repository_commit = None;
70
71 let mut buf = Vec::new();
72 let mut current_element = String::new();
73 let mut in_metadata = false;
74 let mut in_dependencies = false;
75 let mut current_group_framework = None;
76 let mut iteration_count: usize = 0;
77
78 loop {
79 iteration_count += 1;
80 if iteration_count > MAX_ITERATION_COUNT {
81 warn!(
82 "Iteration limit exceeded in .nuspec at {:?}; stopping at {} items",
83 path, MAX_ITERATION_COUNT
84 );
85 break;
86 }
87 match xml_reader.read_event_into(&mut buf) {
88 Ok(Event::Start(e)) => {
89 let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
90 current_element = tag_name.clone();
91
92 if tag_name == "metadata" {
93 in_metadata = true;
94 } else if tag_name == "dependencies" && in_metadata {
95 in_dependencies = true;
96 } else if tag_name == "group" && in_dependencies {
97 current_group_framework = e
98 .attributes()
99 .filter_map(|a| a.ok())
100 .find(|attr| attr.key.as_ref() == b"targetFramework")
101 .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
102 } else if tag_name == "repository" && in_metadata {
103 let repository = parse_repository_metadata(&e);
104 vcs_url = repository.vcs_url;
105 repository_branch = repository.branch;
106 repository_commit = repository.commit;
107 } else if tag_name == "license" && in_metadata {
108 license_type = e
109 .attributes()
110 .filter_map(|a| a.ok())
111 .find(|attr| attr.key.as_ref() == b"type")
112 .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
113 }
114 }
115 Ok(Event::Empty(e)) => {
116 let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
117
118 if tag_name == "dependency" && in_dependencies {
119 if let Some(dep) =
120 parse_nuspec_dependency(&e, current_group_framework.as_deref())
121 {
122 dependencies.push(dep);
123 }
124 } else if tag_name == "repository" && in_metadata {
125 let repository = parse_repository_metadata(&e);
126 vcs_url = repository.vcs_url;
127 repository_branch = repository.branch;
128 repository_commit = repository.commit;
129 }
130 }
131 Ok(Event::Text(e)) => {
132 if !in_metadata {
133 continue;
134 }
135
136 let text = e.decode().ok().map(|s| s.trim().to_string());
137 if let Some(text) = text.filter(|s| !s.is_empty()) {
138 match current_element.as_str() {
139 "id" => name = Some(text),
140 "version" => version = Some(text),
141 "summary" => summary = Some(text),
142 "description" => description = Some(text),
143 "title" => title = Some(text),
144 "projectUrl" => homepage_url = Some(text),
145 "authors" => {
146 parties.push(build_nuget_party("author", text));
147 }
148 "owners" => {
149 parties.push(build_nuget_party("owner", text));
150 }
151 "license" => {
152 extracted_license_statement = Some(text);
153 }
154 "licenseUrl" if extracted_license_statement.is_none() => {
155 extracted_license_statement = Some(text);
156 }
157 "licenseUrl" => {}
158 "copyright" => copyright = Some(text),
159 _ => {}
160 }
161 }
162 }
163 Ok(Event::End(e)) => {
164 let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
165
166 if tag_name == "metadata" {
167 in_metadata = false;
168 } else if tag_name == "dependencies" {
169 in_dependencies = false;
170 } else if tag_name == "group" {
171 current_group_framework = None;
172 }
173
174 current_element.clear();
175 }
176 Ok(Event::Eof) => break,
177 Err(e) => {
178 warn!("Error parsing .nuspec at {:?}: {}", path, e);
179 return vec![default_package_data(Some(DatasourceId::NugetNuspec))];
180 }
181 _ => {}
182 }
183 buf.clear();
184 }
185
186 let final_description = build_nuget_description(
187 summary.as_deref(),
188 description.as_deref(),
189 title.as_deref(),
190 name.as_deref(),
191 );
192
193 let (repository_homepage_url, repository_download_url, api_data_url) =
194 build_nuget_urls(name.as_deref(), version.as_deref());
195
196 let purl = build_nuget_purl(name.as_deref(), version.as_deref());
197
198 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
199 if license_type.as_deref() == Some("expression") {
200 normalize_spdx_declared_license(extracted_license_statement.as_deref())
201 } else {
202 empty_declared_license_data()
203 };
204
205 let holder = None;
206
207 let mut extra_data = serde_json::Map::new();
208 insert_extra_string(&mut extra_data, "license_type", license_type.clone());
209 if license_type.as_deref() == Some("file") {
210 insert_extra_string(
211 &mut extra_data,
212 "license_file",
213 extracted_license_statement.clone(),
214 );
215 }
216 insert_extra_string(&mut extra_data, "repository_branch", repository_branch);
217 insert_extra_string(&mut extra_data, "repository_commit", repository_commit);
218
219 vec![PackageData {
220 datasource_id: Some(DatasourceId::NugetNuspec),
221 package_type: Some(Self::PACKAGE_TYPE),
222 name: name.map(truncate_field),
223 version: version.map(truncate_field),
224 purl,
225 description: final_description.map(truncate_field),
226 homepage_url: homepage_url.map(truncate_field),
227 parties,
228 dependencies,
229 declared_license_expression,
230 declared_license_expression_spdx,
231 license_detections,
232 extracted_license_statement: extracted_license_statement.map(truncate_field),
233 copyright: copyright.map(truncate_field),
234 holder,
235 vcs_url: vcs_url.map(truncate_field),
236 extra_data: if extra_data.is_empty() {
237 None
238 } else {
239 Some(extra_data.into_iter().collect())
240 },
241 repository_homepage_url,
242 repository_download_url,
243 api_data_url,
244 ..default_package_data(Some(DatasourceId::NugetNuspec))
245 }]
246 }
247
248 fn metadata() -> Vec<super::super::metadata::ParserMetadata> {
249 vec![super::super::metadata::ParserMetadata {
250 description: ".NET .nuspec package specification",
251 file_patterns: &["**/*.nuspec"],
252 package_type: "nuget",
253 primary_language: "C#",
254 documentation_url: Some("https://learn.microsoft.com/en-us/nuget/reference/nuspec"),
255 }]
256 }
257}
258
259pub(super) fn parse_nuspec_dependency(
260 element: &quick_xml::events::BytesStart,
261 framework: Option<&str>,
262) -> Option<Dependency> {
263 let mut id = None;
264 let mut version = None;
265 let mut include = None;
266 let mut exclude = None;
267
268 for attr in element.attributes().filter_map(|a| a.ok()) {
269 match attr.key.as_ref() {
270 b"id" => id = String::from_utf8(attr.value.to_vec()).ok(),
271 b"version" => version = String::from_utf8(attr.value.to_vec()).ok(),
272 b"include" => include = String::from_utf8(attr.value.to_vec()).ok(),
273 b"exclude" => exclude = String::from_utf8(attr.value.to_vec()).ok(),
274 _ => {}
275 }
276 }
277
278 let name = id?;
279 let purl = PackageUrl::new("nuget", &name).ok().map(|p| p.to_string());
280
281 let mut extra_data = serde_json::Map::new();
282 if let Some(fw) = framework {
283 extra_data.insert(
284 "framework".to_string(),
285 serde_json::Value::String(fw.to_string()),
286 );
287 }
288 if let Some(inc) = include {
289 extra_data.insert("include".to_string(), serde_json::Value::String(inc));
290 }
291 if let Some(exc) = exclude {
292 extra_data.insert("exclude".to_string(), serde_json::Value::String(exc));
293 }
294
295 Some(Dependency {
296 purl,
297 extracted_requirement: version,
298 scope: Some("dependency".to_string()),
299 is_runtime: Some(true),
300 is_optional: Some(false),
301 is_pinned: Some(false),
302 is_direct: Some(true),
303 resolved_package: None,
304 extra_data: if extra_data.is_empty() {
305 None
306 } else {
307 Some(extra_data.into_iter().collect())
308 },
309 })
310}
311
312pub(super) fn parse_nuspec_content(content: &str) -> Result<PackageData, String> {
313 use quick_xml::Reader;
314
315 let mut xml_reader = Reader::from_str(content);
316 xml_reader.config_mut().trim_text(true);
317
318 let mut name = None;
319 let mut version = None;
320 let mut description = None;
321 let mut homepage_url = None;
322 let mut parties = Vec::new();
323 let mut dependencies = Vec::new();
324 let mut extracted_license_statement = None;
325 let mut license_type = None;
326 let mut copyright = None;
327 let mut vcs_url = None;
328 let mut repository_branch = None;
329 let mut repository_commit = None;
330
331 let mut buf = Vec::new();
332 let mut current_element = String::new();
333 let mut in_metadata = false;
334 let mut in_dependencies = false;
335 let mut current_group_framework = None;
336 let mut iteration_count: usize = 0;
337
338 loop {
339 iteration_count += 1;
340 if iteration_count > MAX_ITERATION_COUNT {
341 return Err(format!(
342 "Iteration limit exceeded parsing .nuspec content; stopping at {} items",
343 MAX_ITERATION_COUNT
344 ));
345 }
346 match xml_reader.read_event_into(&mut buf) {
347 Ok(Event::Start(e)) => {
348 let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
349 current_element = tag_name.clone();
350
351 if tag_name == "metadata" {
352 in_metadata = true;
353 } else if tag_name == "dependencies" && in_metadata {
354 in_dependencies = true;
355 } else if tag_name == "group" && in_dependencies {
356 current_group_framework = e
357 .attributes()
358 .filter_map(|a| a.ok())
359 .find(|attr| attr.key.as_ref() == b"targetFramework")
360 .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
361 } else if tag_name == "repository" && in_metadata {
362 let repository = parse_repository_metadata(&e);
363 vcs_url = repository.vcs_url;
364 repository_branch = repository.branch;
365 repository_commit = repository.commit;
366 } else if tag_name == "license" && in_metadata {
367 license_type = e
368 .attributes()
369 .filter_map(|a| a.ok())
370 .find(|attr| attr.key.as_ref() == b"type")
371 .and_then(|attr| String::from_utf8(attr.value.to_vec()).ok());
372 }
373 }
374 Ok(Event::Empty(e)) => {
375 let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
376
377 if tag_name == "dependency" && in_dependencies {
378 if let Some(dep) =
379 parse_nuspec_dependency(&e, current_group_framework.as_deref())
380 {
381 dependencies.push(dep);
382 }
383 } else if tag_name == "repository" && in_metadata {
384 let repository = parse_repository_metadata(&e);
385 vcs_url = repository.vcs_url;
386 repository_branch = repository.branch;
387 repository_commit = repository.commit;
388 }
389 }
390 Ok(Event::Text(e)) => {
391 if !in_metadata {
392 continue;
393 }
394
395 let text = e.decode().ok().map(|s| s.trim().to_string());
396 if let Some(text) = text.filter(|s| !s.is_empty()) {
397 match current_element.as_str() {
398 "id" => name = Some(text),
399 "version" => version = Some(text),
400 "description" => description = Some(text),
401 "projectUrl" => homepage_url = Some(text),
402 "authors" => {
403 parties.push(build_nuget_party("author", text));
404 }
405 "owners" => {
406 parties.push(build_nuget_party("owner", text));
407 }
408 "license" => {
409 extracted_license_statement = Some(text);
410 }
411 "licenseUrl" if extracted_license_statement.is_none() => {
412 extracted_license_statement = Some(text);
413 }
414 "licenseUrl" => {}
415 "copyright" => copyright = Some(text),
416 _ => {}
417 }
418 }
419 }
420 Ok(Event::End(e)) => {
421 let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
422
423 if tag_name == "metadata" {
424 in_metadata = false;
425 } else if tag_name == "dependencies" {
426 in_dependencies = false;
427 } else if tag_name == "group" {
428 current_group_framework = None;
429 }
430
431 current_element.clear();
432 }
433 Ok(Event::Eof) => break,
434 Err(e) => {
435 return Err(format!("XML parsing error: {}", e));
436 }
437 _ => {}
438 }
439 buf.clear();
440 }
441
442 let (repository_homepage_url, repository_download_url, api_data_url) =
443 build_nuget_urls(name.as_deref(), version.as_deref());
444
445 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
446 if license_type.as_deref() == Some("expression") {
447 normalize_spdx_declared_license(extracted_license_statement.as_deref())
448 } else {
449 empty_declared_license_data()
450 };
451
452 let holder = None;
453
454 let mut extra_data = serde_json::Map::new();
455 insert_extra_string(&mut extra_data, "license_type", license_type.clone());
456 if license_type.as_deref() == Some("file") {
457 insert_extra_string(
458 &mut extra_data,
459 "license_file",
460 extracted_license_statement.clone(),
461 );
462 }
463 insert_extra_string(&mut extra_data, "repository_branch", repository_branch);
464 insert_extra_string(&mut extra_data, "repository_commit", repository_commit);
465
466 Ok(PackageData {
467 datasource_id: Some(DatasourceId::NugetNupkg),
468 package_type: Some(super::nupkg::NupkgParser::PACKAGE_TYPE),
469 name: name.map(truncate_field),
470 version: version.map(truncate_field),
471 description: description.map(truncate_field),
472 homepage_url: homepage_url.map(truncate_field),
473 parties,
474 dependencies,
475 declared_license_expression,
476 declared_license_expression_spdx,
477 license_detections,
478 extracted_license_statement: extracted_license_statement.map(truncate_field),
479 copyright: copyright.map(truncate_field),
480 holder,
481 vcs_url: vcs_url.map(truncate_field),
482 extra_data: if extra_data.is_empty() {
483 None
484 } else {
485 Some(extra_data.into_iter().collect())
486 },
487 repository_homepage_url,
488 repository_download_url,
489 api_data_url,
490 ..default_package_data(Some(DatasourceId::NugetNupkg))
491 })
492}