Skip to main content

osdk_core/
npm.rs

1//! Minimal npm registry helpers: resolve a package version's tarball URL +
2//! Subresource Integrity (SRI), used by the pnpm/yarn backends to install
3//! verified artifacts from the registry (mirror-friendly, first-party checksum).
4
5use 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/// Resolved distribution for one package version.
31#[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
42/// Fetch the tarball URL + checksum for `package@version` (e.g. `yarn`,
43/// `@pnpm/linux-x64`). Tries each selected source and retains every returned
44/// tarball URL for download failover. The checksum comes from the first
45/// parseable SRI `integrity` value (sha512/sha256), falling back to the legacy
46/// `shasum` (sha1, unsupported by our verifier -> None).
47pub 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
106/// List available versions of an npm package (sorted ascending), trying mirrors.
107pub 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}