1use crate::error::{AdvisoryError, Result};
7use async_trait::async_trait;
8use serde::Deserialize;
9use std::collections::HashMap;
10use tracing::debug;
11
12#[async_trait]
14pub trait VersionRegistry: Send + Sync {
15 async fn get_versions(&self, ecosystem: &str, package: &str) -> Result<Vec<String>>;
17}
18
19#[derive(Clone)]
31pub struct PackageRegistry {
32 client: reqwest::Client,
33}
34
35impl Default for PackageRegistry {
36 fn default() -> Self {
37 Self::new()
38 }
39}
40
41impl PackageRegistry {
42 pub fn new() -> Self {
44 Self {
45 client: reqwest::Client::builder()
46 .user_agent("vulnera-advisor/0.1")
47 .timeout(std::time::Duration::from_secs(30))
48 .build()
49 .expect("Failed to build HTTP client"),
50 }
51 }
52
53 pub fn with_client(client: reqwest::Client) -> Self {
55 Self { client }
56 }
57
58 async fn fetch_npm_versions(&self, package: &str) -> Result<Vec<String>> {
60 let url = format!("https://registry.npmjs.org/{}", package);
61 let response =
62 self.client
63 .get(&url)
64 .send()
65 .await
66 .map_err(|e| AdvisoryError::SourceFetch {
67 source_name: "npm".to_string(),
68 message: e.to_string(),
69 })?;
70
71 if !response.status().is_success() {
72 return Err(AdvisoryError::SourceFetch {
73 source_name: "npm".to_string(),
74 message: format!("HTTP {}", response.status()),
75 });
76 }
77
78 let data: NpmPackageResponse =
79 response
80 .json()
81 .await
82 .map_err(|e| AdvisoryError::SourceFetch {
83 source_name: "npm".to_string(),
84 message: e.to_string(),
85 })?;
86
87 Ok(data.versions.keys().cloned().collect())
88 }
89
90 async fn fetch_pypi_versions(&self, package: &str) -> Result<Vec<String>> {
92 let url = format!("https://pypi.org/pypi/{}/json", package);
93 let response =
94 self.client
95 .get(&url)
96 .send()
97 .await
98 .map_err(|e| AdvisoryError::SourceFetch {
99 source_name: "pypi".to_string(),
100 message: e.to_string(),
101 })?;
102
103 if !response.status().is_success() {
104 return Err(AdvisoryError::SourceFetch {
105 source_name: "pypi".to_string(),
106 message: format!("HTTP {}", response.status()),
107 });
108 }
109
110 let data: PyPiPackageResponse =
111 response
112 .json()
113 .await
114 .map_err(|e| AdvisoryError::SourceFetch {
115 source_name: "pypi".to_string(),
116 message: e.to_string(),
117 })?;
118
119 Ok(data.releases.keys().cloned().collect())
120 }
121
122 async fn fetch_cargo_versions(&self, package: &str) -> Result<Vec<String>> {
124 let url = format!("https://crates.io/api/v1/crates/{}", package);
125 let response =
126 self.client
127 .get(&url)
128 .send()
129 .await
130 .map_err(|e| AdvisoryError::SourceFetch {
131 source_name: "crates.io".to_string(),
132 message: e.to_string(),
133 })?;
134
135 if !response.status().is_success() {
136 return Err(AdvisoryError::SourceFetch {
137 source_name: "crates.io".to_string(),
138 message: format!("HTTP {}", response.status()),
139 });
140 }
141
142 let data: CratesIoResponse =
143 response
144 .json()
145 .await
146 .map_err(|e| AdvisoryError::SourceFetch {
147 source_name: "crates.io".to_string(),
148 message: e.to_string(),
149 })?;
150
151 Ok(data.versions.into_iter().map(|v| v.num).collect())
152 }
153
154 async fn fetch_maven_versions(&self, package: &str) -> Result<Vec<String>> {
156 let parts: Vec<&str> = package.split(':').collect();
158 if parts.len() != 2 {
159 return Err(AdvisoryError::config(
160 "Maven package must be in format 'group:artifact'",
161 ));
162 }
163
164 let (group, artifact) = (parts[0], parts[1]);
165 let url = format!(
166 "https://search.maven.org/solrsearch/select?q=g:{}+AND+a:{}&core=gav&rows=200&wt=json",
167 group, artifact
168 );
169
170 let response =
171 self.client
172 .get(&url)
173 .send()
174 .await
175 .map_err(|e| AdvisoryError::SourceFetch {
176 source_name: "maven".to_string(),
177 message: e.to_string(),
178 })?;
179
180 if !response.status().is_success() {
181 return Err(AdvisoryError::SourceFetch {
182 source_name: "maven".to_string(),
183 message: format!("HTTP {}", response.status()),
184 });
185 }
186
187 let data: MavenSearchResponse =
188 response
189 .json()
190 .await
191 .map_err(|e| AdvisoryError::SourceFetch {
192 source_name: "maven".to_string(),
193 message: e.to_string(),
194 })?;
195
196 Ok(data.response.docs.into_iter().map(|d| d.v).collect())
197 }
198
199 async fn fetch_go_versions(&self, package: &str) -> Result<Vec<String>> {
201 let url = format!("https://proxy.golang.org/{}/@v/list", package);
202 let response =
203 self.client
204 .get(&url)
205 .send()
206 .await
207 .map_err(|e| AdvisoryError::SourceFetch {
208 source_name: "go".to_string(),
209 message: e.to_string(),
210 })?;
211
212 if !response.status().is_success() {
213 return Err(AdvisoryError::SourceFetch {
214 source_name: "go".to_string(),
215 message: format!("HTTP {}", response.status()),
216 });
217 }
218
219 let text = response
220 .text()
221 .await
222 .map_err(|e| AdvisoryError::SourceFetch {
223 source_name: "go".to_string(),
224 message: e.to_string(),
225 })?;
226
227 Ok(text.lines().map(|s| s.to_string()).collect())
229 }
230
231 async fn fetch_composer_versions(&self, package: &str) -> Result<Vec<String>> {
233 let url = format!("https://repo.packagist.org/p2/{}.json", package);
234 let response =
235 self.client
236 .get(&url)
237 .send()
238 .await
239 .map_err(|e| AdvisoryError::SourceFetch {
240 source_name: "packagist".to_string(),
241 message: e.to_string(),
242 })?;
243
244 if !response.status().is_success() {
245 return Err(AdvisoryError::SourceFetch {
246 source_name: "packagist".to_string(),
247 message: format!("HTTP {}", response.status()),
248 });
249 }
250
251 let data: PackagistResponse =
252 response
253 .json()
254 .await
255 .map_err(|e| AdvisoryError::SourceFetch {
256 source_name: "packagist".to_string(),
257 message: e.to_string(),
258 })?;
259
260 let versions = data
262 .packages
263 .get(package)
264 .map(|versions| versions.iter().map(|v| v.version.clone()).collect())
265 .unwrap_or_default();
266
267 Ok(versions)
268 }
269
270 async fn fetch_gem_versions(&self, package: &str) -> Result<Vec<String>> {
272 let url = format!("https://rubygems.org/api/v1/versions/{}.json", package);
273 let response =
274 self.client
275 .get(&url)
276 .send()
277 .await
278 .map_err(|e| AdvisoryError::SourceFetch {
279 source_name: "rubygems".to_string(),
280 message: e.to_string(),
281 })?;
282
283 if !response.status().is_success() {
284 return Err(AdvisoryError::SourceFetch {
285 source_name: "rubygems".to_string(),
286 message: format!("HTTP {}", response.status()),
287 });
288 }
289
290 let data: Vec<RubyGemVersion> =
291 response
292 .json()
293 .await
294 .map_err(|e| AdvisoryError::SourceFetch {
295 source_name: "rubygems".to_string(),
296 message: e.to_string(),
297 })?;
298
299 Ok(data.into_iter().map(|v| v.number).collect())
300 }
301
302 async fn fetch_nuget_versions(&self, package: &str) -> Result<Vec<String>> {
304 let url = format!(
305 "https://api.nuget.org/v3-flatcontainer/{}/index.json",
306 package.to_lowercase()
307 );
308 let response =
309 self.client
310 .get(&url)
311 .send()
312 .await
313 .map_err(|e| AdvisoryError::SourceFetch {
314 source_name: "nuget".to_string(),
315 message: e.to_string(),
316 })?;
317
318 if !response.status().is_success() {
319 return Err(AdvisoryError::SourceFetch {
320 source_name: "nuget".to_string(),
321 message: format!("HTTP {}", response.status()),
322 });
323 }
324
325 let data: NuGetVersionsResponse =
326 response
327 .json()
328 .await
329 .map_err(|e| AdvisoryError::SourceFetch {
330 source_name: "nuget".to_string(),
331 message: e.to_string(),
332 })?;
333
334 Ok(data.versions)
335 }
336}
337
338#[async_trait]
339impl VersionRegistry for PackageRegistry {
340 async fn get_versions(&self, ecosystem: &str, package: &str) -> Result<Vec<String>> {
341 let ecosystem_lower = ecosystem.to_lowercase();
342 debug!("Fetching versions for {} in {}", package, ecosystem_lower);
343
344 match ecosystem_lower.as_str() {
345 "npm" => self.fetch_npm_versions(package).await,
346 "pypi" | "pip" => self.fetch_pypi_versions(package).await,
347 "cargo" | "crates.io" => self.fetch_cargo_versions(package).await,
348 "maven" => self.fetch_maven_versions(package).await,
349 "go" | "golang" => self.fetch_go_versions(package).await,
350 "composer" | "packagist" => self.fetch_composer_versions(package).await,
351 "gem" | "rubygems" | "bundler" => self.fetch_gem_versions(package).await,
352 "nuget" => self.fetch_nuget_versions(package).await,
353 _ => Err(AdvisoryError::config(format!(
354 "Unsupported ecosystem: {}",
355 ecosystem
356 ))),
357 }
358 }
359}
360
361#[derive(Debug, Deserialize)]
364struct NpmPackageResponse {
365 versions: HashMap<String, serde_json::Value>,
366}
367
368#[derive(Debug, Deserialize)]
369struct PyPiPackageResponse {
370 releases: HashMap<String, serde_json::Value>,
371}
372
373#[derive(Debug, Deserialize)]
374struct CratesIoResponse {
375 versions: Vec<CratesIoVersion>,
376}
377
378#[derive(Debug, Deserialize)]
379struct CratesIoVersion {
380 num: String,
381}
382
383#[derive(Debug, Deserialize)]
384struct MavenSearchResponse {
385 response: MavenSearchDocs,
386}
387
388#[derive(Debug, Deserialize)]
389struct MavenSearchDocs {
390 docs: Vec<MavenDoc>,
391}
392
393#[derive(Debug, Deserialize)]
394struct MavenDoc {
395 v: String,
396}
397
398#[derive(Debug, Deserialize)]
399struct PackagistResponse {
400 packages: HashMap<String, Vec<PackagistVersion>>,
401}
402
403#[derive(Debug, Deserialize)]
404struct PackagistVersion {
405 version: String,
406}
407
408#[derive(Debug, Deserialize)]
409struct RubyGemVersion {
410 number: String,
411}
412
413#[derive(Debug, Deserialize)]
414struct NuGetVersionsResponse {
415 versions: Vec<String>,
416}
417
418#[cfg(test)]
419mod tests {
420 #[test]
421 fn test_ecosystem_normalization() {
422 assert_eq!("npm".to_lowercase(), "npm");
424 assert_eq!("PyPI".to_lowercase(), "pypi");
425 assert_eq!("CARGO".to_lowercase(), "cargo");
426 }
427
428 #[test]
429 fn test_maven_package_parsing() {
430 let package = "org.apache.logging.log4j:log4j-core";
431 let parts: Vec<&str> = package.split(':').collect();
432 assert_eq!(parts.len(), 2);
433 assert_eq!(parts[0], "org.apache.logging.log4j");
434 assert_eq!(parts[1], "log4j-core");
435 }
436}