Skip to main content

provenant/parsers/nuget/
nuspec.rs

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