Skip to content
20 changes: 12 additions & 8 deletions mise.lock
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ backend = "aqua:FiloSottile/age"
[[tools.bun]]
version = "1.3.3"
backend = "core:bun"
"platforms.linux-arm64" = { url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.3/bun-linux-aarch64.zip"}
"platforms.linux-x64" = { checksum = "blake3:21d78fbc47b9175cc18a79bc228c6b3aa92d102c3f9c006934101c6b588cc900", url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.3/bun-linux-x64.zip"}
"platforms.macos-arm64" = { checksum = "blake3:2b8fa453577fa737c9eaf42730791360c8a133397db28684c93e49f9dd5d501a", url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.3/bun-darwin-aarch64.zip"}
"platforms.macos-x64" = { url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.3/bun-darwin-x64.zip"}
"platforms.windows-x64" = { url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.3/bun-windows-x64.zip"}
"platforms.linux-arm64" = { checksum = "sha256:41b9f4f25256db897c2c135320e4f96c373e20ae6f06d8015187dac83591efc8", url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.3/bun-linux-aarch64.zip"}
"platforms.linux-x64" = { checksum = "sha256:f5c546736f955141459de231167b6fdf7b01418e8be3609f2cde9dfe46a93a3d", url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.3/bun-linux-x64.zip"}
"platforms.macos-arm64" = { checksum = "sha256:f50f5cc767c3882c46675fbe07e0b7b1df71a73ce544aadb537ad9261af00bb1", url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.3/bun-darwin-aarch64.zip"}
"platforms.macos-x64" = { checksum = "sha256:fdaf5e3c91de2f2a8c83e80a125c5111d476e5f7575b2747d71bc51d2c920bd4", url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.3/bun-darwin-x64.zip"}
"platforms.windows-x64" = { checksum = "sha256:53e239b058c13f0bb70949b222c4d40c5ab7d6cad22268b2ace2187fcfd7a247", url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.3/bun-windows-x64.zip"}

[[tools.cargo-binstall]]
version = "1.16.2"
Expand Down Expand Up @@ -61,7 +61,11 @@ backend = "cargo:usage-cli"
[[tools.fd]]
version = "10.3.0"
backend = "aqua:sharkdp/fd"
"platforms.linux-arm64" = { checksum = "sha256:996b9b1366433b211cb3bbedba91c9dbce2431842144d925428ead0adf32020b", url = "https://github.com/sharkdp/fd/releases/download/v10.3.0/fd-v10.3.0-aarch64-unknown-linux-musl.tar.gz"}
"platforms.linux-x64" = { checksum = "sha256:2b6bfaae8c48f12050813c2ffe1884c61ea26e750d803df9c9114550a314cd14", url = "https://github.com/sharkdp/fd/releases/download/v10.3.0/fd-v10.3.0-x86_64-unknown-linux-musl.tar.gz"}
"platforms.macos-arm64" = { checksum = "sha256:0570263812089120bc2a5d84f9e65cd0c25e4a4d724c80075c357239c74ae904", url = "https://github.com/sharkdp/fd/releases/download/v10.3.0/fd-v10.3.0-aarch64-apple-darwin.tar.gz"}
"platforms.macos-x64" = { checksum = "sha256:50d30f13fe3d5914b14c4fff5abcbd4d0cdab4b855970a6956f4f006c17117a3", url = "https://github.com/sharkdp/fd/releases/download/v10.3.0/fd-v10.3.0-x86_64-apple-darwin.tar.gz"}
"platforms.windows-x64" = { checksum = "sha256:318aa2a6fa664325933e81fda60d523fff29444129e91ebf0726b5b3bcd8b059", url = "https://github.com/sharkdp/fd/releases/download/v10.3.0/fd-v10.3.0-x86_64-pc-windows-msvc.zip"}

[[tools.gh]]
version = "2.83.1"
Expand Down Expand Up @@ -92,11 +96,11 @@ backend = "aqua:jqlang/jq"
[[tools.node]]
version = "24.11.1"
backend = "core:node"
"platforms.linux-arm64" = { url = "https://nodejs.org/dist/v24.11.1/node-v24.11.1-linux-arm64.tar.gz"}
"platforms.linux-arm64" = { checksum = "sha256:0dc93ec5c798b0d347f068db6d205d03dea9a71765e6a53922b682b91265d71f", url = "https://nodejs.org/dist/v24.11.1/node-v24.11.1-linux-arm64.tar.gz"}
"platforms.linux-x64" = { checksum = "sha256:58a5ff5cc8f2200e458bea22e329d5c1994aa1b111d499ca46ec2411d58239ca", url = "https://nodejs.org/dist/v24.11.1/node-v24.11.1-linux-x64.tar.gz"}
"platforms.macos-arm64" = { checksum = "sha256:b05aa3a66efe680023f930bd5af3fdbbd542794da5644ca2ad711d68cbd4dc35", url = "https://nodejs.org/dist/v24.11.1/node-v24.11.1-darwin-arm64.tar.gz"}
"platforms.macos-x64" = { url = "https://nodejs.org/dist/v24.11.1/node-v24.11.1-darwin-x64.tar.gz"}
"platforms.windows-x64" = { url = "https://nodejs.org/dist/v24.11.1/node-v24.11.1-win-x64.zip"}
"platforms.macos-x64" = { checksum = "sha256:096081b6d6fcdd3f5ba0f5f1d44a47e83037ad2e78eada26671c252fe64dd111", url = "https://nodejs.org/dist/v24.11.1/node-v24.11.1-darwin-x64.tar.gz"}
"platforms.windows-x64" = { checksum = "sha256:5355ae6d7c49eddcfde7d34ac3486820600a831bf81dc3bdca5c8db6a9bb0e76", url = "https://nodejs.org/dist/v24.11.1/node-v24.11.1-win-x64.zip"}

[[tools."npm:ajv-cli"]]
version = "5.0.0"
Expand Down
54 changes: 54 additions & 0 deletions src/backend/static_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,67 @@
use crate::backend::platform_target::PlatformTarget;
use crate::file;
use crate::hash;
use crate::http::HTTP;
use crate::toolset::ToolVersion;
use crate::toolset::ToolVersionOptions;
use crate::ui::progress_report::SingleReport;
use eyre::{Result, bail};
use indexmap::IndexSet;
use std::path::Path;

// ========== Checksum Fetching Helpers ==========

/// Fetches a checksum for a specific file from a SHASUMS256.txt-style file.
/// Uses cached HTTP requests since the same SHASUMS file is fetched for all platforms.
///
/// # Arguments
/// * `shasums_url` - URL to the SHASUMS256.txt file
/// * `filename` - The filename to look up in the SHASUMS file
///
/// # Returns
/// * `Some("sha256:<hash>")` if found
/// * `None` if the SHASUMS file couldn't be fetched or filename not found
pub async fn fetch_checksum_from_shasums(shasums_url: &str, filename: &str) -> Option<String> {
match HTTP.get_text_cached(shasums_url).await {
Ok(shasums_content) => {
let shasums = hash::parse_shasums(&shasums_content);
shasums.get(filename).map(|h| format!("sha256:{h}"))
}
Err(e) => {
debug!("Failed to fetch SHASUMS from {}: {e}", shasums_url);
None
}
}
}

/// Fetches a checksum from an individual checksum file (e.g., file.tar.gz.sha256).
/// The checksum file should contain just the hash, optionally followed by filename.
///
/// # Arguments
/// * `checksum_url` - URL to the checksum file (e.g., `https://example.com/file.tar.gz.sha256`)
/// * `algo` - The algorithm name to prefix (e.g., "sha256")
///
/// # Returns
/// * `Some("<algo>:<hash>")` if found
/// * `None` if the checksum file couldn't be fetched
pub async fn fetch_checksum_from_file(checksum_url: &str, algo: &str) -> Option<String> {
match HTTP.get_text(checksum_url).await {
Ok(content) => {
// Format is typically "<hash> <filename>" or just "<hash>"
content
.split_whitespace()
.next()
.map(|h| format!("{algo}:{}", h.trim()))
}
Err(e) => {
debug!("Failed to fetch checksum from {}: {e}", checksum_url);
None
}
}
}

// ========== Platform Patterns ==========

// Shared OS/arch patterns used across helpers
const OS_PATTERNS: &[&str] = &[
"linux", "darwin", "macos", "windows", "win", "freebsd", "openbsd", "netbsd", "android",
Expand Down
52 changes: 25 additions & 27 deletions src/cli/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,21 +180,39 @@ impl Lock {
config: &Config,
ts: &Toolset,
) -> Vec<(crate::cli::args::BackendArg, crate::toolset::ToolVersion)> {
// Collect tools from ALL config files (not just resolved current versions)
// This ensures base config tools are included even when overridden by env configs
// Calculate target config_root (same logic as get_lockfile_path)
let target_root = config
.config_files
.keys()
.next()
.map(|p| config_root::config_root(p))
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());

// Collect tools from config files in the target config_root only
let mut all_tools: Vec<_> = Vec::new();
let mut seen: BTreeSet<(String, String)> = BTreeSet::new();

// First, get all tools from the resolved toolset (these are the "current" versions)
// but only if they come from a config file in the target config_root
for (backend, tv) in ts.list_current_versions() {
// Check if this tool's source is in the target config_root
if let Some(source_path) = tv.request.source().path() {
if config_root::config_root(source_path) != target_root {
continue;
}
}
let key = (backend.ba().short.clone(), tv.version.clone());
if seen.insert(key) {
all_tools.push((backend.ba().as_ref().clone(), tv));
}
}

// Then, iterate ALL config files to find tools that may have been overridden
for (_path, cf) in config.config_files.iter() {
// Then, iterate config files in the target config_root to find tools that may have been overridden
for (path, cf) in config.config_files.iter() {
// Skip config files not in the target config_root
if config_root::config_root(path) != target_root {
continue;
}
if let Ok(trs) = cf.to_tool_request_set() {
for (ba, requests, _source) in trs.iter() {
for request in requests {
Expand All @@ -211,10 +229,9 @@ impl Lock {
}
}
}
// Also check installed versions that match this request
let installed = backend.list_installed_versions();
// For "latest" requests, find the highest installed version
if request.version() == "latest" {
// For "latest", find the highest installed version
let installed = backend.list_installed_versions();
if let Some(latest_version) = installed.iter().max_by(|a, b| {
versions::Versioning::new(a).cmp(&versions::Versioning::new(b))
}) {
Expand All @@ -227,21 +244,6 @@ impl Lock {
all_tools.push((ba.as_ref().clone(), tv));
}
}
} else {
// For prefix requests, find matching versions using proper fuzzy matching
// (ensures "1" matches "1.0.0" but not "10.0.0")
for version in
backend.list_installed_versions_matching(&request.version())
{
let key = (ba.short.clone(), version.clone());
if seen.insert(key.clone()) {
let tv = crate::toolset::ToolVersion::new(
request.clone(),
version,
);
all_tools.push((ba.as_ref().clone(), tv));
}
}
}
}
}
Expand Down Expand Up @@ -316,11 +318,7 @@ impl Lock {
let (info, options) = if let Some(backend) = backend {
let options = backend.resolve_lockfile_options(&tv.request, &target);
match backend.resolve_lock_info(&tv, &target).await {
Ok(info) if info.url.is_some() => (Some(info), options),
Ok(_) => {
debug!("No URL found for {} on {}", ba.short, platform.to_key());
(None, options)
}
Ok(info) => (Some(info), options),
Err(e) => {
warn!(
"Failed to resolve {} for {}: {}",
Expand Down
56 changes: 56 additions & 0 deletions src/http.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use std::collections::HashMap;
use std::io::Write;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::Duration;

use base64::Engine;
Expand All @@ -9,6 +11,7 @@ use regex::Regex;
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::{ClientBuilder, IntoUrl, Method, Response};
use std::sync::LazyLock as Lazy;
use tokio::sync::OnceCell;
use tokio_retry::Retry;
use tokio_retry::strategy::{ExponentialBackoff, jitter};
use url::Url;
Expand Down Expand Up @@ -36,6 +39,14 @@ pub static HTTP_FETCH: Lazy<Client> = Lazy::new(|| {
.unwrap()
});

/// In-memory cache for HTTP text responses, useful for requests that are repeated
/// during a single operation (e.g., fetching SHASUMS256.txt for multiple platforms).
/// Each URL gets its own OnceCell to ensure concurrent requests for the same URL
/// wait for the first fetch to complete rather than all fetching simultaneously.
type CachedResult = Arc<OnceCell<Result<String, String>>>;
static HTTP_CACHE: Lazy<Mutex<HashMap<String, CachedResult>>> =
Lazy::new(|| Mutex::new(HashMap::new()));

#[derive(Debug)]
pub struct Client {
reqwest: reqwest::Client,
Expand Down Expand Up @@ -133,6 +144,39 @@ impl Client {
Ok(text)
}

/// Like get_text but caches results in memory for the duration of the process.
/// Useful when the same URL will be requested multiple times (e.g., SHASUMS256.txt
/// when locking multiple platforms). Concurrent requests for the same URL will
/// wait for the first fetch to complete.
pub async fn get_text_cached<U: IntoUrl>(&self, url: U) -> Result<String> {
let url = url.into_url().unwrap();
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using unwrap() here will panic on invalid URLs without a helpful error message. Consider using ? operator to propagate the error with proper context, or provide a descriptive error message.

Suggested change
let url = url.into_url().unwrap();
let url = url.into_url().map_err(Into::into)?;

Copilot uses AI. Check for mistakes.
let key = url.to_string();

// Get or create the OnceCell for this URL
let cell = {
let mut cache = HTTP_CACHE.lock().unwrap();
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using unwrap() on a Mutex::lock() can cause panics if the mutex is poisoned. Consider using lock().expect() with a descriptive message or handle the poison error explicitly to improve debugging.

Suggested change
let mut cache = HTTP_CACHE.lock().unwrap();
let mut cache = HTTP_CACHE.lock().expect("Failed to lock HTTP_CACHE mutex (possibly poisoned)");

Copilot uses AI. Check for mistakes.
cache.entry(key).or_default().clone()
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HashMap uses String keys which requires cloning the URL string. Consider using Arc<str> or a reference-counted key to avoid string clones on cache hits.

Copilot uses AI. Check for mistakes.
};

// Initialize the cell if needed - concurrent callers will wait
let result = cell
.get_or_init(|| {
let url = url.clone();
async move {
match self.get_text(url).await {
Ok(text) => Ok(text),
Err(err) => Err(err.to_string()),
}
}
})
.await;

match result {
Ok(text) => Ok(text.clone()),
Err(err) => bail!("{}", err),
}
}

pub async fn get_html<U: IntoUrl>(&self, url: U) -> Result<String> {
let url = url.into_url().unwrap();
let resp = self.get_async(url.clone()).await?;
Expand Down Expand Up @@ -176,6 +220,18 @@ impl Client {
self.json_headers(url).await.map(|(json, _)| json)
}

/// Like json but caches raw JSON text in memory for the duration of the process.
/// Useful when the same URL will be requested multiple times (e.g., zig index.json
/// when locking multiple platforms). Concurrent requests for the same URL will
/// wait for the first fetch to complete.
pub async fn json_cached<T, U: IntoUrl>(&self, url: U) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
let text = self.get_text_cached(url).await?;
Ok(serde_json::from_str(&text)?)
}

pub async fn json_with_headers<T, U: IntoUrl>(&self, url: U, headers: &HeaderMap) -> Result<T>
where
T: serde::de::DeserializeOwned,
Expand Down
30 changes: 27 additions & 3 deletions src/lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ pub struct PlatformInfo {
pub url_api: Option<String>,
}

impl PlatformInfo {
/// Returns true if this PlatformInfo has no meaningful data (for serde skip)
pub fn is_empty(&self) -> bool {
self.checksum.is_none() && self.url.is_none() && self.url_api.is_none()
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The is_empty() method doesn't check the size field. If size is the only populated field, this method will incorrectly return true. Either include size in the check or document why it's intentionally excluded.

Suggested change
self.checksum.is_none() && self.url.is_none() && self.url_api.is_none()
self.checksum.is_none()
&& self.size.is_none()
&& self.url.is_none()
&& self.url_api.is_none()

Copilot uses AI. Check for mistakes.
}
}

impl TryFrom<toml::Value> for PlatformInfo {
type Error = Report;
fn try_from(value: toml::Value) -> Result<Self> {
Expand Down Expand Up @@ -182,6 +189,7 @@ impl Lockfile {
}

/// Update or add platform info for a tool version
/// Merges with existing info, preserving fields we don't have new values for
pub fn set_platform_info(
&mut self,
short: &str,
Expand All @@ -197,11 +205,27 @@ impl Lockfile {
.iter_mut()
.find(|t| t.version == version && &t.options == options)
{
tool.platforms
.insert(platform_key.to_string(), platform_info);
// Merge with existing platform info, preferring new values when present
let merged = if let Some(existing) = tool.platforms.get(platform_key) {
PlatformInfo {
checksum: platform_info.checksum.or_else(|| existing.checksum.clone()),
size: platform_info.size.or(existing.size),
url: platform_info.url.or_else(|| existing.url.clone()),
url_api: platform_info.url_api.or_else(|| existing.url_api.clone()),
}
} else {
platform_info
};
// Only insert non-empty platform info to avoid `"platforms.linux-x64" = {}`
if !merged.is_empty() {
tool.platforms.insert(platform_key.to_string(), merged);
}
} else {
let mut platforms = BTreeMap::new();
platforms.insert(platform_key.to_string(), platform_info);
// Only insert non-empty platform info
if !platform_info.is_empty() {
platforms.insert(platform_key.to_string(), platform_info);
}
tools.push(LockfileTool {
version: version.to_string(),
backend: backend.map(|s| s.to_string()),
Expand Down
32 changes: 32 additions & 0 deletions src/plugins/core/bun.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ use eyre::Result;
use itertools::Itertools;
use versions::Versioning;

use crate::backend::static_helpers::fetch_checksum_from_shasums;
use crate::cli::args::BackendArg;
use crate::cli::version::{ARCH, OS};
use crate::cmd::CmdLineRunner;
use crate::http::HTTP;
use crate::install_context::InstallContext;
use crate::lockfile::PlatformInfo;
use crate::toolset::ToolVersion;
use crate::ui::progress_report::SingleReport;
use crate::{
Expand Down Expand Up @@ -144,6 +146,36 @@ impl Backend for BunPlugin {
release_type: ReleaseType::GitHub,
}))
}

async fn resolve_lock_info(
&self,
tv: &ToolVersion,
target: &PlatformTarget,
) -> Result<PlatformInfo> {
let version = &tv.version;

// Build platform-specific filename
let os_name = Self::map_os_to_bun(target.os_name());
let arch_name = Self::get_bun_arch_for_target(target);
let filename = format!("bun-{os_name}-{arch_name}.zip");

// Build download URL
let url =
format!("https://github.com/oven-sh/bun/releases/download/bun-v{version}/{filename}");

// Fetch SHASUMS256.txt to get checksum without downloading the zip
let shasums_url = format!(
"https://github.com/oven-sh/bun/releases/download/bun-v{version}/SHASUMS256.txt"
);
let checksum = fetch_checksum_from_shasums(&shasums_url, &filename).await;

Ok(PlatformInfo {
url: Some(url),
checksum,
size: None,
url_api: None,
})
}
}

impl BunPlugin {
Expand Down
Loading
Loading