Skip to main content

provenant/parsers/nuget/
nuspec.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7use 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}