provenant/parsers/nuget/
nupkg.rs1use 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}