use std::env::consts::EXE_EXTENSION;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use itertools::Itertools;
use prek_consts::env_vars::{EnvVars, EnvVarsRead};
use reqwest::Url;
use serde::Deserialize;
use target_lexicon::{Architecture, Environment, HOST, OperatingSystem, Triple};
use tracing::{debug, trace, warn};
use crate::archive;
use crate::checksum::{Sha256Digest, digest_from_sha256sums};
use crate::fs::LockedFile;
use crate::http::{DownloadChecksumPolicy, REQWEST_CLIENT, download_artifact_with};
use crate::languages::ruby::RubyRequest;
use crate::process::Cmd;
use crate::store::Store;
const RV_RUBY_DEFAULT_URL: &str = "https://github.com/spinel-coop/rv-ruby";
fn rv_ruby_base_url(env_vars: &impl EnvVarsRead) -> String {
match env_vars.var(EnvVars::PREK_RUBY_MIRROR) {
Ok(mirror) => mirror.trim_end_matches('/').to_string(),
Err(_) => RV_RUBY_DEFAULT_URL.to_string(),
}
}
fn rv_ruby_api_url(env_vars: &impl EnvVarsRead) -> (String, bool) {
let base = rv_ruby_base_url(env_vars);
if let Some(path) = github_repo_path(&base) {
(
format!("https://api.github.com/repos{path}/releases/latest"),
true,
)
} else {
(format!("{base}/releases/latest"), false)
}
}
fn github_repo_path(url: &str) -> Option<String> {
let url = Url::parse(url).ok()?;
if url.scheme() != "https"
|| url.host_str() != Some("github.com")
|| !url.username().is_empty()
|| url.password().is_some()
|| !matches!(url.port(), None | Some(443))
{
return None;
}
let segments = url.path_segments()?.collect::<Vec<_>>();
let [owner, repo] = segments.as_slice() else {
return None;
};
if owner.is_empty() || repo.is_empty() {
return None;
}
Some(format!("/{owner}/{repo}"))
}
fn maybe_add_github_auth(
req: reqwest::RequestBuilder,
is_github: bool,
env_vars: &impl EnvVarsRead,
) -> reqwest::RequestBuilder {
if is_github {
if let Ok(token) = env_vars.var(EnvVars::GITHUB_TOKEN) {
return req.bearer_auth(token);
}
}
req
}
#[derive(Deserialize)]
struct GitHubRelease {
assets: Vec<GitHubAsset>,
}
#[derive(Deserialize)]
struct GitHubAsset {
name: String,
}
fn rv_platform_string(triple: &Triple) -> Option<&'static str> {
match (
triple.operating_system,
triple.architecture,
triple.environment,
) {
(OperatingSystem::Darwin(_), Architecture::X86_64, _) => Some("ventura"),
(OperatingSystem::Darwin(_), Architecture::Aarch64(_), _) => Some("arm64_sonoma"),
(OperatingSystem::Linux, Architecture::X86_64, Environment::Gnu) => Some("x86_64_linux"),
(OperatingSystem::Linux, Architecture::Aarch64(_), Environment::Gnu) => Some("arm64_linux"),
(OperatingSystem::Linux, Architecture::X86_64, Environment::Musl) => {
Some("x86_64_linux_musl")
}
(OperatingSystem::Linux, Architecture::Aarch64(_), Environment::Musl) => {
Some("arm64_linux_musl")
}
_ => None,
}
}
#[derive(Debug)]
pub(crate) struct RubyResult {
ruby_bin: PathBuf,
version: semver::Version,
engine: String,
}
impl RubyResult {
pub(crate) fn ruby_bin(&self) -> &Path {
&self.ruby_bin
}
pub(crate) fn version(&self) -> &semver::Version {
&self.version
}
pub(crate) fn engine(&self) -> &str {
&self.engine
}
}
pub(crate) struct RubyInstaller {
root: PathBuf,
}
impl RubyInstaller {
pub(crate) fn new(root: PathBuf) -> Self {
Self { root }
}
pub(crate) async fn install(
&self,
store: &Store,
request: &RubyRequest,
allows_download: bool,
) -> Result<RubyResult> {
fs_err::tokio::create_dir_all(&self.root).await?;
let _lock = LockedFile::acquire(self.root.join(".lock"), "ruby").await?;
if let Some(ruby) = self.find_installed(request) {
trace!(
"Using managed Ruby: {} at {}",
ruby.version(),
ruby.ruby_bin().display()
);
return Ok(ruby);
}
if let Some(ruby) = self.find_system_ruby(request).await? {
trace!(
"Using system Ruby: {} at {}",
ruby.version(),
ruby.ruby_bin().display()
);
return Ok(ruby);
}
if !allows_download {
anyhow::bail!(ruby_not_found_error(
request,
"Automatic installation is disabled (language_version: system)."
));
}
let Some(platform) = rv_platform_string(&HOST) else {
anyhow::bail!(ruby_not_found_error(
request,
"Automatic installation is not supported on this platform."
));
};
let versions = match self.list_remote_versions(platform).await {
Ok(v) => v,
Err(e) => {
anyhow::bail!(
"{}\n\nCaused by:\n {e}",
ruby_not_found_error(
request,
"Failed to fetch available Ruby versions from rv-ruby."
)
);
}
};
let Some(version) = versions.into_iter().find(|v| request.matches(v)) else {
anyhow::bail!(ruby_not_found_error(
request,
&format!("No rv-ruby release found matching: {request}")
));
};
self.download(store, &version, platform).await
}
fn find_installed(&self, request: &RubyRequest) -> Option<RubyResult> {
fs_err::read_dir(&self.root)
.ok()?
.flatten()
.filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir()))
.filter_map(|entry| {
let version = semver::Version::parse(&entry.file_name().to_string_lossy()).ok()?;
let bin_dir = entry.path().join("bin");
let ruby_bin = bin_dir.join("ruby");
let gem_bin = bin_dir.join("gem");
if ruby_bin.exists() && gem_bin.exists() {
Some((version, ruby_bin))
} else {
None
}
})
.sorted_unstable_by(|(a, _), (b, _)| b.cmp(a)) .find_map(|(version, ruby_bin)| {
if request.matches(&version) {
Some(RubyResult {
ruby_bin,
version,
engine: "ruby".to_string(),
})
} else {
None
}
})
}
async fn list_remote_versions(&self, platform: &str) -> Result<Vec<semver::Version>> {
let (api_url, is_github) = rv_ruby_api_url(&EnvVars);
let suffix = format!(".{platform}.tar.gz");
let req = REQWEST_CLIENT
.get(&api_url)
.header("Accept", "application/vnd.github+json");
let req = maybe_add_github_auth(req, is_github, &EnvVars);
let release: GitHubRelease = req
.send()
.await
.and_then(reqwest::Response::error_for_status)
.with_context(|| format!("Failed to fetch rv-ruby releases from {api_url}"))?
.json()
.await
.context("Failed to parse rv-ruby release JSON")?;
let versions = release
.assets
.iter()
.filter_map(|asset| parse_version_from_asset(&asset.name, &suffix))
.sorted_unstable()
.rev()
.collect();
Ok(versions)
}
async fn download(
&self,
store: &Store,
version: &semver::Version,
platform: &str,
) -> Result<RubyResult> {
let filename = format!("ruby-{version}.{platform}.tar.gz");
let base_url = rv_ruby_base_url(&EnvVars);
let is_github = github_repo_path(&base_url).is_some();
let download_base_url = format!("{base_url}/releases/latest/download");
let url = format!("{download_base_url}/{filename}");
let checksum_url = format!("{download_base_url}/SHA256SUMS");
let version_str = version.to_string();
let target = self.root.join(&version_str);
debug!(url = %url, target = %target.display(), "Downloading Ruby {version}");
let download = download_artifact_with(
&url,
&filename,
store,
DownloadChecksumPolicy::from_env(&EnvVars),
async || Self::fetch_checksum(&checksum_url, &filename, is_github).await,
|req| maybe_add_github_auth(req, is_github, &EnvVars),
)
.await
.with_context(|| format!("Failed to download Ruby {version} from {url}"))?;
let extracted = archive::extract_archive(download.path())
.await
.with_context(|| format!("Failed to extract Ruby {version}"))?;
let inner = extracted.join(&version_str);
if !inner.exists() {
anyhow::bail!(
"Expected directory `{version_str}` inside rv-ruby archive, found: {:?}",
fs_err::read_dir(extracted)?
.flatten()
.map(|e| e.file_name())
.collect::<Vec<_>>()
);
}
if target.exists() {
debug!(target = %target.display(), "Removing existing Ruby");
fs_err::tokio::remove_dir_all(&target).await?;
}
fs_err::tokio::rename(&inner, &target).await?;
Ok(RubyResult {
ruby_bin: target.join("bin").join("ruby"),
version: version.clone(),
engine: "ruby".to_string(),
})
}
async fn fetch_checksum(
checksum_url: &str,
filename: &str,
is_github: bool,
) -> Result<Option<Sha256Digest>> {
let req = REQWEST_CLIENT
.get(checksum_url)
.header("Accept", "application/octet-stream");
let req = maybe_add_github_auth(req, is_github, &EnvVars);
let response = req.send().await.with_context(|| {
format!("Failed to fetch rv-ruby checksum file from {checksum_url}")
})?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let checksums = response
.error_for_status()
.with_context(|| format!("Failed to fetch rv-ruby checksum file from {checksum_url}"))?
.text()
.await
.with_context(|| format!("Failed to read rv-ruby checksum file from {checksum_url}"))?;
digest_from_sha256sums(&checksums, filename)
}
async fn find_system_ruby(&self, request: &RubyRequest) -> Result<Option<RubyResult>> {
if let Ok(ruby_paths) = which::which_all("ruby") {
for ruby_path in ruby_paths {
if let Some(result) = try_ruby_path(&ruby_path, request).await {
return Ok(Some(result));
}
}
}
#[cfg(not(target_os = "windows"))]
if let Some(result) = search_version_managers(request).await {
return Ok(Some(result));
}
Ok(None)
}
}
async fn try_ruby_path(ruby_path: &Path, request: &RubyRequest) -> Option<RubyResult> {
if let Err(e) = find_gem_for_ruby(ruby_path) {
warn!("Ruby at {} has no gem: {}", ruby_path.display(), e);
return None;
}
match query_ruby_info(ruby_path).await {
Ok((version, engine)) => {
let result = RubyResult {
ruby_bin: ruby_path.to_path_buf(),
version,
engine,
};
if request.matches(&result.version) {
Some(result)
} else {
None
}
}
Err(e) => {
warn!("Failed to query Ruby at {}: {}", ruby_path.display(), e);
None
}
}
}
#[cfg(not(target_os = "windows"))]
async fn search_version_managers(request: &RubyRequest) -> Option<RubyResult> {
let home = EnvVars.var(EnvVars::HOME).ok()?;
let home_path = PathBuf::from(home);
let search_dirs = [
home_path.join(".rvm/rubies"),
home_path.join(".local/share/rv/rubies"),
home_path.join(".data/rv/rubies"),
home_path.join(".local/share/mise/installs/ruby"),
home_path.join(".rbenv/versions"),
home_path.join(".asdf/installs/ruby"),
home_path.join(".rubies"),
PathBuf::from("/opt/rubies"),
PathBuf::from("/opt/homebrew/Cellar/ruby"),
PathBuf::from("/usr/local/Cellar/ruby"),
PathBuf::from("/home/linuxbrew/.linuxbrew/Cellar/ruby"),
home_path.join(".linuxbrew/Cellar/ruby"),
];
for search_dir in &search_dirs {
if let Some(result) = search_ruby_installations(search_dir, request).await {
return Some(result);
}
}
None
}
#[cfg(not(target_os = "windows"))]
async fn search_ruby_installations(dir: &Path, request: &RubyRequest) -> Option<RubyResult> {
let entries = fs_err::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let ruby_path = path.join("bin/ruby");
if ruby_path.exists() {
if let Some(result) = try_ruby_path(&ruby_path, request).await {
trace!(
"Found suitable Ruby in version manager: {}",
ruby_path.display()
);
return Some(result);
}
}
}
None
}
fn parse_version_from_asset(name: &str, platform_suffix: &str) -> Option<semver::Version> {
let name = name.strip_prefix("ruby-")?;
let version_str = name.strip_suffix(platform_suffix)?;
let version = semver::Version::parse(version_str).ok()?;
if !version.pre.is_empty() {
return None;
}
Some(version)
}
fn ruby_not_found_error(request: &RubyRequest, reason: &str) -> String {
format!(
"No suitable Ruby found for request: {request}\n{reason}\nPlease install Ruby manually."
)
}
fn find_gem_for_ruby(ruby_path: &Path) -> Result<PathBuf> {
let ruby_dir = ruby_path
.parent()
.context("Ruby executable has no parent directory")?;
for name in ["gem", "gem.bat", "gem.cmd"] {
let gem_path = ruby_dir.join(name).with_extension(EXE_EXTENSION);
if gem_path.exists() {
return Ok(gem_path);
}
let gem_path = ruby_dir.join(name);
if gem_path.exists() {
return Ok(gem_path);
}
}
anyhow::bail!(
"No gem executable found alongside Ruby at {}",
ruby_path.display()
)
}
pub(crate) async fn query_ruby_info(ruby_path: &Path) -> Result<(semver::Version, String)> {
let script = "puts RUBY_ENGINE; puts RUBY_VERSION";
let output = Cmd::new(ruby_path)
.arg("-e")
.arg(script)
.check(true)
.output()
.await?;
let mut lines = str::from_utf8(&output.stdout)?.lines();
let engine = lines.next().unwrap_or("ruby").to_string();
let version_str = lines.next().context("No version in Ruby output")?.trim();
let version = semver::Version::parse(version_str)
.with_context(|| format!("Failed to parse Ruby version: {version_str}"))?;
Ok((version, engine))
}
#[cfg(test)]
mod tests {
use super::*;
use fs_err as fs;
use std::str::FromStr;
use target_lexicon::Triple;
use tempfile::TempDir;
#[test]
fn test_ruby_request_display() {
assert_eq!(RubyRequest::Any.to_string(), "any");
assert_eq!(RubyRequest::Exact(3, 4, 6).to_string(), "3.4.6");
assert_eq!(RubyRequest::MajorMinor(3, 4).to_string(), "3.4");
assert_eq!(RubyRequest::Major(3).to_string(), "3");
let range = semver::VersionReq::parse(">=3.2").unwrap();
assert_eq!(
RubyRequest::Range(range, ">=3.2".to_string()).to_string(),
">=3.2"
);
}
#[tokio::test]
#[cfg(not(target_os = "windows"))]
async fn test_search_ruby_installations_empty_dir() {
let temp_dir = TempDir::new().unwrap();
let request = RubyRequest::Any;
let result = search_ruby_installations(temp_dir.path(), &request).await;
assert!(result.is_none());
}
#[tokio::test]
#[cfg(not(target_os = "windows"))]
async fn test_search_ruby_installations_no_ruby() {
let temp_dir = TempDir::new().unwrap();
let ruby_dir = temp_dir.path().join("ruby-3.4.6");
fs::create_dir_all(ruby_dir.join("bin")).unwrap();
let request = RubyRequest::Any;
let result = search_ruby_installations(temp_dir.path(), &request).await;
assert!(result.is_none());
}
#[tokio::test]
#[cfg(not(target_os = "windows"))]
async fn test_search_ruby_installations_with_file() {
let temp_dir = TempDir::new().unwrap();
let ruby_dir = temp_dir.path().join("ruby-3.4.6");
fs::create_dir_all(ruby_dir.join("bin")).unwrap();
let ruby_path = ruby_dir.join("bin/ruby");
fs::write(&ruby_path, "#!/bin/sh\necho fake ruby").unwrap();
let request = RubyRequest::Any;
let result = search_ruby_installations(temp_dir.path(), &request).await;
assert!(result.is_none());
}
#[test]
fn test_ruby_not_found_error() {
let error = ruby_not_found_error(&RubyRequest::Exact(3, 4, 6), "Some reason.");
assert!(error.contains("3.4.6"));
assert!(error.contains("No suitable Ruby found"));
assert!(error.contains("Some reason."));
assert!(error.contains("Please install Ruby manually."));
let error = ruby_not_found_error(&RubyRequest::Any, "Another reason.");
assert!(error.contains("any"));
assert!(error.contains("Another reason."));
}
#[test]
fn test_find_gem_for_ruby_missing() {
let temp_dir = TempDir::new().unwrap();
let ruby_path = temp_dir.path().join("bin/ruby");
fs::create_dir_all(temp_dir.path().join("bin")).unwrap();
fs::write(&ruby_path, "fake").unwrap();
let result = find_gem_for_ruby(&ruby_path);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("No gem executable found")
);
}
#[test]
fn test_find_gem_for_ruby_found() {
let temp_dir = TempDir::new().unwrap();
let bin_dir = temp_dir.path().join("bin");
fs::create_dir_all(&bin_dir).unwrap();
let ruby_path = bin_dir.join("ruby");
let gem_path = bin_dir.join("gem");
fs::write(&ruby_path, "fake ruby").unwrap();
fs::write(&gem_path, "fake gem").unwrap();
let result = find_gem_for_ruby(&ruby_path);
assert!(result.is_ok());
assert_eq!(result.unwrap(), gem_path);
}
#[test]
fn test_parse_version_from_asset() {
let suffix = ".x86_64_linux.tar.gz";
assert_eq!(
parse_version_from_asset("ruby-3.4.8.x86_64_linux.tar.gz", suffix),
Some(semver::Version::new(3, 4, 8))
);
assert_eq!(
parse_version_from_asset("ruby-3.3.0.x86_64_linux.tar.gz", suffix),
Some(semver::Version::new(3, 3, 0))
);
assert_eq!(
parse_version_from_asset("ruby-3.4.8.arm64_linux.tar.gz", suffix),
None
);
assert_eq!(
parse_version_from_asset("ruby-3.5.0-preview1.x86_64_linux.tar.gz", suffix),
None
);
assert_eq!(
parse_version_from_asset("ruby-0.49.x86_64_linux.tar.gz", suffix),
None
);
assert_eq!(
parse_version_from_asset("something-else.tar.gz", suffix),
None
);
}
#[test]
fn test_rv_platform_string_for_macos() {
let intel = Triple::from_str("x86_64-apple-darwin").unwrap();
assert_eq!(rv_platform_string(&intel), Some("ventura"));
let arm = Triple::from_str("aarch64-apple-darwin").unwrap();
assert_eq!(rv_platform_string(&arm), Some("arm64_sonoma"));
}
#[test]
fn test_rv_platform_string_for_linux() {
let gnu = Triple::from_str("x86_64-unknown-linux-gnu").unwrap();
assert_eq!(rv_platform_string(&gnu), Some("x86_64_linux"));
let arm_gnu = Triple::from_str("aarch64-unknown-linux-gnu").unwrap();
assert_eq!(rv_platform_string(&arm_gnu), Some("arm64_linux"));
let musl = Triple::from_str("x86_64-unknown-linux-musl").unwrap();
assert_eq!(rv_platform_string(&musl), Some("x86_64_linux_musl"));
let arm_musl = Triple::from_str("aarch64-unknown-linux-musl").unwrap();
assert_eq!(rv_platform_string(&arm_musl,), Some("arm64_linux_musl"));
}
#[test]
fn test_rv_platform_string_unsupported() {
let windows = Triple::from_str("x86_64-pc-windows-msvc").unwrap();
assert_eq!(rv_platform_string(&windows), None);
let linux_unknown_libc = Triple::from_str("x86_64-unknown-linux-gnux32").unwrap();
assert_eq!(rv_platform_string(&linux_unknown_libc), None);
}
#[test]
fn test_find_installed_empty_dir() {
let temp_dir = TempDir::new().unwrap();
let installer = RubyInstaller::new(temp_dir.path().to_path_buf());
assert!(installer.find_installed(&RubyRequest::Any).is_none());
}
#[test]
fn test_find_installed_with_versions() {
let temp_dir = TempDir::new().unwrap();
for version in ["3.3.5", "3.4.8", "3.2.1"] {
let bin_dir = temp_dir.path().join(version).join("bin");
fs::create_dir_all(&bin_dir).unwrap();
fs::write(bin_dir.join("ruby"), "fake").unwrap();
fs::write(bin_dir.join("gem"), "fake").unwrap();
}
let installer = RubyInstaller::new(temp_dir.path().to_path_buf());
let result = installer.find_installed(&RubyRequest::Any).unwrap();
assert_eq!(*result.version(), semver::Version::new(3, 4, 8));
let result = installer
.find_installed(&RubyRequest::MajorMinor(3, 3))
.unwrap();
assert_eq!(*result.version(), semver::Version::new(3, 3, 5));
let result = installer
.find_installed(&RubyRequest::Exact(3, 2, 1))
.unwrap();
assert_eq!(*result.version(), semver::Version::new(3, 2, 1));
assert!(
installer
.find_installed(&RubyRequest::MajorMinor(2, 7))
.is_none()
);
}
#[test]
fn test_github_repo_path() {
assert_eq!(
github_repo_path("https://github.com/spinel-coop/rv-ruby").as_deref(),
Some("/spinel-coop/rv-ruby")
);
assert_eq!(
github_repo_path("https://github.com:443/org/repo").as_deref(),
Some("/org/repo")
);
assert!(github_repo_path("http://github.com/org/repo").is_none());
assert!(github_repo_path("https://gitlab.com/org/repo").is_none());
assert!(github_repo_path("https://my-mirror.example.com/rv-ruby").is_none());
assert!(github_repo_path("https://evil.com/github.com/rv").is_none());
assert!(github_repo_path("https://api.github.com/repos/org/repo").is_none());
assert!(github_repo_path("https://github.com@evil.com/org/repo").is_none());
assert!(github_repo_path("https://github.com:password@evil.com/org/repo").is_none());
assert!(github_repo_path("https://evil.com@github.com/org/repo").is_none());
assert!(github_repo_path("https://github.com:444/org/repo").is_none());
assert!(github_repo_path("https://github.com/org").is_none());
assert!(github_repo_path("https://github.com/org/repo/releases").is_none());
assert!(github_repo_path("ftp://github.com/org/repo").is_none());
}
#[test]
fn rv_ruby_env_overrides() {
assert_eq!(
rv_ruby_base_url(&EnvVars::from_map(&[])),
RV_RUBY_DEFAULT_URL
);
assert_eq!(
rv_ruby_base_url(&EnvVars::from_map(&[(
EnvVars::PREK_RUBY_MIRROR,
"https://example.com/rv-ruby/",
)])),
"https://example.com/rv-ruby"
);
assert_eq!(
rv_ruby_api_url(&EnvVars::from_map(&[])),
(
"https://api.github.com/repos/spinel-coop/rv-ruby/releases/latest".to_string(),
true,
)
);
assert_eq!(
rv_ruby_api_url(&EnvVars::from_map(&[(
EnvVars::PREK_RUBY_MIRROR,
"https://mirror.example.com/rv-ruby",
)])),
(
"https://mirror.example.com/rv-ruby/releases/latest".to_string(),
false,
)
);
let env_vars = EnvVars::from_map(&[(EnvVars::GITHUB_TOKEN, "secret-token")]);
let client = reqwest::Client::new();
let request = maybe_add_github_auth(client.get("https://example.com"), true, &env_vars)
.build()
.unwrap();
assert_eq!(
request
.headers()
.get(reqwest::header::AUTHORIZATION)
.unwrap(),
"Bearer secret-token"
);
let request = maybe_add_github_auth(client.get("https://example.com"), false, &env_vars)
.build()
.unwrap();
assert!(
!request
.headers()
.contains_key(reqwest::header::AUTHORIZATION)
);
}
#[test]
fn test_find_installed_skips_incomplete_dirs() {
let temp_dir = TempDir::new().unwrap();
let bin_dir = temp_dir.path().join("3.4.8").join("bin");
fs::create_dir_all(&bin_dir).unwrap();
fs::write(bin_dir.join("ruby"), "fake").unwrap();
fs::create_dir_all(temp_dir.path().join("3.3.0")).unwrap();
fs::create_dir_all(temp_dir.path().join("not-a-version").join("bin")).unwrap();
let installer = RubyInstaller::new(temp_dir.path().to_path_buf());
assert!(installer.find_installed(&RubyRequest::Any).is_none());
}
}