1use serde::Deserialize;
6
7use crate::backend::Ctx;
8use crate::error::{Error, Result};
9use crate::http;
10use crate::pipeline::Checksum;
11use crate::source::Source;
12use crate::version::{ToolRequest, ToolVersion, VersionInfo, VersionSpec};
13
14#[derive(Debug, Deserialize)]
15struct VersionDoc {
16 #[serde(default)]
17 dist: Dist,
18}
19
20#[derive(Debug, Deserialize, Default)]
21struct Dist {
22 #[serde(default)]
23 tarball: String,
24 #[serde(default)]
25 integrity: String,
26 #[serde(default)]
27 shasum: String,
28}
29
30#[derive(Debug, Clone)]
32pub struct NpmDist {
33 pub urls: Vec<String>,
34 pub checksum: Option<Checksum>,
35}
36
37pub struct NpmVersions {
38 pub versions: Vec<String>,
39 pub dist_tags: std::collections::BTreeMap<String, String>,
40}
41
42pub async fn resolve_dist(
48 ctx: &Ctx,
49 sources: &[Source],
50 package: &str,
51 version: &str,
52) -> Result<NpmDist> {
53 let mut last_err: Option<Error> = None;
54 let mut urls = Vec::new();
55 let mut checksum: Option<Checksum> = None;
56 let mut checksum_source: Option<String> = None;
57 for source in sources {
58 let url = package_url(&source.download_url, package, Some(version));
59 match http::get_cached_source_json::<VersionDoc>(ctx, source, &url).await {
60 Ok(doc) => {
61 if doc.dist.tarball.is_empty() {
62 last_err = Some(Error::other(format!("no tarball for {package}@{version}")));
63 continue;
64 }
65 if !urls.iter().any(|url| url == &doc.dist.tarball) {
66 urls.push(doc.dist.tarball);
67 }
68 let source_checksum = crate::pipeline::verify::parse_sri(&doc.dist.integrity);
69 let has_source_checksum = source_checksum.is_some();
70 if let Some(source_checksum) = source_checksum {
71 if let Some(expected) = &checksum {
72 if expected.algo != source_checksum.algo
73 || expected.hex != source_checksum.hex
74 {
75 return Err(Error::other(format!(
76 "npm registry integrity mismatch for {package}@{version}: {} disagrees with {}",
77 source.id,
78 checksum_source.as_deref().unwrap_or("another source")
79 )));
80 }
81 } else {
82 checksum_source = Some(source.id.clone());
83 checksum = Some(source_checksum);
84 }
85 }
86 if !has_source_checksum && !doc.dist.shasum.is_empty() {
87 tracing::debug!(
88 package,
89 source = %source.id,
90 "npm dist has only sha1 shasum; skipping verification"
91 );
92 }
93 }
94 Err(e) => {
95 last_err = Some(e);
96 }
97 }
98 }
99 if urls.is_empty() {
100 Err(last_err.unwrap_or_else(|| Error::other(format!("cannot resolve {package}@{version}"))))
101 } else {
102 Ok(NpmDist { urls, checksum })
103 }
104}
105
106pub async fn list_versions(ctx: &Ctx, sources: &[Source], package: &str) -> Result<Vec<String>> {
108 Ok(packument(ctx, sources, package).await?.versions)
109}
110
111pub async fn packument(ctx: &Ctx, sources: &[Source], package: &str) -> Result<NpmVersions> {
112 #[derive(Deserialize)]
113 struct Packument {
114 #[serde(default)]
115 versions: std::collections::BTreeMap<String, serde_json::Value>,
116 #[serde(default, rename = "dist-tags")]
117 dist_tags: std::collections::BTreeMap<String, String>,
118 }
119 let mut last_err: Option<Error> = None;
120 for source in sources {
121 let url = package_url(&source.download_url, package, None);
122 match http::get_cached_source_json::<Packument>(ctx, source, &url).await {
123 Ok(p) => {
124 let mut versions: Vec<String> = p.versions.into_keys().collect();
125 versions.sort_by(|a, b| crate::backend::python::cmp_versions(a, b));
126 return Ok(NpmVersions {
127 versions,
128 dist_tags: p.dist_tags,
129 });
130 }
131 Err(e) => last_err = Some(e),
132 }
133 }
134 Err(last_err.unwrap_or_else(|| Error::other(format!("cannot list {package}"))))
135}
136
137pub async fn resolve_package_version(
138 ctx: &Ctx,
139 sources: &[Source],
140 package: &str,
141 backend: &str,
142 request: &ToolRequest,
143) -> Result<ToolVersion> {
144 if let VersionSpec::Exact(version) = &request.spec {
145 let prerelease = semver::Version::parse(version)
146 .map(|version| !version.pre.is_empty())
147 .unwrap_or(false);
148 if prerelease
149 && matches!(
150 ctx.config.settings.prerelease,
151 crate::config::PrereleasePolicy::Never
152 )
153 {
154 return Err(Error::VersionResolve {
155 tool: backend.into(),
156 spec: version.clone(),
157 hint: Some("pre-release versions are disabled".into()),
158 });
159 }
160 let mut resolved = ToolVersion::new(backend, version);
161 resolved.options = request.options.clone();
162 return Ok(resolved);
163 }
164 let channel = match &request.spec {
165 VersionSpec::Prefix(channel)
166 if matches!(channel.as_str(), "canary" | "nightly" | "beta") =>
167 {
168 Some(channel.as_str())
169 }
170 _ => None,
171 };
172 if channel.is_some()
173 && matches!(
174 ctx.config.settings.prerelease,
175 crate::config::PrereleasePolicy::Never
176 )
177 {
178 return Err(Error::VersionResolve {
179 tool: backend.into(),
180 spec: request.spec.to_string(),
181 hint: Some("pre-release channels are disabled".into()),
182 });
183 }
184 let packument = packument(ctx, sources, package).await?;
185 let version = if let Some(channel) = channel {
186 packument
187 .dist_tags
188 .get(channel)
189 .cloned()
190 .ok_or_else(|| Error::VersionResolve {
191 tool: backend.into(),
192 spec: channel.into(),
193 hint: Some("npm dist-tag is not published".into()),
194 })?
195 } else {
196 let versions = packument
197 .versions
198 .into_iter()
199 .map(|version| VersionInfo {
200 stable: semver::Version::parse(&version)
201 .map(|version| version.pre.is_empty())
202 .unwrap_or(false),
203 version,
204 lts: None,
205 })
206 .collect::<Vec<_>>();
207 crate::version::select_version_with_prerelease(
208 &request.spec,
209 &versions,
210 ctx.config.settings.prerelease,
211 )
212 .ok_or_else(|| Error::VersionResolve {
213 tool: backend.into(),
214 spec: request.spec.to_string(),
215 hint: Some("no version matched prerelease policy".into()),
216 })?
217 .version
218 .clone()
219 };
220 let mut resolved = ToolVersion::new(backend, version);
221 resolved.options = request.options.clone();
222 Ok(resolved)
223}
224
225fn package_url(registry: &str, package: &str, version: Option<&str>) -> String {
226 let package_url = http::join_url(registry, package);
227 match version {
228 Some(version) => http::join_url(&package_url, version),
229 None => package_url,
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use std::io::{Read, Write};
237 use std::net::TcpListener;
238
239 fn test_ctx(root: &std::path::Path) -> Ctx {
240 let dirs = crate::dirs::Dirs::resolve_from(|key| match key {
241 "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
242 "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
243 "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
244 _ => None,
245 })
246 .unwrap();
247 Ctx {
248 dirs: dirs.clone(),
249 platform: crate::platform::Platform::current(),
250 config: crate::config::Config {
251 settings: Default::default(),
252 sources: Default::default(),
253 tools: Default::default(),
254 tool_configs: Default::default(),
255 global_tools: Default::default(),
256 global_tool_configs: Default::default(),
257 tool_origins: Default::default(),
258 aliases: Default::default(),
259 project_config_path: None,
260 },
261 client: reqwest::Client::new(),
262 cas: std::sync::Arc::new(crate::store::Cas::new(dirs.store)),
263 show_progress: false,
264 }
265 }
266
267 #[test]
268 fn builds_scoped_registry_urls() {
269 assert_eq!(
270 package_url(
271 "https://registry.example.test/",
272 "@oven/bun-linux-x64",
273 Some("1.2.3")
274 ),
275 "https://registry.example.test/@oven/bun-linux-x64/1.2.3"
276 );
277 assert_eq!(
278 package_url("https://registry.example.test", "bun", None),
279 "https://registry.example.test/bun"
280 );
281 }
282
283 #[tokio::test]
284 async fn selected_sources_drive_metadata_and_download_failover() {
285 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
286 let address = listener.local_addr().unwrap();
287 let server = std::thread::spawn(move || {
288 for _ in 0..3 {
289 let (mut stream, _) = listener.accept().unwrap();
290 let mut request = Vec::new();
291 let mut buffer = [0u8; 1024];
292 while !request.ends_with(b"\r\n\r\n") {
293 let read = stream.read(&mut buffer).unwrap();
294 if read == 0 {
295 break;
296 }
297 request.extend_from_slice(&buffer[..read]);
298 }
299 let request = String::from_utf8(request).unwrap();
300 let path = request.split_whitespace().nth(1).unwrap();
301 let body = match path {
302 "/primary/tool" => r#"{"versions":{"1.0.0":{},"1.1.0":{}}}"#,
303 "/primary/tool/1.1.0" => {
304 r#"{"dist":{"tarball":"https://primary.invalid/tool.tgz","integrity":"sha512-AQID"}}"#
305 }
306 "/fallback/tool/1.1.0" => {
307 r#"{"dist":{"tarball":"https://fallback.invalid/tool.tgz","integrity":"sha512-AQID"}}"#
308 }
309 other => panic!("unexpected request path: {other}"),
310 };
311 write!(
312 stream,
313 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
314 body.len(),
315 body
316 )
317 .unwrap();
318 }
319 });
320
321 let temp = tempfile::tempdir().unwrap();
322 let dirs = crate::dirs::Dirs::resolve_from(|key| match key {
323 "OSDK_DATA_DIR" => Some(temp.path().join("data").display().to_string()),
324 "OSDK_CACHE_DIR" => Some(temp.path().join("cache").display().to_string()),
325 "OSDK_CONFIG_DIR" => Some(temp.path().join("config").display().to_string()),
326 _ => None,
327 })
328 .unwrap();
329 let ctx = Ctx {
330 dirs: dirs.clone(),
331 platform: crate::platform::Platform::current(),
332 config: crate::config::Config {
333 settings: Default::default(),
334 sources: Default::default(),
335 tools: Default::default(),
336 tool_configs: Default::default(),
337 global_tools: Default::default(),
338 global_tool_configs: Default::default(),
339 tool_origins: Default::default(),
340 aliases: Default::default(),
341 project_config_path: None,
342 },
343 client: reqwest::Client::new(),
344 cas: std::sync::Arc::new(crate::store::Cas::new(dirs.store)),
345 show_progress: false,
346 };
347 let sources = vec![
348 Source::official("primary", &format!("http://{address}/primary")),
349 Source::mirror("fallback", &format!("http://{address}/fallback"), 10),
350 ];
351
352 let versions = list_versions(&ctx, &sources, "tool").await.unwrap();
353 assert_eq!(versions, vec!["1.0.0", "1.1.0"]);
354 let dist = resolve_dist(&ctx, &sources, "tool", "1.1.0").await.unwrap();
355 assert_eq!(
356 dist.urls,
357 vec![
358 "https://primary.invalid/tool.tgz",
359 "https://fallback.invalid/tool.tgz"
360 ]
361 );
362 assert!(dist.checksum.is_some());
363 server.join().unwrap();
364 }
365
366 #[tokio::test]
367 async fn npm_metadata_applies_the_selected_sources_headers() {
368 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
369 let address = listener.local_addr().unwrap();
370 let server = std::thread::spawn(move || {
371 let (mut stream, _) = listener.accept().unwrap();
372 let mut request = Vec::new();
373 let mut buffer = [0u8; 1024];
374 while !request.ends_with(b"\r\n\r\n") {
375 let read = stream.read(&mut buffer).unwrap();
376 if read == 0 {
377 break;
378 }
379 request.extend_from_slice(&buffer[..read]);
380 }
381 let request = String::from_utf8(request).unwrap().to_ascii_lowercase();
382 assert!(request.contains("x-registry-key: source-secret"));
383 let body = r#"{"versions":{"1.0.0":{}}}"#;
384 write!(
385 stream,
386 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
387 body.len(),
388 body
389 )
390 .unwrap();
391 });
392
393 let temp = tempfile::tempdir().unwrap();
394 let ctx = test_ctx(temp.path());
395 let mut source = Source::mirror("private", &format!("http://{address}/"), 1);
396 source.forward_credentials = false;
397 source.headers = vec![("X-Registry-Key".into(), "source-secret".into())];
398
399 let versions = list_versions(&ctx, &[source], "tool").await.unwrap();
400 assert_eq!(versions, vec!["1.0.0"]);
401 server.join().unwrap();
402 }
403
404 #[tokio::test]
405 async fn rejects_integrity_disagreement_between_registries() {
406 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
407 let address = listener.local_addr().unwrap();
408 let server = std::thread::spawn(move || {
409 for index in 0..2 {
410 let (mut stream, _) = listener.accept().unwrap();
411 let mut request = Vec::new();
412 let mut buffer = [0u8; 1024];
413 while !request.ends_with(b"\r\n\r\n") {
414 let read = stream.read(&mut buffer).unwrap();
415 if read == 0 {
416 break;
417 }
418 request.extend_from_slice(&buffer[..read]);
419 }
420 let integrity = if index == 0 {
421 "sha512-AQID"
422 } else {
423 "sha512-BAUG"
424 };
425 let body = format!(
426 r#"{{"dist":{{"tarball":"https://example.invalid/tool.tgz","integrity":"{integrity}"}}}}"#
427 );
428 write!(
429 stream,
430 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
431 body.len(),
432 body
433 )
434 .unwrap();
435 }
436 });
437 let temp = tempfile::tempdir().unwrap();
438 let ctx = test_ctx(temp.path());
439 let sources = vec![
440 Source::official("first", &format!("http://{address}/first")),
441 Source::mirror("second", &format!("http://{address}/second"), 10),
442 ];
443 let error = resolve_dist(&ctx, &sources, "tool", "1.0.0")
444 .await
445 .unwrap_err();
446 assert!(error.to_string().contains("integrity mismatch"));
447 server.join().unwrap();
448 }
449
450 #[tokio::test]
451 async fn dist_tags_and_prerelease_policy_resolve_exact_versions() {
452 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
453 let address = listener.local_addr().unwrap();
454 let server = std::thread::spawn(move || {
455 for _ in 0..3 {
456 let (mut stream, _) = listener.accept().unwrap();
457 let mut request = Vec::new();
458 let mut buffer = [0u8; 1024];
459 while !request.ends_with(b"\r\n\r\n") {
460 let read = stream.read(&mut buffer).unwrap();
461 if read == 0 {
462 break;
463 }
464 request.extend_from_slice(&buffer[..read]);
465 }
466 let body = r#"{"versions":{"1.0.0":{},"1.1.0-canary.1":{}},"dist-tags":{"latest":"1.0.0","canary":"1.1.0-canary.1"}}"#;
467 write!(
468 stream,
469 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
470 body.len(),
471 body
472 )
473 .unwrap();
474 }
475 });
476 let temp = tempfile::tempdir().unwrap();
477 let dirs = crate::dirs::Dirs::resolve_from(|key| match key {
478 "OSDK_DATA_DIR" => Some(temp.path().join("data").display().to_string()),
479 "OSDK_CACHE_DIR" => Some(temp.path().join("cache").display().to_string()),
480 "OSDK_CONFIG_DIR" => Some(temp.path().join("config").display().to_string()),
481 _ => None,
482 })
483 .unwrap();
484 let mut ctx = Ctx {
485 dirs: dirs.clone(),
486 platform: crate::platform::Platform::current(),
487 config: crate::config::Config {
488 settings: Default::default(),
489 sources: Default::default(),
490 tools: Default::default(),
491 tool_configs: Default::default(),
492 global_tools: Default::default(),
493 global_tool_configs: Default::default(),
494 tool_origins: Default::default(),
495 aliases: Default::default(),
496 project_config_path: None,
497 },
498 client: reqwest::Client::new(),
499 cas: std::sync::Arc::new(crate::store::Cas::new(dirs.store)),
500 show_progress: false,
501 };
502 let sources = vec![Source::official("fixture", &format!("http://{address}"))];
503
504 let latest = resolve_package_version(
505 &ctx,
506 &sources,
507 "bun",
508 "bun",
509 &ToolRequest::parse("bun@latest").unwrap(),
510 )
511 .await
512 .unwrap();
513 assert_eq!(latest.version, "1.0.0");
514 let canary = resolve_package_version(
515 &ctx,
516 &sources,
517 "bun",
518 "bun",
519 &ToolRequest::parse("bun@canary").unwrap(),
520 )
521 .await
522 .unwrap();
523 assert_eq!(canary.version, "1.1.0-canary.1");
524 ctx.config.settings.prerelease = crate::config::PrereleasePolicy::Allow;
525 let allowed = resolve_package_version(
526 &ctx,
527 &sources,
528 "bun",
529 "bun",
530 &ToolRequest::parse("bun@latest").unwrap(),
531 )
532 .await
533 .unwrap();
534 assert_eq!(allowed.version, "1.1.0-canary.1");
535 ctx.config.settings.prerelease = crate::config::PrereleasePolicy::Never;
536 assert!(resolve_package_version(
537 &ctx,
538 &sources,
539 "bun",
540 "bun",
541 &ToolRequest::parse("bun@canary").unwrap(),
542 )
543 .await
544 .is_err());
545 assert!(resolve_package_version(
546 &ctx,
547 &sources,
548 "bun",
549 "bun",
550 &ToolRequest::parse("bun@1.1.0-canary.1").unwrap(),
551 )
552 .await
553 .is_err());
554 server.join().unwrap();
555 }
556}