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