Skip to main content

provenant/parsers/nuget/
nupkg.rs

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