vulnera_advisor/
version_registry.rs

1//! Package version registry for fetching available versions from package managers.
2//!
3//! This module provides a trait and implementation for querying package registries
4//! across various ecosystems to get a list of all available versions for a package.
5
6use crate::error::{AdvisoryError, Result};
7use async_trait::async_trait;
8use serde::Deserialize;
9use std::collections::HashMap;
10use tracing::debug;
11
12/// Trait for fetching package versions from registries.
13#[async_trait]
14pub trait VersionRegistry: Send + Sync {
15    /// Get all available versions for a package in the given ecosystem.
16    async fn get_versions(&self, ecosystem: &str, package: &str) -> Result<Vec<String>>;
17}
18
19/// Multi-ecosystem package registry implementation.
20///
21/// Supports fetching versions from:
22/// - npm (Node.js)
23/// - PyPI (Python)
24/// - Maven (Java)
25/// - crates.io (Rust)
26/// - Go proxy (Go modules)
27/// - Packagist (PHP/Composer)
28/// - RubyGems (Ruby/Bundler)
29/// - NuGet (.NET)
30#[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    /// Create a new PackageRegistry with default configuration.
43    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    /// Create a new PackageRegistry with a custom HTTP client.
54    pub fn with_client(client: reqwest::Client) -> Self {
55        Self { client }
56    }
57
58    /// Fetch versions from npm registry.
59    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    /// Fetch versions from PyPI.
91    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    /// Fetch versions from crates.io.
123    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    /// Fetch versions from Maven Central.
155    async fn fetch_maven_versions(&self, package: &str) -> Result<Vec<String>> {
156        // Maven packages are in format "group:artifact"
157        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    /// Fetch versions from Go module proxy.
200    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        // Go proxy returns newline-separated versions
228        Ok(text.lines().map(|s| s.to_string()).collect())
229    }
230
231    /// Fetch versions from Packagist (Composer/PHP).
232    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        // Packagist format: {"packages": {"vendor/name": [{"version": "1.0.0"}, ...]}}
261        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    /// Fetch versions from RubyGems.
271    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    /// Fetch versions from NuGet.
303    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// === Response types for JSON parsing ===
362
363#[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        // Test that ecosystem names are properly normalized
423        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}