diff --git a/Cargo.lock b/Cargo.lock index 3464c2baf7..84998d22ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2051,6 +2051,19 @@ dependencies = [ "sha2", ] +[[package]] +name = "pacquet-crypto-shasums-file" +version = "0.0.1" +dependencies = [ + "base64 0.22.1", + "derive_more", + "miette 7.6.0", + "pacquet-network", + "pretty_assertions", + "reqwest", + "tokio", +] + [[package]] name = "pacquet-deps-path" version = "0.0.1" @@ -2084,6 +2097,63 @@ dependencies = [ "tracing", ] +[[package]] +name = "pacquet-engine-runtime-bun-resolver" +version = "0.0.1" +dependencies = [ + "derive_more", + "miette 7.6.0", + "pacquet-crypto-shasums-file", + "pacquet-lockfile", + "pacquet-network", + "pacquet-resolving-npm-resolver", + "pacquet-resolving-resolver-base", + "pretty_assertions", + "reqwest", + "serde", + "serde_json", + "ssri", + "tokio", +] + +[[package]] +name = "pacquet-engine-runtime-deno-resolver" +version = "0.0.1" +dependencies = [ + "base64 0.22.1", + "derive_more", + "miette 7.6.0", + "pacquet-lockfile", + "pacquet-network", + "pacquet-resolving-npm-resolver", + "pacquet-resolving-resolver-base", + "pretty_assertions", + "reqwest", + "serde", + "serde_json", + "ssri", + "tokio", +] + +[[package]] +name = "pacquet-engine-runtime-node-resolver" +version = "0.0.1" +dependencies = [ + "derive_more", + "miette 7.6.0", + "node-semver", + "pacquet-crypto-shasums-file", + "pacquet-lockfile", + "pacquet-network", + "pacquet-resolving-resolver-base", + "pretty_assertions", + "reqwest", + "serde", + "serde_json", + "ssri", + "tokio", +] + [[package]] name = "pacquet-executor" version = "0.0.1" @@ -2301,6 +2371,9 @@ dependencies = [ "pacquet-crypto-hash", "pacquet-deps-path", "pacquet-directory-fetcher", + "pacquet-engine-runtime-bun-resolver", + "pacquet-engine-runtime-deno-resolver", + "pacquet-engine-runtime-node-resolver", "pacquet-executor", "pacquet-fs", "pacquet-git-fetcher", diff --git a/Cargo.toml b/Cargo.toml index e25acc9975..8257b6ca98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,10 @@ repository = "https://github.com/pnpm/pacquet" pacquet-cli = { path = "pacquet/crates/cli" } pacquet-cmd-shim = { path = "pacquet/crates/cmd-shim" } pacquet-crypto-hash = { path = "pacquet/crates/crypto-hash" } +pacquet-crypto-shasums-file = { path = "pacquet/crates/crypto-shasums-file" } +pacquet-engine-runtime-bun-resolver = { path = "pacquet/crates/engine-runtime-bun-resolver" } +pacquet-engine-runtime-deno-resolver = { path = "pacquet/crates/engine-runtime-deno-resolver" } +pacquet-engine-runtime-node-resolver = { path = "pacquet/crates/engine-runtime-node-resolver" } pacquet-fs = { path = "pacquet/crates/fs" } pacquet-registry = { path = "pacquet/crates/registry" } pacquet-tarball = { path = "pacquet/crates/tarball" } diff --git a/pacquet/crates/crypto-shasums-file/Cargo.toml b/pacquet/crates/crypto-shasums-file/Cargo.toml new file mode 100644 index 0000000000..c17b2cfb21 --- /dev/null +++ b/pacquet/crates/crypto-shasums-file/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "pacquet-crypto-shasums-file" +version = "0.0.1" +publish = false +authors.workspace = true +description.workspace = true +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +pacquet-network = { workspace = true } + +base64 = { workspace = true } +derive_more = { workspace = true } +miette = { workspace = true } +reqwest = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } + +[lints] +workspace = true diff --git a/pacquet/crates/crypto-shasums-file/src/lib.rs b/pacquet/crates/crypto-shasums-file/src/lib.rs new file mode 100644 index 0000000000..af3f321b71 --- /dev/null +++ b/pacquet/crates/crypto-shasums-file/src/lib.rs @@ -0,0 +1,203 @@ +//! Pacquet port of pnpm's +//! [`@pnpm/crypto.shasums-file`](https://github.com/pnpm/pnpm/blob/1627943d2a/crypto/shasums-file/src/index.ts). +//! +//! Helpers that download and decode the `SHASUMS256.txt` integrity +//! files Node.js, Bun, and similar runtimes publish alongside their +//! binary releases. The file's format is one ` ` +//! row per line; pacquet converts each row into an SRI-style +//! `sha256-` integrity string the lockfile records on the +//! emitted `BinaryResolution`. +//! +//! Two surfaces: +//! +//! - [`fetch_shasums_file`] — download and parse every row at once. +//! The node-resolver and bun-resolver fan the parsed rows out across +//! every artifact a release ships. +//! - [`pick_file_checksum_from_shasums_file`] — re-parse a previously +//! downloaded body to extract the integrity of a single file. The +//! verifier path uses it when only one variant's hash is needed. + +use std::sync::Arc; + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use derive_more::{Display, Error}; +use miette::Diagnostic; +use pacquet_network::ThrottledClient; + +/// One row parsed out of a `SHASUMS256.txt` body. +/// +/// `integrity` is already SRI-encoded (`sha256-`); callers can +/// drop the value straight into an +/// [`ssri::Integrity`](https://docs.rs/ssri/latest/ssri/struct.Integrity.html) +/// via `parse`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShasumsFileItem { + pub integrity: String, + pub file_name: String, +} + +/// Errors raised by [`fetch_shasums_file`] and [`fetch_shasums_file_raw`]. +/// +/// Mirrors upstream's `FAILED_DOWNLOAD_SHASUM_FILE` code, which the +/// install reporter parses as a network-stage failure. +#[derive(Debug, Display, Error, Diagnostic)] +pub enum FetchShasumsFileError { + #[display("Failed to fetch integrity file: {url} (status: {status})")] + #[diagnostic(code(FAILED_DOWNLOAD_SHASUM_FILE))] + StatusNotOk { url: String, status: u16 }, + + #[display("Failed to fetch integrity file: {url}")] + #[diagnostic(code(FAILED_DOWNLOAD_SHASUM_FILE))] + Network { + url: String, + #[error(source)] + error: Arc, + }, +} + +/// Errors raised by [`pick_file_checksum_from_shasums_file`]. +/// +/// Two upstream codes survive the port verbatim — they are the +/// per-file equivalents of `FAILED_DOWNLOAD_SHASUM_FILE`'s download +/// failure and signal that the body the verifier already has does not +/// answer the question being asked. +#[derive(Debug, Display, Error, Diagnostic)] +pub enum PickFileChecksumError { + #[display("SHA-256 hash not found in SHASUMS256.txt for: {file_name}")] + #[diagnostic(code(NODE_INTEGRITY_HASH_NOT_FOUND))] + NotFound { + #[error(not(source))] + file_name: String, + }, + + #[display("Malformed SHA-256 for {file_name}: {sha256}")] + #[diagnostic(code(NODE_MALFORMED_INTEGRITY_HASH))] + Malformed { file_name: String, sha256: String }, +} + +/// Download `` and decode every ` ` row. +/// +/// Mirrors upstream's +/// [`fetchShasumsFile`](https://github.com/pnpm/pnpm/blob/1627943d2a/crypto/shasums-file/src/index.ts#L11-L27). +/// Empty lines are skipped; whitespace between the hash and the +/// filename is split on `\s+` to tolerate the double-space the +/// upstream files actually use *and* any future formatting drift. +pub async fn fetch_shasums_file( + http_client: &ThrottledClient, + shasums_url: &str, +) -> Result, FetchShasumsFileError> { + let body = fetch_shasums_file_raw(http_client, shasums_url).await?; + Ok(parse_shasums_file(&body)) +} + +/// Companion to [`fetch_shasums_file`] that returns the raw body so +/// callers can later pick a single row out with +/// [`pick_file_checksum_from_shasums_file`]. +/// +/// Mirrors upstream's +/// [`fetchShasumsFileRaw`](https://github.com/pnpm/pnpm/blob/1627943d2a/crypto/shasums-file/src/index.ts#L29-L42). +pub async fn fetch_shasums_file_raw( + http_client: &ThrottledClient, + shasums_url: &str, +) -> Result { + let response = + http_client.acquire_for_url(shasums_url).await.get(shasums_url).send().await.map_err( + |error| FetchShasumsFileError::Network { + url: shasums_url.to_string(), + error: Arc::new(error), + }, + )?; + if !response.status().is_success() { + return Err(FetchShasumsFileError::StatusNotOk { + url: shasums_url.to_string(), + status: response.status().as_u16(), + }); + } + response.text().await.map_err(|error| FetchShasumsFileError::Network { + url: shasums_url.to_string(), + error: Arc::new(error), + }) +} + +/// Parse a `SHASUMS256.txt` body into rows. +/// +/// Split out from [`fetch_shasums_file`] so verifier-side code that +/// already has the body in hand can decode it without re-issuing the +/// network request. +pub fn parse_shasums_file(body: &str) -> Vec { + body.lines() + .filter_map(|line| { + if line.is_empty() { + return None; + } + let mut parts = line.split_whitespace(); + let sha256 = parts.next()?; + let file_name = parts.next()?; + Some(ShasumsFileItem { + integrity: encode_sri(sha256), + file_name: file_name.to_string(), + }) + }) + .collect() +} + +/// Pull the integrity of one file out of a body the caller already has. +/// +/// Mirrors upstream's +/// [`pickFileChecksumFromShasumsFile`](https://github.com/pnpm/pnpm/blob/1627943d2a/crypto/shasums-file/src/index.ts#L46-L67): +/// match on a row ending in ` ` (two spaces — the format +/// upstream's files actually use, *not* the `\s+` permissive split +/// [`fetch_shasums_file`] tolerates), validate the hex hash is exactly +/// 64 lower-case hex characters, then re-encode it as `sha256-`. +pub fn pick_file_checksum_from_shasums_file( + body: &str, + file_name: &str, +) -> Result { + let needle = format!(" {file_name}"); + let line = body + .lines() + .find(|line| line.trim_end().ends_with(&needle)) + .ok_or_else(|| PickFileChecksumError::NotFound { file_name: file_name.to_string() })?; + let sha256 = line.split_whitespace().next().unwrap_or(""); + if !is_sha256_hex(sha256) { + return Err(PickFileChecksumError::Malformed { + file_name: file_name.to_string(), + sha256: sha256.to_string(), + }); + } + Ok(encode_sri(sha256)) +} + +/// Decode a 64-character lower-case hex string into `sha256-`. +/// +/// Pre-condition: `hex` is the value [`is_sha256_hex`] already +/// validated *or* an upstream-trusted row that came straight out of a +/// well-formed `SHASUMS256.txt`. The decode is infallible under that +/// pre-condition; we still fall back to an empty string on a decode +/// failure so a hex hash that slipped past validation does not panic. +fn encode_sri(hex: &str) -> String { + let bytes = decode_hex(hex).unwrap_or_default(); + format!("sha256-{}", BASE64_STANDARD.encode(bytes)) +} + +fn is_sha256_hex(value: &str) -> bool { + // Upstream regex is `^[a-f0-9]{64}$` — lowercase only. Matching + // that explicitly keeps a malformed mixed-case hex row from + // sneaking through the validator that the upstream parser would + // have rejected. + value.len() == 64 + && value.bytes().all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn decode_hex(hex: &str) -> Option> { + if !hex.len().is_multiple_of(2) { + return None; + } + (0..hex.len()) + .step_by(2) + .map(|index| u8::from_str_radix(hex.get(index..index + 2)?, 16).ok()) + .collect() +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/crypto-shasums-file/src/tests.rs b/pacquet/crates/crypto-shasums-file/src/tests.rs new file mode 100644 index 0000000000..c293836642 --- /dev/null +++ b/pacquet/crates/crypto-shasums-file/src/tests.rs @@ -0,0 +1,98 @@ +use pretty_assertions::assert_eq; + +use super::{ + PickFileChecksumError, ShasumsFileItem, parse_shasums_file, + pick_file_checksum_from_shasums_file, +}; + +/// Two valid rows are parsed into SRI-encoded integrities. +/// +/// Mirrors upstream's first +/// [`pickFileChecksumFromShasumsFile` test](https://github.com/pnpm/pnpm/blob/1627943d2a/crypto/shasums-file/test/index.ts#L5-L8) +/// — same input body, same expected integrity for the first row. +#[test] +fn parses_rows_into_sri_encoded_integrities() { + let body = "\ +ed52239294ad517fbe91a268146d5d2aa8a17d2d62d64873e43219078ba71c4e foo.tar.gz +be127be1d98cad94c56f46245d0f2de89934d300028694456861a6d5ac558bf3 foo.msi +"; + let items = parse_shasums_file(body); + assert_eq!( + items, + vec![ + ShasumsFileItem { + integrity: "sha256-7VIjkpStUX++kaJoFG1dKqihfS1i1khz5DIZB4unHE4=".to_string(), + file_name: "foo.tar.gz".to_string(), + }, + ShasumsFileItem { + integrity: "sha256-vhJ74dmMrZTFb0YkXQ8t6Jk00wAChpRFaGGm1axVi/M=".to_string(), + file_name: "foo.msi".to_string(), + }, + ], + ); +} + +/// Empty lines anywhere in the body are dropped. +#[test] +fn skips_empty_lines() { + let body = "\n\nabc def\n"; + // The hash isn't valid hex, so this is just exercising the line + // split. We rely on the upstream contract that the body has a + // proper hash; the picker is the validator. + let items = parse_shasums_file(body); + assert_eq!(items.len(), 1); + assert_eq!(items[0].file_name, "def"); +} + +/// Picking the integrity for an existing filename succeeds. +/// +/// Mirrors the +/// [`picks the right checksum for a file`](https://github.com/pnpm/pnpm/blob/1627943d2a/crypto/shasums-file/test/index.ts#L5-L8) +/// upstream test verbatim. +#[test] +fn picks_the_right_checksum_for_a_file() { + let body = "\ +ed52239294ad517fbe91a268146d5d2aa8a17d2d62d64873e43219078ba71c4e foo.tar.gz +be127be1d98cad94c56f46245d0f2de89934d300028694456861a6d5ac558bf3 foo.msi"; + let integrity = pick_file_checksum_from_shasums_file(body, "foo.tar.gz").unwrap(); + assert_eq!(integrity, "sha256-7VIjkpStUX++kaJoFG1dKqihfS1i1khz5DIZB4unHE4="); +} + +/// Picking a filename that isn't in the body raises `NODE_INTEGRITY_HASH_NOT_FOUND`. +/// +/// Mirrors the +/// [`throws an error if no integrity found`](https://github.com/pnpm/pnpm/blob/1627943d2a/crypto/shasums-file/test/index.ts#L9-L12) +/// upstream test. +#[test] +fn missing_file_name_raises_not_found() { + let body = "\ +ed52239294ad517fbe91a268146d5d2aa8a17d2d62d64873e43219078ba71c4e foo.tar.gz +be127be1d98cad94c56f46245d0f2de89934d300028694456861a6d5ac558bf3 foo.msi"; + let err = pick_file_checksum_from_shasums_file(body, "bar.zip").unwrap_err(); + assert!(matches!( + err, + PickFileChecksumError::NotFound { ref file_name } if file_name == "bar.zip", + )); +} + +/// A malformed (too-short) hash in an otherwise well-formed row raises +/// `NODE_MALFORMED_INTEGRITY_HASH` instead of silently truncating the +/// integrity. +/// +/// Mirrors the +/// [`throws an error if a malformed integrity is found`](https://github.com/pnpm/pnpm/blob/1627943d2a/crypto/shasums-file/test/index.ts#L13-L16) +/// upstream test. +#[test] +fn malformed_hash_raises_malformed() { + let body = "\ +ed52239294ad517fbe91 foo.tar.gz +be127be1d98cad94c56f46245d0f2de89934d300028694456861a6d5ac558bf3 foo.msi"; + let err = pick_file_checksum_from_shasums_file(body, "foo.tar.gz").unwrap_err(); + match err { + PickFileChecksumError::Malformed { file_name, sha256 } => { + assert_eq!(file_name, "foo.tar.gz"); + assert_eq!(sha256, "ed52239294ad517fbe91"); + } + other => panic!("expected Malformed, got {other:?}"), + } +} diff --git a/pacquet/crates/engine-runtime-bun-resolver/Cargo.toml b/pacquet/crates/engine-runtime-bun-resolver/Cargo.toml new file mode 100644 index 0000000000..1606a0743c --- /dev/null +++ b/pacquet/crates/engine-runtime-bun-resolver/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "pacquet-engine-runtime-bun-resolver" +version = "0.0.1" +publish = false +authors.workspace = true +description.workspace = true +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +pacquet-crypto-shasums-file = { workspace = true } +pacquet-lockfile = { workspace = true } +pacquet-network = { workspace = true } +pacquet-resolving-npm-resolver = { workspace = true } +pacquet-resolving-resolver-base = { workspace = true } + +derive_more = { workspace = true } +miette = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +ssri = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } + +[lints] +workspace = true diff --git a/pacquet/crates/engine-runtime-bun-resolver/src/bun_resolver.rs b/pacquet/crates/engine-runtime-bun-resolver/src/bun_resolver.rs new file mode 100644 index 0000000000..80bbde6d90 --- /dev/null +++ b/pacquet/crates/engine-runtime-bun-resolver/src/bun_resolver.rs @@ -0,0 +1,187 @@ +//! Pacquet port of upstream's +//! [`resolveBunRuntime` / `resolveLatestBunRuntime`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/bun-resolver/src/index.ts#L24-L92). + +use std::sync::Arc; + +use derive_more::{Display, Error}; +use miette::Diagnostic; +use pacquet_lockfile::{LockfileResolution, VariationsResolution}; +use pacquet_network::ThrottledClient; +use pacquet_resolving_npm_resolver::MINIMUM_RELEASE_AGE_VIOLATION_CODE; +use pacquet_resolving_resolver_base::{ + LatestInfo, LatestQuery, ResolveError, ResolveFuture, ResolveLatestFuture, ResolveOptions, + ResolveResult, Resolver, UpdateBehavior, WantedDependency, +}; + +use crate::read_bun_assets::{ReadBunAssetsError, read_bun_assets}; + +const RESOLVED_VIA: &str = "github.com/oven-sh/bun"; +const BARE_SPEC_PREFIX: &str = "runtime:"; + +/// Errors emitted by [`BunResolver`]. +#[derive(Debug, Display, Error, Diagnostic)] +pub enum BunResolverError { + #[display("Could not resolve Bun version specified as {spec}")] + #[diagnostic(code(BUN_RESOLUTION_FAILURE))] + ResolutionFailure { + #[error(not(source))] + spec: String, + }, + + #[diagnostic(transparent)] + ReadAssets(#[error(source)] ReadBunAssetsError), +} + +/// Bun runtime resolver entry point. +/// +/// Owns the throttled HTTP client (for the GitHub-release SHASUMS +/// fetch) and an `Arc` for the npm resolver that +/// version selection delegates to. +pub struct BunResolver { + pub http_client: Arc, + pub npm_resolver: Arc, +} + +impl BunResolver { + pub fn new(http_client: Arc, npm_resolver: Arc) -> Self { + Self { http_client, npm_resolver } + } +} + +impl Resolver for BunResolver { + fn resolve<'a>( + &'a self, + wanted_dependency: &'a WantedDependency, + opts: &'a ResolveOptions, + ) -> ResolveFuture<'a> { + Box::pin(self.resolve_impl(wanted_dependency, opts)) + } + + fn resolve_latest<'a>( + &'a self, + query: &'a LatestQuery, + opts: &'a ResolveOptions, + ) -> ResolveLatestFuture<'a> { + Box::pin(self.resolve_latest_impl(query, opts)) + } +} + +impl BunResolver { + async fn resolve_impl( + &self, + wanted_dependency: &WantedDependency, + _opts: &ResolveOptions, + ) -> Result, ResolveError> { + let Some(version_spec) = bare_runtime_spec(wanted_dependency, "bun") else { + return Ok(None); + }; + + let npm_result = self + .npm_resolver + .resolve( + &WantedDependency { + alias: wanted_dependency.alias.clone(), + bare_specifier: Some(version_spec.to_string()), + ..wanted_dependency.clone() + }, + &ResolveOptions::default(), + ) + .await?; + let version = npm_result + .as_ref() + .and_then(|result| result.name_ver.as_ref().map(|name_ver| name_ver.suffix.to_string())) + .ok_or_else(|| { + Box::new(BunResolverError::ResolutionFailure { spec: version_spec.to_string() }) + as ResolveError + })?; + + let variants = read_bun_assets(&self.http_client, &version) + .await + .map_err(|err| Box::new(BunResolverError::ReadAssets(err)) as ResolveError)?; + let resolution = LockfileResolution::Variations(VariationsResolution { variants }); + let manifest = serde_json::json!({ + "name": "bun", + "version": version, + "bin": bun_bin_for_current_os(current_platform()), + }); + Ok(Some(ResolveResult { + id: format!("bun@runtime:{version}").into(), + name_ver: None, + latest: None, + published_at: None, + manifest: Some(manifest), + resolution, + resolved_via: RESOLVED_VIA.to_string(), + normalized_bare_specifier: Some(format!("runtime:{version_spec}")), + alias: wanted_dependency.alias.clone(), + policy_violation: None, + })) + } + + async fn resolve_latest_impl( + &self, + query: &LatestQuery, + opts: &ResolveOptions, + ) -> Result, ResolveError> { + let Some(manifest_spec) = bare_runtime_spec(&query.wanted_dependency, "bun") else { + return Ok(None); + }; + let version_spec = + if query.compatible { manifest_spec.to_string() } else { "latest".to_string() }; + let mut resolve_opts = opts.clone(); + if !query.compatible { + resolve_opts.update = UpdateBehavior::Latest; + } + let npm_result = self + .npm_resolver + .resolve( + &WantedDependency { + alias: Some("bun".to_string()), + bare_specifier: Some(version_spec), + ..WantedDependency::default() + }, + &resolve_opts, + ) + .await?; + let Some(npm_result) = npm_result else { + return Ok(Some(LatestInfo::default())); + }; + if npm_result + .policy_violation + .as_ref() + .is_some_and(|violation| violation.code == MINIMUM_RELEASE_AGE_VIOLATION_CODE) + { + return Ok(Some(LatestInfo::default())); + } + let Some(name_ver) = npm_result.name_ver else { + return Ok(Some(LatestInfo::default())); + }; + Ok(Some(LatestInfo { + latest_manifest: Some(serde_json::json!({ + "name": "bun", + "version": name_ver.suffix.to_string(), + })), + })) + } +} + +fn bare_runtime_spec<'a>(wanted: &'a WantedDependency, expected_alias: &str) -> Option<&'a str> { + if wanted.alias.as_deref() != Some(expected_alias) { + return None; + } + wanted.bare_specifier.as_deref().and_then(|spec| spec.strip_prefix(BARE_SPEC_PREFIX)) +} + +fn bun_bin_for_current_os(platform: &str) -> &'static str { + if platform == "win32" { "bun.exe" } else { "bun" } +} + +fn current_platform() -> &'static str { + match std::env::consts::OS { + "windows" => "win32", + other => other, + } +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/engine-runtime-bun-resolver/src/bun_resolver/tests.rs b/pacquet/crates/engine-runtime-bun-resolver/src/bun_resolver/tests.rs new file mode 100644 index 0000000000..37f9653a6f --- /dev/null +++ b/pacquet/crates/engine-runtime-bun-resolver/src/bun_resolver/tests.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; + +use pacquet_network::ThrottledClient; +use pacquet_resolving_resolver_base::{ + LatestInfo, ResolveError, ResolveFuture, ResolveLatestFuture, ResolveOptions, ResolveResult, + Resolver, WantedDependency, +}; + +use super::BunResolver; + +struct StubResolver; +impl Resolver for StubResolver { + fn resolve<'a>( + &'a self, + _wanted_dependency: &'a WantedDependency, + _opts: &'a ResolveOptions, + ) -> ResolveFuture<'a> { + Box::pin(async { Ok::, ResolveError>(None) }) + } + fn resolve_latest<'a>( + &'a self, + _query: &'a pacquet_resolving_resolver_base::LatestQuery, + _opts: &'a ResolveOptions, + ) -> ResolveLatestFuture<'a> { + Box::pin(async { Ok::, ResolveError>(None) }) + } +} + +fn resolver() -> BunResolver { + BunResolver::new(Arc::new(ThrottledClient::new_for_installs()), Arc::new(StubResolver)) +} + +#[tokio::test] +async fn declines_non_bun_alias() { + let wanted = WantedDependency { + alias: Some("node".to_string()), + bare_specifier: Some("runtime:1.0.0".to_string()), + ..WantedDependency::default() + }; + assert!(resolver().resolve(&wanted, &ResolveOptions::default()).await.unwrap().is_none()); +} + +#[tokio::test] +async fn declines_bun_without_runtime_prefix() { + let wanted = WantedDependency { + alias: Some("bun".to_string()), + bare_specifier: Some("^1.0".to_string()), + ..WantedDependency::default() + }; + assert!(resolver().resolve(&wanted, &ResolveOptions::default()).await.unwrap().is_none()); +} diff --git a/pacquet/crates/engine-runtime-bun-resolver/src/lib.rs b/pacquet/crates/engine-runtime-bun-resolver/src/lib.rs new file mode 100644 index 0000000000..85dceea16b --- /dev/null +++ b/pacquet/crates/engine-runtime-bun-resolver/src/lib.rs @@ -0,0 +1,16 @@ +//! Pacquet port of pnpm's +//! [`@pnpm/engine.runtime.bun-resolver`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/bun-resolver/src/index.ts). +//! +//! Resolves `bun@runtime:` dependencies. Same shape as the +//! [`pacquet_engine_runtime_deno_resolver`](https://docs.rs/pacquet-engine-runtime-deno-resolver) +//! port: version selection delegates to the npm resolver, and asset +//! enumeration walks the GitHub Release `SHASUMS256.txt`. Bun's +//! asset names are simpler — one zip per `(platform, arch)` with an +//! optional `-musl` suffix — so the SHASUMS file alone has every +//! integrity needed without per-asset SHA256 sidecar requests. + +mod bun_resolver; +mod read_bun_assets; + +pub use bun_resolver::{BunResolver, BunResolverError}; +pub use read_bun_assets::ReadBunAssetsError; diff --git a/pacquet/crates/engine-runtime-bun-resolver/src/read_bun_assets.rs b/pacquet/crates/engine-runtime-bun-resolver/src/read_bun_assets.rs new file mode 100644 index 0000000000..193a33c140 --- /dev/null +++ b/pacquet/crates/engine-runtime-bun-resolver/src/read_bun_assets.rs @@ -0,0 +1,124 @@ +//! GitHub Release SHASUMS reader for [`crate::BunResolver`]. +//! +//! Bun's release assets share one `SHASUMS256.txt` at +//! `https://github.com/oven-sh/bun/releases/download/bun-v/SHASUMS256.txt`. +//! Each row covers one platform variant — pacquet decodes them in one +//! pass, sorts the result by URL, and emits +//! [`PlatformAssetResolution`] entries +//! the lockfile records. + +use std::sync::Arc; + +use derive_more::{Display, Error}; +use miette::Diagnostic; +use pacquet_crypto_shasums_file::{FetchShasumsFileError, fetch_shasums_file}; +use pacquet_lockfile::{ + BinaryArchive, BinaryResolution, BinarySpec, LockfileResolution, PlatformAssetResolution, + PlatformAssetTarget, +}; +use pacquet_network::ThrottledClient; +use ssri::Integrity; + +#[derive(Debug, Display, Error, Diagnostic)] +pub enum ReadBunAssetsError { + #[diagnostic(transparent)] + FetchShasumsFile(#[error(source)] FetchShasumsFileError), + + #[display("Failed to parse integrity {integrity} for {file_name}")] + #[diagnostic(code(BUN_PARSE_INTEGRITY))] + Integrity { + integrity: String, + file_name: String, + #[error(source)] + error: Arc, + }, +} + +/// Fetch and decode the Bun-release SHASUMS file for `version`. +pub async fn read_bun_assets( + http_client: &ThrottledClient, + version: &str, +) -> Result, ReadBunAssetsError> { + let integrities_url = + format!("https://github.com/oven-sh/bun/releases/download/bun-v{version}/SHASUMS256.txt"); + let items = fetch_shasums_file(http_client, &integrities_url) + .await + .map_err(ReadBunAssetsError::FetchShasumsFile)?; + + let mut variants = Vec::new(); + for item in items { + let Some(parsed) = parse_asset_name(&item.file_name) else { continue }; + let integrity: Integrity = + item.integrity.parse().map_err(|error| ReadBunAssetsError::Integrity { + integrity: item.integrity.clone(), + file_name: item.file_name.clone(), + error: Arc::new(error), + })?; + let url = format!( + "https://github.com/oven-sh/bun/releases/download/bun-v{version}/{file_name}", + file_name = item.file_name, + ); + let prefix = item.file_name.strip_suffix(".zip").map(str::to_string); + let binary = BinaryResolution { + url, + integrity, + bin: BinarySpec::Single(bun_bin_path(&parsed.platform).to_string()), + archive: BinaryArchive::Zip, + prefix, + }; + let target = PlatformAssetTarget { + os: parsed.platform, + cpu: parsed.arch, + libc: parsed.musl.then(|| "musl".to_string()), + }; + variants.push(PlatformAssetResolution { + resolution: LockfileResolution::Binary(binary), + targets: vec![target], + }); + } + variants.sort_by(|a, b| variant_url(a).cmp(variant_url(b))); + Ok(variants) +} + +fn variant_url(variant: &PlatformAssetResolution) -> &str { + match &variant.resolution { + LockfileResolution::Binary(binary) => binary.url.as_str(), + _ => "", + } +} + +struct BunAssetName { + platform: String, + arch: String, + musl: bool, +} + +/// Match upstream's `^bun-([^-.]+)-([^-.]+)(-musl)?\.zip$` regex. +/// +/// `windows` → `win32` and `aarch64` → `arm64` to align with pnpm's +/// canonical `process.platform` / `process.arch` strings. +fn parse_asset_name(file_name: &str) -> Option { + let stem = file_name.strip_suffix(".zip")?; + let body = stem.strip_prefix("bun-")?; + let (platform_raw, after_platform) = body.split_once('-')?; + if platform_raw.is_empty() || platform_raw.contains('.') { + return None; + } + let (arch_raw, musl) = match after_platform.strip_suffix("-musl") { + Some(arch_raw) => (arch_raw, true), + None => (after_platform, false), + }; + if arch_raw.is_empty() || arch_raw.contains('.') || arch_raw.contains('-') { + return None; + } + let platform = if platform_raw == "windows" { "win32" } else { platform_raw }; + let arch = if arch_raw == "aarch64" { "arm64" } else { arch_raw }; + Some(BunAssetName { platform: platform.to_string(), arch: arch.to_string(), musl }) +} + +fn bun_bin_path(os: &str) -> &'static str { + if os == "win32" { "bun.exe" } else { "bun" } +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/engine-runtime-bun-resolver/src/read_bun_assets/tests.rs b/pacquet/crates/engine-runtime-bun-resolver/src/read_bun_assets/tests.rs new file mode 100644 index 0000000000..34c012a7c4 --- /dev/null +++ b/pacquet/crates/engine-runtime-bun-resolver/src/read_bun_assets/tests.rs @@ -0,0 +1,40 @@ +use pretty_assertions::assert_eq; + +use super::parse_asset_name; + +/// macOS Apple Silicon — `bun-darwin-aarch64.zip` → `darwin/arm64`, +/// `arm64` normalized from `aarch64`. +#[test] +fn parses_apple_silicon_zip() { + let parsed = parse_asset_name("bun-darwin-aarch64.zip").unwrap(); + assert_eq!(parsed.platform, "darwin"); + assert_eq!(parsed.arch, "arm64"); + assert!(!parsed.musl); +} + +/// Linux musl x64 — `bun-linux-x64-musl.zip` → `linux/x64/musl`. +#[test] +fn parses_linux_musl_zip() { + let parsed = parse_asset_name("bun-linux-x64-musl.zip").unwrap(); + assert_eq!(parsed.platform, "linux"); + assert_eq!(parsed.arch, "x64"); + assert!(parsed.musl); +} + +/// Windows x64 — `bun-windows-x64.zip` → `win32/x64`, `win32` +/// normalized from `windows`. +#[test] +fn parses_windows_zip() { + let parsed = parse_asset_name("bun-windows-x64.zip").unwrap(); + assert_eq!(parsed.platform, "win32"); + assert_eq!(parsed.arch, "x64"); + assert!(!parsed.musl); +} + +/// Unrelated assets fall through. +#[test] +fn ignores_unrelated_assets() { + assert!(parse_asset_name("SHASUMS256.txt").is_none()); + assert!(parse_asset_name("bun-linux.zip").is_none()); + assert!(parse_asset_name("bun-linux-x64.tar.gz").is_none()); +} diff --git a/pacquet/crates/engine-runtime-deno-resolver/Cargo.toml b/pacquet/crates/engine-runtime-deno-resolver/Cargo.toml new file mode 100644 index 0000000000..030c11ff3d --- /dev/null +++ b/pacquet/crates/engine-runtime-deno-resolver/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "pacquet-engine-runtime-deno-resolver" +version = "0.0.1" +publish = false +authors.workspace = true +description.workspace = true +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +pacquet-lockfile = { workspace = true } +pacquet-network = { workspace = true } +pacquet-resolving-npm-resolver = { workspace = true } +pacquet-resolving-resolver-base = { workspace = true } + +base64 = { workspace = true } +derive_more = { workspace = true } +miette = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +ssri = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } + +[lints] +workspace = true diff --git a/pacquet/crates/engine-runtime-deno-resolver/src/deno_resolver.rs b/pacquet/crates/engine-runtime-deno-resolver/src/deno_resolver.rs new file mode 100644 index 0000000000..406e3a9874 --- /dev/null +++ b/pacquet/crates/engine-runtime-deno-resolver/src/deno_resolver.rs @@ -0,0 +1,194 @@ +//! Pacquet port of pnpm's +//! [`resolveDenoRuntime` / `resolveLatestDenoRuntime`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/deno-resolver/src/index.ts#L34-L123). + +use std::sync::Arc; + +use derive_more::{Display, Error}; +use miette::Diagnostic; +use pacquet_lockfile::{LockfileResolution, VariationsResolution}; +use pacquet_network::ThrottledClient; +use pacquet_resolving_npm_resolver::MINIMUM_RELEASE_AGE_VIOLATION_CODE; +use pacquet_resolving_resolver_base::{ + LatestInfo, LatestQuery, ResolveError, ResolveFuture, ResolveLatestFuture, ResolveOptions, + ResolveResult, Resolver, UpdateBehavior, WantedDependency, +}; + +use crate::read_deno_assets::{ReadDenoAssetsError, read_deno_assets}; + +const RESOLVED_VIA: &str = "github.com/denoland/deno"; +const BARE_SPEC_PREFIX: &str = "runtime:"; + +/// Errors emitted by [`DenoResolver`]. Mirrors upstream's +/// `DENO_RESOLUTION_FAILURE` / `DENO_MISSING_ASSETS` / +/// `DENO_GITHUB_FAILURE` / `DENO_PARSE_HASH` codes. +#[derive(Debug, Display, Error, Diagnostic)] +pub enum DenoResolverError { + #[display("Could not resolve Deno version specified as {spec}")] + #[diagnostic(code(DENO_RESOLUTION_FAILURE))] + ResolutionFailure { + #[error(not(source))] + spec: String, + }, + + #[diagnostic(transparent)] + ReadAssets(#[error(source)] ReadDenoAssetsError), +} + +/// Deno runtime resolver entry point. +/// +/// Holds: +/// * the throttled HTTP client used for the GitHub Releases / SHA256 +/// requests, and +/// * the npm resolver instance the version-selection step delegates +/// to. An `Arc` keeps the dependency loose so the +/// install layer can share one resolver instance between the +/// default chain and the runtime resolvers. +pub struct DenoResolver { + pub http_client: Arc, + pub npm_resolver: Arc, +} + +impl DenoResolver { + pub fn new(http_client: Arc, npm_resolver: Arc) -> Self { + Self { http_client, npm_resolver } + } +} + +impl Resolver for DenoResolver { + fn resolve<'a>( + &'a self, + wanted_dependency: &'a WantedDependency, + opts: &'a ResolveOptions, + ) -> ResolveFuture<'a> { + Box::pin(self.resolve_impl(wanted_dependency, opts)) + } + + fn resolve_latest<'a>( + &'a self, + query: &'a LatestQuery, + opts: &'a ResolveOptions, + ) -> ResolveLatestFuture<'a> { + Box::pin(self.resolve_latest_impl(query, opts)) + } +} + +impl DenoResolver { + async fn resolve_impl( + &self, + wanted_dependency: &WantedDependency, + _opts: &ResolveOptions, + ) -> Result, ResolveError> { + let Some(version_spec) = bare_runtime_spec(wanted_dependency, "deno") else { + return Ok(None); + }; + + let npm_result = self + .npm_resolver + .resolve( + &WantedDependency { + alias: wanted_dependency.alias.clone(), + bare_specifier: Some(version_spec.to_string()), + ..wanted_dependency.clone() + }, + &ResolveOptions::default(), + ) + .await?; + let version = npm_result + .as_ref() + .and_then(|result| result.name_ver.as_ref().map(|name_ver| name_ver.suffix.to_string())) + .ok_or_else(|| { + Box::new(DenoResolverError::ResolutionFailure { spec: version_spec.to_string() }) + as ResolveError + })?; + + let variants = read_deno_assets(&self.http_client, &version) + .await + .map_err(|err| Box::new(DenoResolverError::ReadAssets(err)) as ResolveError)?; + let resolution = LockfileResolution::Variations(VariationsResolution { variants }); + let manifest = serde_json::json!({ + "name": "deno", + "version": version, + "bin": deno_bin_for_current_os(current_platform()), + }); + + Ok(Some(ResolveResult { + id: format!("deno@runtime:{version}").into(), + name_ver: None, + latest: None, + published_at: None, + manifest: Some(manifest), + resolution, + resolved_via: RESOLVED_VIA.to_string(), + normalized_bare_specifier: Some(format!("runtime:{version_spec}")), + alias: wanted_dependency.alias.clone(), + policy_violation: None, + })) + } + + async fn resolve_latest_impl( + &self, + query: &LatestQuery, + opts: &ResolveOptions, + ) -> Result, ResolveError> { + let Some(manifest_spec) = bare_runtime_spec(&query.wanted_dependency, "deno") else { + return Ok(None); + }; + let version_spec = + if query.compatible { manifest_spec.to_string() } else { "latest".to_string() }; + let mut resolve_opts = opts.clone(); + if !query.compatible { + resolve_opts.update = UpdateBehavior::Latest; + } + let npm_result = self + .npm_resolver + .resolve( + &WantedDependency { + alias: Some("deno".to_string()), + bare_specifier: Some(version_spec), + ..WantedDependency::default() + }, + &resolve_opts, + ) + .await?; + let Some(npm_result) = npm_result else { + return Ok(Some(LatestInfo::default())); + }; + if npm_result + .policy_violation + .as_ref() + .is_some_and(|violation| violation.code == MINIMUM_RELEASE_AGE_VIOLATION_CODE) + { + return Ok(Some(LatestInfo::default())); + } + let Some(name_ver) = npm_result.name_ver else { + return Ok(Some(LatestInfo::default())); + }; + Ok(Some(LatestInfo { + latest_manifest: Some(serde_json::json!({ + "name": "deno", + "version": name_ver.suffix.to_string(), + })), + })) + } +} + +fn bare_runtime_spec<'a>(wanted: &'a WantedDependency, expected_alias: &str) -> Option<&'a str> { + if wanted.alias.as_deref() != Some(expected_alias) { + return None; + } + wanted.bare_specifier.as_deref().and_then(|spec| spec.strip_prefix(BARE_SPEC_PREFIX)) +} + +fn deno_bin_for_current_os(platform: &str) -> &'static str { + if platform == "win32" { "deno.exe" } else { "deno" } +} + +fn current_platform() -> &'static str { + match std::env::consts::OS { + "windows" => "win32", + other => other, + } +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/engine-runtime-deno-resolver/src/deno_resolver/tests.rs b/pacquet/crates/engine-runtime-deno-resolver/src/deno_resolver/tests.rs new file mode 100644 index 0000000000..cc96e19556 --- /dev/null +++ b/pacquet/crates/engine-runtime-deno-resolver/src/deno_resolver/tests.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use pacquet_network::ThrottledClient; +use pacquet_resolving_resolver_base::{ + LatestInfo, ResolveError, ResolveFuture, ResolveLatestFuture, ResolveOptions, ResolveResult, + Resolver, WantedDependency, +}; + +use super::DenoResolver; + +struct StubResolver; +impl Resolver for StubResolver { + fn resolve<'a>( + &'a self, + _wanted_dependency: &'a WantedDependency, + _opts: &'a ResolveOptions, + ) -> ResolveFuture<'a> { + Box::pin(async { Ok::, ResolveError>(None) }) + } + fn resolve_latest<'a>( + &'a self, + _query: &'a pacquet_resolving_resolver_base::LatestQuery, + _opts: &'a ResolveOptions, + ) -> ResolveLatestFuture<'a> { + Box::pin(async { Ok::, ResolveError>(None) }) + } +} + +fn resolver() -> DenoResolver { + DenoResolver::new(Arc::new(ThrottledClient::new_for_installs()), Arc::new(StubResolver)) +} + +/// Non-deno alias is declined. +#[tokio::test] +async fn declines_non_deno_alias() { + let wanted = WantedDependency { + alias: Some("node".to_string()), + bare_specifier: Some("runtime:1.0.0".to_string()), + ..WantedDependency::default() + }; + assert!(resolver().resolve(&wanted, &ResolveOptions::default()).await.unwrap().is_none()); +} + +/// `deno` alias without a `runtime:` prefix is declined. +#[tokio::test] +async fn declines_deno_without_runtime_prefix() { + let wanted = WantedDependency { + alias: Some("deno".to_string()), + bare_specifier: Some("^1.0".to_string()), + ..WantedDependency::default() + }; + assert!(resolver().resolve(&wanted, &ResolveOptions::default()).await.unwrap().is_none()); +} diff --git a/pacquet/crates/engine-runtime-deno-resolver/src/lib.rs b/pacquet/crates/engine-runtime-deno-resolver/src/lib.rs new file mode 100644 index 0000000000..a7fe1126c8 --- /dev/null +++ b/pacquet/crates/engine-runtime-deno-resolver/src/lib.rs @@ -0,0 +1,26 @@ +//! Pacquet port of pnpm's +//! [`@pnpm/engine.runtime.deno-resolver`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/deno-resolver/src/index.ts). +//! +//! Resolves `deno@runtime:` dependencies. Two pieces: +//! +//! 1. **Version selection** delegates to the npm resolver — Deno +//! publishes a `deno` package on the registry whose `manifest.version` +//! field tracks every GitHub release. Using the registry avoids the +//! paginated GitHub releases API and keeps `minimumReleaseAge` +//! enforcement uniform with the rest of the install. +//! 2. **Asset enumeration** then talks to the GitHub Releases API for +//! that tag, downloads each artifact's per-file `.sha256sum`, and +//! emits one [`PlatformAssetResolution`](pacquet_lockfile::PlatformAssetResolution) +//! per `(os, cpu)` triple. +//! +//! Architecture differs slightly from upstream: pacquet's resolver +//! trait owns an [`Arc`](pacquet_resolving_resolver_base::Resolver) +//! for the npm side rather than taking a function reference, so the +//! same instance can plug into the default-resolver chain both +//! directly and as the version-selection dependency of this resolver. + +mod deno_resolver; +mod read_deno_assets; + +pub use deno_resolver::{DenoResolver, DenoResolverError}; +pub use read_deno_assets::ReadDenoAssetsError; diff --git a/pacquet/crates/engine-runtime-deno-resolver/src/read_deno_assets.rs b/pacquet/crates/engine-runtime-deno-resolver/src/read_deno_assets.rs new file mode 100644 index 0000000000..424d59f0aa --- /dev/null +++ b/pacquet/crates/engine-runtime-deno-resolver/src/read_deno_assets.rs @@ -0,0 +1,256 @@ +//! GitHub Releases asset enumerator for [`crate::DenoResolver`]. +//! +//! Each Deno release publishes a `deno---.zip` +//! per platform plus a sibling `.sha256sum` text file with +//! the hex-encoded SHA-256. This module: +//! +//! 1. GETs `https://api.github.com/repos/denoland/deno/releases/tags/v` +//! and pulls the `assets[]` list. +//! 2. For each asset whose filename matches the deno-release pattern, +//! fetches the sibling `.sha256sum`, decodes the hex hash, and +//! builds a [`PlatformAssetResolution`]. +//! 3. Sorts the resulting variants lexically by URL — same as upstream's +//! [`lexCompare`](https://github.com/pnpm/util.lex-comparator/blob/main/src/index.ts) +//! — so the lockfile stays diff-stable across runs. + +use std::sync::Arc; + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use derive_more::{Display, Error}; +use miette::Diagnostic; +use pacquet_lockfile::{ + BinaryArchive, BinaryResolution, BinarySpec, LockfileResolution, PlatformAssetResolution, + PlatformAssetTarget, +}; +use pacquet_network::ThrottledClient; +use serde::Deserialize; +use ssri::Integrity; + +/// Errors raised by the asset enumerator. Surfaced through +/// [`DenoResolverError::ReadAssets`](crate::DenoResolverError::ReadAssets). +#[derive(Debug, Display, Error, Diagnostic)] +pub enum ReadDenoAssetsError { + #[display("No assets found for Deno v{version}")] + #[diagnostic(code(DENO_MISSING_ASSETS))] + MissingAssets { + #[error(not(source))] + version: String, + }, + + #[display("Failed to GET sha256 at {url}")] + #[diagnostic(code(DENO_GITHUB_FAILURE))] + GithubFailure { + url: String, + #[error(source)] + error: Arc, + }, + + #[display("Failed to GET sha256 at {url} (status: {status})")] + #[diagnostic(code(DENO_GITHUB_FAILURE))] + GithubStatus { url: String, status: u16 }, + + #[display("No SHA256 in {url}")] + #[diagnostic(code(DENO_PARSE_HASH))] + ParseHash { + #[error(not(source))] + url: String, + }, + + #[display("Failed to GET release index for Deno v{version}")] + #[diagnostic(code(DENO_GITHUB_FAILURE))] + FetchReleaseIndex { + version: String, + #[error(source)] + error: Arc, + }, + + #[display("Failed to decode release index for Deno v{version}")] + #[diagnostic(code(DENO_GITHUB_FAILURE))] + DecodeReleaseIndex { + version: String, + #[error(source)] + error: Arc, + }, + + #[display("Failed to parse integrity for {url}")] + #[diagnostic(code(DENO_PARSE_HASH))] + Integrity { + url: String, + #[error(source)] + error: Arc, + }, +} + +#[derive(Deserialize)] +struct ReleaseIndex { + #[serde(default)] + assets: Option>, +} + +#[derive(Deserialize)] +struct ReleaseAsset { + name: String, + browser_download_url: String, +} + +/// Fetch and decode every Deno release asset for `version`. +pub async fn read_deno_assets( + http_client: &ThrottledClient, + version: &str, +) -> Result, ReadDenoAssetsError> { + let release_index_url = + format!("https://api.github.com/repos/denoland/deno/releases/tags/v{version}"); + let response = http_client + .acquire_for_url(&release_index_url) + .await + .get(&release_index_url) + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(|error| ReadDenoAssetsError::FetchReleaseIndex { + version: version.to_string(), + error: Arc::new(error), + })?; + let body = response.text().await.map_err(|error| ReadDenoAssetsError::FetchReleaseIndex { + version: version.to_string(), + error: Arc::new(error), + })?; + let index: ReleaseIndex = + serde_json::from_str(&body).map_err(|error| ReadDenoAssetsError::DecodeReleaseIndex { + version: version.to_string(), + error: Arc::new(error), + })?; + let assets = index + .assets + .ok_or_else(|| ReadDenoAssetsError::MissingAssets { version: version.to_string() })?; + + let mut variants = Vec::new(); + for asset in &assets { + let Some(targets) = parse_asset_name(&asset.name) else { continue }; + let sha256 = fetch_sha256(http_client, &asset.browser_download_url).await?; + // `fetch_sha256` already validates that `sha256` is a 64-char + // lower-case hex run via `extract_sha256`, so `decode_hex` + // cannot fail here. Map the impossible-failure branch to + // `DENO_PARSE_HASH` rather than silently falling back to an + // empty byte slice so a future change to `extract_sha256` + // that loosens the validator surfaces with the right error + // code instead of an opaque integrity-parse failure. + let hex_bytes = decode_hex(&sha256).ok_or_else(|| ReadDenoAssetsError::ParseHash { + url: asset.browser_download_url.clone(), + })?; + let integrity_string = format!("sha256-{}", BASE64_STANDARD.encode(hex_bytes)); + let integrity: Integrity = + integrity_string.parse().map_err(|error| ReadDenoAssetsError::Integrity { + url: asset.browser_download_url.clone(), + error: Arc::new(error), + })?; + let archive_url = asset + .browser_download_url + .strip_suffix(".sha256sum") + .unwrap_or(&asset.browser_download_url) + .to_string(); + let binary = BinaryResolution { + url: archive_url, + integrity, + bin: BinarySpec::Single(deno_bin_path(&targets[0].os).to_string()), + archive: BinaryArchive::Zip, + prefix: None, + }; + variants.push(PlatformAssetResolution { + resolution: LockfileResolution::Binary(binary), + targets, + }); + } + variants.sort_by(|a, b| variant_url(a).cmp(variant_url(b))); + Ok(variants) +} + +fn variant_url(variant: &PlatformAssetResolution) -> &str { + match &variant.resolution { + LockfileResolution::Binary(binary) => binary.url.as_str(), + _ => "", + } +} + +/// Parse `deno--.zip.sha256sum` into the +/// [`PlatformAssetTarget`]s the variant covers. +/// +/// Two architectures (`aarch64`, `x86_64`) × three vendor-os pairs +/// (`apple-darwin`, `unknown-linux-gnu`, `pc-windows-msvc`) — anything +/// else falls through unmatched. Windows x64 also lists `arm64` in +/// its targets because the Windows x64 build runs natively on arm64 +/// hosts under emulation. +fn parse_asset_name(name: &str) -> Option> { + let stem = name.strip_suffix(".zip.sha256sum")?; + let body = stem.strip_prefix("deno-")?; + let (cpu_raw, vendor_os) = body.split_once('-')?; + let cpu = match cpu_raw { + "aarch64" => "arm64", + "x86_64" => "x64", + _ => return None, + }; + let os = match vendor_os { + "apple-darwin" => "darwin", + "unknown-linux-gnu" => "linux", + "pc-windows-msvc" => "win32", + _ => return None, + }; + let mut targets = + vec![PlatformAssetTarget { os: os.to_string(), cpu: cpu.to_string(), libc: None }]; + if os == "win32" && cpu == "x64" { + targets.push(PlatformAssetTarget { + os: "win32".to_string(), + cpu: "arm64".to_string(), + libc: None, + }); + } + Some(targets) +} + +async fn fetch_sha256( + http_client: &ThrottledClient, + url: &str, +) -> Result { + let response = + http_client.acquire_for_url(url).await.get(url).send().await.map_err(|error| { + ReadDenoAssetsError::GithubFailure { url: url.to_string(), error: Arc::new(error) } + })?; + if !response.status().is_success() { + return Err(ReadDenoAssetsError::GithubStatus { + url: url.to_string(), + status: response.status().as_u16(), + }); + } + let body = response.text().await.map_err(|error| ReadDenoAssetsError::GithubFailure { + url: url.to_string(), + error: Arc::new(error), + })?; + extract_sha256(&body).ok_or_else(|| ReadDenoAssetsError::ParseHash { url: url.to_string() }) +} + +/// Lift a 64-character hex string out of an arbitrary body. Mirrors +/// upstream's `txt.match(/([a-f0-9]{64})/i)` regex. +fn extract_sha256(body: &str) -> Option { + let bytes = body.as_bytes(); + bytes + .windows(64) + .find(|window| window.iter().all(|byte| byte.is_ascii_hexdigit())) + .map(|window| String::from_utf8_lossy(window).to_ascii_lowercase()) +} + +fn decode_hex(hex: &str) -> Option> { + if !hex.len().is_multiple_of(2) { + return None; + } + (0..hex.len()) + .step_by(2) + .map(|index| u8::from_str_radix(hex.get(index..index + 2)?, 16).ok()) + .collect() +} + +fn deno_bin_path(os: &str) -> &'static str { + if os == "win32" { "deno.exe" } else { "deno" } +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/engine-runtime-deno-resolver/src/read_deno_assets/tests.rs b/pacquet/crates/engine-runtime-deno-resolver/src/read_deno_assets/tests.rs new file mode 100644 index 0000000000..683db62bb6 --- /dev/null +++ b/pacquet/crates/engine-runtime-deno-resolver/src/read_deno_assets/tests.rs @@ -0,0 +1,59 @@ +use pretty_assertions::assert_eq; + +use super::{extract_sha256, parse_asset_name}; + +/// macOS Apple Silicon — `aarch64-apple-darwin` → one `(darwin, arm64)` +/// target. +#[test] +fn parses_apple_silicon_asset_name() { + let targets = parse_asset_name("deno-aarch64-apple-darwin.zip.sha256sum").unwrap(); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].os, "darwin"); + assert_eq!(targets[0].cpu, "arm64"); +} + +/// Linux glibc x64 — `x86_64-unknown-linux-gnu` → one `(linux, x64)` +/// target, `libc=None` (glibc is implicit). +#[test] +fn parses_linux_glibc_asset_name() { + let targets = parse_asset_name("deno-x86_64-unknown-linux-gnu.zip.sha256sum").unwrap(); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].os, "linux"); + assert_eq!(targets[0].cpu, "x64"); + assert!(targets[0].libc.is_none()); +} + +/// Windows x64 covers arm64 under emulation — both targets land on +/// the same variant. +#[test] +fn windows_x64_covers_arm64_under_emulation() { + let targets = parse_asset_name("deno-x86_64-pc-windows-msvc.zip.sha256sum").unwrap(); + assert_eq!(targets.len(), 2); + assert_eq!(targets[0].os, "win32"); + assert_eq!(targets[0].cpu, "x64"); + assert_eq!(targets[1].os, "win32"); + assert_eq!(targets[1].cpu, "arm64"); +} + +/// Non-matching filenames fall through. +#[test] +fn ignores_unrelated_asset_names() { + assert!(parse_asset_name("deno-linux.zip").is_none()); + assert!(parse_asset_name("deno-x86_64-unknown-freebsd.zip.sha256sum").is_none()); + assert!(parse_asset_name("README.md").is_none()); +} + +/// The SHA256 extractor lifts the first 64-character hex run out of an +/// arbitrary body and lower-cases it. +#[test] +fn extract_sha256_lifts_first_hex_run() { + let body = "ED52239294AD517FBE91A268146D5D2AA8A17D2D62D64873E43219078BA71C4E deno.zip"; + let hash = extract_sha256(body).unwrap(); + assert_eq!(hash, "ed52239294ad517fbe91a268146d5d2aa8a17d2d62d64873e43219078ba71c4e"); +} + +/// No 64-char hex run → `None`. +#[test] +fn extract_sha256_none_when_missing() { + assert!(extract_sha256("no hashes here").is_none()); +} diff --git a/pacquet/crates/engine-runtime-node-resolver/Cargo.toml b/pacquet/crates/engine-runtime-node-resolver/Cargo.toml new file mode 100644 index 0000000000..359baa3fab --- /dev/null +++ b/pacquet/crates/engine-runtime-node-resolver/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "pacquet-engine-runtime-node-resolver" +version = "0.0.1" +publish = false +authors.workspace = true +description.workspace = true +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +pacquet-crypto-shasums-file = { workspace = true } +pacquet-lockfile = { workspace = true } +pacquet-network = { workspace = true } +pacquet-resolving-resolver-base = { workspace = true } + +derive_more = { workspace = true } +miette = { workspace = true } +node-semver = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +ssri = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } + +[lints] +workspace = true diff --git a/pacquet/crates/engine-runtime-node-resolver/src/get_node_artifact_address.rs b/pacquet/crates/engine-runtime-node-resolver/src/get_node_artifact_address.rs new file mode 100644 index 0000000000..e2b4e200b5 --- /dev/null +++ b/pacquet/crates/engine-runtime-node-resolver/src/get_node_artifact_address.rs @@ -0,0 +1,53 @@ +//! Pacquet port of +//! [`getNodeArtifactAddress.ts`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/getNodeArtifactAddress.ts). + +use crate::normalize_arch::get_normalized_arch; + +/// Three pieces of the full archive URL for one Node.js artifact. +/// +/// Splitting them keeps the caller free to compose either the URL +/// (`{dirname}/{basename}{extname}`) or the zip-prefix +/// (`{basename}`) — pnpm's +/// [`BinaryResolution.prefix`](https://github.com/pnpm/pnpm/blob/94240bc046/resolving/resolver-base/src/index.ts#L41-L49) +/// needs only the basename, while the `url` field needs the full +/// concatenation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NodeArtifactAddress { + pub basename: String, + pub extname: String, + pub dirname: String, +} + +/// Options bundle for [`get_node_artifact_address`]. +/// +/// `libc` only ever takes `Some("musl")` upstream — glibc is the +/// implicit default and is *not* included in the archive name. The +/// type is `Option<&str>` so future libc additions don't churn the +/// API. +#[derive(Debug, Clone, Copy)] +pub struct GetNodeArtifactAddressOptions<'a> { + pub version: &'a str, + pub base_url: &'a str, + pub platform: &'a str, + pub arch: &'a str, + pub libc: Option<&'a str>, +} + +/// Compose the archive URL pieces for a single Node.js platform variant. +pub fn get_node_artifact_address(opts: GetNodeArtifactAddressOptions<'_>) -> NodeArtifactAddress { + let is_windows = opts.platform == "win32"; + let normalized_platform = if is_windows { "win" } else { opts.platform }; + let normalized_arch = get_normalized_arch(opts.platform, opts.arch, Some(opts.version)); + let arch_suffix = if opts.libc == Some("musl") { "-musl" } else { "" }; + NodeArtifactAddress { + dirname: format!("{base_url}v{version}", base_url = opts.base_url, version = opts.version), + basename: format!( + "node-v{version}-{normalized_platform}-{normalized_arch}{arch_suffix}", + version = opts.version, + ), + extname: if is_windows { ".zip".to_string() } else { ".tar.gz".to_string() }, + } +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/engine-runtime-node-resolver/src/get_node_artifact_address/tests.rs b/pacquet/crates/engine-runtime-node-resolver/src/get_node_artifact_address/tests.rs new file mode 100644 index 0000000000..3f4dbb936d --- /dev/null +++ b/pacquet/crates/engine-runtime-node-resolver/src/get_node_artifact_address/tests.rs @@ -0,0 +1,104 @@ +use pretty_assertions::assert_eq; + +use super::{GetNodeArtifactAddressOptions, NodeArtifactAddress, get_node_artifact_address}; + +/// Mirrors the +/// [`getNodeArtifactAddress`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/test/getNodeArtifactAddress.test.ts) +/// table-driven upstream test: every `(version, mirror, platform, arch)` +/// combination produces the same three-part address — including the +/// pre-16 `darwin/arm64 → x64` Rosetta fall-back the upstream test +/// exercises, the `win32/ia32 → x86` quirk, and the `linux/arm → +/// armv7l` Raspberry-Pi mapping. +#[test] +fn matches_upstream_address_table() { + let cases: &[(&str, &str, &str, &str, NodeArtifactAddress)] = &[ + ( + "16.0.0", + "https://nodejs.org/download/release/", + "win32", + "ia32", + NodeArtifactAddress { + basename: "node-v16.0.0-win-x86".to_string(), + dirname: "https://nodejs.org/download/release/v16.0.0".to_string(), + extname: ".zip".to_string(), + }, + ), + ( + "16.0.0", + "https://nodejs.org/download/release/", + "linux", + "arm", + NodeArtifactAddress { + basename: "node-v16.0.0-linux-armv7l".to_string(), + dirname: "https://nodejs.org/download/release/v16.0.0".to_string(), + extname: ".tar.gz".to_string(), + }, + ), + ( + "16.0.0", + "https://nodejs.org/download/release/", + "linux", + "x64", + NodeArtifactAddress { + basename: "node-v16.0.0-linux-x64".to_string(), + dirname: "https://nodejs.org/download/release/v16.0.0".to_string(), + extname: ".tar.gz".to_string(), + }, + ), + ( + "15.14.0", + "https://nodejs.org/download/release/", + "darwin", + "arm64", + NodeArtifactAddress { + basename: "node-v15.14.0-darwin-x64".to_string(), + dirname: "https://nodejs.org/download/release/v15.14.0".to_string(), + extname: ".tar.gz".to_string(), + }, + ), + ( + "16.0.0", + "https://nodejs.org/download/release/", + "darwin", + "arm64", + NodeArtifactAddress { + basename: "node-v16.0.0-darwin-arm64".to_string(), + dirname: "https://nodejs.org/download/release/v16.0.0".to_string(), + extname: ".tar.gz".to_string(), + }, + ), + ]; + for (version, base_url, platform, arch, expected) in cases { + let actual = get_node_artifact_address(GetNodeArtifactAddressOptions { + version, + base_url, + platform, + arch, + libc: None, + }); + assert_eq!(&actual, expected); + } +} + +/// `libc=Some("musl")` appends the `-musl` suffix to the basename and +/// keeps everything else identical. Mirrors upstream's +/// `getNodeArtifactAddress with libc=musl appends -musl suffix to arch` +/// test. +#[test] +fn libc_musl_appends_suffix_to_arch() { + let actual = get_node_artifact_address(GetNodeArtifactAddressOptions { + version: "22.0.0", + base_url: "https://unofficial-builds.nodejs.org/download/release/", + platform: "linux", + arch: "x64", + libc: Some("musl"), + }); + assert_eq!( + actual, + NodeArtifactAddress { + basename: "node-v22.0.0-linux-x64-musl".to_string(), + dirname: "https://unofficial-builds.nodejs.org/download/release/v22.0.0".to_string(), + extname: ".tar.gz".to_string(), + }, + ); +} diff --git a/pacquet/crates/engine-runtime-node-resolver/src/get_node_mirror.rs b/pacquet/crates/engine-runtime-node-resolver/src/get_node_mirror.rs new file mode 100644 index 0000000000..e4bdda5570 --- /dev/null +++ b/pacquet/crates/engine-runtime-node-resolver/src/get_node_mirror.rs @@ -0,0 +1,41 @@ +//! Pacquet port of +//! [`getNodeMirror.ts`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/getNodeMirror.ts). + +use std::collections::HashMap; + +/// Default mirror for the official Node.js releases. +/// +/// Mirrors upstream's +/// [`DEFAULT_NODE_MIRROR_BASE_URL`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/index.ts#L25). +pub const DEFAULT_NODE_MIRROR_BASE_URL: &str = "https://nodejs.org/download/release/"; + +/// Mirror for the unofficial Node.js builds (musl variants). +/// +/// Mirrors upstream's +/// [`UNOFFICIAL_NODE_MIRROR_BASE_URL`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/index.ts#L26). +pub const UNOFFICIAL_NODE_MIRROR_BASE_URL: &str = + "https://unofficial-builds.nodejs.org/download/release/"; + +/// Resolve the base URL for a given release channel. +/// +/// `node_download_mirrors` is the user's `.npmrc`/config override map +/// keyed by channel (`release`, `nightly`, `rc`, `test`, `v8-canary`). +/// A missing entry falls back to the official nodejs.org tree. The +/// returned URL always ends with `/` so callers can concatenate +/// `v/...` without a defensive check. +pub fn get_node_mirror( + node_download_mirrors: Option<&HashMap>, + release_channel: &str, +) -> String { + let mirror = node_download_mirrors + .and_then(|map| map.get(release_channel).cloned()) + .unwrap_or_else(|| format!("https://nodejs.org/download/{release_channel}/")); + normalize_node_mirror(&mirror) +} + +fn normalize_node_mirror(mirror: &str) -> String { + if mirror.ends_with('/') { mirror.to_string() } else { format!("{mirror}/") } +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/engine-runtime-node-resolver/src/get_node_mirror/tests.rs b/pacquet/crates/engine-runtime-node-resolver/src/get_node_mirror/tests.rs new file mode 100644 index 0000000000..75df567fa8 --- /dev/null +++ b/pacquet/crates/engine-runtime-node-resolver/src/get_node_mirror/tests.rs @@ -0,0 +1,40 @@ +use std::collections::HashMap; + +use pretty_assertions::assert_eq; + +use super::get_node_mirror; + +/// Mirrors upstream's +/// [`getNodeMirror.test.ts`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/test/getNodeMirror.test.ts): +/// a configured mirror per channel always wins over the default. +#[test] +fn configured_mirror_per_channel_wins_over_default() { + for (channel, host) in [ + ("release", "http://test.mirror.localhost/release"), + ("nightly", "http://test.mirror.localhost/nightly"), + ("rc", "http://test.mirror.localhost/rc"), + ("test", "http://test.mirror.localhost/test"), + ("v8-canary", "http://test.mirror.localhost/v8-canary"), + ] { + let mirrors = HashMap::from([(channel.to_string(), host.to_string())]); + assert_eq!(get_node_mirror(Some(&mirrors), channel), format!("{host}/")); + } +} + +/// Empty mirror config falls back to the per-channel default +/// `https://nodejs.org/download//`. +#[test] +fn uses_defaults_when_unconfigured() { + let empty = HashMap::new(); + assert_eq!(get_node_mirror(Some(&empty), "release"), "https://nodejs.org/download/release/"); + assert_eq!(get_node_mirror(None, "release"), "https://nodejs.org/download/release/"); +} + +/// Missing trailing slash on a configured mirror is appended so the +/// caller can concatenate `v/...` without a guard. +#[test] +fn appends_trailing_slash_when_missing() { + let mirrors = + HashMap::from([("release".to_string(), "http://test.mirror.localhost".to_string())]); + assert_eq!(get_node_mirror(Some(&mirrors), "release"), "http://test.mirror.localhost/"); +} diff --git a/pacquet/crates/engine-runtime-node-resolver/src/lib.rs b/pacquet/crates/engine-runtime-node-resolver/src/lib.rs new file mode 100644 index 0000000000..8f96c1d1b9 --- /dev/null +++ b/pacquet/crates/engine-runtime-node-resolver/src/lib.rs @@ -0,0 +1,52 @@ +//! Pacquet port of pnpm's +//! [`@pnpm/engine.runtime.node-resolver`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/index.ts). +//! +//! Resolves `node@runtime:` dependencies against the Node.js +//! release index. The bare specifier carries a release channel +//! (`release`, `nightly`, `rc`, `test`, `v8-canary`) plus a version +//! selector that may be a semver range, an exact version, a dist tag +//! (`lts`, `latest`), or one of the LTS codenames (`argon`, `iron`, +//! …). Once a concrete version is picked, the resolver crawls the +//! mirror's `SHASUMS256.txt` to enumerate every platform-specific +//! artifact and emits one +//! [`VariationsResolution`](pacquet_lockfile::VariationsResolution) +//! variant per `(os, cpu, libc?)` triple. +//! +//! Three pieces: +//! +//! - [`parse_node_specifier()`] — recognise the `/`, +//! ``, prerelease, alias, and bare-range forms. Ports +//! [`parseNodeSpecifier.ts`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/parseNodeSpecifier.ts). +//! - [`get_node_mirror()`] / [`get_node_artifact_address()`] / +//! [`get_normalized_arch`] — mirror URL normalisation, archive URL +//! composition, and the arch quirks for ia32 Windows / armv7l Linux +//! / Apple-Silicon-on-pre-16 macOS. Port +//! [`getNodeMirror.ts`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/getNodeMirror.ts), +//! [`getNodeArtifactAddress.ts`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/getNodeArtifactAddress.ts), +//! and +//! [`normalizeArch.ts`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/normalizeArch.ts). +//! - [`NodeResolver`] — the [`Resolver`](pacquet_resolving_resolver_base::Resolver) +//! impl that ties the parser, mirror config, and asset-list fetch +//! into the dispatcher chain. Ports `index.ts` in the same file +//! tree. + +mod get_node_artifact_address; +mod get_node_mirror; +mod node_resolver; +mod normalize_arch; +mod parse_node_specifier; +mod resolve_node_version; + +pub use get_node_artifact_address::{ + GetNodeArtifactAddressOptions, NodeArtifactAddress, get_node_artifact_address, +}; +pub use get_node_mirror::{ + DEFAULT_NODE_MIRROR_BASE_URL, UNOFFICIAL_NODE_MIRROR_BASE_URL, get_node_mirror, +}; +pub use node_resolver::{NodeResolver, NodeResolverError}; +pub use normalize_arch::get_normalized_arch; +pub use parse_node_specifier::{NodeSpecifier, ParseNodeSpecifierError, parse_node_specifier}; +pub use resolve_node_version::{ + NODE_EXTRAS_IGNORE_PATTERN, ResolveNodeVersionError, resolve_node_version, + resolve_node_versions, +}; diff --git a/pacquet/crates/engine-runtime-node-resolver/src/node_resolver.rs b/pacquet/crates/engine-runtime-node-resolver/src/node_resolver.rs new file mode 100644 index 0000000000..9af0f1ae74 --- /dev/null +++ b/pacquet/crates/engine-runtime-node-resolver/src/node_resolver.rs @@ -0,0 +1,374 @@ +//! Pacquet port of upstream's +//! [`resolveNodeRuntime` / `resolveLatestNodeRuntime`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/index.ts#L39-L97). +//! +//! [`NodeResolver`] implements [`Resolver`] and ties the per-helper +//! pieces (parser, mirror picker, asset reader) together so the +//! default-resolver dispatcher can route `node@runtime:` +//! dependencies through it. + +use std::{collections::HashMap, sync::Arc}; + +use derive_more::{Display, Error}; +use miette::Diagnostic; +use pacquet_crypto_shasums_file::{FetchShasumsFileError, fetch_shasums_file}; +use pacquet_lockfile::{ + BinaryArchive, BinaryResolution, BinarySpec, LockfileResolution, PlatformAssetResolution, + PlatformAssetTarget, VariationsResolution, +}; +use pacquet_network::ThrottledClient; +use pacquet_resolving_resolver_base::{ + LatestInfo, LatestQuery, ResolveError, ResolveFuture, ResolveLatestFuture, ResolveOptions, + ResolveResult, Resolver, WantedDependency, +}; +use ssri::Integrity; + +use crate::{ + get_node_artifact_address::{GetNodeArtifactAddressOptions, get_node_artifact_address}, + get_node_mirror::{ + DEFAULT_NODE_MIRROR_BASE_URL, UNOFFICIAL_NODE_MIRROR_BASE_URL, get_node_mirror, + }, + parse_node_specifier::{ParseNodeSpecifierError, parse_node_specifier}, + resolve_node_version::{ResolveNodeVersionError, resolve_node_version}, +}; + +const RESOLVED_VIA: &str = "nodejs.org"; +const BARE_SPEC_PREFIX: &str = "runtime:"; + +/// Errors emitted by [`NodeResolver::resolve`] / [`NodeResolver::resolve_latest`]. +/// +/// Each variant maps to one of upstream's `node-resolver` codes +/// (`NO_OFFLINE_NODEJS_RESOLUTION`, `NODEJS_VERSION_NOT_FOUND`, +/// `INVALID_NODE_RELEASE_CHANNEL`, plus the network failure modes +/// surfaced by the shasums-file and release-index fetchers). +#[derive(Debug, Display, Error, Diagnostic)] +pub enum NodeResolverError { + #[display("Offline Node.js resolution is not supported")] + #[diagnostic(code(NO_OFFLINE_NODEJS_RESOLUTION))] + Offline, + + #[display("Could not find a Node.js version that satisfies {spec}")] + #[diagnostic(code(NODEJS_VERSION_NOT_FOUND))] + VersionNotFound { + #[error(not(source))] + spec: String, + }, + + #[diagnostic(transparent)] + InvalidReleaseChannel(#[error(source)] ParseNodeSpecifierError), + + #[diagnostic(transparent)] + FetchReleaseIndex(#[error(source)] ResolveNodeVersionError), + + #[diagnostic(transparent)] + FetchShasumsFile(#[error(source)] FetchShasumsFileError), + + #[display("Failed to parse integrity {integrity} for {file_name}")] + #[diagnostic(code(NODE_INTEGRITY_PARSE_FAILED))] + ParseIntegrity { + integrity: String, + file_name: String, + #[error(source)] + error: Arc, + }, +} + +/// Node.js runtime resolver entry point. +/// +/// One instance per install. Owns the throttled HTTP client (so the +/// asset-list fetch contends with the rest of the install for the +/// same socket budget) and the `nodeDownloadMirrors` map a user may +/// have configured. `offline` is honored on every resolve — the +/// resolver fails fast rather than spinning a request that's going +/// to time out behind a proxy. +pub struct NodeResolver { + pub http_client: Arc, + pub node_download_mirrors: HashMap, + pub offline: bool, +} + +impl NodeResolver { + pub fn new(http_client: Arc) -> Self { + Self { http_client, node_download_mirrors: HashMap::new(), offline: false } + } +} + +impl Resolver for NodeResolver { + fn resolve<'a>( + &'a self, + wanted_dependency: &'a WantedDependency, + opts: &'a ResolveOptions, + ) -> ResolveFuture<'a> { + Box::pin(self.resolve_impl(wanted_dependency, opts)) + } + + fn resolve_latest<'a>( + &'a self, + query: &'a LatestQuery, + opts: &'a ResolveOptions, + ) -> ResolveLatestFuture<'a> { + Box::pin(self.resolve_latest_impl(query, opts)) + } +} + +impl NodeResolver { + async fn resolve_impl( + &self, + wanted_dependency: &WantedDependency, + _opts: &ResolveOptions, + ) -> Result, ResolveError> { + let Some(version_spec) = bare_runtime_spec(wanted_dependency, "node") else { + return Ok(None); + }; + + // Upstream's `currentPkg && !update` short-circuit reuses the + // lockfile-pinned VariationsResolution unchanged. Pacquet + // doesn't thread `currentPkg` through `ResolveOptions` yet, so + // every resolve re-fetches the asset list. Restore the fast + // path once the seam carries `currentPkg`. + + if self.offline { + return Err(Box::new(NodeResolverError::Offline) as ResolveError); + } + + let parsed = parse_node_specifier(version_spec).map_err(|err| { + Box::new(NodeResolverError::InvalidReleaseChannel(err)) as ResolveError + })?; + let mirror = get_node_mirror(Some(&self.node_download_mirrors), &parsed.release_channel); + let version = + resolve_node_version(&self.http_client, &parsed.version_specifier, Some(&mirror)) + .await + .map_err(|err| Box::new(NodeResolverError::FetchReleaseIndex(err)) as ResolveError)? + .ok_or_else(|| { + Box::new(NodeResolverError::VersionNotFound { spec: version_spec.to_string() }) + as ResolveError + })?; + let variants = self.read_node_assets(&mirror, &version).await?; + let range = if version == version_spec { version.clone() } else { format!("^{version}") }; + let resolution = LockfileResolution::Variations(VariationsResolution { variants }); + let manifest = serde_json::json!({ + "name": "node", + "version": version, + "bin": node_bins_for_current_os(current_platform()), + }); + Ok(Some(ResolveResult { + id: format!("node@runtime:{version}").into(), + name_ver: None, + latest: None, + published_at: None, + manifest: Some(manifest), + resolution, + resolved_via: RESOLVED_VIA.to_string(), + normalized_bare_specifier: Some(format!("runtime:{range}")), + alias: wanted_dependency.alias.clone(), + policy_violation: None, + })) + } + + async fn resolve_latest_impl( + &self, + query: &LatestQuery, + _opts: &ResolveOptions, + ) -> Result, ResolveError> { + let Some(manifest_spec) = bare_runtime_spec(&query.wanted_dependency, "node") else { + return Ok(None); + }; + let spec_owned; + let version_spec = if query.compatible { + manifest_spec + } else { + spec_owned = "latest"; + spec_owned + }; + let parsed = parse_node_specifier(version_spec).map_err(|err| { + Box::new(NodeResolverError::InvalidReleaseChannel(err)) as ResolveError + })?; + let mirror = get_node_mirror(Some(&self.node_download_mirrors), &parsed.release_channel); + let version = + resolve_node_version(&self.http_client, &parsed.version_specifier, Some(&mirror)) + .await + .map_err(|err| { + Box::new(NodeResolverError::FetchReleaseIndex(err)) as ResolveError + })?; + let Some(version) = version else { + return Ok(Some(LatestInfo::default())); + }; + Ok(Some(LatestInfo { + latest_manifest: Some(serde_json::json!({ + "name": "node", + "version": version, + })), + })) + } + + /// Fetch the `SHASUMS256.txt` for the picked version on the active + /// mirror, then optionally augment with musl variants from + /// unofficial-builds when the active mirror is the official one. + /// + /// Mirrors upstream's + /// [`readNodeAssets`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/index.ts#L99-L113): + /// the musl branch only fires when the active mirror is the + /// default one (custom mirrors are assumed to publish their own + /// musl-or-not policy), and musl-fetch failures are swallowed + /// because old releases simply don't have musl builds. + async fn read_node_assets( + &self, + mirror: &str, + version: &str, + ) -> Result, ResolveError> { + let mut assets = read_node_assets_from_mirror( + &self.http_client, + mirror, + version, + /* musl_only */ false, + ) + .await?; + if mirror == DEFAULT_NODE_MIRROR_BASE_URL + && let Ok(mut musl_assets) = read_node_assets_from_mirror( + &self.http_client, + UNOFFICIAL_NODE_MIRROR_BASE_URL, + version, + /* musl_only */ true, + ) + .await + { + assets.append(&mut musl_assets); + } + Ok(assets) + } +} + +/// Strip `runtime:` from a `(alias, bareSpecifier)` pair when both +/// halves match the runtime contract. Returns `None` (defer to the +/// next resolver) for any other shape. +fn bare_runtime_spec<'a>(wanted: &'a WantedDependency, expected_alias: &str) -> Option<&'a str> { + if wanted.alias.as_deref() != Some(expected_alias) { + return None; + } + wanted.bare_specifier.as_deref().and_then(|spec| spec.strip_prefix(BARE_SPEC_PREFIX)) +} + +/// Read the asset list for one mirror version and decode each row +/// into a [`PlatformAssetResolution`]. +/// +/// The regex mirrors upstream's: `node-v--(-musl)?.(tar.gz|zip)`. +/// Files that don't match (e.g. `.pkg`, `.msi`, source tarballs) are +/// dropped. When `musl_only` is true, glibc builds are filtered out +/// so the asset list only carries the musl-specific variants the +/// caller asked for. +async fn read_node_assets_from_mirror( + http_client: &ThrottledClient, + node_mirror_base_url: &str, + version: &str, + musl_only: bool, +) -> Result, ResolveError> { + let integrities_url = format!("{node_mirror_base_url}v{version}/SHASUMS256.txt"); + let items = fetch_shasums_file(http_client, &integrities_url) + .await + .map_err(|err| Box::new(NodeResolverError::FetchShasumsFile(err)) as ResolveError)?; + let mut assets = Vec::new(); + for item in items { + let Some(parsed) = parse_node_file_name(&item.file_name, version) else { continue }; + let is_musl = parsed.is_musl; + if musl_only && !is_musl { + continue; + } + let mut platform = parsed.platform; + if platform == "win" { + platform = "win32".to_string(); + } + let libc = is_musl.then(|| "musl".to_string()); + let address = get_node_artifact_address(GetNodeArtifactAddressOptions { + version, + base_url: node_mirror_base_url, + platform: &platform, + arch: &parsed.arch, + libc: libc.as_deref(), + }); + let url = format!("{}/{}{}", address.dirname, address.basename, address.extname); + let archive = + if address.extname == ".zip" { BinaryArchive::Zip } else { BinaryArchive::Tarball }; + let integrity: Integrity = item.integrity.parse().map_err(|error| { + Box::new(NodeResolverError::ParseIntegrity { + integrity: item.integrity.clone(), + file_name: item.file_name.clone(), + error: Arc::new(error), + }) as ResolveError + })?; + let prefix = if matches!(archive, BinaryArchive::Zip) { + Some(address.basename.clone()) + } else { + None + }; + let binary = BinaryResolution { + url, + integrity, + bin: bin_spec_for_platform(&platform), + archive, + prefix, + }; + let target = PlatformAssetTarget { os: platform, cpu: parsed.arch, libc }; + assets.push(PlatformAssetResolution { + resolution: LockfileResolution::Binary(binary), + targets: vec![target], + }); + } + Ok(assets) +} + +struct NodeFileName { + platform: String, + arch: String, + is_musl: bool, +} + +/// Match upstream's +/// `^node-v-([^-.]+)-([^.-]+)(-musl)?\.(tar\.gz|zip)$` — +/// implemented by hand so the resolver doesn't pay the regex crate +/// dependency for a single pattern. The version segment is matched +/// literally; the platform and arch each disallow `.` and `-`, and +/// `-musl` is the only legal third segment. +fn parse_node_file_name(file_name: &str, version: &str) -> Option { + let prefix = format!("node-v{version}-"); + let rest = file_name.strip_prefix(&prefix)?; + let (head, suffix) = if let Some(head) = rest.strip_suffix(".tar.gz") { + (head, ".tar.gz") + } else if let Some(head) = rest.strip_suffix(".zip") { + (head, ".zip") + } else { + return None; + }; + let _ = suffix; + let (platform, after_platform) = head.split_once('-')?; + if platform.is_empty() || platform.contains('.') { + return None; + } + let (arch_part, is_musl) = match after_platform.strip_suffix("-musl") { + Some(arch_part) => (arch_part, true), + None => (after_platform, false), + }; + if arch_part.is_empty() || arch_part.contains('.') || arch_part.contains('-') { + return None; + } + Some(NodeFileName { platform: platform.to_string(), arch: arch_part.to_string(), is_musl }) +} + +fn bin_spec_for_platform(platform: &str) -> BinarySpec { + BinarySpec::Single(if platform == "win32" { "node.exe" } else { "bin/node" }.to_string()) +} + +fn node_bins_for_current_os(platform: &str) -> serde_json::Value { + serde_json::json!({ "node": if platform == "win32" { "node.exe" } else { "bin/node" } }) +} + +/// Host platform string in pnpm's normalised form (`win32`, `darwin`, +/// `linux`, …). Reads `std::env::consts::OS` rather than spawning a +/// helper so the lookup is allocation-free. +fn current_platform() -> &'static str { + match std::env::consts::OS { + "windows" => "win32", + other => other, + } +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/engine-runtime-node-resolver/src/node_resolver/tests.rs b/pacquet/crates/engine-runtime-node-resolver/src/node_resolver/tests.rs new file mode 100644 index 0000000000..dbc69ab00a --- /dev/null +++ b/pacquet/crates/engine-runtime-node-resolver/src/node_resolver/tests.rs @@ -0,0 +1,82 @@ +use std::sync::Arc; + +use pacquet_network::ThrottledClient; +use pacquet_resolving_resolver_base::{ResolveOptions, Resolver, WantedDependency}; +use pretty_assertions::assert_eq; + +use super::{NodeResolver, parse_node_file_name}; + +fn resolver() -> NodeResolver { + NodeResolver::new(Arc::new(ThrottledClient::new_for_installs())) +} + +/// A `WantedDependency` whose alias is not `node` is declined (the +/// dispatcher chain falls through to the next resolver). +#[tokio::test] +async fn declines_non_node_alias() { + let wanted = WantedDependency { + alias: Some("foo".to_string()), + bare_specifier: Some("runtime:22.0.0".to_string()), + ..WantedDependency::default() + }; + let outcome = resolver().resolve(&wanted, &ResolveOptions::default()).await.unwrap(); + assert!(outcome.is_none()); +} + +/// `node` alias without a `runtime:` prefix is declined — that shape +/// is owned by the npm resolver (`node` could be a package name too). +#[tokio::test] +async fn declines_node_without_runtime_prefix() { + let wanted = WantedDependency { + alias: Some("node".to_string()), + bare_specifier: Some("^22".to_string()), + ..WantedDependency::default() + }; + let outcome = resolver().resolve(&wanted, &ResolveOptions::default()).await.unwrap(); + assert!(outcome.is_none()); +} + +/// `offline=true` raises `NO_OFFLINE_NODEJS_RESOLUTION` so the install +/// stops with the same code pnpm emits. +#[tokio::test] +async fn offline_raises_no_offline_nodejs_resolution() { + let mut resolver = resolver(); + resolver.offline = true; + let wanted = WantedDependency { + alias: Some("node".to_string()), + bare_specifier: Some("runtime:22.0.0".to_string()), + ..WantedDependency::default() + }; + let err = resolver.resolve(&wanted, &ResolveOptions::default()).await.unwrap_err(); + let code: &dyn miette::Diagnostic = + err.downcast_ref::().expect("error is a NodeResolverError"); + assert_eq!( + code.code().map(|code| code.to_string()).as_deref(), + Some("NO_OFFLINE_NODEJS_RESOLUTION"), + ); +} + +/// Tarball filename pattern parsing — exercises every branch the +/// regex covers upstream (Linux glibc, Linux musl, Windows zip, the +/// unrecognised `.pkg` reject case). +#[test] +fn parses_node_file_names() { + let v = "22.0.0"; + let linux = parse_node_file_name("node-v22.0.0-linux-x64.tar.gz", v).expect("linux glibc"); + assert_eq!(linux.platform, "linux"); + assert_eq!(linux.arch, "x64"); + assert!(!linux.is_musl); + + let musl = parse_node_file_name("node-v22.0.0-linux-x64-musl.tar.gz", v).expect("linux musl"); + assert_eq!(musl.platform, "linux"); + assert_eq!(musl.arch, "x64"); + assert!(musl.is_musl); + + let windows = parse_node_file_name("node-v22.0.0-win-x64.zip", v).expect("windows"); + assert_eq!(windows.platform, "win"); + assert_eq!(windows.arch, "x64"); + assert!(!windows.is_musl); + + assert!(parse_node_file_name("node-v22.0.0.pkg", v).is_none()); + assert!(parse_node_file_name("node-v22.0.0-headers.tar.gz", v).is_none()); +} diff --git a/pacquet/crates/engine-runtime-node-resolver/src/normalize_arch.rs b/pacquet/crates/engine-runtime-node-resolver/src/normalize_arch.rs new file mode 100644 index 0000000000..607ba17277 --- /dev/null +++ b/pacquet/crates/engine-runtime-node-resolver/src/normalize_arch.rs @@ -0,0 +1,40 @@ +//! Pacquet port of +//! [`normalizeArch.ts`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/normalizeArch.ts). + +/// Translate the `(platform, arch)` pair pnpm sees at install time into +/// the directory-name shape nodejs.org actually publishes for. +/// +/// Three quirks worth calling out — each is exercised by an upstream +/// test: +/// - `darwin` / `arm64` on Node ≤ 15 has no Apple-Silicon build, so +/// pnpm falls back to the `x64` (Rosetta) tarball. +/// - `win32` / `ia32` is published as `win-x86`. +/// - Linux `arm` is published as `armv7l` (Raspberry Pi 4 et al.). +/// +/// `node_version` is optional because the macOS Apple-Silicon rule +/// only matters when a concrete version is in hand; callers that +/// haven't picked one yet pass `None` and accept the default mapping. +pub fn get_normalized_arch(platform: &str, arch: &str, node_version: Option<&str>) -> String { + if let Some(version) = node_version + && let Some(major) = node_major(version) + && platform == "darwin" + && arch == "arm64" + && major < 16 + { + return "x64".to_string(); + } + if platform == "win32" && arch == "ia32" { + return "x86".to_string(); + } + if arch == "arm" { + return "armv7l".to_string(); + } + arch.to_string() +} + +fn node_major(version: &str) -> Option { + version.split('.').next()?.parse().ok() +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/engine-runtime-node-resolver/src/normalize_arch/tests.rs b/pacquet/crates/engine-runtime-node-resolver/src/normalize_arch/tests.rs new file mode 100644 index 0000000000..b560ac19e9 --- /dev/null +++ b/pacquet/crates/engine-runtime-node-resolver/src/normalize_arch/tests.rs @@ -0,0 +1,28 @@ +use pretty_assertions::assert_eq; + +use super::get_normalized_arch; + +/// Mirrors upstream's +/// [`normalizeArch.test.ts`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/test/normalizeArch.test.ts): +/// `win32 ia32 → x86`, `linux arm → armv7l` (Raspberry Pi 4), and the +/// identity case `linux x64 → x64`. +#[test] +fn maps_quirky_arches_to_the_published_tarball_directory_name() { + assert_eq!(get_normalized_arch("win32", "ia32", None), "x86"); + assert_eq!(get_normalized_arch("linux", "arm", None), "armv7l"); + assert_eq!(get_normalized_arch("linux", "x64", None), "x64"); + // Pacquet matches upstream's unconditional `arm → armv7l` mapping + // — non-Linux `arm` is normalised the same way, so the assertion + // is a regression guard against accidentally adding a platform + // guard that diverges from pnpm. + assert_eq!(get_normalized_arch("darwin", "arm", None), "armv7l"); +} + +/// Apple Silicon (`darwin arm64`) only has its own tarball from +/// Node 16. Pre-16 falls back to the `x64` Rosetta build; 16 and up +/// stay on `arm64`. Mirrors upstream's `normalizeArch.test.ts:14-19`. +#[test] +fn darwin_arm64_falls_back_to_x64_on_pre_node_16() { + assert_eq!(get_normalized_arch("darwin", "arm64", Some("14.20.0")), "x64"); + assert_eq!(get_normalized_arch("darwin", "arm64", Some("16.17.0")), "arm64"); +} diff --git a/pacquet/crates/engine-runtime-node-resolver/src/parse_node_specifier.rs b/pacquet/crates/engine-runtime-node-resolver/src/parse_node_specifier.rs new file mode 100644 index 0000000000..1d675aa560 --- /dev/null +++ b/pacquet/crates/engine-runtime-node-resolver/src/parse_node_specifier.rs @@ -0,0 +1,140 @@ +//! Pacquet port of +//! [`parseNodeSpecifier.ts`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/parseNodeSpecifier.ts). + +use derive_more::{Display, Error}; +use miette::Diagnostic; + +/// One of nodejs.org's published release channels. +/// +/// Pacquet keeps the value as a `String` (rather than a closed enum) +/// because the upstream parser passes the channel through to the +/// mirror URL builder unchanged — the set is closed today but the +/// validation happens at parse time, not after. +pub const RELEASE_CHANNELS: &[&str] = &["nightly", "rc", "test", "v8-canary", "release"]; + +/// Parsed form of a `runtime:` bare specifier's body. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NodeSpecifier { + pub release_channel: String, + pub version_specifier: String, +} + +/// Errors raised by [`parse_node_specifier`]. +/// +/// Matches upstream's `INVALID_NODE_RELEASE_CHANNEL` code so log +/// consumers (e.g. `@pnpm/cli.default-reporter`) parse the same string. +#[derive(Debug, Display, Error, Diagnostic, Clone, PartialEq, Eq)] +pub enum ParseNodeSpecifierError { + #[display("\"{channel}\" is not a valid Node.js release channel")] + #[diagnostic( + code(INVALID_NODE_RELEASE_CHANNEL), + help("Valid release channels are: nightly, rc, test, v8-canary, release") + )] + InvalidReleaseChannel { + #[error(not(source))] + channel: String, + }, +} + +/// Split a `runtime:` bare specifier's body into its release-channel +/// and version-selector halves. +/// +/// Five shapes are accepted (in priority order): +/// +/// 1. `/` — explicit channel prefix. Unknown channels +/// raise [`ParseNodeSpecifierError::InvalidReleaseChannel`]. +/// 2. `-...` — exact prerelease whose +/// `-` suffix identifies the channel (`nightly`, `rc`, +/// `test`, `v8-canary`). +/// 3. `` — exact stable version on `release`. +/// 4. A standalone channel name (e.g. `nightly`) — short-hand for +/// "latest from that channel". +/// 5. `lts`, `latest`, an LTS codename (`argon`, `iron`, …), or any +/// semver range. All map to the `release` channel and pass through +/// as the version selector verbatim; an invalid input fails at +/// resolution time with `NODEJS_VERSION_NOT_FOUND`. +pub fn parse_node_specifier(specifier: &str) -> Result { + if let Some((channel, rest)) = specifier.split_once('/') { + if !RELEASE_CHANNELS.contains(&channel) { + return Err(ParseNodeSpecifierError::InvalidReleaseChannel { + channel: channel.to_string(), + }); + } + return Ok(NodeSpecifier { + release_channel: channel.to_string(), + version_specifier: rest.to_string(), + }); + } + + if let Some(channel) = prerelease_channel(specifier) { + return Ok(NodeSpecifier { + release_channel: channel.to_string(), + version_specifier: specifier.to_string(), + }); + } + + if is_stable_version(specifier) { + return Ok(NodeSpecifier { + release_channel: "release".to_string(), + version_specifier: specifier.to_string(), + }); + } + + if RELEASE_CHANNELS.contains(&specifier) { + return Ok(NodeSpecifier { + release_channel: specifier.to_string(), + version_specifier: "latest".to_string(), + }); + } + + // `lts` / `latest`, an LTS codename, or a semver range all flow + // through to the picker on the release channel. + Ok(NodeSpecifier { + release_channel: "release".to_string(), + version_specifier: specifier.to_string(), + }) +} + +/// Return the prerelease channel for an exact `X.Y.Z-...` +/// version, or `None` if `specifier` is not an exact prerelease. +/// +/// Mirrors upstream's regex `^\d+\.\d+\.\d+-(nightly|rc|test|v8-canary)`. +fn prerelease_channel(specifier: &str) -> Option<&'static str> { + let (head, suffix) = specifier.split_once('-')?; + if !is_stable_version(head) { + return None; + } + for candidate in &["nightly", "rc", "test", "v8-canary"] { + if suffix == *candidate || suffix.starts_with(&format!("{candidate}.")) { + return Some(candidate); + } + // `v8-canary` has a dot-less continuation: e.g. + // `22.0.0-v8-canary20250101abc`. Same for the `nightly` build + // identifiers like `22.0.0-nightly20250315abcdef`. Allow any + // trailing characters after the channel name. + if let Some(rest) = suffix.strip_prefix(candidate) + && rest.chars().next().is_none_or(|next| !next.is_ascii_alphabetic() || next == '.') + { + return Some(candidate); + } + } + None +} + +fn is_stable_version(value: &str) -> bool { + let mut parts = value.split('.'); + let major = parts.next(); + let minor = parts.next(); + let patch = parts.next(); + if parts.next().is_some() { + return false; + } + matches!((major, minor, patch), + (Some(m), Some(n), Some(p)) + if !m.is_empty() && m.bytes().all(|byte| byte.is_ascii_digit()) + && !n.is_empty() && n.bytes().all(|byte| byte.is_ascii_digit()) + && !p.is_empty() && p.bytes().all(|byte| byte.is_ascii_digit())) +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/engine-runtime-node-resolver/src/parse_node_specifier/tests.rs b/pacquet/crates/engine-runtime-node-resolver/src/parse_node_specifier/tests.rs new file mode 100644 index 0000000000..aa3f433f06 --- /dev/null +++ b/pacquet/crates/engine-runtime-node-resolver/src/parse_node_specifier/tests.rs @@ -0,0 +1,63 @@ +use pretty_assertions::assert_eq; + +use super::{ParseNodeSpecifierError, parse_node_specifier}; + +/// Mirrors upstream's +/// [`parseNodeSpecifier.test.ts`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/test/parseNodeSpecifier.test.ts) +/// table verbatim: every accepted input shape, each paired with the +/// expected `(version_specifier, release_channel)` pair. +#[test] +fn matches_upstream_table() { + let cases: &[(&str, &str, &str)] = &[ + // Semver ranges → release channel. + ("6", "6", "release"), + ("16.0", "16.0", "release"), + // Exact prerelease with rc channel. + ("16.0.0-rc.0", "16.0.0-rc.0", "rc"), + // Channel/range combo (major only). + ("rc/10", "10", "rc"), + // Standalone channel name → latest from that channel. + ("nightly", "latest", "nightly"), + ("rc", "latest", "rc"), + ("test", "latest", "test"), + ("v8-canary", "latest", "v8-canary"), + ("release", "latest", "release"), + // Well-known aliases. + ("lts", "lts", "release"), + ("latest", "latest", "release"), + // LTS codenames. + ("argon", "argon", "release"), + ("iron", "iron", "release"), + // Exact stable version. + ("22.0.0", "22.0.0", "release"), + // Stable release with explicit channel prefix, aliases, and ranges. + ("release/22.0.0", "22.0.0", "release"), + ("release/latest", "latest", "release"), + ("release/lts", "lts", "release"), + ("release/18", "18", "release"), + // Channel/version combos. + ("rc/18", "18", "rc"), + ("rc/18.0.0-rc.4", "18.0.0-rc.4", "rc"), + ("nightly/latest", "latest", "nightly"), + // Exact nightly version. + ("24.0.0-nightly20250315d765e70802", "24.0.0-nightly20250315d765e70802", "nightly"), + // Exact v8-canary version. + ("22.0.0-v8-canary20250101abc", "22.0.0-v8-canary20250101abc", "v8-canary"), + ]; + for (input, expected_version, expected_channel) in cases { + let parsed = parse_node_specifier(input) + .unwrap_or_else(|err| panic!("parse_node_specifier({input}) returned an error: {err}")); + assert_eq!(parsed.version_specifier, *expected_version, "specifier `{input}`"); + assert_eq!(parsed.release_channel, *expected_channel, "specifier `{input}`"); + } +} + +/// An unknown channel name on the left of `/` raises +/// `INVALID_NODE_RELEASE_CHANNEL`. Mirrors upstream's +/// `throws for unknown release channel` test. +#[test] +fn unknown_channel_raises() { + match parse_node_specifier("foo/18").unwrap_err() { + ParseNodeSpecifierError::InvalidReleaseChannel { channel } => assert_eq!(channel, "foo"), + } +} diff --git a/pacquet/crates/engine-runtime-node-resolver/src/resolve_node_version.rs b/pacquet/crates/engine-runtime-node-resolver/src/resolve_node_version.rs new file mode 100644 index 0000000000..2dc697ec4d --- /dev/null +++ b/pacquet/crates/engine-runtime-node-resolver/src/resolve_node_version.rs @@ -0,0 +1,253 @@ +//! Pacquet port of pnpm's +//! [`resolveNodeVersion` / `resolveNodeVersions`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/index.ts#L185-L221). +//! +//! Pull nodejs.org's `index.json` for a mirror, filter the listed +//! versions, and pick the one (or the set) matching a user-supplied +//! selector. The selector may be: +//! +//! - `latest` — the first entry in the index (the newest published +//! build on that channel). +//! - `lts` — the newest entry tagged with any LTS codename. +//! - An LTS codename (`argon`, `iron`, …) — `*` within that codename. +//! - A semver range — pick the `max_satisfying` version. + +use std::sync::Arc; + +use derive_more::{Display, Error}; +use miette::Diagnostic; +use node_semver::{Range, Version}; +use pacquet_network::ThrottledClient; +use serde::Deserialize; + +/// Pattern matched against archive entries pacquet strips out of the +/// Node.js tarball — `npm`, `npx`, and `corepack` ship with Node but +/// pnpm manages its own package managers, so the install layer +/// excludes them per upstream's +/// [`NODE_EXTRAS_IGNORE_PATTERN`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/index.ts#L32). +pub const NODE_EXTRAS_IGNORE_PATTERN: &str = r"^(?:(?:lib/)?node_modules/(?:npm|corepack)(?:/|$)|bin/(?:npm|npx|corepack)$|(?:npm|npx|corepack)(?:\.(?:cmd|ps1))?$)"; + +/// One row of the `index.json` Node.js publishes for each mirror. +/// +/// `version` arrives with a leading `v`; pacquet strips it at the +/// deserialization boundary. `lts` is either `false` (non-LTS build) +/// or a string codename — JSON deserialization needs the union shape. +#[derive(Debug, Clone)] +pub(crate) struct NodeVersion { + pub(crate) version: String, + pub(crate) lts: Option, +} + +#[derive(Debug, Deserialize)] +struct RawNodeVersion { + version: String, + #[serde(default)] + lts: serde_json::Value, +} + +/// Errors raised by [`resolve_node_version`] and [`resolve_node_versions`]. +#[derive(Debug, Display, Error, Diagnostic)] +pub enum ResolveNodeVersionError { + #[display("Failed to fetch Node.js release index at {url}")] + #[diagnostic(code(FETCH_NODE_INDEX_FAILED))] + FetchIndex { + url: String, + #[error(source)] + error: Arc, + }, + + #[display("Failed to decode Node.js release index at {url}")] + #[diagnostic(code(DECODE_NODE_INDEX_FAILED))] + DecodeIndex { + url: String, + #[error(source)] + error: Arc, + }, +} + +/// Pick the single best Node.js version for a selector against a mirror. +/// +/// `node_mirror_base_url` falls back to the official `release` channel +/// when `None`; matches upstream's +/// [`fetchAllVersions` default](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/index.ts#L215). +/// Returns `Ok(None)` when the index is reachable but no version +/// satisfies the selector — the caller raises +/// `NODEJS_VERSION_NOT_FOUND` to mirror upstream. +pub async fn resolve_node_version( + http_client: &ThrottledClient, + version_spec: &str, + node_mirror_base_url: Option<&str>, +) -> Result, ResolveNodeVersionError> { + let all_versions = fetch_all_versions(http_client, node_mirror_base_url).await?; + if version_spec == "latest" { + return Ok(all_versions.first().map(|version| version.version.clone())); + } + let (versions, range) = filter_versions(&all_versions, version_spec); + Ok(max_satisfying(&versions, &range)) +} + +/// Pick every Node.js version a selector accepts against a mirror. +/// +/// Used by `pnpm exec` and the `outdated` command to enumerate +/// candidates rather than commit to a single pick. `version_spec` +/// `None` returns the full mirror index in published order. +pub async fn resolve_node_versions( + http_client: &ThrottledClient, + version_spec: Option<&str>, + node_mirror_base_url: Option<&str>, +) -> Result, ResolveNodeVersionError> { + let all_versions = fetch_all_versions(http_client, node_mirror_base_url).await?; + let Some(version_spec) = version_spec else { + return Ok(all_versions.into_iter().map(|version| version.version).collect()); + }; + if version_spec == "latest" { + return Ok(all_versions + .into_iter() + .next() + .map(|version| vec![version.version]) + .unwrap_or_default()); + } + let (versions, range) = filter_versions(&all_versions, version_spec); + let parsed_range = match Range::parse(&range) { + Ok(parsed) => parsed, + Err(_) => return Ok(Vec::new()), + }; + Ok(versions + .into_iter() + .filter(|version| { + Version::parse(version) + .map(|parsed| satisfies_with_prereleases(&parsed, &parsed_range)) + .unwrap_or(false) + }) + .collect()) +} + +async fn fetch_all_versions( + http_client: &ThrottledClient, + node_mirror_base_url: Option<&str>, +) -> Result, ResolveNodeVersionError> { + let base = node_mirror_base_url.unwrap_or("https://nodejs.org/download/release/"); + let url = format!("{base}index.json"); + let response = http_client + .acquire_for_url(&url) + .await + .get(&url) + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(|error| ResolveNodeVersionError::FetchIndex { + url: url.clone(), + error: Arc::new(error), + })?; + let body = response.text().await.map_err(|error| ResolveNodeVersionError::FetchIndex { + url: url.clone(), + error: Arc::new(error), + })?; + let raw: Vec = serde_json::from_str(&body).map_err(|error| { + ResolveNodeVersionError::DecodeIndex { url: url.clone(), error: Arc::new(error) } + })?; + Ok(raw + .into_iter() + .map(|entry| NodeVersion { + version: entry.version.strip_prefix('v').unwrap_or(&entry.version).to_string(), + lts: lts_codename(entry.lts), + }) + .collect()) +} + +/// Decode the `lts` field upstream emits as `false | string`. +fn lts_codename(value: serde_json::Value) -> Option { + match value { + serde_json::Value::String(name) => Some(name), + _ => None, + } +} + +/// Reduce the mirror index into the candidate set + matching range +/// for the user's selector. Mirrors upstream's `filterVersions`. +fn filter_versions(versions: &[NodeVersion], version_selector: &str) -> (Vec, String) { + if version_selector == "lts" { + return ( + versions + .iter() + .filter(|version| version.lts.is_some()) + .map(|version| version.version.clone()) + .collect(), + "*".to_string(), + ); + } + if is_dist_tag(version_selector) { + let wanted = version_selector.to_ascii_lowercase(); + return ( + versions + .iter() + .filter(|version| { + version.lts.as_deref().is_some_and(|name| name.eq_ignore_ascii_case(&wanted)) + }) + .map(|version| version.version.clone()) + .collect(), + "*".to_string(), + ); + } + (versions.iter().map(|version| version.version.clone()).collect(), version_selector.to_string()) +} + +/// Mirrors `versionSelectorType(...)?.type === 'tag'` upstream: the +/// selector is a "tag" only when it parses as neither a `Version` nor +/// a `Range`. We don't run the `encodeURIComponent` punctuation check +/// — `filter_versions`'s only consumer is the LTS-codename branch, +/// and codenames are alphabetic. +fn is_dist_tag(selector: &str) -> bool { + Version::parse(selector).is_err() && Range::parse(selector).is_err() +} + +fn max_satisfying(versions: &[String], range: &str) -> Option { + let parsed_range = Range::parse(range).ok()?; + let mut best: Option<(Version, String)> = None; + for version in versions { + let Ok(parsed) = Version::parse(version) else { continue }; + if !satisfies_with_prereleases(&parsed, &parsed_range) { + continue; + } + match &best { + Some((current, _)) if current >= &parsed => {} + _ => best = Some((parsed, version.clone())), + } + } + best.map(|(_, raw)| raw) +} + +/// Range-satisfaction check that mirrors upstream's +/// `semver.maxSatisfying(versions, range, { includePrerelease: true, … })` +/// call at +/// [`engine/runtime/node-resolver/src/index.ts`](https://github.com/pnpm/pnpm/blob/1627943d2a/engine/runtime/node-resolver/src/index.ts#L185-L196). +/// +/// `node-semver`'s [`Version::satisfies`] rejects prerelease versions +/// against non-prerelease comparators by default — for example +/// `18.0.0-rc.1` against `^18.0.0` returns `false`. JavaScript's +/// `semver` library exposes an `includePrerelease` opt-in that lets +/// that pairing match, which the upstream node-resolver enables +/// unconditionally. Pacquet approximates the same opt-in by retrying +/// with the prerelease suffix stripped when the straight check fails: +/// if `version` is a prerelease and `MAJOR.MINOR.PATCH` satisfies +/// `range`, treat the candidate as satisfying. Mirrors the strategy +/// already used by `satisfies_with_prereleases` in +/// `resolving-deps-resolver`. +fn satisfies_with_prereleases(version: &Version, range: &Range) -> bool { + if version.satisfies(range) { + return true; + } + if version.pre_release.is_empty() { + return false; + } + let base = Version { + major: version.major, + minor: version.minor, + patch: version.patch, + pre_release: Vec::new(), + build: Vec::new(), + }; + base.satisfies(range) +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/engine-runtime-node-resolver/src/resolve_node_version/tests.rs b/pacquet/crates/engine-runtime-node-resolver/src/resolve_node_version/tests.rs new file mode 100644 index 0000000000..ae40daacb1 --- /dev/null +++ b/pacquet/crates/engine-runtime-node-resolver/src/resolve_node_version/tests.rs @@ -0,0 +1,39 @@ +use pretty_assertions::assert_eq; + +use super::{NodeVersion, filter_versions}; + +fn make_versions() -> Vec { + vec![ + NodeVersion { version: "22.0.0".to_string(), lts: None }, + NodeVersion { version: "20.10.0".to_string(), lts: Some("Iron".to_string()) }, + NodeVersion { version: "20.5.0".to_string(), lts: None }, + NodeVersion { version: "18.18.0".to_string(), lts: Some("Hydrogen".to_string()) }, + NodeVersion { version: "16.20.0".to_string(), lts: Some("Gallium".to_string()) }, + ] +} + +/// `lts` selects every entry with a non-`false` lts codename. +#[test] +fn lts_selector_picks_every_lts_release() { + let (picked, range) = filter_versions(&make_versions(), "lts"); + assert_eq!(picked, vec!["20.10.0", "18.18.0", "16.20.0"]); + assert_eq!(range, "*"); +} + +/// An exact LTS codename narrows to the matching releases. Match is +/// case-insensitive — upstream lower-cases both sides. +#[test] +fn lts_codename_is_case_insensitive() { + let (picked, range) = filter_versions(&make_versions(), "iron"); + assert_eq!(picked, vec!["20.10.0"]); + assert_eq!(range, "*"); +} + +/// A semver range passes through unchanged (`versionRange` is the +/// selector, `versions` is the full unfiltered list). +#[test] +fn semver_range_passes_through() { + let (picked, range) = filter_versions(&make_versions(), "^20"); + assert_eq!(picked.len(), 5); + assert_eq!(range, "^20"); +} diff --git a/pacquet/crates/package-manager/Cargo.toml b/pacquet/crates/package-manager/Cargo.toml index 2501abcad9..3cdf883a43 100644 --- a/pacquet/crates/package-manager/Cargo.toml +++ b/pacquet/crates/package-manager/Cargo.toml @@ -11,37 +11,40 @@ license.workspace = true repository.workspace = true [dependencies] -pacquet-cmd-shim = { workspace = true } -pacquet-crypto-hash = { workspace = true } -pacquet-deps-path = { workspace = true } -pacquet-directory-fetcher = { workspace = true } -pacquet-executor = { workspace = true } -pacquet-fs = { workspace = true } -pacquet-git-fetcher = { workspace = true } -pacquet-lockfile = { workspace = true } -pacquet-lockfile-preferred-versions = { workspace = true } -pacquet-lockfile-verification = { workspace = true } -pacquet-modules-yaml = { workspace = true } -pacquet-network = { workspace = true } -pacquet-config = { workspace = true } -pacquet-graph-hasher = { workspace = true } -pacquet-package-manifest = { workspace = true } -pacquet-package-is-installable = { workspace = true } -pacquet-patching = { workspace = true } -pacquet-real-hoist = { workspace = true } -pacquet-registry = { workspace = true } -pacquet-reporter = { workspace = true } -pacquet-resolving-default-resolver = { workspace = true } -pacquet-resolving-deps-resolver = { workspace = true } -pacquet-resolving-git-resolver = { workspace = true } -pacquet-resolving-local-resolver = { workspace = true } -pacquet-resolving-npm-resolver = { workspace = true } -pacquet-resolving-resolver-base = { workspace = true } -pacquet-resolving-tarball-resolver = { workspace = true } -pacquet-store-dir = { workspace = true } -pacquet-tarball = { workspace = true } -pacquet-workspace = { workspace = true } -pacquet-workspace-state = { workspace = true } +pacquet-cmd-shim = { workspace = true } +pacquet-crypto-hash = { workspace = true } +pacquet-deps-path = { workspace = true } +pacquet-directory-fetcher = { workspace = true } +pacquet-engine-runtime-bun-resolver = { workspace = true } +pacquet-engine-runtime-deno-resolver = { workspace = true } +pacquet-engine-runtime-node-resolver = { workspace = true } +pacquet-executor = { workspace = true } +pacquet-fs = { workspace = true } +pacquet-git-fetcher = { workspace = true } +pacquet-lockfile = { workspace = true } +pacquet-lockfile-preferred-versions = { workspace = true } +pacquet-lockfile-verification = { workspace = true } +pacquet-modules-yaml = { workspace = true } +pacquet-network = { workspace = true } +pacquet-config = { workspace = true } +pacquet-graph-hasher = { workspace = true } +pacquet-package-manifest = { workspace = true } +pacquet-package-is-installable = { workspace = true } +pacquet-patching = { workspace = true } +pacquet-real-hoist = { workspace = true } +pacquet-registry = { workspace = true } +pacquet-reporter = { workspace = true } +pacquet-resolving-default-resolver = { workspace = true } +pacquet-resolving-deps-resolver = { workspace = true } +pacquet-resolving-git-resolver = { workspace = true } +pacquet-resolving-local-resolver = { workspace = true } +pacquet-resolving-npm-resolver = { workspace = true } +pacquet-resolving-resolver-base = { workspace = true } +pacquet-resolving-tarball-resolver = { workspace = true } +pacquet-store-dir = { workspace = true } +pacquet-tarball = { workspace = true } +pacquet-workspace = { workspace = true } +pacquet-workspace-state = { workspace = true } async-recursion = { workspace = true } chrono = { workspace = true } diff --git a/pacquet/crates/package-manager/src/install_without_lockfile.rs b/pacquet/crates/package-manager/src/install_without_lockfile.rs index 243d415f73..4cc69eb782 100644 --- a/pacquet/crates/package-manager/src/install_without_lockfile.rs +++ b/pacquet/crates/package-manager/src/install_without_lockfile.rs @@ -9,6 +9,9 @@ use futures_util::future; use miette::Diagnostic; use pacquet_cmd_shim::{Host, LinkBinsError, link_bins}; use pacquet_config::Config; +use pacquet_engine_runtime_bun_resolver::BunResolver; +use pacquet_engine_runtime_deno_resolver::DenoResolver; +use pacquet_engine_runtime_node_resolver::NodeResolver; use pacquet_network::ThrottledClient; use pacquet_package_manifest::{DependencyGroup, PackageManifest}; use pacquet_reporter::{LogEvent, LogLevel, Reporter, Stage, StageLog}; @@ -25,7 +28,9 @@ use pacquet_resolving_npm_resolver::{ InMemoryPackageMetaCache, MergeNamedRegistriesError, NamedRegistryResolver, NpmResolver, merge_named_registries, }; -use pacquet_resolving_resolver_base::{ResolveOptions, Resolver}; +use pacquet_resolving_resolver_base::{ + LatestQuery, ResolveFuture, ResolveLatestFuture, ResolveOptions, Resolver, WantedDependency, +}; use pacquet_resolving_tarball_resolver::TarballResolver; use pacquet_store_dir::{SharedVerifiedFilesCache, StoreIndex, StoreIndexWriter}; use pacquet_tarball::MemCache; @@ -201,7 +206,7 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> { let meta_cache = Arc::new(InMemoryPackageMetaCache::default()); - let npm_resolver = NpmResolver { + let npm_resolver: Arc = Arc::new(NpmResolver { registries, named_registries: merged_named_registries.clone(), http_client: Arc::clone(&http_client_arc), @@ -211,7 +216,7 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> { offline: config.offline, prefer_offline: config.prefer_offline, ignore_missing_time_field: config.minimum_release_age_ignore_missing_time, - }; + }); let git_resolver = GitResolver::new( Arc::new(RealGitProbe::new(Arc::clone(&http_client_arc))), Arc::new(RealGitRunner::new()), @@ -226,6 +231,12 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> { let local_ctx = LocalResolverContext { preserve_absolute_paths: false }; let local_scheme_resolver = LocalSchemeResolver::new(local_ctx); let local_path_resolver = LocalPathResolver::new(local_ctx); + let mut node_resolver = NodeResolver::new(Arc::clone(&http_client_arc)); + node_resolver.offline = config.offline; + let deno_resolver = + DenoResolver::new(Arc::clone(&http_client_arc), Arc::clone(&npm_resolver)); + let bun_resolver = + BunResolver::new(Arc::clone(&http_client_arc), Arc::clone(&npm_resolver)); let named_registry_resolver = NamedRegistryResolver { named_registries: merged_named_registries, registry_names: named_registry_aliases, @@ -238,20 +249,23 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> { ignore_missing_time_field: config.minimum_release_age_ignore_missing_time, }; // Order mirrors upstream's chain at - // : - // npm → git → tarball → local-scheme → named-registry → - // local-path. The local-resolver split is required by - // named-registry: a `:@scope/pkg` specifier carries an - // embedded `/`, which the path-shape detector + // : + // npm → jsr (folded into npm) → git → tarball → localScheme → + // node → deno → bun → namedRegistry → localPath. The + // local-resolver split is required by named-registry: a + // `:@scope/pkg` specifier carries an embedded `/`, + // which the path-shape detector // (`contains_path_sep` in `parse_bare_specifier.rs`) would // otherwise claim and prevent the named-registry resolver - // from running. Runtimes (node / deno / bun) slot in between - // local-scheme and named-registry as those crates land. + // from running. let resolver: Box = Box::new(DefaultResolver::new(vec![ - Box::new(npm_resolver), + Box::new(ArcResolver(Arc::clone(&npm_resolver))), Box::new(git_resolver), Box::new(tarball_resolver), Box::new(local_scheme_resolver), + Box::new(node_resolver), + Box::new(deno_resolver), + Box::new(bun_resolver), Box::new(named_registry_resolver), Box::new(local_path_resolver), ])); @@ -309,7 +323,15 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> { // Drop the resolver (and its meta cache) before the install // pass: the tree captures every `ResolveResult` we need. + // Dropping `resolver` releases the `ArcResolver` wrapper's + // strong reference to `npm_resolver`; the standalone + // `npm_resolver` binding holds a second strong reference + // because the deno- and bun-resolvers were handed a clone of + // the same `Arc` for their version-selection delegate. Drop + // both so the `NpmResolver` (and its packument cache) can be + // freed before the install pass starts allocating tarballs. drop(resolver); + drop(npm_resolver); let peers_result = importer_result.peers_result; @@ -598,3 +620,32 @@ where Ok(()) } + +/// [`Resolver`] adapter that delegates to a shared `Arc`. +/// +/// [`DefaultResolver::new`] takes `Vec>` — one owner +/// per chain slot. The npm resolver, however, is also handed to the +/// runtime resolvers (`Node` / `Deno` / `Bun` reuse it for version +/// picking) via `Arc`, so the same instance owns its +/// metadata cache across both call paths. This wrapper bridges +/// the two by implementing [`Resolver`] on a `Box`, +/// forwarding every call to the shared backing resolver. +struct ArcResolver(Arc); + +impl Resolver for ArcResolver { + fn resolve<'a>( + &'a self, + wanted_dependency: &'a WantedDependency, + opts: &'a ResolveOptions, + ) -> ResolveFuture<'a> { + self.0.resolve(wanted_dependency, opts) + } + + fn resolve_latest<'a>( + &'a self, + query: &'a LatestQuery, + opts: &'a ResolveOptions, + ) -> ResolveLatestFuture<'a> { + self.0.resolve_latest(query, opts) + } +}