Skip to main content

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