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