otter_pm/
registry.rs

1//! npm registry client
2
3use base64::{Engine as _, engine::general_purpose};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha512};
6use std::collections::HashMap;
7
8/// npm registry client
9pub struct NpmRegistry {
10    registry_url: String,
11    client: reqwest::Client,
12    cache: HashMap<String, PackageMetadata>,
13}
14
15impl NpmRegistry {
16    pub fn new() -> Self {
17        Self::with_registry("https://registry.npmjs.org")
18    }
19
20    pub fn with_registry(url: &str) -> Self {
21        Self {
22            registry_url: url.trim_end_matches('/').to_string(),
23            client: reqwest::Client::new(),
24            cache: HashMap::new(),
25        }
26    }
27
28    /// Fetch package metadata from registry
29    pub async fn get_package(&mut self, name: &str) -> Result<PackageMetadata, RegistryError> {
30        // Check cache first
31        if let Some(pkg) = self.cache.get(name) {
32            return Ok(pkg.clone());
33        }
34
35        let url = format!("{}/{}", self.registry_url, encode_package_name(name));
36
37        let response = self
38            .client
39            .get(&url)
40            .header("Accept", "application/json")
41            .send()
42            .await
43            .map_err(|e| RegistryError::Network(e.to_string()))?;
44
45        if response.status() == 404 {
46            return Err(RegistryError::NotFound(name.to_string()));
47        }
48
49        if !response.status().is_success() {
50            return Err(RegistryError::Http(response.status().as_u16()));
51        }
52
53        let metadata: PackageMetadata = response
54            .json()
55            .await
56            .map_err(|e| RegistryError::Parse(e.to_string()))?;
57
58        self.cache.insert(name.to_string(), metadata.clone());
59        Ok(metadata)
60    }
61
62    /// Resolve a package version to a specific version string
63    pub async fn resolve_version(
64        &mut self,
65        name: &str,
66        version_req: &str,
67    ) -> Result<String, RegistryError> {
68        let metadata = self.get_package(name).await?;
69
70        // Handle dist-tags (latest, next, etc.)
71        if let Some(resolved) = metadata.dist_tags.get(version_req) {
72            return Ok(resolved.clone());
73        }
74
75        // Parse version requirement
76        let req = semver::VersionReq::parse(version_req)
77            .map_err(|e| RegistryError::InvalidVersion(e.to_string()))?;
78
79        // Find best matching version
80        let mut versions: Vec<semver::Version> = metadata
81            .versions
82            .keys()
83            .filter_map(|v| semver::Version::parse(v).ok())
84            .filter(|v| req.matches(v))
85            .collect();
86
87        versions.sort();
88        versions.reverse();
89
90        versions
91            .first()
92            .map(|v| v.to_string())
93            .ok_or_else(|| RegistryError::NoMatchingVersion {
94                name: name.to_string(),
95                req: version_req.to_string(),
96            })
97    }
98
99    /// Download package tarball
100    pub async fn download_tarball(
101        &self,
102        name: &str,
103        version: &str,
104    ) -> Result<Vec<u8>, RegistryError> {
105        let metadata = self
106            .cache
107            .get(name)
108            .ok_or_else(|| RegistryError::NotFound(name.to_string()))?;
109
110        let version_info = metadata
111            .versions
112            .get(version)
113            .ok_or_else(|| RegistryError::NotFound(format!("{}@{}", name, version)))?;
114
115        let tarball_url = &version_info.dist.tarball;
116
117        let response = self
118            .client
119            .get(tarball_url)
120            .send()
121            .await
122            .map_err(|e| RegistryError::Network(e.to_string()))?;
123
124        if !response.status().is_success() {
125            return Err(RegistryError::Http(response.status().as_u16()));
126        }
127
128        let bytes = response
129            .bytes()
130            .await
131            .map_err(|e| RegistryError::Network(e.to_string()))?;
132
133        // Verify integrity if available
134        if let Some(integrity) = &version_info.dist.integrity {
135            verify_integrity(&bytes, integrity)?;
136        }
137
138        Ok(bytes.to_vec())
139    }
140
141    /// Get cached package metadata (without network request)
142    pub fn get_cached(&self, name: &str) -> Option<&PackageMetadata> {
143        self.cache.get(name)
144    }
145
146    /// Clear the package cache
147    pub fn clear_cache(&mut self) {
148        self.cache.clear();
149    }
150}
151
152impl Default for NpmRegistry {
153    fn default() -> Self {
154        Self::new()
155    }
156}
157
158/// Package metadata from npm registry
159#[derive(Debug, Clone, Deserialize, Serialize)]
160pub struct PackageMetadata {
161    pub name: String,
162    #[serde(rename = "dist-tags", default)]
163    pub dist_tags: HashMap<String, String>,
164    #[serde(default)]
165    pub versions: HashMap<String, VersionInfo>,
166}
167
168/// Version-specific package info
169#[derive(Debug, Clone, Deserialize, Serialize)]
170pub struct VersionInfo {
171    pub name: String,
172    pub version: String,
173    #[serde(default)]
174    pub dependencies: Option<HashMap<String, String>>,
175    #[serde(rename = "devDependencies", default)]
176    pub dev_dependencies: Option<HashMap<String, String>>,
177    #[serde(rename = "peerDependencies", default)]
178    pub peer_dependencies: Option<HashMap<String, String>>,
179    #[serde(rename = "optionalDependencies", default)]
180    pub optional_dependencies: Option<HashMap<String, String>>,
181    pub dist: DistInfo,
182}
183
184/// Distribution info (tarball, integrity)
185#[derive(Debug, Clone, Deserialize, Serialize)]
186pub struct DistInfo {
187    pub tarball: String,
188    #[serde(default)]
189    pub shasum: Option<String>,
190    #[serde(default)]
191    pub integrity: Option<String>,
192}
193
194/// Registry errors
195#[derive(Debug, thiserror::Error)]
196pub enum RegistryError {
197    #[error("Package not found: {0}")]
198    NotFound(String),
199
200    #[error("Network error: {0}")]
201    Network(String),
202
203    #[error("HTTP error: {0}")]
204    Http(u16),
205
206    #[error("Parse error: {0}")]
207    Parse(String),
208
209    #[error("Invalid version: {0}")]
210    InvalidVersion(String),
211
212    #[error("No matching version for {name}@{req}")]
213    NoMatchingVersion { name: String, req: String },
214
215    #[error("Integrity check failed")]
216    IntegrityFailed,
217}
218
219/// Encode package name for URL (handle scoped packages)
220fn encode_package_name(name: &str) -> String {
221    if name.starts_with('@') {
222        name.replace('/', "%2f")
223    } else {
224        name.to_string()
225    }
226}
227
228/// Verify package integrity using SHA-512
229fn verify_integrity(data: &[u8], integrity: &str) -> Result<(), RegistryError> {
230    // Format: sha512-base64hash
231    let parts: Vec<&str> = integrity.splitn(2, '-').collect();
232    if parts.len() != 2 || parts[0] != "sha512" {
233        return Ok(()); // Unknown format, skip verification
234    }
235
236    let expected = general_purpose::STANDARD
237        .decode(parts[1])
238        .map_err(|_| RegistryError::IntegrityFailed)?;
239
240    let mut hasher = Sha512::new();
241    hasher.update(data);
242    let actual = hasher.finalize();
243
244    if actual.as_slice() != expected.as_slice() {
245        return Err(RegistryError::IntegrityFailed);
246    }
247
248    Ok(())
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn test_encode_package_name() {
257        assert_eq!(encode_package_name("lodash"), "lodash");
258        assert_eq!(encode_package_name("@types/node"), "@types%2fnode");
259        assert_eq!(encode_package_name("@babel/core"), "@babel%2fcore");
260    }
261
262    #[test]
263    fn test_registry_default() {
264        let registry = NpmRegistry::default();
265        assert_eq!(registry.registry_url, "https://registry.npmjs.org");
266    }
267
268    #[test]
269    fn test_registry_custom_url() {
270        let registry = NpmRegistry::with_registry("https://npm.pkg.github.com/");
271        assert_eq!(registry.registry_url, "https://npm.pkg.github.com");
272    }
273
274    #[tokio::test]
275    #[ignore] // Requires network access
276    async fn test_get_package() {
277        let mut registry = NpmRegistry::new();
278        let result = registry.get_package("lodash").await;
279        if let Ok(pkg) = result {
280            assert_eq!(pkg.name, "lodash");
281            assert!(!pkg.versions.is_empty());
282            assert!(pkg.dist_tags.contains_key("latest"));
283        }
284    }
285
286    #[tokio::test]
287    #[ignore] // Requires network access
288    async fn test_resolve_version() {
289        let mut registry = NpmRegistry::new();
290        // First fetch the package
291        let _ = registry.get_package("lodash").await;
292        // Then resolve a version
293        let result = registry.resolve_version("lodash", "^4.0.0").await;
294        if let Ok(version) = result {
295            assert!(version.starts_with("4."));
296        }
297    }
298
299    #[tokio::test]
300    #[ignore] // Requires network access
301    async fn test_resolve_latest_tag() {
302        let mut registry = NpmRegistry::new();
303        let result = registry.resolve_version("lodash", "latest").await;
304        assert!(result.is_ok());
305    }
306
307    #[tokio::test]
308    #[ignore] // Requires network access
309    async fn test_scoped_package() {
310        let mut registry = NpmRegistry::new();
311        let result = registry.get_package("@types/node").await;
312        if let Ok(pkg) = result {
313            assert_eq!(pkg.name, "@types/node");
314        }
315    }
316}