Skip to main content

dnx_core/
registry.rs

1use crate::errors::{DnxError, Result};
2use bytes::Bytes;
3use futures::stream::{self, StreamExt};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::time::Duration;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct PackageMetadata {
10    pub name: String,
11    #[serde(default)]
12    pub versions: HashMap<String, VersionMetadata>,
13    #[serde(rename = "dist-tags", default)]
14    pub dist_tags: HashMap<String, String>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct VersionMetadata {
19    pub name: String,
20    pub version: String,
21    #[serde(default)]
22    pub dependencies: Option<HashMap<String, String>>,
23    #[serde(rename = "devDependencies", default)]
24    pub dev_dependencies: Option<HashMap<String, String>>,
25    #[serde(rename = "peerDependencies", default)]
26    pub peer_dependencies: Option<HashMap<String, String>>,
27    #[serde(rename = "optionalDependencies", default)]
28    pub optional_dependencies: Option<HashMap<String, String>>,
29    #[serde(default)]
30    pub dist: DistInfo,
31    #[serde(default)]
32    pub bin: Option<serde_json::Value>, // can be string or object
33    /// Platform restrictions: list of supported OS names (e.g. ["linux", "darwin"]).
34    /// Prefixing with `!` means "not supported" (e.g. ["!win32"]).
35    #[serde(default)]
36    pub os: Option<Vec<String>>,
37    /// CPU architecture restrictions (e.g. ["x64", "arm64"]).
38    #[serde(default)]
39    pub cpu: Option<Vec<String>>,
40    /// Whether this package has an install script (preinstall, install, postinstall).
41    /// npm registry returns this with abbreviated metadata.
42    #[serde(rename = "hasInstallScript", default)]
43    pub has_install_script: Option<bool>,
44}
45
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47pub struct DistInfo {
48    #[serde(default)]
49    pub tarball: String,
50    #[serde(default)]
51    pub shasum: String,
52    #[serde(default)]
53    pub integrity: Option<String>,
54}
55
56pub struct RegistryClient {
57    client: reqwest::Client,
58    registry_url: String,
59    auth_token: Option<String>,
60    /// Per-scope registry overrides: "@scope" → (url, optional auth_token)
61    scoped_registries: HashMap<String, (String, Option<String>)>,
62}
63
64impl RegistryClient {
65    pub fn new(registry_url: String, auth_token: Option<String>) -> Self {
66        Self::with_config(registry_url, auth_token, None, HashMap::new())
67    }
68
69    pub fn with_config(
70        registry_url: String,
71        auth_token: Option<String>,
72        proxy: Option<String>,
73        scoped_registries: HashMap<String, (String, Option<String>)>,
74    ) -> Self {
75        // Enforce HTTPS for registry URLs
76        let registry_url = if registry_url.starts_with("http://")
77            && !registry_url.contains("localhost")
78            && !registry_url.contains("127.0.0.1")
79        {
80            eprintln!("Warning: Upgrading insecure HTTP registry URL to HTTPS");
81            registry_url.replacen("http://", "https://", 1)
82        } else {
83            registry_url
84        };
85
86        let mut builder = reqwest::Client::builder()
87            .user_agent("dnx/0.1.1")
88            .timeout(Duration::from_secs(30))
89            .pool_max_idle_per_host(20)
90            .tcp_keepalive(Duration::from_secs(60))
91            .tcp_nodelay(true);
92
93        // Configure proxy if provided
94        if let Some(ref proxy_url) = proxy {
95            if let Ok(proxy) = reqwest::Proxy::all(proxy_url) {
96                builder = builder.proxy(proxy);
97            }
98        }
99
100        let client = builder.build().expect("Failed to build HTTP client");
101
102        Self {
103            client,
104            registry_url,
105            auth_token,
106            scoped_registries,
107        }
108    }
109
110    /// Get the registry URL and auth token for a given package name.
111    /// Scoped packages check for per-scope overrides first.
112    fn registry_for_package(&self, name: &str) -> (&str, Option<&str>) {
113        if name.starts_with('@') {
114            if let Some(scope_end) = name.find('/') {
115                let scope = &name[..scope_end];
116                if let Some((url, token)) = self.scoped_registries.get(scope) {
117                    return (url.as_str(), token.as_deref());
118                }
119            }
120        }
121        (&self.registry_url, self.auth_token.as_deref())
122    }
123
124    pub async fn fetch_package_metadata(&self, name: &str) -> Result<PackageMetadata> {
125        // URL encode scoped packages: @ stays, / becomes %2f
126        let encoded_name = if name.starts_with('@') {
127            let parts: Vec<&str> = name.splitn(2, '/').collect();
128            if parts.len() == 2 {
129                format!("{}%2f{}", parts[0], parts[1])
130            } else {
131                name.to_string()
132            }
133        } else {
134            name.to_string()
135        };
136
137        let (registry_url, scope_auth_token) = self.registry_for_package(name);
138        let url = format!("{}/{}", registry_url.trim_end_matches('/'), encoded_name);
139
140        // Retry logic: 3 attempts with exponential backoff
141        let backoff_delays = [
142            Duration::from_millis(100),
143            Duration::from_millis(500),
144            Duration::from_millis(2000),
145        ];
146
147        let mut last_error = None;
148
149        for (attempt, &delay) in backoff_delays.iter().enumerate() {
150            let mut request = self
151                .client
152                .get(&url)
153                .header("Accept", "application/vnd.npm.install-v1+json");
154
155            // Add Bearer auth if token present (scoped token takes priority)
156            let effective_token = scope_auth_token.or(self.auth_token.as_deref());
157            if let Some(token) = effective_token {
158                request = request.header("Authorization", format!("Bearer {}", token));
159            }
160
161            match request.send().await {
162                Ok(response) => {
163                    let status = response.status();
164
165                    if status.is_success() {
166                        return response.json::<PackageMetadata>().await.map_err(|e| {
167                            DnxError::Registry(format!("Failed to parse metadata: {}", e))
168                        });
169                    } else if status.as_u16() == 404 {
170                        return Err(DnxError::PackageNotFound(name.to_string()));
171                    } else {
172                        last_error = Some(DnxError::Registry(format!(
173                            "HTTP {} for package {}",
174                            status, name
175                        )));
176                    }
177                }
178                Err(e) => {
179                    last_error = Some(DnxError::Network(format!(
180                        "Failed to fetch package {}: {}",
181                        name, e
182                    )));
183                }
184            }
185
186            // Sleep before retry (except on last attempt)
187            if attempt < backoff_delays.len() - 1 {
188                tokio::time::sleep(delay).await;
189            }
190        }
191
192        Err(last_error.unwrap_or_else(|| {
193            DnxError::Registry(format!("Failed to fetch package {} after retries", name))
194        }))
195    }
196
197    pub async fn fetch_tarball(&self, url: &str) -> Result<Bytes> {
198        let backoff_delays = [
199            Duration::from_millis(100),
200            Duration::from_millis(500),
201            Duration::from_millis(2000),
202        ];
203
204        let mut last_error = None;
205
206        // Only send auth token if tarball URL matches registry host
207        let should_send_auth = if let Some(ref _token) = self.auth_token {
208            url::Url::parse(url)
209                .ok()
210                .and_then(|u| u.host_str().map(|h| h.to_string()))
211                .map(|tarball_host| {
212                    url::Url::parse(&self.registry_url)
213                        .ok()
214                        .and_then(|u| u.host_str().map(|h| h.to_string()))
215                        .map(|reg_host| tarball_host == reg_host)
216                        .unwrap_or(false)
217                })
218                .unwrap_or(false)
219        } else {
220            false
221        };
222
223        for (attempt, &delay) in backoff_delays.iter().enumerate() {
224            let mut request = self.client.get(url);
225
226            if should_send_auth {
227                if let Some(ref token) = self.auth_token {
228                    request = request.header("Authorization", format!("Bearer {}", token));
229                }
230            }
231
232            match request.send().await {
233                Ok(response) => {
234                    if response.status().is_success() {
235                        return response.bytes().await.map_err(|e| {
236                            DnxError::Network(format!("Failed to download tarball: {}", e))
237                        });
238                    } else {
239                        last_error = Some(DnxError::Registry(format!(
240                            "HTTP {} when fetching tarball",
241                            response.status()
242                        )));
243                    }
244                }
245                Err(e) => {
246                    last_error = Some(DnxError::Network(format!("Failed to fetch tarball: {}", e)));
247                }
248            }
249
250            if attempt < backoff_delays.len() - 1 {
251                tokio::time::sleep(delay).await;
252            }
253        }
254
255        Err(last_error.unwrap_or_else(|| {
256            DnxError::Registry("Failed to fetch tarball after retries".to_string())
257        }))
258    }
259
260    pub async fn fetch_metadata_batch(
261        &self,
262        names: Vec<String>,
263    ) -> Vec<(String, Result<PackageMetadata>)> {
264        let results: Vec<(String, Result<PackageMetadata>)> = stream::iter(names)
265            .map(|name| {
266                let name_clone = name.clone();
267                async move {
268                    let result = self.fetch_package_metadata(&name_clone).await;
269                    (name_clone, result)
270                }
271            })
272            .buffer_unordered(64)
273            .collect()
274            .await;
275
276        results
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn test_client_creation() {
286        let client = RegistryClient::new("https://registry.npmjs.org".to_string(), None);
287        assert_eq!(client.registry_url, "https://registry.npmjs.org");
288        assert!(client.auth_token.is_none());
289    }
290
291    #[test]
292    fn test_client_with_auth() {
293        let client = RegistryClient::new(
294            "https://registry.npmjs.org".to_string(),
295            Some("test-token".to_string()),
296        );
297        assert_eq!(client.auth_token, Some("test-token".to_string()));
298    }
299}