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
42fn extract_nupkg_archive(path: &Path) -> Result<PackageData, String> {
43 use std::fs;
44 use zip::ZipArchive;
45
46 let file_metadata =
47 fs::metadata(path).map_err(|e| format!("Failed to read file metadata: {}", e))?;
48 let archive_size = file_metadata.len();
49
50 if archive_size > MAX_ARCHIVE_SIZE {
51 return Err(format!(
52 "Archive too large: {} bytes (limit: {} bytes)",
53 archive_size, MAX_ARCHIVE_SIZE
54 ));
55 }
56
57 let file = File::open(path).map_err(|e| format!("Failed to open archive: {}", e))?;
58 let mut archive =
59 ZipArchive::new(file).map_err(|e| format!("Failed to read ZIP archive: {}", e))?;
60
61 let mut total_uncompressed: u64 = 0;
62
63 for i in 0..archive.len() {
64 let content = {
65 let mut entry = archive
66 .by_index(i)
67 .map_err(|e| format!("Failed to read ZIP entry: {}", e))?;
68
69 let entry_name = entry.name().to_string();
70 let entry_size = entry.size();
71
72 total_uncompressed += entry_size;
73 if total_uncompressed > MAX_UNCOMPRESSED_SIZE {
74 warn!(
75 "NuGet: total uncompressed size exceeds {} bytes for {:?}",
76 MAX_UNCOMPRESSED_SIZE, path
77 );
78 return Err(format!(
79 "Total uncompressed size exceeds limit: {} bytes (limit: {} bytes)",
80 total_uncompressed, MAX_UNCOMPRESSED_SIZE
81 ));
82 }
83
84 if !entry_name.ends_with(".nuspec") {
85 continue;
86 }
87
88 if entry_size > MAX_FILE_SIZE {
89 return Err(format!(
90 ".nuspec too large: {} bytes (limit: {} bytes)",
91 entry_size, MAX_FILE_SIZE
92 ));
93 }
94
95 let compressed_size = entry.compressed_size();
96 if compressed_size > 0 {
97 let ratio = entry_size as f64 / compressed_size as f64;
98 if ratio > MAX_COMPRESSION_RATIO {
99 return Err(format!(
100 "Suspicious compression ratio: {:.2}:1 (limit: {:.0}:1)",
101 ratio, MAX_COMPRESSION_RATIO
102 ));
103 }
104 }
105
106 let mut content = String::new();
107 entry
108 .read_to_string(&mut content)
109 .map_err(|e| format!("Failed to read .nuspec: {}", e))?;
110 content
111 };
112
113 let mut package_data = parse_nuspec_content(&content)?;
114
115 let license_file = package_data.extra_data.as_ref().and_then(|extra| {
116 extra
117 .get("license_file")
118 .and_then(|value| value.as_str())
119 .map(|value| value.to_string())
120 });
121
122 if let Some(license_file) = license_file
123 && let Some(license_text) = read_nupkg_license_file(&mut archive, &license_file)?
124 {
125 package_data.extracted_license_statement = Some(license_text);
126 }
127
128 return Ok(package_data);
129 }
130
131 Err("No .nuspec file found in archive".to_string())
132}
133
134fn read_nupkg_license_file(
135 archive: &mut zip::ZipArchive<File>,
136 license_file: &str,
137) -> Result<Option<String>, String> {
138 if license_file.split('/').any(|c| c == "..") || license_file.split('\\').any(|c| c == "..") {
139 warn!(
140 "NuGet: path traversal detected in license file path: {}",
141 license_file
142 );
143 return Ok(None);
144 }
145
146 let normalized_target = license_file.replace('\\', "/");
147
148 for i in 0..archive.len() {
149 let mut entry = archive
150 .by_index(i)
151 .map_err(|e| format!("Failed to read ZIP entry: {}", e))?;
152 let entry_name = entry.name().replace('\\', "/");
153
154 if entry_name != normalized_target
155 && !entry_name.ends_with(&format!("/{}", normalized_target))
156 {
157 continue;
158 }
159
160 let entry_size = entry.size();
161 if entry_size > MAX_FILE_SIZE {
162 return Err(format!(
163 "License file too large: {} bytes (limit: {} bytes)",
164 entry_size, MAX_FILE_SIZE
165 ));
166 }
167
168 let mut content = Vec::new();
169 entry
170 .read_to_end(&mut content)
171 .map_err(|e| format!("Failed to read license file from archive: {}", e))?;
172
173 return Ok(Some(String::from_utf8_lossy(&content).to_string()));
174 }
175
176 Ok(None)
177}
178
179crate::register_parser!(
180 ".NET .nupkg package archive",
181 &["**/*.nupkg"],
182 "nuget",
183 "C#",
184 Some("https://learn.microsoft.com/en-us/nuget/create-packages/creating-a-package"),
185);