mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-20 04:32:26 -04:00
feat(pacquet): port node/deno/bun runtime resolvers (#11783)
* feat(pacquet): port node/deno/bun runtime resolvers and wire them into the install chain
Ports the three `@pnpm/engine.runtime.*-resolver` packages and the shared
`@pnpm/crypto.shasums-file` helper into pacquet, and slots them into the
default-resolver chain so `node@runtime:<spec>`, `deno@runtime:<spec>`,
and `bun@runtime:<spec>` resolve through pacquet as they do in pnpm.
New crates under `pacquet/crates/`:
- `crypto-shasums-file` — downloads and decodes `SHASUMS256.txt`,
shared by node and bun. Mirrors `FAILED_DOWNLOAD_SHASUM_FILE`,
`NODE_INTEGRITY_HASH_NOT_FOUND`, `NODE_MALFORMED_INTEGRITY_HASH`.
- `engine-runtime-node-resolver` — `parse_node_specifier`,
`get_node_mirror`, `get_node_artifact_address`, `normalize_arch`,
`resolve_node_version[s]`, and the `Resolver`-impl entry point.
Handles the unofficial-musl mirror fan-out, the `lts` / LTS-codename
/ channel / range selectors, and the `darwin/arm64 <16 → x64`,
`win32/ia32 → x86`, `arm → armv7l` arch quirks. Error codes
`NO_OFFLINE_NODEJS_RESOLUTION`, `NODEJS_VERSION_NOT_FOUND`,
`INVALID_NODE_RELEASE_CHANNEL` match upstream.
- `engine-runtime-deno-resolver` — version selection delegates to the
npm resolver; assets come from the GitHub Releases API + per-asset
SHA256 sidecars. Windows x64 covers arm64 under emulation.
Errors: `DENO_RESOLUTION_FAILURE`, `DENO_MISSING_ASSETS`,
`DENO_GITHUB_FAILURE`, `DENO_PARSE_HASH`.
- `engine-runtime-bun-resolver` — version selection delegates to npm;
assets come from the GitHub-release `SHASUMS256.txt`. `windows` /
`aarch64` are normalised to `win32` / `arm64`. Error:
`BUN_RESOLUTION_FAILURE`.
Wiring (`install_without_lockfile.rs`): chain order is now
`npm → git → node → deno → bun`, matching upstream's
`resolving/default-resolver/src/index.ts` at 1627943d2a. The npm
resolver is shared via `Arc<dyn Resolver>` so the deno/bun resolvers
reuse the same metadata cache; a small `ArcResolver` adapter bridges
that to `DefaultResolver`'s `Vec<Box<dyn Resolver>>`.
Out of scope (called out in code):
- `currentPkg && !update` short-circuit isn't restored yet — needs
`ResolveOptions::current_pkg` first. The resolver re-fetches the
asset list on every install.
- `nodeDownloadMirrors` defaults to empty. Wiring it through the
config layer is a follow-up.
* fix(pacquet): silence rustdoc errors on runtime-resolver doc comments
CI's `Doc` job runs rustdoc with `-D warnings`. Three intra-doc links
in this PR's new doc comments tripped it:
- `crypto-shasums-file` referenced `pacquet_lockfile::BinaryResolution`
but the crate doesn't (and shouldn't) depend on `pacquet-lockfile`.
Drop the link and leave the name as plain text.
- `engine-runtime-deno-resolver` linked `DefaultResolver` to
`pacquet_resolving_default_resolver::DefaultResolver` — same crate-
dependency story. Rewrite the prose to mention "the default-resolver
chain" without a link.
- `engine-runtime-deno-resolver` doc comment on the public
`ReadDenoAssetsError` linked to the private free function
`read_deno_assets`. Point at the public re-export
`DenoResolverError::ReadAssets` instead so the link is reachable
from generated docs.
- `engine-runtime-bun-resolver` had a redundant explicit link target
on `PlatformAssetResolution` (label and target resolve to the same
item). Drop the redundant target and reword from `…s` to `… entries`
so the link label doesn't carry a stray pluralisation `s`.
* fix(pacquet): drop redundant explicit target on PlatformAssetResolution link
The target resolves to the same item as the label (the type is imported
into scope further down in the same file), so rustdoc with
`-D rustdoc::redundant-explicit-links` rejects the form. Drop the
target and let intra-doc resolution pick it up via the existing use.
* fix(pacquet): satisfy CI Doc + Dylint on runtime-resolver crates
The pacquet CI runs rustdoc and `cargo dylint` (perfectionist lints)
with `-D warnings`, both of which catch issues `just ready` doesn't:
- rustdoc on `engine-runtime-node-resolver/lib.rs` reported
`parse_node_specifier` / `get_node_mirror` / `get_node_artifact_address`
as ambiguous between the module and the same-named function the
module re-exports. Disambiguate by appending `()` to the link label
so rustdoc resolves to the function.
- dylint's `perfectionist::single-letter-closure-param` flagged the
`|c|` parameter in `parse_node_specifier::prerelease_channel`.
Rename to `next` and break the chain so the body stays readable.
- dylint's `perfectionist::prefer-raw-string` flagged the regex literal
on `NODE_EXTRAS_IGNORE_PATTERN`. Convert to a raw string so the
backslash before `.` reads as the regex escape it is.
- dylint's `perfectionist::macro-trailing-comma` flagged the
multi-line `matches!` invocation in the shasums-file `NotFound`
test. Re-shape with the trailing comma and split across lines.
* fix(pacquet): re-flow prerelease_channel closure to keep rustfmt happy
* fix(pacquet): address PR review on runtime-resolver port
Five behavioral / hygiene fixes the CodeRabbit review surfaced that
hold up against the upstream pnpm source:
- `crypto-shasums-file`: tighten `is_sha256_hex` from
`is_ascii_hexdigit` (accepts `A-F`) to lowercase-only `0-9a-f`,
matching upstream's `/^[a-f0-9]{64}$/`.
- `engine-runtime-node-resolver/resolve_node_version`: thread
`error_for_status` into the `fetch_all_versions` GET so non-2xx
responses from `index.json` surface as `FetchIndex` rather than
being read into text and decoded as `DecodeIndex`. Matches the
existing convention in `resolving-npm-resolver/fetch_full_metadata`.
- `engine-runtime-node-resolver/resolve_node_version`: introduce
`satisfies_with_prereleases` mirroring the strategy already used in
`resolving-deps-resolver/resolve_peers` so range selectors like
`rc/18` pick up `18.0.0-rc.X` candidates. Upstream's
`semver.maxSatisfying(...)` runs with `includePrerelease: true`;
`node-semver` Rust does not — strip the prerelease suffix on a
failed straight check and retry against the base version.
- `engine-runtime-deno-resolver/read_deno_assets`: same
`error_for_status` fix on the GitHub Releases API call so a 404 or
rate-limit response is a `FetchReleaseIndex` failure, not a JSON
decode error.
- `package-manager/install_without_lockfile`: also drop the
standalone `npm_resolver` Arc binding after the resolve pass.
`drop(resolver)` only releases the `DefaultResolver` chain (one
strong reference); the `npm_resolver` local kept a second strong
reference because the deno- and bun-resolvers were handed clones
of the same `Arc`. Without the explicit drop the packument cache
stays alive through every fetch/import/link, which the comment
above already says we want to avoid.
Plus one test-only addition (a `darwin/arm` passthrough assertion in
`normalize_arch/tests.rs`) that pins the upstream behavior — pnpm
applies the `arm → armv7l` quirk unconditionally, including outside
Linux. Locking that in keeps a well-meaning future "Linux-only"
narrowing from silently diverging from pnpm.
Other CodeRabbit suggestions (propagate caller opts on the
npm-delegation calls, error on empty Deno variants, offline guard in
`resolve_latest_impl`, scope `arm` rewrite to Linux) all reflect
behaviors that *upstream pnpm doesn't have* — adopting any of them
would break the parity contract in
[`pacquet/AGENTS.md`](pacquet/AGENTS.md). Left in place.
* fix(pacquet/deno-resolver): surface hex-decode failures as DENO_PARSE_HASH
`fetch_sha256` already guarantees the returned string is a 64-char
lower-case hex run, so `decode_hex` cannot fail in practice. Drop the
`unwrap_or_default()` fallback (which would silently feed an empty
byte slice into the integrity construction and then trip an opaque
\`Integrity\` parse error downstream) in favor of an explicit
`ParseHash` error, so a future change that loosens `extract_sha256`'s
validator surfaces with the right code instead of obscuring the
failure shape.
This commit is contained in:
73
Cargo.lock
generated
73
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
26
pacquet/crates/crypto-shasums-file/Cargo.toml
Normal file
26
pacquet/crates/crypto-shasums-file/Cargo.toml
Normal file
@@ -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
|
||||
203
pacquet/crates/crypto-shasums-file/src/lib.rs
Normal file
203
pacquet/crates/crypto-shasums-file/src/lib.rs
Normal file
@@ -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 `<hex-sha256> <filename>`
|
||||
//! row per line; pacquet converts each row into an SRI-style
|
||||
//! `sha256-<base64>` 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-<base64>`); 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<reqwest::Error>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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 `<shasums_url>` and decode every `<hex> <filename>` 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<Vec<ShasumsFileItem>, 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<String, FetchShasumsFileError> {
|
||||
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<ShasumsFileItem> {
|
||||
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 ` <file_name>` (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-<b64>`.
|
||||
pub fn pick_file_checksum_from_shasums_file(
|
||||
body: &str,
|
||||
file_name: &str,
|
||||
) -> Result<String, PickFileChecksumError> {
|
||||
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-<base64>`.
|
||||
///
|
||||
/// 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<Vec<u8>> {
|
||||
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;
|
||||
98
pacquet/crates/crypto-shasums-file/src/tests.rs
Normal file
98
pacquet/crates/crypto-shasums-file/src/tests.rs
Normal file
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
33
pacquet/crates/engine-runtime-bun-resolver/Cargo.toml
Normal file
33
pacquet/crates/engine-runtime-bun-resolver/Cargo.toml
Normal file
@@ -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
|
||||
187
pacquet/crates/engine-runtime-bun-resolver/src/bun_resolver.rs
Normal file
187
pacquet/crates/engine-runtime-bun-resolver/src/bun_resolver.rs
Normal file
@@ -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<dyn Resolver>` for the npm resolver that
|
||||
/// version selection delegates to.
|
||||
pub struct BunResolver {
|
||||
pub http_client: Arc<ThrottledClient>,
|
||||
pub npm_resolver: Arc<dyn Resolver>,
|
||||
}
|
||||
|
||||
impl BunResolver {
|
||||
pub fn new(http_client: Arc<ThrottledClient>, npm_resolver: Arc<dyn Resolver>) -> 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<Option<ResolveResult>, 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<Option<LatestInfo>, 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;
|
||||
@@ -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::<Option<ResolveResult>, ResolveError>(None) })
|
||||
}
|
||||
fn resolve_latest<'a>(
|
||||
&'a self,
|
||||
_query: &'a pacquet_resolving_resolver_base::LatestQuery,
|
||||
_opts: &'a ResolveOptions,
|
||||
) -> ResolveLatestFuture<'a> {
|
||||
Box::pin(async { Ok::<Option<LatestInfo>, 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());
|
||||
}
|
||||
16
pacquet/crates/engine-runtime-bun-resolver/src/lib.rs
Normal file
16
pacquet/crates/engine-runtime-bun-resolver/src/lib.rs
Normal file
@@ -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:<spec>` 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;
|
||||
@@ -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<version>/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<ssri::Error>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Fetch and decode the Bun-release SHASUMS file for `version`.
|
||||
pub async fn read_bun_assets(
|
||||
http_client: &ThrottledClient,
|
||||
version: &str,
|
||||
) -> Result<Vec<PlatformAssetResolution>, 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<BunAssetName> {
|
||||
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;
|
||||
@@ -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());
|
||||
}
|
||||
33
pacquet/crates/engine-runtime-deno-resolver/Cargo.toml
Normal file
33
pacquet/crates/engine-runtime-deno-resolver/Cargo.toml
Normal file
@@ -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
|
||||
194
pacquet/crates/engine-runtime-deno-resolver/src/deno_resolver.rs
Normal file
194
pacquet/crates/engine-runtime-deno-resolver/src/deno_resolver.rs
Normal file
@@ -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<dyn Resolver>` 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<ThrottledClient>,
|
||||
pub npm_resolver: Arc<dyn Resolver>,
|
||||
}
|
||||
|
||||
impl DenoResolver {
|
||||
pub fn new(http_client: Arc<ThrottledClient>, npm_resolver: Arc<dyn Resolver>) -> 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<Option<ResolveResult>, 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<Option<LatestInfo>, 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;
|
||||
@@ -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::<Option<ResolveResult>, ResolveError>(None) })
|
||||
}
|
||||
fn resolve_latest<'a>(
|
||||
&'a self,
|
||||
_query: &'a pacquet_resolving_resolver_base::LatestQuery,
|
||||
_opts: &'a ResolveOptions,
|
||||
) -> ResolveLatestFuture<'a> {
|
||||
Box::pin(async { Ok::<Option<LatestInfo>, 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());
|
||||
}
|
||||
26
pacquet/crates/engine-runtime-deno-resolver/src/lib.rs
Normal file
26
pacquet/crates/engine-runtime-deno-resolver/src/lib.rs
Normal file
@@ -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:<spec>` 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<dyn Resolver>`](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;
|
||||
@@ -0,0 +1,256 @@
|
||||
//! GitHub Releases asset enumerator for [`crate::DenoResolver`].
|
||||
//!
|
||||
//! Each Deno release publishes a `deno-<arch>-<vendor>-<os>.zip`
|
||||
//! per platform plus a sibling `<asset>.sha256sum` text file with
|
||||
//! the hex-encoded SHA-256. This module:
|
||||
//!
|
||||
//! 1. GETs `https://api.github.com/repos/denoland/deno/releases/tags/v<version>`
|
||||
//! 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<reqwest::Error>,
|
||||
},
|
||||
|
||||
#[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<reqwest::Error>,
|
||||
},
|
||||
|
||||
#[display("Failed to decode release index for Deno v{version}")]
|
||||
#[diagnostic(code(DENO_GITHUB_FAILURE))]
|
||||
DecodeReleaseIndex {
|
||||
version: String,
|
||||
#[error(source)]
|
||||
error: Arc<serde_json::Error>,
|
||||
},
|
||||
|
||||
#[display("Failed to parse integrity for {url}")]
|
||||
#[diagnostic(code(DENO_PARSE_HASH))]
|
||||
Integrity {
|
||||
url: String,
|
||||
#[error(source)]
|
||||
error: Arc<ssri::Error>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReleaseIndex {
|
||||
#[serde(default)]
|
||||
assets: Option<Vec<ReleaseAsset>>,
|
||||
}
|
||||
|
||||
#[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<Vec<PlatformAssetResolution>, 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-<cpu>-<vendor-os>.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<Vec<PlatformAssetTarget>> {
|
||||
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<String, ReadDenoAssetsError> {
|
||||
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<String> {
|
||||
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<Vec<u8>> {
|
||||
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;
|
||||
@@ -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());
|
||||
}
|
||||
33
pacquet/crates/engine-runtime-node-resolver/Cargo.toml
Normal file
33
pacquet/crates/engine-runtime-node-resolver/Cargo.toml
Normal file
@@ -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
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<version>/...` without a defensive check.
|
||||
pub fn get_node_mirror(
|
||||
node_download_mirrors: Option<&HashMap<String, String>>,
|
||||
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;
|
||||
@@ -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/<channel>/`.
|
||||
#[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<version>/...` 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/");
|
||||
}
|
||||
52
pacquet/crates/engine-runtime-node-resolver/src/lib.rs
Normal file
52
pacquet/crates/engine-runtime-node-resolver/src/lib.rs
Normal file
@@ -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:<spec>` 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 `<channel>/<spec>`,
|
||||
//! `<channel>`, 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,
|
||||
};
|
||||
374
pacquet/crates/engine-runtime-node-resolver/src/node_resolver.rs
Normal file
374
pacquet/crates/engine-runtime-node-resolver/src/node_resolver.rs
Normal file
@@ -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:<spec>`
|
||||
//! 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<ssri::Error>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<ThrottledClient>,
|
||||
pub node_download_mirrors: HashMap<String, String>,
|
||||
pub offline: bool,
|
||||
}
|
||||
|
||||
impl NodeResolver {
|
||||
pub fn new(http_client: Arc<ThrottledClient>) -> 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<Option<ResolveResult>, 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<Option<LatestInfo>, 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<Vec<PlatformAssetResolution>, 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<version>-<platform>-<arch>(-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<Vec<PlatformAssetResolution>, 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<version>-([^-.]+)-([^.-]+)(-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<NodeFileName> {
|
||||
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;
|
||||
@@ -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::<super::NodeResolverError>().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());
|
||||
}
|
||||
@@ -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<u32> {
|
||||
version.split('.').next()?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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. `<channel>/<spec>` — explicit channel prefix. Unknown channels
|
||||
/// raise [`ParseNodeSpecifierError::InvalidReleaseChannel`].
|
||||
/// 2. `<major.minor.patch>-<channel>...` — exact prerelease whose
|
||||
/// `-<channel>` suffix identifies the channel (`nightly`, `rc`,
|
||||
/// `test`, `v8-canary`).
|
||||
/// 3. `<major.minor.patch>` — 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<NodeSpecifier, ParseNodeSpecifierError> {
|
||||
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-<channel>...`
|
||||
/// 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;
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<reqwest::Error>,
|
||||
},
|
||||
|
||||
#[display("Failed to decode Node.js release index at {url}")]
|
||||
#[diagnostic(code(DECODE_NODE_INDEX_FAILED))]
|
||||
DecodeIndex {
|
||||
url: String,
|
||||
#[error(source)]
|
||||
error: Arc<serde_json::Error>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<Option<String>, 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<Vec<String>, 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<Vec<NodeVersion>, 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<RawNodeVersion> = 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<String> {
|
||||
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>, 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<String> {
|
||||
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;
|
||||
@@ -0,0 +1,39 @@
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::{NodeVersion, filter_versions};
|
||||
|
||||
fn make_versions() -> Vec<NodeVersion> {
|
||||
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");
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<dyn Resolver> = 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
|
||||
// <https://github.com/pnpm/pnpm/blob/b61e268d57/resolving/default-resolver/src/index.ts#L97-L173>:
|
||||
// npm → git → tarball → local-scheme → named-registry →
|
||||
// local-path. The local-resolver split is required by
|
||||
// named-registry: a `<alias>:@scope/pkg` specifier carries an
|
||||
// embedded `/`, which the path-shape detector
|
||||
// <https://github.com/pnpm/pnpm/blob/1627943d2a/resolving/default-resolver/src/index.ts#L128-L147>:
|
||||
// npm → jsr (folded into npm) → git → tarball → localScheme →
|
||||
// node → deno → bun → namedRegistry → localPath. The
|
||||
// local-resolver split is required by named-registry: a
|
||||
// `<alias>:@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<dyn Resolver> = 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<dyn Resolver>`.
|
||||
///
|
||||
/// [`DefaultResolver::new`] takes `Vec<Box<dyn Resolver>>` — 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<dyn Resolver>`, so the same instance owns its
|
||||
/// metadata cache across both call paths. This wrapper bridges
|
||||
/// the two by implementing [`Resolver`] on a `Box<ArcResolver>`,
|
||||
/// forwarding every call to the shared backing resolver.
|
||||
struct ArcResolver(Arc<dyn Resolver>);
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user