1use base64::{Engine as _, engine::general_purpose};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha512};
6use std::collections::HashMap;
7
8pub 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 pub async fn get_package(&mut self, name: &str) -> Result<PackageMetadata, RegistryError> {
30 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 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 if let Some(resolved) = metadata.dist_tags.get(version_req) {
72 return Ok(resolved.clone());
73 }
74
75 let req = semver::VersionReq::parse(version_req)
77 .map_err(|e| RegistryError::InvalidVersion(e.to_string()))?;
78
79 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 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 if let Some(integrity) = &version_info.dist.integrity {
135 verify_integrity(&bytes, integrity)?;
136 }
137
138 Ok(bytes.to_vec())
139 }
140
141 pub fn get_cached(&self, name: &str) -> Option<&PackageMetadata> {
143 self.cache.get(name)
144 }
145
146 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#[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#[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#[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#[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
219fn encode_package_name(name: &str) -> String {
221 if name.starts_with('@') {
222 name.replace('/', "%2f")
223 } else {
224 name.to_string()
225 }
226}
227
228fn verify_integrity(data: &[u8], integrity: &str) -> Result<(), RegistryError> {
230 let parts: Vec<&str> = integrity.splitn(2, '-').collect();
232 if parts.len() != 2 || parts[0] != "sha512" {
233 return Ok(()); }
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] 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] async fn test_resolve_version() {
289 let mut registry = NpmRegistry::new();
290 let _ = registry.get_package("lodash").await;
292 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] 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] 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}