Skip to main content

provenant/parsers/nuget/
nupkg.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::Read;
8use std::path::Path;
9
10use crate::models::{DatasourceId, PackageData, PackageType};
11use crate::parser_warn as warn;
12
13use super::super::PackageParser;
14use super::default_package_data;
15use super::nuspec::parse_nuspec_content;
16
17const MAX_ARCHIVE_SIZE: u64 = 100 * 1024 * 1024;
18const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024;
19const MAX_COMPRESSION_RATIO: f64 = 100.0;
20const MAX_UNCOMPRESSED_SIZE: u64 = 1024 * 1024 * 1024;
21
22pub struct NupkgParser;
23
24impl PackageParser for NupkgParser {
25    const PACKAGE_TYPE: PackageType = PackageType::Nuget;
26
27    fn is_match(path: &Path) -> bool {
28        path.extension()
29            .and_then(|ext| ext.to_str())
30            .is_some_and(|ext| ext == "nupkg")
31    }
32
33    fn extract_packages(path: &Path) -> Vec<PackageData> {
34        vec![match extract_nupkg_archive(path) {
35            Ok(data) => data,
36            Err(e) => {
37                warn!("Failed to extract .nupkg at {:?}: {}", path, e);
38                default_package_data(Some(DatasourceId::NugetNupkg))
39            }
40        }]
41    }
42
43    fn metadata() -> Vec<super::super::metadata::ParserMetadata> {
44        vec![super::super::metadata::ParserMetadata {
45            description: ".NET .nupkg package archive",
46            file_patterns: &["**/*.nupkg"],
47            package_type: "nuget",
48            primary_language: "C#",
49            documentation_url: Some(
50                "https://learn.microsoft.com/en-us/nuget/create-packages/creating-a-package",
51            ),
52        }]
53    }
54}
55
56fn extract_nupkg_archive(path: &Path) -> Result<PackageData, String> {
57    use std::fs;
58    use zip::ZipArchive;
59
60    let file_metadata =
61        fs::metadata(path).map_err(|e| format!("Failed to read file metadata: {}", e))?;
62    let archive_size = file_metadata.len();
63
64    if archive_size > MAX_ARCHIVE_SIZE {
65        return Err(format!(
66            "Archive too large: {} bytes (limit: {} bytes)",
67            archive_size, MAX_ARCHIVE_SIZE
68        ));
69    }
70
71    let file = File::open(path).map_err(|e| format!("Failed to open archive: {}", e))?;
72    let mut archive =
73        ZipArchive::new(file).map_err(|e| format!("Failed to read ZIP archive: {}", e))?;
74
75    let mut total_uncompressed: u64 = 0;
76
77    for i in 0..archive.len() {
78        let content = {
79            let mut entry = archive
80                .by_index(i)
81                .map_err(|e| format!("Failed to read ZIP entry: {}", e))?;
82
83            let entry_name = entry.name().to_string();
84            let entry_size = entry.size();
85
86            total_uncompressed += entry_size;
87            if total_uncompressed > MAX_UNCOMPRESSED_SIZE {
88                warn!(
89                    "NuGet: total uncompressed size exceeds {} bytes for {:?}",
90                    MAX_UNCOMPRESSED_SIZE, path
91                );
92                return Err(format!(
93                    "Total uncompressed size exceeds limit: {} bytes (limit: {} bytes)",
94                    total_uncompressed, MAX_UNCOMPRESSED_SIZE
95                ));
96            }
97
98            if !entry_name.ends_with(".nuspec") {
99                continue;
100            }
101
102            if entry_size > MAX_FILE_SIZE {
103                return Err(format!(
104                    ".nuspec too large: {} bytes (limit: {} bytes)",
105                    entry_size, MAX_FILE_SIZE
106                ));
107            }
108
109            let compressed_size = entry.compressed_size();
110            if compressed_size > 0 {
111                let ratio = entry_size as f64 / compressed_size as f64;
112                if ratio > MAX_COMPRESSION_RATIO {
113                    return Err(format!(
114                        "Suspicious compression ratio: {:.2}:1 (limit: {:.0}:1)",
115                        ratio, MAX_COMPRESSION_RATIO
116                    ));
117                }
118            }
119
120            let mut content = String::new();
121            entry
122                .read_to_string(&mut content)
123                .map_err(|e| format!("Failed to read .nuspec: {}", e))?;
124            content
125        };
126
127        let mut package_data = parse_nuspec_content(&content)?;
128
129        let license_file = package_data.extra_data.as_ref().and_then(|extra| {
130            extra
131                .get("license_file")
132                .and_then(|value| value.as_str())
133                .map(|value| value.to_string())
134        });
135
136        if let Some(license_file) = license_file
137            && let Some(license_text) = read_nupkg_license_file(&mut archive, &license_file)?
138        {
139            package_data.extracted_license_statement = Some(license_text);
140        }
141
142        return Ok(package_data);
143    }
144
145    Err("No .nuspec file found in archive".to_string())
146}
147
148fn read_nupkg_license_file(
149    archive: &mut zip::ZipArchive<File>,
150    license_file: &str,
151) -> Result<Option<String>, String> {
152    if license_file.split('/').any(|c| c == "..") || license_file.split('\\').any(|c| c == "..") {
153        warn!(
154            "NuGet: path traversal detected in license file path: {}",
155            license_file
156        );
157        return Ok(None);
158    }
159
160    let normalized_target = license_file.replace('\\', "/");
161
162    for i in 0..archive.len() {
163        let mut entry = archive
164            .by_index(i)
165            .map_err(|e| format!("Failed to read ZIP entry: {}", e))?;
166        let entry_name = entry.name().replace('\\', "/");
167
168        if entry_name != normalized_target
169            && !entry_name.ends_with(&format!("/{}", normalized_target))
170        {
171            continue;
172        }
173
174        let entry_size = entry.size();
175        if entry_size > MAX_FILE_SIZE {
176            return Err(format!(
177                "License file too large: {} bytes (limit: {} bytes)",
178                entry_size, MAX_FILE_SIZE
179            ));
180        }
181
182        let mut content = Vec::new();
183        entry
184            .read_to_end(&mut content)
185            .map_err(|e| format!("Failed to read license file from archive: {}", e))?;
186
187        return Ok(Some(String::from_utf8_lossy(&content).to_string()));
188    }
189
190    Ok(None)
191}